机器学习训练营-全-
机器学习训练营(全)
原文:Machine Learning Bookcamp
译者:飞龙
前言
前言
我认识 Alexey 已经超过六年了。我们几乎在柏林一家科技公司同一个数据科学团队工作:Alexey 在我离开几个月后开始工作。尽管如此,我们还是通过 Kaggle 这个数据科学竞赛平台和一位共同的朋友相识。我们在一个关于自然语言处理的 Kaggle 竞赛中同一个团队参赛,这是一个有趣的项目,需要仔细使用预训练的词嵌入并巧妙地混合它们。与此同时,Alexey 正在写一本书,并邀请我担任技术审稿人。这本书是关于 Java 和数据科学的,在阅读过程中,我特别印象深刻的是 Alexey 如何精心策划和编排有趣的例子。这很快导致了新的合作:我们共同撰写了一本基于项目的书籍,关于 TensorFlow,我们从事了从强化学习到推荐系统等不同项目,旨在为读者提供灵感和示例。
在与 Alexey 合作的过程中,我发现他像许多从软件工程转向数据科学的人一样,更喜欢通过实践和编码来学习。
因此,当我听说他已经开始另一个基于项目的书籍时,我并不感到特别惊讶。被邀请对 Alexey 的工作提供反馈,我从早期就开始阅读这本书,发现阅读过程非常吸引人。这本书是一本实用的机器学习入门书籍,重点在于动手实践。它面向与 Alexey 有相同背景的人——对数据科学感兴趣的开发商,他们需要快速建立起与数据和数据问题相关的有用和可重用经验。
作为数据科学和人工智能领域超过十本书的作者,我知道关于这个主题已经有大量的书籍和课程。然而,这本书却相当不同。在《机器学习训练营》(Machine Learning Bookcamp)中,你不会找到其他书籍提供的那些似曾相识的数据问题。它没有那种教条、重复的主题流程,就像地图上已经标记好的路线,总是把你带到你已经知道和看过的地点。
书中的每一部分都围绕着实际和几乎贴近现实世界的例子展开。你将学习如何预测汽车的价格,判断客户是否会流失,以及评估不偿还贷款的风险。之后,你将把服装照片分类为 T 恤、连衣裙、裤子和其他类别。这个项目特别有趣,因为 Alexey 亲自整理了这个数据集,你可以用自己衣柜里的衣服来丰富它。
当然,通过阅读这本书,你被期望将机器学习应用于解决常见问题,并且你将使用最简单、最有效的方法来实现最佳结果。前几章从考察基本算法,如线性回归和逻辑回归开始。然后读者逐渐过渡到梯度提升和神经网络。然而,本书的强项在于,在通过实践教授机器学习的同时,它也为你准备进入现实世界。你将处理不平衡的类别和长尾分布,并发现如何处理脏数据。你将评估你的模型,并使用 AWS Lambda 和 Kubernetes 进行部署。这只是你通过阅读本书学习到的新技术的冰山一角。
以工程师的思维模式思考,你可以这样说,这本书的编排方式是为了让你获得涵盖成为优秀数据科学家 80%核心知识的 20%知识。更重要的是,我还要补充一点,你将在 Alexey 的指导下阅读和实践,这是他通过自己的工作和 Kaggle 经验提炼出来的。基于这些前提,我祝愿你在本书的篇章和项目中有一个愉快的旅程。我相信这将帮助你找到接近数据科学及其问题、工具和解决方案的最佳方式。
——卢卡·马萨罗恩
前言
我开始我的职业生涯作为 Java 开发者。大约在 2012-2013 年,我对数据科学和机器学习产生了兴趣。首先,我观看了在线课程,然后我报名参加了研究生课程,用两年的时间学习商业智能和数据科学的各个方面。最终,我在 2015 年毕业,并开始作为数据科学家工作。
在工作中,我的同事向我介绍了 Kaggle——一个数据科学竞赛的平台。我想,“凭借我从课程和硕士学位中获得的所有技能,我能够轻松赢得任何竞赛。”但当我尝试参加竞赛时,我惨败。我在 Kaggle 上所拥有的所有理论知识都毫无用处。我的模型糟糕透顶,最终我在排行榜上垫底。
在接下来的九个月里,我参加了数据科学竞赛。我没有做得特别出色,但这是我真正学习机器学习的时候。
我意识到,对我来说,最好的学习方式是通过做项目。当我专注于问题,当我实施某些东西,当我进行实验时,我才能真正学到东西。但如果我专注于课程和理论,我会花费太多时间在那些在实践中不重要且无用的学习上。
我并不孤单。在讲述这个故事时,我多次听到“我也是!”的声音。这就是为什么《机器学习书营》的重点是通过做项目来学习。我相信软件工程师——与我背景相同的人——通过实践学习得最好。
我们从汽车价格预测项目开始这本书,学习线性回归。然后,我们确定客户是否想要停止使用我们公司的服务。为此,我们学习逻辑回归。为了学习决策树,我们评估银行的客户以确定他们是否能够偿还贷款。最后,我们使用深度学习来将衣服图片分类到不同的类别,如 T 恤、裤子、鞋子、外套等等。
书中的每个项目都以问题描述开始。然后我们使用不同的工具和框架来解决这个问题。通过专注于问题,我们只覆盖了解决这个问题所必需的部分。当然,也有理论内容,但我尽量将其保持在最小化,并专注于实践部分。
然而,有时我不得不在某些章节中包含公式。在关于机器学习的书中避免公式是不可能的。我知道公式对一些人来说很可怕。我也经历过。这就是为什么我用代码解释了所有的公式。当你看到公式时,不要让它吓到你。先尝试理解代码,然后再回到公式,看看代码是如何转化为公式的。这样,公式就不会再让你感到害怕了!
你在这本书中找不到所有可能的主题。我专注于最基本的东西——当你开始使用机器学习时,你一定会用到的 100%确定的事情。还有一些其他重要的主题我没有涉及:时间序列分析、聚类、自然语言处理。阅读这本书后,你将拥有足够的背景知识来自学这些主题。
本书中有三章专注于模型部署。这些是非常重要的一章——可能是最重要的一章。能够部署模型是成功项目和失败项目之间的区别。即使是最优秀的模型,如果其他人无法使用它,也是无用的。这就是为什么值得花时间学习如何使其对他人可访问。这也是为什么我在书中很早就涵盖了这一点,就在我们学习逻辑回归之后。
最后一章是关于使用 Kubernetes 部署模型。这不是一个简单的章节,但如今 Kubernetes 是最常用的容器管理系统。你可能会需要与之合作,这就是为什么它被包含在书中。
最后,本书的每一章都包括练习。你可能想跳过它们,但我不建议这样做。如果你只是跟随这本书,你会学到很多新东西。但如果你不将这些知识应用于实践,你很快就会忘记大部分。练习帮助你将这些新技能应用于实践——你将更好地记住你所学的。
享受你在书中的旅程,随时欢迎与我联系!
——阿列克谢·格里戈廖夫
致谢
在这本书上投入大量业余时间。我花费了无数个夜晚和失眠的夜晚来工作。这就是为什么,首先,我最想感谢我的妻子,感谢她的耐心和支持。
接下来,我想感谢我的编辑 Susan Ethridge,感谢她的耐心。本书的第一个早期访问版本于 2020 年 1 月发布。在那之后不久,我们周围的世界变得疯狂,每个人都被困在家中。对我来说,在书中工作极具挑战性。我不知道我错过了多少个截止日期(很多!),但 Susan 没有催促我,而是让我按照自己的节奏工作。
在 Susan 之后,第一个必须阅读所有章节的人是 Michael Lund。我想感谢 Michael 提供的无价反馈以及他在我的草稿上留下的所有评论。一位审稿人写道:“本书对细节的关注令人赞叹,”这主要是因为 Michael 的贡献。
在封锁期间找到继续撰写本书的动力是困难的。有时,我甚至感觉不到任何能量。但审稿人和 MEAP 读者的反馈非常鼓舞人心。这帮助我在重重困难中完成了本书。因此,我想感谢你们所有人审阅草稿,提供反馈——最重要的是——感谢你们的善意话语以及支持!
我特别想感谢几位与我分享反馈的读者:Martin Tschendel,Agnieszka Kamin´ska 和 Alexey Shvets。此外,我还想感谢在 LiveBook 评论部分或 DataTalks.Club Slack 群组的 #ml-bookcamp 频道留下反馈的每个人。
在第七章中,我使用了一个用于图像分类项目的服装数据集。这个数据集是专门为这本书创建和整理的。我想感谢所有贡献了他们服装图片的人,尤其是 Kenes Shangerey 和 Tagias,他们贡献了整个数据集的 60%。
在上一章中,我介绍了使用 Kubernetes 和 Kubeflow 进行模型部署。Kubeflow 是一种相对较新的技术,其中一些内容尚未得到充分的文档记录。这就是为什么我想感谢我的同事 Theofilos Papapanagiotou 和 Antonio Bernardino,他们帮助我处理了 Kubeflow 相关的事宜。
没有 Manning 市场营销部门的帮助,《机器学习书营》 无法触及大多数读者。我特别想感谢 Lana Klasic 和 Radmila Ercegovac,他们帮助安排了推广本书的活动,并运行社交媒体活动以吸引更多读者。我还想感谢我的项目编辑 Deirdre Hiam;我的审稿编辑 Adriana Sabo;我的校对员 Pamela Hunt;以及我的校对 Melody Dolab。
致所有审稿人:Adam Gladstone, Amaresh Rajasekharan, Andrew Courter, Ben McNamara, Billy O'Callaghan, Chad Davis, Christopher Kottmyer, Clark Dorman, Dan Sheikh, George Thomas, Gustavo Filipe Ramos Gomes, Joseph Perenia, Krishna Chaitanya Anipindi, Ksenia Legostay, Lurdu Matha Reddy Kunireddy, Mike Cuddy, Monica Guimaraes, Naga Pavan Kumar T, Nathan Delboux, Nour Taweel, Oliver Korten, Paul Silisteanu, Rami Madian, Sebastian Mohan, Shawn Lam, Vishwesh Ravi Shrimali, William Pompei,你们的建议帮助使这本书变得更好。
最后但同样重要的是,我想感谢 Luca Massaron,他激励我写书。我永远不可能像你,Luca,那样成为一个多产的作家,但感谢你成为我的一大动力!
关于这本书
应该阅读这本书的人
本书是为那些能够编程并能快速掌握 Python 基础知识的人所写。你不需要有任何机器学习的前期经验。
理想读者是希望开始使用机器学习的软件工程师。然而,一个需要为学习和副项目编写代码的积极大学生也能成功。
此外,那些已经从事机器学习工作但想了解更多的人也会发现这本书很有用。许多已经作为数据科学家和数据分析师工作的人表示,这本书对他们有帮助,特别是关于部署的章节。
本书如何组织:路线图
本书包含九章,我们在整本书中探讨了四个不同的项目。
-
在第一章中,我们介绍了主题——我们讨论了传统软件工程与机器学习之间的区别。我们涵盖了组织机器学习项目的整个过程,从理解业务需求的初始步骤到最后一步部署模型。我们更详细地介绍了建模步骤,并讨论了我们应该如何评估我们的模型并选择最佳模型。为了说明本章的概念,我们使用了垃圾邮件检测问题。
-
在第二章中,我们开始了我们的第一个项目——预测汽车的价格。我们学习如何使用线性回归来完成这个任务。我们首先准备了一个数据集并进行了一些数据清洗。接下来,我们进行了一些数据探索性分析,以更好地理解数据。然后,我们使用 NumPy 自己实现了一个线性回归模型,以了解机器学习模型在底层是如何工作的。最后,我们讨论了正则化和评估模型质量等主题。
-
在第三章,我们处理客户流失检测问题。我们在一家电信公司工作,并希望确定哪些客户可能会很快停止使用我们的服务。这是一个分类问题,我们使用逻辑回归来解决。我们首先进行特征重要性分析,以了解哪些因素对这个问题最重要。然后我们讨论了一热编码作为处理分类变量(如性别、合同类型等)的方法。最后,我们使用 Scikit-learn 训练了一个逻辑回归模型,以了解哪些客户将很快流失。
-
在第四章,我们评估了在第三章开发的模型的表现。我们涵盖了最重要的分类评估指标:准确率、精确率和召回率。我们讨论了混淆矩阵,然后深入 ROC 分析和计算 AUC。我们以讨论 K 折交叉验证来结束这一章。
-
在第五章,我们将客户流失预测模型部署为一个网络服务。这是过程中的一个重要步骤,因为我们如果不使我们的模型可用,它对任何人都没有用处。我们首先使用 Flask,这是一个用于创建网络服务的 Python 框架。然后我们涵盖了 Pipenv 和 Docker 用于依赖管理,并以在 AWS 上部署我们的服务结束。
-
在第六章,我们开始了一个关于风险评估的项目。我们想要了解银行客户是否会遇到偿还贷款的问题。为此,我们学习了决策树的工作原理,并使用 Scikit-learn 训练了一个简单的模型。然后我们转向更复杂的基于树的模型,如随机森林和梯度提升。
-
在第七章,我们构建了一个图像分类项目。我们将训练一个模型,用于将衣物图像分类到 10 个类别,如 T 恤、连衣裙、裤子等。我们使用 TensorFlow 和 Keras 来训练我们的模型,并涵盖了诸如迁移学习等内容,以便能够使用相对较小的数据集训练模型。
-
在第八章,我们部署了在第七章训练的衣物分类模型,并使用 TensorFlow Lite 和 AWS Lambda 进行部署。
-
在第九章,我们部署了衣物分类模型,但在第一部分我们使用了 Kubernetes 和 TensorFlow Serving,在第二部分使用了 Kubeflow 和 Kubeflow Serving。
为了帮助您开始阅读本书以及 Python 及其相关库,我们准备了五个附录章节:
-
附录 A 解释了如何设置本书的环境。我们展示了如何使用 Anaconda 安装 Python,如何运行 Jupyter Notebook,如何安装 Docker,以及如何创建 AWS 账户。
-
附录 B 涵盖了 Python 的基础知识。
-
附录 C 涵盖了 NumPy 的基础知识,并简要介绍了我们进行机器学习所需的最重要线性代数概念:矩阵乘法和矩阵求逆。
-
附录 D 涵盖了 Pandas。
-
附录 E 解释了如何在 AWS SageMaker 上获得带有 GPU 的 Jupyter Notebook。
这些附录是可选的,但它们很有帮助,尤其是如果您之前没有使用过 Python 或 AWS。
您不必从头到尾阅读这本书。为了帮助您导航,您可以使用这张地图:

第二章和第三章是最重要的章节。所有其他章节都依赖于它们。阅读完它们后,您可以跳到第五章来部署模型,第六章来了解基于树的模型,或第七章来学习图像分类。关于评估指标的第四章依赖于第三章:我们评估第三章中关于客户流失预测模型的质量。在第八章和第九章中,我们将部署图像分类模型,因此在继续到第八章或第九章之前,阅读第七章会有所帮助。
每章都包含练习。做这些练习很重要——这将大大帮助您记住材料。
关于代码
本书包含许多源代码示例,无论是编号列表还是与普通文本并列。在两种情况下,源代码都以 fixed-width 字体 如此格式化,以将其与普通文本区分开来。有时代码也会被 **in** **bold** 突出显示,以强调与章节中先前步骤相比已更改的代码,例如当新功能添加到现有代码行时。
在许多情况下,原始源代码已经被重新格式化;我们添加了换行并重新调整了缩进,以适应书籍中可用的页面空间。在极少数情况下,即使这样也不够,列表中还包括了行续接标记(➥)。此外,当代码在文本中描述时,源代码中的注释通常也会从列表中移除。许多列表旁边都有代码注释,突出显示重要概念。
本书代码可在 GitHub 上找到,网址为 github.com/alexeygrigorev/mlbookcamp-code。此存储库还包含许多对您机器学习之旅有帮助的有用链接。
liveBook 讨论论坛
购买 Machine Learning Bookcamp 包括免费访问由 Manning Publications 运营的私人网络论坛,您可以在论坛中就本书发表评论、提出技术问题,并从作者和其他用户那里获得帮助。要访问论坛,请访问 livebook.manning.com/book/machine-learning-bookcamp/welcome/v-11。您还可以在 livebook.manning.com/#!/discussion 上了解更多关于 Manning 的论坛和行为准则。
Manning 对我们读者的承诺是提供一个场所,在那里读者之间以及读者与作者之间可以进行有意义的对话。这不是对作者参与特定数量活动的承诺,作者对论坛的贡献仍然是自愿的(且未付费)。我们建议你尝试向作者提出一些挑战性的问题,以免他的兴趣转移!只要本书仍在印刷,论坛和先前讨论的存档将可通过出版社的网站访问。
其他在线资源
-
该书的网站:
mlbookcamp.com/。它包含基于本书的有用文章和课程。 -
数据爱好者社区:
datatalks.club。你可以在那里提出关于数据或机器学习的任何问题。 -
此外,还有一个用于讨论与书籍相关问题的频道:#ml-bookcamp。
关于作者
亚历克谢·格里戈廖夫与他的妻子和儿子住在柏林。他是一位经验丰富的软件工程师,专注于机器学习。他在 OLX 集团担任首席数据科学家,帮助他的同事将机器学习应用于生产。
工作之余,亚历克谢运营着 DataTalks.Club,这是一个喜欢数据科学和机器学习的人们的社区。他是两本其他书籍的作者:《精通数据科学中的 Java》和《TensorFlow 深度学习项目》。
关于封面插图
《机器学习 Bookcamp》封面上的插图标题为“布拉班特夫人”,或来自布拉班特的女人。这幅插图取自雅克·格拉塞·德·圣索沃尔(1757–1810)的作品集,名为《不同国家的服饰》,于 1797 年在法国出版。每一幅插图都是手工精心绘制和着色的。格拉塞·德·圣索沃尔收藏中的丰富多样性生动地提醒我们,200 年前世界的城镇和地区在文化上是如何截然不同的。他们彼此孤立,说着不同的方言和语言。在街道或乡村,仅凭他们的服饰就能轻易识别他们居住的地方以及他们的职业或社会地位。
自那时以来,我们的着装方式已经改变,而当时区域间的多样性已经逐渐消失。现在很难区分不同大陆、不同城镇、地区或国家的人们。也许我们用更丰富多彩的个人生活——当然,是更丰富多彩、节奏更快的技术生活——来换取了文化多样性。
在难以区分一本计算机书与另一本计算机书的时代,Manning 通过基于两百年前丰富多样的区域生活,并由格拉塞·德·圣索沃尔的图画使之重生的封面设计,庆祝了计算机行业的创新精神和主动性。
1 机器学习简介
本章涵盖
-
理解机器学习及其解决的问题
-
组织成功的机器学习项目
-
训练和选择机器学习模型
-
执行模型验证
在本章中,我们介绍机器学习,并描述它在哪些情况下最有帮助。我们展示了机器学习项目与传统软件工程(基于规则的解决方案)的不同之处,并通过使用垃圾邮件检测系统作为例子来说明这些差异。
要使用机器学习解决现实生活中的问题,我们需要一种组织机器学习项目的方法。在本章中,我们讨论 CRISP-DM:一个实施成功机器学习项目的逐步方法论。
最后,我们更详细地研究 CRISP-DM 的一个步骤——建模步骤。在这个步骤中,我们训练不同的模型,并选择解决我们问题的最佳模型。
1.1 机器学习
机器学习是应用数学和计算机科学的一部分。它使用来自概率、统计和优化理论等数学学科的工具体现数据中的模式。
机器学习背后的主要思想是从例子中学习:我们准备一个包含例子的数据集,机器学习系统“学习”这个数据集。换句话说,我们给系统输入和期望的输出,系统试图自动找出如何进行转换,而不需要询问人类。
例如,我们可以收集一个包含汽车描述和价格的数据集。然后,我们提供一个机器学习模型并使用这个数据集“教授”它,通过展示汽车及其价格。这个过程被称为训练或有时称为拟合(图 1.1)。

图 1.1 机器学习算法接收输入数据(汽车的描述)和期望输出(汽车的价格)。基于这些数据,它产生一个模型。
当训练完成后,我们可以通过要求它预测我们尚未知的汽车价格来使用模型(图 1.2)。

图 1.2 当训练完成后,我们得到一个可以应用于新的输入数据(无价格的车)以产生输出(价格预测)的模型。
我们需要的只是机器学习的一个数据集,其中对于每个输入项(一辆车),我们都有期望的输出(价格)。
这个过程与传统软件工程截然不同。没有机器学习,分析师和开发者会查看他们拥有的数据,并尝试手动寻找模式。之后,他们会提出一些逻辑:将输入数据转换为所需输出的规则集。然后,他们使用 Java 或 Python 等编程语言明确地编码这些规则,结果就是软件。因此,与机器学习相比,人类做了所有困难的工作(图 1.3)。

图 1.3 在传统软件中,模式是手动发现的,然后使用编程语言进行编码。人类完成所有工作。
总结来说,传统软件系统与基于机器学习系统的区别如图 1.4 所示。在机器学习中,我们向系统提供输入和输出数据,结果是模型(代码),它可以将输入转换为输出。困难的工作由机器完成;我们只需要监督训练过程,确保模型是好的(图 1.4B)。相比之下,在传统系统中,我们首先自己发现数据中的模式,然后编写代码将数据转换为期望的结果,使用手动发现的模式(图 1.4A)。
![]() |
(A) 在传统软件中,我们手动发现模式并使用编程语言进行编码。 |
|---|---|
![]() |
(B) 机器学习系统通过学习示例自动发现模式。训练后,它产生一个“知道”这些模式的模型,但我们仍然需要监督它以确保模型是正确的。 |
图 1.4 传统软件系统与机器学习系统的区别。在传统软件工程中,我们完成所有工作,而在机器学习中,我们将模式发现委托给机器。
1.1.1 机器学习与基于规则的系统
为了说明这两种方法之间的区别,并展示为什么机器学习是有帮助的,让我们考虑一个具体的案例。在本节中,我们讨论一个垃圾邮件检测系统来展示这种区别。
假设我们正在运行一个电子邮件服务,用户开始抱怨收到带有广告的不请自来的电子邮件。为了解决这个问题,我们希望创建一个系统,将不受欢迎的邮件标记为垃圾邮件,并将它们转发到垃圾邮件文件夹。
解决问题的明显方法是亲自查看这些电子邮件,看看它们是否有任何模式。例如,我们可以检查发件人和内容。
如果我们发现垃圾邮件中确实存在模式,我们就写下发现的模式,并制定以下两个简单的规则来捕捉这些邮件:
-
如果发件人是 promotions@online.com,则标记为“垃圾邮件”
-
如果标题包含“现在购买 50%折扣”且发件人域名为“online.com”,则标记为“垃圾邮件”
-
否则,标记为“好邮件”
我们用 Python 编写这些规则并创建了一个垃圾邮件检测服务,我们成功地将它部署。一开始,系统运行良好,能够捕捉到所有垃圾邮件,但过了一段时间,新的垃圾邮件开始悄悄溜过。我们现有的规则已经无法成功地将这些邮件标记为垃圾邮件。
为了解决这个问题,我们分析了新消息的内容,并发现其中大部分包含单词deposit。因此,我们添加了一条新规则:
-
如果发件人是“promotions@online.com”,则标记为“垃圾邮件”
-
如果标题包含“现在购买 50%折扣”且发件人域名为“online.com”,则标记为“垃圾邮件”
-
如果邮件正文包含单词“deposit”,则标记为“垃圾邮件”
-
否则,"good email"
在发现这个规则后,我们将修复部署到我们的 Python 服务中,并开始捕获更多的垃圾邮件,使我们的邮件系统的用户感到高兴。
然而,过了一段时间后,用户又开始抱怨:有些人出于好意使用“deposit”这个词,但我们的系统未能识别这一点,将消息标记为垃圾邮件。为了解决这个问题,我们查看好消息并试图了解它们与垃圾邮件消息的不同之处。过了一会儿,我们发现了一些模式并再次修改了规则:
-
如果发件人是“promotions@online.com”,则是垃圾邮件
-
如果标题包含“buy now 50% off”且发件人域名为“online.com”,则是垃圾邮件
-
如果正文包含“deposit”,则
-
如果发件人域名为“test.com”,则是垃圾邮件
-
如果描述长度大于等于 100 个单词,则是垃圾邮件
-
-
否则,"good email"
在这个例子中,我们手动查看输入数据并尝试从中提取模式。分析的结果,我们得到了一组规则,将输入数据(电子邮件)转换为两种可能的结果之一:垃圾邮件或非垃圾邮件。
现在想象一下,我们重复这个过程几百次。结果,我们最终得到的是难以维护和理解的代码。在某个时候,它变得不可能在不破坏现有逻辑的情况下将新模式包含到代码中。因此,从长远来看,维护和调整现有规则以使垃圾邮件检测系统仍然表现良好并最小化垃圾邮件投诉变得相当困难。
这正是机器学习可以提供帮助的情况。在机器学习中,我们通常不尝试手动提取这些模式。相反,我们通过向系统提供一个标记为垃圾邮件或非垃圾邮件的电子邮件数据集,并描述每个对象(电子邮件)的一组特征(特征),将这项任务委托给统计方法。基于这些信息,系统试图在没有任何人工帮助的情况下在数据中找到模式。最终,它学会了如何结合特征,使得垃圾邮件被标记为垃圾邮件,而好邮件则不被标记。
使用机器学习,维护手工规则集的问题就消失了。当出现新的模式时——例如,有新的垃圾邮件类型——我们不是手动调整现有的规则集,而是简单地向机器学习算法提供新的数据。结果,算法从新的数据中提取了新的重要模式,而不会损害旧的模式——前提是这些旧模式在新的数据中仍然重要且存在。
让我们看看如何使用机器学习来解决垃圾邮件分类问题。为此,我们首先需要用一组特征来表示每封电子邮件。一开始,我们可能选择从以下特征开始:
-
标题长度大于 10?是/否
-
正文长度大于 10?是/否
-
发件人“promotions@online.com”?是/否
-
发件人“hpYOSKmL@test.com”?是/否
-
发件人域名“test.com”?是/否
-
描述中包含“存款”?是/否
在这个特定的情况下,我们使用一组六个特征来描述所有电子邮件。巧合的是,这些特征是从先前的规则中推导出来的。
使用这个特征集,我们可以将任何电子邮件编码为特征向量:一个包含特定电子邮件所有特征值的数字序列。

图 1.5 用户标记为垃圾邮件的电子邮件
现在想象一下,我们有一个用户标记为垃圾邮件的电子邮件(图 1.5)。我们可以将这个电子邮件表示为向量[1, 1, 0, 0, 1, 1],并且对于六个特征中的每一个,我们将其编码为 1(真)或 0(假)(图 1.6)。因为我们的用户将这条消息标记为垃圾邮件,所以目标变量是 1(真)。

图 1.6 垃圾邮件的六维特征向量。六个特征中的每一个都由一个数字表示。在这种情况下,如果特征为真,我们使用 1,如果特征为假,我们使用 0(图 1.6)。
这样,我们可以为我们数据库中的所有电子邮件创建特征向量,并为每个电子邮件贴上标签。这些向量将成为模型的输入。然后模型将这些数字组合起来,使得垃圾邮件的预测接近 1(垃圾邮件),而对于普通消息则是 0(非垃圾邮件)(图 1.7)。

图 1.7 机器学习算法的输入由多个特征向量和每个向量的目标变量组成。
因此,我们有一个比一组硬编码的规则更灵活的工具。如果将来有什么变化,我们不需要手动重新访问所有规则并尝试重新组织它们。相反,我们只使用最新的数据,并用新的模型替换旧的模型。
这个例子只是机器学习使我们的生活变得更简单的一种方式。机器学习的其他应用包括
-
建议汽车的价格。
-
预测客户是否会停止使用公司的服务。
-
根据查询的相关性对文档进行排序。
-
向用户展示他们更有可能点击的广告,而不是无关的内容。
-
对维基百科上的有害和不正确的编辑进行分类。这样的系统可以帮助维基百科的版主在验证建议的编辑时优先考虑他们的工作。
-
推荐客户可能购买的商品。
-
对不同类别的图像进行分类。
机器学习的应用不仅限于这些例子。我们可以使用任何可以表示为(输入数据,期望输出)的东西来训练机器学习模型。
1.1.2 当机器学习不起作用时
虽然机器学习很有帮助并能解决许多问题,但在某些情况下实际上并不需要。
对于一些简单的任务,规则和启发式方法通常效果很好,因此最好从它们开始,然后考虑使用机器学习。在我们的垃圾邮件示例中,我们首先创建了一组规则,但随着维护这个集合变得困难,我们转向了机器学习。然而,我们使用了一些规则作为特征,并将它们简单地输入到模型中。
在某些情况下,使用机器学习根本不可能。要使用机器学习,我们需要有数据。如果没有数据,机器学习是不可能的。
1.1.3 监督机器学习
我们刚才看到的电子邮件分类问题是一个监督学习的例子:我们向模型提供特征和目标变量,然后它找出如何使用这些特征来达到目标。这种学习被称为 监督,因为我们通过展示示例来监督或教导模型,就像我们通过展示不同物体的图片并告诉他们这些物体的名称来教导孩子一样。
更正式一点,我们可以用数学方式表达一个监督机器学习模型:

其中
-
g 是我们想要通过机器学习学习的函数。
-
X 是特征矩阵,其中行是特征向量。
-
y 是目标变量:一个向量。
机器学习的目标是学习这个函数 g,使得当它得到矩阵 X 时,输出接近向量 y。换句话说,函数 g 必须能够接收 X 并产生 y。学习 g 的过程通常被称为 训练 或 拟合。我们将 g “拟合”到数据集 X,使其产生 y(图 1.8)。

图 1.8 当我们训练一个模型时,算法接收一个矩阵 X,其中特征向量是行,期望的输出是向量 y,包含我们想要预测的所有值。训练的结果是 g,即模型。训练后,g 应该在应用于 X 时产生 y——简而言之,g(X) ≈ y。
监督学习问题有多种类型,类型取决于目标变量 y。主要类型包括
-
回归:目标变量 y 是数值型,例如汽车价格或明天的温度。我们在第二章中介绍了回归模型。
-
分类:目标变量 y 是分类的,例如垃圾邮件、非垃圾邮件或汽车品牌。我们可以进一步将分类分为两个子类别:(1)二元分类,只有两种可能的结果,例如垃圾邮件或非垃圾邮件,和(2)多类分类,有超过两种可能的结果,例如汽车品牌(丰田、福特、大众等)。分类,尤其是二元分类,是机器学习最常见应用。我们在本书的多个章节中介绍了它,从第三章开始。在第三章中,我们将构建一个预测客户是否会流失——停止使用我们公司服务的模型。
-
排序:目标变量 y 是组内元素的排序,例如搜索结果页面中页面的顺序。排序问题通常出现在搜索和推荐等领域,但这本书的范围之外,我们不会详细讨论。
每个监督学习问题都可以用不同的算法来解决。有许多类型的模型可供选择。这些模型定义了函数 g 如何从 X 中学习预测 y。这些模型包括
-
第二章中介绍了用于解决回归问题的线性回归
-
第三章中介绍了用于解决分类问题的逻辑回归
-
第六章中介绍了用于解决回归和分类问题的基于树的模型
-
第七章中介绍了用于解决回归和分类问题的神经网络
深度学习和神经网络最近受到了很多关注,这主要归功于计算机视觉方法的突破。这些网络在解决图像分类等任务方面比早期方法做得更好。深度学习是机器学习的一个子领域,其中函数 g 是具有许多层的神经网络。我们将在第七章中了解更多关于神经网络和深度学习的内容,在那里我们将训练一个用于图像分类的深度学习模型。
1.2 机器学习过程
创建一个机器学习系统不仅仅是选择一个模型、训练它并将其应用于新数据。这个过程中的模型训练部分只是其中的一个小步骤。
还涉及许多其他步骤,例如确定机器学习可以解决的问题,以及使用模型的预测来影响最终用户。更重要的是,这个过程是迭代的。当我们训练一个模型并将其应用于新的数据集时,我们通常会识别出模型表现不佳的情况。我们使用这些情况重新训练模型,以便新版本能更好地处理这些情况。
某些技术和框架帮助我们以某种方式组织机器学习项目,使其不会失控。其中一个这样的框架是 CRISP-DM,代表跨行业数据挖掘标准流程。它是在很久以前发明的,1996 年,尽管如此,它仍然适用于今天的问题。
根据 CRISP-DM(图 1.9),机器学习过程有六个步骤:
-
业务理解
-
数据理解
-
数据准备
-
建模
-
评估
-
部署

图 1.9 CRISP-DM 过程。一个机器学习项目从理解问题开始,然后进入数据准备、模型训练和评估结果。最后,模型进入部署阶段。这个过程是迭代的,在每一步中,都有可能回到前一步。
每个阶段都涵盖典型的任务:
-
在业务理解步骤中,我们试图确定问题,了解我们如何解决问题,并决定机器学习是否是解决该问题的有用工具。
-
在数据理解步骤中,我们分析可用的数据集,并决定我们是否需要收集更多数据。
-
在数据准备步骤中,我们将数据转换成表格形式,以便将其用作机器学习模型的输入。
-
当数据准备就绪后,我们进入建模步骤,在这个步骤中我们训练一个模型。
-
在确定最佳模型之后,接下来是评估步骤,我们评估模型是否解决了原始的业务问题,并衡量其在解决问题上的成功程度。
-
最后,在部署步骤中,我们将模型部署到生产环境中。
1.2.1 业务理解
让我们以电子邮件服务提供商的垃圾邮件检测为例。我们看到的垃圾邮件消息比以往任何时候都多,而我们当前的系统无法轻松处理。这个问题在业务理解步骤中得到解决:我们分析问题以及现有的解决方案,并试图确定是否将机器学习添加到该系统中将帮助我们停止垃圾邮件。我们还定义了目标和如何衡量它。目标可能是“减少报告的垃圾邮件数量”或“减少客户支持每天收到的关于垃圾邮件的投诉数量”,例如。在这一步骤中,我们可能还会决定机器学习不会有所帮助,并提出一个更简单的方法来解决问题。
1.2.2 数据理解
下一步是数据理解。在这里,我们试图确定我们可以用来解决问题的数据来源。例如,如果我们的网站有一个“举报垃圾邮件”按钮,我们可以获取用户标记为垃圾邮件的收件邮件生成数据。然后我们查看数据并分析它,以决定它是否足够好,可以解决我们的问题。
然而,由于各种原因,这些数据可能不足以解决广泛的问题。一个原因可能是数据集太小,我们无法学习任何有用的模式。另一个原因可能是数据太嘈杂。用户可能没有正确使用按钮,因此它对训练机器学习模型将毫无用处,或者数据收集过程可能出了问题,只收集了我们想要的数据的一小部分。
如果我们得出结论,我们目前拥有的数据不足,我们需要找到一种方法来获取更好的数据,无论是从外部来源获取还是改进我们内部收集数据的方式。此外,我们在这个步骤中做出的发现可能会影响我们在业务理解步骤中设定的目标,因此我们可能需要回到那个步骤并根据我们的发现调整目标。
当我们有可靠的数据来源时,我们进入数据准备步骤。
1.2.3 数据准备
在这一步,我们清理数据,将其转换成可以用于机器学习模型输入的形式。对于垃圾邮件示例,我们将数据集转换成一系列特征,我们稍后将其输入到模型中。
数据准备完成后,我们进入建模步骤。
1.2.4 建模
在这一步,我们决定使用哪种机器学习模型以及如何确保我们从中获得最佳效果。例如,我们可能会决定尝试逻辑回归和深度神经网络来解决垃圾邮件问题。
我们需要知道如何衡量模型的表现,以便选择最佳模型。对于垃圾邮件模型,我们可以查看模型预测垃圾邮件消息的效果,并选择表现最好的那个。为此,设置一个合适的验证框架非常重要,这就是为什么我们在下一节中会详细讨论它。
在这一步,我们很可能需要回到并调整我们准备数据的方式。也许我们提出了一个很好的特征,因此我们回到数据准备步骤编写一些代码来计算这个特征。当代码完成后,我们再次训练模型以检查这个特征是否良好。我们可能会添加一个“主题长度”的特征,重新训练模型,并检查这种变化是否提高了模型的表现,例如。
在我们选择了最佳模型之后,我们进入评估步骤。
1.2.5 评估
在这一步,我们检查模型是否达到了预期。在业务理解步骤中设定目标时,我们也定义了确定目标是否实现的方式。通常,我们通过查看一个重要的业务指标并确保模型将指标推向正确的方向来做到这一点。在垃圾邮件检测的情况下,这个指标可能是点击“报告垃圾邮件”按钮的人数,或者客户支持收到的关于我们正在解决的问题的投诉数量。在两种情况下,我们都希望使用模型来减少这些数量。
现在,这一步与下一步紧密相连:部署。
1.2.6 部署
评估模型的最佳方式是通过实战测试:将模型推出给一部分用户,然后检查我们的业务指标是否对这些用户有所变化。如果我们希望我们的模型减少报告的垃圾邮件数量,例如,我们预计与所有其他用户相比,这个群体会有更少的报告。
模型部署后,我们利用所有在各个步骤中学到的知识,回到第一步来反思我们取得了什么成果(或没有取得什么成果)。我们可能会意识到我们的初始目标是不正确的,而我们真正想要做的是不是减少报告的数量,而是通过减少垃圾邮件的数量来增加客户参与度。因此,我们一直回到业务理解步骤来重新定义我们的目标。然后,当我们再次评估模型时,我们使用不同的业务指标来衡量其成功。
1.2.7 迭代
如我们所见,CRISP-DM 强调了机器学习过程的迭代性质:在最后一步之后,我们总是期望回到第一步,细化原始问题,并根据学习到的信息进行修改。我们永远不会停留在最后一步;相反,我们会重新思考问题,看看在下一轮迭代中我们能做得更好。
人们普遍认为机器学习工程师和数据科学家整天都在训练机器学习模型。实际上,这种想法是不正确的,正如我们在 CRISP-DM 图中(图 1.9)所看到的那样。在建模步骤之前和之后有很多步骤,所有这些步骤对于一个成功的机器学习项目来说都是重要的。
1.3 建模与模型验证
正如我们之前所看到的,训练模型(建模步骤)是整个过程中的一个步骤。但这是一个重要的步骤,因为这是我们实际使用机器学习来训练模型的地方。
在我们收集所有必要的数据并确定数据质量良好之后,我们找到一种处理数据的方法,然后继续训练机器学习模型。在我们的垃圾邮件示例中,这发生在我们收到所有垃圾邮件报告、处理电子邮件并准备好矩阵以供模型使用之后。
在这个阶段,我们可能会问自己该使用什么:逻辑回归还是神经网络。如果我们决定选择神经网络,因为我们听说它是最好的模型,我们如何确保它确实比其他任何模型都要好?
在这一步的目标是产生一个模型,使其达到最佳的预测性能。为了做到这一点,我们需要有一种可靠的方法来衡量每个可能的模型候选者的性能,然后选择最好的一个。
一种可能的方法是训练一个模型,让它在一个实时系统上运行,并观察会发生什么。在我们的垃圾邮件示例中,我们决定使用神经网络来检测垃圾邮件,所以我们训练它并将其部署到我们的生产系统中。然后我们观察模型在新消息上的表现,并记录系统错误的案例。
然而,这种方法并不适合我们的情况:我们不可能为每个模型候选者都这样做。更糟糕的是,我们可能会意外地部署一个真的很差的模型,并且只有在它在我们系统的实际用户上运行之后才会发现它的糟糕。
注意:在实时系统上测试模型被称为在线测试,这对于评估模型在真实数据上的质量非常重要。然而,这种方法属于过程的评估和部署步骤,而不是建模步骤。
在部署模型之前选择最佳模型的一个更好的方法是模拟上线场景。我们获取完整的数据集,从中取出部分数据,并在剩余的数据上训练模型。当训练完成后,我们假装保留的数据集是新的、未见过的数据,并使用它来衡量我们模型的性能。这部分数据通常被称为验证集,将数据集的一部分保留下来并用于评估性能的过程称为验证(见图 1.10)。

图 1.10 为了评估模型的性能,我们留出一部分数据,仅用于验证目的。
在垃圾邮件数据集中,我们可以取出每第十条消息。这样,我们保留了 10%的数据,仅用于验证模型,而将剩余的 90%用于训练。接下来,我们在训练数据上训练逻辑回归和神经网络。当模型训练完成后,我们将它们应用于验证数据集,并检查哪个模型在预测垃圾邮件方面更准确。
如果在将模型应用于验证后,我们发现逻辑回归在 90%的情况下正确预测垃圾邮件,而神经网络在 93%的情况下正确预测,我们得出结论,神经网络模型比逻辑回归是一个更好的选择(图 1.11)。

图 1.11 验证过程。我们将数据集分为两部分,在训练部分上训练模型,并在验证部分上评估性能。使用评估结果,我们可以选择最佳模型。
通常,我们不仅仅尝试两种模型,而是有更多。例如,逻辑回归有一个参数 C,根据我们设置的值,结果可能会有很大的不同。同样,神经网络也有许多参数,每个参数都可能对最终模型的预测性能产生重大影响。除此之外,我们还有其他模型,每个模型都有自己的参数集。我们如何选择具有最佳参数的最佳模型?
要做到这一点,我们使用相同的评估方案。我们在训练数据上使用不同的参数训练模型,然后将它们应用于验证数据,并根据最佳的验证结果选择模型及其参数(图 1.12)。

图 1.12 使用验证数据集选择具有最佳参数的最佳模型
然而,这种方法有一个微妙的问题。如果我们反复进行模型评估过程,并使用相同的验证数据集进行评估,我们观察到的验证数据集中的良好数字可能只是偶然出现的。换句话说,“最佳”模型可能只是在这个特定数据集上预测结果时运气好。
注意 在统计学和其他领域,这个问题被称为多重比较问题或多重测试问题。我们在同一数据集上做出预测的次数越多,我们偶然看到良好性能的可能性就越大。
为了防止这个问题,我们使用同样的想法:我们再次保留一部分数据。我们称这部分数据为测试数据集。我们很少使用它,仅用于测试我们选定的最佳模型(图 1.13)。

图 1.13 将数据分为训练、测试和验证部分
为了将此应用于垃圾邮件示例,我们首先保留 10%的数据作为测试数据集,然后保留 10%的数据作为验证。我们在验证数据集上尝试多个模型,选择最佳模型,并将其应用于测试数据集。如果我们看到验证和测试之间的性能差异不大,我们确认这个模型确实是最佳模型(图 1.14)。

图 1.14 我们使用测试数据集来确认最佳模型在验证集上的性能良好。
重要 设置验证过程是机器学习中最重要的一步。没有它,我们就无法可靠地知道我们刚刚训练的模型是好是坏,甚至是有害的。
选择最佳模型及其最佳参数的过程称为模型选择。我们可以如下总结模型选择(图 1.15):
-
我们将数据分为训练、验证和测试部分。
-
我们首先在训练部分对每个模型进行训练,然后对其进行验证。
-
每次我们训练一个不同的模型时,我们使用验证部分记录评估结果。
-
最后,我们确定哪个模型是最好的,并在测试数据集上对其进行测试。

图 1.15 模型选择过程。首先,我们将数据集分割,选择一个模型,并仅在数据的训练部分对其进行训练。然后我们在验证部分评估模型。我们重复这个过程多次,直到找到最佳模型。
使用模型选择过程,并在离线环境中首先验证和测试模型,以确保我们训练的模型是好的。如果模型在离线环境中表现良好,我们可以决定进入下一步,并将模型部署以评估其在真实用户中的性能。
摘要
-
与传统基于规则的软件工程系统不同,在这些系统中规则是手动提取和编码的,机器学习系统可以被训练来自动从数据中提取有意义的模式。这给我们带来了更多的灵活性,并使得适应变化变得更加容易。
-
成功实施一个机器学习项目需要有一个结构和一套指导方针。CRISP-DM 是一个组织机器学习项目的框架,它将整个过程分解为六个步骤,从业务理解到部署。该框架突出了机器学习的迭代特性,并帮助我们保持组织有序。
-
建模是机器学习项目中一个重要的步骤:这是我们实际使用机器学习来训练模型的部分。在这一步中,我们创建出能够实现最佳预测性能的模型。
-
模型选择是选择最佳模型以解决问题的一个过程。我们将所有可用数据分为三部分:训练集、验证集和测试集。我们在训练集上训练模型,并使用验证集来选择最佳模型。当最佳模型被选中后,我们使用测试步骤作为最终检查,以确保最佳模型表现良好。这个过程帮助我们创建出既实用又不出意外的有效模型。
2 回归机器学习
本章涵盖
-
使用线性回归模型创建汽车价格预测项目
-
使用 Jupyter 笔记本进行初步的探索性数据分析
-
设置验证框架
-
从头实现线性回归模型
-
为模型执行简单的特征工程
-
使用正则化来控制模型
-
使用模型预测汽车价格
在第一章中,我们讨论了监督机器学习,其中我们通过给他们示例来教机器学习模型如何在数据中识别模式。
假设我们有一个包含汽车描述的数据集,如制造商、型号和年龄,我们希望使用机器学习来预测它们的价格。这些汽车的特征被称为特征,价格是目标 变量——我们想要预测的东西。然后模型获取特征并将它们组合起来输出价格。
这是一个监督学习的例子:我们有一些关于某些汽车价格的信息,我们可以用它来预测其他汽车的价格。在第一章中,我们也讨论了不同类型的监督学习:回归和分类。当目标变量是数值时,我们有一个回归问题,而当目标变量是分类变量时,我们有一个分类问题。
在本章中,我们创建一个回归模型,从最简单的一个开始:线性回归。我们亲自实现算法,这足以用几行代码完成。同时,它非常具有说明性,它将教会你如何处理 NumPy 数组并执行基本的矩阵运算,如矩阵乘法和矩阵求逆。我们还遇到了在求逆矩阵时的数值不稳定性问题,并看到正则化如何帮助解决这些问题。
2.1 汽车价格预测项目
本章解决的问题是在预测汽车价格。假设我们有一个人们可以买卖二手汽车的网站。当在网站上发布广告时,卖家经常难以提出一个有意义的定价。我们希望帮助我们的用户通过自动价格推荐。我们要求卖家指定汽车的型号、制造商、年份、里程和其他重要特征,然后根据这些信息,我们希望提出最佳价格。
公司的一名产品经理偶然发现了一个包含汽车价格的公开数据集,并要求我们看看它。我们检查了数据,发现它包含了所有重要的特征以及推荐的价格——这正是我们用例所需要的。因此,我们决定使用这个数据集来构建价格推荐算法。
项目的计划如下:
-
首先,我们下载数据集。
-
接下来,我们对数据进行初步分析。
-
之后,我们设置一个验证策略,以确保我们的模型产生正确的预测。
-
然后我们使用 Python 和 NumPy 实现线性回归模型。
-
接下来,我们将介绍特征工程,从数据中提取重要特征以改进模型。
-
最后,我们将了解如何通过正则化使我们的模型稳定,并使用它来预测汽车价格。
2.1.1 下载数据集
对于这个项目,我们首先要做的是安装所有必需的库:Python、NumPy、Pandas 和 Jupyter Notebook。最简单的方法是使用一个名为 Anaconda 的 Python 发行版(www.anaconda.com)。请参阅附录 A 以获取安装指南。
在安装了库之后,我们需要下载数据集。我们有多种方法可以做到这一点。您可以通过 Kaggle 网络界面手动下载,网址为 www.kaggle.com/CooperUnion/cardataset。(您可以在 www.kaggle.com/jshih7/car-price-prediction 上了解更多关于数据集及其收集方式的信息。)前往该网站,打开它,然后点击下载链接。另一种选择是使用 Kaggle 命令行界面(CLI),这是一个用于通过 Kaggle 访问所有数据集的工具。对于本章,我们将使用第二种方法。我们将在附录 A 中描述如何配置 Kaggle CLI。
注意 Kaggle 是一个面向对机器学习感兴趣的人的在线社区。它最出名的是举办机器学习竞赛,但它也是一个数据共享平台,任何人都可以分享数据集。有超过 16,000 个数据集可供任何人使用。它是项目想法的绝佳来源,并且对于机器学习项目非常有用。
在本章以及整本书中,我们将积极使用 NumPy。我们将随着内容的展开介绍所有必要的 NumPy 操作,但请参阅附录 C 以获取更深入的介绍。
本项目的源代码可在 GitHub 上的书籍仓库中找到,网址为 github.com/alexeygrigorev/mlbookcamp-code,在 chapter-02-car-price 章节中。
作为第一步,我们将为这个项目创建一个文件夹。我们可以给它起任何名字,例如 chapter-02-car-price:
mkdir chapter-02-car-price
cd chapter-02-car-price
然后我们下载数据集:
kaggle datasets download -d CooperUnion/cardataset
此命令下载 cardataset.zip 文件,这是一个压缩文件。让我们解压缩它:
unzip cardataset.zip
在里面,有一个文件:data.csv。
当我们有了数据集后,让我们继续下一步:理解它。
2.2 探索性数据分析
理解数据是机器学习过程中的重要一步。在我们能够训练任何模型之前,我们需要知道我们有什么样的数据以及它是否有用。我们通过探索性数据分析(EDA)来完成这项工作。
我们查看数据集以学习
-
目标变量的分布
-
这个数据集中的特征
-
这些特征中值的分布
-
数据的质量
-
缺失值的数量
2.2.1 探索性数据分析工具箱
这种分析的主要工具是 Jupyter Notebook、Matplotlib 和 Pandas:
-
Jupyter Notebook 是一个用于交互式执行 Python 代码的工具。它允许我们执行一段代码并立即看到结果。此外,我们可以在自由文本中显示图表并添加注释。它还支持其他语言,如 R 或 Julia(因此得名:Jupyter 代表 Julia、Python、R),但我们将仅使用它来执行 Python。
-
Matplotlib 是一个用于绘图的库。它非常强大,允许你创建不同类型的可视化,如折线图、条形图和直方图。
-
Pandas 是一个用于处理表格数据的库。它可以读取来自任何来源的数据,无论是 CSV 文件、JSON 文件还是数据库。
我们还将使用 Seaborn,这是另一个基于 Matplotlib 构建的绘图工具,它使得绘制图表变得更加容易。
让我们通过执行以下命令来启动一个 Jupyter Notebook:
jupyter notebook
此命令在当前目录中启动 Jupyter Notebook 服务器,并在默认的网页浏览器中打开它(图 2.1)。

图 2.1 Jupyter Notebook 服务的起始屏幕
如果 Jupyter 在远程服务器上运行,则需要额外的配置。请参阅附录 A 了解设置详情。
现在让我们为这个项目创建一个笔记本。点击“新建”,然后在“笔记本”部分选择 Python 3。我们可以将其命名为 chapter-02-car-price-project—点击当前标题(未命名),并将其替换为新的名称。
首先,我们需要导入这个项目所需的所有库。在第一个单元格中写下以下内容:
import numpy as np ❶
import pandas as pd ❷
from matplotlib import pyplot as plt ❸
import seaborn as sns ❸
%matplotlib inline ❹
❶ 导入 NumPy:一个用于数值计算的库
❷ 导入 Pandas:一个用于表格数据的库
❸ 导入绘图库:Matplotlib 和 Seaborn
❹ 确保在 Jupyter Notebooks 中正确渲染图表
前两行,❶ 和 ❷,是导入所需库的语句:NumPy 用于数值操作,Pandas 用于表格数据。惯例是使用较短的别名(如 pd 在 import pandas as pd 中)来导入这些库。这种惯例在 Python 机器学习社区中很常见,每个人都遵循它。
接下来的两行,❸,是导入绘图库的语句。第一个,Matplotlib,是一个用于创建高质量可视化的库。直接使用这个库可能并不总是容易。一些库使使用 Matplotlib 更加简单,而 Seaborn 就是其中之一。
最后,行 ❹ 的 %matplotlib inline 告诉 Jupyter Notebook 期望在笔记本中渲染图表,因此当需要时它将能够渲染它们。
按下 Shift+Enter 或点击运行来执行所选单元格的内容。
我们不会深入探讨 Jupyter Notebooks 的细节。请访问官方网站 (jupyter.org) 了解更多信息。该网站提供了丰富的文档和示例,可以帮助你掌握它。
2.2.2 读取和准备数据
现在,让我们读取我们的数据集。我们可以使用 Pandas 的 read_csv 函数来完成此操作。将以下代码放入下一个单元格,并再次按下 Shift+Enter:
df = pd.read_csv('data.csv')
这行代码读取 CSV 文件并将结果写入名为 df 的变量中,df 是 DataFrame 的简称。现在我们可以检查有多少行。让我们使用 len 函数:
len(df)
该函数打印出 11914,这意味着在这个数据集中几乎有 12,000 辆汽车(图 2.2)。

图 2.2 Jupyter Notebooks 是交互式的。我们可以在一个单元中输入一些代码,执行它,并立即看到结果,这对于探索性数据分析来说非常理想。
现在,让我们使用 df.head() 来查看 DataFrame 的前五行(图 2.3)。

图 2.3 Pandas DataFrame 的 head() 函数输出:它显示了数据集的前五行。这个输出使我们能够了解数据的外观。
这让我们对数据的外观有了初步的了解。我们已能看到这个数据集中存在一些不一致性:列名有时有空格,有时有下划线(_)。特征值也是如此:有时它们是大写的,有时是带有空格的短字符串。这很不方便且令人困惑,但我们可以通过规范化来解决这些问题——将所有空格替换为下划线并将所有字母转换为小写:
df.columns = df.columns.str.lower().str.replace(' ', '_') ❶
string_columns = list(df.dtypes[df.dtypes == 'object'].index) ❷
for col in string_columns:
df[col] = df[col].str.lower().str.replace(' ', '_') ❸
❶ 将所有列名转换为小写,并将空格替换为下划线
❷ 仅选择具有字符串值的列
❸ 将 DataFrame 中所有字符串列的值转换为小写,并将空格替换为下划线
在 ❶ 和 ❸ 中,我们使用了特殊的 str 属性。使用它,我们可以同时对该列的整个字符串值应用字符串操作,而无需编写任何 for 循环。我们用它将列名和这些列的内容转换为小写,并将空格替换为下划线。
我们只能使用此属性来处理具有字符串值的列。这正是为什么我们在 ❷ 中首先选择这样的列。
注意:在本章及后续章节中,我们将随着内容的展开介绍相关的 Pandas 操作,但会保持较高的概括性。请参考附录 D 以获得对 Pandas 的更一致和深入的介绍。
在此初步预处理之后,DataFrame 看起来更加统一(图 2.4)。

图 2.4 数据预处理的结果。列名和值已规范化:它们都是小写,并且空格被转换为下划线。
如我们所见,这个数据集包含多个列:
-
make:汽车的制造商(宝马、丰田等)
-
model:汽车的型号
-
year:汽车制造的年份
-
engine_fuel_type:发动机所需的燃料类型(柴油、电动等)
-
engine_hp:发动机马力
-
engine_cylinders:发动机的气缸数
-
transmission_type:变速器类型(自动或手动)
-
driven_wheels:前轮驱动、后轮驱动、全轮驱动
-
number_of_doors:汽车的门数
-
market_category:豪华、跨界等
-
vehicle_size:紧凑型、中型或大型
-
vehicle_style:轿车或敞篷车
-
highway_mpg:高速公路上的每加仑英里数(mpg)
-
city_mpg:城市每加仑英里数
-
popularity:汽车在 Twitter 流中提到的次数
-
msrp:制造商建议零售价
对于我们来说,这里最有趣的列是最后一个:MSRP(制造商建议零售价,或简称为汽车价格)。我们将使用这个列来预测汽车的价格。
2.2.3 目标变量分析
MSRP 列包含重要信息——它是我们的目标变量,即y,这是我们想要学习预测的值。
探索性数据分析的第一步始终是查看y的值看起来如何。我们通常通过检查y的分布来完成此操作:对y的可能值及其发生频率的视觉描述。这种可视化类型称为直方图。
我们将使用 Seaborn 来绘制直方图,所以请在 Jupyter Notebook 中输入以下内容:
sns.histplot(df.msrp, bins=40)
绘制这个图表后,我们立即注意到价格分布有一个非常长的尾巴。左侧有许多低价汽车,但数量迅速下降,并且有一个高价汽车数量非常少的长期尾巴(见图 2.5)。

图 2.5 数据集中价格分布。我们看到价格轴低端的许多值,而高端几乎没有。这是一个长尾分布,这是许多低价且少量高价物品的典型情况。
我们可以通过稍微放大并查看低于 10 万美元的值来更仔细地观察(图 2.6):
sns.histplot(df.msrp[df.msrp < 100000])

图 2.6 低于 10 万美元的汽车价格分布。仅查看低于 10 万美元的汽车价格,我们可以更好地看到分布的头部。我们也注意到很多价格为 1000 美元的汽车。
长尾使得我们很难看到分布,但它对模型的影响更大:这种分布可以极大地混淆模型,因此它不会很好地学习。解决这个问题的方法之一是取对数转换。如果我们对价格应用对数函数,它将消除不希望的效果(图 2.7)。


图 2.7 价格的对数。长尾效应被消除,我们可以在一个图中看到整个分布。
在有零值的情况下,+1 部分很重要。零的对数是负无穷大,但一的对数是零。如果我们的值都是非负的,通过加 1,我们确保转换后的值不会低于零。
对于我们的具体情况,零值不是问题——我们所有的价格都从 1000 美元开始——但这是我们遵循的惯例。NumPy 有一个执行这种转换的函数:
log_price = np.log1p(df.msrp)
要查看转换后的价格分布,我们可以使用相同的histplot函数(图 2.7):
sns.histplot(log_price)
正如我们所看到的,这种转换去除了长尾,现在分布看起来像钟形曲线。当然,由于低价的大峰值,这个分布不是正态分布,但模型可以更容易地处理它。
备注:一般来说,当目标分布看起来像正态分布(图 2.8)时是很好的。在这种情况下,线性回归等模型表现良好。

图 2.8 正态分布,也称为高斯分布,遵循钟形曲线,它是对称的,并且在中心有一个峰值。
练习 2.1
分布的头部是一个有很多值的范围。那么,分布的长尾是什么?
a) 大约在 1,000 美元左右的峰值
b) 当许多值非常远离头部分散时的情况——这些值在直方图上视觉上看起来像“尾部”
c) 在一个很短的范围内紧密堆积的许多非常相似的价值
2.2.4 检查缺失值
我们稍后会更仔细地查看其他特征,但现在我们应该做的一件事是检查数据中的缺失值。这一步很重要,因为通常机器学习模型无法自动处理缺失值。我们需要知道是否需要对这些值进行特殊处理。
Pandas 有一个方便的函数可以检查缺失值:
df.isnull().sum()
这个函数显示了
make 0
model 0
year 0
engine_fuel_type 3
engine_hp 69
engine_cylinders 30
transmission_type 0
driven_wheels 0
number_of_doors 6
market_category 3742
vehicle_size 0
vehicle_style 0
highway_mpg 0
city_mpg 0
popularity 0
msrp 0
我们首先看到的是,MSRP——我们的目标变量——没有任何缺失值。这个结果是好的,因为否则,这样的记录对我们来说将没有用处:我们总是需要知道观察到的目标值才能用于训练模型。此外,一些列有缺失值,特别是市场类别,其中我们几乎有 4,000 行缺失值。
我们在训练模型时需要处理缺失值,所以我们应该记住这个问题。现在,我们不对这些特征做任何其他操作,继续到下一步:设置验证框架,以便我们可以训练和测试机器学习模型。
2.2.5 验证框架
如我们之前所学的,尽早设置验证框架很重要,以确保我们训练的模型是好的,并且可以泛化——也就是说,模型可以应用于新的、未见过的数据。为了做到这一点,我们留出一部分数据,只在一个部分上训练模型。然后我们使用留出的数据集——我们没有用于训练的数据集——来确保模型的预测是有意义的。
这一步骤很重要,因为我们通过使用优化方法来训练模型,这些方法将函数 g(X) 与数据 X 相拟合。有时这些优化方法会捕捉到虚假模式——这些模式在模型看来似乎是真实模式,但实际上是随机波动。例如,如果我们有一个小的训练数据集,其中所有宝马车的价格仅为 10,000 美元,那么模型会认为这是世界上所有宝马车的真实情况。
为了确保这一点,我们使用验证。因为验证数据集没有用于训练模型,优化方法没有看到这些数据。当我们将模型应用于这些数据时,它模拟了将模型应用于我们从未见过的新的数据的情况。如果验证数据集中有价格高于 10,000 美元的宝马车,但我们的模型将预测它们为 10,000 美元,我们将注意到模型在这些示例上的表现不佳。
如我们所知,我们需要将数据集分为三个部分:训练集、验证集和测试集(图 2.9)。

图 2.9 整个数据集分为三部分:训练集、验证集和测试集。
让我们将 DataFrame 分割成以下形式
-
20% 的数据用于验证。
-
20% 用于测试。
-
剩余的 60% 用于训练。
列表 2.1 将数据分为验证集、测试集和训练集
n = len(df) ❶
n_val = int(0.2 * n) ❷
n_test = int(0.2 * n) ❷
n_train = n - (n_val + n_test) ❷
np.random.seed(2) ❸
idx = np.arange(n) ❹
np.random.shuffle(idx) ❹
df_shuffled = df.iloc[idx] ❺
df_train = df_shuffled.iloc[:n_train].copy() ❻
df_val = df_shuffled.iloc[n_train:n_train+n_val].copy() ❻
df_test = df_shuffled.iloc[n_train+n_val:].copy() ❻
❶ 获取 DataFrame 中的行数
❷ 计算应该有多少行用于训练、验证和测试
❸ 固定随机种子以确保结果可重复
❹ 创建一个包含从 0 到 (n-1) 的索引的 NumPy 数组,并对其进行打乱
❺ 使用索引数组获取打乱的 DataFrame
❻ 将打乱顺序的 DataFrame 分割为训练集、验证集和测试集
让我们仔细看看这段代码,并澄清一些事情。
在 ❹ 中,我们创建一个数组并将其打乱。让我们看看那里会发生什么。我们可以取一个包含五个元素的较小数组并对其进行打乱:
idx = np.arange(5)
print('before shuffle', idx)
np.random.shuffle(idx)
print('after shuffle', idx)
如果我们运行它,它将打印出类似的内容
before shuffle [0 1 2 3 4]
after shuffle [2 3 0 4 1]
然而,如果我们再次运行它,结果将不同:
before shuffle [0 1 2 3 4]
after shuffle [4 3 0 2 1]
为了确保每次运行它时结果都相同,在 ❸ 中我们固定随机种子:
np.random.seed(2)
idx = np.arange(5)
print('before shuffle', idx)
np.random.shuffle(idx)
print('after shuffle', idx)
函数 np.random.seed 接收任何数字,并使用此数字作为 NumPy 随机包内生成所有数据的起始种子。
当我们执行此代码时,它将打印
before shuffle [0 1 2 3 4]
after shuffle [2 4 1 3 0]
在这种情况下,结果仍然是随机的,但当我们重新执行时,结果与之前的运行相同:
before shuffle [0 1 2 3 4]
after shuffle [2 4 1 3 0]
这有利于可重复性。如果我们希望其他人运行此代码并获得相同的结果,我们需要确保一切固定,即使是代码中的“随机”部分。
注意:这使结果在同一台计算机上可重复。在不同的操作系统和不同的 NumPy 版本下,结果可能不同。
在我们创建一个索引数组 idx 之后,我们可以使用它来获取初始 DataFrame 的打乱版本。为此,在 ❺ 中,我们使用 iloc,这是一种通过行号访问 DataFrame 的方法:
df_shuffled = df.iloc[idx]
如果 idx 包含打乱的连续数字,此代码将生成一个打乱的 DataFrame(图 2.10)。

图 2.10 使用 iloc 打乱 DataFrame。当与打乱的索引数组一起使用时,它创建一个打乱的 DataFrame。
在这个例子中,我们使用了 iloc 和一个索引列表。我们也可以使用冒号运算符(:)来指定范围,这正是我们在 ❻ 中对打乱顺序的 DataFrame 进行拆分以创建训练、验证和测试集时所做的事情:
df_train = df_shuffled.iloc[:n_train].copy()
df_val = df_shuffled.iloc[n_train:n_train+n_val].copy()
df_test = df_shuffled.iloc[n_train+n_val:].copy()
现在 DataFrame 被分成三部分,我们可以继续。我们的初步分析显示价格分布有一个长尾,为了消除其影响,我们需要应用对数变换。我们可以为每个 DataFrame 单独做这件事:
y_train = np.log1p(df_train.msrp.values)
y_val = np.log1p(df_val.msrp.values)
y_test = np.log1p(df_test.msrp.values)
为了避免不小心在以后使用目标变量,让我们从数据框中移除它:
del df_train['msrp']
del df_val['msrp']
del df_test['msrp']
注意:移除目标变量是一个可选步骤。但确保我们在训练模型时不会使用它是很有帮助的:如果那样做了,我们会用价格来预测价格,我们的模型将具有完美的准确性。
当完成验证拆分后,我们可以进行下一步:训练模型。
2.3 回归问题的机器学习
在进行初步数据分析后,我们准备训练一个模型。我们正在解决的问题是一个回归问题:目标是预测一个数字——汽车的价格。对于这个项目,我们将使用最简单的回归模型:线性回归。
2.3.1 线性回归
为了预测汽车的价格,我们需要使用机器学习模型。为了做到这一点,我们将使用线性回归,我们将自己实现它。通常,我们不手动做这件事;相反,我们让一个框架为我们做这件事。然而,在本章中,我们想表明这些框架中并没有什么魔法:这只是代码。线性回归是一个完美的模型,因为它相对简单,可以用几行 NumPy 代码实现。
首先,让我们了解线性回归是如何工作的。正如我们从第一章所知,一个监督机器学习模型的形式是

这是一个矩阵形式。X 是一个矩阵,其中观测的特征是矩阵的行,而 y 是一个包含我们想要预测的值的向量。
这些矩阵和向量可能听起来很复杂,所以让我们退一步,考虑单个观测 x[i] 和我们想要预测的值 y[i] 发生了什么。这里的索引 i 表示这是一个观测编号 i,是我们训练数据集中 m 个观测中的一个。
然后,对于这个单个观测,前面的公式看起来是这样的

如果我们有 n 个特征,我们的向量 x[i] 是 n-维的,因此它有 n 个分量:

因为它有 n 个分量,我们可以将函数 g 写成一个具有 n 个参数的函数,这与前面的公式相同:

对于我们的案例,我们在训练数据集中有 7,150 辆车。这意味着 m = 7,150,而 i 可以是 0 到 7,149 之间的任何数字。例如,当 i = 10 时,我们有一辆以下这样的车:
make rolls-royce
model phantom_drophead_coupe
year 2015
engine_fuel_type premium_unleaded_(required)
engine_hp 453
engine_cylinders 12
transmission_type automatic
driven_wheels rear_wheel_drive
number_of_doors 2
market_category exotic,luxury,performance
vehicle_size large
vehicle_style convertible
highway_mpg 19
city_mpg 11
popularity 86
msrp 479775
让我们挑选几个数值特征,暂时忽略其他特征。我们可以从马力、城市油耗和受欢迎程度开始:
engine_hp 453
city_mpg 11
popularity 86
然后,我们将这些特征分别分配给 x[i1],x[i2],和 x[i3]。这样,我们得到具有三个分量的特征向量 x[i]:

为了更容易理解,我们可以将这种数学符号翻译成 Python。在我们的例子中,函数 g 具有以下签名:
def g(xi):
# xi is a list with n elements
# do something with xi
# return the result
pass
在这段代码中,变量 xi 是我们的向量 x[i]。根据实现方式,xi 可能是一个包含 n 个元素的列表或一个大小为 n 的 NumPy 数组。
对于之前描述的汽车,xi 是一个包含三个元素的列表:
xi = [453, 11, 86]
当我们将函数 g 应用到向量 xi 上时,它会产生 y_pred 作为输出,这是 g 对 xi 的预测:
y_pred = g(xi)
我们期望这个预测尽可能接近 y[i],即汽车的实际价格。
注意:在本节中,我们将使用 Python 来说明数学公式背后的思想。我们不需要使用这些代码片段来完成项目。另一方面,将此代码放入 Jupyter 中并尝试运行它可能有助于理解这些概念。
函数 g 可以有多种形式,机器学习算法的选择定义了它的工作方式。
如果 g 是线性回归模型,它具有以下形式:

变量 w[0],w[1],w[2],...,w[n]* 是模型的参数:
-
w[0] 是 偏置 项。
-
w[1],w[2],...,w[n] 是每个特征 x[i1],x[i2],...,x[in] 的 权重。
这些参数定义了模型如何组合特征,以便最终预测尽可能好。这些参数背后的含义可能还不清楚,因为我们将在这个部分稍后讨论它们。
为了使公式更短,让我们使用求和符号:
练习 2.2
对于监督学习,我们使用机器学习模型对单个观察值 y[i] ≈ g(x[i])。在这个项目中,x[i] 和 y[i] 是什么?
a) x[i] 是一个特征向量——一个包含描述对象(汽车)的几个数字的向量——而 y[i] 是这辆车的价格的对数。
b) y[i] 是一个特征向量——一个包含描述对象(汽车)的几个数字的向量——而 x[i] 是这辆车的价格的对数。

这些权重是我们在训练模型时模型学习的。为了更好地理解模型如何使用这些权重,让我们考虑以下值(表 2.1)。
表 2.1 线性回归模型学习到的权重示例
| w[0] | w[1] | w[2] | w[3] |
|---|---|---|---|
| 7.17 | 0.01 | 0.04 | 0.002 |
因此,如果我们想将这个模型翻译成 Python,它将看起来像这样:
w0 = 7.17
# [w1 w2 w3 ]
w = [0.01, 0.04, 0.002]
n = 3
def linear_regression(xi):
result = w0
for j in range(n):
result = result + xi[j] * w[j]
return result
我们把所有特征权重放在一个单独的列表w中——就像我们之前对xi所做的那样。我们现在需要做的就是遍历这些权重并将它们乘以相应的特征值。这不过是将之前的公式直接翻译成 Python。
这很容易看出。再看一下公式:

我们的例子有三个特征,所以n = 3,我们有

这正是代码中的内容
result = w0 + xi[0] * w[0] + xi[1] * w[1] + xi[2] * w[2]
除了 Python 中的索引从 0 开始之外,x[i1]变为xi[0],而w[1]是w[0]。
现在,让我们看看当我们将模型应用于我们的观察结果x[i]并替换权重为它们的值时会发生什么:

对于这个观察结果,我们得到的预测是 12.31。记住,在预处理过程中,我们对目标变量y应用了对数变换。这就是为什么在这个数据上训练的模型也预测了价格的对数。为了撤销变换,我们需要取对数的指数。在我们的情况下,当我们这样做时,预测变为$603,000:
exp(12.31 + 1) = 603,000
偏差项(7.17)是我们对汽车一无所知时预测的值;它作为基准。
我们确实对汽车有一些了解:马力、城市每加仑英里数和流行度。这些特征是x[i1],x[i2],和x[i3]特征,每个特征都告诉我们关于汽车的一些信息。我们使用这些信息来调整基准。
让我们考虑第一个特征:马力。这个特征的权重是 0.01,这意味着对于每个额外的马力单位,我们通过添加 0.01 来调整基准。因为我们有 453 匹马在引擎中,所以我们向基准添加 4.53:453 匹马 · 0.01 = 4.53。
同样,对于每加仑多行驶一英里,价格会增加 0.04,所以我们加上 0.44:11 MPG · 0.04 = 0.44。
最后,我们考虑流行度。在我们的例子中,Twitter 流中的每一次提及都会导致 0.002 的增加。总的来说,流行度对最终预测的贡献为 0.172。
这正是为什么当我们结合所有内容时得到 12.31 的原因(图 2.11)。

图 2.11 线性回归的预测是基准值 7.17(偏差项)加上我们从特征中获得的信息调整后的结果。马力对最终预测的贡献为 4.53;每加仑英里数,0.44;流行度,0.172。
现在,让我们记住我们实际上处理的是向量,而不是单个数字。我们知道x[i]是一个有n个分量的向量:

我们也可以将所有权重合并到一个单独的向量w中:

实际上,我们在 Python 示例中已经那样做了,当时我们把所有的权重放在一个列表中,这是一个维度为 3 的向量,包含每个单个特征的权重。这就是我们的例子中向量的样子:


因为现在我们将特征和权重都视为向量 x[i] 和 w,我们可以用它们之间的点积替换这些向量的元素之和:

点积是乘以两个向量的方式:我们乘以向量的对应元素,然后将结果相加。有关向量-向量乘法的更多详细信息,请参阅附录 C。
将点积公式的翻译转换为代码是直接的:
def dot(xi, w):
n = len(w)
result = 0.0
for j in range(n):
result = result + xi[j] * w[j]
return result
使用新的符号,我们可以将线性回归的整个方程重新写为

其中
-
w[0] 是偏置项。
-
w 是 n-维权重向量。
现在我们可以使用新的 dot 函数,因此 Python 中的线性回归函数变得非常简短:
def linear_regression(xi):
return w0 + dot(xi, w)
或者,如果 xi 和 w 是 NumPy 数组,我们可以使用内置的 dot 方法进行乘法:
def linear_regression(xi):
return w0 + xi.dot(w)
为了使其更短,我们可以通过在 w[1] 前面添加 w[0] 来将 w[0] 和 w 合并成一个 (n+1)-维向量:

在这里,我们有一个新的权重向量 w,它由偏置项 w[0] 后跟原始权重向量 w 中的权重 w[1],w[2]*,... 组成。
在 Python 中,这很容易做到。如果我们已经有一个包含旧权重的列表 w,我们只需要做以下操作:
w = [w0] + w
记住,Python 中的加号运算符用于连接列表,所以 [1] + [2, 3, 4] 将创建一个包含四个元素的新列表:[1, 2, 3, 4]。在我们的例子中,w 已经是一个列表,所以我们创建一个新的 w,在开头添加一个额外的元素:w0。
因为现在 w 成为一个 (n+1)-维向量,我们还需要调整特征向量 x[i],以便它们之间的点积仍然有效。我们可以通过添加一个虚拟特征 x[i 0],它始终取值为 1 来轻松完成此操作。然后,我们在 x[i 1] 之前将这个新的虚拟特征添加到 x[i] 中:

或者,用代码表示:
xi = [1] + xi
我们创建一个新的列表 xi,其第一个元素为 1,后跟旧列表 xi 中的所有元素。
通过这些修改,我们可以将模型表示为新的 x[i] 和新的 w 之间的点积:

代码的转换很简单:
w0 = 7.17
w = [0.01, 0.04, 0.002]
w = [w0] + w
def linear_regression(xi):
xi = [1] + xi
return dot(xi, w)
这些线性回归公式是等价的,因为新 x[i] 的第一个特征是 1,所以当我们乘以 x[i] 的第一个分量和 w 的第一个分量时,我们得到偏置项,因为 w[0] · 1 = w[0]。
我们现在可以再次考虑更大的图景,并讨论矩阵形式。有许多观察结果,x[i] 是其中之一。因此,我们有了 m 个特征向量 x[1],x[2],...,x[i],...,x[m],并且这些向量中的每一个都包含 n+1 个特征:

我们可以将这些向量组合成矩阵的行。让我们称这个矩阵为 X(见图 2.12)。

图 2.12 矩阵 X,其中观测值 x[1]、x[2]、...、x[m] 是行
让我们看看代码中的样子。我们可以从训练数据集中取几行,例如第一行、第二行和第十行:
x1 = [1, 148, 24, 1385]
x2 = [1, 132, 25, 2031]
x10 = [1, 453, 11, 86]
现在,让我们将这些行组合成另一个列表:
X = [x1, x2, x10]
列表 X 现在包含三个列表。我们可以将其视为一个 3x4 矩阵——一个有三行四列的矩阵:
X = [[1, 148, 24, 1385],
[1, 132, 25, 2031],
[1, 453, 11, 86]]
这个矩阵的每一列都是一个特征:
-
第一列是一个虚拟特征,值为“1。”
-
第二列是引擎马力。
-
第三列——城市油耗。
-
最后一个——流行度,或 Twitter 流中提及的数量。
我们已经了解到,为了对一个单个特征向量进行预测,我们需要计算这个特征向量与权重向量之间的点积。现在我们有一个矩阵 X,在 Python 中它是一个特征向量的列表。为了对矩阵的所有行进行预测,我们可以简单地遍历 X 的所有行并计算点积:
predictions = []
for xi in X:
pred = dot(xi, w)
predictions.append(pred)
在线性代数中,这是矩阵-向量乘法:我们用向量 w 乘以矩阵 X。线性回归的公式变为

结果是一个数组,包含 X 每一行的预测。有关矩阵-向量乘法的更多详细信息,请参阅附录 C。
使用这种矩阵公式,将线性回归应用于预测的代码变得非常简单。将其转换为 NumPy 变得直截了当:
predictions = X.dot(w)
练习 2.3
当我们将矩阵 X 与权重向量 w 相乘时,我们得到
a) 包含实际价格的向量 y
b) 一个包含价格预测的向量 y
c) 一个包含价格预测的单个数字 y
2.3.2 训练线性回归模型
到目前为止,我们只介绍了如何进行预测。为了能够做到这一点,我们需要知道权重 w。我们如何得到它们?
我们从数据中学习权重:我们使用目标变量 y 来找到这样的 w,它能以最佳方式结合 X 的特征。“最佳方式”在线性回归的情况下意味着它最小化了预测 g(X) 与实际目标 y 之间的误差。
我们有多种方法可以实现这一点。我们将使用正则方程,这是实现起来最简单的方法。权重向量 w 可以用以下公式计算:

注意:本书不涵盖正则方程的推导过程。我们仅在附录 C 中简要介绍了其工作原理,但为了更深入的了解,你应该查阅机器学习教材。《统计学习基础》,第二版,作者 Hastie、Tibshirani 和 Friedman,是一个很好的起点。
这段数学可能看起来令人害怕或困惑,但它很容易翻译成 NumPy:
-
X^T 是 X 的转置。在 NumPy 中,它是
X.T。 -
X^T**X 是矩阵-矩阵乘法,我们可以使用 NumPy 的
dot方法来完成:X.T.dot(X)。 -
X^(–1) 是 X 的逆。我们可以使用
np.linalg.inv函数来计算逆。
因此,上述公式可以直接转换为
inv(X.T.dot(X)).dot(X.T).dot(y)
请参阅附录 C 了解有关此方程的更多详细信息。
要实现正规方程,我们需要做以下几步:
-
创建一个函数,该函数接受一个包含特征矩阵 X 和一个包含目标向量 y。
-
向矩阵 X 添加一个虚拟列(始终设置为 1 的特征)。
-
训练模型:通过使用正规方程计算权重 w。
-
将这个 w 分解为偏置 w[0] 和其余权重,并返回它们。
最后一步——将 w 分解为偏置项和其余部分——是可选的,主要为了方便;否则,每次我们想要进行预测时,都需要添加一个虚拟列,而不是在训练期间一次性完成。
让我们来实现它。
列表 2.2 使用 NumPy 实现的线性回归
def train_linear_regression(X, y):
# adding the dummy column
ones = np.ones(X.shape[0]) ❶
X = np.column_stack([ones, X]) ❷
# normal equation formula
XTX = X.T.dot(X) ❸
XTX_inv = np.linalg.inv(XTX) ❹
w = XTX_inv.dot(X.T).dot(y) ❺
return w[0], w[1:] ❻
❶ 创建一个只包含一个的数组
❷ 将包含一个的数组作为 X 的第一列添加
❸ 计算 X^TX
❹ 计算 X^TX 的逆
❺ 计算正规方程的其余部分
❻ 将权重向量分为偏置和其余权重
通过六行代码,我们已经实现了我们的第一个机器学习算法。在❶中,我们创建了一个只包含一个的向量,并将其作为第一列附加到矩阵 X 上;这是❷中的虚拟特征。接下来,我们在❸中计算 X^TX 并在❹中计算其逆,然后将它们组合起来计算❺中的 w。最后,我们在❻中将权重分为偏置 w[0] 和其余权重 w。
我们用于添加一个包含一个的列的 column_stack 函数可能一开始会让人困惑,所以让我们更仔细地看看它:
np.column_stack([ones, X])
它接受一个 NumPy 数组的列表,在我们的情况下包含 ones 和 X 并将它们堆叠(图 2.13)。

函数 column_stack 接受一个 NumPy 数组的列表并将它们按列堆叠。在我们的情况下,该函数将包含一个的数组作为矩阵的第一列附加。
如果将权重分解为偏置项和其余部分,则用于预测的线性回归公式略有变化:

这仍然很容易翻译成 NumPy:
y_pred = w0 + X.dot(w)
让我们用它来为我们的项目服务!
2.4 预测价格
我们已经涵盖了大量的理论,现在让我们回到我们的项目:预测汽车的价格。我们现在有一个用于训练线性回归模型的函数可供使用,所以让我们用它来构建一个简单的基线解决方案。
2.4.1 基线解决方案
然而,要使用它,我们需要有一些数据:一个矩阵 X 和一个包含目标变量 y 的向量。我们已经准备好了 y,但我们还没有 X:我们现在有一个数据框,而不是一个矩阵。因此,我们需要从我们的数据集中提取一些特征来创建这个矩阵 X。
我们将从一个非常简单的方式来创建特征开始:选择一些数值特征,并从它们中形成矩阵 X。在上一个例子中,我们只使用了三个特征。这次,我们包括一些额外的特征,并使用以下列:
-
engine_hp
-
engine_cylinders
-
highway_mpg
-
city_mpg
-
popularity
让我们从数据框中选择特征并将它们写入一个新的变量 df_num:
base = ['engine_hp', 'engine_cylinders', 'highway_mpg', 'city_mpg',
'popularity']
df_num = df_train[base]
如在探索性数据分析部分所述,数据集有缺失值。我们需要做些事情,因为线性回归模型不能自动处理缺失值。
一个选择是删除包含至少一个缺失值的所有行。然而,这种方法有一些缺点。最重要的是,我们将失去其他列中的信息。即使我们可能不知道一辆车的车门数量,我们仍然知道其他关于这辆车的信息,比如制造商、型号、年龄以及其他我们不希望丢弃的东西。
另一个选择是将缺失值填充为某个其他值。这样,我们不会失去其他列中的信息,并且即使行中有缺失值,仍然可以进行预测。最简单的方法是将缺失值填充为零。我们可以使用 Pandas 的 fillna 方法:
df_num = df_num.fillna(0)
这种方法可能不是处理缺失值的最佳方式,但通常来说,它已经足够好了。如果我们把缺失的特征值设为零,相应的特征就会被简单地忽略。
注意:另一种选择是将缺失值替换为平均值。对于某些变量,例如汽缸数,零值没有太多意义:一辆车不可能没有汽缸。然而,这将使我们的代码更加复杂,并且不会对结果产生重大影响。这就是为什么我们遵循一个更简单的方法,将缺失值替换为零。
很容易看出为什么将一个特征设为零等同于忽略它。让我们回顾一下线性回归的公式。在我们的情况下,我们有五个特征,所以公式是

如果第三个特征缺失,并且我们用零填充它,x[i 3] 就变成了零:

在这个例子中,无论这个特征的权重 w[3] 如何,乘积 x[i 3]w[3] 总是会是零。换句话说,这个特征对最终预测没有任何贡献,我们将只基于没有缺失的特征进行预测:

现在我们需要将这个 DataFrame 转换为一个 NumPy 数组。最简单的方法是使用它的 values 属性:
X_train = df_num.values
X_train 是一个矩阵——一个二维的 NumPy 数组。它是我们可以将它作为输入传递给 linear_regresson 函数的东西。让我们称它为
w_0, w = train_linear_regression(X_train, y_train)
我们刚刚训练了第一个模型!现在我们可以将其应用于训练数据以查看其预测效果:
y_pred = w_0 + X_train.dot(w)
为了看到预测有多好,我们可以使用histplot——这是 Seaborn 中用于绘制直方图的函数,我们之前已经使用过——来绘制预测值并与实际价格进行比较:
sns.histplot(y_pred, label='prediction')
sns.histplot(y_train, label='target')
plt.legend()
从图表(图 2.14)中我们可以看到,我们预测的值的分布与实际值看起来相当不同。这个结果可能表明,模型没有足够的能力来捕捉目标变量的分布。这对我们来说不应该是个惊喜:我们使用的模型相当基础,只包括五个非常简单的特征。

图 2.14 预测值(浅灰色)和实际值(深灰色)的分布。我们看到我们的预测并不很好;它们与实际分布差异很大。
2.4.2 RMSE:评估模型质量
观察图表并比较实际目标变量的分布与预测的分布是评估质量的好方法,但我们在每次更改模型中的任何内容时都不能这样做。相反,我们需要使用一个量化模型质量的指标。我们可以使用许多指标来评估回归模型的表现。最常用的一种是均方根误差——简称 RMSE。
RMSE 告诉我们我们的模型犯的错误有多大。它是通过以下公式计算的:

让我们尝试理解这里发生了什么。首先,让我们看看总和内部。我们有

这是我们对观测的预测与该观测的实际目标值之间的差异(图 2.15)。

图 2.15 预测值 g(x[i]) 和实际值 y[i] 之间的差异
然后,我们使用差的平方,这给较大的差异赋予了更多的权重。例如,如果我们预测 9.5,而实际值是 9.6,差异是 0.1,所以它的平方是 0.01,相当小。但如果我们预测 7.3,而实际值是 10.3,差异是 3,差的平方是 9(图 2.16)。
这是 RMSE 中的 SE 部分(平方误差)。

图 2.16 预测值和实际值之间差的平方。对于大的差异,平方相当大。
接下来,我们有一个总和:

这个求和遍历所有m个观测值,并将所有平方误差(图 2.17)组合成一个单一的数字。

图 2.17 所有平方差之和是一个单一的数字。
如果我们将这个总和除以m,我们得到均方误差:

这就是我们的模型在平均意义上的平方误差——RMSE 中的 M 部分(平均),或均方误差(MSE)。MSE 本身也是一个很好的指标(图 2.18)。

图 2.18 MSE 是通过计算平方误差的平均值来计算的。
最后,我们计算其平方根:

这是 RMSE 中的 R 部分(根)(图 2.19)。

图 2.19 RMSE:我们首先计算均方误差(MSE),然后计算其平方根。
当使用 NumPy 实现 RMSE 时,我们可以利用向量化:将相同的操作应用于一个或多个 NumPy 数组中所有元素的过程。使用向量化可以获得多个好处。首先,代码更加简洁:我们不需要编写任何循环来对数组中的每个元素应用相同的操作。其次,向量化操作比简单的 Python 循环要快得多。
考虑以下实现。
列表 2.3 根均方误差的实现
def rmse(y, y_pred):
error = y_pred - y ❶
mse = (error ** 2).mean() ❷
return np.sqrt(mse) ❸
❶ 计算预测值与目标之间的差异
❷ 计算 MSE:首先计算平方误差,然后计算其平均值
❸ 计算平方根以获得 RMSE
在❶中,我们计算预测向量与目标变量向量之间的逐元素差异。结果是包含差异的新 NumPy 数组error。在❷中,我们在一行中执行两个操作:计算error数组中每个元素的平方,然后计算结果的平均值,这给我们 MSE。在❸中,我们计算平方根以获得 RMSE。
NumPy 和 Pandas 中的逐元素操作非常方便。我们可以对整个 NumPy 数组(或 Pandas 序列)应用操作,而无需编写循环。
例如,在我们的rmse函数的第一行中,我们计算预测值与实际价格之间的差异:
error = y_pred - y
这里发生的情况是,对于y_pred的每个元素,我们减去相应的y元素,然后将结果放入新的数组error(图 2.20)。

图 2.20 y_pred和y之间的逐元素差异。结果写入error数组。
接下来,我们计算error数组中每个元素的平方,然后计算其平均值以获得我们模型的均方误差(图 2.21)。

图 2.21 计算 MSE,我们首先计算误差数组中每个元素的平方,然后计算结果的平均值。
要确切了解发生了什么,我们需要知道幂运算符(**)也是逐元素应用的,因此结果是另一个数组,其中原始数组的所有元素都被平方。当我们有这个包含平方元素的新的数组时,我们只需使用mean()方法计算其平均值(图 2.22)。

图 2.22 功运算符(**)逐元素应用于误差数组。结果是另一个数组,其中每个元素都是平方的。然后我们计算平方误差数组的平均值以计算 MSE。
最后,我们计算平均值平方根以获得 RMSE:
np.sqrt(mse)
现在我们可以使用 RMSE 来评估模型的质量:
rmse(y_train, y_pred)
代码打印出 0.75。这个数字告诉我们,平均而言,模型的预测偏差为 0.75。这个结果本身可能不是非常有用,但我们可以用它来比较这个模型与其他模型。如果一个模型的 RMSE(均方根误差)比另一个模型更好(更低),这表明该模型更好。
2.4.3 验证模型
在上一节的例子中,我们在训练集上计算了 RMSE。这个结果是有用的,但它并不反映模型将如何被使用。模型将被用来预测它之前没有见过的汽车价格。为此目的,我们留出了一部分验证数据集。我们故意不使用它进行训练,而是保留它来验证模型。
我们已经将数据分成多个部分:df_train、df_val和df_test。我们还从df_train创建了一个矩阵X_train,并使用X_train和y_train来训练模型。现在我们需要执行相同的步骤来获取X_val——一个从验证数据集计算出的特征矩阵。然后我们可以将模型应用于X_val以获取预测结果,并将其与y_val进行比较。
首先,我们创建X_val矩阵,遵循与X_train相同的步骤:
df_num = df_val[base]
df_num = df_num.fillna(0)
X_val = df_num.values
我们已经准备好将模型应用于X_val以获取预测结果:
y_pred = w_0 + X_val.dot(w)
y_pred数组包含验证数据集的预测结果。现在我们使用y_pred并将其与y_val中的实际价格进行比较,使用我们之前实现的 RMSE 函数:
rmse(y_val, y_pred)
这段代码打印的值是 0.76,这是我们用于比较模型的数字。
在之前的代码中,我们已经看到了一些重复:训练和验证测试需要相同的预处理,我们重复编写了相同的代码。因此,将此逻辑移动到单独的函数中并避免代码重复是有意义的。
我们可以称这个函数为prepare_X,因为它从一个 DataFrame 创建了一个矩阵X。
列表 2.4 将 DataFrame 转换为矩阵的prepare_X函数
def prepare_X(df):
df_num = df[base]
df_num = df_num.fillna(0)
X = df_num.values
return X
现在,整个训练和评估过程变得更简单,看起来像这样:
X_train = prepare_X(df_train) ❶
w_0, w = train_linear_regression(X_train, y_train) ❶
X_val = prepare_X(df_val) ❷
y_pred = w_0 + X_val.dot(w) ❷
print('validation:', rmse(y_val, y_pred)) ❸
❶ 训练模型
❷ 将模型应用于验证数据集
❸ 在验证数据上计算 RMSE
这为我们提供了一种检查任何模型调整是否会导致模型预测质量改进的方法。作为下一步,让我们添加更多特征并检查它是否得到更低的 RMSE 分数。
2.4.4 简单特征工程
我们已经有一个具有简单特征的简单基线模型。为了进一步提高我们的模型,我们可以向模型添加更多特征:我们创建其他特征并将它们添加到现有特征中。正如我们已经知道的,这个过程被称为特征工程。
由于我们已经设置了验证框架,我们可以轻松地验证添加新特征是否提高了模型的质量。我们的目标是提高在验证数据上计算的 RMSE。
首先,我们从特征“年份”创建一个新的特征,“年龄”。一辆车的年龄在预测其价格时应该非常有帮助:直观地讲,车越新,价格应该越贵。
因为数据集是在 2017 年创建的(我们可以通过检查df_train.year.max()来验证),我们可以通过从 2017 年减去汽车制造的年份来计算年龄:
df_train['age'] = 2017 - df_train.year
这个操作是一个逐元素操作。我们计算 2017 年与年序列中每个元素的差值。结果是包含差值的新 Pandas 序列,我们将它写回 dataframe 作为年龄列。
我们已经知道我们需要将相同的预处理应用两次:训练集和验证集。因为我们不希望多次重复特征提取代码,所以让我们将这个逻辑放入prepare_X函数中。
列表 2.5 在prepare_X函数中创建年龄特征
def prepare_X(df):
df = df.copy() ❶
features = base.copy() ❷
df['age'] = 2017 - df.year ❸
features.append('age') ❹
df_num = df[features]
df_num = df_num.fillna(0)
X = df_num.values
return X
❶ 创建输入参数的副本以防止副作用
❷ 创建包含基本特征的基列表的副本
❸ 计算年龄特征
❹ 将年龄添加到我们用于模型的特征名称列表中
这次我们实现函数的方式与之前的版本略有不同。让我们看看这些差异。首先,在❶中,我们创建了一个传入函数的 DataFrame df的副本。在代码的后面部分,我们通过添加额外的行来修改df ❸。这种行为被称为副作用:函数的调用者可能不会期望函数会改变 DataFrame。为了避免不愉快的惊喜,我们改而修改原始 DataFrame 的副本。在❷中,我们出于同样的原因创建了一个包含基本特征的列表的副本。稍后,我们通过添加新特征 ❹ 扩展这个列表,但我们不希望改变原始列表。其余的代码与之前相同。
让我们测试添加“年龄”特征是否会导致任何改进:
X_train = prepare_X(df_train)
w_0, w = train_linear_regression(X_train, y_train)
X_val = prepare_X(df_val)
y_pred = w_0 + X_val.dot(w)
print('validation:', rmse(y_val, y_pred))
代码打印
validation: 0.517
验证错误是 0.517,这比基线解决方案中的 0.76 有很好的改进。因此,我们得出结论,添加“年龄”确实有助于进行预测。
我们还可以查看预测值的分布:
sns.histplot(y_pred, label='prediction')
sns.histplot(y_val, label='target')
plt.legend()
我们看到(图 2.23),预测值的分布比之前更接近目标分布。确实,验证 RMSE 分数证实了这一点。

图 2.23 预测值(浅灰色)与实际值(深灰色)的分布。有了新特征,模型比之前更接近原始分布。
2.4.5 处理分类变量
我们看到添加“年龄”对模型非常有帮助。让我们继续添加更多特征。我们可以使用的下一个列之一是门数。这个变量看起来是数值的,可以取三个值:2、3 和 4 扇门。尽管直接将变量放入模型很有吸引力,但这实际上并不是一个数值变量:我们不能说增加一扇门,汽车的价格就会增长(或下降)一定的金额。相反,这个变量是分类的。
分类变量描述了对象的特征,可以取几个可能的值之一。汽车的品牌是一个分类变量;例如,它可以是丰田、宝马、福特或任何其他品牌。通过其值很容易识别分类变量,这些值通常是字符串而不是数字。然而,情况并不总是这样。例如,门数是分类的:它只能取三个可能的值(2、3 和 4)。
我们可以用多种方式在机器学习模型中使用分类变量。其中一种最简单的方法是将这样的变量通过一组二元特征进行编码,每个不同的值都有一个单独的特征。
在我们的情况下,我们将创建三个二元特征:num_doors_2、num_doors_3和num_doors_4。如果汽车有两扇门,num_doors_2将被设置为 1,其余的将被设置为 0。如果汽车有三扇门,num_doors_3将获得值 1,对num_doors_4也是如此。
这种对分类变量进行编码的方法被称为独热编码。我们将在第三章中更深入地了解这种编码分类变量的方法。现在,让我们选择最简单的方式来执行这种编码:遍历可能的值(2、3 和 4),并对每个值进行检查,看观察值的值是否与之匹配。
让我们将这些行添加到prepare_X函数中:
for v in [2, 3, 4]: ❶
feature = 'num_doors_%s' % v ❷
value = (df['number_of_doors'] == v).astype(int) ❸
df[feature] = value ❹
features.append(feature)
❶ 遍历“门数”变量的可能值
❷ 为特征赋予一个有意义的名称,例如,对于v=2使用“num_doors_2”
❸ 创建独热编码特征
❹ 使用❷中的名称将特征添加回数据框
这段代码可能难以理解,所以让我们更仔细地看看这里发生了什么。最困难的一行是❸:
(df['number_of_doors'] == v).astype(int)
这里发生了两件事。第一件事是括号内的表达式,我们使用了等于(==)运算符。这种操作也是一种元素级操作,就像我们在计算 RMSE 时之前使用的那样。在这种情况下,操作创建了一个新的 Pandas 系列。如果原始系列中的元素等于v,则结果中的对应元素为 True;否则,元素为 False。该操作创建了一个 True/False 值的系列。因为v有三个值(2、3 和 4),并且我们将此操作应用于v的每个值,因此我们创建了三个系列(图 2.24)。

图 2.24 我们使用==运算符从原始系列中创建新的系列:一个用于两扇门,一个用于三扇门,一个用于四扇门。
接下来,我们将布尔序列转换为整数,使得 True 变为 1,False 变为 0,这可以通过 astype(int) 方法轻松完成(图 2.25)。现在我们可以将结果用作特征,并将它们放入线性回归中。

图 2.25 使用 astype(int) 将具有布尔值的序列转换为整数
车门数量,正如我们讨论的,是一个分类变量,看起来像是数值变量,因为其值是整数(2、3 和 4)。数据集中我们拥有的所有其他分类变量都是字符串。
我们可以使用相同的方法来编码其他分类变量。让我们从 make 开始。为了我们的目的,获取和使用最频繁出现的值就足够了。让我们找出五个最频繁出现的值:
df['make'].value_counts().head(5)
代码打印
chevrolet 1123
ford 881
volkswagen 809
toyota 746
dodge 626
我们将这些值取出来,并像编码车门数量一样编码 make。
接下来,我们创建五个新变量,分别命名为 is_make_chevrolet、is_make_ford、is_ make_volkswagen、is_make_toyota 和 is_make_dodge:
for v in ['chevrolet', 'ford', 'volkswagen', 'toyota', 'dodge']:
feature = 'is_make_%s' % v
df[feature] = (df['make'] == v).astype(int)
features.append(feature)
现在,整个 prepare_X 应该看起来像以下这样。
列表 2.6 处理分类变量 number of doors 和 make
def prepare_X(df):
df = df.copy()
features = base.copy()
df['age'] = 2017 - df.year
features.append('age')
for v in [2, 3, 4]: ❶
feature = 'num_doors_%s' % v
df[feature] = (df['number_of_doors'] == v).astype(int)
features.append(feature)
for v in ['chevrolet', 'ford', 'volkswagen', 'toyota', 'dodge']: ❷
feature = 'is_make_%s' % v
df[feature] = (df['make'] == v).astype(int)
features.append(feature)
df_num = df[features]
df_num = df_num.fillna(0)
X = df_num.values
return X
❶ 编码车门数量变量
❷ 编码 make 变量
让我们检查这段代码是否提高了模型的 RMSE:
X_train = prepare_X(df_train)
w_0, w = train_linear_regression(X_train, y_train)
X_val = prepare_X(df_val)
y_pred = w_0 + X_val.dot(w)
print('validation:', rmse(y_val, y_pred))
代码打印
validation: 0.507
之前的值是 0.517,所以我们成功进一步提高了 RMSE 分数。
我们可以使用更多变量:engine_fuel_type、transmission_type、driven_ wheels、market_category、vehicle_size 和 vehicle_style。让我们对它们做同样的事情。修改后,prepare_X 开始看起来更复杂一些。
列表 2.7 在 prepare_X 函数中处理更多分类变量
def prepare_X(df):
df = df.copy()
features = base.copy()
df['age'] = 2017 - df.year
features.append('age')
for v in [2, 3, 4]:
feature = 'num_doors_%s' % v
df[feature] = (df['number_of_doors'] == v).astype(int)
features.append(feature)
for v in ['chevrolet', 'ford', 'volkswagen', 'toyota', 'dodge']:
feature = 'is_make_%s' % v
df[feature] = (df['make'] == v).astype(int)
features.append(feature)
for v in ['regular_unleaded', 'premium_unleaded_(required)',
'premium_unleaded_(recommended)',
'flex-fuel_(unleaded/e85)']: ❶
feature = 'is_type_%s' % v
df[feature] = (df['engine_fuel_type'] == v).astype(int)
features.append(feature)
for v in ['automatic', 'manual', 'automated_manual']: ❷
feature = 'is_transmission_%s' % v
df[feature] = (df['transmission_type'] == v).astype(int)
features.append(feature)
for v in ['front_wheel_drive', 'rear_wheel_drive',
'all_wheel_drive', 'four_wheel_drive']: ❸
feature = 'is_driven_wheels_%s' % v
df[feature] = (df['driven_wheels'] == v).astype(int)
features.append(feature)
for v in ['crossover', 'flex_fuel', 'luxury',
'luxury,performance', 'hatchback']: ❹
feature = 'is_mc_%s' % v
df[feature] = (df['market_category'] == v).astype(int)
features.append(feature)
for v in ['compact', 'midsize', 'large']: ❺
feature = 'is_size_%s' % v
df[feature] = (df['vehicle_size'] == v).astype(int)
features.append(feature)
for v in ['sedan', '4dr_suv', 'coupe', 'convertible',
'4dr_hatchback']: ❻
feature = 'is_style_%s' % v
df[feature] = (df['vehicle_style'] == v).astype(int)
features.append(feature)
df_num = df[features]
df_num = df_num.fillna(0)
X = df_num.values
return X
❶ 编码类型变量
❷ 编码变速器变量
❸ 编码驱动轮数量
❹ 编码市场类别
❺ 编码大小
❻ 编码风格
让我们测试一下:
X_train = prepare_X(df_train)
w_0, w = train_linear_regression(X_train, y_train)
X_val = prepare_X(df_val)
y_pred = w_0 + X_val.dot(w)
print('validation:', rmse(y_val, y_pred))
我们看到的结果比之前差得多。我们得到 34.2,这比之前的 0.5 多得多。
注意:你得到的结果可能因 Python 版本、NumPy 版本、NumPy 依赖项的版本、操作系统和其他因素而异。但从 0.5 到显著更大的验证指标跳跃应该总是提醒我们。
新特征不仅没有帮助,反而使分数变得更差。幸运的是,我们有验证来帮助我们发现问题。在下一节中,我们将看到为什么会发生这种情况以及如何处理它。
2.4.6 正则化
我们看到添加新特征并不总是有帮助,在我们的例子中,它使事情变得更糟。这种行为的原因是数值不稳定性。回想一下正则方程的公式:

方程中的一个项是 X^TX 矩阵的逆:

在我们的情况下,问题是矩阵的求逆。有时,当我们向 X 添加新列时,我们可能会不小心添加一个由其他列组合而成的列。例如,如果我们已经有了城市中的 MPG 特征,并决定添加城市中的每升公里数,第二个特征与第一个特征相同,但乘以一个常数。
当这种情况发生时,X^TX 变得 不定 或 奇异,这意味着无法找到这个矩阵的逆。如果我们尝试求一个奇异矩阵的逆,NumPy 将通过引发一个 LinAlgError 来告诉我们:
LinAlgError: Singular matrix
我们的代码没有引发任何异常,然而这种情况发生是因为我们通常没有那些完美线性组合其他列的列。真实数据往往很嘈杂,存在测量误差(例如,将 MPG 记录为 1.3 而不是 13),舍入误差(例如,存储 0.0999999 而不是 0.1),以及许多其他错误。从技术上讲,这样的矩阵不是奇异的,所以 NumPy 不会报错。
然而,由于这个原因,权重中的某些值变得极其大——比它们应有的值大得多。
如果我们查看我们的 w[0] 和 w 的值,我们会看到这确实如此。例如,偏差项 w[0] 的值为 5788519290303866.0(这个值可能取决于机器、操作系统和 NumPy 的版本),w 的一些分量也有极其大的负值。
在数值线性代数中,这类问题被称为 数值不稳定性问题,通常通过正则化技术来解决。正则化的目的是通过强制矩阵可逆来确保其逆的存在。正则化是机器学习中的一个重要概念:它意味着“控制”——控制模型的权重,使它们表现正确,并且不会变得过大,就像我们案例中那样。
正则化的一种方法是在矩阵的每个对角元素上添加一个很小的数。然后我们得到以下线性回归的公式:

备注:正则化线性回归通常被称为 岭回归。许多库,包括 Scikit-learn,使用 ridge 来指代正则化线性回归,而 线性回归 则指未正则化的模型。
让我们看看变化的部分:我们需要求逆的矩阵。它看起来是这样的:

这个公式说明我们需要 I——一个 单位矩阵,这是一个对角线上的元素为 1,其他地方为 0 的矩阵。我们将这个单位矩阵乘以一个数 α。这样,I 对角线上的所有 1 都变成了 α。然后我们求 α**I 和 X^TX 的和,这会将 α 添加到 X^TX 的所有对角元素上。
这个公式可以直接转换为 NumPy 代码:
XTX = X_train.T.dot(X_train)
XTX = XTX + 0.01 * np.eye(XTX.shape[0])
np.eye函数创建一个二维 NumPy 数组,它也是一个单位矩阵。当我们乘以 0.01 时,对角线上的 1 变成了 0.01,因此当我们把这个矩阵加到XTX上时,我们只向它的主对角线添加了 0.01(图 2.26)。

(A) NumPy 中的 eye 函数创建一个单位矩阵。

(B) 当我们将单位矩阵乘以一个数时,这个数会出现在结果的主对角线上。

(C) 将乘以 0.01 的单位矩阵加到另一个矩阵上的效果与将该数加到该矩阵的主对角线上的效果相同。
图 2.26 使用单位矩阵向正方形矩阵的主对角线添加 0.01
让我们创建一个新的函数,它使用这个想法并实现带正则化的线性回归。
列表 2.8 带正则化的线性回归
def train_linear_regression_reg(X, y, r=0.0): ❶
ones = np.ones(X.shape[0])
X = np.column_stack([ones, X])
XTX = X.T.dot(X)
reg = r * np.eye(XTX.shape[0]) ❷
XTX = XTX + reg ❷
XTX_inv = np.linalg.inv(XTX)
w = XTX_inv.dot(X.T).dot(y)
return w[0], w[1:]
❶ 通过使用参数 r 来控制正则化的程度
❷ 将 r 加到 XTX 的主对角线上
该函数与线性回归非常相似,但有几行是不同的。首先,有一个额外的参数r,它控制正则化的程度——这对应于我们添加到X^TX主对角线上的公式中的数α。
正则化通过使w的分量更小来影响最终解。我们可以看到,添加的正则化越多,权重就越小。
让我们检查不同r值下的权重会发生什么:
for r in [0, 0.001, 0.01, 0.1, 1, 10]:
w_0, w = train_linear_regression_reg(X_train, y_train, r=r)
print('%5s, %.2f, %.2f, %.2f' % (r, w_0, w[13], w[21]))
代码打印
0, 5788519290303866.00, -9.26, -5788519290303548.00
0.001, 7.20, -0.10, 1.81
0.01, 7.18, -0.10, 1.81
0.1, 7.05, -0.10, 1.78
1, 6.22, -0.10, 1.56
10, 4.39, -0.09, 1.08
我们从 0 开始,这是一个无正则化解,得到非常大的数字。然后我们尝试 0.001,并将它增加 10 倍:0.01、0.1、1 和 10。我们看到,随着r的增长,我们选择的值变得越小。
现在我们检查正则化是否有助于我们的问题,以及添加正则化后我们得到的 RMSE 是多少。让我们用 r=0.001 运行它:
X_train = prepare_X(df_train)
w_0, w = train_linear_regression_reg(X_train, y_train, r=0.001)
X_val = prepare_X(df_val)
y_pred = w_0 + X_val.dot(w)
print('validation:', rmse(y_val, y_pred))
代码打印
Validation: 0.460
这个结果比之前的分数有所改进:0.507。
注意:有时,当添加新功能导致性能下降时,简单地移除此功能可能就足以解决问题。拥有验证数据集对于决定是否添加正则化、移除特征或两者都做非常重要:我们使用验证数据上的分数来选择最佳选项。在我们特定的案例中,我们发现添加正则化有帮助:它提高了我们之前的分数。
我们尝试使用r=0.001,但也应该尝试其他值。让我们尝试几个不同的值来选择最佳参数r:
X_train = prepare_X(df_train)
X_val = prepare_X(df_val)
for r in [0.000001, 0.0001, 0.001, 0.01, 0.1, 1, 5, 10]:
w_0, w = train_linear_regression_reg(X_train, y_train, r=r)
y_pred = w_0 + X_val.dot(w)
print('%6s' %r, rmse(y_val, y_pred))
我们看到,较小的r可以实现最佳性能:
1e-06 0.460225
0.0001 0.460225
0.001 0.460226
0.01 0.460239
0.1 0.460370
1 0.461829
5 0.468407
10 0.475724
我们还注意到,对于小于 0.1 的值,性能变化不大,除了第六位数字,我们不应该认为它具有显著性。
让我们将r=0.01 的模型作为最终模型。现在我们可以将其与测试数据集进行比较,以验证模型是否有效:
X_train = prepare_X(df_train)
w_0, w = train_linear_regression_reg(X_train, y_train, r=0.01)
X_val = prepare_X(df_val)
y_pred = w_0 + X_val.dot(w)
print('validation:', rmse(y_val, y_pred))
X_test = prepare_X(df_test)
y_pred = w_0 + X_test.dot(w)
print('test:', rmse(y_test, y_pred))
代码打印
validation: 0.460
test: 0.457
因为这两个数字非常接近,我们得出结论,该模型可以很好地泛化到新的未见数据。
练习 2.4
正则化是必需的,因为
a) 它可以控制模型的权重,不让它们增长过大。
b) 现实世界的数据是有噪声的。
c) 我们经常遇到数值不稳定性问题。
有多个可能的答案。
2.4.7 使用模型
因为我们现在有一个模型,我们可以开始用它来预测汽车的价格。
假设一个用户在我们的网站上发布了以下广告:
ad = {
'city_mpg': 18,
'driven_wheels': 'all_wheel_drive',
'engine_cylinders': 6.0,
'engine_fuel_type': 'regular_unleaded',
'engine_hp': 268.0,
'highway_mpg': 25,
'make': 'toyota',
'market_category': 'crossover,performance',
'model': 'venza',
'number_of_doors': 4.0,
'popularity': 2031,
'transmission_type': 'automatic',
'vehicle_size': 'midsize',
'vehicle_style': 'wagon',
'year': 2013
}
我们想建议这辆车的价格。为此,我们使用我们的模型:
df_test = pd.DataFrame([ad])
X_test = prepare_X(df_test)
首先,我们创建一个包含一行的小型数据框。这一行包含我们之前创建的ad字典中的所有值。接下来,我们将这个数据框转换为矩阵。
现在我们可以将我们的模型应用于矩阵来预测这辆车的价格:
y_pred = w_0 + X_test.dot(w)
然而,这个预测不是最终价格;它是价格的对数。为了得到实际价格,我们需要取消对数并应用指数函数:
suggestion = np.expm1(y_pred)
suggestion
输出是 28,294.13。这辆车的实际价格是 $31,120,所以我们的模型离实际价格不远。
2.5 下一步
2.5.1 练习
你可以尝试以下方法来提高模型:
-
编写一个二进制编码的函数。 在本章中,我们手动实现了类别编码:我们查看前五个值,将它们写入一个列表,然后遍历列表以创建二进制特征。这样做很麻烦,这就是为什么编写一个自动执行此操作的函数是个好主意。这个函数应该有多个参数:数据框、类别变量的名称以及它应该考虑的最频繁值数量。这个函数还应该帮助我们完成之前的练习。
-
尝试更多的特征工程。 在实现类别编码时,我们只包括了每个类别变量前五个值。在编码过程中包括更多值可能会提高模型。尝试这样做,并重新评估模型的 RMSE 质量。
2.5.2 其他项目
现在你可以做其他一些项目:
-
预测房屋的价格。你可以从
www.kaggle.com/dgomonov/new-york-city-airbnb-open-data获取纽约市 Airbnb 开放数据集,或者从scikit-learn.org/stable/modules/ generated/sklearn.datasets.fetch_california_housing.html获取加利福尼亚住房数据集。 -
检查其他数据集,例如
archive.ics.uci.edu/ml/datasets.php?task =reg,它们有数值目标值。例如,我们可以使用学生表现数据集(archive.ics.uci.edu/ml/datasets/ Student+Performance)中的数据来训练一个模型,以确定学生的表现。
摘要
-
进行简单的初步探索性分析非常重要。其中之一是帮助我们找出数据是否有缺失值。当存在缺失值时,无法训练线性回归模型,因此检查我们的数据并在必要时填充缺失值非常重要。
-
作为探索性数据分析的一部分,我们需要检查目标变量的分布。如果目标分布有一个长尾,我们需要应用对数变换。没有它,我们可能会从线性回归模型中得到不准确和误导性的预测。
-
训练/验证/测试数据划分是检查我们模型的最佳方式。它为我们提供了一种可靠地衡量模型性能的方法,并且像数值不稳定性等问题不会被人忽视。
-
线性回归模型基于一个简单的数学公式,理解这个公式是成功应用模型的关键。了解这些细节有助于我们在编码之前了解模型的工作原理。
-
使用 Python 和 NumPy 从头实现线性回归并不困难。这样做有助于我们理解机器学习背后没有魔法:它只是简单的数学转换成代码。
-
RMSE 为我们提供了一种衡量模型在验证集上的预测性能的方法。它让我们确认模型是好的,并帮助我们比较多个模型以找到最佳模型。
-
特征工程是创建新特征的过程。添加新特征对于提高模型性能很重要。在添加新特征时,我们始终需要使用验证集来确保我们的模型确实得到了改进。没有持续的监控,我们可能会得到平庸或非常糟糕的性能。
-
有时,我们会遇到可以通过正则化解决的问题数值不稳定性问题。有一个好的方法来验证模型对于在问题变得太晚之前发现问题至关重要。
-
模型训练和验证后,我们可以用它来做出预测,例如将其应用于价格未知的汽车,以估计它们可能的价值。
在第三章中,我们将学习如何使用机器学习进行分类,使用逻辑回归来预测客户流失。
练习答案
-
练习 2.1 B) 值分布远离头部。
-
练习 2.2 A) x[i] 是一个特征向量,y[i] 是价格的对数。
-
练习 2.3 B) 一个包含价格预测的向量 y。
-
练习 2.4 A),B),C)所有三个答案都是正确的。
3 机器学习用于分类
本章涵盖
-
执行探索性数据分析以识别重要特征
-
将分类变量编码以在机器学习模型中使用
-
使用逻辑回归进行分类
在本章中,我们将使用机器学习来预测客户流失。
客户流失是指客户停止使用公司的服务。因此,客户流失预测就是识别那些可能很快取消合同的客户。如果公司能够做到这一点,它就可以通过提供折扣来努力留住用户。
自然地,我们可以使用机器学习来做到这一点:我们可以使用关于流失客户的过去数据,并根据这些数据创建一个模型来识别即将离开的当前客户。这是一个二元分类问题。我们想要预测的目标变量是分类变量,并且只有两种可能的结果:流失或未流失。
在第一章中,我们了解到存在许多监督机器学习模型,我们特别提到了可以用于二元分类的模型,包括逻辑回归、决策树和神经网络。在本章中,我们从最简单的一个开始:逻辑回归。尽管它确实是最简单的,但它仍然非常强大,并且与其他模型相比具有许多优势:它速度快,易于理解,其结果易于解释。它是机器学习中的工作马,也是工业界最广泛使用的模型。
3.1 客户流失预测项目
我们为本章准备的项目是电信公司的客户流失预测。我们将使用逻辑回归和 Scikit-learn 来完成这个项目。
想象一下,我们正在一家提供电话和互联网服务的电信公司工作,我们遇到了一个问题:一些客户开始流失。他们不再使用我们的服务,转而去了其他供应商。我们希望阻止这种情况发生,因此我们开发了一个系统来识别这些客户,并给予他们留存的激励。我们希望通过促销信息来吸引他们,并给予他们折扣。我们还希望了解模型为什么认为我们的客户会流失,为此,我们需要能够解释模型的预测结果。
我们收集了一个数据集,其中记录了一些关于我们客户的信息:他们使用了什么类型的服务,他们支付了多少钱,以及他们与我们在一起的时间有多长。我们还知道谁取消了他们的合同并停止使用我们的服务(流失)。我们将使用这些信息作为机器学习模型的目标变量,并使用所有其他可用信息来预测它。
项目的计划如下:
-
首先,我们下载数据集并进行一些初步准备:重命名列并更改列内的值,以确保整个数据集的一致性。
-
然后我们将数据分为训练集、验证集和测试集,以便我们可以验证我们的模型。
-
作为初步数据分析的一部分,我们查看特征重要性以确定哪些特征在我们的数据中很重要。
-
我们将分类变量转换为数值变量,以便在模型中使用它们。
-
最后,我们训练一个逻辑回归模型。
在上一章中,我们完全是自己实现的,使用了 Python 和 NumPy。然而,在这个项目中,我们将开始使用 Scikit-learn,这是一个用于机器学习的 Python 库。具体来说,我们将用它来
-
将数据集分为训练集和测试集
-
对分类变量进行编码
-
训练逻辑回归
3.1.1 Telco churn 数据集
如前一章所述,我们将使用 Kaggle 数据集进行数据。这次我们将使用来自 www.kaggle.com/blastchar/telco-customer-churn 的数据。
根据描述,这个数据集包含以下信息:
-
客户服务:电话;多线;互联网;技术支持和额外服务,如在线安全、备份、设备保护和电视流媒体
-
账户信息:他们作为客户的时间长度、合同类型、支付方式类型
-
费用:客户在过去一个月和总计中被收取的费用
-
人口统计信息:性别、年龄以及他们是否有子女或伴侣
-
演退:是/否,客户在过去一个月内是否离开了公司
首先,我们下载数据集。为了保持整洁,我们首先创建一个文件夹,chapter-03-churn-prediction。然后我们进入那个目录,并使用 Kaggle CLI 下载数据:
kaggle datasets download -d blastchar/telco-customer-churn
下载后,我们解压缩存档以从那里获取 CSV 文件:
unzip telco-customer-churn.zip
我们现在可以开始了。
3.1.2 初始数据准备
第一步是创建一个新的 Jupyter 笔记本。如果它没有运行,请启动它:
jupyter notebook
我们将笔记本章节命名为 chapter-03-churn-project(或我们喜欢的任何其他名称)。
如前所述,我们首先添加常用的导入:
import pandas as pd
import numpy as np
import seaborn as sns
from matplotlib import pyplot as plt
%matplotlib inline
现在我们可以读取数据集:
df = pd.read_csv('WA_Fn-UseC_-Telco-Customer-Churn.csv')
我们使用 read_csv 函数读取数据,然后将结果写入名为 df 的数据框。为了查看它包含多少行,让我们使用 len 函数:
len(df)
它打印出 7043,所以这个数据集有 7,043 行。数据集不大,但应该足以训练一个不错的模型。
接下来,让我们使用 df.head() 查看前几行(图 3.1)。默认情况下,它显示数据框的前五行。

图 3.1 df.head() 命令的输出,显示了 telco churn 数据集的前五行
这个数据框有相当多的列,所以它们都不适合在屏幕上显示。相反,我们可以使用 T 函数转置数据框,交换列和行,使得列(如 customerID、性别等)变成行。这样我们就可以看到更多的数据(图 3.2):
df.head().T

图 3.2 df.head().T 命令的输出,显示了电信客户流失数据集的前三行。原始行以列的形式显示:这样,可以查看更多数据,而无需使用滑块。
我们看到数据集有几个列:
-
CustomerID: 客户 ID
-
Gender: 男性/女性
-
SeniorCitizen: 客户是否为老年人(0/1)
-
Partner: 是否与伴侣同住(是/否)
-
Dependents: 是否有受抚养人(是/否)
-
Tenure: 自合同开始以来的月份数
-
PhoneService: 是否有电话服务(是/否)
-
MultipleLines: 是否有多个电话线路(是/否/无电话服务)
-
InternetService: 互联网服务类型(无/光纤/光纤)
-
OnlineSecurity: 如果启用了在线安全(是/否/无互联网)
-
OnlineBackup: 如果启用了在线备份服务(是/否/无互联网)
-
DeviceProtection: 如果启用了设备保护服务(是/否/无互联网)
-
TechSupport: 客户是否有技术支持(是/否/无互联网)
-
StreamingTV: 如果启用了电视流媒体服务(是/否/无互联网)
-
StreamingMovies: 如果启用了电影流媒体服务(是/否/无互联网)
-
Contract: 合同类型(每月/年度/两年)
-
PaperlessBilling: 如果账单是无纸化的(是/否)
-
PaymentMethod: 付款方式(电子支票、邮寄支票、银行转账、信用卡)
-
MonthlyCharges: 每月收费金额(数值)
-
TotalCharges: 总计收费金额(数值)
-
Churn: 如果客户已取消合同(是/否)
对我们来说最有趣的是 Churn。作为我们模型的目标变量,这是我们想要学习预测的内容。它有两个值:如果客户流失则为“是”,如果客户未流失则为“否”。
当读取 CSV 文件时,Pandas 会尝试自动确定每列的正确类型。然而,有时很难正确完成这项任务,推断出的类型并不是我们期望的。这就是为什么检查实际类型是否正确很重要的原因。让我们通过使用df.dtypes来查看它们:
df.dtypes
我们看到(图 3.3)大多数类型都被正确推断。回想一下,对象表示字符串值,这是我们期望大多数列的内容。然而,我们可能注意到两件事。首先,SeniorCitizen 被检测为 int64,因此它具有整型类型,而不是对象。原因是,与其他列中的 yes 和 no 值不同,这里有的是 1 和 0 值,因此 Pandas 将其解释为整型列。这对我们来说并不是真正的问题,因此我们不需要为此列进行任何额外的预处理。

图 3.3 自动推断的数据框所有列的类型。对象表示字符串。TotalCharges 被错误地识别为“对象”,但它应该是“浮点数”。
另一点需要注意的是 TotalCharges 的类型。我们预计此列应该是数值类型:它包含客户被收取的总金额,因此应该是数字,而不是字符串。然而,Pandas 推断其类型为“对象”。原因是,在某些情况下,此列包含一个空格(“ ”)来表示缺失值。当遇到非数值字符时,Pandas 没有其他选择,只能将该列声明为“对象”。
重要提示:注意当你期望一个列是数值类型,但 Pandas 表示它不是的情况:最可能的原因是该列包含用于缺失值的特殊编码,这需要额外的预处理。
我们可以通过使用 Pandas 中的特殊函数 to_numeric 将此列强制转换为数值类型:通过将列转换为数字。默认情况下,此函数在遇到非数值数据(如空格)时会引发异常,但我们可以通过指定 errors='coerce' 选项来使其跳过这些情况。这样 Pandas 将所有非数值值替换为 NaN(不是一个数字):
total_charges = pd.to_numeric(df.TotalCharges, errors='coerce')
为了确认数据确实包含非数值字符,我们现在可以使用 total_charges 的 isnull() 函数来引用 Pandas 无法解析原始字符串的所有行:
df[total_charges.isnull()][['customerID', 'TotalCharges']]
我们可以看到 TotalCharges 列中确实存在空格(图 3.4)。

图 3.4 我们可以通过将内容解析为数值来检测列中的非数值数据,并查看解析失败的行。
现在取决于我们决定如何处理这些缺失值。尽管我们可以对它们做很多事情,但我们将做与上一章相同的事情——将缺失值设置为零:
df.TotalCharges = pd.to_numeric(df.TotalCharges, errors='coerce')
df.TotalCharges = df.TotalCharges.fillna(0)
此外,我们注意到列名不遵循相同的命名约定。其中一些以小写字母开头,而其他一些以大写字母开头,值中也有空格。
让我们通过将所有内容转换为小写并替换空格为下划线来使其统一。这样我们就消除了数据中的所有不一致性。我们使用与上一章完全相同的代码:
df.columns = df.columns.str.lower().str.replace(' ', '_')
string_columns = list(df.dtypes[df.dtypes == 'object'].index)
for col in string_columns:
df[col] = df[col].str.lower().str.replace(' ', '_')
接下来,让我们看看我们的目标变量:churn。目前,它是分类的,有两个值,“是”和“否”(图 3.5A)。对于二元分类,所有模型通常都期望一个数字:0 代表“否”,1 代表“是”。让我们将其转换为数字:
df.churn = (df.churn == 'yes').astype(int)
当我们使用 df.churn == 'yes' 时,我们创建了一个布尔类型的 Pandas 系列对象。如果原始系列中的位置是“是”,则该系列的位置等于 True,否则为 False。因为该系列只能取“否”这个值,所以“是”转换为 True,“否”转换为 False(图 3.5B)。当我们使用 astype(int) 函数进行类型转换时,将 True 转换为 1,将 False 转换为 0(图 3.5C)。这正是我们在上一章实现类别编码时使用的相同思路。

(A)原始的 Churn 列:它是一个只包含“是”和“否”值的 Pandas 系列对象。

(B)比较运算符的结果:它是一个布尔序列,当原始序列的元素是“yes”时为 True,否则为 False。

(C)将布尔序列转换为整数的结果:True 转换为 1,False 转换为 0。
图 3.5 表达式 (df.churn == 'yes').astype(int) 的逐个步骤分解
我们已经做了一些预处理,所以让我们留出一部分数据用于测试。在上一章中,我们实现了自己进行这一操作的代码。这对于理解其工作原理是很好的,但通常我们不需要每次都从头开始编写这些代码。相反,我们使用库中的现有实现。在本章中,我们使用 Scikit-learn,它有一个名为 model_selection 的模块可以处理数据分割。让我们使用它。
我们需要从 model_selection 中导入的函数叫做 train_test_split:
from sklearn.model_selection import train_test_split
导入后,就可以使用了:
df_train_full, df_test = train_test_split(df, test_size=0.2, random_state=1)
train_test_split 函数接受一个数据框 df 并创建两个新的数据框:df_train_full 和 df_test。它是通过打乱原始数据集然后以这种方式分割数据来实现的,即测试集包含 20%的数据,训练集包含剩余的 80%(图 3.6)。在内部,它的实现与我们上一章自己实现的方式相似。

图 3.6 当使用 train_test_split 时,原始数据集被打乱,然后分割,使得 80%的数据进入训练集,剩余的 20%进入测试集。
此函数包含几个参数:
-
我们传递的第一个参数是我们想要分割的数据框:
df。 -
第二个参数是
test_size,它指定了我们想要留出的测试数据集的大小——在我们的例子中是 20%。 -
我们传递的第三个参数是
random_state。它对于确保每次我们运行此代码时,数据框的分割方式完全相同是必要的。
数据的打乱是通过随机数生成器完成的;固定随机种子对于确保每次我们打乱数据时,行的最终排列都是相同的非常重要。
我们确实看到了打乱带来的副作用:如果我们使用 head() 方法查看分割后的数据框,例如,我们会注意到索引似乎是无序的(图 3.7)。

图 3.7 train_test_split 的副作用:新数据框中的索引(第一列)被随机打乱,因此不再是连续的数字,如 0、1、2、...,而是看起来是随机的。
在上一章中,我们将数据分为三个部分:训练集、验证集和测试集。然而,train_test_split 函数只将数据分为两个部分:训练集和测试集。尽管如此,我们仍然可以将原始数据集分为三个部分;我们只需取出一部分并再次分割(图 3.8)。

图 3.8 由于train_test_split将数据集仅分为两部分,我们需要三部分,因此我们进行了两次分割。首先,我们将整个数据集分为完整的训练集和测试集,然后我们将完整的训练集分为训练集和验证集。
让我们再次将df_train_full数据框分割成训练集和验证集:
df_train, df_val = train_test_split(df_train_full, test_size=0.33, random_state=11) ❶
y_train = df_train.churn.values ❷
y_val = df_val.churn.values ❷
del df_train['churn'] ❸
del df_val['churn'] ❸
❶ 在分割时设置随机种子,以确保每次运行代码时结果都是相同的
❷ 将包含目标变量 churn 的列保存到数据框外部
❸ 从两个数据框中删除 churn 列,以确保我们不会在训练过程中意外使用 churn 变量作为特征
现在数据框已经准备好了,我们可以使用训练数据集进行初步的探索性数据分析。
3.1.3 探索性数据分析
在训练模型之前查看数据非常重要。我们了解的数据和问题越多,之后构建的模型就越好。
我们应该始终检查数据集中是否存在任何缺失值,因为许多机器学习模型难以处理缺失数据。我们已经发现了 TotalCharges 列的问题,并用零替换了缺失值。现在让我们看看是否需要执行任何额外的空值处理:
df_train_full.isnull().sum()
它打印所有零(图 3.9),因此数据集中没有缺失值,不需要做任何额外的事情。

图 3.9 我们不需要处理数据集中的缺失值:所有列中的所有值都是存在的。
我们还应该做的一件事是检查目标变量中值的分布。让我们使用value_counts()方法来看看:
df_train_full.churn.value_counts()
它打印
0 4113
1 1521
第一列是目标变量的值,第二列是计数。正如我们所见,大多数客户没有流失。
我们知道绝对数字,但让我们也检查所有客户中流失用户的比例。为此,我们需要将流失客户数除以客户总数。我们知道 5,634 中有 1,521 人流失,所以比例是
1521 / 5634 = 0.27
这给出了流失用户的比例,或者客户流失的概率。正如我们在训练数据集中看到的那样,大约 27%的客户停止使用我们的服务,其余的继续作为客户。
流失用户的比例,或流失的概率,有一个特殊的名称:流失率。
计算流失率的另一种方法是mean()方法。它比手动计算率更方便:
global_mean = df_train_full.churn.mean()
使用这种方法,我们也得到了 0.27(图 3.10)。

图 3.10 计算训练数据集中的全局流失率
它产生相同结果的原因是我们计算平均值的方式。如果您不记得,该公式的计算方法是

其中 n 是数据集中的项目数。
因为 y[i] 只能取零和一,当我们把它们全部加起来时,我们得到的是一的数量,或者说是转出的人数。然后我们将其除以总客户数,这与我们之前计算转出率的公式完全相同。
我们的转出数据集是一个所谓的不平衡数据集的例子。在我们的数据集中,没有转出的人数是转出人数的三倍,我们说非转出类别主导了转出类别。我们可以清楚地看到:我们的数据中的转出率是 0.27,这是类别不平衡的强烈指标。与不平衡相反的是平衡的情况,即正负类别在所有观测值中均匀分布。
练习 3.1
布尔数组的平均值是
a) 数组中False元素的比例:False元素的数量除以数组的长度
b) 数组中True元素的比例:True元素的数量除以数组的长度
c) 数组的长度
我们数据集中的分类变量和数值变量都很重要,但它们也不同,需要不同的处理。为此,我们希望单独查看它们。
我们将创建两个列表:
-
分类,它将包含分类变量的名称 -
数值,同样,它将包含数值变量的名称
让我们创建它们:
categorical = ['gender', 'seniorcitizen', 'partner', 'dependents',
'phoneservice', 'multiplelines', 'internetservice',
'onlinesecurity', 'onlinebackup', 'deviceprotection',
'techsupport', 'streamingtv', 'streamingmovies',
'contract', 'paperlessbilling', 'paymentmethod']
numerical = ['tenure', 'monthlycharges', 'totalcharges']
首先,我们可以看到每个变量有多少个唯一值。我们已经知道每个列应该只有几个,但让我们验证一下:
df_train_full[categorical].nunique()
事实上,我们看到大多数列都有两到三个值,而一个(paymentmethod)有四个(图 3.11)。这是好的。我们不需要额外的时间来准备和清理数据;一切都已经准备好了。

图 3.11 每个分类变量的不同值数量。我们看到所有变量都有非常少的唯一值。
现在我们来到了探索性数据分析的另一个重要部分:了解哪些特征可能对我们的模型很重要。
3.1.4 特征重要性
了解其他变量如何影响目标变量,即转出,是理解数据和构建好模型的关键。这个过程被称为特征重要性分析,它通常作为探索性数据分析的一部分来进行,以确定哪些变量对模型有用。它还为我们提供了关于数据集的额外见解,并帮助回答像“是什么导致客户转出?”和“转出的人有什么特征?”等问题。
我们有两种不同类型的特征:分类和数值。每种类型都有不同的测量特征重要性的方法,因此我们将分别查看每种。
转出率
让我们从查看分类变量开始。我们可以做的第一件事是查看每个变量的转出率。我们知道分类变量可以取一组值,每个值在数据集中定义一个组。
我们可以查看一个变量的所有不同值。然后,对于每个变量,都有一个客户群体:所有具有该值的客户。对于这样的每个群体,我们可以计算客户流失率,即群体客户流失率。当我们得到这个值时,我们可以将其与全局客户流失率进行比较——即一次性计算所有观察值的客户流失率。
如果两者之间的差异很小,那么在预测客户流失时,这个值并不重要,因为这个客户群体与其他客户并没有真正的区别。另一方面,如果差异很大,那么这个群体内部存在某些因素使其与其他客户群体区分开来。机器学习算法应该能够捕捉到这一点并在预测时使用它。
让我们先检查gender变量。这个gender变量可以取两个值,女性和男性。有两个客户群体:gender == 'female' 的客户群体和 gender == 'male' 的客户群体(图 3.12)。为了计算所有女性客户的客户流失率,我们首先只选择对应 gender == 'female' 的行,然后计算他们的客户流失率:
female_mean = df_train_full[df_train_full.gender == 'female'].churn.mean()

图 3.12 根据性别变量的值将数据框分为两组:一组是gender == "female" 的组,另一组是gender == "male" 的组。
然后我们对所有男性客户做同样的处理:
male_mean = df_train_full[df_train_full.gender == 'male'].churn.mean()
当我们执行此代码并检查结果时,我们看到女性客户的客户流失率是 27.7%,男性客户的客户流失率是 26.3%,而全局客户流失率是 27%(图 3.13)。女性和男性群体流失率之间的差异相当小,这表明了解客户的性别并不能帮助我们确定他们是否会流失。

图 3.13 全球客户流失率与男性和女性客户流失率的比较。这些数字非常接近,这意味着在预测客户流失时,性别不是一个有用的变量。
现在让我们看看另一个变量:partner。它取值为“是”和“否”,因此有两组客户:partner == 'yes' 的客户和 partner == 'no' 的客户。
我们可以使用之前使用的相同代码来检查分组客户流失率。我们只需要更改过滤条件:
partner_yes = df_train_full[df_train_full.partner == 'yes'].churn.mean()
partner_no = df_train_full[df_train_full.partner == 'no'].churn.mean()
如我们所见,有伴侣的人的流失率与没有伴侣的人的流失率相当不同:分别是 20%和 33%。这意味着没有伴侣的客户比有伴侣的客户更有可能流失(图 3.14)。

图 3.14 有伴侣的人的客户流失率显著低于没有伴侣的人的客户流失率——分别是 20.5%和 33%——这表明partner变量对预测客户流失是有用的。
风险比
除了观察组别比率与全球比率之间的差异之外,观察它们之间的比率也很有趣。在统计学中,不同组别概率之间的比率称为风险比,其中风险指的是出现效果的风险。在我们的案例中,效果是客户流失,因此是客户流失的风险:
风险 = 组别比率 / 全球比率
例如,对于性别等于女性的情况,客户流失的风险是 1.02:
风险 = 27.7% / 27% = 1.02
风险是一个介于零和无穷大之间的数值。它有一个很好的解释,告诉你组别元素出现效果(客户流失)的可能性与整个群体相比如何。
如果组别比率与全球比率之间的差异很小,风险值接近 1:这个组别的风险水平与整个人群相同。该组别的客户流失的可能性与其他人一样。换句话说,风险值接近 1 的组别根本不具风险(图 3.15,组 A)。
如果风险值低于 1,那么这个组别的风险较低:这个组别的客户流失率低于全球流失率。例如,值 0.5 意味着这个组别的客户流失的可能性是普通客户的一半(图 3.15,组 B)。
另一方面,如果这个值高于 1,那么这个组别是有风险的:组别中的客户流失率高于整个人群。因此,风险值为 2 意味着该组别的客户流失的可能性是整个人群的两倍(图 3.15,组 C)。

图 3.15 不同组别的客户流失率与全球客户流失率比较。在组别(A)中,比率大致相同,因此客户流失的风险约为 1。在组别(B)中,组别客户流失率低于全球比率,因此风险约为 0.5。最后,在组别(C)中,组别客户流失率高于全球比率,因此风险接近 2。
术语风险最初来自对照试验,其中一组患者接受治疗(药物),而另一组则不接受(仅给予安慰剂)。然后我们通过计算每个组别负面结果的发生率,然后计算比率来比较药物的有效性:
风险 = 组别 1 的负面结果发生率 / 组别 2 的负面结果发生率
如果药物证明是有效的,它被认为可以降低出现负面结果的几率,且风险值小于 1。
让我们计算性别和伴侣的风险。对于性别变量,男性和女性的风险值都大约为 1,因为两组的比率与全球比率没有显著差异。不出所料,对于伴侣变量则不同;没有伴侣的风险更高(表 3.1)。
表 3.1 性别和伴侣变量的流失率和风险。女性和男性的流失率与全局流失率没有显著差异,因此他们流失的风险较低:两者的风险值都在 1 左右。另一方面,没有伴侣的人的流失率显著高于平均水平,使他们变得有风险,风险值为 1.22。有伴侣的人流失较少,因此他们的风险仅为 0.75。
| 变量 | 值 | 流失率 | 风险 |
|---|---|---|---|
| 性别 | 女性 | 27.7% | 1.02 |
| 男性 | 26.3% | 0.97 | |
| 伴侣 | 是 | 20.5% | 0.75 |
| 无 | 33% | 1.22 |
我们只从两个变量中做了这个。现在让我们对所有分类变量做同样的事情。为了做到这一点,我们需要一段代码来检查变量具有的所有值,并为这些值中的每个计算流失率。
如果我们使用 SQL,这将非常简单。对于性别,我们需要做类似这样的事情:
SELECT
gender, AVG(churn),
AVG(churn) - global_churn,
AVG(churn) / global_churn
FROM
data
GROUP BY
gender
这是对 Pandas 的一个粗略翻译:
global_mean = df_train_full.churn.mean()
df_group = df_train_full.groupby(by='gender').churn.agg(['mean']) ❶
df_group['diff'] = df_group['mean'] - global_mean ❷
df_group['risk'] = df_group['mean'] / global_mean ❸
df_group
❶ 计算 AVG(churn)
❷ 计算组流失率与全局流失率之间的差异
❸ 计算客户流失风险
在❶中,我们计算了AVG(churn)部分。为此,我们使用agg函数来表示我们需要将数据聚合到每个组的一个值中:平均值。在❷中,我们创建了一个名为 diff 的新列,我们将在这里保存组平均值与全局平均值之间的差异。同样,在❸中,我们创建了一个名为 risk 的列,我们在这里计算组平均值与全局平均值之间的比例。
我们可以在图 3.16 中看到结果。

图 3.16 性别变量的客户流失率。我们看到,对于两个值,组客户流失率与全局客户流失率之间的差异并不大。
现在我们对所有分类变量做同样的事情。我们可以遍历它们,并对每个变量应用相同的代码:
from IPython.display import display
for col in categorical: ❶
df_group = df_train_full.groupby(by=col).churn.agg(['mean']) ❷
df_group['diff'] = df_group['mean'] - global_mean
df_group['rate'] = df_group['mean'] / global_mean
display(df_group) ❸
❶ 遍历所有分类变量
❷ 对每个分类变量执行分组操作
❸ 显示结果数据框
这段代码中有两点不同。首先,我们不是手动指定列名,而是遍历所有分类变量。
第二个不同之处更为微妙:我们需要调用display函数来在循环中渲染数据框。我们通常显示数据框的方式是将它作为 Jupyter Notebook 单元格中的最后一行,然后执行该单元格。如果我们这样做,数据框将作为单元格的输出显示。这正是我们在本章开头(图 3.1)看到数据框内容的方式。然而,我们无法在循环中这样做。为了仍然能够看到数据框的内容,我们显式地调用display函数。
从结果(图 3.17)中我们了解到
-
对于性别,男性和女性之间没有太大差异。两组的平均值大致相同,两组的风险值都接近 1。
-
老年人比非老年人更容易流失:老年人的流失风险为 1.53,非老年人的流失风险为 0.89。
-
有伴侣的人比没有伴侣的人流失率低。风险分别是 0.75 和 1.22。
-
使用电话服务的人不会面临流失风险:风险接近 1,与全球流失率几乎没有差异。不使用电话服务的人流失的可能性更低:风险低于 1,与全球流失率的差异为负。

(A) 流失比率和风险:gender

(B) 流失比率和风险:seniorcitizen

(C) 流失比率和风险:partner

(D) 流失比率和风险:phoneservice
图 3.17 四个分类变量的流失率差异和风险:gender、seniorcitizen、partner和phoneservice
一些变量有相当显著的区别(图 3.18):
-
没有技术支持的客户比有技术支持的客户更容易流失。
-
每月签订合同的客户取消合同的情况比其他人多,而两年合同的客户很少流失。

(A) 流失比率和风险:techsupport

(B) 流失比率和风险:contract
图 3.18 techsupport和contract组流失率与全球流失率之间的差异。没有技术支持和按月签订合同的客户比其他组的客户流失得多,而有技术支持和两年合同的客户是风险很低的客户。
这样,仅通过观察差异和风险,我们就可以识别出最具判别性的特征:有助于检测流失的特征。因此,我们预计这些特征将对我们未来的模型有用。
互信息
我们刚才探讨的差异对我们的分析有用,对于理解数据也很重要,但很难用它们来说明最重要的特征是什么,以及技术支持是否比合同类型更有用。
幸运的是,重要性指标可以帮助我们:我们可以测量一个分类变量与目标变量之间的依赖程度。如果两个变量是相关的,知道一个变量的值会给我们关于另一个变量的某些信息。另一方面,如果一个变量与目标变量完全独立,那么它就没有用,可以从数据集中安全地移除。
在我们的案例中,知道客户有按月签订的合同可能表明这位客户更有可能流失。
与其他类型的合同相比,每月签订合同的客户流失率往往要高得多。这正是我们想要在数据中找到的那种关系。如果没有这样的关系在数据中,机器学习模型将无法工作——它们将无法做出预测。依赖度越高,特征就越有用。
对于分类变量,这样的度量之一是互信息,它告诉我们如果我们知道另一个变量的值,我们将了解多少关于一个变量的信息。这是一个来自信息理论的概念,在机器学习中,我们经常用它来衡量两个变量之间的相互依赖性。
互信息值越高,表示依赖度越高:如果一个分类变量与目标之间的互信息高,那么这个分类变量将非常有助于预测目标。另一方面,如果互信息低,分类变量与目标独立,因此该变量将不会对预测目标有用。
互信息已经在 Scikit-learn 的metrics包中的mutual_info_score函数中实现,因此我们可以直接使用它:
from sklearn.metrics import mutual_info_score
def calculate_mi(series): ❶
return mutual_info_score(series, df_train_full.churn) ❷
df_mi = df_train_full[categorical].apply(calculate_mi) ❸
df_mi = df_mi.sort_values(ascending=False).to_frame(name='MI') ❹
df_mi
❶ 创建一个用于计算互信息的独立函数
❷ 使用 Scikit-learn 中的mutual_info_score函数
❸ 将❶中的函数应用于数据集的每一列分类变量
❹ 对结果值进行排序
在❸中,我们使用apply方法将我们在❶中定义的calculate_mi函数应用于df_train_full数据框的每一列。因为我们包括了一个额外的步骤,即仅选择分类变量,所以它只应用于它们。我们在❶中定义的函数只接受一个参数:series。这是一个来自数据框的列,我们在其上调用了apply()方法。在❷中,我们计算序列与目标变量churn之间的互信息得分。输出是一个单一数字,因此apply()方法的输出是一个 Pandas 序列。最后,我们根据互信息得分对序列的元素进行排序,并将序列转换为数据框。这样,结果在 Jupyter 中显示得很好。
正如我们所见,contract、onlinesecurity和techsupport是最重要的特征之一(图 3.19)。事实上,我们之前已经指出contract和techsupport非常有信息量。gender是重要性最低的特征之一,因此我们不应该期望它对模型有用。

(A) 根据互信息得分,最有用的特征。

(B) 根据互信息得分,最无用的特征。
图 3.19 分类变量与目标变量之间的互信息。值越高越好。根据它,contract是最有用的变量,而gender是最无用的。
相关系数
互信息是一种量化两个分类变量之间依赖程度的度量方法,但当其中一个特征是数值时,它不起作用,因此我们不能将其应用于我们拥有的三个数值变量。
然而,我们可以测量二进制目标变量和数值变量之间的依赖性。我们可以假设二进制变量是数值的(只包含数字零和一),然后使用统计学中的经典方法来检查这些变量之间是否存在任何依赖性。
其中一种方法是相关系数(有时称为皮尔逊相关系数)。它是一个从-1 到 1 的值:
-
正相关意味着当一个变量上升时,另一个变量也倾向于上升。在二进制目标的情况下,当变量的值较高时,我们更经常看到一,而不是零。但当变量的值较低时,零的出现频率高于一。
-
零相关意味着两个变量之间没有关系:它们是完全独立的。
-
负相关发生在当一个变量上升而另一个变量下降时。在二进制情况下,如果值较高,目标变量中零的个数多于一的个数。当值较低时,一的个数多于零的个数。
在 Pandas 中计算相关系数非常容易:
df_train_full[numerical].corrwith(df_train_full.churn)
我们在图 3.20 中看到了结果:
-
tenure与流失率之间的相关系数为-0.35:它有一个负号,这意味着客户待的时间越长,他们流失的频率就越低。对于服务期在两个月或更短的客户,流失率为 60%;对于服务期在 3 到 12 个月之间的客户,流失率为 40%;而对于服务期超过一年的客户,流失率为 17%。因此,服务期的值越高,流失率就越小(图 3.21A)。 -
monthlycharges的正相关系数为 0.19,这意味着支付更多的客户倾向于更频繁地离开。只有 8%的每月支付少于 20 美元的客户流失;支付介于 21 美元和 50 美元之间的客户流失频率更高,流失率为 18%;而支付超过 50 美元的人中有 32%流失(图 3.21B)。 -
totalcharges存在负相关,这是有道理的:人们在公司待的时间越长,支付的总金额就越多,因此他们离职的可能性就越小。在这种情况下,我们预计会出现与tenure相似的图案。对于较小的值,流失率较高;对于较大的值,流失率较低。

图 3.20 数值变量与流失率的相关性。tenure有高度的负相关:随着服务期的增长,流失率下降。monthlycharges有正相关:客户支付越多,他们流失的可能性就越大。
在进行初步的探索性数据分析、识别重要特征并对问题有所了解之后,我们准备进行下一步:特征工程和模型训练。

(A) 不同tenure值下的客户流失率。相关系数为负,因此趋势是下降的:对于更高的tenure值,客户流失率较小。

(B) 不同monthlycharges值下的客户流失率。相关系数为正,因此趋势是上升的:对于更高的monthlycharges值,客户流失率更高。
图 3.21 tenure(-0.35 的负相关性)和monthlycharges(0.19 的正相关性)的客户流失率
3.2 特征工程
我们初步查看数据并确定了可能对模型有用的信息。在这样做之后,我们清楚地理解了其他变量如何影响客户流失——我们的目标。
在我们继续进行训练之前,然而,我们需要执行特征工程步骤:将所有分类变量转换为数值特征。我们将在下一节中这样做,之后我们就可以准备训练逻辑回归模型了。
3.2.1 分类变量的独热编码
正如我们在第一章中已经知道的,我们不能直接将分类变量放入机器学习模型中。模型只能处理矩阵中的数字。因此,我们需要将我们的分类数据转换为矩阵形式,或者进行编码。
其中一种编码技术是独热编码。我们已经在上一章中看到了这种编码技术,当时我们在创建汽车品牌和其他分类变量的特征时使用了它。在那里,我们只是简要地提到了它,并且以非常简单的方式使用了它。在本章中,我们将花更多的时间来理解和使用它。
如果一个变量contract有(月度、年度和两年期)可能的值,我们可以用一个客户拥有年度合同来表示为(0, 1, 0)。在这种情况下,年度值是激活的,或热,因此得到 1,而其他值不是激活的,或冷,因此它们是 0。
为了更好地理解这一点,让我们考虑一个有两个分类变量的案例,并看看我们如何从它们中创建一个矩阵。这些变量是
-
gender,其值为 female 和 male -
contract,其值为月度、年度和两年期
由于gender变量只有两个可能的值,我们在结果矩阵中创建两个列。contract变量有三个列,因此我们的新矩阵将总共包含五个列:
-
gender=female -
gender=male -
contract=monthly -
contract=yearly -
contract=two-year
让我们考虑两个客户(图 3.22):
-
拥有年度合同的女性客户
-
拥有月度合同的男性客户
对于第一位客户,gender变量通过在gender=female列中放置 1,在gender=male列中放置 0 来进行编码。同样,contract=yearly得到 1,而其他合同列(contract=monthly和contract=two-year)得到 0。
对于第二位客户,gender=male和contract=monthly得到 1,其余列得到 0(图 3.22)。

图 3.22 原始数据集带有分类变量在左侧,独热编码表示在右侧。对于第一个客户,性别=male 和合同=monthly 是热列,因此它们得到 1。对于第二个客户,热列是性别=female 和合同=yearly。
我们之前实现的方式简单但相当有限。我们首先查看变量的前五个值,然后对每个值进行循环并手动在数据框中创建一个列。然而,当特征数量增加时,这个过程变得繁琐。
幸运的是,我们不需要手动实现这个功能:我们可以使用 Scikit-learn。在 Scikit-learn 中,我们可以以多种方式执行独热编码,但我们将使用DictVectorizer。
如其名所示,DictVectorizer接受一个字典并将其矢量化——也就是说,它从中创建向量。然后这些向量被组合成一个矩阵的行。这个矩阵被用作机器学习算法的输入(图 3.23)。

图 3.23 创建模型的过程。首先,我们将数据框转换为字典列表,然后我们将列表矢量化为矩阵,最后我们使用矩阵来训练模型。
要使用这种方法,我们需要将我们的数据框转换为字典列表,这在 Pandas 中使用to_dict方法并带有orient="records"参数是非常简单的:
train_dict = df_train[categorical + numerical].to_dict(orient='records')
如果我们查看这个新列表的第一个元素,我们会看到
{'gender': 'male',
'seniorcitizen': 0,
'partner': 'yes',
'dependents': 'yes',
'phoneservice': 'yes',
'multiplelines': 'no',
'internetservice': 'no',
'onlinesecurity': 'no_internet_service',
'onlinebackup': 'no_internet_service',
'deviceprotection': 'no_internet_service',
'techsupport': 'no_internet_service',
'streamingtv': 'no_internet_service',
'streamingmovies': 'no_internet_service',
'contract': 'two_year',
'paperlessbilling': 'no',
'paymentmethod': 'mailed_check',
'tenure': 12,
'monthlycharges': 19.7,
'totalcharges': 258.35}
数据框中的每一列是这个字典的键,值来自实际的数据框行值。
现在我们可以使用DictVectorizer。我们创建它,然后将其拟合到我们之前创建的字典列表:
from sklearn.feature_extraction import DictVectorizer
dv = DictVectorizer(sparse=False)
dv.fit(train_dict)
在这段代码中,我们创建了一个名为dv的DictVectorizer实例,并通过调用fit方法来“训练”它。fit方法查看这些字典的内容,并确定每个变量的可能值以及如何将它们映射到输出矩阵的列中。如果一个特征是分类的,它将应用独热编码方案,但如果一个特征是数值的,它将保持不变。
DictVectorizer类可以接受一组参数。我们指定其中一个:sparse=False。此参数意味着创建的矩阵将不是稀疏的,而是创建一个简单的 NumPy 数组。如果你不了解稀疏矩阵,不要担心:我们在这个章节中不需要它们。
在我们拟合矢量化器之后,我们可以使用它通过transform方法将字典转换为矩阵:
X_train = dv.transform(train_dict)
这个操作创建了一个有 45 列的矩阵。让我们看看第一行,它对应于我们之前查看的客户:
X_train[0]
当我们将这段代码放入 Jupyter Notebook 单元格中并执行时,我们得到以下输出:
array([ 0\. , 0\. , 1\. , 1\. , 0\. , 0\. , 0\. , 1\. ,
0\. , 1\. , 1\. , 0\. , 0\. , 86.1, 1\. , 0\. ,
0\. , 0\. , 0\. , 1\. , 0\. , 0\. , 1\. , 0\. ,
1\. , 0\. , 1\. , 1\. , 0\. , 0\. , 0\. , 0\. ,
1\. , 0\. , 0\. , 0\. , 1\. , 0\. , 0\. , 1\. ,
0\. , 0\. , 1\. , 71\. , 6045.9])
如我们所见,大多数元素都是 1 和 0——它们是独热编码的分类变量。然而,并非所有都是 1 和 0。我们看到其中三个是其他数字。这些是我们的数值变量:monthlycharges、tenure和totalcharges。
我们可以通过使用 get_feature_names 方法来学习所有这些列的名称:
dv.get_feature_names()
它打印
['contract=month-to-month',
'contract=one_year',
'contract=two_year',
'dependents=no',
'dependents=yes',
# some rows omitted
'tenure',
'totalcharges']
如我们所见,对于每个分类特征,它为每个不同的值创建多个列。对于 contract,我们有 contract=month-to-month、contract=one_year 和 contract=two_year,对于 dependents,我们有 dependents=no 和 dependents =yes。像 tenure 和 totalcharges 这样的特征保持原始名称,因为它们是数值型的;因此,DictVectorizer 不对它们进行更改。
现在我们的特征已编码为矩阵,因此我们可以进行下一步:使用模型来预测流失。
练习 3.2
DictVectorizer 将如何编码以下字典列表?
records = [
{'total_charges': 10, 'paperless_billing': 'yes'},
{'total_charges': 30, 'paperless_billing': 'no'},
{'total_charges': 20, 'paperless_billing': 'no'}
]
a) 列:['total_charges', 'paperless_billing=yes', 'paperless_ billing=no']
值:[10, 1, 0], [30, 0, 1], [20, 0, 1]
b) 列:['total_charges=10', 'total_charges=20', 'total_charges= 30', 'paperless_billing=yes', 'paperless_billing=no']
值:[1, 0, 0, 1, 0], [0, 0, 1, 0, 1], [0, 1, 0, 0, 1]
3.3 分类机器学习
我们已经学习了如何使用 Scikit-learn 对分类变量进行独热编码,现在我们可以将它们转换为一组数值特征,并将所有内容组合成一个矩阵。
当我们有一个矩阵时,我们就准备好进行模型训练部分了。在本节中,我们学习如何训练逻辑回归模型并解释其结果。
3.3.1 逻辑回归
在本章中,我们使用逻辑回归作为分类模型,现在我们训练它来区分已流失和未流失的用户。
逻辑回归与我们在上一章学习的线性回归有很多相似之处。如果你还记得,线性回归模型是一种可以预测数值的回归模型。它具有以下形式

其中
-
x[i] 是对应于第 i 个观察的特征向量。
-
w[0] 是偏置项。
-
w 是包含模型权重的向量。
我们应用这个模型并得到 g(x[i])——我们认为 x[i] 应该有的值的预测。线性回归被训练来预测目标变量 y[i]——观察 i 的实际值。在上一章中,这是汽车的价格。
线性回归是一个线性模型。它被称为线性是因为它将模型的权重与特征向量线性组合,使用点积。线性模型易于实现、训练和使用。由于它们的简单性,它们也很快。
逻辑回归也是一个线性模型,但与线性回归不同,它是一个分类模型,而不是回归模型,尽管名称可能暗示了这一点。它是一个二元分类模型,因此目标变量 y[i] 是二元的;它只能取零和一这两个值。y[i] 等于 1 的观测通常被称为 正例:即我们想要预测的效果存在的例子。同样,y[i] 等于 0 的例子被称为 负例:即我们想要预测的效果不存在。在我们的项目中,y[i] 等于 1 表示客户流失,而 y[i] 等于 0 则表示相反:客户留在了我们这里。
逻辑回归的输出是概率——即观察值 x[i] 为正的概率,或者说,y[i] 等于 1 的概率。在我们的案例中,这是客户 i 将会流失的概率。
为了能够将输出作为概率处理,我们需要确保模型的预测值始终在零和一之间。为此,我们使用一个特殊的数学函数,称为 sigmoid,逻辑回归模型的完整公式如下:

如果我们将其与线性回归公式进行比较,唯一的区别就是这个 sigmoid 函数:在线性回归的情况下,我们只有 w[0] + x[i]^T**w。这就是为什么这两个模型都是线性的;它们都是基于点积操作。
sigmoid 函数将任何值映射到 0 到 1 之间的一个数(图 3.24)。它被定义为以下方式:


图 3.24 sigmoid 函数输出的值总是在 0 到 1 之间。当输入为 0 时,sigmoid 的结果是 0.5;对于负值,结果小于 0.5,并且当输入值小于-6 时开始接近 0。当输入为正值时,sigmoid 的结果大于 0.5,并且当输入值从 6 开始时接近 1。
我们从第二章知道,如果特征向量 x[i] 是 n-维的,点积 x[i]^T**w 可以展开为一个和,我们可以将 g(x[i]) 写作:

或者,使用求和符号表示,如下:

之前,我们将公式翻译成 Python 进行说明。现在我们在这里做同样的操作。
线性回归模型具有以下公式:

如果你记得上一章的内容,这个公式可以翻译成以下 Python 代码:
def linear_regression(xi):
result = bias
for j in range(n):
result = result + xi[j] * w[j]
return result
将逻辑回归公式翻译成 Python 与线性回归的情况几乎相同,只是在最后我们应用 sigmoid 函数:
def logistic_regression(xi):
score = bias
for j in range(n):
score = score + xi[j] * w[j]
prob = sigmoid(score)
return prob
当然,我们还需要定义 sigmoid 函数:
import math
def sigmoid(score):
return 1 / (1 + math.exp(-score))
我们使用score来表示应用 Sigmoid 函数之前的中间结果。分数可以取任何实数值。probability是应用 Sigmoid 函数到分数的结果;这是最终输出,它只能取零和一之间的值。
逻辑回归模型的参数与线性回归相同:
-
w[0] 是偏置项。
-
w = (w[1], w[2], ..., w[n]) 是权重向量。
为了学习权重,我们需要训练模型,我们将使用 Scikit-learn 来完成这项工作。
练习 3.3
为什么逻辑回归需要使用 Sigmoid 函数?
a) Sigmoid 将输出转换为-6 和 6 之间的值,这更容易处理。
b) Sigmoid 确保输出值在零和一之间,这可以解释为概率。
3.3.2 训练逻辑回归
要开始,我们首先导入模型:
from sklearn.linear_model import LogisticRegression
然后我们通过调用fit方法来训练它:
model = LogisticRegression(solver='liblinear', random_state=1)
model.fit(X_train, y_train)
Scikit-learn 中的LogisticRegression类封装了此模型背后的训练逻辑。它是可配置的,我们可以更改相当多的参数。实际上,我们已经指定了其中两个:solver和random_state。两者都是可复现性的必要条件:
-
random_state.随机数生成器的种子数字。在训练模型时,它会对数据进行打乱;为了确保每次打乱都是相同的,我们固定了种子。 -
solver。底层优化库。在当前版本(撰写本文时,v0.20.3),此参数的默认值为liblinear,但根据文档(scikit-learn.org/stable/modules/generated/sklearn.linear_model.LogisticRegression.html),它将在 v0.22 版本中变为不同的值。为了确保我们可以在后续版本中复现结果,我们也设置了此参数。
模型中其他有用的参数包括C,它控制正则化级别。我们将在下一章讨论参数调整时讨论它。指定C是可选的;默认情况下,它的值为 1.0。
训练需要几秒钟,完成后,模型就准备好进行预测了。让我们看看模型的性能如何。我们可以将其应用于我们的验证数据,以获得验证数据集中每个客户的流失概率。
为了做到这一点,我们需要将独热编码方案应用于所有分类变量。首先,我们将数据框转换为字典列表,然后将其输入到我们之前拟合的DictVectorizer中:
val_dict = df_val[categorical + numerical].to_dict(orient='records') ❶
X_val = dv.transform(val_dict) ❷
❶ 我们以与训练期间完全相同的方式进行独热编码。
❷ 我们使用transform而不是fit和transform,这是我们在之前拟合的。
结果,我们得到X_val,这是一个包含验证数据集特征的矩阵。现在我们准备好将这个矩阵放入模型中。为了得到概率,我们使用模型的predict_proba方法:
y_pred = model.predict_proba(X_val)
predict_proba的结果是一个二维 NumPy 数组,或一个两列矩阵。数组的第一个列包含目标为负(无流失)的概率,第二个列包含目标为正(流失)的概率(图 3.25)。

图 3.25 模型的预测:一个两列矩阵。第一列包含目标为零(客户不会流失)的概率。第二列包含相反的概率(目标是 1,客户会流失)。
这些列传达相同的信息。我们知道流失的概率——它是p,而不流失的概率总是 1 – p,所以我们不需要两个列。
因此,只需要取预测的第二个列。要在 NumPy 中从二维数组中选择一列,我们可以使用切片操作[:, 1]:
y_pred = model.predict_proba(X_val)[:, 1]
这种语法可能让人困惑,所以让我们来分解一下。括号中有两个位置,第一个用于行,第二个用于列。
当我们使用[:, 1]时,NumPy 是这样解释的:
-
:表示选择所有行。 -
1表示只选择索引为 1 的列,因为索引从 0 开始,所以它是第二列。
因此,我们得到一个只包含第二列值的二维 NumPy 数组。
这个输出(概率)通常被称为软预测。这些告诉我们流失的概率,是一个介于零和一之间的数字。如何解释这个数字以及如何使用它取决于我们。
记得我们想要如何使用这个模型:我们想要通过识别那些即将取消与公司合同的人,并向他们发送促销信息,提供折扣和其他优惠来保留客户。我们这样做是希望他们在收到优惠后能继续与我们合作。另一方面,我们不希望向所有客户发送促销,因为这会损害我们的财务状况:我们的利润会减少,如果有的话。
要做出是否向客户发送促销信件的实际决定,仅使用概率是不够的。我们需要硬预测——True(流失,因此发送邮件)或False(不流失,因此不发送邮件)的二进制值。
要得到二进制预测,我们取概率并在某个阈值之上进行切割。如果一个客户的概率高于这个阈值,我们预测流失,否则不流失。如果我们选择 0.5 作为这个阈值,进行二进制预测就很简单。我们只需使用“>=”运算符:
y_pred >= 0.5
NumPy 中的比较运算符是逐元素应用的,结果是一个只包含布尔值的新数组:True和False。在底层,它会对y_pred数组的每个元素进行比较。如果元素大于 0.5 或等于 0.5,输出数组中对应的元素就是True,否则是False(图 3.26)。

图 3.26 NumPy 中的>=操作符是逐元素应用的。对于每个元素,它执行比较操作,结果是一个包含True或False值的数组,这取决于比较的结果。
让我们将结果写入churn数组:
churn = y_pred >= 0.5
当我们有模型做出的这些硬预测时,我们希望了解它们有多好,因此我们准备进入下一步:评估这些预测的质量。在下一章中,我们将花费更多的时间学习关于二元分类的不同评估技术,但现在,让我们进行一个简单的检查,以确保我们的模型学到了有用的东西。
最简单的事情是取每个预测值并与实际值进行比较。如果我们预测客户会流失,而实际值也是流失,或者我们预测客户不会流失,而实际值也是不会流失,那么我们的模型做出了正确的预测。如果预测值与实际值不匹配,那么它们就不好。如果我们计算预测值与实际值匹配的次数,我们可以用它来衡量我们模型的质量。
这个质量指标被称为准确率。使用 NumPy 计算准确率非常简单:
(y_val == churn).mean()
虽然计算起来很容易,但当你第一次看到这个表达式时,可能很难理解它做了什么。让我们尝试将其分解成单独的步骤。
首先,我们应用==操作符来比较两个 NumPy 数组:y_val和churn。如果你还记得,第一个数组y_val只包含数字:零和一。这是我们目标变量:如果客户流失则为 1,否则为 0。第二个数组包含布尔预测值:True和False。在这种情况下,True表示我们预测客户会流失,而False表示客户不会流失(图 3.27)。

图 3.27 将==操作符应用于比较目标数据与我们的预测
尽管这两个数组内部有不同的数据类型(整数和布尔值),但仍然可以比较它们。布尔数组会被转换为整数类型,这样True值会被转换为“1”,而False值会被转换为“0”。然后 NumPy 就可以执行实际的比较操作(图 3.28)。

图 3.28 为了比较预测值与目标数据,包含预测值的数组被转换为整数。
与>=操作符类似,==操作符也是逐元素应用的。然而,在这种情况下,我们有两组数组需要比较,我们比较一个数组中的每个元素与另一个数组中相应的元素。结果是另一个包含True或False值的布尔数组,这取决于比较的结果(图 3.29)。

图 3.29 NumPy 中的==操作符应用于两个 NumPy 数组,是逐元素应用的。
在我们的情况下,如果y_pred中的真实值与我们的 churn 预测匹配,则标签是True,如果不匹配,则标签是False。换句话说,如果我们的预测是正确的,则为True,如果不正确,则为False。
最后,我们取比较的结果——布尔数组——并使用mean()方法计算其平均值。然而,这个方法应用于数字,而不是布尔值,因此在计算平均值之前,值被转换为整数:True值转换为“1”,False值转换为“0”(图 3.30)。

图 3.30 当计算布尔数组的平均值时,NumPy 首先将其转换为整数,然后计算平均值。
最后,正如我们已经知道的,如果我们计算只包含一和零的数组的平均值,结果是数组中一的比例,我们已经在计算流失率时使用了这个比例。因为在这种情况下,“1”(True)是一个正确的预测,“0”(False)是一个错误的预测,所以得到的结果告诉我们正确预测的百分比。
执行这一行代码后,我们在输出中看到 0.8。这意味着模型预测与实际值匹配了 80%的时间,或者说模型在 80%的情况下做出了正确的预测。这就是我们所说的模型的准确率。
现在我们知道了如何训练模型并评估其准确率,但了解它是如何做出预测的仍然很有用。在下一节中,我们试图查看模型内部,看看我们如何解释它所学习的系数。
3.3.3 模型解释
我们知道逻辑回归模型有两个参数,它从数据中学习这些参数:
-
w[0]是偏差项。
-
w = (w[1], w[2], ..., w[n]**)是权重向量。
我们可以从model.intercept_[0]中获取偏差项。当我们使用所有特征训练模型时,偏差项是-0.12。
其余的权重存储在model.coef_[0]中。如果我们查看内部,它只是一个数字数组,单独来看很难理解。
为了了解每个权重与哪个特征相关联,让我们使用DictVectorizer的get_feature_names方法。在查看它们之前,我们可以将特征名称与系数一起压缩:
dict(zip(dv.get_feature_names(), model.coef_[0].round(3)))
这会打印
{'contract=month-to-month': 0.563,
'contract=one_year': -0.086,
'contract=two_year': -0.599,
'dependents=no': -0.03,
'dependents=yes': -0.092,
... # the rest of the weights is omitted
'tenure': -0.069,
'totalcharges': 0.0}
为了理解模型的工作原理,让我们考虑当我们应用这个模型时会发生什么。为了建立直觉,让我们训练一个更简单、更小的模型,它只使用三个变量:contract、tenure和totalcharges。
变量tenure和totalcharges是数值型,所以我们不需要进行任何额外的预处理;我们可以直接使用它们。另一方面,contract是一个分类变量,因此为了能够使用它,我们需要应用独热编码。
让我们重新执行训练时相同的步骤,这次使用一组更小的特征集:
small_subset = ['contract', 'tenure', 'totalcharges']
train_dict_small = df_train[small_subset].to_dict(orient='records')
dv_small = DictVectorizer(sparse=False)
dv_small.fit(train_dict_small)
X_small_train = dv_small.transform(train_dict_small)
为了不与之前的模型混淆,我们在所有名称中添加了small。这样,我们可以清楚地知道我们使用的是较小的模型,并且可以避免意外覆盖我们已有的结果。此外,我们还将用它来比较小模型与完整模型的质量。
让我们看看小模型将使用哪些特征。为此,正如之前一样,我们使用DictVectorizer的get_feature_names方法:
dv_small.get_feature_names()
它输出了特征名称:
['contract=month-to-month',
'contract=one_year',
'contract=two_year',
'tenure',
'totalcharges']
有五个特征。正如预期的那样,我们有tenure和totalcharges,因为它们是数值型的,所以它们的名称没有改变。
对于contract变量,它是分类的,所以DictVectorizer应用了 one-hot 编码方案将其转换为数字。contract有三个不同的值:按月、一年和两年。因此,one-hot 编码方案创建了三个新的特征:contract=month-to-month、contract=one_year和contract= two_years。
让我们在这一组特征上训练小模型:
model_small = LogisticRegression(solver='liblinear', random_state=1)
model_small.fit(X_small_train, y_train)
模型几秒钟后准备就绪,我们可以查看它学到的权重。让我们首先检查偏差项:
model_small.intercept_[0]
它输出-0.638。然后我们可以检查其他权重,使用与之前相同的代码:
dict(zip(dv_small.get_feature_names(), model_small.coef_[0].round(3)))
这行代码显示了每个特征的权重:
{'contract=month-to-month': 0.91,
'contract=one_year': -0.144,
'contract=two_year': -1.404,
'tenure': -0.097,
'totalcharges': 0.000}
让我们把所有这些权重放在一起,并称它们为w[1]、w[2]、w[3]、w[4]和w[5](表 3.2)。
表 3.2 逻辑回归模型的权重
| 偏差 | contract |
tenure |
charges |
|---|---|---|---|
| month | year | 2-year | |
| w[0] | w[1] | w[2] | w[3] |
| —0.639 | 0.91 | —0.144 | —1.404 |
现在我们来看看这些权重,并尝试理解它们的含义以及我们如何解释它们。
首先,让我们思考一下偏差项及其含义。回想一下,在线性回归的情况下,它是基线预测:在没有了解其他关于观察结果的情况下所做的预测。在汽车价格预测项目中,它将是汽车的平均价格。这不是最终的预测;稍后,这个基线将与其他权重一起进行校正。
在逻辑回归的情况下,情况类似:它是基线预测——或者平均会得到的分数。同样,我们稍后会用其他权重来校正这个分数。然而,对于逻辑回归,解释要复杂一些,因为我们在得到最终输出之前还需要应用 sigmoid 函数。让我们考虑一个例子来帮助我们理解这一点。
在我们的情况下,偏差项的值为-0.639。这个值是负的。如果我们看 sigmoid 函数,我们可以看到对于负值,输出低于 0.5(图 3.31)。对于-0.639, churn 的概率为 34%。这意味着平均而言,客户更有可能继续与我们合作而不是流失。

图 3.31 sigmoid 曲线上偏差项-0.639。结果概率小于 0.5,因此平均客户更有可能不会流失。
偏置项前的符号为负的原因是类别不平衡。训练数据中流失用户比未流失用户少得多,这意味着平均流失概率较低,因此这个偏置项的值是有意义的。
接下来的三个权重是合同变量的权重。因为我们使用 one-hot 编码,所以我们有三个contract特征和三个权重,每个特征一个:
'contract=month-to-month': 0.91,
'contract=one_year': -0.144,
'contract=two_year': -1.404.
为了建立我们对如何理解和解释 one-hot 编码权重的直觉,让我们考虑一个签订按月合同的客户。contract变量有以下 one-hot 编码表示:第一个位置对应按月值,是热的,因此设置为“1”。其余位置对应 one_year 和 two_years,因此它们是冷的,设置为“0”(图 3.32)。

图 3.32 按月合同客户的 one-hot 编码表示
我们还知道与contract=month-to-month,contract=one_year和contract=two_years对应的权重w[1],w[2]和w[3](图 3.33)。

图 3.33 contract=month-to-month,contract=one_year 和 contract=two_years 特征的权重
为了进行预测,我们执行特征向量与权重的点积,即每个位置的值的乘积然后求和。乘积的结果是 0.91,结果与图 3.34 中contract=month-to-month特征的权重相同。

图 3.34 合同变量的 one-hot 编码表示与相应权重的点积。结果是 0.91,这是热特征的权重。
让我们考虑另一个例子:一个签订两年合同的客户。在这种情况下,contract=two_year特征是热的,值为“1”,其余的是冷的。当我们用变量的 one-hot 编码表示与权重向量相乘时,我们得到-1.404(图 3.35)。

图 3.35 对于签订两年合同的客户,点积的结果是-1.404。
如我们所见,在预测过程中,只考虑了热特征的权重,其余权重在计算分数时并未考虑。这是有道理的:冷特征的值为零,当我们乘以零时,结果仍然是零(图 3.36)。

图 3.36 当我们将变量的 one-hot 编码表示与模型中的权重向量相乘时,结果是热特征的权重。
对于独热编码特征的权重符号的解释遵循与偏置项相同的直觉。如果一个权重是正的,那么相应的特征是流失的指标,反之亦然。如果它是负的,那么它更有可能属于非流失客户。
让我们再次看看contract变量的权重。对于contract=month-to-month的第一个权重是正的,因此拥有这种合同类型的客户更有可能流失而不是不流失。其他两个特征contract=one_year和contract=two_years具有负号,因此这类客户更有可能对公司保持忠诚(图 3.37)。

图 3.37 权重的符号很重要。如果是正的,它是一个良好的流失指标;如果是负的,它表示一个忠诚的客户。
权重的幅度也很重要。对于two_year,权重是-1.404,其幅度大于one_year的权重-0.144。因此,两年合同不是流失的更强指标,比一年合同更强。这证实了我们之前做的特征重要性分析。这个特征集的风险比(流失风险)为每月 1.55,一年为 0.44,两年为 0.10(图 3.38)。

图 3.38 合同特征的权重及其转换为概率。对于contract=two_year,权重是-1.404,这表示非常低的流失概率。对于contract=one_year,权重是-0.144,因此概率适中。而对于contract=month-to-month,权重是 0.910,概率相当高。
现在让我们看看数值特征。我们有两个:tenure和totalcharges。tenure特征的权重是-0.097,带有负号。这意味着相同的事情:这个特征是流失的指标。我们已经从特征重要性分析中知道,客户与我们合作的时间越长,他们流失的可能性就越小。tenure与流失之间的相关性是-0.35,这也是一个负数。这个特征的权重证实了这一点:对于客户与我们合作的每一个月,总分数会降低 0.097。
另一个数值特征totalchanges的权重为零。因为它为零,无论这个特征的值是多少,模型都不会考虑它,所以这个特征对于做出预测并不是真正重要的。
为了更好地理解它,让我们考虑几个例子。对于第一个例子,让我们想象我们有一个拥有月度合同的用户,与我们合作了一年,支付了 1000 美元(图 3.39)。

图 3.39 模型为拥有月度合同和 12 个月服务期的客户计算的分数
这是我们对这位客户的预测:
-
我们从基线分数开始。它是具有-0.639 值的偏置项。
-
因为这是一个按月签订的合同,我们给这个值加上 0.91,得到 0.271。现在分数变成了正数,这可能意味着客户将要流失。我们知道月度合同是流失的一个强烈指标。
-
接下来,我们考虑
tenure变量。对于客户在我们这里停留的每个月,我们从目前的分数中减去 0.097。因此,我们得到 0.271 – 12 · 0.097 = –0.893。现在分数再次变为负数,所以流失的可能性降低。 -
现在我们加上客户支付给我们的金额(
totalcharges)乘以这个特征的权重,但因为它是零,所以我们不做任何事情。结果仍然是–0.893。 -
最终分数是一个负数,所以我们认为这位客户不太可能很快流失。
-
为了看到实际的流失概率,我们计算分数的 sigmoid 值,大约是 0.29。我们可以将这个值视为这位客户将要流失的概率。
如果我们还有另一位客户,他签订了年度合同,与我们合作了 24 个月,花费了 2,000 美元,那么分数是–2.823(图 3.40)。

图 3.40 模型为签订了年度合同且服务了 24 个月的客户计算的分数
在取 sigmoid 之后,–2.823 的分数变为 0.056,所以这位客户的流失概率更低(图 3.41)。

图 3.41 –2.823 和–0.893 的分数分别转换为概率:0.05 和 0.29
3.3.4 使用模型
现在我们对逻辑回归有了更好的了解,我们还可以解释我们的模型学到了什么,并理解它是如何进行预测的。
此外,我们还应用了模型到验证集上,计算了每个客户的流失概率,并得出结论,该模型的准确率是 80%。在下一章中,我们将评估这个数字是否令人满意,但现在,让我们尝试使用我们训练的模型。现在我们可以将模型应用于客户进行评分。这相当简单。
首先,我们取一个我们想要评分的客户,并将所有变量值放入一个字典中:
customer = {
'customerid': '8879-zkjof',
'gender': 'female',
'seniorcitizen': 0,
'partner': 'no',
'dependents': 'no',
'tenure': 41,
'phoneservice': 'yes',
'multiplelines': 'no',
'internetservice': 'dsl',
'onlinesecurity': 'yes',
'onlinebackup': 'no',
'deviceprotection': 'yes',
'techsupport': 'yes',
'streamingtv': 'yes',
'streamingmovies': 'yes',
'contract': 'one_year',
'paperlessbilling': 'yes',
'paymentmethod': 'bank_transfer_(automatic)',
'monthlycharges': 79.85,
'totalcharges': 3320.75,
}
注意:当我们为预测准备项目时,它们应该经过与我们为训练模型所进行的相同的预处理步骤。如果我们不按完全相同的方式进行,模型可能无法获得它期望看到的东西,在这种情况下,预测可能会非常不准确。这就是为什么在前面的例子中,在customer字典中,字段名称和字符串值都转换为小写,空格被下划线替换。
现在我们可以使用我们的模型来查看这位客户是否会流失。让我们试试。
首先,我们使用DictVectorizer将这个字典转换为矩阵:
X_test = dv.transform([customer])
向量化器的输入是一个包含一个项目的列表:我们只想为一位客户评分。输出是一个包含特征的矩阵,而这个矩阵只包含一行——这位客户的特征:
[[ 0\. , 1\. , 0\. , 1\. , 0\. , 0\. , 0\. ,
1\. , 1\. , 0\. , 1\. , 0\. , 0\. , 79.85,
1\. , 0\. , 0\. , 1\. , 0\. , 0\. , 0\. ,
0\. , 1\. , 0\. , 1\. , 1\. , 0\. , 1\. ,
0\. , 0\. , 0\. , 0\. , 1\. , 0\. , 0\. ,
0\. , 1\. , 0\. , 0\. , 1\. , 0\. , 0\. ,
1\. , 41\. , 3320.75]]
我们看到一组独热编码特征(一和零)以及一些数值特征(monthlycharges、tenure和totalcharges)。
现在我们将这个矩阵放入训练好的模型中:
model.predict_proba(X_test)
输出是一个包含预测的矩阵。对于每个客户,它输出两个数字,即客户继续与公司合作和客户流失的概率。因为只有一个客户,所以我们得到一个只有一行两列的小 NumPy 数组:
[[0.93, 0.07]]
我们只需要矩阵中的第一个行和第二个列的数字:这位客户流失的概率。要从数组中选择这个数字,我们使用方括号操作符:
model.predict_proba(X_test)[0, 1]
我们使用这个操作符从数组中选择第二列。然而,这次只有一个行,所以我们可以明确要求 NumPy 返回该行的值。因为 NumPy 中的索引从 0 开始,[0, 1]表示第一行,第二列。
当我们执行这一行时,我们看到输出是 0.073,这意味着这位客户流失的概率仅为 7%。这低于 50%,所以我们将不会给这位客户发送促销邮件。
我们可以尝试评分另一位客户:
customer = {
'gender': 'female',
'seniorcitizen': 1,
'partner': 'no',
'dependents': 'no',
'phoneservice': 'yes',
'multiplelines': 'yes',
'internetservice': 'fiber_optic',
'onlinesecurity': 'no',
'onlinebackup': 'no',
'deviceprotection': 'no',
'techsupport': 'no',
'streamingtv': 'yes',
'streamingmovies': 'no',
'contract': 'month-to-month',
'paperlessbilling': 'yes',
'paymentmethod': 'electronic_check',
'tenure': 1,
'monthlycharges': 85.7,
'totalcharges': 85.7
}
让我们进行一次预测:
X_test = dv.transform([customer])
model.predict_proba(X_test)[0, 1]
模型的输出是 83%的流失可能性,因此我们应该给这位客户发送促销邮件,希望留住他们。
到目前为止,我们已经对逻辑回归的工作原理、如何使用 Scikit-learn 进行训练以及如何将其应用于新数据有了直觉。我们还没有涵盖结果的评估;这是我们将在下一章中要做的。
3.4 下一步
3.4.1 练习
你可以尝试一些事情来更好地学习这个主题:
-
在上一章中,我们亲自实现了许多事情,包括线性回归和数据集拆分。在这一章中,我们学习了如何使用 Scikit-learn 来做这些。尝试使用 Scikit-learn 重新做上一章的项目。要使用线性回归,你需要从
sklearn.linear_model包中导入LinearRegression。要使用正则化回归,你需要从同一个包sklearn.linear_model中导入Ridge。 -
我们查看特征重要性指标,以获取对数据集的一些洞察,但并没有真正使用这些信息进行其他目的。使用这些信息的一种方法可能是从数据集中移除无用的特征,使模型更简单、更快,并可能更好。尝试从训练数据矩阵中排除两个最无用的特征(
gender和phoneservices),看看验证准确率会发生什么变化。如果我们移除最有用的特征(contract)会怎样呢?
3.4.2 其他项目
我们可以用多种方式使用分类来解决现实生活中的问题,现在,在学完本章内容后,你应该有足够的知识来应用逻辑回归来解决类似的问题。特别是,我们建议以下方法:
-
分类模型常用于营销目的,它解决的问题之一是潜在客户评分。一个潜在客户可能转化(成为实际客户)也可能不转化。在这种情况下,转化是我们想要预测的目标。你可以从
www.kaggle.com/ashydv/ leads-dataset获取一个数据集并为其构建模型。你可能注意到,潜在客户评分问题与客户流失预测问题类似,但在一种情况下,我们希望新客户与我们签订合同,而在另一种情况下,我们希望客户不要取消合同。 -
分类的一个流行应用是预测违约,即估计客户不偿还贷款的风险。在这种情况下,我们想要预测的变量是违约,它也有两种结果:客户是否成功按时偿还了贷款(良好客户)或者没有(违约)。你可以在网上找到许多用于训练模型的数据库,例如
archive.ics.uci.edu/ml/datasets/default+of+credit+card+clients(或者通过 Kaggle 可用的相同数据:www.kaggle.com/pratjain/credit-card-default)。
摘要
-
分类特征的风险告诉我们具有该特征的群体是否会具有我们模型中的条件。对于客户流失,值低于 1.0 表示低流失风险,而值高于 1.0 表示高流失风险。它告诉我们哪些特征对于预测目标变量很重要,并帮助我们更好地理解我们正在解决的问题。
-
互信息衡量一个分类变量与目标之间的(不)独立性程度。这是一种确定重要特征的好方法:互信息越高,特征就越重要。
-
相关系数衡量两个数值变量之间的依赖程度,它可以用来确定一个数值特征是否对预测目标变量有用。
-
独热编码为我们提供了一种将分类变量表示为数字的方法。没有它,我们就不可能轻松地在模型中使用这些变量。机器学习模型通常期望所有输入变量都是数值型的,因此如果我们想在建模中使用分类特征,编码方案是至关重要的。
-
我们可以通过使用 Scikit-learn 的
DictVectorizer来实现独热编码。它自动检测分类变量并对它们应用独热编码方案,同时保留数值变量不变。使用起来非常方便,并且不需要我们进行大量的编码。 -
逻辑回归是一个线性模型,就像线性回归一样。不同之处在于逻辑回归在最后有一个额外的步骤:它应用 sigmoid 函数将分数转换为概率(介于零和一之间的数字)。这使得我们可以将其用于分类。输出是属于正类(在我们的案例中是流失)的概率。
-
数据准备完成后,训练逻辑回归非常简单:我们使用 Scikit-learn 中的
LogisticRegression类并调用fit函数。 -
模型输出的是概率,而不是硬预测。为了二值化输出,我们在某个阈值处截断预测。如果概率大于或等于 0.5,我们预测
True(流失),否则预测False(未流失)。这使得我们可以使用该模型来解决我们的问题:预测流失的客户。 -
逻辑回归模型的权重易于解释和说明,尤其是在使用单热编码方案对分类变量进行编码时。这有助于我们更好地理解模型的行为,并向他人解释它在做什么以及它是如何工作的。
在下一章中,我们将继续进行关于客户流失预测的项目。我们将探讨评估二元分类器的方法,然后利用这些信息来调整模型的表现。
练习答案
-
练习 3.1 B) 真实元素的百分比
-
练习 3.2 A) 它将保持数值变量不变,并仅对分类变量进行编码。
-
练习 3.3 B) sigmoid 函数将输出转换为介于零和一之间的值。
4 分类评估指标
本章涵盖
-
准确率作为评估二元分类模型及其局限性的方式
-
使用混淆矩阵确定模型出错的地方
-
从混淆矩阵推导其他指标,如精确率和召回率
-
使用 ROC(接收者操作特征)和 AUC(ROC 曲线下的面积)来进一步了解二元分类模型的表现
-
对模型进行交叉验证以确保其表现最优
-
调整模型参数以实现最佳的预测性能
在本章中,我们继续上一章开始的项目:客户流失预测。我们已经下载了数据集,完成了初步的预处理和探索性数据分析,并训练了预测客户是否会流失的模型。我们还在验证数据集上评估了该模型,并得出结论,其准确率为 80%。
我们一直推迟到现在的疑问是 80%的准确率是否良好,以及它在模型质量方面的实际意义。我们将在本章回答这个问题,并讨论评估二元分类模型的其它方法:混淆矩阵、精确率、召回率、ROC 曲线和 AUC。
本章提供了大量复杂信息,但我们在这里讨论的评估指标对于实际机器学习至关重要。如果你不立即理解不同评估指标的细节,请不要担心:这需要时间和实践。随时可以回到本章,重新审视细节。
4.1 评估指标
我们已经为预测流失客户构建了一个二元分类模型。现在我们需要能够确定它的好坏。
为了做到这一点,我们使用了一个指标——一个查看模型做出的预测并将其与实际值进行比较的函数。然后,基于比较结果,它计算出模型的好坏。这非常有用:我们可以用它来比较不同的模型,并选择具有最佳指标值的模型。
存在着不同类型的指标。在第二章中,我们使用了 RMSE(均方根误差)来评估回归模型。然而,这个指标只能用于回归模型,不适用于分类。
对于评估分类模型,我们有其他更合适的指标。在本节中,我们将介绍二元分类最常见的评估指标,从我们在第三章中看到的准确率开始。
4.1.1 分类准确率
如你所记得的,二元分类模型的准确率是指它做出正确预测的百分比(图 4.1)。

图 4.1 模型的准确率是正确预测的预测比例。
这种准确率是评估分类器的最简单方法:通过计算我们的模型正确的情况数量,我们可以了解很多关于模型行为和质量的信息。
在验证数据集上计算准确率很简单——我们只需计算正确预测的比例:
y_pred = model.predict_proba(X_val)[:, 1] ❶
churn = y_pred >= 0.5 ❷
(churn == y_val).mean() ❸
❶ 从模型获取预测值
❷ 进行“硬”预测
❸ 计算准确率
我们首先将模型应用于验证集以获取预测值 ❶。这些预测值是概率,所以我们将其在 ❷ 处截断为 0.5。最后,我们计算在 ❷ 中与实际情况匹配的预测比例。
结果是 0.8016,这意味着我们的模型准确率为 80%。
我们首先应该问自己为什么选择 0.5 作为阈值而不是其他数字。这是一个任意的选择,但实际上检查其他阈值也不难:我们只需遍历所有可能的阈值候选者并计算每个的准确率。然后我们可以选择具有最佳准确率分数的那个。
尽管我们可以轻松实现自己的准确率,但我们也可以使用现有的实现。Scikit-learn 库提供了各种指标,包括准确率以及我们稍后将要使用的许多其他指标。你可以在 metrics 包中找到这些指标。
我们将继续在第三章开始的工作笔记本上工作。让我们打开它,并添加 import 语句以从 Scikit-learn 的 metrics 包中导入准确率:
from sklearn.metrics import accuracy_score
现在,我们可以遍历不同的阈值并检查哪个阈值给出了最佳的准确率:
thresholds = np.linspace(0, 1, 11) ❶
for t in thresholds: ❷
churn = y_pred >= t ❸
acc = accuracy_score(y_val, churn) ❸
print('%0.2f %0.3f' % (t, acc)) ❹
❶ 创建一个包含不同阈值的数组:0.0、0.1、0.2 等
❷ 遍历每个阈值值
❸ 使用 Scikit-learn 的 accuracy_score 函数计算准确率
❹ 将阈值和准确率值打印到标准输出
在此代码中,我们首先创建一个包含阈值的数组。我们使用 NumPy 的 linspace 函数来做到这一点:它接受两个数字(在我们的情况下是 0 和 1)和数组应具有的元素数量(11)。结果,我们得到一个包含数字 0.0、0.1、0.2、...、1.0 的数组。你可以在附录 C 中了解更多关于 linspace 和其他 NumPy 函数的信息。
我们将这些数字用作阈值:我们遍历它们,并对每个值计算准确率。最后,我们打印出阈值和准确率分数,以便我们可以决定哪个阈值是最好的。
当我们执行代码时,它将打印以下内容:
0.00 0.261
0.10 0.595
0.20 0.690
0.30 0.755
0.40 0.782
0.50 0.802
0.60 0.790
0.70 0.774
0.80 0.742
0.90 0.739
1.00 0.739
如我们所见,使用 0.5 的阈值给出了最佳的准确率。通常,0.5 是一个很好的起始阈值,但我们应该尝试其他阈值以确保 0.5 是最佳选择。
为了使其更直观,我们可以使用 Matplotlib 创建一个图表,显示准确率如何根据阈值变化。我们重复之前的过程,但这次不是只打印准确率分数,我们首先将这些值放入一个列表中:
thresholds = np.linspace(0, 1, 21) ❶
accuracies = [] ❷
for t in thresholds:
acc = accuracy_score(y_val, y_pred >= t) ❸
accuracies.append(acc) ❹
❶ 创建不同的阈值值(这次是 21 而不是 11)
❷ 创建一个空列表来存储准确率值
❸ 计算给定阈值下的准确率
❹ 记录此阈值的准确率
然后我们使用 Matplotlib 绘制这些值:
plt.plot(thresholds, accuracies)
执行这一行后,我们应该看到一个显示阈值与准确率之间关系的图表(图 4.2)。正如我们已经知道的,0.5 的阈值在准确率方面是最好的。

图 4.2 在不同阈值下评估的模型准确率。在将预测切分在 0.5 阈值时,我们达到了最佳准确率:如果预测高于 0.5,我们预测“流失”,否则我们预测“无流失”。
因此,最佳阈值是 0.5,我们可以达到的该模型的最佳准确率是 80%。
在上一章中,我们训练了一个更简单的模型:我们称之为 model_small。它基于仅三个变量:contract、tenure和totalcharges。
让我们也检查一下它的准确率。为此,我们首先在验证数据集上做出预测,然后计算准确率:
val_dict_small = df_val[small_subset].to_dict(orient='records') ❶
X_small_val = dv_small.transform(val_dict_small) ❶
y_pred_small = model_small.predict_proba(X_small_val)[:, 1] ❷
churn_small = y_pred_small >= 0.5 ❷
accuracy_score(y_val, churn_small) ❸
❶ 对验证数据进行 one-hot 编码
❷ 使用小型模型预测流失
❸ 计算预测的准确率
当我们运行这段代码时,我们看到小型模型的准确率是 76%。因此,大型模型实际上比小型模型准确 4%。
然而,这仍然没有告诉我们 80%(或 76%)是否是一个好的准确率。
4.1.2 虚拟基线
虽然这个数字看起来还不错,但要了解 80%是否真的很好,我们需要将其与某些东西联系起来——例如,一个简单易懂的基线。这样一个基线可以是总是预测相同值的虚拟模型。
在我们的例子中,数据集是不平衡的,我们没有很多流失用户。因此,虚拟模型可以总是预测大多数类——“无流失”。换句话说,这个模型将始终输出 False,无论特征如何。这不是一个特别有用的模型,但我们可以将其用作基线,并与其他两个模型进行比较。
让我们创建这个基线预测:
size_val = len(y_val) ❶
baseline = np.repeat(False, size_val) ❷
❶ 获取验证集中的客户数量
❷ 创建一个只包含 False 元素的数组
要创建一个包含基线预测的数组,我们首先需要确定验证集中有多少个元素。
接下来,我们创建一个包含虚拟预测的数组——这个数组的所有元素都是 False 值。我们使用 NumPy 的repeat函数来完成这个操作:它接受一个元素,并按照我们要求重复它多次。有关repeat函数和其他 NumPy 函数的更多详细信息,请参阅附录 C。
现在我们可以使用之前相同的代码来检查这个基线预测的准确率:
accuracy_score(baseline, y_val)
当我们运行这段代码时,它显示 0.738。这意味着基线模型的准确率大约是 74%(图 4.3)。

图 4.3 基线是一个总是为所有客户预测相同值的“模型”。这个基线的准确率是 74%。
正如我们所见,小型模型仅比朴素基线好 2%,而大型模型好 6%。如果我们考虑我们为训练这个大型模型所付出的所有努力,6%似乎并不比虚拟基线有显著改进。
流失预测是一个复杂的问题,也许这个改进是很大的。然而,仅从准确率分数来看,这一点并不明显。根据准确率,我们的模型仅略优于一个将所有客户视为非流失且不尝试保留任何客户的虚拟模型。
因此,我们需要其他指标——其他衡量我们模型质量的方法。这些指标基于混淆矩阵,我们将在下一节中介绍的概念。
4.2 混淆矩阵
尽管准确率容易理解,但它并不总是最好的指标。事实上,有时它可能会误导。我们已经看到这种情况发生了:我们模型的准确率是 80%,尽管这个数字看起来不错,但它仅比总是输出相同预测“无流失”的虚拟模型的准确率高 6%。
这种情况通常发生在我们有一个类别不平衡(一个类别的实例比另一个类别多)的情况下。我们知道,对于我们的问题,这绝对是一个案例:74%的客户没有流失,而只有 26%的客户流失了。
对于这类情况,我们需要一种不同的方法来衡量我们模型的质量。我们有几种选择,其中大多数都是基于混淆矩阵:一个简洁地表示我们模型预测所有可能结果的表格。
4.2.1 混淆矩阵简介
我们知道,对于二元分类模型,我们只能有两种可能的预测:真和假。在我们的情况下,我们可以预测一个客户是否会流失(真)或不会流失(假)。
当我们将模型应用于包含客户的整个验证数据集时,我们将它分为两部分(图 4.4):
-
模型预测为“流失”的客户
-
模型预测为“无流失”的客户

图 4.4 我们的模型将验证数据集中的所有客户分为两组:我们认为会流失的客户和不会流失的客户。
只有两种可能的正确结果:再次,真或假。客户要么实际上流失了(真),要么没有(假)。
这意味着,通过使用真实信息——关于目标变量的信息——我们再次可以将数据集分为两部分(图 4.5):
-
流失的客户
-
没有流失的客户

图 4.5 使用真实数据,我们可以将验证数据集分为两组:实际流失的客户和没有流失的客户。
当我们做出预测时,它要么是正确的,要么是错误的:
-
如果我们预测“流失”,客户确实可能会流失,也可能不会。
-
如果我们预测“无流失”,那么客户确实可能没有流失,但也可能流失。
这给我们带来了四种可能的结果(图 4.6):
-
我们预测为假,答案是假。
-
我们预测为假,但答案是真。
-
我们预测为真,但答案是假。
-
我们预测为真,答案是真。

图 4.6 有四种可能的结果:我们预测“churn”,客户要么流失要么没有,我们预测“no churn”,客户再次要么流失要么没有。
这两种情况——第一种和最后一种——是好的:预测与实际值匹配。剩下的两种情况是坏的:我们没有做出正确的预测。
每种这四种情况都有其自己的名称(图 4.7):
-
真阴性(TN):我们预测为假(“no churn”),而实际标签也是假(“no churn”)。
-
真阳性(TP):我们预测为真(“churn”),而实际标签也是真(“churn”)。
-
假阴性(FN):我们预测为假(“no churn”),但实际上是真(客户流失了)。
-
假阳性(FP):我们预测为真(“churn”),但实际上是假(客户留在了我们这里)。

图 4.7 每种可能的四种结果都有其自己的名称:真阴性、假阴性、假阳性和真阳性。
将这些结果以表格形式排列是直观的。我们可以将预测类别(假和真)放在列中,将实际类别(假和真)放在行中(图 4.8)。

图 4.8 我们可以将结果组织成表格——预测值作为列,实际值作为行。这样,我们将所有预测场景分解为四个不同的组:TN(真阴性)、TP(真阳性)、FN(假阴性)和 FP(假阳性)。
当我们替换每种结果发生的次数时,我们得到我们模型的混淆矩阵(图 4.9)。

图 4.9 在混淆矩阵中,每个单元格包含每种结果发生的次数。
使用 NumPy 计算混淆矩阵单元格中的值相当简单。接下来,我们将看到如何做到这一点。
4.2.2 使用 NumPy 计算混淆矩阵
为了更好地理解我们的混淆矩阵,我们可以直观地展示它对验证数据集的影响(图 4.10)。

图 4.10 当我们将模型应用于验证数据集时,我们得到四种不同的结果(TN、FP、TP 和 FN)。
要计算混淆矩阵,我们需要执行以下步骤:
-
首先,预测将数据集分为两部分:我们预测为真(“churn”)的部分和预测为假(“no churn”)的部分。
-
同时,目标变量将这个数据集分为两个不同的部分:实际流失的客户(
y_val中的“1”)和没有流失的客户(y_val中的“0”)。 -
当我们将这些分割组合起来时,我们得到四组客户,这正好是混淆矩阵中的四种不同结果。
将这些步骤转换为 NumPy 是直接的:
t = 0.5 ❶
predict_churn = (y_pred >= t) ❶
predict_no_churn = (y_pred < t) ❶
actual_churn = (y_val == 1) ❷
actual_no_churn = (y_val == 0) ❷
true_positive = (predict_churn & actual_churn).sum() ❸
false_positive = (predict_churn & actual_no_churn).sum() ❹
false_negative = (predict_no_churn & actual_churn).sum() ❺
true_negative = (predict_no_churn & actual_no_churn).sum() ❻
❶ 在阈值 0.5 处进行预测
❷ 获取实际的目标值
❸ 计算真正结果(我们正确预测了流失的情况)
❹ 计算假阳性(我们预测了流失,但客户没有流失的情况)
❺ 计算真阴性(我们正确预测了没有流失的情况)
❻ 计算假阴性(我们预测了没有流失,但客户流失了的情况)
我们从在 0.5 的阈值上进行预测开始。
结果是两个 NumPy 数组:
-
在第一个数组(
predict_churn)中,如果一个元素为 True,表示模型认为相应的客户将要流失,否则为 False。 -
同样,在第二个数组(
predict_no_churn)中,True 表示模型认为客户不会流失。
第二个数组predict_no_churn是predict_churn的完全相反:如果predict_churn中的元素为 True,则predict_no_churn中的元素为 False,反之亦然(图 4.11)。这是验证数据集第一次分割成两部分——基于预测的分割。

图 4.11 将预测分割成两个布尔 NumPy 数组:predict_churn如果概率高于 0.5,predict_no_churn如果概率低于 0.5
接下来,我们在❷中记录目标变量的实际值。结果也是两个 NumPy 数组(图 4.12):
-
如果客户流失了(值为“1”),那么
actual_churn中的相应元素为 True,否则为 False。 -
对于
actual_no_churn,情况正好相反:当客户没有流失时为 True。

图 4.12 将实际值数组分割成两个布尔 NumPy 数组:actual_no_churn如果客户没有流失(y_val == 0)和actual_churn如果客户流失了(y_val == 1)
这就是数据集的第二次分割——基于目标变量的分割。
现在我们将这两个分割合并——或者更准确地说,这四个 NumPy 数组。
要计算在❸中的真正结果数量,我们使用 NumPy 的逻辑“与”运算符(&)和sum方法:
true_positive = (predict_churn & actual_churn).sum()
逻辑“与”运算符仅在两个值都为 True 时才返回 True。如果至少有一个是 False 或者两个都是 False,则返回 False。在true_positive的情况下,它只有在预测“流失”并且客户实际上流失时才会是 True(图 4.13)。

图 4.13 将元素级和运算符(&)应用于两个 NumPy 数组predict_churn和actual_churn;这创建了一个新的数组,其中在两个数组都包含 True 的位置为 True,在其他所有位置为 False。
然后我们使用 NumPy 的sum方法,它简单地计算数组中有多少个True值。它是通过首先将布尔数组转换为整数,然后求和来做到这一点的(图 4.14)。我们在上一章使用mean方法时已经看到了类似的行为。

图 4.14 在布尔数组上调用sum方法:我们得到数组中为 True 的元素数量。
因此,我们得到了真正正例的数量。其他值在行❹、❺和❻中类似计算。
现在,我们只需要将这些值组合成一个 NumPy 数组:
confusion_table = np.array(
[[true_negative, false_positive],
[false_negative, true_positive]])
当我们打印出来时,我们得到以下数字:
[[1202, 172],
[ 197, 289]]
绝对数字可能难以理解,因此我们可以通过将每个值除以项目总数来将它们转换为分数:
confusion_table / confusion_table.sum()
这将打印出以下数字:
[[0.646, 0.092],
[0.105, 0.155]]
我们可以将结果总结在一张表中(表 4.1)。我们看到模型在预测负值方面做得相当好:65%的预测是真正的负例。然而,它犯了很多两种类型的错误:错误正例和错误负例的数量大致相等(分别为 9%和 11%)。
表 4.1 在阈值为 0.5 的流失分类器的混淆矩阵。我们看到模型很容易正确预测非流失用户,但更难识别流失用户。
| 包含所有特征的完整模型 |
|---|
| 实际 |
| 真实 |
这个表格让我们更好地理解了模型的性能——现在我们可以将性能分解为不同的组成部分,并了解模型在哪些地方犯了错误。实际上,我们看到模型的性能并不理想:它在尝试识别将要流失的用户时犯了很多错误。这是仅凭准确率分数无法看到的。
我们可以使用完全相同的代码对小型模型重复相同的流程(表 4.2)。
表 4.2 小型模型的混淆矩阵
| 包含三个特征的小型模型 |
|---|
| 实际 |
| 真实 |
当我们将小型模型与完整模型进行比较时,我们发现它在正确识别非流失用户方面差了 2%(真正负例从 63%变为 65%),在正确识别流失用户方面也差了 2%(真正正例从 13%变为 15%),这两个差异加起来解释了这两个模型准确率之间的 4%差异(76%对 80%)。
混淆矩阵中的值是许多其他评估指标的基础。例如,我们可以通过将所有正确的预测——真正的负例和真正的正例相加——然后除以表中四个单元格中所有观察值的总数来计算准确率:
准确率 = (真正的负例 + 真正的正例) / (真正的负例 + 真正的正例 + 错误的正例 + 错误的负例)
除了准确率之外,我们还可以根据混淆矩阵中的值计算其他指标。最有用的是精确率和召回率,我们将在下一节中介绍。
练习 4.1
什么是错误正例?
a) 我们预测客户“不会流失”,但他们停止使用我们的服务
b) 我们预测客户会“流失”,但他们没有流失
c) 我们预测客户会“流失”,并且他们确实流失了
4.2.3 精确率和召回率
如前所述,在处理像我们这样的不平衡数据集时,准确率可能会误导。在这种情况下,其他指标是有帮助的:精确率和召回率。
精确率和召回率都是从混淆矩阵的值计算得出的。它们两者都有助于我们理解模型在类别不平衡情况下的质量。
让我们从精确率开始。模型的精确率告诉我们有多少正预测是正确的。它是正确预测的正例的比例。在我们的案例中,它是实际流失(TP)的客户数除以我们认为会流失的所有客户数(TP + FP)(图 4.15):
P = TP / (TP + FP)
对于我们的模型,精确率为 62%:
P = 289 / (289 + 172) = 172 / 461 = 0.62

图 4.15 模型的精确率是指所有正预测(TP + FP)中正确预测(TP)的比例。
召回率是指所有正例中正确分类的正例的比例。在我们的案例中,为了计算召回率,我们首先查看所有流失的客户,看看我们正确识别了多少。
计算召回率的公式是
R = TP / (TP + FN)
就像精确率的公式一样,分子是真正的正例数,但分母不同:它是验证数据集中所有正例(y_val == 1)的数量(图 4.16)。

图 4.16 模型的召回率是指所有客户中正确预测为流失(TP)的客户占所有流失客户(TP + FN)的比例。
对于我们的模型,召回率为 59%:
R = 286 / (289 + 197) = 289 / 486 = 0.59
精确率和召回率之间的差异可能一开始看起来很微妙。在两种情况下,我们都关注正确预测的数量,但差异在于分母(图 4.17):
-
精确率:在预测为流失的客户中,正确预测(TP)的百分比是多少?
-
召回率:在所有流失客户(TP + FN)中,正确预测为流失(TP)的百分比是多少?

图 4.17 精确率和召回率都关注正确的预测(TP),但分母不同。对于精确率,它是预测为流失的客户数,而对于召回率,它是流失的客户数。
我们还可以看到,精确率和召回率都没有考虑真正的负例(图 4.17)。这正是为什么它们是评估不平衡数据集的良好指标。对于类别不平衡的情况,真正的负例通常比其他所有东西都多——但与此同时,它们通常对我们来说也不是特别有趣。让我们看看原因。
我们项目的目标是识别可能流失的客户。一旦我们做到了这一点,我们就可以向他们发送促销信息,希望他们改变主意。
在进行这项工作时,我们会犯两种类型的错误:
-
我们意外地向那些本不会流失的人发送了消息——这些人就是模型的假阳性。
-
我们有时也未能识别那些实际上会流失的人。我们没有向这些人发送消息——他们是我们的假阴性。
精度和召回率帮助我们量化这些错误。
精度帮助我们了解有多少人错误地收到了促销信息。精度越好,假阳性就越少。62%的精度意味着 62%的触及客户确实会流失(我们的真阳性),而剩余的 38%则不会(假阳性)。
召回率帮助我们了解我们未能找到多少流失客户。召回率越好,假阴性就越少。59%的召回率意味着我们只触及了 59%的所有流失用户(真阳性),而未能识别剩余的 41%(假阴性)。
如我们所见,在这两种情况下,我们实际上并不需要知道真阴性的数量:尽管我们可以正确地将它们识别为未流失,但我们不会对它们做任何事情。
尽管 80%的准确率可能表明模型很棒,但查看其精度和召回率告诉我们,它实际上犯了很多错误。这通常不是决定性的:在机器学习中,模型犯错误是不可避免的,而现在我们至少对我们的流失预测模型性能有了更好的、更现实的了解。
精度和召回率是很有用的指标,但它们仅描述了分类器在某个特定阈值下的性能。通常,有一个能够总结分类器在所有可能阈值选择下性能的指标是有用的。我们将在下一节中查看这些指标。
练习 4.2
什么是精度?
a) 验证数据集中正确识别的流失客户百分比
b) 在我们预测为流失的客户中,实际流失的客户百分比
练习 4.3
什么是召回率?
a) 在所有流失客户中正确识别的流失客户百分比
b) 在我们预测为流失的客户中,正确分类的客户百分比
4.3 ROC 曲线和 AUC 分数
我们之前提到的指标仅适用于二元预测——当我们输出中只有真和假值时。然而,我们确实有方法来评估模型在所有可能的阈值选择下的性能。ROC 曲线就是这些选项之一。
ROC 代表“接收者操作特征”,它最初是为评估二战期间雷达探测器的强度而设计的。它被用来评估探测器如何区分两个信号:是否有飞机在那里。如今,它被用于类似的目的:它显示了模型如何区分两个类别,正类和负类。在我们的案例中,这些类别是“流失”和“未流失”。
我们需要两个指标来绘制 ROC 曲线:TPR 和 FPR,即真阳性率和假阳性率。让我们来看看这些指标。
4.3.1 真阳性率和假阳性率
ROC 曲线基于两个量,FPR 和 TPR:
-
假阳性率 (FPR):所有负例中假阳性的比例
-
真阳性率 (TPR):所有正例中真阳性的比例
与精确率和召回率一样,这些值基于混淆矩阵。我们可以使用以下公式来计算它们:
FPR = FP / (FP + TN)
TPR = TP / (TP + FN)
FPR 和 TPR 涉及混淆矩阵(图 4.18)的两个独立部分:
-
对于 FPR,我们查看表格的第一行:所有负例中假阳性的比例。
-
对于 TPR,我们查看表格的第二行:所有正例中真阳性的比例。

图 4.18 在计算 FPR 时,我们查看混淆矩阵的第一行,而在计算 TPR 时,我们查看第二行。
让我们为我们的模型计算这些值(图 4.19):
FPR = 172 / 1374 = 12.5%
FPR 是我们预测为流失的用户占未流失用户总数的比例。FPR 的值越小,说明模型越好——它有很少的假阳性:
TPR = 289 / 486 = 59%
TPR 是我们预测为流失的用户占实际流失用户总数的比例。请注意,TPR 与召回率相同,因此 TPR 越高越好。

图 4.19 FPR 是所有非流失客户中假阳性的比例:FPR 越小越好。TPR 是所有流失客户中真阳性的比例:TPR 越大越好。
然而,我们仍然只考虑 FPR 和 TPR 指标在单个阈值值上的情况——在我们的例子中,是 0.5。为了能够使用它们来绘制 ROC 曲线,我们需要为许多不同的阈值值计算这些指标。
4.3.2 在多个阈值下评估模型
二元分类模型,如逻辑回归,通常输出一个概率——介于零和一之间的分数。为了做出实际预测,我们通过设置某个阈值将输出二值化,以获得仅包含真和假值的分数。
我们可以像本章前面评估准确率时那样,对一系列阈值进行模型评估,而不是评估一个特定的阈值。
为了做到这一点,我们首先遍历不同的阈值值,并计算每个阈值的混淆矩阵值。
列表 4.1 计算不同阈值下的混淆矩阵
scores = [] ❶
thresholds = np.linspace(0, 1, 101) ❷
for t in thresholds: ❷
tp = ((y_pred >= t) & (y_val == 1)).sum() ❸
fp = ((y_pred >= t) & (y_val == 0)).sum() ❸
fn = ((y_pred < t) & (y_val == 1)).sum() ❸
tn = ((y_pred < t) & (y_val == 0)).sum() ❸
scores.append((t, tp, fp, fn, tn)) ❹
❶ 创建一个列表,我们将在这里保存结果
❷ 创建一个包含不同阈值值的数组,并遍历它们
❸ 计算每个阈值下的预测混淆矩阵
❹ 将结果追加到分数列表中
这个想法与我们之前用准确率所做的是类似的,但不同的是,我们记录的不是单个值,而是混淆矩阵的所有四个结果。
处理一个元组列表并不容易,所以让我们将其转换为 Pandas 数据框:
df_scores = pd.DataFrame(scores) ❶
df_scores.columns = ['threshold', 'tp', 'fp', 'fn', 'tn'] ❷
❶ 将列表转换为 Pandas dataframe
❷ 为 dataframe 的列分配名称
这给我们一个包含五个列的 dataframe(图 4.20)。

图 4.20 在不同阈值水平下评估的混淆矩阵元素的 dataframe。[::10]表达式选择 dataframe 中的每 10 条记录。
现在,我们可以计算 TPR 和 FPR 得分。因为数据现在在 dataframe 中,我们可以一次性计算所有值:
df_scores['tpr'] = df_scores.tp / (df_scores.tp + df_scores.fn)
df_scores['fpr'] = df_scores.fp / (df_scores.fp + df_scores.tn)
运行此代码后,dataframe 中新增了两列:tpr 和 fpr(图 4.21)。

图 4.21 包含混淆矩阵值以及在不同阈值下评估的 TPR 和 FPR 的 dataframe
让我们绘制它们(图 4.22):
plt.plot(df_scores.threshold, df_scores.tpr, label='TPR')
plt.plot(df_scores.threshold, df_scores.fpr, label='FPR')
plt.legend()

图 4.22 模型在不同阈值下的 TPR 和 FPR
TPR 和 FPR 都从 100%开始——在阈值为 0.0 时,我们预测每个人都“流失”:
-
FPR(假正率)为 100%,因为我们预测中只有假阳性。没有真正的阴性:没有人被预测为非流失。
-
TPR 为 100%,因为我们只有真正的阳性,没有假阴性。
随着阈值的增加,这两个指标都会下降,但下降速度不同。
理想情况下,FPR 应该迅速下降。小的 FPR 表明模型在预测负例(假阳性)时犯的错误很少。
另一方面,TPR 应该缓慢下降,理想情况下始终接近 100%:这意味着模型很好地预测了真正的阳性。
为了更好地理解这些 TPR 和 FPR 的含义,让我们将其与两个基线模型进行比较:一个随机模型和理想模型。我们将从一个随机模型开始。
4.3.3 随机基线模型
随机模型输出 0 到 1 之间的随机分数,无论输入如何。它很容易实现——我们只需生成一个包含均匀随机数的数组:
np.random.seed(1) ❶
y_rand = np.random.uniform(0, 1, size=len(y_val)) ❷
❶ 设置随机种子以确保可重复性
❷ 生成 0 到 1 之间的随机数数组
现在,我们可以简单地假设y_rand包含我们“模型”的预测。
让我们计算随机模型的 FPR 和 TPR。为了简化,我们将重用之前编写的代码并将其放入函数中。
列表 4.2 在不同阈值下计算 TPR 和 FPR 的函数
def tpr_fpr_dataframe(y_val, y_pred): ❶
scores = [] ❷
thresholds = np.linspace(0, 1, 101) ❷
for t in thresholds: ❷
tp = ((y_pred >= t) & (y_val == 1)).sum() ❷
fp = ((y_pred >= t) & (y_val == 0)).sum() ❷
fn = ((y_pred < t) & (y_val == 1)).sum() ❷
tn = ((y_pred < t) & (y_val == 0)).sum() ❷
scores.append((t, tp, fp, fn, tn)) ❷
df_scores = pd.DataFrame(scores) ❸
df_scores.columns = ['threshold', 'tp', 'fp', 'fn', 'tn'] ❸
df_scores['tpr'] = df_scores.tp / (df_scores.tp + df_scores.fn) ❹
df_scores['fpr'] = df_scores.fp / (df_scores.fp + df_scores.tn) ❹
return df_scores ❺
❶ 定义一个函数,该函数接受实际值和预测值
❷ 计算不同阈值下的混淆矩阵
❸ 将混淆矩阵中的数字转换为 dataframe
❹ 使用混淆矩阵中的数字计算 TPR 和 FPR
❺ 返回结果 dataframe
现在,让我们使用此函数来计算随机模型的 TPR 和 FPR:
df_rand = tpr_fpr_dataframe(y_val, y_rand)
这创建了一个包含不同阈值下 TPR 和 FPR 值的 dataframe(图 4.23)。

图 4.23 随机模型的 TPR 和 FPR 值
让我们绘制它们:
plt.plot(df_rand.threshold, df_rand.tpr, label='TPR')
plt.plot(df_rand.threshold, df_rand.fpr, label='FPR')
plt.legend()
我们看到,TPR 和 FPR 曲线几乎沿着直线从 100%下降到 0%,几乎遵循直线(图 4.24)。

图 4.24 随机分类器的 TPR 和 FPR 以直线从 100% 降至 0%。
在阈值为 0.0 时,我们将所有人视为流失。TPR 和 FPR 都是 100%:
-
FPR 为 100%,因为我们只有假正例:所有非流失客户都被识别为流失。
-
TPR 为 100%,因为我们只有真正的正例:我们可以正确地将所有流失客户分类为流失。
随着阈值的增加,TPR 和 FPR 都会降低。
在阈值为 0.4 时,概率为 40% 的模型预测“非流失”,概率为 60% 的模型预测“流失”。TPR 和 FPR 都是 60%:
-
FPR 为 60%,因为我们错误地将 60% 的非流失客户分类为流失。
-
TPR 为 60%,因为我们正确地将 60% 的流失客户分类为流失。
最后,在 1.0 的阈值下,TPR 和 FPR 都是 0%。在这个阈值下,我们预测所有人都是非流失:
-
FPR 为 0%,因为我们没有假正例:我们可以正确地将所有非流失客户分类为非流失。
-
TPR 为 0%,因为我们没有真正的正例:所有流失客户都被识别为非流失。
现在让我们继续到下一个基线,看看理想模型下的 TPR 和 FPR 看起来如何。
4.3.4 理想模型
理想模型总是做出正确的决策。我们将更进一步,考虑理想排名模型。该模型以这种方式输出分数,使得流失客户的分数总是高于非流失客户。换句话说,所有流失的预测概率应该高于非流失的预测概率。
因此,如果我们将模型应用于我们的验证集中的所有客户,然后按预测概率排序,我们首先将得到所有非流失客户,然后是流失客户(图 4.25)。

图 4.25 理想模型按顺序排列客户,首先是非流失客户,然后是流失客户。
当然,在现实生活中我们不可能有这样的模型。然而,它仍然很有用:我们可以用它来比较我们的 TPR 和 FPR 与理想模型的 TPR 和 FPR。
让我们生成理想的预测。为了简化,我们生成一个带有假目标变量的数组,这些变量已经排序:首先只包含 0s,然后只包含 1s(图 4.25)。至于“预测”,我们可以使用 np.linspace 函数创建一个数组,其中的数字从第一个单元格的 0 增长到最后一个单元格的 1。
让我们来做这件事:
num_neg = (y_val == 0).sum() ❶
num_pos = (y_val == 1).sum() ❶
y_ideal = np.repeat([0, 1], [num_neg, num_pos]) ❷
y_pred_ideal = np.linspace(0, 1, num_neg + num_pos) ❸
df_ideal = tpr_fpr_dataframe(y_ideal, y_pred_ideal) ❹
❶ 计算数据集中负例和正例的数量
❷ 生成一个数组,首先重复 0s num_neg 次数,然后重复 1s num_pos 次数
❸ 生成“模型”的预测:从第一个单元格的 0 增长到最后一个单元格的 1 的数字
❹ 计算分类器的 TPR 和 FPR 曲线
因此,我们得到一个包含理想模型 TPR 和 FPR 值的数据框(图 4.26)。您可以在附录 C 中了解更多关于 np.linspace 和 np.repeat 函数的信息。

图 4.26 理想模型的 TPR 和 FPR 值
现在我们可以绘制它(图 4.27):
plt.plot(df_ideal.threshold, df_ideal.tpr, label='TPR')
plt.plot(df_ideal.threshold, df_ideal.fpr, label='FPR')
plt.legend()

图 4.27 理想模型的 TPR 和 FPR 曲线
从图中,我们可以看到
-
TPR 和 FPR 都从 100%开始,结束于 0%。
-
对于低于 0.74 的阈值,我们总是正确地将所有流失客户分类为流失;这就是为什么 TRP 保持在 100%。另一方面,我们将一些非流失客户错误地分类为流失——这些是我们的假阳性。随着阈值的提高,越来越少地非流失客户被分类为流失,因此 FPR 下降。在 0.6 时,我们错误地将 258 名非流失客户分类为流失(图 4.28,A)。
-
阈值为 0.74 是理想情况:所有流失客户都被分类为流失,所有非流失客户都被分类为非流失;这就是为什么 TPR 是 100%,FPR 是 0%(图 4.28,B)。
-
在 0.74 到 1.0 之间,我们总是正确地将所有非流失客户分类,因此 FPR 保持在 0%。然而,当我们提高阈值时,我们开始错误地将越来越多的流失客户分类为非流失客户,因此 TPR 下降。在 0.8 时,446 名流失客户中有 114 名被错误地分类为非流失客户。只有 372 个预测是正确的,因此 TPR 是 76%(图 4.28,C)。

图 4.28 在不同阈值下评估的理想排名模型的 TPR 和 FPR
现在我们已经准备好构建 ROC 曲线。
练习 4.4
理想排名模型做什么?
a) 当应用于验证数据时,它对客户的评分使得对于非流失客户,评分总是低于流失客户。
b) 它将非流失客户的评分高于流失客户。
4.3.5 ROC 曲线
要创建 ROC 曲线,我们不是将 FPR 和 TPR 与不同的阈值值进行比较,而是将它们相互比较。为了比较,我们还添加了理想和随机模型到图中:
plt.figure(figsize=(5, 5)) ❶
plt.plot(df_scores.fpr, df_scores.tpr, label='Model') ❷
plt.plot(df_rand.fpr, df_rand.tpr, label='Random') ❷
plt.plot(df_ideal.fpr, df_ideal.tpr, label='Ideal') ❷
plt.legend()
❶ 使图表成为正方形
❷ 绘制模型和基线模型的 ROC 曲线
结果,我们得到了一个 ROC 曲线(图 4.29)。当我们绘制它时,我们可以看到随机分类器的 ROC 曲线是从左下角到右上角的近似直线。然而,对于理想模型,曲线首先上升直到达到 100% TPR,然后向右直到达到 100% FPR。

图 4.29 ROC 曲线显示了模型 FPR 和 TPR 之间的关系。
我们的目标模型应该位于这两条曲线之间。我们希望我们的模型尽可能接近理想曲线,尽可能远离随机曲线。
随机模型的 ROC 曲线作为一个良好的视觉基线——当我们将其添加到图中时,它有助于我们判断我们的模型与这个基线有多远——所以总是在图中包含这条线是个好主意。
然而,我们并不真的需要在每次想要有一个 ROC 曲线时都生成一个随机模型:我们知道它看起来像什么,所以我们可以简单地在一个从(0, 0)到(1, 1)的直线上绘制一条直线。
对于理想模型,我们知道它总是上升到(0, 1),然后向右到(1, 1)。左上角被称为“理想点”:这是理想模型获得 100% TPR 和 0% FPR 的点。我们希望我们的模型尽可能地接近理想点。
有这个信息,我们可以将绘制曲线的代码简化为以下内容:
plt.figure(figsize=(5, 5))
plt.plot(df_scores.fpr, df_scores.tpr)
plt.plot([0, 1], [0, 1])
这产生了图 4.30 中的结果。

图 4.30 ROC 曲线。基线使得我们更容易看到我们的模型 ROC 曲线与随机模型 ROC 曲线的距离。左上角(0, 1)是“理想点”:我们的模型越接近这一点,越好。
虽然计算许多阈值下的所有 FPR 和 TPR 值是一个很好的练习,但每次我们想要绘制 ROC 曲线时,我们不需要自己来做。我们只需使用 Scikit-learn 的metrics包中的roc_curve函数即可:
from sklearn.metrics import roc_curve
fpr, tpr, thresholds = roc_curve(y_val, y_pred)
plt.figure(figsize=(5, 5))
plt.plot(fpr, tpr)
plt.plot([0, 1], [0, 1])
因此,我们得到了与前一个图相同的图(图 4.30)。
现在我们来尝试更深入地理解曲线,并了解它实际上能告诉我们什么。为此,我们将 TPR 和 FPR 值在 ROC 曲线上可视化为它们的阈值(图 4.31)。
在 ROC 图中,我们从(0, 0)点开始——这是左下角的位置。它对应于 0%的 FPR 和 0%的 TPR,这发生在高阈值如 1.0 时,此时没有客户得分高于该值。对于这些情况,我们只是简单地预测“无流失”给所有人。这就是为什么我们的 TPR 是 0%:我们从未正确预测到流失客户。另一方面,FPR 是 0%,因为这个虚拟模型可以正确预测所有非流失客户为非流失,因此没有假阳性。
随着曲线上升,我们考虑在更小阈值下评估的 FPR 和 TPR 值。在 0.7 时,FPR 变化很小,从 0%变为 2%,但 TPR 从 0%增加到 20%(图 4.31,B 和 C)。

(A) 不同阈值下的 TPR 和 FPR

(B) 模型在不同阈值下的 FPR 和 TPR 值

(C) 选择阈值下的 FPR 和 TPR 值
图 4.31 将不同阈值下的 TPR 和 FPR 图(A 和 B)转换为 ROC 曲线(C)。在 ROC 图中,我们从左下角的高阈值值开始,此时大多数客户被预测为非流失,并逐渐过渡到右上角,此时大多数客户被预测为流失。
随着我们沿着线走,我们不断降低阈值,并在更小的值上评估模型,预测越来越多的客户为流失。在某个点上,我们覆盖了大部分的正面(流失客户)。例如,在 0.2 的阈值下,我们预测大多数用户为流失,这意味着许多这些预测是假阳性。FPR 随后开始比 TPR 增长得更快;在 0.2 的阈值下,它已经接近 40%。
最终,我们达到 0.0 阈值,预测每个人都将流失,从而达到 ROC 图的最右上角。
当我们从高阈值值开始时,所有模型都是相同的:任何在高阈值值下的模型都会退化到始终预测 False 的恒定“模型”。随着我们降低阈值,我们开始预测一些客户会流失。模型越好,正确分类为流失的客户就越多,从而产生更好的 TPR。同样,好的模型 FPR 较小,因为它们有更少的误报。
因此,一个好的模型的 ROC 曲线首先尽可能地上升,然后才开始向右转。另一方面,表现差的模型从一开始就具有更高的 FPR(假正率)和更低的 TPR(真正率),因此它们的曲线倾向于更早地向右移动(图 4.32)。

图 4.32 良好模型的 ROC 曲线在转向右之前尽可能地上升。另一方面,表现差的模型一开始就倾向于有更多的误报,因此它们倾向于更早地向右移动。
我们可以用这个方法来比较多个模型:我们只需将它们绘制在同一张图上,并查看哪个更接近(0, 1)的理想点。例如,让我们看一下大模型和小模型的 ROC 曲线,并将它们绘制在同一张图上:
fpr_large, tpr_large, _ = roc_curve(y_val, y_pred)
fpr_small, tpr_small, _ = roc_curve(y_val, y_pred_small)
plt.figure(figsize=(5, 5))
plt.plot(fpr_large, tpr_large, color='black', label='Large')
plt.plot(fpr_small, tpr_small, color='black', label='Small')
plt.plot([0, 1], [0, 1])
plt.legend()
这样我们可以在同一张图上得到两个 ROC 曲线(图 4.33)。我们可以看到大模型比小模型好:它在所有阈值下都更接近理想点。

图 4.33 在同一张图上绘制多个 ROC 曲线有助于我们直观地识别哪个模型表现更好。
ROC 曲线本身非常有用,但我们还有一个基于它的另一个指标:AUC,即 ROC 曲线下的面积。
4.3.6 ROC 曲线下的面积(AUC)
当我们使用 ROC 曲线来评估我们的模型时,希望它们尽可能地接近理想位置,并且尽可能地远离随机基线。
我们可以通过测量 ROC 曲线下的面积来量化这种“接近度”。我们可以使用这个指标——简称为 AUROC,或通常简单地称为 AUC——作为评估二元分类模型性能的指标。
理想模型形成一个 1x1 的正方形,因此其 ROC 曲线下的面积是 1,或 100%。随机模型只占一半,因此其 AUC 是 0.5,或 50%。我们两个模型的 AUC——大模型和小模型——将在 50%的随机基线和 100%的理想曲线之间。
重要:AUC(曲线下面积)为 0.9 表明模型表现相当好;0.8 是可接受的,0.7 表现不佳,而 0.6 则表示表现相当差。
要计算我们模型的 AUC,我们可以使用来自 Scikit-learn 的metrics包中的auc函数:
from sklearn.metrics import auc
auc(df_scores.fpr, df_scores.tpr)
对于大模型,结果是 0.84;对于小模型,结果是 0.81(图 4.34)。客户流失预测是一个复杂的问题,因此 AUC 为 80%已经相当不错。

图 4.34 我们模型的 AUC:大模型为 84%,小模型为 81%
如果我们只需要 AUC,我们不需要先计算 ROC 曲线。我们可以走捷径,使用 Scikit-learn 的roc_auc_score函数,它负责一切并直接返回我们模型的 AUC:
from sklearn.metrics import roc_auc_score
roc_auc_score(y_val, y_pred)
我们得到的结果与之前的大致相同(图 4.35)。

图 4.35 使用 Scikit-learn 的roc_auc_score函数计算 AUC。
注意:roc_auc_score的值可能与我们自己计算 TPR 和 FPR 的数据框中的 AUC 值略有不同:Scikit-learn 内部使用更精确的方法来创建 ROC 曲线。
ROC 曲线和 AUC 得分告诉我们模型如何将正例和负例分开。更重要的是,AUC 有一个很好的概率解释:它告诉我们随机选择的正例得分高于随机选择的负例得分的概率是多少。
假设我们随机选择一个已知已经流失的客户和一个没有流失的客户,然后对这些客户应用模型并查看每个客户的得分。我们希望模型对流失客户的评分高于非流失客户。AUC 告诉我们这种情况发生的概率:这是随机选择的流失客户得分高于随机选择的非流失客户得分的概率。
我们可以验证这一点。如果我们进行 10,000 次这样的实验,然后统计正例得分高于负例得分的情况有多少次,那么这个比例应该大致对应于 AUC:
neg = y_pred[y_val == 0] ❶
pos = y_pred[y_val == 1] ❷
np.random.seed(1) ❸
neg_choice = np.random.randint(low=0, high=len(neg), size=10000) ❹
pos_choice = np.random.randint(low=0, high=len(pos), size=10000) ❺
(pos[pos_choice] > neg[neg_choice]).mean() ❻
❶ 选择所有非流失客户的得分
❷ 选择所有流失客户的得分
❸ 将种子值固定以确保结果可重复
❹ 随机选择 10,000 个负例得分(非流失客户)
❺ 随机选择 10,000 个正例得分(流失客户)
❻ 对于每个正例,检查其得分是否高于相应的负例
这会输出 0.8356,这确实非常接近我们分类器的 AUC 值。
这种对 AUC 的解释使我们能够对模型的质量有更深入的了解。理想的模型将所有客户按顺序排列,首先是未流失客户,然后是流失客户。在这种情况下,AUC 总是 1.0:随机选择的流失客户得分总是高于非流失客户。另一方面,随机模型只是重新排列客户,因此流失客户的得分只有 50%的机会高于非流失客户。
因此,AUC 不仅为我们提供了一种评估所有可能阈值下模型的方法,而且还描述了模型分离两个类别的好坏:在我们的例子中,是流失和非流失。如果分离得好,那么我们可以按顺序排列客户,使得大多数流失用户排在前面。这样的模型将有一个好的 AUC 得分。
注意:你应该记住这个解释:它为那些没有机器学习背景的人提供了一个简单的方式来解释 AUC 的含义,比如经理和其他决策者。
这使得 AUC 成为大多数情况下的默认分类指标,并且当我们寻找模型的最佳参数集时,它通常是使用的指标。
寻找最佳参数的过程称为“参数调整”,在下一节中我们将看到如何进行这一过程。
4.4 参数调整
在上一章中,我们使用了一个简单的保留验证方案来测试我们的模型。在这个方案中,我们取出部分数据并仅保留它用于验证目的。这种做法很好,但并不总是能给出完整的画面。它告诉我们模型在这些特定的数据点上表现如何。然而,这并不一定意味着模型在其他数据点上会有相同的表现。那么,我们如何检查模型是否确实以一致和可预测的方式工作呢?
4.4.1 K 折交叉验证
有可能使用所有可用数据来评估模型的质量并获得更可靠的验证结果。我们可以简单地多次进行验证。
首先,我们将整个数据集分成若干部分(比如说,三部分)。然后我们在其中两部分上训练一个模型,在剩下的那部分上进行验证。我们重复这个过程三次,最后得到三个不同的分数。这正是 K 折交叉验证(图 4.36)背后的理念。

图 4.36 K 折交叉验证(K=3)。我们将整个数据集分成三个相等的部分,或者称为折。然后,对于每一折,我们将它作为验证数据集,并使用剩下的 K-1 折作为训练数据。在训练模型后,我们在验证折上评估它,最后我们得到 k 个指标值。
在我们实现它之前,我们需要使训练过程更简单,这样就可以轻松地多次运行这个过程。为此,我们将所有训练代码放入一个train函数中,该函数首先将数据转换为 one-hot 编码表示,然后训练模型。
列表 4.3 训练模型
def train(df, y):
cat = df[categorical + numerical].to_dict(orient='records') ❶
dv = DictVectorizer(sparse=False) ❶
dv.fit(cat) ❶
X = dv.transform(cat) ❶
model = LogisticRegression(solver='liblinear') ❷
model.fit(X, y) ❷
return dv, model
❶ 应用 one-hot 编码
❷ 训练模型
同样,我们也把预测逻辑放入一个predict函数中。这个函数接受一个包含客户的数据框,我们之前“训练”过的向量器——用于进行 one-hot 编码——以及模型。然后我们应用向量器到数据框上,得到一个矩阵,最后将模型应用到矩阵上以得到预测。
列表 4.4 将模型应用于新数据
def predict(df, dv, model):
cat = df[categorical + numerical].to_dict(orient='records') ❶
X = dv.transform(cat) ❶
y_pred = model.predict_proba(X)[:, 1] ❷
return y_pred
❶ 应用与训练相同的 one-hot 编码方案
❷ 使用模型进行预测
现在我们可以使用这些函数来实现 K 折交叉验证。
我们不需要自己实现交叉验证:在 Scikit-learn 中有一个用于此目的的类。它被称为KFold,位于model_selection包中。
列表 4.5 K 折交叉验证
from sklearn.model_selection import KFold ❶
kfold = KFold(n_splits=10, shuffle=True, random_state=1) ❷
aucs = [] ❸
for train_idx, val_idx in kfold.split(df_train_full): ❹
df_train = df_train_full.iloc[train_idx] ❺
df_val = df_train_full.iloc[val_idx] ❺
y_train = df_train.churn.values ❺
y_val = df_val.churn.values ❺
dv, model = train(df_train, y_train) ❻
y_pred = predict(df_val, dv, model) ❻
auc = roc_auc_score(y_val, y_pred) ❼
aucs.append(auc) ❽
❶ 导入 KFold 类
❷ 使用它将数据分割成 10 部分
❸ 创建一个用于存储结果的列表
❹ 对数据的 10 个不同分割进行迭代
❺ 将数据分割成训练集和验证集
❻ 训练模型并进行预测
❼ 使用 AUC 评估训练模型在验证数据上的质量
❽ 将 AUC 保存到结果列表中
注意,当在 KFold 类中定义分割时(❷),我们设置了三个参数:
-
n_splits=10:这是 K,它指定了分割的数量。 -
shuffle=True:我们要求它在分割数据之前先打乱数据。 -
random_state=1:因为在这个过程中有随机化(打乱数据),我们希望结果是可以重现的,因此我们固定随机数生成器的种子。
在这里,我们使用了 K 折交叉验证,K = 10。因此,当我们运行它时,最后我们得到 10 个不同的数字——10 个 AUC 分数,这些分数是在 10 个不同的验证折叠上评估的:
0.849, 0.841, 0.859, 0.833, 0.824, 0.841, 0.844, 0.822, 0.845, 0.861
它不再是单个数字,我们可以将其视为模型 AUC 分数的分布。我们可以从这个分布中获得一些统计数据,例如均值和标准差:
print('auc = %0.3f ± %0.3f' % (np.mean(aucs), np.std(aucs)))
这会打印“0.842 ± 0.012”。
现在,我们不仅知道了平均性能,而且还有关于该性能波动性的一个概念,或者它可能偏离平均值的程度。
一个好的模型应该在不同的折叠中相当稳定:这样我们才能确保当模型上线时不会出现很多意外。标准差告诉我们这一点:它越小,模型就越稳定。
现在,我们可以使用 K 折交叉验证来进行参数调整:选择最佳参数。
4.4.2 寻找最佳参数
我们学习了如何使用 K 折交叉验证来评估我们模型的性能。我们之前训练的模型使用的是参数 C 的默认值,该参数控制正则化的数量。
让我们选择我们的交叉验证过程来选择最佳参数 C。为此,我们首先调整 train 函数以接受一个额外的参数。
列表 4.6 用于训练具有参数 C 的模型的函数,以控制正则化
def train(df, y, C): ❶
cat = df[categorical + numerical].to_dict(orient='records')
dv = DictVectorizer(sparse=False)
dv.fit(cat)
X = dv.transform(cat)
model = LogisticRegression(solver='liblinear', C=C) ❷
model.fit(X, y)
return dv, model
❶ 在训练函数中添加一个额外的参数
❷ 在训练期间使用此参数
现在,让我们找到最佳的参数 C。这个想法很简单:
-
遍历不同的
C值。 -
对于每个
C,运行交叉验证,并记录所有折叠的平均 AUC 以及标准差。
列表 4.7 调整模型:使用交叉验证选择最佳参数 C
nfolds = 5
kfold = KFold(n_splits=nfolds, shuffle=True, random_state=1)
for C in [0.001, 0.01, 0.1, 0.5, 1, 10]:
aucs = []
for train_idx, val_idx in kfold.split(df_train_full):
df_train = df_train_full.iloc[train_idx]
df_val = df_train_full.iloc[val_idx]
y_train = df_train.churn.values
y_val = df_val.churn.values
dv, model = train(df_train, y_train, C=C)
y_pred = predict(df_val, dv, model)
auc = roc_auc_score(y_val, y_pred)
aucs.append(auc)
print('C=%s, auc = %0.3f ± %0.3f' % (C, np.mean(aucs), np.std(aucs)))
当我们运行它时,它会打印
C=0.001, auc = 0.825 ± 0.013
C=0.01, auc = 0.839 ± 0.009
C=0.1, auc = 0.841 ± 0.008
C=0.5, auc = 0.841 ± 0.007
C=1, auc = 0.841 ± 0.007
C=10, auc = 0.841 ± 0.007
我们看到,当 C = 0.1 后,平均 AUC 保持不变,不再增长。
然而,当C = 0.5 时,标准差比C = 0.1 时小,因此我们应该使用那个。我们更喜欢C = 0.5 而不是C = 1 和C = 10 的原因很简单:当C参数较小时,模型更正则化。这个模型的权重更受限制,所以一般来说,它们更小。模型中的小权重给我们额外的保证,即当我们使用真实数据时,模型会表现良好。因此,我们选择C = 0.5。
现在我们需要做最后一步:在完整的训练和验证数据集上训练模型,并将其应用于测试数据集以验证它确实工作得很好。
让我们使用我们的train和predict函数来做这件事:
y_train = df_train_full.churn.values
y_test = df_test.churn.values
dv, model = train(df_train_full, y_train, C=0.5) ❶
y_pred = predict(df_test, dv, model) ❷
auc = roc_auc_score(y_test, y_pred) ❸
print('auc = %.3f' % auc) ❸
❶ 在完整训练数据集上训练模型
❷ 将其应用于测试数据集
❸ 在测试数据上评估预测
当我们执行代码时,我们看到模型在保留的测试集上的性能(AUC)为 0.858。
这比我们在验证集上得到的分数略高,但这不是问题;这可能是偶然发生的。重要的是,分数与验证分数没有显著差异。
现在,我们可以使用这个模型对真实客户进行评分,并考虑我们的防止客户流失的市场营销活动。在下一章中,我们将看到如何在生产环境中部署这个模型。
4.5 下一步
4.5.1 练习
尝试以下练习以进一步探索模型评估和模型选择的话题:
-
在本章中,我们绘制了不同阈值值下的 TPR 和 FPR,这帮助我们理解了这些指标的含义,以及当我们选择不同的阈值时,模型性能如何变化。对于精度和召回率进行类似的练习是有帮助的,所以尝试重复这个实验,这次使用精度和召回率而不是 TPR 和 FPR。
-
当绘制不同阈值值下的精度和召回率时,我们可以看到精度和召回率之间存在冲突:当一个上升时,另一个下降,反之亦然。这被称为“精度-召回率权衡”:我们无法选择一个使精度和召回率都好的阈值。然而,我们确实有选择阈值的策略,尽管精度和召回率是冲突的。其中之一是绘制精度和召回率曲线,并查看它们的交点,然后使用这个阈值对预测进行二值化。尝试实现这个想法。
-
另一个解决精度-召回率权衡问题的方法是 F1 分数——一个将精度和召回率结合成一个值的分数。然后,为了选择最佳阈值,我们可以简单地选择最大化 F1 分数的那个。计算 F1 分数的公式是 F1 = 2 · P · R / (P + R),其中 P 是精度,R 是召回率。实施这个想法,并根据 F1 指标选择最佳阈值。
-
我们已经看到,精确度和召回率是比准确率更好的分类模型评估指标,因为它们不依赖于假阳性,而在不平衡数据集中假阳性的数量可能很高。然而,我们后来发现,AUC 实际上确实使用了 FPR 中的假阳性。对于非常不平衡的情况(例如,1,000 个负例对 1 个正例),AUC 也可能成为问题。在这种情况下,另一个指标表现得更好:精确度-召回率曲线下的面积,或称 AU PR。精确度-召回率曲线类似于 ROC 曲线,但不是绘制 FPR 与 TPR 的对比,而是我们在 x 轴上绘制召回率,在 y 轴上绘制精确率。就像 ROC 曲线一样,我们也可以计算 PR 曲线下的面积,并将其用作评估不同模型的指标。尝试绘制我们模型的 PR 曲线,计算 AU PR 分数,并将它们与随机模型以及理想模型的分数进行比较。
-
我们介绍了 K 折交叉验证,并使用它来了解测试数据集上 AUC 分数的分布可能是什么样子。当 K = 10 时,我们得到 10 个观察值,在某些情况下可能不够。然而,这个想法可以扩展到重复的 K 折交叉验证步骤。这个过程很简单:我们多次重复 K 折交叉验证过程,每次迭代通过选择不同的随机种子来以不同的方式对数据集进行洗牌。实现重复交叉验证,并进行 10 折交叉验证 10 次,以查看分数的分布情况。
4.5.2 其他项目
你也可以继续学习前一章中的其他自学项目:线索评分项目和违约预测项目。尝试以下操作:
-
计算本章中涵盖的所有指标:混淆矩阵、精确度和召回率,以及 AUC。还尝试计算练习中的分数:F1 分数以及 AU PR(精确度-召回率曲线下的面积)。
-
使用 K 折交叉验证来选择模型的最佳参数
C。
摘要
-
指标是用于评估机器学习模型性能的单个数字。一旦我们选择了一个指标,我们就可以用它来比较多个机器学习模型,并选择最佳模型。
-
准确率是最简单的二元分类指标:它告诉我们验证集中正确分类的观察值的百分比。它易于理解和计算,但当数据集不平衡时可能会产生误导。
-
当一个二元分类模型做出预测时,我们只有四种可能的结果:真正例和真负例(正确答案)以及假阳性和假阴性(错误答案)。混淆矩阵以视觉方式排列这些结果,使其易于理解。它为我们提供了许多其他二元分类指标的基础。
-
精确度是我们预测为真的观测值中正确答案的比例。如果我们使用流失模型发送促销信息,精确度告诉我们收到信息的所有客户中真正打算流失的客户百分比。精确度越高,我们错误地将非流失用户分类为流失的用户就越少。
-
召回率是所有正观测值中正确答案的比例。它告诉我们我们正确识别为流失的流失客户百分比。召回率越高,我们未能识别的流失客户就越少。
-
ROC 曲线同时分析所有阈值下的二分类模型。ROC 曲线下的面积(AUC)告诉我们模型将正观测值与负观测值区分得有多好。由于其可解释性和广泛适用性,AUC 已成为评估二分类模型的默认指标。
-
K 折交叉验证为我们提供了一种使用所有训练数据进行模型验证的方法:我们将数据分成 K 个折叠,依次使用每个折叠作为验证集,其余的 K-1 个折叠用于训练。结果,我们得到的不是单个数字,而是 K 个值,每个折叠一个。我们可以使用这些数字来了解模型平均性能,以及估计它在不同折叠之间的波动性。
-
K 折交叉验证是调整参数和选择最佳模型的最佳方法:它为我们提供了跨多个折叠的指标可靠估计。
在下一章中,我们将探讨将我们的模型部署到生产环境中的方法。
练习题答案
-
练习 4.1 B) 我们预测会“流失”的客户,但他们并没有流失。
-
练习 4.2 B) 我们预测为流失的客户中实际流失的客户百分比。
-
练习 4.3 A) 在所有流失客户中正确识别的流失客户百分比。
-
练习 4.4 A) 理想排名模型总是给流失客户比非流失客户更高的分数。
5 部署机器学习模型
本章涵盖
-
使用 Pickle 保存模型
-
使用 Flask 提供模型服务
-
使用 Pipenv 管理依赖项
-
使用 Docker 使服务自包含
-
使用 AWS Elastic Beanstalk 将其部署到云端
随着我们继续使用机器学习技术,我们将继续使用我们已经开始的项目:流失预测。在第三章中,我们使用 Scikit-learn 构建了一个用于识别流失客户的模型。之后,在第四章中,我们使用交叉验证选择了最佳参数 C 来评估该模型的质量。
我们已经有一个存在于我们的 Jupyter Notebook 中的模型。现在我们需要将这个模型投入生产,以便其他服务可以使用模型根据我们模型的结果做出决策。
在本章中,我们介绍 模型部署:将模型投入使用的流程。特别是,我们看看如何将模型打包到网络服务中,以便其他服务可以使用它。我们还看看如何将网络服务部署到生产就绪环境。
5.1 流失预测模型
为了开始部署,我们使用之前训练的模型。首先,在本节中,我们回顾如何使用该模型进行预测,然后我们看看如何使用 Pickle 保存它。
5.1.1 使用模型
为了简化操作,我们可以继续使用我们在第三章和第四章中使用的相同的 Jupyter Notebook。
让我们使用这个模型来计算以下客户的流失概率:
customer = {
'customerid': '8879-zkjof',
'gender': 'female',
'seniorcitizen': 0,
'partner': 'no',
'dependents': 'no',
'tenure': 41,
'phoneservice': 'yes',
'multiplelines': 'no',
'internetservice': 'dsl',
'onlinesecurity': 'yes',
'onlinebackup': 'no',
'deviceprotection': 'yes',
'techsupport': 'yes',
'streamingtv': 'yes',
'streamingmovies': 'yes',
'contract': 'one_year',
'paperlessbilling': 'yes',
'paymentmethod': 'bank_transfer_(automatic)',
'monthlycharges': 79.85,
'totalcharges': 3320.75,
}
为了预测这位客户是否会流失,我们可以使用上一章中编写的 predict 函数:
df = pd.DataFrame([customer])
y_pred = predict(df, dv, model)
y_pred[0]
此函数需要一个数据框,因此我们首先创建一个包含一行——我们的客户的数据框。然后,我们将它放入 predict 函数中。结果是包含单个元素的 NumPy 数组——这位客户的预测流失概率:
0.059605
这意味着这位客户有 6% 的流失概率。
现在让我们看看之前编写的 predict 函数,该函数用于将模型应用于验证集中的客户:
def predict(df, dv, model):
cat = df[categorical + numerical].to_dict(orient='rows')
X = dv.transform(cat)
y_pred = model.predict_proba(X)[:, 1]
return y_pred
对于单个客户使用它似乎效率低下且不必要:我们仅从单个客户创建一个数据框,然后在 predict 中将其转换回字典。
为了避免进行这种不必要的转换,我们可以为仅预测单个客户的流失概率创建一个单独的函数。让我们称这个函数为 predict_single:
def predict_single(customer, dv, model): ❶
X = dv.transform([customer]) ❷
y_pred = model.predict_proba(X)[:, 1] ❸
return y_pred[0] ❹
❶ 不是传递一个数据框,而是传递单个客户
❷ 向量化客户:创建矩阵 X
❸ 将模型应用于此矩阵
❹ 由于我们只有一个客户,我们只需要结果的第一元素。
使用它变得简单——我们只需用我们的客户(一个字典)调用它:
predict_single(customer, dv, model)
结果相同:这位客户有 6% 的流失概率。
我们在第三章开始的 Jupyter Notebook 中训练了我们的模型。这个模型就在那里,一旦我们停止 Jupyter Notebook,训练好的模型就会消失。这意味着现在我们只能在笔记本中使用它,其他地方都不能使用。接下来,我们将看到如何解决这个问题。
5.1.2 使用 Pickle 保存和加载模型
要在笔记本之外使用它,我们需要保存它,然后稍后,另一个进程可以加载并使用它(图 5.1)。

图 5.1 我们在一个 Jupyter Notebook 中训练模型。要使用它,我们首先需要保存它,然后在不同的进程中加载它。
Pickle 是一个序列化/反序列化模块,它已经内置到 Python 中:使用它,我们可以将任意 Python 对象(除少数例外)保存到文件中。一旦我们有了文件,我们就可以在不同的进程中从那里加载模型。
注意:“Pickle”也可以用作动词:在 Python 中 pickle 一个对象意味着使用 Pickle 模块保存它。
保存模型
要保存模型,我们首先导入 Pickle 模块,然后使用 dump 函数:
import pickle
with open('churn-model.bin', 'wb') as f_out: ❶
pickle.dump(model, f_out) ❷
❶ 指定我们想要保存的文件
❷ 使用 Pickle 将模型保存到文件
要保存模型,我们使用 open 函数,它接受两个参数:
-
我们想要打开的文件名。对我们来说,它是 churn-model.bin。
-
我们打开文件的模式。对我们来说,它是
wb,这意味着我们想要写入文件(w),文件是二进制(b)而不是文本——Pickle 使用二进制格式写入文件。
open 函数返回 f_out——我们可以用来写入文件的文件描述符。
接下来,我们使用 Pickle 的 dump 函数。它也接受两个参数:
-
我们想要保存的对象。对我们来说,它是
model。 -
指向输出文件的文件描述符,对我们来说它是
f_out。
最后,我们在代码中使用 with 构造。当我们使用 open 打开文件时,我们需要在完成写入后关闭它。使用 with 会自动完成。如果没有使用 with,我们的代码将看起来像这样:
f_out = open('churn-model.bin', 'wb')
pickle.dump(model, f_out)
f_out.close()
然而,在我们的情况下,仅仅保存模型是不够的:我们还有一个与模型一起“训练”的 DictVectorizer。我们需要保存这两个。
在序列化时将这两个都放在一个元组中是最简单的方法:
with open('churn-model.bin', 'wb') as f_out:
pickle.dump((dv, model), f_out) ❶
❶ 我们保存的对象是一个包含两个元素的元组。
加载模型
要加载模型,我们使用 Pickle 的 load 函数。我们可以在同一个 Jupyter Notebook 中测试它:
with open('churn-model.bin', 'rb') as f_in: ❶
dv, model = pickle.load(f_in) ❷
❶ 以读取模式打开文件
❷ 加载元组并解包它
我们再次使用 open 函数,但这次使用不同的模式:rb,这意味着我们以读取模式打开它(r),文件是二进制(b)。
警告:指定模式时要小心。意外指定错误模式可能会导致数据丢失:如果你用 w 模式而不是 r 模式打开现有文件,它将覆盖内容。
因为我们保存了一个元组,所以在加载时我们解包它,因此我们同时得到向量化器和模型。
警告:从互联网上解包对象是不安全的:它可以在您的机器上执行任意代码。仅用于您信任的事物以及您自己保存的事物。
让我们创建一个简单的 Python 脚本,该脚本加载模型并将其应用于客户。
我们将把这个文件命名为 churn_serving.py。(在本书的 GitHub 仓库中,这个文件被命名为 churn_serving_simple.py。)它包含
-
我们之前编写的
predict_single函数 -
加载模型的代码
-
将模型应用于客户的代码
您可以参考附录 B 来了解如何创建 Python 脚本。
首先,我们从导入开始。对于这个脚本,我们需要导入 Pickle 和 NumPy:
import pickle
import numpy as np
接下来,让我们将predict_single函数放在那里:
def predict_single(customer, dv, model):
X = dv.transform([customer])
y_pred = model.predict_proba(X)[:, 1]
return y_pred[0]
现在,我们可以加载我们的模型:
with open('churn-model.bin', 'rb') as f_in:
dv, model = pickle.load(f_in)
并应用它:
customer = {
'customerid': '8879-zkjof',
'gender': 'female',
'seniorcitizen': 0,
'partner': 'no',
'dependents': 'no',
'tenure': 41,
'phoneservice': 'yes',
'multiplelines': 'no',
'internetservice': 'dsl',
'onlinesecurity': 'yes',
'onlinebackup': 'no',
'deviceprotection': 'yes',
'techsupport': 'yes',
'streamingtv': 'yes',
'streamingmovies': 'yes',
'contract': 'one_year',
'paperlessbilling': 'yes',
'paymentmethod': 'bank_transfer_(automatic)',
'monthlycharges': 79.85,
'totalcharges': 3320.75,
}
prediction = predict_single(customer, dv, model)
最后,让我们显示结果:
print('prediction: %.3f' % prediction)
if prediction >= 0.5:
print('verdict: Churn')
else:
print('verdict: Not churn')
保存文件后,我们可以使用 Python 运行此脚本:
python churn_serving.py
我们应该立即看到结果:
prediction: 0.059
verdict: Not churn
这样,我们就可以加载模型并将其应用于脚本中指定的客户。
当然,我们不会手动将客户信息放入脚本中。在下一节中,我们将介绍一个更实用的方法:将模型放入网络服务中。
5.2 模型提供
我们已经知道如何在不同的进程中加载一个训练好的模型。现在我们需要提供这个模型——使其可供他人使用。
在实践中,这通常意味着模型被部署为一个网络服务,这样其他服务就可以与之通信,请求预测,并使用结果来做出自己的决策。
在本节中,我们将展示如何使用 Flask 在 Python 中实现它——一个用于创建网络服务的 Python 框架。首先,我们来看看为什么我们需要使用网络服务。
5.2.1 网络服务
我们已经知道如何使用模型进行预测,但到目前为止,我们只是将客户的特征作为 Python 字典硬编码。让我们尝试想象我们的模型在实际中是如何被使用的。
假设我们有一个运行营销活动的服务。对于每个客户,它需要确定流失的概率,如果足够高,它将发送带有折扣的促销电子邮件。当然,这个服务需要使用我们的模型来决定是否应该发送电子邮件。
实现这一目标的一种可能方式是修改活动服务的代码:加载模型,并在服务中评分客户。这种方法很好,但活动服务需要是 Python 编写的,我们需要完全控制其代码。
不幸的是,这种情况并不总是如此:它可能用某种其他语言编写,或者可能由不同的团队负责这个项目,这意味着我们不会拥有所需的控制权。
解决这个问题的典型方案是将模型放入一个网络服务中——一个只负责评分客户的小型服务(一个微服务)。
因此,我们需要创建一个流失服务——一个在 Python 中提供流失模型的网络服务。给定客户的特征,它将返回该客户流失的概率。对于每个客户,营销服务将向流失服务请求流失概率,如果流失概率足够高,那么我们将发送促销电子邮件(图 5.2)。

图 5.2 流失服务负责提供流失预测模型,使其他服务能够使用它。
这给我们带来了另一个优势:关注点的分离。如果模型是由数据科学家创建的,那么他们可以负责服务并维护它,而其他团队则负责营销服务。
在 Python 中创建网络服务最受欢迎的框架之一是 Flask,我们将在下一节中介绍。
5.2.2 Flask
在 Python 中实现网络服务最简单的方法是使用 Flask。它相当轻量级,启动时需要很少的代码,并且隐藏了处理 HTTP 请求和响应的大部分复杂性。
在我们将模型放入网络服务之前,让我们先了解使用 Flask 的基础知识。为此,我们将创建一个简单的函数并将其作为网络服务提供。在了解基础知识之后,我们将处理模型。
假设我们有一个简单的 Python 函数,名为 ping:
def ping():
return 'PONG'
它没有做太多:当被调用时,它只是简单地响应 PONG。让我们使用 Flask 将这个函数转换成网络服务。
Anaconda 预装了 Flask,但如果您使用不同的 Python 发行版,您需要安装它:
pip install flask
我们将把这个代码放入一个 Python 文件中,并将其命名为 flask_test.py。
要使用 Flask,我们首先需要导入它:
from flask import Flask
现在我们创建一个 Flask 应用程序——这是注册需要在网络服务中公开的函数的中心对象。我们将我们的应用程序命名为 test:
app = Flask('test')
接下来,我们需要指定如何通过分配地址或 Flask 术语中的“路由”来访问函数。在我们的例子中,我们想使用 /ping 地址:
@app.route('/ping', methods=['GET']) ❶
def ping():
return 'PONG'
❶ 注册了 /ping 路由,并将其分配给 ping 函数
这段代码使用了装饰器——这是一种高级的 Python 功能,我们在这本书中不会详细讲解。我们不需要详细了解它是如何工作的;只需知道,通过在函数定义上方放置 @app.route,我们将网络服务的 /ping 地址分配给 ping 函数。
要运行它,我们只需要最后一部分:
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=9696)
app 的 run 方法启动服务。我们指定了三个参数:
-
debug=True。当代码有更改时,会自动重新启动我们的应用程序。 -
host='0.0.0.0'。使网络服务公开;否则,当它在远程机器(例如 AWS)上托管时,将无法访问它。 -
port=9696。我们用来访问应用程序的端口。
我们现在可以开始我们的服务了。让我们来做这件事:
python flask_test.py
当我们运行它时,我们应该看到以下内容:
* Serving Flask app "test" (lazy loading)
* Environment: production
WARNING: This is a development server. Do not use it in a production deployment.
Use a production WSGI server instead.
* Debug mode: on
* Running on http://0.0.0.0:9696/ (Press CTRL+C to quit)
* Restarting with stat
* Debugger is active!
* Debugger PIN: 162-129-136
这意味着我们的 Flask 应用程序现在正在运行,并准备好接收请求。为了测试它,我们可以使用我们的浏览器:打开它,在地址栏中输入 localhost:9696/ping。如果您在远程服务器上运行它,应将 localhost 替换为服务器的地址。(对于 AWS EC2,请使用公共 DNS 主机名。确保在您的 EC2 实例的安全组中打开了端口 9696:转到安全组,并添加一个自定义 TCP 规则,端口为 9696,源为 0.0.0.0/0。)浏览器应响应 PONG(图 5.3)。

图 5.3 检查我们的应用程序是否工作的最简单方法是使用 Web 浏览器。
Flask 记录它接收到的所有请求,因此我们应该看到一条指示在 /ping 路由上有一个 GET 请求的行:
127.0.0.1 - - [02/Apr/2020 21:59:09] "GET /ping HTTP/1.1" 200 -
如我们所见,Flask 非常简单:用不到 10 行代码,我们就创建了一个 Web 服务。
接下来,我们将看到如何调整我们的流失预测脚本,并将其转换为 Web 服务。
5.2.3 使用 Flask 提供流失模型
我们已经学习了一些 Flask 的知识,所以现在我们可以回到我们的脚本,将其转换为 Flask 应用程序。
要对客户进行评分,我们的模型需要获取特征,这意味着我们需要一种方法将一些数据从一项服务(营销服务)传输到另一项服务(流失服务)。
作为数据交换格式,Web 服务通常使用 JSON(JavaScript 对象表示法)。它与我们在 Python 中定义字典的方式类似:
{
"customerid": "8879-zkjof",
"gender": "female",
"seniorcitizen": 0,
"partner": "no",
"dependents": "no",
...
}
要发送数据,我们使用 POST 请求,而不是 GET:POST 请求可以在请求中包含数据,而 GET 请求则不能。
因此,为了让营销服务能够从流失服务获取预测,我们需要创建一个接受 POST 请求的 /predict 路由。流失服务将解析有关客户的 JSON 数据并以 JSON 格式响应(图 5.4)。

图 5.4 要获取预测,我们将有关客户的 JSON 数据 POST 到 /predict 路由,并得到响应中的流失概率。
现在我们知道我们想要做什么,所以让我们开始修改 churn_serving.py 文件。
首先,我们在文件顶部添加一些额外的导入:
from flask import Flask, request, jsonify
尽管之前我们只导入了 Flask,但现在我们需要导入两个更多的事物:
-
request:获取 POST 请求的内容 -
jsonsify:以 JSON 格式响应
接下来,创建 Flask 应用程序。让我们称它为 churn:
app = Flask('churn')
现在我们需要创建一个函数,
-
获取请求中的客户数据
-
调用
predict_simple对客户进行评分 -
以 JSON 格式响应流失概率
我们将称此函数为 predict 并将其分配给 /predict 路由:
@app.route('/predict', methods=['POST']) ❶
def predict():
customer = request.get_json() ❷
prediction = predict_single(customer, dv, model) ❸
churn = prediction >= 0.5 ❹
result = { ❹
'churn_probability': float(prediction), ❹
'churn': bool(churn), ❹
} ❹
return jsonify(result) ❺
❶ 将 /predict 路由分配给 predict 函数
❷ 获取请求内容的 JSON 格式
❸ 对客户进行评分
❹ 准备响应
❺ 将响应转换为 JSON
要将路由分配给函数,我们使用 @app.route 装饰器,同时告诉 Flask 只期望 POST 请求。
predict 函数的核心内容与我们之前在脚本中做的类似:它接受一个客户,将其传递给 predict_single,并对结果进行一些处理。
最后,让我们添加运行 Flask 应用的最后两行:
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=9696)
我们已经准备好运行它:
python churn_serving.py
运行后,我们应该看到一条消息,说明应用已启动并正在等待传入的请求:
* Serving Flask app "churn" (lazy loading)
* Environment: production
WARNING: This is a development server. Do not use it in a production deployment.
Use a production WSGI server instead.
* Debug mode: on
* Running on http://0.0.0.0:9696/ (Press CTRL+C to quit)
* Restarting with stat
* Debugger is active!
测试这段代码比之前要困难一些:这次,我们需要使用 POST 请求,并在请求体中包含我们想要评分的客户。
在 Python 中使用 requests 库来做这件事是最简单的方式。它也预安装在 Anaconda 中,但如果使用不同的发行版,可以使用pip安装它:
pip install requests
我们可以打开之前使用的同一个 Jupyter Notebook,并从那里测试网络服务。
首先,导入 requests:
import requests
现在,向我们的服务发送一个 POST 请求
url = 'http://localhost:9696/predict' ❶
response = requests.post(url, json=customer) ❷
result = response.json() ❸
❶ 服务所在的服务器地址
❷ 在 POST 请求中发送客户(作为 JSON)
❸ 将响应解析为 JSON
results 变量包含来自流失服务的响应:
{'churn': False, 'churn_probability': 0.05960590758316391}
这是我们之前在终端中看到的信息,但现在我们是从一个网络服务中获取的响应。
注意:一些工具,如 Postman (www.postman.com/),使测试网络服务变得更容易。本书中不涵盖 Postman,但你可以自由尝试。
如果营销服务使用 Python,这正是它与流失服务通信并决定谁应该收到促销邮件的方式。
只用几行代码,我们就创建了一个在笔记本电脑上运行的实用网络服务。在下一节中,我们将看到如何管理服务中的依赖项并为其部署做准备。
5.3 管理依赖
对于本地开发,Anaconda 是一个完美的工具:它几乎包含了我们可能需要的所有库。然而,这也存在一个缺点:解包后占用 4GB 的空间,这太大了。对于生产环境,我们更倾向于只安装我们实际需要的库。
此外,不同的服务有不同的要求。通常,这些要求是冲突的,因此我们无法使用同一个环境同时运行多个服务。
在本节中,我们将看到如何以隔离的方式管理我们应用程序的依赖项,这样就不会干扰其他服务。我们将介绍两个工具来完成这项工作:用于管理 Python 库的 Pipenv,以及用于管理操作系统和系统库等系统依赖项的 Docker。
5.3.1 Pipenv
为了提供流失模型,我们只需要几个库:NumPy、Scikit-learn 和 Flask。因此,我们不需要带上包含所有库的整个 Anaconda 发行版,我们可以安装一个全新的 Python 安装,并使用pip安装我们需要的库:
pip install numpy scikit-learn flask
在做之前,让我们思考一下当我们使用pip安装库时会发生什么:
-
我们运行
pip install library来安装一个名为 Library 的 Python 包(假设它存在)。 -
Pip 前往 PyPI.org(Python 包索引——一个包含 Python 包的仓库),获取并安装这个库的最新版本。比如说,它是版本 1.0.0。
安装后,我们使用这个特定的版本开发和测试我们的服务。一切工作得很好。后来,我们的同事想帮助我们进行项目,所以他们也在他们的机器上运行 pip install 来设置一切——但这次,最新版本变成了 1.3.1。
如果我们运气不好,版本 1.0.0 和 1.3.1 可能不兼容,这意味着我们为版本 1.0.0 编写的代码将无法在版本 1.3.1 中工作。
在使用 pip 安装库时指定确切的版本可以解决这个问题:
pip install library==1.0.0
不幸的是,可能还会出现另一个问题:如果我们的某些同事已经安装了版本 1.3.1,并且他们已经使用它为其他服务,那么在这种情况下,他们不能回到使用版本 1.0.0:这可能导致他们的代码停止工作。
我们可以通过为每个项目创建一个 虚拟环境 来解决这些问题——一个仅包含特定项目所需库的独立 Python 发行版。
Pipenv 是一个使管理虚拟环境更简单的工具。我们可以使用 pip 来安装它:
pip install pipenv
之后,我们使用 pipenv 而不是 pip 来安装依赖项:
pipenv install numpy scikit-learn flask
当运行它时,我们会看到首先配置虚拟环境,然后安装库:
Running virtualenv with interpreter .../bin/python3
✔ Successfully created virtual environment!
Virtualenv location: ...
Creating a Pipfile for this project...
Installing numpy...
Adding numpy to Pipfile's [packages]...
✔ Installation Succeeded
Installing scikit-learn...
Adding scikit-learn to Pipfile's [packages]...
✔ Installation Succeeded
Installing flask...
Adding flask to Pipfile's [packages]...
✔ Installation Succeeded
Pipfile.lock not found, creating...
Locking [dev-packages] dependencies...
Locking [packages] dependencies...
⠙ Locking...
安装完成后,它创建了两个文件:Pipenv 和 Pipenv.lock。
Pipenv 文件看起来相当简单:
[[source]]
name = "pypi"
url = "https://pypi.org/simple"
verify_ssl = true
[dev-packages]
[packages]
numpy = "*"
scikit-learn = "*"
flask = "*"
[requires]
python_version = "3.7"
我们可以看到这个文件包含了一个库列表以及我们使用的 Python 版本。
另一个文件——Pipenv.lock——包含了项目中使用的库的具体版本。由于文件太大,无法在此全部显示,但让我们看看文件中的一个条目:
"flask": {
"hashes": [
"sha256:4efa1ae2d7c9865af48986de8aeb8504...",
"sha256:8a4fdd8936eba2512e9c85df320a37e6..."
],
"index": "pypi",
"version": "==1.1.2"
}
如我们所见,它记录了安装期间使用的库的确切版本。为了确保库不会更改,它还保存了散列——可以用来验证未来我们下载的库的确切版本的校验和。这样,我们将依赖项“锁定”到特定版本。通过这样做,我们确保未来我们不会遇到同一库的两个不兼容版本。
如果有人需要为我们项目工作,他们只需运行 install 命令:
pipenv install
此步骤将首先创建一个虚拟环境,然后从 Pipenv.lock 中安装所有必需的库。
重要:锁定库的版本对于未来的可重复性很重要,并有助于我们避免代码不兼容带来的不愉快惊喜。
安装完所有库后,我们需要激活虚拟环境——这样,我们的应用程序将使用库的正确版本。我们通过运行 shell 命令来完成:
pipenv shell
它告诉我们它正在虚拟环境中运行:
Launching subshell in virtual environment...
现在,我们可以运行我们的脚本以提供服务:
python churn_serving.py
或者,我们不必首先明确进入虚拟环境然后运行脚本,我们可以用一条命令完成这两个步骤:
pipenv run python churn_serving.py
Pipenv 中的run命令简单地运行虚拟环境中的指定程序。
无论我们以何种方式运行它,我们都应该看到与之前完全相同的输出:
* Serving Flask app "churn" (lazy loading)
* Environment: production
WARNING: This is a development server. Do not use it in a production deployment.
Use a production WSGI server instead.
* Debug mode: on
* Running on http://0.0.0.0:9696/ (Press CTRL+C to quit)
当我们用 requests 进行测试时,我们会看到相同的输出:
{'churn': False, 'churn_probability': 0.05960590758316391}
你很可能会在控制台注意到以下警告:
* Environment: production
WARNING: This is a development server. Do not use it in a production deployment.
Use a production WSGI server instead.
内置的 Flask 网络服务器确实仅适用于开发:它非常容易用于测试我们的应用程序,但在负载下不会可靠地工作。我们应该使用合适的 WSGI 服务器,正如警告所建议的。
WSGI 代表Web 服务器网关接口,这是一个描述 Python 应用程序应该如何处理 HTTP 请求的规范。WSGI 的细节对于本书的目的并不重要,所以我们不会详细讨论。
我们将通过安装生产 WSGI 服务器来处理这个警告。Python 有多种可能的选择,我们将使用 Gunicorn。
注意 Gunicorn 在 Windows 上不工作:它依赖于 Linux 和 Unix(包括 MacOS)特有的功能。一个在 Windows 上也能工作的良好替代品是 Waitress。稍后,我们将使用 Docker,这将解决这个问题——它在容器内运行 Linux。
让我们用 Pipenv 安装它:
pipenv install gunicorn
此命令安装库,并通过将其添加到 Pipenv 和 Pipenv.lock 文件中,将其作为项目依赖项包含在内。
让我们用 Gunicorn 运行我们的应用程序:
pipenv run gunicorn --bind 0.0.0.0:9696 churn_serving:app
如果一切顺利,我们应该在终端看到以下信息:
[2020-04-13 22:58:44 +0200] [15705] [INFO] Starting gunicorn 20.0.4
[2020-04-13 22:58:44 +0200] [15705] [INFO] Listening at: http://0.0.0.0:9696 (15705)
[2020-04-13 22:58:44 +0200] [15705] [INFO] Using worker: sync
[2020-04-13 22:58:44 +0200] [16541] [INFO] Booting worker with pid: 16541
与 Flask 内置的网络服务器不同,Gunicorn 适用于生产,因此当我们开始使用它时,在负载下不会出现任何问题。
如果我们用之前相同的代码进行测试,我们会看到相同的答案:
{'churn': False, 'churn_probability': 0.05960590758316391}
Pipenv 是一个管理依赖项的出色工具:它将所需的库隔离到单独的环境中,从而帮助我们避免不同版本的同一包之间的冲突。
在下一节中,我们将探讨 Docker,它允许我们进一步隔离我们的应用程序并确保它在任何地方都能顺利运行。
5.3.2 Docker
我们已经学会了如何使用 Pipenv 管理 Python 依赖。然而,一些依赖项存在于 Python 之外。最重要的是,这些依赖项包括操作系统(OS)以及系统库。
例如,我们可能会使用 Ubuntu 16.04 版本来开发我们的服务,但如果我们的某些同事使用 Ubuntu 20.04 版本,他们在尝试在自己的笔记本电脑上执行服务时可能会遇到麻烦。
Docker 通过将操作系统和系统库打包到Docker 容器中解决了“在我的机器上它工作”的问题——这是一个在任何安装了 Docker 的地方都能工作的自包含环境(图 5.5)。

图 5.5 在没有隔离(a)的情况下,服务使用系统 Python 运行。在虚拟环境(b)中,我们将服务的依赖项隔离在环境中。在 Docker 容器(c)中,我们将服务的整个环境隔离,包括操作系统和系统库。
一旦服务被打包成 Docker 容器,我们就可以在 主机机器 上运行它——我们的笔记本电脑(无论操作系统如何)或任何公共云提供商。
让我们看看如何将其用于我们的项目。我们假设您已经安装了 Docker。请参阅附录 A 了解如何安装 Docker 的详细信息。
首先,我们需要创建一个 Docker 镜像——这是我们的服务的描述,包括所有设置和依赖项。Docker 将稍后使用该镜像来创建容器。为此,我们需要一个 Dockerfile——一个包含如何创建镜像的指令的文件(图 5.6)。

图 5.6 我们使用 Dockerfile 中的指令构建一个镜像。然后我们可以在主机机器上运行这个镜像。
让我们创建一个名为 Dockerfile 的文件,并包含以下内容(注意,文件不应包含注释):
FROM python:3.7.5-slim ❶
ENV PYTHONUNBUFFERED=TRUE ❷
RUN pip --no-cache-dir install pipenv ❸
WORKDIR /app ❹
COPY ["Pipfile", "Pipfile.lock", "./"] ❺
RUN pipenv install --deploy --system && \ ❻
rm -rf /root/.cache ❻
COPY ["*.py", "churn-model.bin", "./"] ❼
EXPOSE 9696 ❽
ENTRYPOINT ["gunicorn", "--bind", "0.0.0.0:9696", "churn_serving:app"] ❾
❶ 指定基础镜像
❷ 设置特殊的 Python 设置以便能够看到日志
❸ 安装 Pipenv
❹ 将工作目录设置为 /app
❺ 复制 Pipenv 文件
❻ 从 Pipenv 文件中安装依赖项
❼ 复制我们的代码以及模型
❽ 打开我们的 Web 服务使用的端口
❾ 指定服务应该如何启动
这需要解包大量信息,尤其是如果你以前从未见过 Dockerfile。让我们逐行分析。
首先,我们指定基本 Docker 镜像:
FROM python:3.7.5-slim
我们使用这个镜像作为起点,并在其之上构建自己的镜像。通常,基础镜像已经包含了操作系统和系统库,如 Python 本身,因此我们只需要安装我们项目的依赖项。在我们的例子中,我们使用 python:3.7.5-slim,它基于 Debian 10.2,包含 Python 版本 3.7.5 和 pip。您可以在 Docker Hub 上了解更多关于 Python 基础镜像的信息(hub.docker.com/_/python)——共享 Docker 镜像的服务。
所有 Dockerfile 都应该以 FROM 语句开始。
接下来,我们将 PYTHONUNBUFFERED 环境变量设置为 TRUE:
ENV PYTHONUNBUFFERED=TRUE
如果没有这个设置,我们在 Docker 内运行 Python 脚本时将无法看到日志。
然后,我们使用 pip 安装 Pipenv:
RUN pip --no-cache-dir install pipenv
Docker 中的 RUN 指令简单地运行一个 shell 命令。默认情况下,pip 将库保存到缓存中,以便稍后可以更快地安装。我们不需要在 Docker 容器中这样做,因此我们使用 --no-cache-dir 设置。
然后,我们指定工作目录:
WORKDIR /app
这大致相当于 Linux 中的 cd 命令(更改目录),因此之后我们将运行的所有内容都将执行在 /app 文件夹中。
然后,我们将 Pipenv 文件复制到当前工作目录(即,/app):
COPY ["Pipfile", "Pipfile.lock", "./"]
我们使用这些文件通过 Pipenv 安装依赖项:
RUN pipenv install --deploy --system && \
rm -rf /root/.cache
之前,我们只是简单地使用pipenv install来做这件事。在这里,我们包括了两个额外的参数:--deploy和--system。在 Docker 内部,我们不需要创建虚拟环境——我们的 Docker 容器已经与系统其他部分隔离。设置这些参数允许我们跳过创建虚拟环境,并使用系统 Python 来安装所有依赖项。
安装完库后,我们清理缓存以确保我们的 Docker 镜像不会变得太大。
然后,我们复制我们的项目文件以及 pickle 模型:
COPY ["*.py", "churn-model.bin", "./"]
接下来,我们指定应用程序将使用的端口。在我们的例子中,它是 9696:
EXPOSE 9696
最后,我们告诉 Docker 我们的应用程序应该如何启动:
ENTRYPOINT ["gunicorn", "--bind", "0.0.0.0:9696", "churn_serving:app"]
这是我们之前在本地运行 Gunicorn 时使用的相同命令。
让我们构建图像。我们通过在 Docker 中运行build命令来完成:
docker build -t churn-prediction .
-t标志让我们设置图像的标签名称,最后一个参数——点号——指定了包含 Dockerfile 的目录。在我们的例子中,这意味着我们使用当前目录。
当我们运行它时,Docker 首先下载基础镜像:
Sending build context to Docker daemon 51.71kB
Step 1/11 : FROM python:3.7.5-slim
3.7.5-slim: Pulling from library/python
000eee12ec04: Downloading 24.84MB/27.09MB
ddc2d83f8229: Download complete
735b0bee82a3: Downloading 19.56MB/28.02MB
8c69dcedfc84: Download complete
495e1cccc7f9: Download complete
然后它逐行执行 Dockerfile 中的每一行:
Step 2/9 : ENV PYTHONUNBUFFERED=TRUE
---> Running in d263b412618b
Removing intermediate container d263b412618b
---> 7987e3cf611f
Step 3/9 : RUN pip --no-cache-dir install pipenv
---> Running in e8e9d329ed07
Collecting pipenv
...
最后,Docker 告诉我们它已成功构建了一个图像,并将其标记为 churn-prediction:latest:
Successfully built d9c50e4619a1
Successfully tagged churn-prediction:latest
我们已经准备好使用这个图像来启动一个 Docker 容器。使用run命令来执行:
docker run -it -p 9696:9696 churn-prediction:latest
我们在这里指定了一些参数:
-
-it标志告诉 Docker 我们从终端运行它,并且我们需要看到结果。 -
-p参数指定端口映射。9696:9696意味着将容器上的端口 9696 映射到主机机器上的端口 9696。 -
最后,我们需要图像名称和标签,在我们的例子中是
churn-prediction :latest。
现在我们的服务正在 Docker 容器内运行,我们可以通过端口 9696(图 5.7)连接到它。这是我们之前用于应用程序的相同端口。

图 5.7 主机机器上的 9696 端口映射到容器的 9696 端口,因此当我们向localhost:9696发送请求时,它由 Docker 中的我们的服务处理。
让我们使用相同的代码进行测试。当我们运行它时,我们会看到相同的响应:
{'churn': False, 'churn_probability': 0.05960590758316391}
Docker 使得以可重复的方式运行服务变得容易。使用 Docker,容器内的环境始终保持不变。这意味着如果我们能在笔记本电脑上运行我们的服务,它将可以在任何其他地方工作。
我们已经在笔记本电脑上测试了我们的应用程序,所以现在让我们看看如何在公共云上运行它并将其部署到 AWS。
5.4 部署
我们不在笔记本电脑上运行生产服务;我们需要专门的服务器来运行这些服务。
在本节中,我们将介绍一个可能的选项:亚马逊网络服务,或简称 AWS。我们选择 AWS 是因为它的流行——我们与亚马逊或 AWS 没有关联。
其他流行的公共云包括 Google Cloud、Microsoft Azure 和 Digital Ocean。本书不涵盖它们,但你应该能够在网上找到类似的说明并将模型部署到你喜欢的云服务提供商。
这个部分是可选的,所以你可以安全地跳过它。要遵循本节中的说明,你需要有一个 AWS 账户并配置 AWS 命令行工具(CLI)。请参阅附录 A 以了解如何设置它。
5.4.1 AWS Elastic Beanstalk
AWS 提供了许多服务,我们有很多可能的部署 Web 服务的方式。例如,你可以租用一个 EC2 机器(AWS 中的服务器)并手动在其上设置服务,使用 AWS Lambda 的“无服务器”方法,或者使用一系列其他服务。
在本节中,我们将使用 AWS Elastic Beanstalk,这是将模型部署到 AWS 的最简单方法之一。此外,我们的服务足够简单,因此我们可以在免费层限制内使用。换句话说,我们可以在第一年免费使用它。
Elastic Beanstalk 自动处理我们在生产环境中通常需要处理的大量事情,包括
-
将我们的服务部署到 EC2 实例
-
扩展规模:在高峰时段添加更多实例以处理负载
-
缩小规模:在负载消失时移除这些实例
-
如果服务因任何原因崩溃,重新启动服务
-
在实例之间平衡负载
我们还需要一个特殊的实用工具——Elastic Beanstalk 命令行界面(CLI)——来使用 Elastic Beanstalk。CLI 是用 Python 编写的,因此我们可以像安装任何其他 Python 工具一样使用pip来安装它。
然而,因为我们使用 Pipenv,我们可以将其添加为开发依赖项。这样,我们只为我们的项目安装它,而不是系统范围内。
pipenv install awsebcli --dev
注意,开发依赖项是我们用于开发应用程序的工具和库。通常,我们只需要在本地使用它们,不需要将它们包含在实际部署到生产环境的包中。
安装 Elastic Beanstalk 后,我们可以进入我们项目的虚拟环境:
pipenv shell
现在 CLI 应该可用。让我们检查一下:
eb --version
它应该打印版本:
EB CLI 3.18.0 (Python 3.7.7)
接下来,我们运行初始化命令:
eb init -p docker churn-serving
注意,我们使用 -p docker:这样,我们指定这是一个基于 Docker 的项目。
如果一切正常,它会在 .elasticbeanstalk 文件夹中创建一些文件,包括一个 config.yml 文件。
现在,我们可以通过使用 local run 命令在本地测试我们的应用程序:
eb local run --port 9696
这应该与上一节中的 Docker 以相同的方式工作:它首先构建一个镜像,然后运行容器。
为了测试它,我们可以使用之前相同的代码并得到相同的答案:
{'churn': False, 'churn_probability': 0.05960590758316391}
在本地验证它运行良好后,我们就准备好将其部署到 AWS 了。我们可以用一条命令来完成:
eb create churn-serving-env
这个简单的命令负责设置我们需要的所有内容,从 EC2 实例到自动扩展规则:
Creating application version archive "app-200418_120347".
Uploading churn-serving/app-200418_120347.zip to S3\. This may take a while.
Upload Complete.
Environment details for: churn-serving-env
Application name: churn-serving
Region: us-west-2
Deployed Version: app-200418_120347
Environment ID: e-3xkqdzdjbq
Platform: arn:aws:elasticbeanstalk:us-west-2::platform/Docker running on 64bit Amazon Linux 2/3.0.0
Tier: WebServer-Standard-1.0
CNAME: UNKNOWN
Updated: 2020-04-18 10:03:52.276000+00:00
Printing Status:
2020-04-18 10:03:51 INFO createEnvironment is starting.
-- Events -- (safe to Ctrl+C)
创建所有内容需要几分钟。我们可以监控这个过程,并在终端中查看它在做什么。
当它准备就绪时,我们应该看到以下信息:
2020-04-18 10:06:53 INFO Application available at churn-serving-env.5w9pp7bkmj.us-west-2.elasticbeanstalk.com.
2020-04-18 10:06:53 INFO Successfully launched environment: churn-serving-env
日志中的 URL(churn-serving-env.5w9pp7bkmj.us-west-2.elasticbeanstalk.com)很重要:这是我们访问应用程序的方式。现在我们可以使用此 URL 进行预测(图 5.8)。

图 5.8 我们的服务已部署在 AWS Elastic Beanstalk 的容器内。要访问它,我们使用其公共 URL。
让我们测试一下:
host = 'churn-serving-env.5w9pp7bkmj.us-west-2.elasticbeanstalk.com'
url = 'http://%s/predict' % host
response = requests.post(url, json=customer)
result = response.json()
result
如前所述,我们应该看到相同的响应:
{'churn': False, 'churn_probability': 0.05960590758316393}
那就结束了!我们有一个正在运行的服务。
警告:这是一个玩具示例,我们创建的服务对世界上任何人都可访问。如果您在组织内部进行操作,应尽可能限制访问权限。将此示例扩展为安全版本并不困难,但这超出了本书的范围。在您的工作中实施之前,请咨询公司的安全部门。
我们可以使用 CLI 从终端做所有事情,但也可以从 AWS 控制台管理它。为此,我们在那里找到 Elastic Beanstalk,并选择我们刚刚创建的环境(图 5.9)。

图 5.9 我们可以在 AWS 控制台中管理 Elastic Beanstalk 环境。
要关闭它,请使用 AWS 控制台中的环境操作菜单选择“终止部署”。
警告:尽管 Elastic Beanstalk 允许免费层使用,但我们始终应该小心,一旦不再需要,就立即将其关闭。
或者,我们可以使用 CLI 来执行:
eb terminate churn-serving-env
几分钟后,部署将从 AWS 中移除,URL 将不再可访问。
AWS Elastic Beanstalk 是一个用于开始提供机器学习模型的出色工具。更高级的实现方式涉及容器编排系统,如 AWS ECS 或 Kubernetes,或者使用 AWS Lambda 的“无服务器”。我们将在第八章和第九章中介绍深度学习模型的部署时回到这个话题。
5.5 下一步
我们已经了解了 Pipenv 和 Docker,并将我们的模型部署到了 AWS Elastic Beanstalk。尝试以下其他事情来扩展您自己的技能。
5.5.1 练习
尝试以下练习,以进一步探索模型部署的主题:
-
如果您不使用 AWS,请尝试在其他云服务提供商上重复第 5.4 节中的步骤。例如,您可以尝试 Google Cloud、Microsoft Azure、Heroku 或 Digital Ocean。
-
Flask 并非在 Python 中创建 Web 服务的唯一方式。您可以尝试其他框架,如 FastAPI (
fastapi.tiangolo.com/)、Bottle (github.com/bottlepy/bottle) 或 Falcon (github.com/falconry/falcon)。
5.5.2 其他项目
您可以继续之前章节中的其他项目,并将它们作为 Web 服务提供。例如:
-
第二章中创建的汽车价格预测模型。
-
第三章中的自学项目:评分项目和使用默认预测项目。
摘要
-
Pickle 是一个内置在 Python 中的序列化/反序列化库。我们可以用它来保存我们在 Jupyter Notebook 中训练的模型,并在 Python 脚本中加载它。
-
将模型提供给他人的最简单方式是将它封装成一个 Flask 网络服务。
-
Pipenv 是一个通过创建虚拟环境来管理 Python 依赖项的工具,因此一个 Python 项目的依赖项不会干扰另一个 Python 项目的依赖项。
-
Docker 通过将 Python 依赖项、系统依赖项以及操作本身打包到一个 Docker 容器中,使得服务可以完全与其他服务隔离。
-
AWS Elastic Beanstalk 是一种简单的方式来部署一个网络服务。它负责管理 EC2 实例,根据需要调整服务的大小,并在出现故障时重启服务。
在下一章中,我们继续学习分类,但使用的是不同类型的模型——决策树。
6 决策树和集成学习
本章涵盖
-
决策树和决策树学习算法
-
随机森林:将多个树组合成一个模型
-
梯度提升作为组合决策树的另一种方法
在第三章中,我们描述了二元分类问题,并使用逻辑回归模型来预测客户是否会流失。
在本章中,我们同样解决一个二元分类问题,但我们使用的是不同系列的机器学习模型:基于树的模型。决策树,最简单的基于树的模型,不过是一系列的条件-结果规则组合。我们可以将多个决策树组合成一个集成,以实现更好的性能。我们介绍了两种基于树的集成模型:随机森林和梯度提升。
本章我们准备的项目是违约预测:我们预测客户是否会无法偿还贷款。我们学习如何使用 Scikit-learn 训练决策树和随机森林模型,并探索 XGBoost——一个实现梯度提升模型的库。
6.1 信用风险评分项目
想象我们是一家银行的员工。当我们收到一个贷款申请时,我们需要确保如果我们提供资金,客户将能够偿还。每个申请都存在一种违约风险——即无法偿还资金。
我们希望最小化这种风险:在同意提供贷款之前,我们想要对客户进行评分并评估违约的可能性。如果风险太高,我们拒绝申请。这个过程被称为“信用风险评分”。
机器学习可以用于计算风险。为此,我们需要一个包含贷款的数据库,其中对于每个申请,我们知道它是否成功偿还。使用这些数据,我们可以构建一个预测违约概率的模型,并可以使用这个模型来评估未来借款人无法偿还资金的风险。
这是本章我们要做的事情:使用机器学习来计算违约风险。项目的计划如下:
-
首先,我们获取数据并进行一些初步预处理。
-
接下来,我们使用 Scikit-learn 训练一个决策树模型来预测违约概率。
-
之后,我们解释了决策树是如何工作的,以及模型有哪些参数,并展示了如何调整这些参数以获得最佳性能。
-
然后我们将多个决策树组合成一个模型——随机森林。我们查看其参数并调整它们以实现最佳的预测性能。
-
最后,我们探索将决策树组合的另一种方式——梯度提升。我们使用 XGBoost,这是一个高效的库,实现了梯度提升。我们将训练一个模型并调整其参数。
信用风险评分是一个二元分类问题:如果客户违约,目标为正(“1”),否则为负(“0”)。为了评估我们的解决方案,我们将使用 AUC(ROC 曲线下面积),这在第四章中已介绍。AUC 描述了我们的模型将案例区分成正例和负例的能力。
本项目的代码可在本书的 GitHub 仓库中找到(github.com/alexeygrigorev/mlbookcamp-code 中的 chapter-06-trees 文件夹)。
6.1.1 信用评分数据集
对于本项目,我们使用加泰罗尼亚理工大学数据挖掘课程的数据集(www.cs.upc.edu/~belanche/Docencia/mineria/mineria.html)。该数据集描述了客户(资历、年龄、婚姻状况、收入和其他特征)、贷款(请求的金额、物品的价格)及其状态(已偿还或未偿还)。
我们使用可在 GitHub 上找到的此数据集的副本(github.com/gastonstat/CreditScoring/)。让我们下载它。
首先,为我们的项目创建一个文件夹(例如,chapter-06-credit-risk),然后使用 wget 获取它:
wget https://github.com/gastonstat/CreditScoring/raw/master/CreditScoring.csv
或者,您可以将链接输入浏览器并保存到项目文件夹中。
接下来,如果尚未启动,请启动 Jupyter Notebook 服务器:
jupyter notebook
进入项目文件夹,创建一个新的笔记本(例如,chapter-06-credit-risk)。
如常,我们首先导入 Pandas、NumPy、Seaborn 和 Matplotlib:
import pandas as pd
import numpy as np
import seaborn as sns
from matplotlib import pyplot as plt
%matplotlib inline
在我们按下 Ctrl-Enter 后,库被导入,我们就可以使用 Pandas 读取数据了:
df = pd.read_csv('CreditScoring.csv')
现在数据已加载,因此让我们先初步查看它,看看在使用之前是否需要进行任何预处理。
6.1.2 数据清理
要使用数据集完成任务,我们需要查找数据中的任何问题并修复它们。
让我们从查看由 df.head() 函数生成的 DataFrame 的前几行开始(图 6.1)。

图 6.1 信用评分数据集的前五行
首先,我们可以看到所有列名都以大写字母开头。在执行其他任何操作之前,让我们将所有列名转换为小写,并使其与其他项目保持一致(图 6.2):
df.columns = df.columns.str.lower()

图 6.2 列名为小写的 DataFrame
我们可以看到 DataFrame 有以下列:
-
status: 客户是否成功偿还贷款(1)或未偿还(2)
-
seniority: 工作经验(年)
-
home: 房屋所有权类型:租房(1),房主(2),以及其他
-
time: 贷款计划期(月)
-
age: 客户的年龄
-
marital [status]: 单身(1),已婚(2),以及其他
-
records: 客户是否有任何先前记录:没有(1),有(2)(从数据集描述中不清楚这一列中有什么样的记录。为了本项目的目的,我们可能假设它是指银行数据库中的记录。)
-
职业:工作类型:全职(1),兼职(2)和其他
-
支出:客户每月的花费
-
收入:客户每月的收入
-
资产:客户所有资产的总价值
-
信贷债务:信用债务金额
-
金额:贷款请求的金额
-
价格:客户想要购买的商品的价格
尽管大多数列都是数值型,但也有一些是分类型:状态、住所、婚姻[状态]、记录和职业。然而,我们在 DataFrame 中看到的是数字,而不是字符串。这意味着我们需要将它们转换为它们的实际名称。在包含数据集的 GitHub 仓库中有一个脚本,可以将数字解码为类别(github.com/gastonstat/CreditScoring/blob/master/Part1_CredScoring_ Processing.R)。最初,这个脚本是用 R 编写的,因此我们需要将其转换为 Pandas。
我们从状态列开始。值“1”表示“OK”,值“2”表示“default”,而“0”表示该值缺失——让我们用“unk”(代表“unknown”)来替换它。
在 Pandas 中,我们可以使用map将数字转换为字符串。为此,我们首先定义一个字典,将当前值(数字)映射到所需值(字符串):
status_values = {
1: 'ok',
2: 'default',
0: 'unk'
}
现在,我们可以使用这个字典来进行映射:
df.status = df.status.map(status_values)
这会创建一个新的序列,我们立即将其写回 DataFrame。结果,状态列中的值被覆盖,看起来更有意义(图 6.3)。

图 6.3 为了将状态列中的原始值(数字)转换为更有意义的表示(字符串),我们使用map方法。
我们对其他所有列重复相同的步骤。首先,我们将对住所列进行操作:
home_values = {
1: 'rent',
2: 'owner',
3: 'private',
4: 'ignore',
5: 'parents',
6: 'other',
0: 'unk'
}
df.home = df.home.map(home_values)
接下来,让我们为婚姻、记录和职业列进行操作:
marital_values = {
1: 'single',
2: 'married',
3: 'widow',
4: 'separated',
5: 'divorced',
0: 'unk'
}
df.marital = df.marital.map(marital_values)
records_values = {
1: 'no',
2: 'yes',
0: 'unk'
}
df.records = df.records.map(records_values)
job_values = {
1: 'fixed',
2: 'parttime',
3: 'freelance',
4: 'others',
0: 'unk'
}
df.job = df.job.map(job_values)
经过这些转换后,包含分类变量的列包含的是实际值,而不是数字(图 6.4)。

图 6.4 将分类变量的值从整数转换为字符串。
作为下一步,让我们看一下数值列。首先,让我们检查每个列的摘要统计信息:最小值、平均值、最大值等。要做到这一点,我们可以使用 DataFrame 的describe方法:
df.describe().round()
注意:describe命令的输出可能会让人困惑。在我们的例子中,存在像 1.000000e+08 或 8.703625e+06 这样的科学记数法值。为了强制 Pandas 使用不同的表示法,我们使用round:它会移除数字的小数部分并将其四舍五入到最接近的整数。
这让我们对每个列中值的分布情况有了大致的了解(图 6.5)。

图 6.5 数据框中所有数值列的摘要。我们注意到其中一些列的最大值是 99999999。
我们立即注意到,在某些情况下最大值是99999999。这相当可疑。实际上,这是一个人工值——这就是在这个数据集中缺失值是如何编码的。
有三个列存在这个问题:收入、资产和债务。让我们将这些大数字替换为 NaN:
for c in ['income', 'assets', 'debt']:
df[c] = df[c].replace(to_replace=99999999, value=np.nan)
我们使用replace方法,它接受两个值:
-
to_replace:原始值(在我们的案例中是“99999999”) -
value:目标值(在我们的案例中是“NaN”)
经过这种转换后,汇总统计中不再出现可疑的数字(图 6.6)。

图 6.6 替换大值后的汇总统计
在完成数据集准备之前,让我们看看我们的目标变量status:
df.status.value_counts()
value_counts的输出显示了每个值的计数:
ok 3200
default 1254
unk 1
Name: status, dtype: int64
注意到有一行状态为“未知”:我们不知道这位客户是否成功偿还了贷款。对于我们的项目来说,这一行没有用,所以让我们从数据集中移除它:
df = df[df.status != 'unk']
在这种情况下,我们实际上并没有“移除”它:我们创建了一个新的 DataFrame,其中不包含状态为“未知”的记录。
通过查看数据,我们已经识别出数据中的一些重要问题,并解决了它们。
对于这个项目,我们跳过像第二章(汽车价格预测项目)和第三章(客户流失预测项目)中那样更详细的数据探索,但你也可以自由地重复我们在那里覆盖的步骤。
6.1.3 数据集准备
现在我们的数据集已经清理完毕,我们几乎准备好用它进行模型训练了。在我们能够这样做之前,我们还需要做几步:
-
将数据集分割为训练集、验证集和测试集。
-
处理缺失值。
-
使用独热编码对分类变量进行编码。
-
创建特征矩阵 X 和目标变量 y 。
让我们先开始分割数据。我们将数据分成三个部分:
-
训练数据(原始数据集的 60%)
-
验证数据(20%)
-
测试数据(20%)
同样,我们将使用 Scikit-learn 中的train_test_split。因为我们不能一次将其分成三个数据集,所以我们需要分两次(图 6.7)。首先,我们将 20%的数据保留用于测试,然后剩下的 80%将分为训练集和验证集:
from sklearn.model_selection import train_test_split
df_train_full, df_test = train_test_split(df, test_size=0.2, random_state=11)
df_train, df_val = train_test_split(df_train_full, test_size=0.25, random_state=11)

图 6.7 因为train_test_split只能将数据集分成两部分,但我们需要三部分,所以我们进行了两次分割。
第二次分割时,我们将 25%的数据放在一边,而不是 20%(test_size=0.25)。因为df_train_full包含 80%的记录,四分之一(即 25%)的 80%对应于原始数据集的 20%。
为了检查数据集的大小,我们可以使用len函数:
len(df_train), len(df_val), len(df_test)
运行时,我们得到以下输出:
(2672, 891, 891)
因此,对于训练,我们将使用大约 2,700 个示例,几乎 900 个用于验证和测试。
我们想要预测的结果是status。我们将用它来训练模型,所以它是我们的y——目标变量。因为我们的目标是确定某人是否未能偿还贷款,所以正类是default。这意味着如果客户违约,y是“1”,否则是“0”。这很简单就可以实现:
y_train = (df_train.status == 'default').values
y_val = (df_val.status == 'default').values
现在,我们需要从 DataFrames 中移除status。如果我们不这样做,我们可能会意外地使用这个变量进行训练。为此,我们使用del运算符:
del df_train['status']
del df_val['status']
接下来,我们将处理X——特征矩阵。
从初步分析中,我们知道我们的数据包含缺失值——是我们自己添加的 NaN。我们可以用零替换缺失值:
df_train = df_train.fillna(0)
df_val = df_val.fillna(0)
要使用分类变量,我们需要对它们进行编码。在第三章中,我们应用了独热编码技术。在独热编码中,每个值如果存在(“hot”)则编码为“1”,如果不存在(“cold”)则编码为“0”。为了实现它,我们使用了 Scikit-learn 的DictVectorizer。
DictVectorizer需要一个字典列表,所以我们需要首先将 DataFrames 转换为这种格式:
dict_train = df_train.to_dict(orient='records')
dict_val = df_val.to_dict(orient='records')
结果中的每个字典代表 DataFrame 中的一行。例如,dict_train中的第一个记录看起来是这样的:
{'seniority': 10,
'home': 'owner',
'time': 36,
'age': 36,
'marital': 'married',
'records': 'no',
'job': 'freelance',
'expenses': 75,
'income': 0.0,
'assets': 10000.0,
'debt': 0.0,
'amount': 1000,
'price': 1400}
这个字典列表现在可以用作DictVectorizer的输入:
from sklearn.feature_extraction import DictVectorizer
dv = DictVectorizer(sparse=False)
X_train = dv.fit_transform(dict_train)
X_val = dv.transform(dict_val)
结果,我们有了训练集和验证集的特征矩阵。请参阅第三章,了解更多关于使用 Scikit-learn 进行独热编码的细节。
现在,我们已经准备好训练一个模型了!在下一节中,我们将介绍最简单的树模型:决策树。
6.2 决策树
决策树是一种编码一系列 if-then-else 规则的数据结构。树中的每个节点包含一个条件。如果条件得到满足,我们就走向树的右侧;否则,我们走向左侧。最终我们到达最终决策(图 6.8)。

图 6.8 决策树由具有条件的节点组成。如果一个节点的条件得到满足,我们就走向右侧;否则,我们走向左侧。
在 Python 中将决策树表示为一系列if-else语句非常简单。例如:
def assess_risk(client):
if client['records'] == 'yes':
if client['job'] == 'parttime':
return 'default'
else:
return 'ok'
else:
if client['assets'] > 6000:
return 'ok'
else:
return 'default'
通过机器学习,我们可以自动从数据中提取这些规则。让我们看看我们如何做到这一点。
6.2.1 决策树分类器
我们将使用 Scikit-learn 来训练决策树。因为我们正在解决一个分类问题,所以我们需要使用tree包中的DecisionTreeClassifier。让我们导入它:
from sklearn.tree import DecisionTreeClassifier
训练模型就像调用fit方法一样简单:
dt = DecisionTreeClassifier()
dt.fit(X_train, y_train)
为了检查结果是否良好,我们需要评估模型在验证集上的预测性能。让我们使用 AUC(ROC 曲线下的面积)来做这件事。
信用风险评估是一个二元分类问题,对于这种情况,AUC 是最佳评估指标之一。如您在第四章中回忆的那样,AUC 显示了模型将正例与负例分开的好坏程度。它有一个很好的解释:它描述了随机选择的一个正例(“违约”)的分数高于随机选择的一个负例(“正常”)的概率。这是一个与项目相关的指标:我们希望风险客户比非风险客户有更高的分数。有关 AUC 的更多详细信息,请参阅第四章。
同样,我们将使用 Scikit-learn 的实现,所以让我们导入它:
from sklearn.metrics import roc_auc_score
首先,我们在训练集上评估性能。因为我们选择了 AUC 作为评估指标,我们需要分数而不是硬预测。正如我们在第三章中了解的那样,我们需要使用predict_proba方法:
y_pred = dt.predict_proba(X_train)[:, 1]
roc_auc_score(y_train, y_pred)
当我们执行它时,我们看到分数是 100%——完美的分数。这意味着我们可以无误差地预测违约吗?在得出结论之前,让我们先检查一下验证集上的分数:
y_pred = dt.predict_proba(X_val)[:, 1]
roc_auc_score(y_val, y_pred)
运行后,我们看到验证集上的 AUC 只有 65%。
我们刚刚观察到一个过拟合的例子。这个树学得非常好,以至于它简单地记住了每个客户的结局。然而,当我们将其应用于验证集时,模型失败了。它从数据中提取的规则对训练集来说过于具体,因此在训练期间没有看到的客户上表现不佳。在这种情况下,我们说模型不能泛化。
当我们有一个复杂且足够强大的模型来记住所有训练数据时,就会发生过拟合。如果我们迫使模型更简单,我们可以使其更弱,并提高模型泛化的能力。
我们有多种方法可以控制树的复杂性。一个选项是限制其大小:我们可以指定max_depth参数,它控制最大层数。树具有的层次越多,它能够学习的规则就越复杂(见图 6.9)。

图 6.9 一个具有更多层次的树可以学习更复杂的规则。一个具有两个层次的树比具有三个层次的树更简单,因此更不容易过拟合。
max_depth参数的默认值是None,这意味着树可以尽可能大。我们可以尝试一个更小的值,并比较结果。
例如,我们可以将其更改为 2:
dt = DecisionTreeClassifier(max_depth=2)
dt.fit(X_train, y_train)
为了可视化我们刚刚学习的树,我们可以使用tree包中的export_text函数:
from sklearn.tree import export_text
tree_text = export_text(dt, feature_names=dv.feature_names_)
print(tree_text)
我们只需要使用feature_names参数指定特征的名称。我们可以从DictVectorizer中获取它。当我们打印出来时,我们得到以下内容:
|--- records=no <= 0.50
| |--- seniority <= 6.50
| | |--- class: True
| |--- seniority > 6.50
| | |--- class: False
|--- records=no > 0.50
| |--- job=parttime <= 0.50
| | |--- class: False
| |--- job=parttime > 0.50
| | |--- class: True
输出中的每一行都对应一个具有条件的节点。如果条件为真,我们就进入并重复这个过程,直到我们到达最终决策。最后,如果类别是True,则决策是“违约”,否则是“正常”。
条件 records=no > 0.50 意味着客户没有记录。回想一下,我们使用 one-hot 编码用两个特征来表示 records:records=yes 和 records=no。对于一个没有记录的客户,records=no 被设置为“1”,而 records=yes 被设置为“0”。因此,“records=no > 0.50”在 records 的值为 no 时为真(图 6.10)。

图 6.10 将 max_depth 设置为 2 时我们学习的树
让我们检查分数:
y_pred = dt.predict_proba(X_train)[:, 1]
auc = roc_auc_score(y_train, y_pred)
print('train auc', auc)
y_pred = dt.predict_proba(X_val)[:, 1]
auc = roc_auc_score(y_val, y_pred)
print('validation auc', auc)
我们看到训练分数下降了:
train auc: 0.705
val auc: 0.669
之前,训练集上的性能是 100%,但现在只有 70.5%。这意味着模型不能再记住训练集中的所有结果。
然而,验证集上的分数更好:66.9%,比之前的结果(65%)有所提高。通过使其更简单,我们提高了模型泛化的能力。现在它更好地预测它之前未见过的客户的结局。
然而,这棵树还有一个问题——它太简单了。为了使其更好,我们需要调整模型:尝试不同的参数,看看哪些参数能导致最佳的 AUC。除了 max_depth,我们还可以控制其他参数。为了了解这些参数的含义以及它们如何影响模型,让我们退一步,看看决策树是如何从数据中学习规则的。
6.2.2 决策树学习算法
要了解决策树如何从数据中学习,让我们简化这个问题。首先,我们将使用一个包含仅一个特征:assets 的更小的数据集(图 6.11)。

图 6.11 一个包含一个特征:assets 的较小数据集。目标变量是 status。
第二,我们将生长一个非常小的树,只有一个节点。
数据集中我们只有一个特征:assets。这就是为什么节点中的条件将是 assets > T,其中 T 是我们需要确定的阈值。如果条件为真,我们将预测“OK”,如果为假,我们的预测将是“default”(图 6.12)。

图 6.12 一个只有一个节点的简单决策树。该节点包含一个条件 assets > T。我们需要找到 T 的最佳值。
条件 assets > T 被称为 split。它将数据集分为两组:满足条件的数据点和不满足条件的数据点。
如果 T 是 4000,那么我们就有资产超过 $4,000 的客户(在右侧)和资产少于 $4,000 的客户(在左侧)(图 6.13)。

图 6.13 节点中的条件将数据集分为两部分:满足条件的数据点(在右侧)和不满足条件的数据点(在左侧)。
现在,我们将这些组转换为 leaves——决策节点——通过在每个组中取最频繁的状态并将其用作最终决策。在我们的例子中,“default”是左侧组中最频繁的结果,“OK”在右侧(图 6.14)。

图 6.14 左侧最频繁的结果是“default”。对于右侧的组,结果是“OK”。
因此,如果一个客户的资产超过$4,000,我们的决策是“OK”,否则是“default” assets > 4000(图 6.15)。

图 6.15 通过将每个组的最大频率结果分配给叶子节点,我们得到最终的决策树
杂质度
这些组应尽可能同质。理想情况下,每个组应只包含一个类的观测值。在这种情况下,我们称这些组为纯。
例如,如果我们有一个包含四个客户的结果组[“default”,“default”,“default”,“default”],它是纯的:它只包含违约的客户。但一个组[“default”,“default”,“default”,“OK”]是不纯的:有一个客户没有违约。
当训练决策树模型时,我们希望找到这样的 T,使得两组的 杂质度 最小。
因此,寻找 T 的算法相当简单:
-
尝试所有可能的 T 值。
-
对于每个 T,将数据集分为左右两组并测量它们的杂质度。
-
选择具有最低杂质度的 T。
我们可以使用不同的标准来衡量杂质度。最容易理解的是 误分类率,它表示一个组中有多少观测值不属于多数类。
注意:Scikit-learn 使用更先进的分割标准,如熵和基尼杂质度。我们在这本书中没有涵盖它们,但思想是相同的:它们测量分割的杂质度程度。

图 6.16 对于 assets > 4000,两组的误分类率都是四分之一。
让我们计算分割 T = 4000 的误分类率(图 6.16):
-
对于左侧组,多数类是“default”。总共有四个数据点,其中有一个不属于“default”。误分类率是 25%(1/4)。
-
对于右侧组,“OK”是多数类,有一个“default”。因此,误分类率也是 25%(1/4)。
-
为了计算分割的整体杂质度,我们可以对两组取平均值。在这种情况下,平均值为 25%。
注意:在实际中,我们不是简单地跨两组取平均值,而是按比例对每个组进行加权平均——我们按其大小成比例地加权每个组。为了简化计算,我们在这章中使用简单平均。
T = 4000 不是 assets 的唯一可能分割。让我们尝试其他 T 值,例如 2000、3000 和 5000(图 6.17):
-
对于 T = 2000,左侧的杂质度为 0%(0/2,所有都是“default”),右侧的杂质度为 33.3%(2/6,6 个中的 2 个是“default”,其余是“OK”)。平均值为 16.6%。
-
对于 T = 3000,左侧为 0%,右侧为 20%(1/5)。平均值为 10%。
-
对于 T = 5000,左侧的 50%(3/6)和右侧的 50%(1/2)。平均值为 50%。

图 6.17 除了 assets > 4000,我们还可以尝试其他 T 的值,例如 2000、3000 和 5000。
当 T = 3000 时,最佳平均不纯度为 10%:左边的树没有错误,而右边的树只有一个(五行中的一行)。因此,我们应该选择 3000 作为我们最终模型的阈值(图 6.18)。

图 6.18 对于这个数据集,最好的分割是 assets > 3000。
选择最佳的分割特征
现在让我们使问题变得更加复杂,并向数据集添加另一个特征:debt(图 6.19)。

图 6.19 包含两个特征的数据集:assets 和 debt。目标变量是 status。
之前我们只有一个特征:assets。我们确信它将被用于分割数据。现在我们有两个特征,因此除了选择最佳的分割阈值外,我们还需要弄清楚使用哪个特征。
解决方案很简单:我们尝试所有特征,并为每个特征选择最佳阈值。
让我们修改训练算法以包括这个变化:
-
对于每个特征,尝试所有可能的阈值。
-
对于每个阈值值 T,测量分割的不纯度。
-
选择具有可能最低不纯度的特征和阈值。
让我们将这个算法应用于我们的数据集:
-
我们已经确定对于
assets,最好的 T 是 3000。这个分割的平均不纯度为 10%。 -
对于
debt,最好的 T 是 1000。在这种情况下,平均不纯度为 17%。
因此,最好的分割是 asset > 3000(图 6.20)。

图 6.20 最好的分割是 assets > 3000,其平均不纯度为 10%。
左边的群组已经纯净,但右边的群组不是。我们可以通过重复这个过程来减少其不纯度:再次分割它!
当我们将相同的算法应用于右侧的数据集时,我们发现最好的分割条件是 debt > 1000。现在树中有两个层级——或者说,我们可以这样说,这个树的深度是 2(图 6.21)。

图 6.21 通过递归地重复算法到右边的群组,我们得到一个有两个层级的树。
在决策树准备好之前,我们需要做最后一步:将群组转换为决策节点。为此,我们取每个群组中最频繁的状态。这样,我们得到一个决策树(图 6.22)。

图 6.22 群组已经纯净,所以最频繁的状态就是每个群组唯一的状态。我们将这个状态作为每个叶节点的最终决策。
停止标准
当训练决策树时,我们可以继续分割数据,直到所有群组都是纯净的。这正是当我们不对 Scikit-learn 中的树进行任何限制时发生的情况。正如我们所看到的,得到的模型变得过于复杂,这导致了过拟合。
我们通过使用 max_depth 参数解决了这个问题——我们限制了树的大小,不让它长得太大。
要决定是否继续分割数据,我们使用停止标准——描述是否应在树中添加另一个分割或停止的标准。
最常见的停止标准是
-
组已经纯净。
-
树达到了深度限制(由
max_depth参数控制)。 -
组太小,无法继续分割(由
min_samples_leaf参数控制)。
通过使用这些标准提前停止,我们迫使我们的模型更简单,从而降低过拟合的风险。
让我们利用这些信息来调整训练算法:
-
找到最佳分割点:
-
对每个特征尝试所有可能的阈值值。
-
使用具有最低不纯度的算法。
-
-
如果达到最大允许深度,则停止。
-
如果左侧的组足够大且尚未纯净,则在左侧重复。
-
如果右侧的组足够大且尚未纯净,则在右侧重复。
尽管这是一个简化的决策树学习算法版本,但它应该足以让你对学习过程的内部有足够的直觉。
最重要的是,我们知道两个参数控制着模型的复杂性。通过改变这些参数,我们可以提高模型的表现。
练习 6.1
我们有一个包含 10 个特征的数据集,需要向该数据集添加另一个特征。训练速度会发生什么变化?
a) 添加一个特征后,训练时间会更长。
b) 特征数量不会影响训练速度。
6.2.3 决策树参数调整
寻找最佳参数集的过程称为参数调整。我们通常通过改变模型并检查其在验证数据集上的分数来完成此操作。最后,我们使用具有最佳验证分数的模型。
正如我们刚刚学到的,我们可以调整两个参数:
-
max_depth -
min_leaf_size
这两个是最重要的,所以我们只调整它们。您可以在官方文档中检查其他参数(scikit-learn.org/stable/modules/generated/sklearn.tree.DecisionTreeClassifier.html)。
在我们之前训练模型时,我们将树的深度限制为 2,但没有触及min_leaf_size。这样,我们在验证集上得到了 66%的 AUC。
让我们找到最佳参数。
我们首先调整max_depth。为此,我们迭代几个合理的值,看看哪个效果最好:
for depth in [1, 2, 3, 4, 5, 6, 10, 15, 20, None]:
dt = DecisionTreeClassifier(max_depth=depth)
dt.fit(X_train, y_train)
y_pred = dt.predict_proba(X_val)[:, 1]
auc = roc_auc_score(y_val, y_pred)
print('%4s -> %.3f' % (depth, auc))
值None表示没有对深度的限制,因此树将尽可能生长。
当我们运行此代码时,我们看到max_depth为 5 时给出了最佳的 AUC(76.6%),其次是 4 和 6(图 6.23)。

图 6.23 深度的最佳值为 5(76.6%),其次是 4(76.1%)和 6(75.4%)。
接下来,我们调整min_leaf_size。为此,我们对max_depth的三个最佳参数进行迭代,并对每个参数,遍历不同的min_leaf_size值:
for m in [4, 5, 6]:
print('depth: %s' % m)
for s in [1, 5, 10, 15, 20, 50, 100, 200]:
dt = DecisionTreeClassifier(max_depth=m, min_samples_leaf=s)
dt.fit(X_train, y_train)
y_pred = dt.predict_proba(X_val)[:, 1]
auc = roc_auc_score(y_val, y_pred)
print('%s -> %.3f' % (s, auc))
print()
运行后,我们看到最佳的 AUC 为 78.5%,参数为min_sample_leaf=15和max_depth=6(表 6.1)。
注意:正如我们所见,我们用于min_leaf_size的值会影响max_depth的最佳值。你可以尝试更广泛的max_depth值范围来进一步调整性能。
表 6.1 对于不同min_leaf_size(行)和max_depth(列)值的验证集上的 AUC
| depth=4 | depth=5 | depth=6 | |
|---|---|---|---|
| 1 | 0.761 | 0.766 | 0.754 |
| 5 | 0.761 | 0.768 | 0.760 |
| 10 | 0.761 | 0.762 | 0.778 |
| 15 | 0.764 | 0.772 | 0.785 |
| 20 | 0.761 | 0.774 | 0.774 |
| 50 | 0.753 | 0.768 | 0.770 |
| 100 | 0.756 | 0.763 | 0.776 |
| 200 | 0.747 | 0.759 | 0.768 |
我们已经找到了最佳参数,所以让我们使用它们来训练最终的模型:
dt = DecisionTreeClassifier(max_depth=6, min_samples_leaf=15)
dt.fit(X_train, y_train)
决策树是简单而有效的模型,但当我们组合多个树时,它们变得更加强大。接下来,我们将看到如何实现它以获得更好的预测性能。
6.3 随机森林
假设我们没有机器学习算法来帮助我们进行信用风险评估。相反,我们有一组专家。
每个专家可以独立决定是否批准贷款申请或拒绝它。单个专家可能会犯错误。然而,所有专家一起决定接受申请,但客户未能偿还金钱的可能性较小。
因此,我们可以独立地询问所有专家,然后将他们的意见合并成最终的决策,例如,通过使用多数投票(图 6.24)。

图 6.24 一组专家的决策比单个专家的决策更好。
这个想法也适用于机器学习。单个模型可能错误,但如果我们将多个模型的输出组合成一个,错误答案的可能性更小。这个概念被称为集成学习,模型的组合被称为集成。
为了使这可行,模型需要不同。如果我们训练同一个决策树模型 10 次,它们都会预测相同的输出,所以这完全没有用。
要有不同的模型,最简单的方法是训练每棵树在不同的特征子集上。例如,假设我们有三个特征:“资产”、“负债”和“价格”。我们可以训练三个模型:
-
第一个将使用“资产”和“负债”。
-
第二个将使用“负债”和“价格”。
-
最后一个将使用“资产”和“价格”。
采用这种方法,我们将有不同的树,每棵树都会做出自己的决策(图 6.25)。但是,当我们把他们的预测放在一起时,他们的错误会平均化,结合起来,它们具有更强的预测能力。

图 6.25 我们想要组合的集成模型不应该相同。我们可以通过在每个树的不同特征子集上训练来确保它们不同。
将多个决策树组合成一个集成的方式称为随机森林。为了训练随机森林,我们可以这样做(图 6.26):
-
训练N个独立的决策树模型。
-
对于每个模型,选择一个随机特征子集,并仅使用这些特征进行训练。
-
在预测时,将N个模型的输出组合在一起。
注意:这是一个非常简化的算法版本。它足以说明主要思想,但在现实中,它更复杂。

图 6.26 训练随机森林模型:对于每个树的训练,随机选择一个特征子集。在做出最终预测时,将所有预测组合在一起。
Scikit-learn 包含随机森林的实现,因此我们可以用它来解决我们的问题。让我们试试。
6.3.1 训练随机森林
在 Scikit-learn 中使用随机森林,我们需要从ensemble包中导入RandomForestClassifier:
from sklearn.ensemble import RandomForestClassifier
在训练模型时,我们首先需要指定我们想要在集成中拥有的树的数量。我们通过n_estimators参数来完成:
rf = RandomForestClassifier(n_estimators=10)
rf.fit(X_train, y_train)
训练完成后,我们可以评估结果:
y_pred = rf.predict_proba(X_val)[:, 1]
roc_auc_score(y_val, y_pred)
它显示 77.9%。然而,你看到的数字可能不同。每次我们重新训练模型,分数都会变化:它在 77%到 80%之间变化。
原因是随机化:为了训练一棵树,我们随机选择一个特征子集。为了使结果一致,我们需要通过将某个值分配给random_state参数来固定随机数生成器的种子:
rf = RandomForestClassifier(n_estimators=10, random_state=3)
rf.fit(X_train, y_train)
现在我们可以评估它:
y_pred = rf.predict_proba(X_val)[:, 1]
roc_auc_score(y_val, y_pred)
这次,我们得到了 78%的 AUC。无论我们重新训练模型多少次,这个分数都不会改变。
集成中的树的数量是一个重要的参数,它影响模型的性能。通常,具有更多树的模型比具有较少树的模型更好。另一方面,添加过多的树并不总是有帮助。
为了看到我们需要多少棵树,我们可以遍历不同的n_estimators值,并查看其对 AUC 的影响:
aucs = [] ❶
for i in range(10, 201, 10): ❷
rf = RandomForestClassifier(n_estimators=i, random_state=3) ❷
rf.fit(X_train, y_train) ❷
y_pred = rf.predict_proba(X_val)[:, 1] ❸
auc = roc_auc_score(y_val, y_pred) ❸
print('%s -> %.3f' % (i, auc)) ❸
aucs.append(auc) ❹
❶ 创建一个包含 AUC 结果的列表
❷ 在每次迭代中逐步训练更多的树
❸ 评估分数
❹ 将分数添加到其他分数的列表中
在这段代码中,我们尝试不同的树的数量:从 10 到 200,以 10 为步长(10,20,30,...)。每次我们训练一个模型,我们都在验证集上计算其 AUC,并记录下来。
完成后,我们可以绘制结果:
plt.plot(range(10, 201, 10), aucs)
在图 6.27 中,我们可以看到结果。

图 6.27 不同n_estimators参数值的随机森林模型性能
性能在前 25-30 棵树时迅速增长;然后增长放缓。在 130 棵树之后,添加更多的树不再有帮助:性能大约保持在 82%的水平。
树的数量并不是我们为了获得更好的性能可以更改的唯一参数。接下来,我们看看还有哪些其他参数我们应该调整以改进模型。
6.3.2 随机森林的参数调整
随机森林集成由多个决策树组成,因此我们需要调整随机森林的最重要参数是相同的:
-
max_depth -
min_leaf_size
我们可以更改其他参数,但在此章节中不会详细讨论。有关更多信息,请参阅官方文档(scikit-learn.org/ stable/modules/generated/sklearn.ensemble.RandomForestClassifier.html)。
让我们从max_depth开始。我们已经知道这个参数显著影响决策树的表现。对于随机森林也是如此:较大的树比较小的树更容易过拟合。
让我们测试几个max_depth的值,看看随着树的数量增长,AUC 如何演变:
all_aucs = {} ❶
for depth in [5, 10, 20]: ❷
print('depth: %s' % depth)
aucs = [] ❸
for i in range(10, 201, 10): ❸
rf = RandomForestClassifier(n_estimators=i,
max_depth=depth, random_state=1) ❹
rf.fit(X_train, y_train)
y_pred = rf.predict_proba(X_val)[:, 1]
auc = roc_auc_score(y_val, y_pred) ❺
print('%s -> %.3f' % (i, auc)) ❺
aucs.append(auc) ❺
all_aucs[depth] = aucs ❻
print()
❶ 创建一个包含 AUC 结果的字典
❷ 遍历不同的深度值
❸ 为当前深度级别创建一个包含 AUC 结果的列表
❹ 遍历不同的 n_estimator 值
❺ 评估模型
❻ 将当前深度级别的 AUC 保存到字典中
现在对于每个max_depth的值,我们有一系列 AUC 分数。我们可以现在绘制它们:
num_trees = list(range(10, 201, 10))
plt.plot(num_trees, all_aucs[5], label='depth=5')
plt.plot(num_trees, all_aucs[10], label='depth=10')
plt.plot(num_trees, all_aucs[20], label='depth=20')
plt.legend()
在图 6.28 中我们看到结果。

图 6.28 不同max_depth参数值的随机森林性能
当max_depth=10时,AUC 超过 82%,而对于其他值,表现较差。
现在让我们调整min_samples_leaf。我们将上一步中max_depth参数的值设置好,然后按照之前的方法确定min_samples_leaf的最佳值:
all_aucs = {}
for m in [3, 5, 10]:
print('min_samples_leaf: %s' % m)
aucs = []
for i in range(10, 201, 20):
rf = RandomForestClassifier(n_estimators=i, max_depth=10, min_samples_leaf=m, random_state=1)
rf.fit(X_train, y_train)
y_pred = rf.predict_proba(X_val)[:, 1]
auc = roc_auc_score(y_val, y_pred)
print('%s -> %.3f' % (i, auc))
aucs.append(auc)
all_aucs[m] = aucs
print()
让我们绘制它:
num_trees = list(range(10, 201, 20))
plt.plot(num_trees, all_aucs[3], label='min_samples_leaf=3')
plt.plot(num_trees, all_aucs[5], label='min_samples_leaf=5')
plt.plot(num_trees, all_aucs[10], label='min_samples_leaf=10')
plt.legend()
然后回顾结果(图 6.29)。

图 6.29 不同min_samples_leaf值(max_depth=10)的随机森林性能
我们看到对于小的min_samples_leaf值,AUC 略好,最佳值为 5。
因此,对于我们的问题,随机森林的最佳参数是
-
max_depth=10 -
min_samples_leaf=5
我们使用 200 棵树实现了最佳的 AUC,因此我们应该将n_estimators参数设置为 200。
让我们训练最终的模型:
rf = RandomForestClassifier(n_estimators=200, max_depth=10, min_samples_leaf=5, random_state=1)
随机森林并不是结合多个决策树的唯一方法。还有另一种方法:梯度提升。我们将在下一节中介绍。
练习 6.2
为了使集成有用,随机森林中的树应该彼此不同。这是通过以下方式实现的:
a) 为每棵单独的树选择不同的参数
b) 为每棵树随机选择不同的特征子集
c) 随机选择分割值
6.4 梯度提升
在随机森林中,每棵树都是独立的:它是在不同的特征集上训练的。在单独的树训练完成后,我们将它们的决策组合起来以获得最终的决策。
然而,这并不是将多个模型组合在一起的一种唯一方法。另一种方法是按顺序训练模型——每个后续模型都试图纠正前一个模型的错误:
-
训练第一个模型。
-
查看它所犯的错误。
-
训练另一个模型来纠正这些错误。
-
再次查看错误;按顺序重复。
这种组合模型的方式被称为提升。梯度提升是这种方法的特定变体,它与树特别有效(见图 6.30)。

图 6.30 在梯度提升中,我们按顺序训练模型,每个后续的树都纠正前一个树的错误。
让我们看看我们如何用它来解决我们的问题。
6.4.1 XGBoost:极端梯度提升
我们有许多优秀的梯度提升模型实现:Scikit-learn 的GradientBoostingClassifier,XGBoost,LightGBM 和 CatBoost。在本章中,我们使用 XGBoost(代表“Extreme Gradient Boosting”),这是最受欢迎的实现。
XGBoost 不包含在 Anaconda 中,因此要使用它,我们需要安装它。最简单的方法是使用pip安装:
pip install xgboost
接下来,打开我们的项目笔记本并导入它:
import xgboost as xgb
注意:在某些情况下,导入 XGBoost 可能会给出类似于YMLLoadWarning的警告。你不必担心这个问题;库将无问题地工作。
在导入 XGBoost 时使用别名xgb是一种约定,就像在其他流行的 Python 机器学习包中一样。
在我们能够训练 XGBoost 模型之前,我们需要将我们的数据包装成DMatrix——一种用于高效查找分割的特殊数据结构。让我们来做这件事:
dtrain = xgb.DMatrix(X_train, label=y_train, feature_names=dv.feature_names_)
当创建一个DMatrix实例时,我们传递三个参数:
-
X_train:特征矩阵 -
y_train:目标变量 -
feature_names:X_train中特征的名字
让我们对验证数据集也做同样的事情:
dval = xgb.DMatrix(X_val, label=y_val, feature_names=dv.feature_names_)
下一步是指定训练参数。我们只使用 XGBoost 默认参数的一小部分(查看官方文档以获取参数完整列表:xgboost.readthedocs.io/en/latest/parameter.html):
xgb_params = {
'eta': 0.3,
'max_depth': 6,
'min_child_weight': 1,
'objective': 'binary:logistic',
'nthread': 8,
'seed': 1,
'silent': 1
}
对于我们来说,现在最重要的参数是objective:它指定了学习任务。我们正在解决一个二元分类问题——这就是为什么我们需要选择binary :logistic。我们将在本节的后面部分介绍这些参数的其余部分。
为了训练一个 XGBoost 模型,我们使用train函数。让我们从 10 棵树开始:
model = xgb.train(xgb_params, dtrain, num_boost_round=10)
我们向train函数提供了三个参数:
-
xgb_params:训练参数 -
dtrain:训练数据集(DMatrix的一个实例) -
num_boost_round=10:要训练的树的数量
几秒钟后,我们得到一个模型。为了评估它,我们需要在验证数据集上进行预测。为此,使用predict方法,并将验证数据包装在DMatrix中:
y_pred = model.predict(dval)
结果y_pred是一个一维 NumPy 数组,包含预测:验证数据集中每个客户的预测风险分数(图 6.31)。

图 6.31 XGBoost 的预测
接下来,我们使用与之前相同的方法计算 AUC:
roc_auc_score(y_val, y_pred)
执行后,我们得到 81.5%。这是一个相当好的结果,但仍然略逊于我们最好的随机森林模型(82.5%)。
当我们可以看到随着树的数量增长,模型性能如何变化时,训练 XGBoost 模型会更简单。我们将在下一部分看到如何做到这一点。
6.4.2 模型性能监控
要了解随着树的数量增长,AUC 如何变化,我们可以使用 watchlist——XGBoost 内置的用于监控模型性能的功能。
watchlist 是一个包含元组的 Python 列表。每个元组包含一个 DMatrix 及其名称。我们通常这样做:
watchlist = [(dtrain, 'train'), (dval, 'val')]
此外,我们修改了训练参数列表:我们需要指定用于评估的指标。在我们的例子中,它是 AUC:
xgb_params = {
'eta': 0.3,
'max_depth': 6,
'min_child_weight': 1,
'objective': 'binary:logistic',
'eval_metric': 'auc', ❶
'nthread': 8,
'seed': 1,
'silent': 1
}
❶ 将评估指标设置为 AUC
在训练期间使用 watchlist,我们需要为train函数指定两个额外的参数:
-
evals: watchlist。 -
verbose_eval: 打印指标的频率。如果我们将其设置为“10”,则每 10 步后我们会看到结果。
让我们来训练它:
model = xgb.train(xgb_params, dtrain,
num_boost_round=100,
evals=watchlist, verbose_eval=10)
在训练过程中,XGBoost 将分数打印到输出:
[0] train-auc:0.862996 val-auc:0.768179
[10] train-auc:0.950021 val-auc:0.815577
[20] train-auc:0.973165 val-auc:0.817748
[30] train-auc:0.987718 val-auc:0.817875
[40] train-auc:0.994562 val-auc:0.813873
[50] train-auc:0.996881 val-auc:0.811282
[60] train-auc:0.998887 val-auc:0.808006
[70] train-auc:0.999439 val-auc:0.807316
[80] train-auc:0.999847 val-auc:0.806771
[90] train-auc:0.999915 val-auc:0.806371
[99] train-auc:0.999975 val-auc:0.805457
随着树的数量增长,训练集上的分数上升(图 6.32)。

图 6.32 树的数量对训练集和验证集 AUC 的影响。要了解如何绘制这些值,请查看书中 GitHub 仓库中的笔记本。
这种行为是预期的:在提升中,每个后续模型都试图纠正前一个步骤中的错误,因此分数总是提高的。
然而,对于验证分数来说,情况并非如此。它最初会上升,但随后开始下降。这是过拟合的影响:我们的模型变得越来越复杂,直到它简单地记住整个训练集。这对预测训练集之外的客户结果没有帮助,验证分数反映了这一点。
我们在第 30 次迭代时获得了最佳的 AUC(81.7%),但这与第 10 次迭代获得的分数(81.5%)并没有太大区别。
接下来,我们将看到如何通过调整参数来获得 XGBoost 的最佳性能。
6.4.3 XGBoost 的参数调整
此前,我们使用默认参数的子集来训练模型:
xgb_params = {
'eta': 0.3,
'max_depth': 6,
'min_child_weight': 1,
'objective': 'binary:logistic',
'eval_metric': 'auc',
'nthread': 8,
'seed': 1,
'silent': 1
}
我们主要对前三个参数感兴趣。这些参数控制训练过程:
-
eta: 学习率。决策树和随机森林没有这个参数。我们将在本节稍后调整它时介绍。 -
max_depth: 每棵树允许的最大深度;与 Scikit-learn 中的DecisionTreeClassifier的max_depth相同。 -
min_child_weight: 每个组中最小观察数;与 Scikit-learn 中的DecisionTreeClassifier的min_leaf_size相同。
其他参数:
-
objective:我们想要解决的问题的类型。对于分类,它应该是binary:logistic。 -
eval_metric:我们用于评估的指标。对于这个项目,它是“AUC”。 -
nthread:我们用于训练模型的线程数。XGBoost 在并行化训练方面非常出色,所以将其设置为计算机的核心数。 -
seed:随机数生成器的种子;我们需要设置它以确保结果可重复。 -
silent:输出的详细程度。当我们将其设置为“1”时,它只输出警告。
这不是参数的完整列表,只是基本参数。你可以在官方文档中了解更多关于所有参数的信息(xgboost.readthedocs.io/en/latest/parameter.html)。
我们已经知道max_depth和min_child_weight(min_leaf_size),但我们之前没有遇到过eta——学习率参数。让我们来谈谈它,看看我们如何可以优化它。
学习率
在提升中,每一棵树都试图纠正前一次迭代的错误。学习率决定了这种纠正的权重。如果我们有一个大的eta值,纠正会显著地超过之前的预测。另一方面,如果值较小,只有一小部分这种纠正被使用。
在实践中,这意味着
-
如果
eta太大,模型会过早地开始过拟合,而没有意识到其全部潜力。 -
如果它太小,我们需要训练很多树才能产生好的结果。
默认值 0.3 对于大数据集来说相当合理,但对我们这样的小数据集,我们应该尝试更小的值,如 0.1 甚至 0.05。
让我们试试看是否有助于提高性能:
xgb_params = {
'eta': 0.1, ❶
'max_depth': 6,
'min_child_weight': 1,
'objective': 'binary:logistic',
'eval_metric': 'auc',
'nthread': 8,
'seed': 1,
'silent': 1
}
❶ 将eta从 0.3 改为 0.1
因为现在我们可以使用 watchlist 来监控我们模型的性能,我们可以训练尽可能多的迭代次数。之前我们使用了 100 次迭代,但这可能对于较小的eta来说不够。所以让我们用 500 轮进行训练:
model = xgb.train(xgb_params, dtrain,
num_boost_round=500, verbose_eval=10,
evals=watchlist)
当运行时,我们看到最佳的验证分数是 82.4%:
[60] train-auc:0.976407 val-auc:0.824456
之前,当eta设置为默认值 0.3 时,我们能够达到 81.7%的 AUC。让我们比较这两个模型(图 6.33)。

图 6.33 eta参数对验证分数的影响
当eta为 0.3 时,我们很快就能得到最佳的 AUC,但随后开始过拟合。在第 30 次迭代后,验证集上的性能下降。
当eta为 0.1 时,AUC 增长较慢,但峰值更高。对于较小的学习率,需要更多的树才能达到峰值,但我们可以实现更好的性能。
为了比较,我们还可以尝试其他eta的值(图 6.34):
-
对于 0.05,最佳的 AUC 为 82.2%(经过 120 次迭代)。
-
对于 0.01,最佳的 AUC 是 82.1%(经过 500 次迭代)。

图 6.34 当eta较小时,模型需要更多的树。
当eta为 0.05 时,性能与 0.1 相似,但需要多 60 次迭代才能达到峰值。
对于eta为 0.01,它增长得太慢,即使经过 500 次迭代,也没有达到峰值。如果我们尝试更多的迭代,它可能达到与其他值相同的 AUC 水平。即使如此,这也不实用:在预测时间评估所有这些树变得计算成本高昂。
因此,我们使用eta的值为 0.1。接下来,让我们调整其他参数。
练习 6.3
我们有一个eta=0.1的梯度提升模型。它需要 60 棵树来达到最佳性能。如果我们把eta增加到 0.5,会发生什么?
a) 树的数量不会改变。
b) 模型需要更多的树来达到其最佳性能。
c) 模型需要更少的树来达到其最佳性能。
调整其他参数
下一个调整的参数是max_depth。默认值是 6,所以我们可以尝试
-
较低的值;例如,3
-
较高的值;例如,10
这个结果应该能让我们知道最佳max_depth值是在 3 到 6 之间还是 6 到 10 之间。
首先,检查 3:
xgb_params = {
'eta': 0.1,
'max_depth': 3, ❶
'min_child_weight': 1,
'objective': 'binary:logistic',
'eval_metric': 'auc',
'nthread': 8,
'seed': 1,
'silent': 1
}
❶ 将 max_depth 从 6 改为 3
使用它我们能得到的最佳 AUC 是 83.6%。
接下来,尝试 10。在这种情况下,最佳值是 81.1%。
这意味着max_depth的最佳参数值应该在 3 到 6 之间。然而,当我们尝试 4 时,我们发现最佳 AUC 是 83%,这略低于 3 深度的 AUC(图 6.35)。

图 6.35 max_depth的最佳值是 4:使用它,我们可以达到 83.6%的 AUC。
下一个调整的参数是min_child_weight。它与 Scikit-learn 中的决策树中的min_leaf_size相同:它控制树在叶子节点中的最小观测数。
让我们尝试一系列值,看看哪个效果最好。除了默认值(1)外,我们还可以尝试 10 和 30(图 6.36)。

图 6.36 min_child_weight的最佳值是 1,但与其他参数值相比并没有显著不同。
从图 6.36 中我们可以看到
-
对于
min_child_weight=1,AUC 为 83.6%。 -
对于
min_child_weight=10,AUC 为 83.3%。 -
对于
min_child_weight=30,AUC 为 83.5%。
这些选项之间的差异并不显著,所以我们将保留默认值。
我们最终模型的参数是
xgb_params = {
'eta': 0.1,
'max_depth': 3,
'min_child_weight': 1,
'objective': 'binary:logistic',
'eval_metric': 'auc',
'nthread': 8,
'seed': 1,
'silent': 1
}
在我们可以完成模型之前,我们需要做最后一步:我们需要选择最佳树的数量。这很简单:查看验证分数达到峰值时的迭代次数,并使用这个数字。
在我们的情况下,我们需要为最终模型训练 180 棵树(图 6.37):
[160] train-auc:0.935513 val-auc:0.835536
[170] train-auc:0.937885 val-auc:0.836384
[180] train-auc:0.93971 val-auc:0.836565 <- best
[190] train-auc:0.942029 val-auc:0.835621
[200] train-auc:0.943343 val-auc:0.835124

图 6.37 最终模型的最佳树数量是 180。
随机森林模型能得到的最佳 AUC 是 82.5%,而梯度提升模型能得到的最佳 AUC 是 1%更多(83.6%)。
这是最好的模型,所以让我们将其作为我们的最终模型——并且我们应该用它来评分贷款申请。
6.4.4 测试最终模型
我们几乎准备好使用它来进行风险评估。在我们能够使用它之前,我们还需要做两件事:
-
在结合了训练集和验证集的数据集上重新训练最终模型。我们不再需要验证集,因此我们可以使用更多的数据进行训练,这将使模型略微更好。
-
在测试集上测试模型。这是我们一开始就保留的数据的一部分。现在我们使用它来确保模型没有过拟合,并且能够在完全未见过的数据上表现良好。
下一步是:
-
将与
df_train和df_val相同的预处理应用于df_full_train和df_test。结果,我们得到了特征矩阵X_train和X_test以及我们的目标变量y_train和y_test。 -
使用我们之前选择的参数在组合数据集上训练模型。
-
将模型应用于测试数据以获取测试预测。
-
确认模型表现良好且没有过拟合。
让我们开始吧。首先,创建目标变量:
y_train = (df_train_full.status == 'default').values
y_test = (df_test.status == 'default').values
因为我们使用整个 DataFrame 来创建特征矩阵,所以我们需要移除目标变量:
del df_train_full['status']
del df_test['status']
接下来,我们将 DataFrame 转换为字典列表,然后使用独热编码来获取特征矩阵:
dict_train = df_train_full.fillna(0).to_dict(orient='records')
dict_test = df_test.fillna(0).to_dict(orient='records')
dv = DictVectorizer(sparse=False)
X_train = dv.fit_transform(dict_train)
X_test = dv.transform(dict_test)
最后,我们使用之前确定的最优参数和这些数据来训练 XGBoost 模型:
dtrain = xgb.DMatrix(X_train, label=y_train, feature_names=dv.feature_names_)
dtest = xgb.DMatrix(X_test, label=y_test, feature_names=dv.feature_names_)
xgb_params = {
'eta': 0.1,
'max_depth': 3,
'min_child_weight': 1,
'objective': 'binary:logistic',
'eval_metric': 'auc',
'nthread': 8,
'seed': 1,
'silent': 1
}
num_trees = 160
model = xgb.train(xgb_params, dtrain, num_boost_round=num_trees)
然后在测试集上评估其性能:
y_pred_xgb = model.predict(dtest)
roc_auc_score(y_test, y_pred_xgb)
输出是 83.2%,这与验证集上的 83.6%的性能相当。这意味着我们的模型没有过拟合,并且可以很好地与它未见过的客户一起工作。
练习 6.4
随机森林和梯度提升之间的主要区别是
a) 在梯度提升中,树是按顺序训练的,并且每一棵后续的树都会改进前一棵树。在随机森林中,所有树都是独立训练的。
b) 梯度提升比使用随机森林要快得多。
c) 在随机森林中,树是按顺序训练的,并且每一棵后续的树都会改进前一棵树。在梯度提升中,所有树都是独立训练的。
6.5 下一步
我们已经学习了关于决策树、随机森林和梯度提升的基础知识。我们学到了很多,但在这个章节中我们所能涵盖的只是冰山一角。你可以通过做练习来进一步探索这个主题。
6.5.1 练习
-
特征工程是从现有特征中创建新特征的过程。对于这个项目,我们没有创建任何特征;我们只是使用了数据集中提供的那些。添加更多特征应该有助于提高我们模型的性能。例如,我们可以添加请求金额与物品总价的比率。尝试通过工程更多特征进行实验。
-
在训练随机森林时,我们通过为每棵树选择特征的一个随机子集来得到不同的模型。为了控制子集的大小,我们使用
max_features参数。尝试调整此参数,看看它是否会影响验证集上的 AUC。 -
极端随机树(或简称 extra trees)是随机森林的一种变体,其中随机化的想法被推向了极致。它不是寻找最佳可能的分割点,而是随机选择分割条件。这种方法有几个优点:extra trees 训练更快,并且它们不太容易过拟合。另一方面,它们需要更多的树才能达到足够的性能。在 Scikit-learn 中,
ExtraTreesClassifier来自ensemble包实现了它。在这个项目中尝试它。 -
在 XGBoost 中,
colsample_bytree参数控制我们为每棵树选择的特征数量——它与随机森林中的max_features类似。尝试调整此参数,看看是否可以提高性能:尝试从 0.1 到 1.0 的值,步长为 0.1。通常最佳值在 0.6 到 0.8 之间,但有时 1.0 会给出最佳结果。 -
除了随机选择列(特征)之外,我们还可以选择行的一个子集(客户)。这被称为子采样,它有助于防止过拟合。在 XGBoost 中,
subsample参数控制我们为每个集成树选择的示例比例。尝试从 0.4 到 1.0 的值,步长为 0.1。通常最佳值在 0.6 到 0.8 之间。
6.5.2 其他项目
- 所有基于树的模型都可以解决回归问题——预测一个数字。在 Scikit-learn 中,DecisionTreeRegressor 和 RandomForestRegressor 实现了模型的回归变体。在 XGBoost 中,我们需要将目标更改为
reg:squarederror。使用这些模型来预测汽车价格,并尝试解决其他回归问题。
摘要
-
决策树是一个表示一系列 if-then-else 决策的模型。它很容易理解,并且在实践中表现也相当好。
-
我们通过选择最佳分割点来训练决策树,使用不纯度度量。我们控制的主要参数是树的深度和每个叶子的最大样本数。
-
随机森林是将许多决策树组合成一个模型的方法。就像一个专家团队一样,单个树可能会犯错,但在一起,它们不太可能做出错误的决策。
-
随机森林应该有一系列多样化的模型来进行良好的预测。这就是为什么模型中的每棵树都使用不同的特征集进行训练。
-
我们需要为随机森林更改的主要参数与决策树相同:树的深度和每个叶子的最大样本数。此外,我们还需要选择我们想要在集成中拥有的树的数量。
-
虽然在随机森林中树是独立的,但在梯度提升中,树是顺序的,每个后续模型都纠正前一个模型的错误。在某些情况下,这会导致更好的预测性能。
-
我们需要调整的梯度提升参数与随机森林类似:树的深度、叶子节点中最大观测数以及树的数量。除此之外,我们还有
eta——学习率。它指定了每棵树对集成模型的贡献。
基于树的模型易于解释和理解,并且通常表现相当出色。梯度提升非常出色,通常在结构化数据(表格格式的数据)上实现最佳性能。
在下一章中,我们将探讨神经网络:一种不同类型的模型,与之前相比,它在非结构化数据(如图像)上实现最佳性能。
练习题答案
-
练习题 6.1 A) 添加一个更多特征后,训练时间会更长。
-
练习题 6.3 C) 该模型需要更少的树来达到其最佳性能。
-
练习题 6.2 B) 为每棵树随机选择不同的特征子集。
-
练习题 6.4 A) 梯度提升中的树是顺序训练的。在随机森林中,树是独立训练的。
7 神经网络和深度学习
本章涵盖了
-
卷积神经网络用于图像分类
-
TensorFlow 和 Keras——构建神经网络的框架
-
使用预训练的神经网络
-
卷积神经网络的内部结构
-
使用迁移学习训练模型
-
数据增强——生成更多训练数据的过程
之前,我们只处理表格数据——CSV 文件中的数据。在本章中,我们将处理一种完全不同的数据类型——图像。
我们为本章准备的项目是衣服的分类。我们将预测一张衣服的图片是 T 恤、衬衫、裙子、连衣裙还是其他东西。
这是一个图像分类问题。为了解决它,我们将学习如何使用 TensorFlow 和 Keras 训练深度神经网络来识别衣服的类型。本章的材料将帮助您开始使用神经网络并执行任何类似的图像分类项目。
让我们开始吧!
7.1 时尚分类
想象一下,我们在一个在线时尚市场上工作。我们的用户每天上传成千上万张图片来销售他们的衣服。我们希望通过自动推荐合适的类别来帮助用户更快地创建商品列表。
要做到这一点,我们需要一个用于图像分类的模型。之前,我们介绍了多种分类模型:逻辑回归、决策树、随机森林和梯度提升。这些模型在处理表格数据时效果很好,但使用它们来处理图像相当困难。
为了解决我们的问题,我们需要一种不同类型的模型:卷积神经网络,这是一种专门用于图像的特殊模型。这些神经网络由许多层组成,这就是为什么它们通常被称为“深度”。深度学习是机器学习的一部分,它处理深度神经网络。
训练这些模型的框架也与我们之前看到的框架不同,因此在本章中我们使用 TensorFlow 和 Keras 而不是 Scikit-learn。
我们项目的计划是
-
首先,我们下载数据集并使用预训练的模型来分类图像。
-
然后,我们将讨论神经网络,并了解它们是如何在内部工作的。
-
之后,我们调整预训练的神经网络来解决我们的任务。
-
最后,我们通过从我们已有的图像中生成更多图像来扩展我们的数据集。
为了评估我们模型的质量,让我们使用准确率:我们正确分类的项目百分比。
在一个章节中不可能涵盖深度学习背后的所有理论。在本书中,我们专注于最基本的部分,这对于完成本章的项目和其他类似的图像分类项目已经足够。当我们遇到对完成此项目非必要的概念时,我们会参考 CS231n——斯坦福大学关于神经网络的课程。课程笔记可在cs231n.github.io上在线获取。
该项目的代码可在本书的 GitHub 仓库中找到,网址为 github.com/alexeygrigorev/mlbookcamp-code,在 chapter-07-neural-nets 文件夹中。该文件夹中有多个笔记本。对于本章的大部分内容,我们需要 07-neural-nets-train.ipynb。对于 7.5 节,我们使用 07-neural-nets-test.ipynb。
7.1.1 GPU 与 CPU 对比
训练神经网络是一个计算密集型过程,需要强大的硬件来加快速度。为了加快训练速度,我们通常使用 GPU——图形处理单元,或者简单地说,显卡。
对于本章,不需要 GPU。你可以在你的笔记本电脑上完成所有操作,但没有 GPU,速度将比有 GPU 慢大约八倍。
如果你有一张 GPU 显卡,你需要从 TensorFlow 安装特殊的驱动程序来使用它。(有关详细信息,请参阅 TensorFlow 的官方文档:www.tensorflow.org/install/gpu。)或者,你可以租用一个预配置的 GPU 服务器。例如,我们可以使用 AWS SageMaker 来租用一个已经设置好的 Jupyter Notebook 实例。有关如何使用 SageMaker 的详细信息,请参阅附录 E。其他云服务提供商也有带 GPU 的服务器,但本书中不涉及它们。无论你使用什么环境,只要能安装 Python 和 TensorFlow,代码都可以在任何地方运行。
决定代码运行位置后,我们可以进行下一步:下载数据集。
7.1.2 下载服装数据集
首先,让我们为这个项目创建一个文件夹,并将其命名为 07-neural-nets。
对于这个项目,我们需要一个服装数据集。我们将使用服装数据集的一个子集(更多信息请查看 github.com/alexeygrigorev/clothing-dataset),它包含大约 3,800 张 10 个不同类别的图片。数据可在 GitHub 仓库中找到。让我们克隆它:
git clone https://github.com/alexeygrigorev/clothing-dataset-small.git
如果你使用 AWS SageMaker 进行操作,你可以在笔记本的一个单元中执行此命令。只需在命令前加上感叹号(“!”)(图 7.1)。`

图 7.1 在 Jupyter 中执行 shell 脚本命令:只需在命令前加上感叹号(“!”)。
数据集已经按文件夹划分(图 7.2):
-
训练集:用于训练模型的图片(3,068 张图片)
-
验证集:用于验证的图片(341 张图片)
-
测试集:用于测试的图片(372 张图片)

图 7.2 数据集已分为训练集、验证集和测试集。
每个文件夹都有 10 个子文件夹:每个子文件夹对应一种服装类型(图 7.3)。

图 7.3 数据集中的图片按子文件夹组织。
如我们所见,这个数据集包含 10 类服装,从连衣裙和帽子到短裤和鞋子。
每个子文件夹只包含一个类别的图片(图 7.4)。

图 7.4 裤子文件夹的内容
在这些图片中,服装物品有不同的颜色,背景也不同。有些物品放在地板上,有些散布在床上或桌子上,有些则挂在无色背景前。
使用这些多样的图像,我们无法使用之前介绍的方法。我们需要一种特殊类型的模型:神经网络。此模型还需要不同的工具,我们将在下一章介绍。
7.1.3 TensorFlow 和 Keras
如果你使用 AWS SageMaker,你不需要安装任何东西:它已经包含了所有必需的库。
但是如果你使用带有 Anaconda 的笔记本电脑,或者在其他地方运行代码,你需要安装 TensorFlow——一个用于构建神经网络库。
使用pip来完成:
pip install tensorflow
TensorFlow 是一个低级框架,它并不总是容易使用。在本章中,我们使用 Keras——一个建立在 TensorFlow 之上的高级库。Keras 使训练神经网络变得简单得多。它随 TensorFlow 一起预安装,所以我们不需要安装任何额外的东西。
注意:以前,Keras 不是 TensorFlow 的一部分,你可以在互联网上找到许多它仍然是独立库的例子。然而,Keras 的接口并没有发生显著变化,所以你可能发现的多数例子在新 Keras 中仍然有效。
在撰写本文时,TensorFlow 的最新版本是 2.3.0,AWS SageMaker 使用 TensorFlow 版本 2.1.0。版本之间的差异不是问题;本章中的代码适用于这两个版本,并且很可能会适用于所有 TensorFlow 2 版本。
我们准备开始,创建一个新的笔记本,名为 chapter-07-neural-nets。像往常一样,我们首先导入 NumPy 和 Matplotlib:
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline
接下来,导入 TensorFlow 和 Keras:
import tensorflow as tf
from tensorflow import keras
准备工作已完成,现在我们可以查看我们拥有的图像。
7.1.4 图像
Keras 提供了一个用于加载图像的特殊函数,称为load_img。让我们导入它:
from tensorflow.keras.preprocessing.image import load_img
注意:当 Keras 是一个独立的包时,导入看起来是这样的:
from keras.preprocessing.image import load_img
如果你在互联网上找到一些旧的 Keras 代码,并希望用它与 TensorFlow 的最新版本一起使用,只需在导入时在前面添加tensorflow。这很可能会让它正常工作。
让我们使用这个函数来查看其中的一张图像:
path = './clothing-dataset-small/train/t-shirt'
name = '5f0a3fa0-6a3d-4b68-b213-72766a643de7.jpg'
fullname = path + '/' + name
load_img(fullname)
执行单元格后,我们应该看到一件 T 恤的图像(图 7.5)。

图 7.5 训练集中的 T 恤图像
要在神经网络中使用此图像,我们需要调整它的大小,因为模型总是期望图像为特定的大小。例如,我们本章中使用的网络需要一个 150 × 150 的图像或一个 299 × 299 的图像。
要调整图像大小,请指定target_size参数:
load_img(fullname, target_size=(299, 299))
结果,图像变成了方形并且有点压扁(图 7.6)。

图 7.6 调整图像大小,使用target_size参数。
现在我们将使用神经网络来对这张图像进行分类。
7.2 卷积神经网络
神经网络是一类用于解决分类和回归问题的机器学习模型。我们的问题是分类问题——我们需要确定图像的类别。
然而,我们的问题是特殊的:我们处理的是图像。这就是为什么我们需要一种特殊的神经网络类型——卷积神经网络,它可以从图像中提取视觉模式并使用它们进行预测。
预训练神经网络在互联网上可用,让我们看看我们如何可以使用其中一个模型来完成这个项目。
7.2.1 使用预训练模型
从头开始训练卷积神经网络是一个耗时过程,需要大量数据和强大的硬件。对于像 ImageNet 这样的大型数据集(包含 1400 万张图像),可能需要几周不间断的训练。(更多信息请访问image-net.org)
幸运的是,我们不需要自己来做这件事:我们可以使用预训练模型。通常,这些模型是在 ImageNet 上训练的,可以用于通用图像分类。
这非常简单,我们甚至不需要自己下载任何东西——Keras 会自动处理。我们可以使用许多不同类型的模型(称为架构)。您可以在官方 Keras 文档中找到可用预训练模型的良好总结(keras.io/api/applications/)。
对于本章,我们将使用 Xception,这是一个相对较小的模型,但性能良好。首先,我们需要导入模型本身和一些有用的函数:
from tensorflow.keras.applications.xception import Xception
from tensorflow.keras.applications.xception import preprocess_input
from tensorflow.keras.applications.xception import decode_predictions
我们导入了三样东西:
-
Xception: 实际模型 -
preprocess_input: 准备图像以便模型使用的函数 -
decode_prediction: 解码模型预测的函数
让我们加载这个模型:
model = Xception(
weights='imagenet',
input_shape=(299, 299, 3)
)
我们在这里指定了两个参数:
-
weights: 我们希望使用从 ImageNet 预训练的模型。 -
input_shape: 输入图像的大小:高度、宽度和通道数。我们将图像调整大小到 299 × 299,每个图像有三个通道:红色、绿色和蓝色。
当我们第一次加载它时,它会从互联网下载实际模型。完成后,我们就可以使用它了。
让我们在之前看到的图像上测试它。首先,我们使用load_img函数加载它:
img = load_img(fullname, target_size=(299, 299))
img变量是一个Image对象,我们需要将其转换为 NumPy 数组。这很容易做到:
x = np.array(img)
这个数组应该与图像具有相同的形状。让我们检查一下:
x.shape
我们看到(299, 299, 3)。它包含三个维度(图 7.7):
-
图像宽度:299
-
图像高度:299
-
通道数:红色、绿色、蓝色

图 7.7 转换后,图像变成一个形状为宽度 × 高度 × 通道数的 NumPy 数组。
这与我们加载神经网络时指定的输入形状相匹配。然而,模型并不期望只得到一张单独的图像。它得到的是一个批次的图像——几个图像组合在一个数组中。这个数组应该有四个维度:
-
图像数量
-
宽度
-
高度
-
通道数
例如,对于 10 张图像,其形状是(10, 299, 299, 3)。因为我们只有一张图像,我们需要创建一个包含这张单张图像的批次:
X = np.array([x])
注意:如果我们有多个图像,例如,x,y和z,我们会写成
X = np.array([x, y, z])
让我们检查它的形状:
X.shape
如我们所见,它是(1, 299, 299, 3)——它是一张大小为 299 × 299 且有三个通道的图像。
在我们可以将模型应用于我们的图像之前,我们需要使用preprocess_input函数来准备它:
X = preprocess_input(X)
这个函数将原始数组中的 0 到 255 之间的整数转换为-1 到 1 之间的数字。
现在,我们已经准备好使用模型了。
7.2.2 获取预测
要应用模型,使用predict方法:
pred = model.predict(X)
让我们看看这个数组:
pred.shape
这个数组相当大——它包含 1000 个元素(图 7.8)。

图 7.8 预训练的 Xception 模型的输出
这个 Xception 模型预测图像是否属于 1000 个类别之一,所以预测数组中的每个元素都是属于这些类别之一的概率。
我们不知道这些类别是什么,所以仅通过查看数字很难理解这个预测。幸运的是,我们可以使用一个函数,decode_ predictions,将预测解码成有意义的类别名称:
decode_predictions(pred)
这显示了这张图像最有可能的前五个类别:
[[('n02667093', 'abaya', 0.028757658),
('n04418357', 'theater_curtain', 0.020734021),
('n01930112', 'nematode', 0.015735716),
('n03691459', 'loudspeaker', 0.013871926),
('n03196217', 'digital_clock', 0.012909736)]]
并非我们预期的结果。很可能是像这样的 T 恤在 ImageNet 中并不常见,这就是为什么结果对我们问题没有用。
尽管这些结果对我们来说并不特别有帮助,但我们仍然可以将这个神经网络作为解决我们问题的基模型。
要了解我们如何做到这一点,我们首先应该对卷积神经网络的工作方式有一个感觉。让我们看看当我们调用predict方法时模型内部发生了什么。
7.3 模型的内部结构
所有神经网络都是按层组织的。我们取一个图像,通过所有层,最后得到预测(图 7.9)。

图 7.9 神经网络由多个层组成。
通常,一个模型有很多层。例如,我们这里使用的 Xception 模型有 71 层。这就是为什么这些神经网络被称为“深度”神经网络——因为它们有很多层。
对于卷积神经网络来说,最重要的层是
-
卷积层
-
密集层
首先,让我们看看卷积层。
7.3.1 卷积层
即使“卷积层”听起来很复杂,它也不过是一组过滤器——形状简单的“图像”,如条纹(图 7.10)。

图 7.10 卷积层的过滤器示例(非真实网络)
卷积层中的过滤器是在训练过程中由模型学习的。然而,因为我们使用的是预训练的神经网络,所以我们不需要担心它;我们已经有过滤器了。
要将卷积层应用于图片,我们将每个过滤器在这个图像上滑动。例如,我们可以从左到右和从上到下滑动(图 7.11)。

图 7.11 应用过滤器时,我们将其在图像上滑动。
在滑动过程中,我们比较过滤器的内容与过滤器下图像的内容。对于每次比较,我们记录相似度。这样,我们得到一个特征图——一个包含数字的数组,其中大数字表示过滤器和图像之间的匹配,而小数字表示不匹配(图 7.12)。

图 7.12 特征图是应用过滤器到图像的结果。图中高值对应于图像与过滤器之间具有高度相似性的区域。
因此,特征图告诉我们可以在图像的哪个位置找到过滤器中的形状。
一个卷积层由许多过滤器组成,因此我们实际上得到了多个特征图——每个过滤器对应一个(图 7.13)。

图 7.13 每个卷积层包含许多过滤器,因此我们得到一组特征图:每个我们使用的过滤器对应一个。
现在,我们可以将一个卷积层的输出用作下一层的输入。
从前一层我们知道不同条纹和其他简单形状的位置。当两个简单形状出现在相同的位置时,它们会形成更复杂的图案——十字形、角度或圆形。
这就是下一层的过滤器所做的事情:它们将前一层中的形状组合成更复杂的结构。网络越深,网络能够识别的复杂模式就越多(图 7.14)。

图 7.14 深层卷积层可以检测图像的越来越复杂的特征。
我们重复这个过程来检测越来越多复杂的形状。这样,网络“学习”了图像的一些独特特征。对于衣服,可能是短袖或长袖或领口的类型。对于动物,可能是尖耳朵或软耳朵或胡须的存在。
最后,我们得到一个图像的向量表示:一个一维数组,其中每个位置对应某些高级视觉特征。数组的某些部分可能对应袖子,而其他部分则代表耳朵和胡须。在这个层面上,通常很难从这些特征中得出意义,但它们具有足够的区分能力,可以区分 T 恤和裤子,或猫和狗。
现在我们需要使用这种向量表示来组合这些高级特征,并得出最终决策。为此,我们使用不同类型的层——密集层。
7.3.2 密集层
密集层处理图像的向量表示,并将这些视觉特征转换为实际的类别——T 恤、连衣裙、夹克或其他类别(图 7.15)。

图 7.15 卷积层将图像转换为向量表示,密集层将向量表示转换为实际标签。
为了理解它是如何工作的,让我们退一步思考如何使用逻辑回归对图像进行分类。
假设我们想要构建一个二分类模型来预测图像是否为 T 恤。在这种情况下,逻辑回归的输入是图像的向量表示——特征向量 x。
从第三章,我们知道为了进行预测,我们需要将 x 中的特征与权重向量 w 结合,然后应用 sigmoid 函数以获得最终的预测:
sigmoid(x^T w)
我们可以通过将向量 x 的所有成分连接到输出——成为 T 恤的概率(图 7.16)来直观地展示它。

图 7.16 逻辑回归:我们将特征向量 x 的所有成分组合起来以获得预测。
如果我们需要对多个类别进行预测呢?例如,我们可能想知道是否有一张 T 恤、衬衫或连衣裙的图片。在这种情况下,我们可以为每个类别构建多个逻辑回归模型——每个类别一个(图 7.17)。

图 7.17 为了预测多个类别,我们训练多个逻辑回归模型。
通过组合多个逻辑回归模型,我们仅仅创建了一个小型神经网络!
为了使它看起来更简单,我们可以将输出合并到一个层——输出层(图 7.18)。

图 7.18 多个逻辑回归模型组合在一起形成一个小型神经网络。
当我们想要预测 10 个类别时,输出层有 10 个元素。为了进行预测,我们查看输出层的每个元素,并选择得分最高的一个。
在这种情况下,我们有一个网络,只有一个层:将输入转换为输出的层。
这一层被称为 密集层。它被称为“密集”是因为它将输入的每个元素与其输出的所有元素相连接。因此,这些层有时被称为“全连接”(图 7.19)。

图 7.19 密集层将其输入的每个元素与其输出的每个元素相连接。
然而,我们不必仅仅停留在只有一个输出层。我们可以在输入和最终输出之间添加更多层(图 7.20)。

图 7.20 具有两个层的神经网络:一个内部层和一个输出层
因此,当我们调用 predict 时,图像首先通过一系列卷积层。这样,我们提取了该图像的向量表示。接下来,这个向量表示通过一系列密集层,我们得到最终的预测(图 7.21)。

图 7.21 在卷积神经网络中,图像首先通过一系列卷积层,然后通过一系列密集层。
注意:在这本书中,我们给出了卷积神经网络内部结构的简化和高层次概述。除了卷积层和密集层之外,还存在许多其他层。对于这个主题的更深入介绍,请查看 CS231n 笔记(cs231n.github.io/convolutional-networks)。
现在,让我们回到代码,看看我们如何调整预训练神经网络以适应我们的项目。
7.4 训练模型
训练卷积神经网络需要大量时间和数据。但有一个捷径:我们可以使用迁移学习,这是一种将预训练模型适应我们问题的方法。
7.4.1 迁移学习
训练的困难通常来自卷积层。为了能够从图像中提取良好的向量表示,过滤器需要学习良好的模式。为此,网络需要看到许多不同的图像——越多越好。但一旦我们有了良好的向量表示,训练密集层就相对容易了。
这意味着我们可以使用在 ImageNet 上预训练的神经网络来解决我们的问题。这个模型已经学习了良好的过滤器。因此,我们保留这个模型的卷积层,但丢弃密集层,并训练新的(如图 7.22 所示)。

图 7.22 为了将预训练模型适应新领域,我们保留旧的卷积层,但训练新的密集层。
在本节中,我们正是这样做的。但在我们开始训练之前,我们需要准备好我们的数据集。
7.4.2 加载数据
在前面的章节中,我们将整个数据集加载到内存中,并使用它来获取X——包含特征的矩阵。对于图像来说,这更困难:我们可能没有足够的内存来存储所有图像。
Keras 提供了一个解决方案——ImageDataGenerator。它不是将整个数据集加载到内存中,而是从磁盘以小批次加载图像。让我们来使用它:
from tensorflow.keras.preprocessing.image import ImageDataGenerator
train_gen = ImageDataGenerator(
preprocessing_function=preprocess_input ❶
)
❶ 对每张图像应用preprocess_input函数
我们已经知道图像需要使用preprocess_input函数进行预处理。这就是为什么我们需要告诉ImageDataGenerator数据应该如何准备。
我们现在有一个生成器,所以我们只需要将其指向包含数据的目录。为此,使用flow_from_directory方法:
train_ds = train_gen.flow_from_directory(
"clothing-dataset-small/train", ❶
target_size=(150, 150), ❷
batch_size=32, ❸
)
❶ 从训练目录中加载所有图像
❷ 将图像调整大小到 150 × 150
❸ 以 32 张图像的批次加载图像
在我们的初步实验中,我们使用 150 × 150 大小的小图像。这样,训练模型会更快。此外,小尺寸使得使用笔记本电脑进行训练成为可能。
我们的数据集中有 10 类服装,每类的图像都存储在单独的目录中。例如,所有 T 恤都存储在 T 恤文件夹中。生成器可以使用文件夹结构来推断每张图像的标签。
当我们执行单元格时,它会告诉我们训练数据集中有多少张图像以及有多少个类别:
Found 3068 images belonging to 10 classes.
现在,我们对验证数据集重复相同的过程:
validation_gen = ImageDataGenerator(
preprocessing_function=preprocess_input
)
val_ds = validation_gen.flow_from_directory(
"clothing-dataset-small/validation",
target_size=image_size,
batch_size=batch_size,
)
像之前一样,我们使用训练数据集来训练模型,使用验证数据集来选择最佳参数。
我们已经加载了数据,现在我们准备训练一个模型。
7.4.3 创建模型
首先,我们需要加载基础模型——这是我们用于从图像中提取向量表示的预训练模型。像之前一样,我们也使用 Xception,但这次我们只包括预训练的卷积层部分。之后,我们添加我们自己的密集层。
因此,让我们创建基础模型:
base_model = Xception(
weights='imagenet', ❶
include_top=False ❷
input_shape=(150, 150, 3), ❸
)
❶ 使用在 ImageNet 上预训练的模型
❷ 仅保留卷积层
❸ 图像应为 150 × 150 像素,具有三个通道。
注意include_top参数:这样,我们明确指定我们对其预训练神经网络的密集层不感兴趣,只对卷积层感兴趣。在 Keras 术语中,“top”是指网络的最终层集(图 7.23)。

图 7.23 在 Keras 中,网络的输入在底部,输出在顶部,所以include_top=False意味着“不包括最终的密集层”。
我们不想训练基础模型;尝试这样做将破坏所有过滤器。因此,我们将基础模型“冻结”,通过将trainable参数设置为 False:
base_model.trainable = False
现在,让我们构建服装分类模型:
inputs = keras.Input(shape=(150, 150, 3)) ❶
base = base_model(inputs, training=False) ❷
vector = keras.layers.GlobalAveragePooling2D()(base) ❸
outputs = keras.layers.Dense(10)(vector) ❹
model = keras.Model(inputs, outputs) ❺
❶ 输入图像应为 150 × 150 像素,具有三个通道。
❷ 使用 base_model 提取高级特征。
❸ 提取向量表示:将 base_model 的输出转换为向量
❹ 添加一个大小为 10 的密集层:每个类别一个元素
❺ 将输入和输出组合成一个 Keras 模型
我们构建模型的方式被称为“功能风格”。一开始可能会有些困惑,所以让我们逐行查看。
首先,我们指定输入和期望的数组大小:
inputs = keras.Input(shape=(150, 150, 3))
接下来,我们创建基础模型:
base = base_model(inputs, training=False)
尽管base_model已经是一个模型,但我们将其用作函数,并给它两个参数——inputs和training=False:
-
第一个参数说明
base_model的输入将是什么。它将从inputs中来。 -
第二个参数(
training=False)是可选的,表示我们不希望训练基础模型。
结果是base,这是一个功能组件(类似于base_model),我们可以将其与其他组件结合使用。我们将其用作下一层的输入:
vector = keras.layers.GlobalAveragePooling2D()(base)
在这里,我们创建一个池化层——一种特殊的结构,允许我们将卷积层的输出(一个三维数组)转换为向量(一个一维数组)。
创建后,我们立即用base作为参数调用它。这样,我们说这个层的输入来自base。
这可能有点令人困惑,因为我们创建了一个层,然后立即将其连接到基础。我们可以重写它以使其更容易理解:
pooling = keras.layers.GlobalAveragePooling2D() ❶
vector = pooling(base) ❷
❶ 首先创建一个池化层
❷ 连接到基础模型
结果,我们得到vector。这是另一个功能组件,我们将其连接到下一层——一个密集层:
outputs = keras.layers.Dense(10)(vector)
同样,我们首先创建层,然后将其连接到vector。目前,我们创建了一个只有一个密集层的网络。这足以开始。
现在结果是outputs——我们想要从网络中获取的最终结果。
因此,在我们的情况下,数据进入inputs并从outputs流出。我们只需要进行最后一步——将inputs和outputs都包裹在一个Model类中:
model = keras.Model(inputs, outputs)
我们需要在这里指定两个参数:
-
模型将获取的输入,在我们的例子中是
inputs -
模型的输出是什么,即
outputs
让我们退一步再次查看模型定义代码,按照从inputs到outputs的数据流(图 7.24)。

图 7.24 数据流:一个图像进入inputs,然后base_model将其转换为base,接着池化层将其转换为vector,然后密集层将其转换为output。最后,inputs和outputs进入一个 Keras 模型。
为了使其更容易可视化,我们可以将每一行代码视为一个块,它从上一个块获取数据,对其进行转换,并将其传递给下一个块(图 7.25)。

图 7.25 数据流:每一行 Keras 代码作为一个块
因此,我们创建了一个模型,它可以接收一个图像,使用基础模型获取其向量表示,并通过密集层进行最终预测。
现在让我们开始训练它。
7.4.4 训练模型
我们已经指定了模型:输入、模型的元素(基础模型、池化层)以及最终的输出层。
现在我们需要训练它。为此,我们需要一个优化器,它调整网络的权重以使其在执行任务时表现得更好。
我们不会详细介绍优化器的工作原理——这超出了本书的范围,并且完成项目不需要它。但如果你想了解更多关于它们的信息,请查看 CS231n 笔记(cs231n.github.io/neural-networks-3/)。你可以在 Keras 的官方文档中查看可用的优化器列表(keras.io/api/optimizers/)。
对于我们的项目,我们将使用 Adam 优化算法——这是一个良好的默认选择,并且在大多数情况下,使用它就足够了。
让我们创建它:
learning_rate = 0.01
optimizer = keras.optimizers.Adam(learning_rate)
Adam 需要一个参数:学习率,它指定了我们的网络学习速度有多快。
学习率可能会显著影响我们网络的质量。如果我们设置得太高,网络学习得太快,可能会意外地跳过一些重要的细节。在这种情况下,预测性能不是最优的。如果我们设置得太低,网络训练时间过长,因此训练过程非常低效。
我们稍后会调整这个参数。目前,我们将其设置为 0.01——一个良好的默认值以开始。
为了训练一个模型,优化器需要知道模型表现如何。为此,它使用一个损失函数,随着网络的改善,这个损失函数会变小。优化器的目标是使这个损失最小化。
keras.losses包提供了许多不同的损失函数。以下是最重要的几个:
-
BinaryCrossentropy:用于训练二元分类器 -
CategoricalCrossentropy:用于训练具有多个类别的分类模型 -
MeanSquaredError:用于训练回归模型
因为我们需要将衣物分类到 10 个不同的类别中,所以我们使用分类交叉熵损失:
loss = keras.losses.CategoricalCrossentropy(from_logits=True)
对于这个损失函数,我们指定了一个参数:from_logits=True。我们需要这样做,因为我们的网络最后一层输出的是原始分数(称为“logits”),而不是概率。官方文档建议这样做以提高数值稳定性(www.tensorflow.org/api_docs/python/tf/keras/losses/CategoricalCrossentropy)。
注意:或者,我们也可以这样定义网络的最后一层:
outputs = keras.layers.Dense(10, activation='softmax')(vector)
在这种情况下,我们明确告诉网络输出概率:softmax 类似于 sigmoid,但适用于多个类别。因此,输出不再是“logits”,所以我们可以省略这个参数:
loss = keras.losses.CategoricalCrossentropy()
现在让我们将优化器和损失函数结合起来。为此,我们使用模型的compile方法:
model.compile(
optimizer=optimizer,
loss=loss,
metrics=["accuracy"]
)
除了优化器和损失函数,我们还指定了在训练期间想要跟踪的指标。我们感兴趣的是准确率:正确预测的图像百分比。
我们模型已经准备好进行训练了!要执行训练,请使用fit方法:
model.fit(train_ds, epochs=10, validation_data=val_ds)
我们指定了三个参数:
-
train_ds:训练数据集 -
epochs:模型将遍历训练数据的次数 -
validation_data:用于评估的数据集
对整个训练数据集进行一次迭代称为一个epoch。我们进行的迭代越多,网络对训练数据集的学习就越好。
在某个时刻,模型可能会对数据集学习得非常好,以至于开始过拟合。为了知道何时发生这种情况,我们需要监控模型在验证数据集上的性能。这就是为什么我们指定了validation_data参数。
当我们开始训练时,Keras 会告诉我们进度:
Train for 96 steps, validate for 11 steps
Epoch 1/10
96/96 [==============================] - 22s 227ms/step - loss: 1.2372 - accuracy: 0.6734 - val_loss: 0.8453 - val_accuracy: 0.7713
Epoch 2/10
96/96 [==============================] - 16s 163ms/step - loss: 0.6023 - accuracy: 0.8194 - val_loss: 0.7928 - val_accuracy: 0.7859
...
Epoch 10/10
96/96 [==============================] - 16s 165ms/step - loss: 0.0274 - accuracy: 0.9961 - val_loss: 0.9342 - val_accuracy: 0.8065
从这里我们可以看到
-
训练速度:每个 epoch 所需的时间。
-
训练和验证数据集上的准确率。我们应该监控验证集上的准确率,以确保模型不会开始过拟合。例如,如果验证准确率在多个 epoch 中下降,这可能是一个过拟合的迹象。
-
训练和验证的损失。我们不太关心损失——它不太直观,数值也难以解释。
注意:您得到的结果可能会有所不同。模型的总体预测性能应该相似,但具体的数字可能不会相同。在使用神经网络时,即使固定随机种子,确保完美的可重复性也变得更加困难。
如您所见,模型在训练数据集上的准确率迅速达到 99%,但验证分数在所有 epoch 中都保持在 80%左右(图 7.26)。

图 7.26 每个 epoch 后评估的训练和验证数据集上的准确率
训练数据上的完美准确率并不一定意味着我们的模型过拟合,但这是一个我们应该调整学习率参数的好迹象。我们之前提到这是一个重要的参数,所以现在让我们调整它。
练习 7.1
迁移学习是将预训练模型(基础模型)用于将图像转换为它的向量表示,然后在上面训练另一个模型的过程。
a) 正确
b) 错误
7.4.5 调整学习率
我们开始时使用的学习率是 0.01。这是一个好的起点,但并不一定是最佳的学习率:我们发现我们的模型学习得太快,在几个 epoch 之后就能以 100%的准确率预测训练集。
让我们进行实验,尝试为这个参数尝试其他值。
首先,为了简化,我们应该将模型创建的逻辑放在一个单独的函数中。这个函数将学习率作为参数。
列 7.1 创建模型的函数
def make_model(learning_rate):
base_model = Xception(
weights='imagenet',
input_shape=(150, 150, 3),
include_top=False
)
base_model.trainable = False
inputs = keras.Input(shape=(150, 150, 3))
base = base_model(inputs, training=False)
vector = keras.layers.GlobalAveragePooling2D()(base)
outputs = keras.layers.Dense(10)(vector)
model = keras.Model(inputs, outputs)
optimizer = keras.optimizers.Adam(learning_rate)
loss = keras.losses.CategoricalCrossentropy(from_logits=True)
model.compile(
optimizer=optimizer,
loss=loss,
metrics=["accuracy"],
)
return model
我们已经尝试了 0.01,所以让我们尝试 0.001:
model = make_model(learning_rate=0.001)
model.fit(train_ds, epochs=10, validation_data=val_ds)
我们还可以尝试更小的值 0.0001:
model = make_model(learning_rate=0.0001)
model.fit(train_ds, epochs=10, validation_data=val_ds)
如我们所见(图 7.27),对于 0.001,训练准确率上升的速度不如 0.01 快,但使用 0.0001 上升得非常慢。在这种情况下,网络学习得太慢——它欠拟合。

图 7.27 使用学习率 0.001 和 0.0001 的模型性能
如果我们查看所有学习率的验证分数(图 7.28),我们会看到 0.001 的学习率是最好的。

图 7.28 三种不同学习率下我们的模型在验证集上的准确率
对于 0.001 的学习率,最佳准确率是 83%(表 7.1)。
表 7.1 不同 dropout 率值的验证准确率
| 学习率 | 0.01 | 0.001 | 0.0001 |
|---|---|---|---|
| 验证准确率 | 82.7% | 83.0% | 78.0% |
注意:您的数字可能略有不同。也有可能是在您的实验中,0.01 的学习率比 0.001 实现了略好的结果。
0.01 和 0.001 之间的差异并不显著。但如果我们查看训练数据的准确率,使用 0.01,它更快地过拟合训练数据。在某个点上,它甚至达到了 100%的准确率。当训练集和验证集的性能差异很大时,过拟合的风险也高。因此,我们应该优先选择 0.001 的学习率。
训练完成后,我们需要保存模型。现在我们将看看如何进行。
7.4.6 保存模型和检查点
模型训练完成后,我们可以使用save_weights方法保存它:
model.save_weights('xception_v1_model.h5', save_format='h5')
我们需要指定以下内容:
-
输出文件:
'xception_v1_model.h5' -
格式:h5,这是一种用于保存二进制数据的格式
你可能已经注意到,在训练过程中,我们的模型在验证集上的性能上下波动。这样,经过 10 次迭代后,我们不一定能得到最佳模型——也许最佳性能是在第 5 或第 6 次迭代时实现的。
我们可以在每次迭代后保存模型,但这会产生太多的数据。如果我们租用云服务器,它很快就会占用所有可用空间。
相反,我们只有在模型在验证集上的最佳得分比之前更好时才保存模型。例如,如果之前的最佳准确率是 0.8,但我们将其提高到了 0.91,我们就保存模型。否则,我们继续训练过程而不保存模型。
这个过程被称为模型检查点。Keras 有一个专门用于此目的的类:ModelCheckpoint。让我们使用它:
checkpoint = keras.callbacks.ModelCheckpoint(
"xception_v1_{epoch:02d}_{val_accuracy:.3f}.h5", ❶
save_best_only=True, ❷
monitor="val_accuracy" ❸
)
❶ 指定了保存模型的文件名模板
❷ 只有在比之前的迭代更好时才保存模型
❸ 使用验证准确率来选择最佳模型
第一个参数是文件名的模板。让我们再看一遍:
"xception_v1_{epoch:02d}_{val_accuracy:.3f}.h5"
它内部有两个参数:
-
{epoch:02d}被替换为 epoch 的编号。 -
{val_accuracy:.3f}被替换为验证准确率。
由于我们将save_best_only设置为True,ModelCheckpoint会跟踪最佳准确率,并在每次准确率提高时将结果保存到磁盘。
我们将ModelCheckpoint实现为一个回调——在每个 epoch 完成后执行任何操作的方式。在这种情况下,回调评估模型,并在准确率提高时保存结果。
我们可以通过将其传递给fit方法的callbacks参数来使用它:
model = make_model(learning_rate=0.001) ❶
model.fit(
train_ds,
epochs=10,
validation_data=val_ds,
callbacks=[checkpoint] ❷
)
❶ 创建了一个新的模型
❷ 指定了训练期间要使用的回调列表
经过几次迭代后,我们已经在磁盘上保存了一些模型(图 7.29)。

图 7.29 由于ModelCheckpoint回调只在模型改进时保存模型,所以我们只有 4 个包含我们模型的文件,而不是 10 个。
我们已经学会了如何存储最佳模型。现在让我们通过向网络添加更多层来改进我们的模型。
7.4.7 添加更多层
之前,我们用一个密集层训练了一个模型:
inputs = keras.Input(shape=(150, 150, 3))
base = base_model(inputs, training=False)
vector = keras.layers.GlobalAveragePooling2D()(base)
outputs = keras.layers.Dense(10)(vector)
model = keras.Model(inputs, outputs)
我们不必限制自己只使用一层,因此让我们在基础模型和预测的最后一层之间添加另一层(图 7.30)。

图 7.30 我们在向量表示和输出之间添加了另一个密集层。
例如,我们可以添加一个大小为 100 的密集层:
inputs = keras.Input(shape=(150, 150, 3))
base = base_model(inputs, training=False)
vector = keras.layers.GlobalAveragePooling2D()(base)
inner = keras.layers.Dense(100, activation='relu')(vector) ❶
outputs = keras.layers.Dense(10)(inner) ❷
model = keras.Model(inputs, outputs)
❶ 添加了一个大小为 100 的密集层
❷ 不是将输出连接到向量,而是连接到内部
注意:选择 100 作为内部密集层的大小没有特别的原因。我们应该将其视为一个参数:就像学习率一样,我们可以尝试不同的值,看看哪个能带来更好的验证性能。在本章中,我们不会尝试改变内部层的大小,但你可以自由尝试。
这样,我们在基础模型和输出之间添加了一个层(图 7.31)。

图 7.31 在 vector 和 outputs 之间添加了一个新的 inner 层
让我们再次看看带有新密集层的行:
inner = keras.layers.Dense(100, activation='relu')(vector)
这里,我们将 activation 参数设置为 relu。
记住,我们通过组合多个逻辑回归来得到神经网络。在逻辑回归中,sigmoid 用于将原始分数转换为概率。但对于内部层,我们不需要概率,可以用其他函数替换 sigmoid。这些函数被称为 激活函数。ReLU(修正线性单元)是其中之一,对于内部层来说,它比 sigmoid 是更好的选择。
sigmoid 函数存在梯度消失问题,这使得训练深层神经网络变得不可能。ReLU 解决了这个问题。要了解更多关于这个问题以及一般激活函数的信息,请参阅 CS231n 笔记(cs231n.github.io/neural-networks-1/)。
添加另一个层后,我们过拟合的风险显著增加。为了避免这种情况,我们需要在模型中添加正则化。接下来,我们将看到如何做到这一点。
7.4.8 正则化和 dropout
Dropout 是一种用于对抗神经网络过拟合的特殊技术。Dropout 的主要思想是在训练时冻结密集层的一部分。在每次迭代中,随机选择要冻结的部分。只有未冻结的部分被训练,冻结的部分则完全不接触。
如果忽略网络的一部分,整体模型不太可能过拟合。当网络处理一批图像时,层的冻结部分看不到这些数据——它是关闭的。这样,网络记住图像就更加困难(图 7.32)。

(A) 两个没有 dropout 的密集层

(B) 两个带有 dropout 的密集层
图 7.32 带有 dropout 时,连接到冻结节点的连接被丢弃。
对于每个批次,随机选择要冻结的部分,这样网络就会学会从不完全信息中提取模式,这使得它更加鲁棒,并且不太可能过拟合。
我们可以通过设置 dropout 率——每步冻结层中元素的分数来控制 dropout 的强度。
在 Keras 中,我们在第一个 Dense 层之后添加一个 Dropout 层,并设置 dropout 率:
inputs = keras.Input(shape=(150, 150, 3))
base = base_model(inputs, training=False)
vector = keras.layers.GlobalAveragePooling2D()(base)
inner = keras.layers.Dense(100, activation='relu')(vector)
drop = keras.layers.Dropout(0.2)(inner)
outputs = keras.layers.Dense(10)(drop)
model = keras.Model(inputs, outputs)
这样,我们在网络中添加了另一个块——dropout 块(图 7.33)。

图 7.33 Dropout 是 inner 层和 outputs 层之间另一个块。
让我们训练这个模型。为了简化,我们首先需要更新 make_model 函数,并在那里添加一个参数来控制 dropout 率。
列表 7.2 创建具有 dropout 的模型的功能
def make_model(learning_rate, droprate):
base_model = Xception(
weights='imagenet',
input_shape=(150, 150, 3),
include_top=False
)
base_model.trainable = False
inputs = keras.Input(shape=(150, 150, 3))
base = base_model(inputs, training=False)
vector = keras.layers.GlobalAveragePooling2D()(base)
inner = keras.layers.Dense(100, activation='relu')(vector)
drop = keras.layers.Dropout(droprate)(inner)
outputs = keras.layers.Dense(10)(drop)
model = keras.Model(inputs, outputs)
optimizer = keras.optimizers.Adam(learning_rate)
loss = keras.losses.CategoricalCrossentropy(from_logits=True)
model.compile(
optimizer=optimizer,
loss=loss,
metrics=["accuracy"],
)
return model
让我们尝试为 droprate 参数设置四个不同的值,看看我们的模型性能如何变化:
-
0.0: 没有任何内容被冻结,因此这相当于完全没有包括 dropout 层。
-
0.2: 只有 20%的层被冻结,
-
0.5: 层的一半被冻结。
-
0.8: 大多数层(80%)被冻结。
使用 dropout,训练模型需要更多的时间:在每一步中,只有我们网络的一部分在学习,因此我们需要走更多的步骤。这意味着我们应该在训练时增加 epoch 的数量。
因此,让我们开始训练:
model = make_model(learning_rate=0.001, droprate=0.0) ❶
model.fit(train_ds, epochs=30, validation_data=val_ds) ❷
❶ 修改 droprate 以实验不同的值
❷ 训练模型比之前更多的 epochs
当它完成后,通过复制代码到另一个单元格并更改值为 0.2、0.5 和 0.8 来重复此操作。
从验证数据集的结果来看,0.0、0.2 和 0.5 之间没有显著差异。然而,0.8 更差——我们使网络学习变得非常困难(图 7.34)。

图 7.34 对于 dropout 率为 0.0、0.2 和 0.5 的情况,验证集上的准确率相似。然而,对于 0.8,它更差。
我们能达到的最佳准确率是 0.5 dropout 率下的 84.5%(表 7.2)。
表 7.2 不同 dropout 率值下的验证准确率
| Dropout 率 | 0.0 | 0.2 | 0.5 | 0.8 |
|---|---|---|---|---|
| 验证准确率 | 84.2% | 84.2% | 84.5% | 82.4% |
注意:您可能得到不同的结果,并且可能存在不同的 dropout 率值可以达到最佳准确率。
在这种情况下,当验证数据集上的准确率没有明显差异时,查看训练集上的准确率也是有用的(图 7.35)。

图 7.35 当 dropout 率为 0.0 时,网络快速过拟合,而 0.8 的率使学习变得非常困难。
没有 dropout 的情况下,模型会快速记住整个训练数据集,经过 10 个 epoch 后,准确率达到 99.9%。当 dropout 率为 0.2 时,它需要更多的时间来过拟合训练数据集,而对于 0.5,即使经过 30 次迭代,也没有达到完美的准确率。将率设置为 0.8,我们使网络学习变得非常困难,因此即使在训练数据集上,准确率也较低。
我们可以看到,当 dropout 率为 0.5 时,网络不像其他网络那样快速过拟合,同时保持了与 0.0 和 0.2 相同的验证数据集准确率。因此,我们应该优先选择使用 0.5 dropout 率训练的模型。
通过添加另一个层和 dropout,我们将准确率从 83%提高到 84%。尽管这种增加对于这个特定案例来说并不显著,但 dropout 是抵抗过拟合的有力工具,我们在使模型更复杂时应该使用它。
除了 dropout,我们还可以使用其他方法来抵抗过拟合。例如,我们可以生成更多数据。在下一节中,我们将看到如何做到这一点。
练习 7.2
在 dropout 中,我们
a) 完全移除模型的一部分
b) 冻结模型的一部分,使其在训练过程中的一个迭代中不被更新
c) 冻结模型的一部分,使其在整个训练过程中不被使用
7.4.9 数据增强
获取更多数据总是好主意,通常这是我们提高模型质量所能做的最好的事情。不幸的是,并不总是能够获取更多数据。
对于图像,我们可以从现有图像中生成更多数据。例如:
-
垂直和水平翻转图像。
-
旋转图像。
-
稍微放大或缩小。
-
以其他方式改变图像。
从现有数据集中生成更多数据的过程称为数据增强(图 7.36)。

图 7.36 我们可以通过修改现有图像来生成更多训练数据。
从现有图像创建新图像的最简单方法是将它水平、垂直或两者都翻转(图 7.37)。

图 7.37 水平和垂直翻转图像
在我们的情况下,水平翻转可能没有太大意义,但垂直翻转应该是有用的。
注意:如果你对如何生成这些图像感兴趣,请查看 GitHub 仓库中本书的 07-augmentations.ipynb 笔记本。
旋转是另一种我们可以使用的图像处理策略:我们可以通过旋转现有图像一定角度来生成新的图像(图 7.38)。

图 7.38 旋转图像。如果旋转角度为负,图像将逆时针旋转。
剪切是另一种可能的变换。它通过“拉动”图像的一侧来扭曲图像。当剪切为正时,我们向下拉动右侧,当它为负时,我们向上拉动右侧(图 7.39)。

图 7.39 剪切变换。我们通过其右侧拉动图像上下移动。
初看,剪切和旋转的效果可能看起来相似,但实际上它们相当不同。剪切会改变图像的几何形状,但旋转不会:它只会旋转图像(图 7.40)。

图 7.40 剪切通过拉动图像改变其几何形状,因此正方形变成平行四边形。旋转不会改变形状,所以正方形仍然是正方形。
接下来,我们可以水平(图 7.41)或垂直(图 7.42)移动图像。

图 7.41 水平移动图像。正值将图像向左移动,而负值将图像向右移动。

图 7.42 垂直移动图像。正值将图像向上移动,而负值将图像向下移动。
最后,我们可以放大或缩小图像(图 7.43)。

图 7.43 放大或缩小。当缩放因子小于 1 时,我们放大;如果大于 1,我们缩小。
此外,我们可以结合多种数据增强策略。例如,我们可以取一个图像,水平翻转,缩小,然后旋转。
通过对同一图像应用不同的增强,我们可以生成更多的新图像(图 7.44)。

图 7.44 10 张由同一图像生成的新图像
Keras 提供了一种内置的数据增强方法。它基于 ImageDataGenerator,我们之前已经用它来读取图像。
生成器接受许多参数。之前,我们只使用了preprocessing_function——它是预处理图像所需的。其他参数也可用,其中许多负责增强数据集。
例如,我们可以创建一个新的生成器:
train_gen = ImageDataGenerator(
rotation_range=30,
width_shift_range=30.0,
height_shift_range=30.0,
shear_range=10.0,
zoom_range=0.2,
horizontal_flip=True,
vertical_flip=False,
preprocessing_function=preprocess_input
)
让我们更仔细地看看这些参数:
-
rotation_range=30: 将图像随机旋转-30 到 30 度之间的任意角度。 -
width_shift_range=30: 将图像水平移动,移动值在-30 到 30 像素之间。 -
height_shift_range=30: 将图像垂直移动,移动值在-30 到 30 像素之间。 -
shear_range=10: 通过-10 到 10(也是像素)之间的值应用剪切变换。 -
zoom_range=0.2: 使用 0.8 到 1.2(1 - 0.2 和 1 + 0.2)之间的缩放因子应用缩放变换。 -
horizontal_flip=True: 随机水平翻转图像。 -
vertical_flip=False: 不要垂直翻转图像。
对于我们的项目,让我们选择这些增强中的一小部分:
train_gen = ImageDataGenerator(
shear_range=10.0,
zoom_range=0.1,
horizontal_flip=True,
preprocessing_function=preprocess_input,
)
接下来,我们像之前一样使用生成器:
train_ds = train_gen.flow_from_directory(
"clothing-dataset-small/train",
target_size=(150, 150),
batch_size=32,
)
我们只需要将增强应用于训练数据。我们不使用它进行验证:我们希望使我们的评估保持一致,并能够比较在增强数据集上训练的模型和在未增强数据集上训练的模型。
因此,我们使用与之前完全相同的代码加载验证数据集:
validation_gen = ImageDataGenerator(
preprocessing_function=preprocess_input
)
val_ds = validation_gen.flow_from_directory(
"clothing-dataset-small/validation",
target_size=image_size,
batch_size=batch_size,
)
现在,我们已经准备好训练一个新的模型:
model = make_model(learning_rate=0.001, droprate=0.2)
model.fit(train_ds, epochs=50, validation_data=val_ds)
注意:为了简洁起见,这里省略了模型检查点的代码。如果您想保存最佳模型,请添加它。
为了训练这个模型,我们需要比之前更多的 epoch。数据增强也是一种正则化策略。我们不是反复在相同的图像上训练,而是在每个 epoch 中,网络看到的是同一图像的不同变体。这使得模型更难记住数据,并减少了过拟合的可能性。
训练此模型后,我们成功将准确率提高了 1%,从 84%提高到 85%。
这种改进并不真正显著。但我们已经进行了大量实验,并且我们可以相对快速地做到这一点,因为我们使用了 150 × 150 的小图像。现在我们可以将我们迄今为止所学的一切应用到更大的图像上。
练习 7.3
数据增强有助于防止过拟合,因为
a) 模型不会反复看到相同的图像。
b) 它为数据集增加了大量多样性——旋转和其他图像变换。
c) 它生成可能存在的图像示例,但模型在其他情况下不会看到。
d) 所有上述内容。
7.4.10 训练更大的模型
即使对于人来说,理解一个 150 × 150 的小图像中包含什么类型的物品可能都很有挑战性。对于计算机来说也是如此:很难看到重要的细节,因此模型可能会混淆裤子与短裤或 T 恤与衬衫。
通过将图像的大小从 150 × 150 增加到 299 × 299,网络将更容易看到更多细节,因此可以达到更高的准确率。
注意:在较大图像上训练模型的时间大约是小图像的四倍。如果您没有访问带有 GPU 的计算机,您不需要运行本节中的代码。从概念上讲,过程是相同的,唯一的区别是输入大小。
因此,让我们修改我们创建模型的函数。为此,我们需要调整make_model(列表 7.2)的代码,并在两个地方进行调整:
-
Xception 的
input_shape参数 -
输入的
C参数
在这两种情况下,我们需要将(150, 150, 3)替换为(299, 299, 3)。
接下来,我们需要调整训练和验证生成器的target_size参数。我们将(150, 150)替换为(299, 299),其他一切保持不变。
现在我们已经准备好训练一个模型了!
model = make_model(learning_rate=0.001, droprate=0.2)
model.fit(train_ds, epochs=20, validation_data=val_ds)
注意:为了保存模型,请添加检查点。
该模型在验证数据上达到了约 89%的准确率。这比之前的模型有了相当大的改进。
我们已经训练了一个模型,现在是时候使用它了。
7.5 使用模型
之前,我们训练了多个模型。最好的模型是我们在大图像上训练的模型——它有 89%的准确率。第二好的模型准确率为 85%。
现在我们使用这些模型进行预测。要使用模型,我们首先需要加载它。
7.5.1 加载模型
您可以使用自己训练的模型,或者下载我们为书籍训练的模型并使用它。
要下载它们,请访问书籍 GitHub 仓库的发布部分,并查找第七章:深度学习(图 7.45)的模型。或者,访问此 URL:github.com/alexeygrigorev/mlbookcamp-code/releases/tag/chapter7-model。

图 7.45 您可以从书籍的 GitHub 仓库下载我们为这一章训练的模型。
然后下载在 299 × 299 图像上训练的大模型(xception_v4_large)。要使用它,请使用models包中的load_model函数加载模型:
model = keras.models.load_model('xception_v4_large_08_0.894.h5')
我们已经使用了训练和验证数据集。我们已经完成了训练过程,现在是时候在测试数据上评估这个模型了。
7.5.2 评估模型
要加载测试数据,我们遵循相同的方法:我们使用ImageDataGenerator,但指向测试目录。让我们来做:
test_gen = ImageDataGenerator(
preprocessing_function=preprocess_input
)
test_ds = test_gen.flow_from_directory(
"clothing-dataset-small/test",
shuffle=False,
target_size=(299, 299), ❶
batch_size=32,
)
❶ 如果您使用的是小模型,请使用(150, 150)。
在 Keras 中评估一个模型就像调用evaluate方法一样简单:
model.evaluate(test_ds)
它将模型应用于测试文件夹中的所有数据,并显示了损失和准确率的评估指标:
12/12 [==============================] - 70s 6s/step - loss: 0.2493 - accuracy: 0.9032
我们的模型在测试数据集上显示了 90%的准确率,这与验证数据集上的性能相当(89%)。
如果我们对小数据集重复同样的过程,我们会看到性能更差:
12/12 [==============================] - 15s 1s/step - loss: 0.6931 - accuracy: 0.8199
准确率是 82%,而在验证数据集上是 85%。模型在测试数据集上的表现更差。
这可能是因为随机波动:验证集和测试集的大小都不大,只有 300 个示例。因此,模型可能在验证集上运气好,在测试集上运气差。
然而,这可能是过拟合的迹象。通过反复在验证数据集上评估模型,我们选择了表现非常幸运的模型。也许这种幸运不具有普遍性,这就是为什么模型在之前未见过的数据上的表现较差。
现在让我们看看如何将模型应用于单个图像以获取预测。
7.5.3 获取预测
如果我们想将模型应用于单个图像,我们需要做与ImageDataGenerator内部执行相同的事情:
-
加载一张图片。
-
预处理它。
我们已经知道如何加载图片。我们可以使用load_img来做这件事:
path = 'clothing-dataset-small/test/pants/c8d21106-bbdb-4e8d-83e4-bf3d14e54c16.jpg'
img = load_img(path, target_size=(299, 299))
这是一张裤子的图片(图 7.46)。

图 7.46 训练数据集中的一张裤子图片
接下来,我们预处理图片:
x = np.array(img)
X = np.array([x])
X = preprocess_input(X)
最后,我们得到预测结果:
pred = model.predict(X)
我们可以通过检查预测的第一行来查看图像的预测:pred[0](图 7.47)。

图 7.47 我们模型的预测。它是一个包含 10 个元素的数组,每个类别一个。
结果是一个包含 10 个元素的数组,每个元素包含一个分数。分数越高,图像属于相应类别的可能性就越大。
要获取得分最高的元素,我们可以使用argmax方法。它返回得分最高的元素的索引(图 7.48)。

图 7.48 argmax函数返回得分最高的元素。
要知道哪个标签对应于类别 4,我们需要获取映射。它可以从数据生成器中提取。但让我们手动将其放入字典中:
labels = {
0: 'dress',
1: 'hat',
2: 'longsleeve',
3: 'outwear',
4: 'pants',
5: 'shirt',
6: 'shoes',
7: 'shorts',
8: 'skirt',
9: 't-shirt'
}
要获取标签,只需在字典中查找:
labels[pred[0].argmax()]
正如我们所见,标签是“裤子”,这是正确的。此外,请注意标签“短裤”有一个很高的正分:裤子和短裤在视觉上非常相似。但“裤子”显然是赢家。
我们将在下一章中使用这段代码,我们将在这个章节中将我们的模型投入生产。
7.6 下一步
我们已经学习了训练用于预测衣服类型的分类模型所需的基本知识。我们涵盖了大量的材料,但还有更多内容需要学习,这些内容超出了本章的范围。你可以通过做练习来更深入地探索这个主题。
7.6.1 练习
-
对于深度学习,我们拥有的数据越多,效果越好。但这个项目使用的数据集并不大:我们仅在 3,068 张图像上训练了我们的模型。为了使其更好,我们可以添加更多训练数据。你可以在其他数据源中找到更多衣服的图片;例如,在
www.kaggle.com/dqmonn/zalando-store-crawl、www.kaggle.com/paramaggarwal/fashion-product-images-dataset或www.kaggle.com/c/imaterialist-fashion-2019-FGVC6。尝试向训练数据中添加更多图片,并看看是否提高了验证数据集上的准确率。 -
扩增有助于我们训练更好的模型。在本章中,我们只使用了最基本的扩充策略。你可以进一步探索这个主题并尝试其他类型的图像修改。例如,添加旋转和移动,看看是否有助于模型获得更好的性能。
-
除了内置的数据集扩充方式,我们还有专门的库来做这件事。其中之一是 Albumentations(
github.com/albumentations-team/albumentations),它包含更多的图像处理算法。你还可以尝试它并看看哪些扩充对这个问题有效。 -
有许多可用的预训练模型。我们使用了 Xception,但还有很多其他模型。你可以尝试它们并看看它们是否提供了更好的性能。使用 Keras,使用不同模型非常简单:只需从不同的包中导入。例如,你可以尝试 ResNet50 并将其与 Xception 的结果进行比较。查看文档以获取更多信息(
keras.io/api/applications/)。
7.6.2 其他项目
你可以做的许多图像分类项目:
-
从 Avito 数据集(在线分类)预测图像类别(
www.kaggle.com/c/avito-duplicate-ads-detection)。请注意,这个数据集中存在许多重复项,因此在分割数据用于验证时要小心。使用组织者准备的训练/测试分割并进行一些额外的清理以确保没有重复图像可能是个好主意。
摘要
-
TensorFlow 是一个用于构建和使用神经网络的框架。Keras 是 TensorFlow 之上的一个库,它使模型训练变得更加简单。
-
对于图像处理,我们需要一种特殊的神经网络:卷积神经网络。它由一系列卷积层和一系列密集层组成。
-
神经网络中的卷积层将图像转换为它的向量表示。这种表示包含高级特征。密集层使用这些特征来进行预测。
-
我们不需要从头开始训练卷积神经网络。我们可以使用 ImageNet 上的预训练模型进行通用分类。
-
迁移学习是将预训练模型调整到我们问题的过程。我们保留原始的卷积层,但创建新的密集层。这显著减少了训练模型所需的时间。
-
我们使用 dropout 来防止过拟合。在每次迭代中,它会冻结网络的一部分,这样只有其他部分才能用于训练。这使网络能够更好地泛化。
-
我们可以通过旋转、垂直和水平翻转现有图像以及进行其他转换来从现有图像中创建更多训练数据。这个过程称为数据增强,它增加了数据的变异性,并减少了过拟合的风险。
在本章中,我们训练了一个用于分类服装图像的卷积神经网络。我们可以将其保存、加载,并在 Jupyter Notebook 中使用它。但这还不足以在生产环境中使用。
在下一章中,我们将展示如何在生产环境中使用它,并讨论两种生产化深度学习模型的方式:AWS Lambda 中的 TensorFlow Lite 和 Kubernetes 中的 TensorFlow Serving。
练习答案
-
练习 7.1 A) 正确
-
练习 7.2 B) 冻结模型的一部分,使其在训练的一个迭代中不更新。
-
练习 7.3 D) 以上所有
8 无服务器深度学习
本章涵盖
-
使用 TensorFlow Lite 提供模型服务——一个轻量级的环境,用于应用 TensorFlow 模型
-
使用 AWS Lambda 部署深度学习模型
-
通过 API Gateway 将 lambda 函数作为 Web 服务公开
在上一章中,我们训练了一个用于分类服装图像的深度学习模型。现在我们需要部署它,使模型可供其他服务使用。
我们有做这件事的许多可能方法。我们已经在第五章中介绍了模型部署的基础知识,其中我们讨论了使用 Flask、Docker 和 AWS Elastic Beanstalk 来部署逻辑回归模型。
在本章中,我们将讨论部署模型的无服务器方法——我们将使用 AWS Lambda。
8.1 无服务器:AWS Lambda
AWS Lambda 是亚马逊的一项服务。它的主要承诺是您可以“无需考虑服务器即可运行代码。”
它实现了承诺:在 AWS Lambda 中,我们只需上传一些代码。服务会负责运行它并根据负载进行扩展和缩减。
此外,您只需为函数实际使用的时间付费。当没有人使用模型并调用我们的服务时,您不需要支付任何费用。
在本章中,我们使用 AWS Lambda 来部署我们之前训练的模型。为此,我们还将使用 TensorFlow Lite——TensorFlow 的轻量级版本,它只包含最基本的功能。

图 8.1 服务概述:它获取图像的 URL,应用模型,并返回预测结果。
我们想要构建一个 Web 服务,它
-
获取请求中的 URL
-
从此 URL 加载图像
-
使用 TensorFlow Lite 将模型应用于图像并获取预测结果
-
返回结果(图 8.1)
要创建此服务,我们需要
-
将模型从 Keras 转换为 TensorFlow Lite 格式
-
预处理图像——调整大小并应用预处理函数
-
将代码打包成 Docker 镜像,并上传到 ECR(AWS 的 Docker 仓库)
-
在 AWS 上创建和测试 lambda 函数
-
使用 AWS API Gateway 使 lambda 函数对每个人可用
我们假设您有一个 AWS 账户并且已经配置了 AWS CLI 工具。有关详细信息,请参阅附录 A。
注意:在撰写本文时,AWS Lambda 由 AWS 免费层覆盖。这意味着您可以免费进行本章的所有实验。要检查条件,请参阅 AWS 文档(aws.amazon.com/free/)。
我们在这里使用 AWS,但这种方法也适用于其他无服务器平台。
本章的代码可在本书的 GitHub 仓库中找到(github.com/alexeygrigorev/mlbookcamp-code/)的 chapter-08-serverless 文件夹中。
让我们先来讨论 TensorFlow Lite。
8.1.1 TensorFlow Lite
TensorFlow 是一个功能丰富的框架。然而,其中大部分功能对于模型部署来说并不需要,而且它们占用了大量的空间:当压缩时,TensorFlow 占用超过 1.5 GB 的空间。
另一方面,TensorFlow Lite(通常简称为“TF Lite”)仅占用 50 MB 的空间。它针对移动设备进行了优化,只包含基本部分。使用 TF Lite,你只能使用模型进行预测,不能做其他任何事情,包括训练新模型。
尽管它最初是为移动设备创建的,但它适用于更多情况。只要我们有 TensorFlow 模型但无法承担携带整个 TensorFlow 包的费用,我们就可以使用它。
注意:TF Lite 库正在积极开发中,变化相当快。自本书出版以来,安装此库的方式可能已经改变。请参阅官方文档以获取最新说明(www.tensorflow.org/lite/guide/python)。
现在我们来安装这个库。我们可以使用 pip 来做:
pip install --extra-index-url https://google-coral.github.io/py-repo/ tflite_runtime
当运行 pip install 时,我们添加 extra-index-url 参数。我们安装的库不在 Python 包的中央仓库中可用,但它存在于另一个仓库中。我们需要指向这个仓库。
注意:对于非 Debian 基础的 Linux 发行版,如 CentOS、Fedora 或 Amazon Linux,以这种方式安装的库可能不起作用:当你尝试导入库时可能会出错。如果是这种情况,你需要自己编译这个库。有关更多详细信息,请参阅此处说明:github.com/alexeygrigorev/serverless-deep-learning。对于 MacOS 和 Windows,它应该按预期工作。
TF Lite 使用一种特殊的优化格式来存储模型。为了使用 TF Lite 的模型,我们需要将我们的模型转换为这种格式。我们将在下一步进行转换。
8.1.2 将模型转换为 TF Lite 格式
我们使用 h5 格式保存了前一章中的模型。这种格式适合存储 Keras 模型,但它不适用于 TF Lite。因此,我们需要将我们的模型转换为 TF-Lite 格式。
如果你没有前一章中的模型,请继续下载它:
wget https://github.com/alexeygrigorev/mlbookcamp-code/releases/download/
chapter7-model/xception_v4_large_08_0.894.h5
现在,让我们创建一个简单的脚本来转换这个模型——convert.py。
首先,从导入开始:
import tensorflow as tf
from tensorflow import keras
接下来,加载 Keras 模型:
model = keras.models.load_model('xception_v4_large_08_0.894.h5')
最后,将其转换为 TF Lite:
converter = tf.lite.TFLiteConverter.from_keras_model(model)
tflite_model = converter.convert()
with tf.io.gfile.GFile('clothing-model-v4.tflite', 'wb') as f:
f.write(tflite_model)
让我们运行它:
python convert.py
运行后,我们应该在我们的目录中有一个名为 clothing-model-v4.tflite 的文件。
我们现在可以为此模型进行图像分类,将模型应用于服装图像,以了解给定的图像是否是 T 恤、裤子、裙子或其他东西。然而,记住在我们可以使用模型对图像进行分类之前,图像需要进行预处理。我们将在下一节中看到如何进行预处理。
8.1.3 准备图像
在之前,当在 Keras 中测试模型时,我们使用preprocess_input函数对每个图像进行预处理。这是我们在上一章中导入它的方式:
from tensorflow.keras.applications.xception import preprocess_input
然后我们在将图像放入模型之前应用了这个函数。
然而,当部署我们的模型时,我们不能使用相同的函数。这个函数是 TensorFlow 包的一部分,在 TF Lite 中没有等效函数。我们不想仅仅为了这个简单的预处理函数就依赖 TensorFlow。
相反,我们可以使用一个只包含我们需要的代码的特殊库:keras_image_helper。这个库是为了简化本书中的解释而编写的。如果您想了解更多关于图像如何进行预处理的详细信息,请查看源代码。它可在github.com/alexeygrigorev/keras-image-helper找到。这个库可以加载图像,调整大小,并应用 Keras 模型所需的其它预处理转换。
让我们使用pip安装它:
pip install keras_image_helper
接下来,打开 Jupyter,创建一个名为 chapter-08-model-test 的笔记本。
我们首先从库中导入create_preprocessor函数:
from keras_image_helper import create_preprocessor
函数create_preprocessor接受两个参数:
-
name:模型的名称。您可以在keras.io/api/applications/中查看可用的模型列表。 -
target_size:神经网络期望获取的图像大小。
我们使用了 Xception 模型,它期望的图像大小为 299 × 299。让我们为我们的模型创建一个预处理程序:
preprocessor = create_preprocessor('xception', target_size=(299, 299))
现在,让我们获取一条裤子的图片(图 8.2),并对其进行准备:
image_url = 'http://bit.ly/mlbookcamp-pants'
X = preprocessor.from_url(image_url)

图 8.2 我们用于测试的裤子图片
结果是一个形状为(1, 299, 299, 3)的 NumPy 数组:
-
这是一个仅包含一张图像的批次。
-
299 × 299 是图像的大小。
-
有三个通道:红色、绿色和蓝色。
我们已经准备好了图像,并且准备好使用模型对其进行分类。让我们看看如何使用 TF Lite 来完成这项工作。
8.1.4 使用 TensorFlow Lite 模型
我们已经有了上一步的数组X,现在我们可以使用 TF Lite 对其进行分类。
首先,导入 TF Lite:
import tflite_runtime.interpreter as tflite
加载我们之前转换的模型:
interpreter = tflite.Interpreter(model_path='clothing-model-v4.tflite') ❶
interpreter.allocate_tensors() ❷
❶ 创建 TF Lite 解释器
❷ 使用模型初始化解释器
为了能够使用模型,我们需要获取其输入(X将放入其中)和输出(我们从其中获取预测结果):
input_details = interpreter.get_input_details() ❶
input_index = input_details[0]['index'] ❶
output_details = interpreter.get_output_details() ❷
output_index = output_details[0]['index'] ❷
❶ 获取输入:网络中接受数组 X 的部分
❷ 获取输出:网络中具有最终预测的部分
要应用模型,将之前准备的X放入输入,调用解释器,并从输出获取结果:
interpreter.set_tensor(input_index, X) ❶
interpreter.invoke() ❷
preds = interpreter.get_tensor(output_index) ❸
❶ 将 X 放入输入
❷ 运行模型以获取预测
❸ 从输出获取预测
preds数组包含预测结果:
array([[-1.8682897, -4.7612453, -2.316984 , -1.0625705, 9.887156 ,
-2.8124316, -3.6662838, 3.2003622, -2.6023388, -4.8350453]],
dtype=float32)
现在,我们可以像之前一样使用它——将标签分配给数组的每个元素:
labels = [
'dress',
'hat',
'longsleeve',
'outwear',
'pants',
'shirt',
'shoes',
'shorts',
'skirt',
't-shirt'
]
results = dict(zip(labels, preds[0]))
完成了!我们在results变量中有了预测结果:
{'dress': -1.8682897,
'hat': -4.7612453,
'longsleeve': -2.316984,
'outwear': -1.0625705,
'pants': 9.887156,
'shirt': -2.8124316,
'shoes': -3.6662838,
'shorts': 3.2003622,
'skirt': -2.6023388,
't-shirt': -4.8350453}
我们看到pants标签的分数最高,所以这肯定是一张裤子的图片。
现在让我们使用这段代码来构建我们未来的 AWS Lambda 函数!
8.1.5 Lambda 函数的代码
在上一节中,我们编写了 lambda 函数所需的全部代码。现在让我们将其整合到一个名为 lambda_function.py 的单个脚本中。
如同往常,从导入开始:
import tflite_runtime.interpreter as tflite
from keras_image_helper import create_preprocessor
然后,创建预处理程序:
preprocessor = create_preprocessor('xception', target_size=(299, 299))
接下来,加载模型,获取输出和输入:
interpreter = tflite.Interpreter(model_path='clothing-model-v4.tflite')
interpreter.allocate_tensors()
input_details = interpreter.get_input_details()
input_index = input_details[0]['index']
output_details = interpreter.get_output_details()
output_index = output_details[0]['index']
为了让它更整洁,我们可以将所有生成预测的代码放在一个函数中:
def predict(X):
interpreter.set_tensor(input_index, X)
interpreter.invoke()
preds = interpreter.get_tensor(output_index)
return preds[0]
接下来,让我们创建另一个用于准备结果的函数:
labels = [
'dress',
'hat',
'longsleeve',
'outwear',
'pants',
'shirt',
'shoes',
'shorts',
'skirt',
't-shirt'
]
def decode_predictions(pred):
result = {c: float(p) for c, p in zip(labels, pred)}
return result
最后,将所有内容整合到一个名为lambda_handler的函数中——这是 AWS Lambda 环境调用的函数。它将使用我们之前定义的所有内容:
def lambda_handler(event, context):
url = event['url']
X = preprocessor.from_url(url)
preds = predict(X)
results = decode_predictions(preds)
return results
在这种情况下,event参数包含我们请求中传递给 lambda 函数的所有信息(图 8.3)。context参数通常不使用。

图 8.3 Lambda 函数的输入和输出:输入传递给event参数,预测结果作为输出返回。
我们现在可以开始测试了!为了本地测试,我们需要将此代码放入 AWS Lambda 的 Python Docker 容器中。
8.1.6 准备 Docker 镜像
首先,创建一个名为 Dockerfile 的文件:
FROM public.ecr.aws/lambda/python:3.7 ❶
RUN pip3 install keras_image_helper --no-cache-dir ❷
RUN pip3 install https://raw.githubusercontent.com/alexeygrigorev/serverless-deep-learning
➥/master/tflite/tflite_runtime-2.2.0-cp37-cp37m-linux_x86_64.whl
➥--no-cache-dir ❸
COPY clothing-model-v4.tflite clothing-model-v4.tflite ❹
COPY lambda_function.py lambda_function.py ❺
CMD [ "lambda_function.lambda_handler" ] ❻
❶ 使用官方 Docker 镜像
❷ 安装 keras_image_helper
❸ 安装 TF Lite
❹ 复制模型
❺ 复制 lambda 函数
❻ 定义 lambda 函数的位置
让我们查看文件中的每一行。首先 ❶,我们使用 AWS Lambda 的官方 Python 3.7 Docker 镜像。你可以在这里看到其他可用的镜像:gallery.ecr.aws/。然后 ❷,我们安装 keras_image_helper 库。
接下来 ❸,我们安装了一个专门为与 Amazon Linux 一起工作而编译的 TF Lite 特殊版本。我们本章前面使用的安装说明在 Amazon Linux 上不适用,只适用于 Ubuntu(以及其他基于 Debian 的发行版)。这就是为什么我们需要使用特殊版本。你可以在这里了解更多信息:github.com/alexeygrigorev/serverless-deep-learning.
然后 ❹,我们将模型复制到镜像中。这样做后,模型成为镜像的一部分。这样,部署模型就更加简单。我们也可以使用另一种方法——模型可以放在 S3 上,在脚本启动时加载。这更复杂,但也更灵活。对于本书,我们选择了更简单的方法。
然后 ❺,我们复制之前准备的 lambda 函数的代码。
最后 ❻,我们告诉lambda环境需要查找名为 lambda_function 的文件,并在该文件中查找名为lambda_handler的函数。这是我们之前章节中准备的函数。
让我们构建这个镜像:
docker build -t tf-lite-lambda .
接下来,我们需要检查 lambda 函数是否正常工作。让我们运行这个镜像:
docker run --rm -p 8080:8080 tf-lite-lambda
它正在运行!我们现在可以测试它了。
我们可以继续使用我们之前创建的 Jupyter Notebook,或者我们可以创建一个名为 test.py 的单独 Python 文件。它应该包含以下内容——你会注意到它与我们在第五章中为测试我们的网络服务所编写的代码非常相似:
import requests
data = { ❶
"url": "http://bit.ly/mlbookcamp-pants"
}
url = "http://localhost:8080/2015-03-31/functions/function/invocations" ❷
results = requests.post(url, json=data).json() ❸
print(results) ❸
❶准备请求
❷指定 URL
❸向服务发送 POST 请求
首先,我们在❶中定义data变量——这是我们请求。然后我们指定服务的 URL 在❷——这是函数当前部署的位置。最后,在❸中,我们使用 POST 方法提交请求,并在results变量中获取预测结果。
当我们运行它时,我们得到以下响应:
{
"dress": -1.86829,
"hat": -4.76124,
"longsleeve": -2.31698,
"outwear": -1.06257,
"pants": 9.88715,
"shirt": -2.81243,
"shoes": -3.66628,
"shorts": 3.20036,
"skirt": -2.60233,
"t-shirt": -4.83504
}
模型工作正常!
我们几乎准备好将其部署到 AWS 了。为此,我们首先需要将此镜像发布到 ECR——AWS 的 Docker 容器注册表。
8.1.7 将镜像推送到 AWS ECR
要将此 Docker 镜像发布到 AWS,我们首先需要使用 AWS CLI 工具创建一个注册表:
aws ecr create-repository --repository-name lambda-images
它将返回一个看起来像这样的 URL:
<ACCOUNT_ID>.dkr.ecr.<REGION>.amazonaws.com/lambda-images
你需要这个 URL。
或者,也可以使用 AWS 控制台创建注册表。
一旦创建注册表,我们需要将镜像推送到那里。因为这个注册表属于我们的账户,我们首先需要验证我们的 Docker 客户端。在 Linux 和 MacOS 上,你可以这样做:
$(aws ecr get-login --no-include-email)
在 Windows 上,运行aws ecr get-login --no-include-email,复制输出,将其输入到终端,并手动执行。
现在让我们使用注册表 URL 将镜像推送到 ECR:
REGION=eu-west-1 ❶
ACCOUNT=XXXXXXXXXXXX ❶
REMOTE_NAME=${ACCOUNT}.dkr.ecr.${REGION}.amazonaws.com/lambda-images:tf-lite-lambda
docker tag tf-lite-lambda ${REMOTE_NAME}
docker push ${REMOTE_NAME}
❶指定区域和您的 AWS 账户 ID。
现在已经推送,我们可以用它来在 AWS 中创建一个 lambda 函数。
8.1.8 创建 lambda 函数
这个步骤使用 AWS 控制台更容易完成,所以打开它,转到服务,并选择 Lambda。
接下来,点击创建函数。选择容器镜像(图 8.4)。

图 8.4 创建lambda函数时,选择容器镜像。
之后,填写详细信息(图 8.5)。

图 8.5 输入函数名称和容器镜像 URI。
容器镜像 URI 应该是我们之前创建并推送到 ECR 的镜像:
<ACCOUNT>.dkr.ecr.<REGION>.amazonaws.com/lambda-images:tf-lite-lambda
你可以使用浏览图片按钮找到它(图 8.5)。其余部分保持不变,然后点击创建函数。函数已创建!
现在我们需要给我们的函数更多的内存,并让它运行更长的时间而不超时。为此,选择配置选项卡,选择常规配置,然后点击编辑(图 8.6)。

图 8.6 Lambda 函数的默认设置:默认的内存量(128 MB)不足,因此我们需要增加它。点击编辑来进行修改。
默认设置对深度学习模型来说不好。我们需要配置这个函数,给它更多的 RAM,并允许它有更多的时间。
为了这样做,点击编辑按钮,给它 1024 MB 的 RAM,并将超时设置为 30 秒(图 8.7)。

图 8.7 将内存量增加到 1024 MB,并将超时设置为 30 秒。
保存它。
已准备好!要测试它,请转到“测试”标签页(图 8.8)。

图 8.8 测试按钮位于屏幕顶部。点击它以测试函数。
它会建议创建一个测试事件。给它起个名字(例如,test),并在请求体中放入以下内容:
{
"url": "http://bit.ly/mlbookcamp-pants"
}
保存并再次点击“测试”按钮。大约 15 秒后,你应该会看到“执行结果:成功”(图 8.9)。

图 8.9 我们模型的预测。对于“裤子”的预测得分最高。
当我们第一次运行测试时,它需要从 ECR 拉取镜像,将所有库加载到内存中,并做一些其他事情以“预热”。但一旦完成,后续的调用所需时间会更少——这个模型大约需要两秒钟。
我们已成功将模型部署到 AWS Lambda,并且它正在运行!
此外,请记住,你只有在函数被调用时才需要付费,所以如果你没有使用这个函数,你不需要担心删除它。而且你也不需要担心管理 EC2 实例——AWS Lambda 会为我们处理一切。
已经可以使用这个模型做很多事情:AWS Lambda 与 AWS 的许多其他服务很好地集成。但如果我们想将其作为 Web 服务并通过 HTTP 发送请求,我们需要通过 API 网关将其公开。
我们将在下一部分展示如何进行这项操作。
8.1.9 创建 API 网关
在 AWS 控制台中,找到 API 网关服务。创建一个新的 API:选择 REST API,并点击“构建”。
然后选择“新建 API”,将其命名为“clothes-classification”(图 8.10)。点击“创建 API”。

图 8.10 在 AWS 中创建新的 REST API 网关
接下来,点击“操作”按钮并选择“资源”。然后,创建一个名为 predict 的资源(图 8.11)。

图 8.11 创建预测资源
注意:predict 这个名字不符合 REST 命名规范:通常资源应该是名词。然而,将预测端点命名为 predict 是常见的;这就是为什么我们不遵循 REST 规范。
创建资源后,为它创建一个 POST 方法(图 8.12):
-
点击“预测”。
-
点击“操作”。
-
选择“创建方法”。
-
从列表中选择 POST。
-
点击勾选按钮。

图 8.12 为预测资源创建一个 POST 方法。
我们几乎准备好了!
现在将 Lambda 函数作为集成类型,并输入你的 lambda 函数名称(图 8.13)。

图 8.13 为预测资源配置 POST 操作。确保不要勾选“代理集成”。
注意:确保不要使用代理集成——这个复选框应该保持未勾选状态。如果你使用这个选项,API 网关会在请求中添加一些额外的信息,我们就需要调整 lambda 函数。
完成这些操作后,我们应该能看到集成(图 8.14)。

图 8.14 部署 API
让我们测试一下。点击 TEST,并在请求体中放入之前相同的请求:
{
"url": "http://bit.ly/mlbookcamp-pants"
}
响应相同:预测的类别是 pants(图 8.15)。

图 8.15 lambda 函数的响应。pants 类别的分数最高。
要在外部使用它,我们需要部署 API。从操作列表中选择部署 API(图 8.16)。

图 8.16 函数 clothes-classification 现在已连接到 API Gateway 中的 predict 资源的 POST 方法。TEST 按钮有助于验证与 lambda 的连接。
接下来,创建一个新的阶段测试(图 8.17)。

图 8.17 配置 API 的阶段
点击部署,我们部署了 API。现在找到调用 URL 字段。它应该看起来像这样:
0a1v3fyo2m.execute-api.eu-west-1.amazonaws.com/test
现在我们只需要在 URL 末尾添加“/predict”即可调用 lambda 函数。
让我们使用之前创建的 test.py 脚本并替换那里的 URL:
import requests
data = {
"url": "http://bit.ly/mlbookcamp-pants"
}
url = "https://0a1v3fyo2m.execute-api.eu-west-1.amazonaws.com/test/predict"
results = requests.post(url, json=data).json()
print(results)
运行它:
python test.py
响应与之前相同:
{
"dress": -1.86829,
"hat": -4.76124,
"longsleeve": -2.31698,
"outwear": -1.06257,
"pants": 9.88715,
"shirt": -2.81243,
"shoes": -3.66628,
"shorts": 3.20036,
"skirt": -2.60233,
"t-shirt": -4.83504
}
现在我们的模型通过一个我们可以从任何地方使用的网络服务公开。
8.2 下一步
8.2.1 练习
尝试以下操作以进一步探索无服务器模型部署的主题:
-
AWS Lambda 不是唯一的无服务器环境。你还可以在 Google Cloud 中实验云函数,以及在 Azure 上的 Azure 函数。
-
SAM(Serverless Application Model)是 AWS 的一项工具,用于简化创建 AWS Lambda 函数的过程(
aws.amazon.com/serverless/sam/)。你可以使用它来重新实现本章的项目。 -
无服务器(
www.serverless.com/)是一个类似于 SAM 的框架。它不仅限于 AWS,也适用于其他云服务提供商。你可以尝试它,并从本章部署项目。
8.2.2 其他项目
你可以做很多其他项目:
- AWS Lambda 是托管机器学习模型的便捷平台。在本章中,我们部署了一个深度学习模型。你也可以进一步实验,并将我们在前几章训练的模型以及作为练习一部分开发的模型部署。
摘要
-
TensorFlow Lite 是“完整”TensorFlow 的轻量级替代品。它只包含使用深度学习模型所需的最重要部分。使用它可以使使用 AWS Lambda 部署模型的过程更快、更简单。
-
Lambda 函数可以在本地使用 Docker 运行。这样,我们可以在不部署到 AWS 的情况下测试我们的代码。
-
要部署 lambda 函数,我们需要将其代码放入 Docker 中,将 Docker 镜像发布到 ECR,然后在创建 lambda 函数时使用镜像的 URI。
-
为了暴露 Lambda 函数,我们使用了 API Gateway。这样,我们将 Lambda 函数作为一项网络服务提供,任何人都可以使用它。
在本章中,我们使用了 AWS Lambda——一种用于部署深度学习模型的免服务器方法。我们不想担心服务器,而是让环境来处理这个问题。
在下一章中,我们实际上开始考虑服务器,并使用 Kubernetes 集群来部署模型。
9 使用 Kubernetes 和 Kubeflow 提供模型服务
本章涵盖
-
理解在云中部署和提供模型的不同方法
-
使用 TensorFlowServing 提供 Keras 和 TensorFlow 模型服务
-
将 TensorFlow Serving 部署到 Kubernetes
-
使用 Kubeflow 和 KFServing 简化部署过程
在上一章中,我们讨论了使用 AWS Lambda 和 TensorFlow Lite 进行模型部署。
在本章中,我们讨论了模型部署的“服务器端”方法:我们在 Kubernetes 上使用 TensorFlow Serving 提供服装分类模型。我们还讨论了 Kubeflow,它是 Kubernetes 的一个扩展,使得模型部署更加容易。
我们将在本章中涵盖大量内容,但由于 Kubernetes 的复杂性,深入探讨细节是不可能的。因此,我们经常引用外部资源,以更深入地介绍一些主题。但请放心;你将学习到足够多的知识,以便能够舒适地使用它部署自己的模型。
9.1 Kubernetes 和 Kubeflow
Kubernetes 是一个容器编排平台。听起来很复杂,但实际上它只是一个我们可以部署 Docker 容器的场所。它负责将这些容器作为 Web 服务暴露出来,并根据我们接收到的请求数量上下调整这些服务。
Kubernetes 不是最容易学习的工具,但它非常强大。你可能会在某些时候需要使用它。这就是我们决定在本书中介绍它的原因。
Kubeflow 是建立在 Kubernetes 之上的另一个流行工具。它使得使用 Kubernetes 部署机器学习模型变得更加容易。在本章中,我们将介绍 Kubernetes 和 Kubeflow。
在第一部分,我们讨论 TensorFlow Serving 和纯 Kubernetes。我们讨论了如何使用这些技术进行模型部署。第一部分计划如下
-
首先,我们将 Keras 模型转换为 TensorFlow Serving 所使用的特殊格式。
-
然后,我们使用 TensorFlow Serving 在本地运行模型。
-
之后,我们创建一个服务来预处理图像并与 TensorFlow Serving 通信。
-
最后,我们使用 Kubernetes 同时部署模型和预处理服务。
注意:本章不试图深入探讨 Kubernetes。我们仅展示如何使用 Kubernetes 来部署模型,并经常引用更专业的资源,这些资源更详细地介绍了 Kubernetes。
在第二部分,我们使用 Kubeflow,这是 Kubernetes 上的一个工具,使得部署更加容易:
-
我们使用为 TensorFlow Serving 准备的相同模型,并使用 KFServing(Kubeflow 负责提供服务的部分)进行部署。
-
然后,我们创建一个转换器来预处理图像和后处理预测。
本章的代码可在本书的 GitHub 仓库中找到(github.com/alexeygrigorev/mlbookcamp-code/),位于 chapter-09-kubernetes 和 chapter-09-kubeflow 文件夹中。
让我们开始吧!
9.2 使用 TensorFlow Serving 提供模型服务
在第七章中,我们使用 Keras 预测图像的类别。在第八章中,我们将模型转换为 TF Lite,并使用 AWS Lambda 进行预测。在本章中,我们将使用 TensorFlow Serving 来完成这项工作。
TensorFlow Serving,通常简称为“TF Serving”,是一个专为提供 TensorFlow 模型而设计的系统。与专为移动设备制作的 TF Lite 不同,TF Serving 专注于服务器。通常,服务器具有 GPU,TF Serving 知道如何利用它们。
AWS Lambda 非常适合实验和应对少量图像——每天少于一百万张。但是,当我们超过这个数量并获得更多图像时,AWS Lambda 变得昂贵。然后,使用 Kubernetes 和 TF Serving 部署模型是一个更好的选择。
仅使用 TF Serving 部署模型是不够的。我们还需要另一个服务来准备图像。接下来,我们将讨论我们将构建的系统架构。
9.2.1 服务架构概述
TF Serving 专注于唯一的一件事——提供模型服务。它期望接收到的数据已经准备好:图像已调整大小、预处理并发送为正确的格式。
这就是为什么仅仅将模型放入 TF Serving 还不够。我们需要一个额外的服务来处理数据预处理。
我们需要一个系统来提供深度学习模型的服务(如图 9.1 所示):
-
网关:预处理部分。它获取我们需要进行预测的 URL,准备它,并将其发送到模型。我们将使用 Flask 来创建这个服务。
-
模型:实际模型的那个部分。我们将使用 TF Serving 来完成这项工作。

图 9.1 我们系统的双层架构。网关获取用户请求并准备数据,TF Serving 使用这些数据来进行预测。
与单一组件相比,使用两个组件的系统可能看起来是一个不必要的复杂性。在前一章中,我们不需要这样做。我们只有一个部分——lambda 函数。
在原则上,我们可以从那个 lambda 函数中提取代码,将其放入 Flask 中,并用于提供模型服务。这种方法确实可行,但可能不是最有效的方法。如果我们需要处理数百万张图像,合理利用资源是很重要的。
与单一组件相比,有两个独立的组件使得为每个部分选择正确的资源变得更容易:
-
除了预处理之外,网关还花费大量时间下载图像。它不需要强大的计算机来完成这项工作。
-
TF Serving 组件需要更强大的机器,通常带有 GPU。使用这样强大的机器来下载图像将是浪费。
-
我们可能需要许多网关实例,而只需要少数几个 TF Serving 实例。通过将它们分离成不同的组件,我们可以独立地扩展每个组件。
我们将从第二个组件——TF Serving 开始。
在第七章,我们训练了一个 Keras 模型。为了使用 TF Serving,我们需要将其转换为 TF Serving 使用的特殊格式,这被称为 saved_model。我们将在下一步进行此操作。
9.2.2 保存模型格式
我们之前训练的 Keras 模型以 h5 格式保存。TF Serving 无法读取 h5 格式:它期望模型以 saved_model 格式存在。在本节中,我们将 h5 模型转换为 saved_model 文件。
如果您没有第七章中的模型,可以使用 wget 下载它:
wget https://github.com/alexeygrigorev/mlbookcamp-code/releases/download/chapter7-model/xception_v4_large_08_0.894.h5
现在,让我们进行转换。我们可以从 Jupyter Notebook 或 Python 脚本中执行此操作。
在任何情况下,我们首先进行导入:
import tensorflow as tf
from tensorflow import keras
然后加载模型:
model = keras.models.load_model('xception_v4_large_08_0.894.h5')
最后,以 saved_model 格式保存它:
tf.saved_model.save(model, 'clothing-model')
就这样。运行此代码后,我们在 clothing-model 文件夹中保存了模型。
要能够稍后使用此模型,我们需要了解一些信息:
-
模型签名名称。模型签名描述了模型的输入和输出。您可以在此处了解更多关于模型签名的信息:
www.tensorflow.org/tfx/serving/signature_defs. -
输入层的名称。
-
输出层的名称。
当使用 Keras 时,我们不需要担心它,但 TF Serving 要求我们必须有这些信息。
TensorFlow 内置了一个用于分析 saved_model 格式模型的特殊实用工具——saved_model_cli。我们不需要安装任何额外的软件。我们将使用此实用工具的 show 命令:
saved_model_cli show --dir clothing-model --all
让我们看看输出:
MetaGraphDef with tag-set: 'serve' contains the following SignatureDefs:
...
signature_def['serving_default']: ❶
The given SavedModel SignatureDef contains the following input(s):
inputs['input_8'] tensor_info: ❷
dtype: DT_FLOAT
shape: (-1, 299, 299, 3)
name: serving_default_input_8:0
The given SavedModel SignatureDef contains the following output(s):
outputs['dense_7'] tensor_info: ❸
dtype: DT_FLOAT
shape: (-1, 10)
name: StatefulPartitionedCall:0
Method name is: tensorflow/serving/predict
❶ 签名定义—serving_default
❷ 输入名称—input_8
❸ 输出名称—dense_7
在这个输出中,我们关注三件事:
-
模型的签名定义(signature_def)。在这种情况下,它是
serving_default。 -
输入 (
input_8)。这是模型的输入名称。 -
输出 (
dense_7)。这是模型输出层的名称。
注意:请注意这些名称——我们稍后调用此模型时需要用到它们。
模型已转换,现在我们准备好使用 TF Serving 提供服务。
9.2.3 在本地运行 TensorFlow Serving
在本地运行 TF Serving 最简单的方法之一是使用 Docker。您可以在官方文档中了解更多信息:www.tensorflow.org/tfx/serving/docker. 更多关于 Docker 的信息请参阅第五章。
我们需要做的只是调用 docker run 命令,指定模型路径及其名称:
docker run -it --rm \
-p 8500:8500 \ ❶
-v "$(pwd)/clothing-model:/models/clothing-model/1" \ ❷
-e MODEL_NAME=clothing-model \ ❸
tensorflow/serving:2.3.0 ❹
❶ 打开端口 8500 以提供服务
❷ 挂载我们的模型
❸ 指定模型名称
❹ 使用 TensorFlow Serving 镜像,版本 2.3.0
当运行时,我们使用三个参数:
-
-p: 将主机机器(我们运行 Docker 的计算机)上的端口 8500 映射到容器内部的端口 8500 ❶。 -
-v: 将模型文件放入 Docker 镜像内部 ❷。模型被放置在 /models/clothing-model/1,其中 clothing-model 是模型的名称,1 是版本号。 -
-e: 将MODEL_NAME变量设置为clothing-model❸,这是 ❷ 中的目录名称。
要了解更多关于 docker run 命令的信息,请参阅官方 Docker 文档 (docs.docker.com/engine/reference/run/)。
运行此命令后,我们应该在终端中看到日志:
2020-12-26 22:56:37.315629: I tensorflow_serving/core/loader_harness.cc:87] Successfully loaded servable version {name: clothing-model version: 1}
2020-12-26 22:56:37.321376: I tensorflow_serving/model_servers/server.cc:371] Running gRPC ModelServer at 0.0.0.0:8500 ...
[evhttp_server.cc : 238] NET_LOG: Entering the event loop ...
Entering the event loop 消息告诉我们 TF Serving 已成功启动并准备好接收请求。
但我们目前还不能使用它。为了准备一个请求,我们需要加载一个图像,对其进行预处理,并将其转换为特殊的二进制格式。接下来,我们将看到我们如何做到这一点。
9.2.4 从 Jupyter 调用 TF Serving 模型
对于通信,TF Serving 使用 gRPC——一种专为高性能通信设计的特殊协议。此协议依赖于 protobuf,这是一种有效的数据传输格式。与 JSON 不同,它是二进制的,这使得请求显著更紧凑。
要了解如何使用它,让我们首先从 Jupyter Notebook 中对这些技术进行实验。我们使用 gRPC 和 protobuf 连接到使用 TF Serving 部署的模型。之后,我们可以在下一节将此代码放入 Flask 应用程序中。
让我们开始。我们需要安装几个库:
-
grpcio:用于 Python 中的 gRPC 支持
-
tensorflow-serving-api:用于从 Python 使用 TF Serving
使用 pip 安装它们:
pip install grpcio==1.32.0 tensorflow-serving-api==2.3.0
我们还需要 keras_image_helper 库来预处理图像。我们已经在第八章中使用了这个库。如果您还没有安装它,请使用 pip 进行安装:
pip install keras_image_helper==0.0.1
接下来,创建一个 Jupyter Notebook。我们可以称它为 chapter-09-image-preparation。像往常一样,我们从导入开始:
import grpc
import tensorflow as tf
from tensorflow_serving.apis import predict_pb2
from tensorflow_serving.apis import prediction_service_pb2_grpc
我们导入了三样东西:
-
gRPC:用于与 TF Serving 通信
-
TensorFlow:用于 protobuf 定义(我们稍后会看到它是如何使用的。)
-
来自 TensorFlow Serving 的几个函数
现在,我们需要定义到我们服务的连接:
host = 'localhost:8500'
channel = grpc.insecure_channel(host)
stub = prediction_service_pb2_grpc.PredictionServiceStub(channel)
注意:我们使用了一个不安全的通道——一个不需要认证的通道。本章中所有服务之间的通信都在同一网络内部进行。这个网络对外界是关闭的,因此使用不安全的通道不会造成任何安全漏洞。设置安全通道是可能的,但超出了本书的范围。
对于图像预处理,我们使用 keras_image_helper 库,就像之前一样:
from keras_image_helper import create_preprocessor
preprocessor = create_preprocessor('xception', target_size=(299, 299))
让我们使用与第八章中(图 9.2)相同的裤子图片。

图 9.2 我们用于测试的裤子图片
让我们将其转换为 NumPy 数组:
url = "http://bit.ly/mlbookcamp-pants"
X = preprocessor.from_url(url)
我们在 X 中有一个 NumPy 数组,但我们不能直接使用它。对于 gRPC,我们需要将其转换为 protobuf。TensorFlow 有一个专门用于此的功能:tf.make_tensor_proto。
这就是我们的使用方法:
def np_to_protobuf(data):
return tf.make_tensor_proto(data, shape=data.shape)
此函数接受两个参数:
-
一个 NumPy 数组:
data, -
此数组的维度:
data.shape
注意:在这个例子中,我们使用 TensorFlow 将 NumPy 数组转换为 protobuf。TensorFlow 是一个庞大的库,所以仅仅为了一个小的函数而依赖它是不可取的。在本章中,我们这样做是为了简单起见,但在生产环境中你不应该这样做,因为使用带有大镜像的 Docker 可能会引发问题:下载镜像需要更多时间,并且它们会占用更多空间。查看此存储库以了解你可以做什么:github.com/alexeygrigorev/tensorflow-protobuf。
现在,我们可以使用 np_to_protobuf 函数来准备一个 gRPC 请求:
pb_request = predict_pb2.PredictRequest() ❶
pb_request.model_spec.name = 'clothing-model' ❷
pb_request.model_spec.signature_name = 'serving_default' ❸
pb_request.inputs['input_8'].CopyFrom(np_to_protobuf(X)) ❹
❶ 如果有一个项目,引入该项目
❷ 设置模型名称为 clothing-model
❸ 指定签名名称:serving_default
❹ 将 X 转换为 protobuf,并将其分配给 input_8
让我们逐行查看。首先,在 ❶ 中,我们创建了一个请求对象。TF Serving 使用这个对象中的信息来确定如何处理请求。
在 ❷ 中,我们指定了模型的名称。回想一下,当在 Docker 中运行 TF Serving 时,我们指定了 MODEL_NAME 参数——我们将其设置为 clothing-model。在这里,我们表示我们想要将请求发送到该模型。
在 ❸ 中,我们指定了想要查询的签名。当我们分析 saved_model 文件时,签名名称是 serving_default,所以我们在这里使用它。你可以在官方 TF Serving 文档中了解更多关于签名的信息(www.tensorflow.org/tfx/serving/signature_defs)。
在 ❹ 中,我们做了两件事。首先,我们将 X 转换为 protobuf。然后,我们将结果设置到名为 input_8 的输入中。这个名称也来源于我们对 saved_model 文件的分析。
让我们执行它:
pb_result = stub.Predict(pb_request, timeout=20.0)
这向 TF Serving 实例发送了一个请求。然后 TF Serving 将模型应用于请求,并将结果发送回来。结果被保存到 pb_result 变量中。要从那里获取预测,我们需要访问其中一个输出:
pred = pb_result.outputs['dense_7'].float_val
注意,我们需要通过名称引用特定的输出——dense_7。在分析 saved_model 文件的签名时,我们也注意到了它——现在我们用它来获取预测结果。
pred 变量是一个浮点数列表——预测结果:
[-1.868, -4.761, -2.316, -1.062, 9.887, -2.812, -3.666, 3.200, -2.602, -4.835]
我们需要将这些数字列表转换为我们可以理解的东西——我们需要将其与标签连接起来。我们使用与之前章节相同的方法:
labels = [
'dress',
'hat',
'longsleeve',
'outwear',
'pants',
'shirt',
'shoes',
'shorts',
'skirt',
't-shirt'
]
result = {c: p for c, p in zip(labels, pred)}
这给出了最终结果:
{'dress': -1.868,
'hat': -4.761,
'longsleeve': -2.316,
'outwear': -1.062,
'pants': 9.887,
'shirt': -2.812,
'shoes': -3.666,
'shorts': 3.200,
'skirt': -2.602,
't-shirt': -4.835}
我们看到 pants 标签具有最高的分数。
我们成功从 Jupyter Notebook 连接到 TF Serving 实例,并使用了 gRPC 和 protobuf 来实现这一点。现在让我们把这个代码放入一个网络服务中。
9.2.5 创建网关服务
我们已经有了与使用 TF Serving 部署的模型通信所需的所有代码。
然而,这段代码并不方便使用。我们模型的用户不需要担心下载图像、进行预处理、将其转换为 protobuf 以及我们做的所有其他事情。他们应该能够发送图像的 URL 并获取预测结果。
为了让我们的用户更容易使用,我们将所有这些代码放入一个网络服务中。用户将与服务交互,服务将与 TF Serving 通信。因此,服务将作为我们模型的网关。这就是为什么我们可以简单地称之为“网关”(图 9.3)。

图 9.3 网关服务是一个 Flask 应用,它获取图像的 URL 并准备它。然后它使用 gRPC 和 protobuf 与 TF Serving 进行通信。
我们使用 Flask 来创建这个服务。我们之前已经使用过 Flask;你可以参考第五章以获取更多详细信息。
网关服务需要做这些事情:
-
从请求中获取图像的 URL。
-
下载图像,预处理它,并将其转换为 NumPy 数组。
-
将 NumPy 数组转换为 protobuf,并使用 gRPC 与 TF Serving 通信。
-
后处理结果——将包含数字的原始列表转换为人类可理解的形式。
因此,让我们创建它!首先创建一个名为 model_server.py 的文件——我们将所有这些逻辑放在那里。
首先,我们获取与笔记本中相同的导入:
import grpc
import tensorflow as tf
from tensorflow_serving.apis import predict_pb2
from tensorflow_serving.apis import prediction_service_pb2_grpc
from keras_image_helper import create_preprocessor
现在,我们需要添加 Flask 导入:
from flask import Flask, request, jsonify
接下来,创建 gRPC stub 连接:
host = os.getenv('TF_SERVING_HOST', 'localhost:8500') ❶
channel = grpc.insecure_channel(host)
stub = prediction_service_pb2_grpc.PredictionServiceStub(channel)
❶ 使 TF Serving URL 可配置
而不是简单地硬编码 TF Serving 实例的 URL,我们通过环境变量TF_SERVING_HOST使其可配置。如果没有设置变量,我们使用默认值'localhost:8500'。
现在,让我们创建预处理程序:
preprocessor = create_preprocessor('xception', target_size=(299, 299))
此外,我们还需要定义我们类的名称:
labels = [
'dress',
'hat',
'longsleeve',
'outwear',
'pants',
'shirt',
'shoes',
'shorts',
'skirt',
't-shirt'
]
而不是简单地从笔记本中复制粘贴代码,我们可以使代码更有组织,并将其放入两个函数中:
-
make_request:用于从 NumPy 数组创建 gRPC 请求 -
process_response:用于将类标签附加到预测中
让我们从make_request开始:
def np_to_protobuf(data):
return tf.make_tensor_proto(data, shape=data.shape)
def make_request(X):
pb_request = predict_pb2.PredictRequest()
pb_request.model_spec.name = 'clothing-model'
pb_request.model_spec.signature_name = 'serving_default'
pb_request.inputs['input_8'].CopyFrom(np_to_protobuf(X))
return pb_request
接下来,创建 process_response:
def process_response(pb_result):
pred = pb_result.outputs['dense_7'].float_val
result = {c: p for c, p in zip(labels, pred)}
return result
最后,让我们将所有这些放在一起:
def apply_model(url):
X = preprocessor.from_url(url) ❶
pb_request = make_request(X) ❷
pb_result = stub.Predict(pb_request, timeout=20.0) ❸
return process_response(pb_result) ❹
❶ 从提供的 URL 预处理图像
❷ 将 NumPy 数组转换为 gRPC 请求
❸ 执行请求
❹ 处理响应,并将标签附加到预测中
所有代码都已准备就绪。我们只需要做最后一件事:创建一个 Flask 应用和predict函数。让我们来做:
app = Flask('clothing-model')
@app.route('/predict', methods=['POST'])
def predict():
url = request.get_json()
result = apply_model(url['url'])
return jsonify(result)
if __name__ == "__main__":
app.run(debug=True, host='0.0.0.0', port=9696)
现在,我们已经准备好运行服务。在终端中执行此命令:
python model_server.py
等待它准备好。我们应该在终端中看到以下内容:
* Running on http://0.0.0.0:9696/ (Press CTRL+C to quit)
让我们来测试一下!就像在第五章中一样,我们使用 requests 库来做这个。你可以打开任何 Jupyter Notebook。例如,你可以在我们用 gRPC 连接到 TF Serving 的同一个笔记本中继续。
我们需要发送一个带有 URL 的请求并显示响应。这是使用 requests 库来完成的方式:
import requests
req = {
"url": "http://bit.ly/mlbookcamp-pants"
}
url = 'http://localhost:9696/predict'
response = requests.post(url, json=req)
response.json()
在这里,我们向我们的服务发送一个 POST 请求并显示结果。响应与之前相同:
{'dress': -1.868,
'hat': -4.761,
'longsleeve': -2.316,
'outwear': -1.062,
'pants': 9.887,
'shirt': -2.812,
'shoes': -3.666,
'shorts': 3.200,
'skirt': -2.602,
't-shirt': -4.835}
服务已就绪并且本地工作正常。让我们使用 Kubernetes 来部署它!
9.3 使用 Kubernetes 进行模型部署
Kubernetes 是一个用于自动化容器部署的编排系统。我们可以用它来托管任何 Docker 容器。在本节中,我们将看到如何使用 Kubernetes 来部署我们的应用程序。
首先,我们将从介绍一些 Kubernetes 基础知识开始。
9.3.1 Kubernetes 简介
Kubernetes 中的主要抽象单元是Pod。Pod 包含一个单一的 Docker 镜像,当我们想要提供服务时,Pod 执行实际的工作。
Pods 存在于一个节点上——这是一个实际的机器。一个节点通常包含一个或多个 Pod。
部署一个应用程序,我们定义一个部署。我们指定应用程序应该有多少个 Pod 以及应该使用哪个镜像。当我们的应用程序开始接收到更多请求时,有时我们想要向我们的部署中添加更多 Pod 来处理流量的增加。这也可以自动发生——这个过程被称为水平自动扩展。
服务是部署中 Pod 的入口点。客户端与服务交互,而不是与单个 Pod 交互。当服务收到请求时,它会将其路由到部署中的一个 Pod。
Kubernetes 集群外部的客户端通过入口与集群内部的服务交互。
假设我们有一个名为 Gateway 的服务。对于这个服务,我们有一个包含三个 Pod 的部署(Gateway Deployment)——Pod A、Node 1 上的 Pod B 和 Node 2 上的 Pod D(图 9.4)。当客户端想要向服务发送请求时,它首先由入口处理,然后服务将请求路由到部署中的一个 Pod。在这个例子中,是部署在节点 1 上的 Pod A。Pod A 上的服务处理请求,客户端接收响应。

图 9.4 Kubernetes 集群的解剖结构。Pod 是我们应用程序的实例。它们存在于节点上——实际的机器。属于同一应用程序的 Pod 被分组在一个部署中。客户端与服务通信,服务将请求路由到部署中的一个 Pod。
这是对 Kubernetes 关键词汇的非常简短的介绍,但应该足以开始。要了解更多关于 Kubernetes 的信息,请参阅官方文档(kubernetes.io/)。
在下一节中,我们将看到如何在 AWS 上创建我们自己的 Kubernetes 集群。
9.3.2 在 AWS 上创建 Kubernetes 集群
要能够将我们的服务部署到 Kubernetes 集群,我们需要有一个。我们有多个选项:
-
你可以在云中创建一个集群。所有主要的云服务提供商都使得在云中设置 Kubernetes 集群成为可能。
-
你可以使用 Minikube 或 MicroK8S 在本地设置它。你可以在这里了解更多信息:
mlbookcamp.com/article/local-k8s.html。
在本节中,我们使用 AWS 的 EKS。EKS 代表 Elastic Kubernetes Service,是 AWS 提供的一项服务,允许我们以最小的努力创建一个 Kubernetes 集群。其他选择包括 Google Cloud 的 GKE(Google Kubernetes Engine)和 Azure 的 AKS(Azure Kubernetes Service)。
对于本节,你需要使用三个命令行工具:
-
AWS CLI:管理 AWS 资源。更多信息请参阅附录 A。
-
eksctl:管理 EKS 集群 (docs.aws.amazon.com/eks/latest/userguide/eksctl.html)。 -
kubectl:管理 Kubernetes 集群中的资源 (kubernetes.io/docs/tasks/tools/install-kubectl/)。它适用于任何集群,而不仅仅是 EKS。
官方文档足以安装这些工具,但你也可以参考本书的网站获取更多信息 (mlbookcamp.com/article/eks)。
如果你没有使用 AWS 但使用的是其他云服务提供商,你需要使用他们的工具来设置 Kubernetes 集群。因为 Kubernetes 并未绑定到任何特定的供应商,所以本章中的大多数说明将适用于你拥有集群的任何位置。
一旦安装了 eksctl 和 AWS CLI,我们就可以创建一个 EKS 集群。
首先,准备一个包含集群配置的文件。在你的项目目录中创建一个名为 cluster.yaml 的文件:
apiVersion: eksctl.io/v1alpha5
kind: ClusterConfig
metadata:
name: ml-bookcamp-eks
region: eu-west-1
version: "1.18"
nodeGroups:
- name: ng
desiredCapacity: 2
instanceType: m5.xlarge
创建配置文件后,我们可以使用 eksctl 启动一个集群:
eksctl create cluster -f cluster.yaml
注意:创建集群需要 15-20 分钟,所以请耐心等待。
使用此配置,我们在 eu-west-1 区域创建了一个集群,集群中部署了 Kubernetes 版本 1.18。集群的名称是 ml-bookcamp-eks。如果你想将其部署到其他区域,你可以更改它。此集群将使用两台 m5.xlarge 机器。你可以在此处了解更多关于此类实例的信息:aws.amazon.com/ec2/instance-types/m5/。这对于我们在本章中需要进行的 Kubernetes 和 Kubeflow 的实验来说是足够的。
注意:EKS 不在 AWS 免费层中。你可以在 AWS 的官方文档中了解更多关于费用的信息 (aws.amazon.com/eks/pr-icing/)。
一旦创建,我们需要配置 kubectl 以便能够访问它。对于 AWS,我们使用 AWS CLI 来完成此操作:
aws eks --region eu-west-1 update-kubeconfig --name ml-bookcamp-eks
此命令应在默认位置生成一个 kubectl 配置文件。在 Linux 和 MacOS 上,此位置是 ~/.kube/config。
现在我们来检查一切是否正常工作,并确认我们可以使用 kubectl 连接到我们的集群:
kubectl get service
此命令返回当前正在运行的服务列表。我们还没有部署任何内容,所以我们预计只会看到一个服务——Kubernetes 本身。你应该看到以下结果:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kubernetes ClusterIP 10.100.0.1 <none> 443/TCP 6m17s
连接成功,现在我们可以部署一个服务。为此,我们首先需要准备一个包含实际服务的 Docker 镜像。让我们接下来做这件事。
9.3.3 准备 Docker 镜像
在前面的章节中,我们创建了服务系统的两个组件:
-
TF-Serving:包含实际模型的组件
-
Gateway:与 TF Serving 通信的图像预处理组件
现在我们部署它们。我们首先部署 TF Serving 镜像。
TensorFlow Serving 镜像
如第八章所述,我们首先需要将我们的镜像发布到 ECR——AWS 的 Docker 注册库。让我们创建一个名为 model-serving 的注册库:
aws ecr create-repository --repository-name model-serving
应该返回一个类似这样的路径:
<ACCOUNT>.dkr.ecr.<REGION>.amazonaws.com/model-serving
重要提示——我们稍后会需要这个路径。
当在本地运行 TF Serving 的 Docker 镜像时,我们使用了这个命令(你现在不需要运行它):
docker run -it --rm \
-p 8500:8500 \
-v "$(pwd)/clothing-model:/models/clothing-model/1" \
-e MODEL_NAME=clothing-model \
tensorflow/serving:2.3.0
我们使用了-v参数将模型从clothing-model挂载到镜像内的/models/clothing-model/1 目录。
也可以使用 Kubernetes 来完成这个操作,但在这个章节中,我们遵循一个更简单的方法,将模型直接包含在镜像中,类似于我们在第八章中所做的。
让我们为它创建一个 Dockerfile。我们可以将其命名为 tf-serving.dockerfile:
FROM tensorflow/serving:2.3.0 ❶
ENV MODEL_NAME clothing-model ❷
COPY clothing-model /models/clothing-model/1 ❸
❶ 使用 Tensorflow Serving 镜像作为其基础
❷ 设置 MODEL_NAME 变量为 clothing-model
❸ 将模型复制到/models/clothing-model/1
我们基于❶中的 TensorFlow Serving 镜像。接下来,在❷中,我们将环境变量MODEL_NAME设置为clothing-model,这相当于-e参数。接下来,在❸中,我们将模型复制到/models/clothing-model/1,这相当于使用-v参数。
注意:如果你想要使用带有 GPU 的计算机,请使用 tensorflow/serving :2.3.0-gpu 镜像(在 Dockerfile 中注释为❶)。
让我们构建它:
IMAGE_SERVING_LOCAL="tf-serving-clothing-model"
docker build -t ${IMAGE_SERVING_LOCAL} -f tf-serving.dockerfile .
接下来,我们需要将此镜像发布到 ECR。首先,我们需要使用 AWS CLI 对 ECR 进行认证:
$(aws ecr get-login --no-include-email)
注意:在输入命令时需要包含“$”。括号内的命令返回另一个命令。使用“$()”,我们执行这个命令。
接下来,使用远程 URI 标记镜像:
ACCOUNT=XXXXXXXXXXXX
REGION=eu-west-1
REGISTRY=${ACCOUNT}.dkr.ecr.${REGION}.amazonaws.com/model-serving
IMAGE_SERVING_REMOTE=${REGISTRY}:${IMAGE_SERVING_LOCAL}
docker tag ${IMAGE_SERVING_LOCAL} ${IMAGE_SERVING_REMOTE}
确保更改 ACCOUNT 和 REGION 变量。
现在我们已经准备好将图像推送到 ECR:
docker push ${IMAGE_SERVING_REMOTE}
已推送!现在我们需要对 Gateway 组件做同样的操作。
Gateway 镜像
现在让我们为 Gateway 组件准备图像。Gateway 是一个网络服务,它依赖于多个 Python 库:
-
Flask 和 Gunicorn
-
keras_image_helper
-
grpcio
-
TensorFlow
-
TensorFlow-Serving-API
记住,在第五章中,我们使用了 Pipenv 来管理依赖项。让我们在这里也使用它:
pipenv install flask gunicorn \
keras_image_helper==0.0.1 \
grpcio==1.32.0 \
tensorflow==2.3.0 \
tensorflow-serving-api==2.3.0
运行此命令会创建两个文件:Pipfile 和 Pipfile.lock。
警告:尽管我们已经提到了这一点,但这是非常重要的,所以需要重复。在这里,我们只依赖于 TensorFlow 的一个功能。在生产环境中,最好不要安装 TensorFlow。在本章中,我们这样做是为了简单起见。而不是依赖于 TensorFlow,我们可以只取我们需要的 protobuf 文件,并显著减小我们的 Docker 镜像大小。有关说明,请参阅此存储库:github.com/alexeygrigorev/tensorflow-protobuf.
现在让我们创建一个 Docker 镜像。首先创建一个名为 gateway.dockerfile 的 Dockerfile,内容如下:
FROM python:3.7.5-slim
ENV PYTHONUNBUFFERED=TRUE
RUN pip --no-cache-dir install pipenv
WORKDIR /app
COPY ["Pipfile", "Pipfile.lock", "./"]
RUN pipenv install --deploy --system && \
rm -rf /root/.cache
COPY "model_server.py" "model_server.py"
EXPOSE 9696
ENTRYPOINT ["gunicorn", "--bind", "0.0.0.0:9696", "model_server:app"]
这个 Dockerfile 与我们之前拥有的文件非常相似。有关更多信息,请参阅第五章。
让我们现在构建这个镜像:
IMAGE_GATEWAY_LOCAL="serving-gateway"
docker build -t ${IMAGE_GATEWAY_LOCAL} -f gateway.dockerfile .
然后将其推送到 ECR:
IMAGE_GATEWAY_REMOTE=${REGISTRY}:${IMAGE_GATEWAY_LOCAL}
docker tag ${IMAGE_GATEWAY_LOCAL} ${IMAGE_GATEWAY_REMOTE}
docker push ${IMAGE_GATEWAY_REMOTE}
注意:为了验证这些镜像在本地很好地协同工作,您需要使用 Docker Compose (docs.docker.com/compose/)。这是一个非常有用的工具,我们建议您花时间学习它,但在这里我们不会对其进行介绍。
我们已经将这两个镜像发布到 ECR,现在我们准备将服务部署到 Kubernetes!我们将在下一部分进行操作。
9.3.4 部署到 Kubernetes
在我们部署之前,让我们回顾一下 Kubernetes 的基础知识。在集群内部存在以下对象:
-
Pod:Kubernetes 中的最小单元。它是一个单独的进程,我们有一个 Docker 容器在一个 pod 中。
-
部署:多个相关 pods 的组。
-
服务:位于部署前面并路由请求到各个 pods 的组件。
要将应用程序部署到 Kubernetes,我们需要配置两件事:
-
部署:它指定了此部署的 pods 将如何看起来。
-
服务:指定如何访问服务以及服务如何连接到 pods。
让我们从配置 TF Serving 的部署开始。
部署 TF Serving
在 Kubernetes 中,我们通常使用 YAML 文件来配置一切。为了配置部署,我们在项目目录中创建一个名为 tf-serving-clothing-model-deployment.yaml 的文件,内容如下:
apiVersion: apps/v1
kind: Deployment ❶
metadata:
name: tf-serving-clothing-model ❷
labels:
app: tf-serving-clothing-model ❷
spec:
replicas: 1 ❸
selector:
matchLabels:
app: tf-serving-clothing-model ❷
template:
metadata:
labels:
app: tf-serving-clothing-model ❷
spec: ❹
containers: ❹
- name: tf-serving-clothing-model ❷
image: <ACCOUNT>.dkr.ecr.<REGION>.
➥amazonaws.com/model-serving:tf-serving-clothing-model ❺
ports:
- containerPort: 8500 ❻
❶ 配置部署
❷ 指定部署的名称
❸ 仅创建一个服务实例——一个 pod
❹ 定义 pods 的规范
❺ 使用我们之前创建的镜像
❻ 公开端口 8500 这个配置相当长,所以让我们看看所有重要的行。
在 ❶ 中,我们指定了在这个 YAML 文件中我们想要配置的 Kubernetes 对象类型——它是一个部署。
在 ❷ 中,我们定义了部署的名称并设置了一些元数据信息。我们需要重复多次:一次用于设置部署的名称(“name”),以及几次(“labels: app”)用于我们稍后要配置的服务。
在 ❸ 中,我们设置了部署中我们想要的实例数——pods。
在 ❹ 中,我们指定了 pods 的配置——我们设置了所有 pods 都将拥有的参数。
在❺中,我们设置 Docker 镜像的 URI。Pod 将使用这个镜像。别忘了在那里也放入您的账户 ID 以及正确的区域。
最后,在❻中,我们打开此部署的 pods 上的 8500 端口。这是 TF Serving 使用的端口。
要了解更多关于在 Kubernetes 中配置部署的信息,请查看官方文档(kubernetes.io/docs/concepts/workloads/controllers/deployment)。
我们有一个配置。现在我们需要使用它来创建一个 Kubernetes 对象——在我们的情况下是一个部署。我们通过使用kubectl的apply命令来完成:
kubectl apply -f tf-serving-clothing-model-deployment.yaml
-f参数告诉kubectl它需要从配置文件中读取配置。
为了验证它是否正在工作,我们需要检查是否出现了新的部署。这是我们可以获取所有活动部署列表的方法:
kubectl get deployments
输出应该看起来像这样:
NAME READY UP-TO-DATE AVAILABLE AGE
tf-serving-clothing-model 1/1 1 1 41s
我们可以看到我们的部署在那里。此外,我们还可以获取 pods 的列表。它与获取所有部署的列表非常相似:
kubectl get pods
输出应该类似于以下内容:
NAME READY STATUS RESTARTS AGE
tf-serving-clothing-model-56bc84678d-b6n4r 1/1 Running 0 108s
现在我们需要在部署之上创建一个服务。
TF Serving 服务
我们希望从网关调用 TF Serving。为此,我们需要在 TF Serving 部署前面创建一个服务。
就像部署一样,我们首先为服务创建一个配置文件。它也是一个 YAML 文件。创建一个名为tf-serving-clothing-model-service.yaml的文件,内容如下:
apiVersion: v1
kind: Service
metadata:
name: tf-serving-clothing-model ❶
labels:
app: tf-serving-clothing-model ❶
spec: ❷
ports:
- port: 8500 ❷
targetPort: 8500 ❷
protocol: TCP
name: http
selector: ❸
app: tf-serving-clothing-model ❸
❶ 配置服务的名称
❷ 服务的规范——将要使用的端口
❸ 通过指定部署的标签将服务连接到部署
我们以相同的方式应用它——通过使用apply命令:
kubectl apply -f tf-serving-clothing-model-service.yaml
为了检查它是否工作,我们可以获取所有服务的列表,看看我们的服务是否在那里:
kubectl get services
我们应该看到类似以下的内容
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kubernetes ClusterIP 10.100.0.1 <none> 443/TCP 84m
tf-serving- ClusterIP 10.100.111.165 <none> 8500/TCP 19s
clothing-model
除了默认的 Kubernetes 服务之外,我们还有一个 tf-serving-clothing-model,这是我们刚刚创建的服务。
要访问此服务,我们需要获取其 URL。内部 URL 通常遵循以下模式:
<service-name>.<namespace-name>.svc.cluster.local
<service-name>部分是 tf-serving-clothing-model。
我们没有为这个服务使用任何特定的命名空间,因此 Kubernetes 自动将服务放入“default”命名空间。我们在这里不会介绍命名空间,但你可以在官方文档(kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/)中了解更多。
这是刚刚创建的服务的 URL:
tf-serving-clothing-model.default.svc.cluster.local
我们稍后会需要这个 URL,当配置网关时。
我们已经创建了 TF Serving 的部署以及服务。现在让我们创建一个网关的部署。
网关部署
如前所述,我们首先创建一个包含配置的 YAML 文件。创建一个名为serving-gateway-deployment.yaml的文件:
apiVersion: apps/v1
kind: Deployment
metadata:
name: serving-gateway
labels:
app: serving-gateway
spec:
replicas: 1
selector:
matchLabels:
app: serving-gateway
template:
metadata:
labels:
app: serving-gateway
spec:
containers:
- name: serving-gateway
image: <ACCOUNT>.dkr.ecr.<REGION>.amazonaws.com/model-serving:serving-gateway
ports:
- containerPort: 9696
env: ❶
- name: TF_SERVING_HOST ❶
value: "tf-serving-clothing-model.default.svc.cluster.local:8500" ❶
❶ 设置 TF_SERVING_HOST 环境变量的值
将图像 URL 中的<ACCOUNT>和<REGION>替换为您的值。
此部署的配置与 TF Serving 的部署非常相似,只有一个重要区别:我们通过将其设置为包含我们模型的服务(在列表中用粗体显示)的 URL 来指定 TF_SERVING_HOST 变量的值。
让我们应用这个配置:
kubectl apply -f serving-gateway-deployment.yaml
这应该会创建一个新的 Pod 和一个新的部署。让我们看看 Pod 列表:
kubectl get pod
的确,有一个新的 Pod 在那里:
NAME READY STATUS RESTARTS AGE
tf-serving-clothing-model-56bc84678d-b6n4r 1/1 Running 0 1h
serving-gateway-5f84d67b59-lx8tq 1/1 Running 0 30s
警告 Gateway 使用 gRPC 与 TF Serving 通信。当部署多个 TF Serving 实例时,您可能会遇到在这些实例之间分配负载的问题(kubernetes.io/blog/2018/11/07/grpc-load-balancing-on-kubernetes-without-tears/)。为了解决这个问题,您需要安装一个服务网格工具,如 Linkerd、Istio 或类似工具。与您的运维团队交谈,了解您在公司如何实现。
我们为 Gateway 创建了一个部署。现在我们需要为它配置服务。我们将在下一步进行。
Gateway 服务配置
我们为 Gateway 创建了一个部署,现在我们需要创建一个服务。这个服务与我们为 TF Serving 创建的服务不同——它需要公开访问,因此 Kubernetes 集群之外的服务可以使用它。为此,我们需要使用一种特殊类型的服务——LoadBalancer。它创建了一个外部负载均衡器,可在 Kubernetes 集群外部使用。在 AWS 的情况下,它使用 ELB,即弹性负载均衡服务。
让我们创建一个名为 serving-gateway-service.yaml 的配置文件:
apiVersion: v1
kind: Service
metadata:
name: serving-gateway
labels:
app: serving-gateway
spec:
type: LoadBalancer ❶
ports:
- port: 80 ❷
targetPort: 9696 ❷
protocol: TCP
name: http
selector:
app: serving-gateway
❶ 使用 LoadBalancer 类型
❷ 将 Pod 中的端口 9696 映射到服务的端口 80
在❶中,我们指定了服务的类型——LoadBalancer。
在❷中,我们将服务的端口 80 连接到 Pod 的端口 9696。这样,我们就不需要在连接服务时指定端口——它将使用默认的 HTTP 端口,即 80。
让我们应用这个配置:
kubectl apply -f serving-gateway-service.yaml
要查看服务的外部 URL,请使用 describe 命令:
kubectl describe service serving-gateway
它将输出有关服务的一些信息:
Name: serving-gateway
Namespace: default
Labels: <none>
Annotations: <none>
Selector: app=serving-gateway
Type: LoadBalancer
IP Families: <none>
IP: 10.100.100.24
IPs: <none>
LoadBalancer Ingress: ad1fad0c1302141989ed8ee449332e39-117019527.eu-west-1.elb.amazonaws.com
Port: http 80/TCP
TargetPort: 9696/TCP
NodePort: http 32196/TCP
Endpoints: <none>
Session Affinity: None
External Traffic Policy: Cluster
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal EnsuringLoadBalancer 4s service-controller Ensuring load balancer
Normal EnsuredLoadBalancer 2s service-controller Ensured load balancer
我们对带有 LoadBalancer Ingress 的行感兴趣。这是我们访问 Gateway 服务的 URL。在我们的例子中,这是 URL:
ad1fad0c1302141989ed8ee449332e39-117019527.eu-west-1.elb.amazonaws.com
Gateway 服务已准备好使用。让我们开始吧!
9.3.5 测试服务
当在本地运行 TF Serving 和 Gateway 时,我们准备了一段简单的 Python 代码片段来测试我们的服务。让我们重用它。转到相同的笔记本,并将本地 IP 地址替换为上一节中获得的 URL:
import requests
req = {
"url": "http://bit.ly/mlbookcamp-pants"
}
url = 'http://ad1fad0c1302141989ed8ee449332e39-117019527.eu-west-1.elb.amazonaws.com/predict'
response = requests.post(url, json=req)
response.json()
运行它。结果,我们得到与之前相同的预测:
{'dress': -1.86829,
'hat': -4.76124,
'longsleeve': -2.31698,
'outwear': -1.06257,
'pants': 9.88716,
'shirt': -2.81243,
'shoes': -3.66628,
'shorts': 3.20036,
'skirt': -2.60233,
't-shirt': -4.83504}
它正在工作——这意味着我们刚刚成功使用 TF Serving 和 Kubernetes 部署了我们的深度学习模型!
重要提示:如果您完成了 EKS 的实验,别忘了关闭集群。如果您不关闭它,即使它是空闲的,您也需要为此付费。您可以在本章末尾找到有关如何操作的说明。
在本例中,我们仅从用户的角度介绍了 Kubernetes,而不是从运维的角度。我们还没有讨论自动扩展、监控、警报以及其他对于将机器学习模型投入生产所必需的重要主题。有关这些主题的更多详细信息,请参阅 Kubernetes 书籍或 Kubernetes 的官方文档。
你可能已经注意到,为了部署单个模型,我们需要做很多事情:创建 Docker 镜像,推送到 ECR,创建部署,创建服务。对于几个模型来说,这没问题,但如果需要为数十个或数百个模型这样做,就会变得有问题且重复。
有一个解决方案——Kubeflow。它使部署变得更容易。在下一节中,我们将看到如何使用它来托管 Keras 模型。
9.4 使用 Kubeflow 进行模型部署
Kubeflow 是一个旨在简化在 Kubernetes 上部署机器学习服务的项目。
它由一系列工具组成,每个工具都旨在解决特定问题。例如:
-
Kubeflow Notebooks Server:使集中托管 Jupyter Notebooks 变得更容易
-
Kubeflow Pipelines:自动化训练过程
-
Katib:为模型选择最佳参数
-
Kubeflow Serving(简称“KFServing”):部署机器学习模型
以及许多其他内容。你可以在这里了解更多关于其组件的信息:www.kubeflow.org/docs/components/。
在本章中,我们专注于模型部署,因此我们只需要使用 Kubeflow 的一个组件——KFServing。
如果你想安装整个 Kubeflow 项目,请参阅官方文档。它包含了针对主要云提供商(如 Google Cloud Platform、Microsoft Azure 和 AWS)的安装说明。www.kubeflow.org/docs/aws/aws-e2e/。
关于在 AWS 上仅安装 KFServing 而不安装 Kubeflow 其余部分的说明,请参阅本书网站:mlbookcamp.com/article/kfserving-eks-install。我们使用这篇文章来设置本章其余部分的环境,但这里的代码应该可以通过少量修改与任何 Kubeflow 安装兼容。
注意:安装可能对你来说并不简单,尤其是如果你之前没有做过类似的事情。如果你对某些事情不确定,请向运维团队的人寻求帮助以设置它。
9.4.1 模型:将其上传到 S3
要使用 KFServing 部署 Keras 模型,我们首先需要将其转换为 saved_model 格式。我们之前已经完成了这个步骤,所以我们可以直接使用转换后的文件。
接下来,我们需要在 S3 中创建一个存储桶,我们将把我们的模型放在那里。让我们称它为 mlbookcamp-models-
我们可以使用 AWS CLI 创建它:
aws s3api create-bucket \
--bucket mlbookcamp-models-alexey \
--region eu-west-1 \
--create-bucket-configuration LocationConstraint=eu-west-1
在创建存储桶之后,我们需要将模型上传到那里。为此,请使用 AWS CLI:
aws s3 cp --recursive clothing-model s3:/ /mlbookcamp-models-alexey/clothing-model/0001/
注意,结尾有“0001”。这很重要——KFServing,就像 TF Serving 一样,需要一个模型的版本。我们没有这个模型的任何先前版本,所以我们把“0001”加在结尾。
现在我们已经准备好部署这个模型了。
9.4.2 使用 KFServing 部署 TensorFlow 模型
在之前,当我们使用纯 Kubernetes 部署我们的模型时,我们需要配置一个部署然后一个服务。而不是这样做,KFServing 定义了一种特殊的 Kubernetes 对象——InferenceService。我们只需要配置一次,它将自动创建所有其他 Kubernetes 对象——包括服务和部署。
首先,创建另一个 YAML 文件(tf-clothes.yaml),内容如下:
apiVersion: "serving.kubeflow.org/v1beta1"
kind: "InferenceService"
metadata:
name: "clothing-model"
spec:
default:
predictor:
serviceAccountName: sa ❶
tensorflow: ❷
storageUri: "s3:/ /mlbookcamp-models-alexey/clothing-model"
❶ 使用 serviceAccountName 访问 S3
❷ 在 S3 中指定包含模型的存储位置
当从 S3 访问模型时,我们需要指定服务账户名称以便能够获取模型。这告诉 KFServing 如何访问 S3 存储桶——我们在❶中指定它。关于在 EKS 上安装 KFServing 的文章也涵盖了这一点(mlbookcamp.com/article/kfserving-eks-install)。
就像通常的 Kubernetes 一样,我们使用kubectl应用此配置:
kubectl apply -f tf-clothing.yaml
因为它创建了一个 InferenceService 对象,我们需要使用kubectl的get命令获取此类对象的列表:
kubectl get inferenceservice
我们应该看到类似以下的内容:
NAME URL READY AGE
clothing-model http://clothing-model... True ... 97s
如果我们的服务READY尚未为True,我们需要等待一会儿,直到它准备好。这可能需要 1-2 分钟。
现在请注意 URL 和模型的名称:
-
URL:
clothing-model.default.kubeflow.mlbookcamp.com/v1/models/clothing-model。在你的配置中,主机将不同,因此整个 URL 也将不同。 -
模型名称:clothing-model。
注意,URL 可能需要一些时间才能从我们的笔记本电脑上访问。DNS 的变化可能需要一些时间来传播。
9.4.3 访问模型
模型已部署。让我们使用它!为此,我们可以启动 Jupyter Notebook 或创建一个 Python 脚本文件。
KFServing 使用 HTTP 和 JSON,因此我们使用 requests 库与其通信。所以,让我们首先导入它:
import requests
接下来,我们需要使用图像预处理器来准备图像。它与之前使用的是同一个:
from keras_image_helper import create_preprocessor
preprocessor = create_preprocessor('xception', target_size=(299, 299))
现在,我们需要一个用于测试的图像。我们使用与上一节中相同的裤子图像,并使用相同的代码来获取它和预处理它:
image_url = "http://bit.ly/mlbookcamp-pants"
X = preprocessor.from_url(image_url)
X 变量包含一个 NumPy 数组。在我们将数据发送到 KFServing 之前,我们需要将其转换为列表:
data = {
"instances": X.tolist()
}
我们已经有了请求。作为下一步,我们需要定义我们将发送请求的 URL。我们已经在上一节中有了它,但我们需要稍作修改:
-
使用 HTTPS 而不是 HTTP。
-
在 URL 的末尾添加 “:predict”。
经过这些更改,URL 如下所示:
url = 'https://clothing-model.default.kubeflow.mlbookcamp.com/v1/models/clothing-model:predict'
我们准备发送请求:
resp = requests.post(url, json=data)
results = resp.json()
让我们看看结果:
{'predictions': [[-1.86828923,
-4.76124525,
-2.31698346,
-1.06257045,
9.88715553,
-2.81243205,
-3.66628242,
3.20036,
-2.60233665,
-4.83504581]]}
就像我们之前做的那样,我们需要将预测转换为人类可读的格式。我们通过将标签分配给结果的每个元素来实现:
pred = results['predictions'][0]
labels = [
'dress',
'hat',
'longsleeve',
'outwear',
'pants',
'shirt',
'shoes',
'shorts',
'skirt',
't-shirt'
]
result = {c: p for c, p in zip(labels, pred)}
这里是结果:
{'dress': -1.86828923,
'hat': -4.76124525,
'longsleeve': -2.31698346,
'outwear': -1.06257045,
'pants': 9.88715553,
'shirt': -2.81243205,
'shoes': -3.66628242,
'shorts': 3.20036,
'skirt': -2.60233665,
't-shirt': -4.83504581}
我们已经部署了我们的模型,并且它可以被使用。
但我们不能期望使用我们模型的人会高兴于不得不自己准备图像。在下一节中,我们将讨论转换器——它们可以减轻预处理图像的负担。
9.4.4 KFServing 转换器
在上一节中,我们介绍了网关服务。它位于客户端和模型之间,负责将客户端的请求转换为模型期望的格式(图 9.5)。

图 9.5 网关服务负责预处理图像,因此我们的应用程序的客户端不需要这样做。
幸运的是,对于我们来说,我们不需要为 KFServing 引入另一个网关服务。相反,我们可以使用一个 转换器。
转换器负责
-
预处理来自客户端的请求并将其转换为我们的模型期望的格式
-
对模型输出的后处理——将其转换为客户端需要的格式
我们可以将上一节中的所有预处理代码放入一个转换器中(图 9.6)。

图 9.6 KFServing 转换器可以在预处理步骤中下载图像并准备它,以及在后处理步骤中为模型的输出附加标签。
就像我们手动创建的网关服务一样,KFServing 中的转换器是独立于模型部署的。这意味着它们可以独立地进行扩展和缩减。这是一件好事——它们执行不同类型的工作:
-
转换器正在执行 I/O 工作(下载图像)。
-
模型正在执行 CPU 密集型工作(应用神经网络进行预测)。
要创建一个转换器,我们需要安装 Python 的 KFServing 库并创建一个扩展 KFModel 类的类。
它看起来像这样:
class ImageTransformer(kfserving.KFModel):
def preprocess(self, inputs):
# implement pre-processing logic
def postprocess(self, inputs):
# implement post-processing logic
我们不会深入介绍如何构建自己的转换器,但如果你想了解如何做,请查看这篇文章:mlbookcamp.com/article/kfserving-transformers。相反,对于这本书,我们准备了一个使用 keras_image_helper 库的转换器。你可以在这里查看其源代码:github.com/alexeygrigorev/kfserving-keras-transformer。
让我们使用它。首先,我们需要删除旧的推理服务:
kubectl delete -f tf-clothes.yaml
然后,更新配置文件(tf-clothes.yaml),并在其中包含变换器部分(加粗):
apiVersion: "serving.kubeflow.org/v1alpha2"
kind: "InferenceService"
metadata:
name: "clothing-model"
spec:
default:
predictor: ❶
serviceAccountName: sa
tensorflow:
storageUri: "s3:/ /mlbookcamp-models-alexey/clothing-model"
transformer: ❷
custom:
container:
image: "agrigorev/kfserving-keras-transformer:0.0.1" ❸
name: user-container
env:
- name: MODEL_INPUT_SIZE ❹
value: "299,299" ❹
- name: KERAS_MODEL_NAME ❹
value: "xception" ❹
- name: MODEL_LABELS ❹
value: "dress,hat,longsleeve,outwear,pants,
ehttps://shirt,shoes,shorts,skirt,t-shirt" ❹
❶ 在“预测器”部分定义了模型
❷ 在“变换器”部分定义了变换器
❸ 为变换器设置镜像
❹ 配置它——指定输入大小、模型名称和标签
除了我们之前提到的“预测器”部分之外,我们添加了另一个部分——“变换器”。我们使用的变换器是一个公开可用的镜像,位于 agrigorev/kfserving-keras-transformer:0.0.1。
它依赖于 keras_image_helper 库来进行变换。为此,我们需要设置三个参数:
-
MODEL_INPUT_SIZE: 模型期望的输入大小:299 x 299 -
KERAS_MODEL_NAME: 用于训练模型的 Keras 应用程序(keras.io/api/applications/)中架构的名称 -
MODEL_LABELS: 我们想要预测的类别
让我们应用这个配置:
kubectl apply -f tf-clothes.yaml
等待几分钟,直到它准备好——使用kubectl get inferenceservice来检查状态。
部署完成后(READY为True),我们可以对其进行测试。我们将在下一节进行测试。
9.4.5 测试变换器
使用变换器,我们不需要担心准备镜像:只需发送镜像的 URL 即可。代码变得更加简单。
它看起来是这样的:
import requests
data = {
"instances": [
{"url": "http://bit.ly/mlbookcamp-pants"},
]
}
url = 'https://clothing-model.default.kubeflow.mlbookcamp.com/v1/models/clothing-model:predict'
result = requests.post(url, json=data).json()
服务的 URL 保持不变。结果包含预测:
{'predictions': [{'dress': -1.8682, 'hat': -4.7612, 'longsleeve': -2.3169,
'outwear': -1.0625, 'pants': 9.8871, 'shirt': -2.8124, 'shoes': -3.6662,
'shorts': 3.2003, 'skirt': -2.6023, 't-shirt': -4.8350}]}
就这样!现在我们可以使用这个模型了。
9.4.6 删除 EKS 集群
在尝试了 EKS 之后,别忘了关闭集群。使用eksctl来完成这个操作:
eksctl delete cluster --name ml-bookcamp-eks
要验证集群已被移除,你可以在 AWS 控制台中检查 EKS 服务页面。
9.5 下一步
你已经学到了训练用于预测衣服类型的分类模型所需的基本知识。我们已经涵盖了大量的内容,但还有更多内容需要学习,而本章无法全部涵盖。你可以通过做练习来更深入地探索这个主题。
9.5.1 练习
-
Docker Compose 是一个用于运行具有多个容器的应用程序的工具。在我们的示例中,网关需要与 TF Serving 模型通信;这就是为什么我们需要能够将它们链接起来。Docker Compose 可以帮助我们做到这一点。尝试在本地运行 TF Serving 和网关。
-
在本章中,我们使用了 AWS 的 EKS。为了学习 Kubernetes,在本地进行实验是有益的。使用 Minikube 或 Microk8s 在本地重现 TF Serving 和 Gateway 的示例。
-
在本章的所有实验中,我们使用了默认的 Kubernetes 命名空间。在实际应用中,我们通常为不同的应用程序组使用不同的命名空间。在了解了 Kubernetes 中的命名空间之后,然后在不同的命名空间中部署我们的服务。例如,你可以称其为“模型”。
-
KFServing 转换器是预处理数据的有力工具。我们尚未讨论如何自行实现它们,而是使用了已经实现的转换器。要了解更多关于它们的信息,请自行实现这个转换器。
9.5.2 其他项目
有许多项目你可以做来更好地学习 Kubernetes 和 Kubeflow:
-
在本章中,我们介绍了一个深度学习模型。它相当复杂,我们最终创建了两个服务。在第七章之前我们开发的其它模型较为简单,只需要一个简单的 Flask 应用来托管它们。你可以使用 Flask 和 Kubernetes 部署第二章、第三章和第六章中的模型。
-
KFServing 可以用于部署其他类型的模型,而不仅仅是 TensorFlow。用它来部署第三章和第六章中的 Scikit-learn 模型。
摘要
-
TensorFlow-Serving 是一个用于部署 Keras 和 TensorFlow 模型的系统。它使用 gRPC 和 protobuf 进行通信,并且高度优化以提供服务。
-
当使用 TensorFlow Serving 时,我们通常需要一个组件来将用户请求准备成模型期望的格式。这个组件隐藏了与 TensorFlow Serving 交互的复杂性,使得客户端更容易使用模型。
-
要在 Kubernetes 上部署某些内容,我们需要创建一个部署和一个服务。部署描述了应该部署的内容:Docker 镜像及其配置。服务位于部署之前,并将请求路由到各个容器。
-
Kubeflow 和 KFServing 简化了部署过程:我们只需要指定模型的存储位置,它们会自动处理创建部署、服务和其他重要事项。
-
KFServing 转换器使得预处理模型接收到的数据和后处理结果变得更加容易。有了转换器,我们不需要为预处理创建一个特殊的网关服务。
附录 A. 准备环境
A.1 安装 Python 和 Anaconda
对于本书中的项目,我们将使用 Anaconda,这是一个包含您所需的大部分机器学习包的 Python 发行版:NumPy、SciPy、Scikit-learn、Pandas 等等。
A.1.1 在 Linux 上安装 Python 和 Anaconda
本节中的说明适用于您在远程机器或笔记本电脑上安装 Anaconda 的情况。尽管我们只在 Ubuntu 18.04 LTS 和 20.04 LTS 上进行了测试,但这个过程应该适用于大多数 Linux 发行版。
注意:建议使用 Ubuntu Linux 来运行本书中的示例。这不是一个严格的要求,您应该在其他操作系统上运行示例时不会遇到问题。如果您没有装有 Ubuntu 的计算机,您可以在云中在线租用一台。请参阅“在 AWS 上租用服务器”部分以获取更详细的说明。
几乎每个 Linux 发行版都预装了 Python 解释器,但始终有一个单独的 Python 安装是个好主意,以避免与系统 Python 发生冲突。使用 Anaconda 是一个很好的选择:它安装在用户目录中,并且不会干扰系统 Python。
要安装 Anaconda,您首先需要下载它。访问www.anaconda.com,点击获取入门版。然后选择下载 Anaconda 安装程序。这应该会带您到www.anaconda.com/products/individual。
选择 64 位(x86)安装程序和最新可用的版本——写作时为 3.8(图 A.1)。

图 A.1 下载 Anaconda 的 Linux 安装程序
接下来,复制安装包的链接。在我们的例子中是repo.anaconda.com/archive/Anaconda3-2021.05-Linux-x86_64.sh。
注意:如果 Anaconda 有新版本可用,您应该安装它。所有代码在新版本上都能正常工作,不会出现问题。
现在转到终端下载它:
wget https://repo.anaconda.com/archive/Anaconda3-2021.05-Linux-x86_64.sh
然后安装它:
bash Anaconda3-2021.05-Linux-x86_64.sh
阅读协议,如果您接受,请输入“yes”,然后选择您想要安装 Anaconda 的位置。您可以使用默认位置,但不必这么做。
在安装过程中,您将被询问是否想要初始化 Anaconda。输入“yes”,它将自动完成所有操作:
Do you wish the installer to initialize Anaconda3
by running conda init? [yes|no]
[no] >>> yes
如果您不想让安装程序初始化它,您可以通过将 Anaconda 的二进制文件位置添加到PATH变量中来手动完成。在主目录中打开.bashrc文件,并在末尾添加此行:
export PATH=~/anaconda3/bin:$PATH
安装完成后,您可以删除安装程序:
rm Anaconda3-2021.05-Linux-x86_64.sh
接下来,打开一个新的终端 shell。如果您正在使用远程机器,您可以通过按 Ctrl-D 退出当前会话,然后使用之前相同的ssh命令再次登录。
现在一切应该都能正常工作。你可以通过使用which命令来测试你的系统是否选择了正确的二进制文件:
which python
如果你是在 AWS 的 EC2 实例上运行,你应该看到类似这样的内容:
/home/ubuntu/anaconda3/bin/python
当然,路径可能不同,但应该是 Anaconda 安装的路径。
现在,你已经准备好使用 Python 和 Anaconda 了。
A.1.2 在 Windows 上安装 Python 和 Anaconda
Windows 的 Linux 子系统
在 Windows 上安装 Anaconda 的推荐方法是使用 Windows 的 Linux 子系统。
要在 Windows 上安装 Ubuntu,请打开 Microsoft Store,在搜索框中查找 ubuntu;然后选择 Ubuntu 18.04 LTS(图 A.2)。

图 A.2 使用 Microsoft Store 在 Windows 上安装 Ubuntu。
要安装它,只需在下一个窗口中点击获取(图 A.3)。

图 A.3 要在 Windows 上安装 Ubuntu 18.04,请点击获取。
安装完成后,我们可以通过点击启动按钮(图 A.4)来使用它。

图 A.4 点击启动以运行 Ubuntu 终端。
首次运行时,它将要求你指定用户名和密码(图 A.5)。之后,终端就准备好使用了。

图 A.5 在 Windows 上运行的 Ubuntu 终端
现在,你可以使用 Ubuntu 终端并遵循 Linux 的说明来安装 Anaconda。
Anaconda Windows 安装程序
或者,我们可以使用 Anaconda 的 Windows 安装程序。首先,我们需要从anaconda.com/distribution(图 A.6)下载它。导航到 Windows 安装程序部分并下载 64 位图形安装程序(或者如果你使用的是较旧的计算机,可以选择 32 位版本)。

图 A.6 下载 Anaconda 的 Windows 安装程序
下载安装程序后,只需运行它并按照设置指南(图 A.7)进行操作。

图 A.7 Anaconda 安装程序
这非常直接,你应该没有问题运行它。安装成功后,你应该可以通过从开始菜单中选择 Anaconda Navigator 来运行它。
A.1.3 在 macOS 上安装 Python 和 Anaconda
macOS 的说明应该与 Linux 和 Windows 类似:选择带有最新 Python 版本的安装程序并执行它。
A.2 运行 Jupyter
A.2.1 在 Linux 上运行 Jupyter
安装 Anaconda 后,你可以运行 Jupyter。首先,你需要创建一个目录,Jupyter 将使用该目录来存储所有的 notebooks:
mkdir notebooks
然后cd到这个目录,从这里运行 Jupyter:
cd notebooks
它将使用此目录来创建 notebooks。现在让我们运行 Jupyter:
jupyter notebook
如果你想在本地计算机上运行 Jupyter,这应该足够了。如果你想在远程服务器上运行它,例如 AWS 的 EC2 实例,你需要添加一些额外的命令行选项:
jupyter notebook --ip=0.0.0.0 --no-browser
在这种情况下,你必须指定两个东西:
-
Jupyter 将用于接受传入 HTTP 请求的 IP 地址(
--ip=0.0.0.0)。默认情况下,它使用 localhost,这意味着只能从计算机内部访问 Notebook 服务。 -
--no-browser参数,这样 Jupyter 就不会尝试使用默认的网页浏览器打开带有笔记本的 URL。当然,远程机器上没有网页浏览器,只有终端。
注意:在 AWS 上的 EC2 实例的情况下,你还需要配置安全规则,以允许实例在端口 8888 上接收请求。请参阅“在 AWS 上租用服务器”部分以获取更多详细信息。
当你运行此命令时,你应该会看到类似以下内容:
[C 04:50:30.099 NotebookApp]
To access the notebook, open this file in a browser:
file:/ / /run/user/1000/jupyter/nbserver-3510-open.html
Or copy and paste one of these URLs:
http://(ip-172-31-21-255 or 127.0.0.1):8888/?token=670dfec7558c9a84689e4c3cdbb473e158d3328a40bf6bba
当启动时,Jupyter 会生成一个随机令牌。你需要这个令牌来访问网页。这是出于安全考虑,所以没有人可以访问 Notebook 服务,除了你。
从终端复制 URL,并将(ip-172-31-21-255 或 127.0.0.1)替换为实例 URL。你应该得到类似以下内容:
此 URL 由三部分组成:
-
实例的 DNS 名称:如果你使用 AWS,你可以从 AWS 控制台或使用 AWS CLI 获取它。
-
端口(8888,这是 Jupyter 笔记本服务的默认端口)。
-
你刚刚从终端复制的令牌。
之后,你应该能够看到 Jupyter Notebooks 服务并创建一个新的笔记本(图 A.8)。

图 A.8 Jupyter Notebook 服务。现在你可以创建一个新的笔记本。
如果你使用的是远程机器,当你退出 SSH 会话时,Jupyter Notebook 服务将停止工作。内部进程附属于 SSH 会话,并将被终止。为了避免这种情况,你可以在 screen 工具中运行服务,这是一个用于管理多个虚拟终端的工具:
screen -R jupyter
此命令将尝试连接到名为 jupyter 的 screen,但如果不存在这样的 screen,它将创建一个。
然后,在 screen 内部,你可以输入相同的命令来启动 Jupyter Notebook:
jupyter notebook --ip=0.0.0.0 --no-browser
通过尝试从你的网页浏览器访问它来检查它是否工作。在确认它工作后,你可以通过按 Ctrl-A 后跟 D 来断开 screen:首先按 Ctrl-A,稍等片刻,然后按 D(对于 macOS,首先按 Ctrl-A 然后按 Ctrl-D)。screen 内部运行的所有内容都不附加到当前的 SSH 会话,所以当你断开 screen 并退出会话时,Jupyter 进程将继续运行。
你现在可以断开 SSH 连接(通过按 Ctrl-D)并验证 Jupyter URL 是否仍然可用。
A.2.2 在 Windows 上运行 Jupyter
与 Python 和 Anaconda 一样,如果你使用 Windows 的 Linux 子系统安装 Jupyter,Linux 的说明也适用于 Windows。
默认情况下,Linux 子系统没有配置浏览器运行。因此,我们需要使用以下命令来启动 Jupyter:
jupyter notebook --no-browser
或者,我们可以将 BROWSER 变量设置为指向 Windows 中的浏览器:
export BROWSER='/mnt/c/Windows/explorer.exe'
然而,如果您没有使用 Linux 子系统,而是使用 Windows 安装程序安装 Anaconda,启动 Jupyter Notebook 服务的方式就不同了。
首先,我们需要在开始菜单中打开 Anaconda Navigator。一旦打开,在应用程序标签页中找到 Jupyter 并点击启动(图 A.9)。

图 A.9 要运行 Jupyter Notebook 服务,在应用程序标签页中找到 Jupyter 并点击启动。
服务成功启动后,带有 Jupyter 的浏览器应该会自动打开(图 A.10)。

图 A.10 使用 Anaconda Navigator 启动的 Jupyter Notebook 服务
A.2.3 在 MacOS 上运行 Jupyter
Linux 的说明也应该适用于 macOS,无需进行任何额外更改。
A.3 安装 Kaggle CLI
Kaggle CLI 是访问 Kaggle 平台的命令行界面,包括 Kaggle 竞赛和 Kaggle 数据集的数据。
您可以使用 pip 安装它:
pip install kaggle --upgrade
然后,您需要对其进行配置。首先,您需要从 Kaggle 获取凭证。为此,请访问您的 Kaggle 个人资料(如果您还没有,请创建一个),位于 https://www.kaggle.com/www.kaggle.com/agrigorev/account。
在 API 部分,点击创建新的 API 令牌(图 A.11)。

图 A.11 要生成用于 Kaggle CLI 的 API 令牌,请点击您的 Kaggle 账户页面上的创建新 API 令牌。
这将下载一个名为 kaggle.json 的文件,这是一个包含两个字段的 JSON 文件:username 和 key。如果您在下载文件的同一台计算机上配置 Kaggle CLI,只需将此文件移动到 Kaggle CLI 期望的位置:
mkdir ~/.kaggle
mv kaggle.json ~/.kaggle/kaggle.json
如果您在远程机器上配置它,例如 EC2 实例,您需要复制此文件的内容并将其粘贴到终端中。使用 nano 打开文件(如果不存在,将创建文件):
mkdir ~/.kaggle
nano ~/.kaggle/kaggle.json
将您下载的 kaggle.json 文件的内容粘贴进去。通过按 Ctrl-O 保存文件,并通过按 Ctrl-X 退出 nano。
现在通过尝试列出可用的数据集来测试它是否正在工作:
kaggle datasets list
您还可以通过尝试第二章的数据集来测试它是否可以下载数据集:
kaggle datasets download -d CooperUnion/cardataset
它应该下载一个名为 cardataset.zip 的文件。
A.4 访问源代码
我们已经将本书的源代码存储在 GitHub 上,这是一个托管源代码的平台。您可以在以下链接查看:github.com/alexeygrigorev/mlbookcamp-code。
GitHub 使用 Git 来管理代码,因此您需要一个 Git 客户端来访问本书的代码。
Git 在所有主要的 Linux 发行版中都是预安装的。例如,我们用于在 AWS 上创建 Ubuntu 实例的 AMI 已经包含了它。
如果您的发行版没有 Git,安装它也很容易。例如,对于基于 Debian 的发行版(如 Ubuntu),您需要运行以下命令:
sudo apt-get install git
在 macOS 上,要使用 Git,您需要安装命令行工具,或者,作为替代,从 sourceforge.net/projects/git-osx-installer/ 下载安装程序。
对于 Windows,您可以从 git-scm.com/download/win 下载 Git。
一旦您安装了 Git,您就可以使用它来获取书中的代码。要访问它,您需要运行以下命令:
git clone https://github.com/alexeygrigorev/mlbookcamp-code.git
现在,您可以运行 Jupyter Notebook:
cd mlbookcamp-code
jupyter notebook
如果您没有 Git 且不想安装它,也可以在不使用 Git 的情况下访问代码。您可以从 zip 存档中下载最新代码并解压。在 Linux 上,您可以通过执行以下命令来完成此操作:
wget -O mlbookcamp-code.zip \
https://github.com/alexeygrigorev/mlbookcamp-code/archive/master.zip
unzip mlbookcamp-code.zip
rm mlbookcamp-code.zip
您也可以直接使用您的网络浏览器:输入 URL,下载 zip 存档,并提取内容。
A.5 安装 Docker
在第五章,我们使用 Docker 将我们的应用程序打包到隔离的容器中。安装非常简单。
A.5.1 在 Linux 上安装 Docker
这些步骤基于 Docker 网站上的官方 Ubuntu 指令(docs.docker.com/engine/install/ubuntu/)。
首先,我们需要安装所有先决条件:
sudo apt-get update
sudo apt-get install apt-transport-https ca-certificates curl software-properties-common
接下来,我们添加包含 Docker 二进制文件的存储库:
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
现在,我们可以安装它:
sudo apt-get update
sudo apt-get install docker-ce
最后,如果我们想在不使用 sudo 的情况下执行 Docker 命令,我们需要将我们的用户添加到 docker 用户组:
sudo adduser $(whoami) docker
现在,您需要重新启动您的系统。在 EC2 或其他远程机器的情况下,只需注销并重新登录即可。
要测试一切是否正常工作,运行 hello-world 容器:
docker run hello-world
您应该会看到一个表示一切正常的信息:
Hello from Docker!
This message shows that your installation appears to be working correctly.
A.5.2 在 Windows 上安装 Docker
要在 Windows 上安装 Docker,您需要从官方网站下载安装程序(hub.docker.com/editions/community/docker-ce-desktop-windows/),然后简单地按照说明操作。
A.5.3 在 MacOS 上安装 Docker
就像在 Windows 上一样,在 MacOS 上安装 Docker 也非常简单:首先,从官方网站下载安装程序(hub.docker.com/editions/community/docker-ce-desktop-mac/),然后按照说明操作。
A.6 在 AWS 上租用服务器
使用云服务是获取远程机器的最简单方法,您可以使用它来跟随书中的示例。
现在有很多选择,包括像亚马逊网络服务(AWS)、谷歌云平台、微软 Azure 和 Digital Ocean 这样的云计算提供商。在云中,你可以在短时间内使用服务器,并且通常按小时、按分钟甚至按秒计费。你可以根据计算能力(CPU 或 GPU 的数量)和 RAM 选择最适合你需求的机器。
你也可以租用专用服务器更长时间,并按月支付。如果你打算长期使用服务器——比如说,六个月或更长时间——租用专用服务器会更便宜。Hetzner.com在这种情况下可能是一个不错的选择。他们还提供带有 GPU 的服务器。
为了让你更容易地设置本书所需的所有库的环境,我们在这里提供了在 AWS 上设置 EC2(弹性计算云)机器的说明。EC2 是 AWS 的一部分,允许你按任何配置和任何时长租用服务器。
注意:我们与亚马逊或 AWS 没有关联。我们选择在本书中使用它,因为在写作时它是使用最广泛的云服务提供商。
如果你还没有 AWS 账户或者最近才创建了一个账户,你符合免费层的资格:你有一个为期 12 个月的试用期,期间你可以免费试用 AWS 的大部分产品。我们尽可能使用免费层,如果某些服务不包含在这个层中,我们会特别说明。
注意,本节中的说明是可选的,你不必使用 AWS 或任何其他云服务。代码应该在任何 Linux 机器上都能运行,所以如果你有一台装有 Linux 的笔记本电脑,应该足够你完成本书的学习。Mac 或 Windows 电脑也应该是可以的,但我们没有在这些平台上彻底测试代码。
A.6.1 在 AWS 上注册
你需要做的第一件事是创建一个账户。为此,请访问aws.amazon.com,然后点击创建 AWS 账户按钮(见图 A.12)。

图 A.12 要创建账户,请点击主 AWS 页面上的创建 AWS 账户。
注意:本附录是在 2019 年 10 月编写的,截图也是在那时拍摄的。请注意,AWS 网站上的内容和管理控制台的外观可能会发生变化。
按照说明填写所需详细信息。这应该是一个简单的过程,类似于在任何网站上注册的过程。
注意:在注册过程中,AWS 会要求你提供银行卡的详细信息。
完成注册并验证账户后,你应该会看到主页面——AWS 管理控制台(见图 A.13)。

图 A.13 AWS 管理控制台是 AWS 的起始页面。
恭喜!您刚刚创建了一个根账户。然而,不建议将根账户用于任何操作:它具有非常广泛的权限,允许您在 AWS 账户上做任何事。通常,您使用根账户创建更弱权限的账户,然后使用它们来完成日常任务。
要创建此类账户,请在查找服务框中键入“IAM”,然后点击下拉列表中的该项。在左侧菜单中选择用户,然后点击添加用户(见图 A.14)。

图 A.14 在 AWS 身份和访问管理(IAM)服务中添加用户
现在,您只需遵循说明并回答问题。在某个时候,它将询问访问类型:您需要选择程序性访问和 AWS 管理控制台访问(见图 A.15)。我们将使用命令行界面(CLI)和 Web 界面来处理 AWS。

图 A.15 我们将使用 Web 界面和命令行界面来处理 AWS,因此您需要选择两种访问类型。
在设置权限步骤中,您指定新用户将能够做什么。您希望用户拥有完全权限,因此请在上部选择直接附加现有策略,并在策略列表中选择管理员访问(见图 A.16)。

图 A.16 选择管理员访问策略以允许新用户访问 AWS 上的所有内容。
作为下一步,系统将询问您有关标签的问题——现在您可以安全地忽略这些。标签通常用于有多个人在同一 AWS 账户上工作的公司,主要用于费用管理目的,因此它们不应成为您在本书中进行的项目的担忧。
最后,当您成功创建了新用户时,向导将建议您下载凭据(见图 A.17)。下载它们并妥善保管;您稍后配置 AWS CLI 时需要使用它们。

图 A.17 新创建用户的详细信息。您可以看到登录 URL 和下载用于程序性访问的凭据。
要访问管理控制台,您可以使用 AWS 为您生成的链接。它出现在成功框中,并遵循以下模式:
https://
将此链接添加到书签可能是个好主意。一旦 AWS 验证了账户(这可能需要一点时间),您就可以使用它来登录:只需提供您在创建用户时指定的用户名和密码。
现在,您可以使用 AWS 的服务了。最重要的是,您可以创建一个 EC2 实例。
A.6.2 访问账单信息
当使用云服务提供商时,您通常按秒计费:对于您使用的每个 AWS 服务,您将支付预定义的费用。每个月底,您将收到账单,通常自动处理。资金将从您与 AWS 账户关联的银行卡中扣除。
重要提示:尽管我们使用免费层来遵循本书中的大多数示例,但您应定期检查计费页面,以确保您没有意外使用可计费服务。
要了解月底您将需要支付多少费用,您可以访问 AWS 的计费页面。
如果您使用的是根账户(您首先创建的账户),只需在 AWS 控制台主页上输入“计费”即可导航到计费页面(图 A.18)。
![图片 A-18.png]
图 A.18 要访问计费页面,在快速访问搜索框中输入“计费”。
如果您尝试从用户账户(或 IAM 用户——在创建根账户后创建的)访问同一页面,您会注意到这是不允许的。为了解决这个问题,您需要
-
允许所有 IAM 用户访问计费页面,
-
给予 AMI 用户访问计费页面的权限。
允许所有 IAM 用户访问计费页面很简单:前往“我的账户”(图 A.19 A),进入“IAM 用户和角色访问计费信息”部分并点击编辑(图 A.19 B),然后选择“激活 IAM 访问”选项并点击更新(图 A.19 C)。
![图片 A-19a.png]
(A)要允许 AMI 用户访问计费信息,点击“我的账户”。
![图片 A-19b.png]
(B)在“我的账户”设置中,找到“IAM 用户和角色访问计费信息”部分并点击编辑。
![图片 A-19c.png]
(C)启用“激活 IAM 访问”选项并点击更新。
图 A.19 为 IAM 用户启用访问计费信息
之后,前往 IAM 服务,找到我们之前创建的 IAM 用户,并点击它。接下来,点击“添加权限”按钮(图 A.20)。
![图片 A-20.png]
图 A.20 要允许 IAM 用户访问计费信息,我们需要为该用户添加特殊权限。为此,点击“添加权限”按钮。
然后将现有的计费策略附加到用户(图 A.21)。

图 A.21 点击“添加权限”按钮后,选择“直接附加现有策略”选项,并在列表中选择“计费”。
之后,IAM 用户应该能够访问计费信息页面。
A.6.3 创建 EC2 实例
EC2 是一种从 AWS 租用机器的服务。您可以使用它来创建一个 Linux 机器,用于本书中的项目。为此,首先前往 AWS 中的 EC2 页面。最简单的方法是在 AWS 管理控制台主页的“查找服务”框中输入“EC2”;然后从下拉列表中选择 EC2 并按 Enter 键(图 A.22)。

图 A.22 要访问 EC2 服务页面,在 AWS 管理控制台主页的“查找服务”框中输入 EC2 并按 Enter 键。
在 EC2 页面上,从左侧菜单中选择“实例”,然后点击“启动实例”(图 A.23)。

图 A.23 要创建 EC2 实例,在左侧菜单中选择“实例”并点击“启动实例”。
这将带您进入一个六步表单。第一步是指定实例将使用的 AMI(Amazon Machine Image)。我们推荐使用 Ubuntu:它是最受欢迎的 Linux 发行版之一,我们在这本书的所有示例中都使用了它。其他镜像也应该可以正常工作,但我们尚未测试它们。
在撰写本文时,Ubuntu Server 20.04 LTS 可用(图 A.24),因此请使用它。在列表中找到它,然后点击“选择”。

图 A.24 你的实例将基于 Ubuntu Server 18.04 LTS。
你应该注意 AMI 的 ID:在这个例子中是 ami-0a8e758f5e873d1c1,但根据你的 AWS 区域和 Ubuntu 版本,它可能不同。
注意:此 AMI 允许免费层使用,这意味着如果你使用免费层测试 AWS,使用此 AMI 不会产生费用。
之后你需要选择实例类型。有许多选项,包括不同数量的 CPU 内核和不同数量的 RAM。如果你想保持在免费层,请选择 t2.micro(图 A.25)。这是一台相当小的机器:它只有 1 个 CPU 和 1 GB RAM。当然,在计算能力方面,它不是最好的实例,但对于这本书中的许多项目来说应该足够了。

图 A.25 t2.micro 是一个相当小的实例,只有 1 个 CPU 和 1 GB RAM,但可以免费使用。
下一步是配置实例详情。这里你不需要做任何更改,可以直接进入下一步:添加存储(图 A.26)。

图 A.26 在 AWS 中创建 EC2 实例的第四步:添加存储。将大小更改为 16GB。
在这里,你指定实例上需要多少空间。默认建议的 8GB 不够用,因此选择 18GB。这对于我们在这本书中将要做的多数项目来说应该足够了。更改后,点击“下一步:添加标签”。
在下一步中,你为新的实例添加标签。你应该添加的唯一标签是“名称”,这允许你为实例赋予一个可读性强的名称。添加键“名称”和值“ml-bookcamp-instance”(或你喜欢的任何其他名称),如图 A.27 所示。

图 A.27 在第五步中你可能想要指定的唯一标签是“名称”:它允许你为实例赋予一个可读性强的名称。
下一步相当重要:选择安全组。这允许你配置网络防火墙并指定实例的访问方式和哪些端口是开放的。你希望在实例上托管 Jupyter Notebook,因此你需要确保其端口已开放并且你可以登录到远程机器。
因为您在 AWS 账户中还没有任何安全组,您现在需要创建一个新的:选择创建新的安全组,并将其命名为 jupyter(图 A.28)。您希望使用 SSH 从您的计算机连接到实例,因此您需要确保 SSH 连接被允许。为了启用此功能,在第一行中选择类型下拉列表中的 SSH。

图 A.28 在 EC2 实例上运行 Jupyter Notebook 的安全组创建
通常 Jupyter Notebook 服务运行在端口 8888 上,因此您需要添加一个自定义 TCP 规则,以便可以从互联网上的任何地方访问端口 8888。
当您这样做时,您可能会看到一个警告,告诉您这可能不安全(图 A.29)。对我们来说这不是问题,因为我们没有在实例上运行任何关键任务。实施适当的安全措施不是一件简单的事情,也不在本书的范围之内。

图 A.29 AWS 警告我们,我们添加的规则不够严格。在我们的情况下这不是问题,我们可以安全地忽略这个警告。
下次您创建实例时,您将能够重用这个安全组而不是创建一个新的。选择选择现有的安全组,并从列表中选择它(图 A.30)。

图 A.30 创建实例时,也可以将现有的安全组分配给实例。
配置安全组是最后一步。确认一切正常,然后点击审查和启动。
AWS 还不允许您启动实例:您还需要配置 SSH 密钥以登录到实例。因为您的 AWS 账户还是新的,还没有密钥,您需要创建一个新的密钥对。从下拉列表中选择创建一个新的密钥对,并将其命名为 jupyter(图 A.31)。

图 A.31 要能够使用 SSH 登录到实例,您需要创建一个密钥对。
点击下载密钥对,并将文件保存在您的计算机上的某个位置。确保您以后可以访问此文件;这对于能够连接到实例非常重要。
下次您创建实例时,您可以重用这个密钥。在第一个下拉列表中选择选择现有的密钥对,选择您想要使用的密钥,并点击复选框以确认您仍然拥有该密钥(图 A.32)。

图 A.32 创建实例时也可以使用现有的密钥。
现在,您可以通过点击启动实例来启动实例。您应该看到一个确认,表明一切正常,实例正在启动(图 A.33)。

图 A.33 AWS 告诉我们,一切顺利,现在实例正在启动。
在这条消息中,你可以看到实例的 ID。在我们的例子中,它是 i-0b1a64d4d20997aff。你现在可以点击它以查看实例的详细信息(图 A.34)。因为你想要使用 SSH 连接到你的实例,你需要获取公网 DNS 名称来做这件事。你可以在描述标签中找到它。

图 A.34 新创建的实例的详细信息。要使用 SSH 连接到它,你需要公网 DNS 名称。
A.6.4 连接到实例
在上一节中,你在 EC2 上创建了一个实例。现在你需要登录到这个实例来安装所有必需的软件。你将使用 SSH 来做这件事。
在 Linux 上连接到实例
你已经知道你的实例的公网 DNS 名称。在我们的例子中,它是ec2-18-191-156-172.us-east-2.compute.amazonaws.com。在你的情况下,名称将不同:名称的第一部分(ec2-18-191-156-172)取决于实例获得的 IP,第二部分(us-east-2)取决于它运行的区域。要使用 SSH 进入实例,你需要这个名称。
当你第一次使用从 AWS 下载的密钥时,你需要确保文件的权限设置正确。执行以下命令:
chmod 400 jupyter.pem
现在,你可以使用密钥登录到实例:
ssh -i "jupyter.pem" \
ubuntu@ec2-18-191-156-172.us-east-2.compute.amazonaws.com
当然,你应该用从实例描述中复制的 DNS 名称替换这里显示的 DNS 名称。
在允许你进入机器之前,SSH 客户端会要求你确认你信任远程实例:
The authenticity of host 'ec2-18-191-156-172.us-east-2.compute.amazonaws.com (18.191.156.172)' can't be established.
ECDSA key fingerprint is SHA256:S5doTJOGwXVF3i1IFjB10RuHufaVSe+EDqKbGpIN0wI.
Are you sure you want to continue connecting (yes/no)?
输入“yes”以确认。
现在,你应该能够登录到实例并看到欢迎信息(图 A.35)。

图 A.35 成功登录到 EC2 实例后,你应该看到欢迎信息。
现在,你可以用这台机器做任何你想做的事情。
在 Windows 上连接到实例
在 Windows 上使用 Linux 子系统是连接到 EC2 实例的最简单方法:你可以在那里使用 SSH,并遵循与 Linux 相同的说明。
或者,你可以使用 Putty (www.putty.org) 从 Windows 连接到 EC2 实例。
在 macOS 上连接到实例
SSH 内置在 macOS 中,因此 Linux 的步骤也应该适用于 Mac。
A.6.5 关闭实例
在你完成与实例的工作后,你应该关闭它。
重要提示:工作完成后,关闭实例非常重要。即使你不再需要机器并且它是空闲的,每使用一秒钟你都会被收费。如果请求的实例在 AWS 使用的前 12 个月内是免费层资格的,则不适用,但无论如何,定期检查你的账户状态并禁用不必要的服务的习惯是好的。
你可以从终端这样做:
sudo shutdown now
你也可以从 Web 界面操作:选择你想要关闭的实例,转到操作,然后选择实例状态 > 停止(图 A.36)。

图 A.36 从 AWS 控制台停止实例
一旦实例被停止,您可以通过选择同一子菜单中的“启动”来再次启动它。您还可以完全删除实例:为此,您需要使用“终止”选项。
A.6.6 配置 AWS CLI
AWS CLI 是 AWS 的命令行界面。对于我们需要的多数事情,使用 AWS 控制台就足够了,但在某些情况下,我们需要命令行工具。例如,在第五章中,我们将模型部署到 Elastic Beanstalk,并需要配置 CLI。
要使用命令行界面(CLI),您需要安装 Python。如果您使用 Linux 或 macOS,您应该已经内置了 Python 发行版。或者,您可以使用下一节中的说明安装 Anaconda。
仅拥有 Python 是不够的;您还需要安装 AWS CLI 本身。您可以在终端中运行以下命令来完成此操作:
pip install awscli
如果您已经有了,更新它是个好主意:
pip install -U awscli
安装完成后,您需要配置工具,指定在创建用户时下载的访问令牌和密钥。
完成此操作的一种方法是使用configure命令:
aws configure
在创建用户时,它将要求您提供我们下载的密钥:
$ aws configure
AWS Access Key ID [None]: <ENTER_ACCESS_KEY>
AWS Secret Access Key [None]: <ENTER_SECRET_KEY>
Default region name [None]: us-east-2
Default output format [None]:
这里使用的区域名称是 us-east-2,位于俄亥俄州。
当您完成工具的配置后,请验证它是否正常工作。您可以要求 CLI 返回您的身份,它应该与您的用户详情相匹配:
$ aws sts get-caller-identity
{
"UserId": "AIDAVUO4TTOO55WN6WHZ4",
"Account": "XXXXXXXXXXXX",
"Arn": "arn:aws:iam::XXXXXXXXXXXX:user/ml-bookcamp"
}
附录 B. Python 简介
Python 目前是构建机器学习项目的最流行语言,这就是为什么我们在这本书的项目中使用它。
如果您不熟悉 Python,本附录涵盖了基础知识:本书中使用的语法和语言特性。它不是深入教程,但它应该提供足够的信息,让您在完成附录后立即开始使用 Python。请注意,它很简短,并且针对已经知道如何使用任何其他编程语言进行编程的人。
要充分利用本附录,创建一个 jupyter notebook,给它起一个像 appendix-b-python 这样的名字,并使用它来执行附录中的代码。让我们开始吧。
B.1 变量
Python 是一种动态语言——因此您不需要像 Java 或 C++ 那样声明类型。例如,要创建一个整型或字符串类型的变量,我们只需要进行简单的赋值:
a = 10 ❶
b = 'string_b' ❷
c = "string_c" ❷
d = 0.999 ❸
❶ a 是一个整数。
❷ b 和 c 是字符串。
❸ d 是一个浮点数。
要将内容打印到标准输出,我们可以使用 print 函数:
print(a, b, c, d)
它打印
10 string_b string_c 0.999
要执行代码,您可以将每个代码片段放在单独的 jupyter notebook 单元格中,然后执行它。要执行单元格中的代码,您可以按 Run 按钮,或使用 Shift+Enter 快捷键(图 B.1)。

图 B.1 在 Jupyter Notebook 单元格中执行的字节码。您可以在执行代码后立即看到输出。
当我们将多个参数传递给 print 时,就像前面的例子一样,它在打印参数之间添加一个空格。
我们可以使用一种称为 元组 的特殊构造将多个变量组合在一起:
t = (a, b)
当我们打印 t 时,我们得到以下内容:
(10, 'string_b')
要将元组展开为多个变量,我们使用 元组赋值:
(c, d) = t
现在 c 和 d 包含元组的第一个值,以及第二个值:
print(c, d)
它打印
10 string_b
在使用元组赋值时,我们可以省略括号:
c, d = t
这会产生相同的结果。
元组赋值非常有用,可以使代码更简洁。例如,我们可以用它来交换两个变量的内容:
a = 10
b = 20
a, b = b, a ❶
print("a =", a)
print("b =", b)
❶ 将 a 替换为 b,将 b 替换为 a。
它将打印
a = 20
b = 10
在打印时,我们可以使用 % 操作符来创建格式化的字符串:
print("a = %s" % a) ❶
print("b = %s" % b) ❷
❶ 将 %s 替换为 a 的内容。
❷ 将 %s 替换为 b 的内容。
它将产生相同的输出:
a = 20
b = 10
在这里 %s 是一个占位符:在这种情况下,它意味着我们想要将传递的参数格式化为字符串。其他常用选项包括
-
使用
%d来格式化为数字 -
使用
%f来格式化为浮点数
我们可以将多个参数传递给元组中的格式化操作符:
print("a = %s, b = %s" % (a, b))
占位符 %s 的第一次出现将被替换为 a,第二次出现将被替换为 b,因此将生成以下内容:
a = 20, b = 10
最后,如果我们有一个浮点数,我们可以使用特殊的格式化来处理它:
n = 0.0099999999
print("n = %.2f" % n)
这将在格式化字符串时将浮点数四舍五入到小数点后第二位,因此执行代码时我们将看到 0.01。
字符串格式化有许多选项,还有其他格式化方式。例如,还有所谓的“新”格式化方式,使用 string.format 方法,我们不会在本附录中介绍。您可以在 pyformat.info 或官方文档中了解更多关于这些格式化选项的信息。
B.1.1 控制流
Python 中有三种控制流语句:if、for 和 while。让我们看看每个语句。
条件
控制程序执行流程的简单方法是 if 语句。在 Python 中,if 的语法如下:
a = 10
if a >= 5:
print('the statement is true')
else:
print('the statement is false')
这将打印第一条语句:
the statement is true
注意,在 Python 中,我们在 if 语句之后使用缩进来分组代码。我们可以使用 elif(else-if 的缩写)将多个 if 语句链接在一起:
a = 3
if a >= 5:
print('the first statement is true')
elif a >= 0:
print('the second statement is true')
else:
print('both statements are false')
这段代码将打印第二条语句:
the second statement is true
对于循环
当我们想要多次重复相同的代码块时,我们使用循环。Python 中的传统 for 循环看起来像这样:
for i in range(10):
print(i)
这段代码将打印从 0 到 9 的数字,不包括 10:
0
1
2
3
4
5
6
7
8
9
当指定范围时,我们可以设置起始数字、结束数字和增量步长:
for i in range(10, 100, 5):
print(i)
这段代码将打印从 10 到 100(不包括)的数字,步长为 5:10, 15, 20, ..., 95。
要提前退出循环,我们可以使用 break 语句:
for i in range(10):
print(i)
if i > 5:
break
这段代码将打印从 0 到 6 的数字。当 i 为 6 时,它将中断循环,因此它不会打印 6 之后的任何数字:
0
1
2
3
4
5
6
要跳过循环的迭代,我们使用 continue 语句:
for i in range(10):
if i <= 5:
continue
print(i)
这段代码将在 i 为 5 或更少时跳过迭代,因此它只会打印从 6 开始的数字:
6
7
8
9
当循环
while 循环也适用于 Python。它在某个条件为 True 时执行。例如:
cnt = 0
while cnt <= 5:
print(cnt)
cnt = cnt + 1
在这段代码中,我们重复循环直到条件 cnt <= 5 为 True。一旦这个条件不再为 True,执行停止。这段代码将打印从 0 到 5 的数字,包括 5:
0
1
2
3
4
5
我们也可以在 while 循环中使用 break 和 continue 语句。
B.1.2 集合
集合是特殊的容器,允许在其中保持多个元素。我们将查看四种类型的集合:列表、元组、集合和字典。
列表
列表 是一个有序集合,可以通过索引访问元素。要创建列表,我们可以简单地将元素放在方括号内:
numbers = [1, 2, 3, 5, 7, 11, 13]
要通过索引获取元素,我们可以使用括号表示法:
el = numbers[1]
print(el)
在 Python 中,索引从 0 开始,因此当我们请求索引为 1 的元素时,我们得到 2。
我们也可以更改列表中的值:
numbers[1] = -2
要从末尾访问元素,我们可以使用负索引。例如,-1 将获取最后一个元素,-2——倒数第二个元素,依此类推:
print(numbers[-1], numbers[-2])
如我们所期望的,它打印了 13 11。
要向列表中添加元素,请使用 append 函数。它将元素添加到列表的末尾:
numbers.append(17)
要遍历列表中的元素,我们使用 for 循环:
for n in numbers:
print(n)
当我们执行它时,我们看到所有元素都被打印出来:
1
-2
3
5
7
11
13
17
这在其他语言中也称为 for-each 循环:我们为集合中的每个元素执行循环体。它不包含索引,只包含元素本身。如果我们还需要访问每个元素的索引,我们可以使用 range,就像我们之前做的那样:
for i in range(len(numbers)):
n = numbers[i]
print("numbers[%d] = %d" % (i, n))
函数 len 返回列表的长度,因此此代码大致等同于在 C 或 Java 中以传统方式遍历数组并按索引访问每个元素。当我们执行它时,代码将打印以下内容:
numbers[0] = 1
numbers[1] = -2
numbers[2] = 3
numbers[3] = 5
numbers[4] = 7
numbers[5] = 11
numbers[6] = 13
numbers[7] = 17
实现相同功能的一种更“Pythonic”(在 Python 世界中更常见和更符合习惯用法)的方法是使用 enumerate 函数:
for i, n in enumerate(numbers):
print("numbers[%d] = %d" % (i, n))
在此代码中,i 变量将获取索引,而 n 变量将获取列表中的相应元素。此代码将产生与上一个循环完全相同的输出。
要将多个列表连接成一个,我们可以使用加号运算符。例如,考虑两个列表:
list1 = [1, 2, 3, 5]
list2 = [7, 11, 13, 17]
我们可以通过连接两个列表来创建一个包含 list1 中所有元素后跟 list2 中元素的第三个列表:
new_list = list1 + list2
这将产生以下列表:
[1, 2, 3, 5, 7, 11, 13, 17]
最后,也可以创建一个列表的列表:一个其元素也是列表的列表。为了展示这一点,让我们首先创建三个包含数字的列表:
list1 = [1, 2, 3, 5]
list2 = [7, 11, 13, 17]
list3 = [19, 23, 27, 29]
现在,让我们将它们组合成另一个列表:
lists = [list1, list2, list3]
现在 lists 是一个列表的列表。当我们使用 for 循环遍历它时,在每次迭代中我们都会得到一个列表:
for l in lists:
print(l)
这将产生以下输出:
[1, 2, 3, 5]
[7, 11, 13, 17]
[19, 23, 27, 29]
切片
Python 中另一个有用的概念是 切片——它用于获取列表的一部分。例如,让我们再次考虑数字列表:
numbers = [1, 2, 3, 5, 7]
如果我们想选择包含前三个元素的下标列表,我们可以使用冒号运算符(:)来指定选择范围:
top3 = numbers[0:3]
在这个例子中,0:3 表示“从索引 0 开始选择元素,直到索引 3(不包括 3)。”结果包含前三个元素:[1, 2, 3]。请注意,它选择了索引为 0、1 和 2 的元素,因此不包括 3。
如果我们想包含列表的开始部分,我们不需要指定范围中的第一个数字:
top3 = numbers[:3]
如果我们不指定范围中的第二个数字,我们将得到列表的剩余所有元素:
last3 = numbers[2:]
列表 last3 将包含最后三个元素:[3, 5, 7](图 B.2)。

图 B.2 使用冒号运算符选择列表的子列表
元组
我们在变量部分之前已经遇到过元组。元组也是集合;它们与列表非常相似。唯一的区别是它们是不可变的:一旦创建了一个元组,就不能更改元组的内容。
创建元组时使用括号:
numbers = (1, 2, 3, 5, 7, 11, 13)
与列表一样,我们可以通过索引获取值:
el = numbers[1]
print(el)
然而,我们无法更新元组中的值。当我们尝试这样做时,我们会得到一个错误:
numbers[1] = -2
如果我们尝试执行此代码,我们会得到
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
<ipython-input-15-9166360b9018> in <module>
----> 1 numbers[1] = -2
TypeError: 'tuple' object does not support item assignment
同样,我们无法向元组中添加新元素。但是,我们可以使用连接来实现相同的结果:
numbers = numbers + (17,)
在这里,我们创建一个新的元组,它包含旧数字,并将其与只包含一个数字(17)的另一个元组连接起来:17. 注意,我们需要添加一个逗号来创建一个元组;否则,Python 会将其视为一个简单的数字。
事实上,上一页的表达式等同于以下写法
numbers = (1, 2, 3, 5, 7, 11, 13) + (17,)
执行此操作后,我们得到一个新的包含新元素的元组,因此当打印时,我们得到
(1, 2, 3, 5, 7, 11, 13, 17)
集合
另一个有用的集合是集合:它是一个无序集合,只保留唯一元素。与列表不同,它不能包含重复项,并且也无法通过索引访问集合中的单个元素。
要创建一个集合,我们使用花括号:
numbers = {1, 2, 3, 5, 7, 11, 13}
注意:要创建一个空集合,我们需要使用set:
empty_set = set()
简单地放置空花括号将创建一个字典——这是一个我们在附录中稍后要讨论的集合:
empty_dict = {}
集合在检查集合是否包含一个元素时比列表更快。我们使用in运算符进行检查:
print(1 in numbers)
由于“1”在numbers集合中,这一行代码将打印True。
要向集合中添加一个元素,我们使用add方法:
numbers.add(17)
要遍历集合中的所有元素,我们再次使用for循环:
for n in numbers:
print(n)
当我们执行它时,它打印
1
2
3
5
7
11
13
17
字典
字典是 Python 中另一个极其有用的集合:我们使用它来构建键值映射。要创建字典,我们使用花括号,并使用冒号(:)分隔键和值:
words_to_numbers = {
'one': 1,
'two': 2,
'three': 3,
}
要通过键检索值,我们使用方括号:
print(words_to_numbers['one'])
如果某个元素不在字典中,Python 会引发异常:
print(words_to_numbers['five'])
当我们尝试执行它时,我们得到以下错误:
---------------------------------------------------------------------------
KeyError Traceback (most recent call last)
<ipython-input-38-66a309b8feb5> in <module>
----> 1 print(words_to_numbers['five'])
KeyError: 'five'
为了避免它,我们可以在尝试获取值之前先检查键是否在字典中。我们可以使用in语句进行检查:
if 'five' in words_to_numbers:
print(words_to_numbers['five'])
else:
print('not in the dictionary')
当运行此代码时,我们将在输出中看到not in the dictionary。
另一个选项是使用get方法。它不会引发异常,但如果键不在字典中,则返回None:
value = words_to_numbers.get('five')
print(value)
它将打印None。当使用get时,我们可以指定默认值,以防键不在字典中:
value = words_to_numbers.get('five', -1)
print(value)
在这种情况下,我们将得到-1。
要遍历字典中的所有键,我们使用keys方法的返回结果进行for循环:
for k in words_to_numbers.keys():
v = words_to_numbers[k]
print("%s: %d" % (k, v))
它将打印
one: 1
two: 2
three: 3
或者,我们可以直接使用items方法遍历字典中的键值对:
for k, v in words_to_numbers.items():
print("%s: %d" % (k, v))
它产生的输出与之前的代码完全相同。
列表推导
列表推导是 Python 中创建和过滤列表的特殊语法。让我们再次考虑一个包含数字的列表:
numbers = [1, 2, 3, 5, 7]
假设我们想要创建另一个列表,其中包含原始列表中所有元素的平方。为此,我们可以使用for循环:
squared = []
for n in numbers:
s = n * n
squared.append(s)
我们可以使用列表推导式简洁地重写此代码为单行:
squared = [n * n for n in numbers]
还可以在其中添加一个if条件,以仅处理满足条件的元素:
squared = [n * n for n in numbers if n > 3]
它等价于以下代码:
squared = []
for n in numbers:
if n > 3:
s = n * n
squared.append(s)
如果我们只需要应用过滤器并保持元素不变,我们也可以这样做:
filtered = [n for n in numbers if n > 3]
这等价于
filtered = []
for n in numbers:
if n > 3:
filtered.append(n)
也可以使用列表推导来创建具有稍不同语法的其他集合。例如,对于字典,我们在表达式周围放置花括号,并使用冒号来分隔键和值:
result = {k: v * 10 for (k, v) in words_to_numbers.items() if v % 2 == 0}
这是对以下代码的快捷方式:
result = {}
for (k, v) in words_to_numbers.items():
if v % 2 == 0:
result[k] = v * 10
警告 当学习列表推导时,可能会诱使你开始到处使用它。通常它最适合简单情况,但对于更复杂的情况,为了更好的代码可读性,应优先使用for循环而不是列表推导。如果有疑问,请使用for循环。
B.1.3 代码复用
在某个时候,当我们编写了大量代码时,我们需要考虑如何更好地组织它。我们可以通过将小块可重用代码放入函数或类中来实现这一点。让我们看看如何做到这一点。
函数
要创建一个函数,我们使用def关键字:
def function_name(arg1, arg2):
# body of the function
return 0
当我们想要退出函数并返回某个值时,我们使用return语句。如果我们简单地放置return而不带任何值或在函数体中不包括return,则函数将返回None。
例如,我们可以编写一个函数,该函数打印从 0 到指定数字的值:
def print_numbers(max): ❶
for i in range(max + 1): ❷
print(i)
❶ 创建一个带有一个参数的函数:max。
❷ 在函数内部使用 max 参数。
要调用此函数,只需在名称后添加括号内的参数:
print_numbers(10)
在调用函数时,也可以提供参数的名称:
print_numbers(max=10)
类
类比函数提供了更高层次的抽象:它们可以有一个内部状态和操作这个状态的方法。让我们考虑一个类,NumberPrinter,它和上一节中的函数做同样的事情——打印数字。
class NumberPrinter:
def __init__(self, max): ❶
self.max = max ❷
def print_numbers(self): ❸
for i in range(self.max + 1): ❹
print(i)
❶ 类初始化器
❷ 将 max 参数分配给 max 字段。
❸ 类的方法
❹ 在调用方法时使用内部状态。
在此代码中,__init__是初始化器。每次我们想要创建类的实例时都会运行:
num_printer = NumberPrinter(max=10)
注意,在类内部,__init__方法有两个参数:self和max。所有方法的第一参数始终必须是self:这样我们可以在方法内部使用self来访问对象的状态。
然而,当我们稍后调用方法时,我们不需要向self参数传递任何内容:它对我们来说是隐藏的。因此,当我们对NumberPrinter对象的实例调用print_number方法时,我们只需简单地放置没有参数的空括号:
num_printer.print_numbers()
这段代码产生的输出和上一节中的函数相同。
导入代码
现在假设我们想要将一些代码放入一个单独的文件中。让我们创建一个名为 useful_code.py 的文件,并将其放置在笔记本所在的同一文件夹中。
使用编辑器打开此文件。在文件内部,我们可以放置我们刚刚创建的函数和类。这样,我们创建了一个名为useful_code的模块。要访问模块内部的函数和类,我们使用import语句导入它们:
import useful_code
一旦导入,我们就可以使用它:
num_printer = useful_code.NumberPrinter(max=10)
num_printer.print_numbers()
同样,我们也可以导入一个模块并给它一个短名。例如,如果我们想用uc代替useful_code,我们可以这样做:
import useful_code as uc
num_printer = uc.NumberPrinter(max=10)
num_printer.print_numbers()
这是在科学 Python 中一个非常常见的习语。像 NumPy 和 Pandas 这样的软件包通常使用较短的别名导入:
import numpy as np
import pandas as pd
最后,如果我们不想从模块中导入所有内容,我们可以使用from ... import语法选择确切要导入的内容:
from useful_code import NumberPrinter
num_printer = NumberPrinter(max=10)
num_printer.print_numbers()
B.1.4 安装库
我们可以将我们的代码放入对每个人可用的软件包中。例如,NumPy 或 Pandas 就是这样的软件包。它们已经在 Anaconda 发行版中可用,但通常它们不会与 Python 预先安装。
要安装这样的外部软件包,我们可以使用内置的软件包安装器 pip。要使用它,打开您的终端并执行那里的pip install命令:
pip install numpy scipy pandas
在安装命令之后,我们列出我们想要安装的软件包。在安装时,也可以指定每个软件包的版本:
pip install numpy==1.16.5 scipy==1.3.1 pandas==0.25.1
当我们已经有了一个软件包,但它已经过时,我们想要更新它时,我们需要运行带有-U标志的pip install:
pip install -U numpy
最后,如果我们想要删除一个软件包,我们使用pip uninstall:
pip uninstall numpy
B.1.5 Python 程序
要执行 Python 代码,我们可以简单地调用 Python 解释器并指定我们想要执行的文件。例如,要运行useful_code.py脚本中的代码,在命令行中执行以下命令:
python useful_code.py
当我们执行它时,没有任何事情发生:我们只是在其中声明了一个函数和一个类,实际上并没有使用它们。为了看到一些结果,我们需要在文件中添加几行代码。例如,我们可以添加以下内容:
num_printer = NumberPrinter(max=10)
num_printer.print_numbers()
现在我们执行这个文件时,我们看到NumberPrinter打印的数字。
然而,当我们导入一个模块时,Python 内部会执行模块内的所有内容。这意味着下次我们在笔记本中执行import useful_code时,我们会看到那里打印的数字。
为了避免这种情况,我们可以告诉 Python 解释器某些代码只有在作为脚本执行时才需要运行,而不是导入。为了实现这一点,我们将我们的代码放在以下构造中:
if __name__ == "__main__":
num_printer = NumberPrinter(max=10)
num_printer.print_numbers()
最后,我们也可以在运行 Python 脚本时传递参数:
import sys
# declarations of print_numbers and NumberPrinter
if __name__ == "__main__":
max_number = int(sys.argv[1]) ❶
num_printer = NumberPrinter(max=max_number) ❷
num_printer.print_numbers()
❶ 将参数解析为整数:默认情况下,它是一个字符串。
❷ 将解析的参数传递给 NumberPrinter 实例。
现在我们可以使用自定义参数运行脚本:
python useful_code.py 5
结果,我们会看到从0到5的数字:
0
1
2
3
4
5
附录 C. NumPy 简介
我们不期望读者有任何 NumPy 知识,并试图在前进的过程中将所有必要的信息放入章节中。然而,因为本书的目的是教授机器学习而不是 NumPy,所以我们无法在章节中详细涵盖所有内容。这就是附录的重点:在一个集中的地方概述 NumPy 最重要的概念。
除了介绍 NumPy 外,附录还涵盖了一些对机器学习有用的线性代数知识,包括矩阵和向量乘法、矩阵逆和正则方程。
NumPy 是一个 Python 库,所以如果你还不熟悉 Python,请查看附录 B。
C.1 NumPy
NumPy 代表数值 Python——它是一个用于数值操作的 Python 库。NumPy 在 Python 机器学习生态系统中扮演着核心角色:Python 中的几乎所有库都依赖于它。例如,Pandas、Scikit-learn 和 TensorFlow 都依赖于 NumPy 进行数值运算。
NumPy 在 Anaconda 的 NumPy 发行版中预先安装,所以如果你使用它,你不需要做任何额外的事情。但是如果你不使用 Anaconda,使用pip安装 NumPy 相当简单:
pip install numpy
要实验 NumPy,让我们创建一个新的 Jupyter Notebook,并将其命名为 appendix-c-numpy。
要使用 NumPy,我们需要导入它。这就是为什么在第一个单元中我们写下
import numpy as np
在科学 Python 社区中,导入 NumPy 时使用别名是很常见的。这就是为什么我们在安装代码中添加了as np。这允许我们在代码中使用np而不是numpy。
我们将从 NumPy 的核心数据结构:NumPy 数组开始探索。
C.1.1 NumPy 数组
NumPy 数组类似于 Python 列表,但它们在机器学习等数值计算任务上进行了更好的优化。
要创建一个预定义大小且填充零的数组,我们使用np.zeros函数:
zeros = np.zeros(10)
这创建了一个包含 10 个零元素的数组(图 C.1)。

图 C.1 创建一个长度为 10 且填充零的 NumPy 数组
同样,我们可以使用np.ones函数创建一个包含 1 的数组:
ones = np.ones(10)
它的工作方式与 zeros 完全相同,只是元素是 1。
这两个函数都是更通用函数的快捷方式:np.full。它创建一个特定大小的数组,并用指定的元素填充。例如,要创建一个大小为 10 且填充零的数组,我们执行以下操作:
array = np.full(10, 0.0)
我们可以使用np.repeat函数达到相同的结果:
array = np.repeat(0.0, 10)
这段代码产生的结果与早期代码(图 C.2)相同。

图 C.2 要创建一个填充特定数字的数组,请使用np.full或np.repeat。
尽管在这个例子中两个函数都产生了相同的代码,但np.repeat实际上更强大。例如,我们可以使用它创建一个多个元素依次重复的数组:
array = np.repeat([0.0, 1.0], 5)
它创建了一个大小为 10 的数组,其中数字 0 重复了五次,然后数字 1 重复了五次(图 C.3):
array([0., 0., 0., 0., 0., 1., 1., 1., 1., 1.])

图 C.3 np.repeat函数比np.full更灵活:它可以通过重复多个元素来创建数组。
我们甚至可以更加灵活,并指定每个元素应该重复多少次:
array = np.repeat([0.0, 1.0], [2, 3])
在这种情况下,0.0 重复两次,1.0 重复三次:
array([0., 0., 1., 1., 1.])
就像列表一样,我们可以使用方括号访问数组的元素:
el = array[1]
print(el)
这段代码打印 0.0。
与通常的 Python 列表不同,我们可以通过使用方括号中的索引列表同时访问数组的多个元素:
print(array[[4, 2, 0]])
结果是另一个大小为 3 的数组,包含原始数组中通过 4、2 和 0 分别索引的元素:
[1., 1., 0.]
我们还可以使用方括号更新数组的元素:
array[1] = 1
print(array)
因为我们将索引 1 处的元素从 0 改为 1,所以它打印以下内容:
[0\. 1\. 1\. 1\. 1.]
如果我们已经有了一个包含数字的列表,我们可以使用np.array将其转换为 NumPy 数组:
elements = [1, 2, 3, 4]
array = np.array(elements)
现在array是一个大小为 4 的 NumPy 数组,其元素与原始列表相同:
array([1, 2, 3, 4])
另一个用于创建 NumPy 数组的非常有用的函数是np.arange。它是 Python 的range的 NumPy 等价物:
np.arange(10)
它创建了一个长度为 10 的数组,包含从 0 到 9 的数字,并且像标准 Python 的range一样,10 不包括在数组中:
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
经常我们需要创建一个大小一定的数组,并用介于某个数字x和某个数字y之间的数字填充。例如,想象我们需要创建一个包含从 0 到 1 的数字的数组:
0.0, 0.1, 0.2, ..., 0.9, 1.0
我们可以使用np.linspace:
thresholds = np.linspace(0, 1, 11)
此函数有三个参数:
-
起始数字:在我们的例子中,我们希望从 0 开始。
-
最后一个数字:我们希望以 1 结束。
-
结果数组的长度:在我们的例子中,我们希望在数组中有 11 个数字。
此代码生成从 0 到 1(图 C.4)的 11 个数字。

图 C.4 NumPy 的linspace函数生成一个指定长度的序列(11),从 0 开始,到 1 结束。
Python 列表通常可以包含任何类型的元素。对于 NumPy 数组来说情况并非如此:数组中的所有元素必须具有相同的类型。这些类型被称为dtype。
有四种广泛的 dtype 类别:
-
无符号整数(uint):始终为正数(或零)的整数
-
有符号整数(int):可以是正数和负数的整数
-
浮点数(float):实数
-
布尔值(bool):只有 True 和 False 值
每种 dtype 都有多种变体,这取决于在内存中表示值所使用的位数。
对于 uint,我们有四种类型:uint8、uint16、uint32和uint64,分别具有 8、16、32 和 64 位大小。同样,我们有四种 int 类型:int8、int16、int32和int64。我们使用的位数越多,可以存储的数字就越大(表 C.1)。
表 C.1 三种常见的 NumPy dtype:uint、int 和 float。每种 dtype 都有多种大小从 8 到 64 位的变体。链接
| 大小(位) | 无符号整型 | 整型 | 浮点型 |
|---|---|---|---|
| 8 | 0 .. 2⁸ – 1 | –2⁷ .. 2⁷ – 1 | – |
| 16 | 0 .. 2¹⁶ – 1 | –2¹⁵ .. 2¹⁵ – 1 | 半精度 |
| 32 | 0 .. 2³² – 1 | –2³¹ .. 2³¹ – 1 | 单精度 |
| 64 | 0 .. 2⁶⁴ – 1 | –2⁶³ .. 2⁶³ – 1 | 双精度 |
对于浮点数,我们有三种类型:float16、float32和float64。我们使用的位数越多,浮点数就越精确。
你可以在官方文档中查看不同数据类型的完整列表(docs.scipy.org/doc/numpy-1.13.0/user/basics.types.html)。
注意 在 NumPy 中,默认的浮点数据类型是float64,每个数字使用 64 位(8 字节)。对于大多数机器学习应用,我们不需要这样的精度,可以通过使用float32而不是float64来减少内存占用两倍。
在创建数组时,我们可以指定数据类型。例如,当使用np.zeros和np.ones时,默认数据类型是float64。我们可以在创建数组时指定数据类型(图 C.5):
zeros = np.zeros(10, dtype=np.uint8)

图 C.5 我们可以在创建数组时指定数据类型。
当我们有一个整数数组并赋值一个超出范围的数字时,数字会被截断:只保留最低有效位。
例如,假设我们使用我们刚刚创建的uint8数组zeros。因为数据类型是uint8,它能够存储的最大数字是 255。让我们尝试将 300 赋值给数组的第一个元素:
zeros[0] = 300
print(zeros[0])
因为 300 大于 255,所以只保留最低有效位,所以这段代码打印出 44。
警告 在选择数组的数据类型时要小心。如果你不小心选择了一个过窄的数据类型,当你输入一个大的数字时,NumPy 不会警告你。它将简单地截断它们。
遍历数组中的所有元素与列表类似。我们只需使用一个for循环:
for i in np.arange(5):
print(i)
这段代码打印从 0 到 4 的数字:
0
1
2
3
4
C.1.2 二维 NumPy 数组
到目前为止,我们已经介绍了 NumPy 的一维数组。我们可以将这些数组视为向量。然而,对于机器学习应用,仅仅有向量是不够的:我们还需要矩阵。
在纯 Python 中,我们会使用列表的列表来表示。在 NumPy 中,等价的是二维数组。
要创建一个全为零的二维数组,我们只需在调用np.zeros时使用一个元组而不是一个数字:
zeros = np.zeros((5, 2), dtype=np.float32)
我们使用元组(5, 2),因此它创建了一个有五行两列的全零数组(图 C.6)。

图 C.6 要创建一个二维数组,使用包含两个元素的元组。第一个元素指定行数,第二个元素指定列数。
同样地,我们可以使用np.ones或np.fill——而不是一个单独的数字,我们放入一个元组。
数组的维度称为 形状。这是传递给 np.zeros 函数的第一个参数:它指定数组将有多少行和列。要获取数组的形状,使用 shape 属性:
print(zeros.shape)
当我们执行它时,我们看到 (5, 2)。
可以将列表的列表转换为 NumPy 数组。与通常的数字列表一样,只需使用 np.array 即可:
numbers = [ ❶
[1, 2, 3],
[4, 5, 6],
[7, 8, 9]
]
numbers = np.array(numbers) ❷
❶ 创建一个列表的列表
❷ 将列表转换为二维数组
执行此代码后,numbers 变成了一个形状为 (3, 3) 的 NumPy 数组。当我们打印它时,我们得到
array([[1, 2, 3],
[4, 5, 6],
[7, 8, 9]])
要访问二维数组的一个元素,我们需要在括号内使用两个数字:
print(numbers[0, 1])
这段代码将访问索引为 0 的行和索引为 1 的列。所以它将打印 2。
与一维数组一样,我们使用赋值运算符 (=) 来改变二维数组的一个单独的值:
numbers[0, 1] = 10
当我们执行它时,数组的内容会改变:
array([[ 1, 10, 3],
[ 4, 5, 6],
[ 7, 8, 9]])
如果我们只放一个数字而不是两个,我们将得到整个行,这是一个一维的 NumPy 数组:
numbers[0]
这段代码返回索引为 0 的整个行:
array([1 2 3])
要访问二维数组的一列,我们使用冒号 (😃 而不是第一个元素。像行一样,结果也是一个一维的 NumPy 数组:
numbers[:, 1]
当我们执行它时,我们看到整个列:
array([2 5 8])
也可以使用赋值运算符覆盖整个行或列的内容。例如,假设我们想要替换矩阵中的一行:
numbers[1] = [1, 1, 1]
这导致以下变化:
array([[ 1, 10, 3],
[ 1, 1, 1],
[ 7, 8, 9]])
同样,我们可以替换整个列的内容:
numbers[:, 2] = [9, 9, 9]
因此,最后一列改变了:
array([[ 1, 10, 9],
[ 1, 1, 9],
[ 7, 8, 9]])
C.1.3 随机生成的数组
通常,生成填充随机数的数组很有用。在 NumPy 中,我们使用 np.random 模块来完成此操作。
例如,要生成一个 5 × 2 的随机数数组,这些数在 0 和 1 之间均匀分布,使用 np.random.rand:
arr = np.random.rand(5, 2)
当我们运行它时,它生成一个看起来像这样的数组:
array([[0.64814431, 0.51283823],
[0.40306102, 0.59236807],
[0.94772704, 0.05777113],
[0.32034757, 0.15150334],
[0.10377917, 0.68786012]])
每次我们运行代码,它都会生成不同的结果。有时我们需要结果可重复,这意味着如果我们稍后想要执行此代码,我们将得到相同的结果。为了实现这一点,我们可以设置随机数生成器的种子。一旦设置了种子,随机数生成器每次运行代码时都会产生相同的序列:
np.random.seed(2)
arr = np.random.rand(5, 2)
在 Ubuntu Linux 版本 18.04 和 NumPy 版本 1.17.2 上,它生成以下数组:
array([[0.4359949 , 0.02592623],
[0.54966248, 0.43532239],
[0.4203678 , 0.33033482],
[0.20464863, 0.61927097],
[0.29965467, 0.26682728]])
无论我们重新执行这个单元格多少次,结果都是相同的。
警告:固定随机数生成器的种子可以保证在相同的操作系统和相同的 NumPy 版本下执行时生成器产生相同的结果。然而,没有保证更新 NumPy 版本不会影响可重复性:版本的变化可能会导致随机数生成器算法的变化,这可能导致不同版本的结果不同。
如果我们想要从标准正态分布中采样,而不是均匀分布,我们使用 np.random.randn:
arr = np.random.randn(5, 2)
注意:每次我们在附录中生成随机数组时,我们都会确保在生成之前固定种子数字,即使我们没有在代码中明确指定它——我们这样做是为了确保一致性。我们使用 2 作为种子。这个数字没有特别的原因。
要生成介于 0 和 100(不包括 100)之间的均匀分布随机整数,我们可以使用 np.random.randint:
randint = np.random.randint(low=0, high=100, size=(5, 2))
当执行代码时,我们得到一个 5 × 2 的整数 NumPy 数组:
array([[40, 15],
[72, 22],
[43, 82],
[75, 7],
[34, 49]])
另一个非常有用的功能是打乱数组——以随机顺序重新排列数组的元素。例如,让我们创建一个范围数组并对其进行打乱:
idx = np.arange(5)
print('before shuffle', idx)
np.random.shuffle(idx)
print('after shuffle', idx)
当我们运行代码时,我们看到以下内容:
before shuffle [0 1 2 3 4]
after shuffle [2 3 0 4 1]
C.2 NumPy 操作
NumPy 附带了一系列与 NumPy 数组一起工作的操作。在本节中,我们将介绍本书中需要用到的操作。
C.2.1 逐元素操作
NumPy 数组支持所有算术运算:加法(+)、减法(–)、乘法(*)、除法(/)以及其他。
为了说明这些操作,我们首先使用 arange 创建一个数组:
rng = np.arange(5)
这个数组包含从 0 到 4 的五个元素:
array([0, 1, 2, 3, 4])
要将数组中的每个元素乘以 2,我们只需使用乘法运算符 (*):
rng * 2
结果,我们得到一个新数组,其中每个原始数组的元素都乘以 2:
array([0, 2, 4, 6, 8])
注意,我们不需要显式地编写任何循环来对每个元素单独应用乘法操作:NumPy 为我们做了。我们可以说乘法操作是 逐元素 应用的——一次应用到所有元素。加法(+)、减法(–)和除法(/)操作也是逐元素的,不需要显式循环。
这种逐元素操作通常被称为 向量化的:for 循环在本地代码(用 C 和 Fortran 编写)中内部执行,因此操作非常快!
注意:尽可能使用 NumPy 的向量化操作而不是循环:它们总是快得多。
在之前的代码中,我们只使用了一个操作。在一个表达式中可以同时应用多个操作:
(rng - 1) * 3 / 2 + 1
这段代码创建了一个包含结果的新数组:
array([-0.5, 1\. , 2.5, 4\. , 5.5])
注意,原始数组包含整数,但由于我们使用了除法操作,结果是一个浮点数数组。
之前,我们的代码涉及一个数组和简单的 Python 数字。如果两个数组具有相同的形状,也可以对它们进行逐元素操作。
例如,假设我们有两个数组,一个包含从 0 到 4 的数字,另一个包含一些随机噪声:
noise = 0.01 * np.random.rand(5)
numbers = np.arange(5)
我们有时需要这样做来模拟不理想的真实数据:在现实中,收集数据时总是存在不完美,我们可以通过添加噪声来模拟这些不完美。
我们通过首先生成 0 到 1 之间的数字,然后将它们乘以 0.01 来构建 noise 数组。这实际上生成了介于 0 和 0.01 之间的随机数:
array([0.00435995, 0.00025926, 0.00549662, 0.00435322, 0.00420368])
然后,我们可以将这两个数组相加,得到一个包含总和的第三个数组:
result = numbers + noise
在此数组中,结果中的每个元素是两个其他数组相应元素的总和:
array([0.00435995, 1.00025926, 2.00549662, 3.00435322, 4.00420368])
我们可以使用round方法将数字四舍五入到任何精度:
result.round(4)
它也是一个逐元素操作,因此它一次性应用于所有元素,并将数字四舍五入到第四位:
array([0.0044, 1.0003, 2.0055, 3.0044, 4.0042])
有时我们需要对数组的所有元素进行平方。为此,我们可以简单地乘以数组自身。让我们首先生成一个数组:
pred = np.random.rand(3).round(2)
此数组包含三个随机数:
array([0.44, 0.03, 0.55])
现在,我们可以将它乘以自身:
square = pred * pred
因此,我们得到一个新的数组,其中每个元素都是原始数组元素的平方:
array([0.1936, 0.0009, 0.3025])
或者,我们可以使用幂运算符(**):
square = pred ** 2
这两种方法都会得到相同的结果(图 C.7)。

图 C.7 有两种方法可以平方数组的元素:将数组与自身相乘或使用幂运算(**)。
对于机器学习应用,我们可能需要的一些其他有用的逐元素操作包括指数、对数和平方根:
pred_exp = np.exp(pred) ❶
pred_log = np.log(pred) ❷
pred_sqrt = np.sqrt(pred) ❸
❶ 计算指数
❷ 计算对数
❸ 计算平方根
布尔操作也可以逐元素应用于 NumPy 数组。为了说明它们,让我们再次生成一个包含一些随机数的数组:
pred = np.random.rand(3).round(2)
此数组包含以下数字:
array([0.44, 0.03, 0.55])
我们可以看到大于 0.5 的元素:
result = pred >= 0.5
因此,我们得到一个包含三个布尔值的数组:
array([False, False, True])
我们知道原始数组的最后一个元素大于 0.5,所以它是True,其余都是False。
与算术运算一样,我们可以在形状相同的两个 NumPy 数组上应用布尔操作。让我们生成两个随机数组:
pred1 = np.random.rand(3).round(2)
pred2 = np.random.rand(3).round(2)
数组具有以下值:
array([0.44, 0.03, 0.55])
array([0.44, 0.42, 0.33])
现在我们可以使用大于等于运算符(>=)来比较这些数组的值:
pred1 >= pred2
因此,我们得到一个包含布尔值的数组(图 C.8):
array([ True, False, True])

图 C.8 NumPy 中的布尔操作是逐元素的,并且可以应用于形状相同的两个数组以比较值。
最后,我们可以将逻辑运算(如逻辑与(&)和逻辑或(|))应用于布尔 NumPy 数组。让我们再次生成两个随机数组:
pred1 = np.random.rand(5) >= 0.3
pred2 = np.random.rand(5) >= 0.4
生成的数组具有以下值:
array([ True, False, True])
array([ True, True, False])
与算术运算一样,逻辑运算符也是逐元素进行的。例如,为了计算逐元素与,我们只需使用数组中的&运算符(图 C.9):
res_and = pred1 & pred2
因此,我们得到
array([ True, False, False])
逻辑或以相同的方式工作(图 C.9):
res_or = pred1 | pred2

图 C.9 逻辑运算,如逻辑与和逻辑或,也可以逐元素应用。
这将创建以下数组:
array([ True, True, True])
C.2.2 汇总操作
而逐元素操作接受一个数组并产生一个形状相同的数组,而汇总操作接受一个数组并产生一个单一数字。
例如,我们可以生成一个数组,然后计算所有元素的总和:
pred = np.random.rand(3).round(2)
pred_sum = pred.sum()
在这个例子中,pred是
array([0.44, 0.03, 0.55])
然后pred_sum是所有三个元素的总和,即 1.02:
0.44 + 0.03 + 0.55 = 1.02
其他汇总操作包括 min、mean、max 和 std:
print('min = %.2f' % pred.min())
print('mean = %.2f' % pred.mean())
print('max = %.2f' % pred.max())
print('std = %.2f' % pred.std())
运行此代码后,它产生
min = 0.03
mean = 0.34
max = 0.55
std = 0.22
当我们有一个二维数组时,汇总操作也会产生一个数字。然而,我们也可以单独将这些操作应用于行或列。
例如,让我们生成一个 4 × 3 的数组:
matrix = np.random.rand(4, 3).round(2)
这将生成一个数组:
array([[0.44, 0.03, 0.55],
[0.44, 0.42, 0.33],
[0.2 , 0.62, 0.3 ],
[0.27, 0.62, 0.53]])
当我们调用 max 方法时,它返回一个数字:
matrix.max()
结果是 0.62,这是矩阵所有元素中的最大数。

图 C.10 我们可以指定应用操作的轴:axis=1 表示应用于行,axis=0 表示应用于列。
如果我们现在想要找到每行的最大数,我们可以使用 max 方法并指定应用此操作的轴。当我们想要对行进行操作时,我们使用 axis=1(图 C.10):
matrix.max(axis=1)
因此,我们得到一个包含四个数字的数组——每行的最大数:
array([0.55, 0.44, 0.62, 0.62])
同样,我们也可以找到每列的最大数。为此,我们使用 axis=0:
matrix.max(axis=0)
这次结果是三个数字——每列的最大数:
array([0.44, 0.62, 0.55])
其他操作——sum、min、mean、std 以及许多其他操作——也可以接受 axis 作为参数。例如,我们可以轻松计算每行的元素总和:
matrix.sum(axis=1)
执行时,我们得到四个数字:
array([1.02, 1.19, 1.12, 1.42])
C.2.3 排序
经常我们需要对数组的元素进行排序。让我们看看如何在 NumPy 中进行操作。首先,让我们生成一个包含四个元素的数组:
pred = np.random.rand(4).round(2)
我们生成的数组包含以下元素:
array([0.44, 0.03, 0.55, 0.44])
要创建数组的排序副本,请使用 np.sort:
np.sort(pred)
它返回一个包含所有元素排序的数组:
array([0.03, 0.44, 0.44, 0.55])
因为它创建了一个副本并对其进行排序,所以原始数组 pred 保持不变。
如果我们想要就地排序数组的元素而不创建另一个数组,我们可以在数组本身上调用 sort 方法:
pred.sort()
现在,数组 pred 已经排序。
当涉及到排序时,我们还有另一个有用的工具:argsort。它不是对数组进行排序,而是返回排序后数组中的索引(图 C.11):
idx = pred.argsort()

图 C.11 sort 函数对数组进行排序,而 argsort 生成一个索引数组,该数组可以排序数组。
现在,数组 idx 包含排序顺序的索引:
array([1, 0, 3, 2])
现在,我们可以使用带索引的数组 idx 来获取排序后的原始数组:
pred[idx]
如我们所见,它确实已经排序:
array([0.03, 0.44, 0.44, 0.55])
C.2.4 重新塑形和组合
每个 NumPy 数组都有一个形状,它指定了它的大小。对于一维数组,它是数组的长度,对于二维数组,它是行数和列数。我们已经知道,我们可以通过使用 shape 属性来访问数组的形状:
rng = np.arange(12)
rng.shape
rng 的形状是 (12),这意味着它是一个长度为 12 的一维数组。因为我们使用了 np.arange 来创建数组,它包含从 0 到 11(包含)的数字:
array([ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
将数组的形状从一维转换为二维是可能的。我们使用 reshape 方法来完成:
rng.reshape(4, 3)
结果,我们得到一个四行三列的矩阵:
array([[ 0, 1, 2],
[ 3, 4, 5],
[ 6, 7, 8],
[ 9, 10, 11]])
重塑之所以成功,是因为可以将 12 个原始元素重新排列成四行三列。换句话说,元素的总数没有改变。然而,如果我们尝试将其重塑为(4, 4),它将不允许我们这样做:
rng.reshape(4, 4)
当我们这样做时,NumPy 会引发一个ValueError:
---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
<ipython-input-176-880fb98fa9c8> in <module>
----> 1 rng.reshape(4, 4)
ValueError: cannot reshape array of size 12 into shape (4,4)
有时我们需要通过将多个数组组合在一起来创建一个新的 NumPy 数组。让我们看看如何做。
首先,我们创建两个数组,我们将使用它们来演示:
vec = np.arange(3)
mat = np.arange(6).reshape(3, 2)
第一个,vec,是一个包含三个元素的向量:
array([0, 1, 2])
第二个,mat,是一个二维数组,有三行两列:
array([[0, 1],
[2, 3],
[4, 5]])
将两个 NumPy 数组组合在一起的最简单方法是使用np.concatenate函数:
np.concatenate([vec, vec])
它接受一个一维数组列表并将它们组合成一个更大的数组。在我们的例子中,我们传递了两次vec,因此结果是一个长度为六的数组:
array([0, 1, 2, 0, 1, 2])
我们可以使用np.hstack(水平堆叠的简称)达到相同的结果:
np.hstack([vec, vec])
它再次接受一个数组列表并将它们水平堆叠,产生一个更大的数组:
array([0, 1, 2, 0, 1, 2])
我们也可以将np.hstack应用于二维数组:
np.hstack([mat, mat])
结果是另一个矩阵,其中原始矩阵通过列水平堆叠:
array([[0, 1, 0, 1],
[2, 3, 2, 3],
[4, 5, 4, 5]])
然而,对于二维数组,np.concatenate的工作方式与np.hstack不同:
np.concatenate([mat, mat])
当我们将np.concatenate应用于矩阵时,它将它们垂直堆叠,而不是像一维数组那样水平堆叠,创建一个具有六行的新矩阵:
array([[0, 1],
[2, 3],
[4, 5],
[0, 1],
[2, 3],
[4, 5]])
结合 NumPy 数组的另一种有用方法是np.column_stack:它允许我们将向量和矩阵堆叠在一起。例如,假设我们想要给我们的矩阵添加一个额外的列。为此,我们只需传递一个包含向量和矩阵的列表:
np.column_stack([vec, mat])
结果,我们得到一个新的矩阵,其中vec成为第一列,其余的mat跟在后面:
array([[0, 0, 1],
[1, 2, 3],
[2, 4, 5]])
我们可以将np.column_stack应用于两个向量:
np.column_stack([vec, vec])
结果是一个两列的矩阵:
array([[0, 0],
[1, 1],
[2, 2]])
与np.hstack类似,它水平堆叠数组,存在np.vstack,它垂直堆叠数组:
np.vstack([vec, vec])
当我们垂直堆叠两个向量时,我们得到一个有两行的矩阵:
array([[0, 1, 2],
[0, 1, 2]])
我们也可以垂直堆叠两个矩阵:
np.vstack([mat, mat])
结果与np.concatenate([mat, mat])相同——我们得到一个具有六行的新矩阵:
array([[0, 1],
[2, 3],
[4, 5],
[0, 1],
[2, 3],
[4, 5]])
np.vstack函数也可以将向量和矩阵堆叠在一起,实际上创建了一个具有新行的矩阵:
np.vstack([vec, mat.T])
当我们这样做时,vec成为新矩阵的第一行:
array([[0, 1, 2],
[0, 2, 4],
[1, 3, 5]])
注意,在这段代码中,我们使用了mat的T属性。这是一个矩阵转置操作,它将矩阵的行转换为列:
mat.T
原始的mat具有以下数据:
array([[0, 1],
[2, 3],
[4, 5]])
经过转置后,原本是列的变成了行:
array([[0, 2, 4],
[1, 3, 5]])
C.2.5 切片和过滤
与 Python 列表类似,我们也可以使用切片来访问 NumPy 数组的一部分。例如,假设我们有一个 5 × 3 的矩阵:
mat = np.arange(15).reshape(5, 3)
这个矩阵有五行三列:
array([[ 0, 1, 2],
[ 3, 4, 5],
[ 6, 7, 8],
[ 9, 10, 11],
[12, 13, 14]])
我们可以通过使用切片操作来访问矩阵的某些部分。例如,我们可以使用范围操作符 (😃 获取第一行自由行:
mat[:3]
它返回索引为 0、1 和 2 的行(不包括 3):
array([[0, 1, 2],
[3, 4, 5],
[6, 7, 8]])
如果我们只需要第 1 行和第 2 行,我们指定范围的开头和结尾:
mat[1:3]
这给我们所需的行:
array([[3, 4, 5],
[6, 7, 8]])
与行类似,我们也可以只选择一些列;例如,前两列:
mat[:, :2]
这里我们有两个范围:
-
第一个是一个简单的冒号 (😃,没有起始和结束,这意味着“包含所有行。”
-
第二个是一个包括列 0 和 1(不包括列 2)的范围。
因此,我们得到
array([[ 0, 1],
[ 3, 4],
[ 6, 7],
[ 9, 10],
[12, 13]])
当然,我们可以将两者结合起来,选择任何我们想要的矩阵部分:
mat[1:3, :2]
这给我们第 1 行和第 2 行以及第 0 列和第 1 列:
array([[3, 4],
[6, 7]])
如果我们不需要范围,而是需要一些特定的行或列,我们可以简单地提供一个索引列表:
mat[[3, 0, 1]]
这给我们三个索引为 3、0 和 1 的行:
array([[ 9, 10, 11],
[ 0, 1, 2],
[ 3, 4, 5]])
而不是使用单个索引,我们可以使用二进制掩码来指定要选择哪些行。例如,假设我们想要选择行中第一个元素是奇数的行。
要检查第一个元素是否为奇数,我们需要做以下操作:
-
选择矩阵的第一列。
-
将模 2 操作 (%) 应用于所有元素以计算除以 2 的余数。
-
如果余数是 1,则该数字是奇数,如果是 0,则该数字是偶数。
这对应于以下 NumPy 表达式:
mat[:, 0] % 2 == 1
最后,它产生一个布尔数组:
array([False, True, False, True, False])
我们可以看到,对于第 1 行和第 3 行表达式是 True,而对于第 0 行、第 2 行和第 5 行是 False。
现在,我们可以使用这个表达式来选择只包含 True 的行:
mat[mat[:, 0] % 2 == 1]
这给我们一个只有两行的矩阵:第 1 行和第 3 行:
array([[ 3, 4, 5],
[ 9, 10, 11]])
C.3 线性代数
NumPy 非常受欢迎的原因之一是它支持线性代数操作。NumPy 将所有内部计算委托给 BLAS 和 LAPACK——经过时间考验的用于高效低级计算的库——这就是为什么它如此之快。
在本节中,我们简要概述了本书中需要的线性代数操作。我们首先从最常见的开始:矩阵和向量乘法。
C.3.1 乘法
在线性代数中,我们有多种乘法类型:
-
向量-向量乘法:向量乘以另一个向量
-
矩阵-向量乘法:矩阵乘以向量
-
矩阵-矩阵乘法:矩阵乘以另一个矩阵
让我们更仔细地看看每一个,并看看如何在 NumPy 中实现它们。
向量-向量乘法
向量-向量乘法涉及两个向量。它通常被称为 点积 或 标量积;它接受两个向量并产生一个 标量——一个单一的数字。
假设我们有两个向量,u 和 v,每个向量的长度为 n;那么 u 和 v 之间的点积是

注意:在本附录中,长度为 n 的向量的元素从 0 到 n–1 编号:这样更容易将数学符号的概念映射到 NumPy。
这直接转换为 Python。如果我们有两个 NumPy 数组 u 和 v,它们之间的点积是
dot = 0
for i in range(n):
dot = u[i] * v[i]
当然,我们可以利用 NumPy 中的向量运算功能,用一行表达式来计算它:
(u * v).sum()
然而,由于这是一个相当常见的操作,它被实现为 NumPy 中的 dot 方法。因此,为了计算点积,我们只需调用 dot:
u.dot(v)
矩阵-向量乘法
另一种乘法类型是矩阵-向量乘法。
假设我们有一个大小为 m×n 的矩阵 X 和一个大小为 n 的向量 u。如果我们用 X 乘以 u,我们得到另一个大小为 m 的向量(图 C.12):


图 C.12 当我们将一个 4×3 矩阵乘以一个长度为 3 的向量时,我们得到一个长度为 4 的向量。
我们可以将矩阵 X 视为一组 n 个行向量 x[i],每个大小为 m(图 C.13)。

图 C.13 我们可以将矩阵 X 视为四个行向量 x[i],每个大小为 3。
然后,我们可以将矩阵-向量乘法 Xu 表示为 m 次向量-向量乘法,每次乘法都是矩阵的每一行 x[i] 与向量 u 之间的乘法。结果是另一个向量——向量 v(图 C.14)。

图 C.14 矩阵-向量乘法是一组向量-向量乘法:我们将矩阵 X 的每一行 x[i] 乘以向量 u,并得到向量 v。
将这个想法转换为 Python 是直接的:
v = np.zeros(m) ❶
for i in range(m): ❷
v[i] = X[i].dot(u) ❸
❶ 创建一个空向量 v
❷ 对于 X 的每一行 x[i]
❸ 计算向量 v 的第 i 个元素作为点积 x[i] * u
就像向量-向量乘法一样,我们可以使用矩阵 X(一个二维数组)的 dot 方法来乘以向量 u(一个一维数组):
v = X.dot(u)
结果是向量 v——一个一维的 NumPy 数组。
矩阵-矩阵乘法
最后,我们有一个矩阵-矩阵乘法。假设我们有两个矩阵,X 的大小为 m×n 和 U 的大小为 n×k。那么结果是另一个大小为 m×k 的矩阵 V(图 C.15):


图 C.15 当我们将一个 4×3 矩阵 X 乘以一个 3×2 矩阵 U 时,我们得到一个 4×2 矩阵 V。
理解矩阵-矩阵乘法最简单的方法是将 U 视为一组列:u[0], u[1], ..., u[k][-1](图 C.16)。

图 C.16 我们可以将 U 视为一组列向量。在这种情况下,我们有两个列:u[0] 和 u[1]。
然后矩阵-矩阵乘法 XU 是一系列矩阵-向量乘法 Xu[i]。每次乘法的结果是一个向量 v[i],它是结果矩阵 V 的第 i 列(图 C.17):


图 C.17 我们可以将矩阵-矩阵乘法 XU 视为一组矩阵-向量乘法 v[i] = Xu[i],其中 u[i]s 是 U 的列。结果是所有 v[i] 的堆叠组成的矩阵 V。
在 NumPy 中实现它,我们可以简单地这样做:
V = np.zeros((m, k)) ❶
for i in range(k): ❷
vi = X.dot(U[:, i]) ❸
V[:, i] = vi ❹
❶ 创建一个空矩阵 V
❷ 对于矩阵 U 的每一列 u[i]
❸ 计算 v[i] 作为矩阵-向量乘法 X * u[i]
❹ 将 v[i] 作为 V 的第 i 列传递
回想一下,U[:, i] 表示获取第 i 列。然后我们用 X 乘以该列,得到 vi。使用 V[:, i],并且因为我们有赋值(=),我们用 vi 覆盖 V 的第 i 列。
当然,在 NumPy 中有一个快捷方式——又是 dot 方法:
V = X.dot(U)
C.3.2 矩阵逆
方阵 X 的逆矩阵是矩阵 X^(–1),使得 X^(–1)X = I,其中 I 是单位矩阵。单位矩阵 I 在进行矩阵-向量乘法时不会改变向量:

我们为什么需要它?假设我们有一个系统:

我们知道矩阵 A 和结果向量 b,但不知道向量 x——我们想要找到它。换句话说,我们想要解这个系统。
做这件事的一种可能方式是
-
计算 A^(–1),即 A 的逆矩阵,然后
-
将方程的两边乘以逆矩阵 A^(–1)
在这样做的时候,我们得到

因为 A^(–1)A = I,所以我们有

或者

在 NumPy 中,为了计算逆矩阵,我们使用 np.linalg.inv:
A = np.array([
[0, 1, 2],
[1, 2, 3],
[2, 3, 3]
])
Ainv = np.linalg.inv(A)
对于这个特定的方阵 A,可以计算其逆矩阵,所以 Ainv 有以下值:
array([[-3., 3., -1.],
[ 3., -4., 2.],
[-1., 2., -1.]])
我们可以验证,如果我们用矩阵乘以其逆矩阵,我们得到单位矩阵:
A.dot(Ainv)
结果确实是单位矩阵:
array([[1., 0., 0.],
[0., 1., 0.],
[0., 0., 1.]])
注意:如果你只想解方程 Ax = b,那么你实际上不需要计算逆矩阵。从计算的角度来看,计算逆矩阵是一个昂贵的操作。相反,我们应该使用 np.linalg.solve,这要快得多:
b = np.array([1, 2, 3])
x = np.linalg.solve(A, b)
在这本书中,当计算线性回归的权重时,我们为了简单起见使用逆矩阵:这使得代码更容易理解。
有些矩阵没有逆矩阵。首先,非方阵无法求逆。此外,并非所有方阵都可以求逆:存在奇异矩阵——对于这些矩阵,不存在逆矩阵。
当我们尝试在 NumPy 中求一个奇异矩阵的逆时,我们会得到一个错误:
B = np.array([
[0, 1, 1],
[1, 2, 3],
[2, 3, 5]
])
np.linalg.inv(B)
这段代码会引发 LinAlgError:
---------------------------------------------------------------------------
LinAlgError Traceback (most recent call last)
<ipython-input-286-14528a9f848e> in <module>
5 ])
6
----> 7 np.linalg.inv(B)
<__array_function__ internals> in inv(*args, **kwargs)
<...>
LinAlgError: Singular matrix
C.3.3 正则方程
在第二章中,我们使用了正则方程来计算线性回归的权重向量。在本节中,我们简要概述了如何得到公式,但不深入细节。更多信息,请参阅任何线性代数教科书。
这一节可能看起来数学性很强,但请随意跳过:它不会影响你对本书的理解。如果你在大学里学习过正则方程和线性回归,但现在已经忘记了大部分内容,这一节应该能帮助你刷新记忆。
假设我们有一个包含观测的矩阵 X 和一个包含结果的向量 y。我们想要找到一个向量 w,使得

然而,因为 X 不是一个方阵,我们无法简单地求逆,这个系统的精确解不存在。我们可以尝试找到一个不精确的解,并执行以下技巧。我们将两边乘以 X 的转置:

现在 X^TX 是一个方阵,应该可以求逆。让我们称这个矩阵为 C:

该方程变为

在这个方程中,X^Ty 也是一个向量:当我们用一个矩阵乘以一个向量时,我们得到一个向量。让我们称它为 z。所以现在我们有

这个系统现在有一个精确解,这是我们最初想要解决的系统的最佳近似解。证明这一点超出了本书的范围,所以请参考教科书以获取更多细节。
为了解这个系统,我们可以求逆 C 并将两边乘以它:

或者

现在我们已经得到了 w 的解。让我们用原始的 X 和 y 来重新表示它:

这是正规方程,它找到原始系统 Xw = y 的最佳近似解 w。
将其转换为 NumPy 非常简单:
C = X.T.dot(X)
Cinv = np.linalg.inv(C)
w = Cinv.dot(X.T).dot(y)
现在数组 w 包含了该系统的最佳近似解。
附录 D. Pandas 简介
我们不期望本书的读者具备 Pandas 知识。然而,我们在整本书中广泛使用它。当我们这样做时,我们尝试解释代码,但并不总是能够详细涵盖所有内容。
在这个附录中,我们更深入地介绍了 Pandas,涵盖了我们在章节中使用到的所有功能。
D.1 Pandas
Pandas 是一个用于处理表格数据的 Python 库。它是数据操作中流行且方便的工具。它在准备数据以训练机器学习模型时特别有用。
如果你使用 Anaconda,它已经预装了 Pandas。如果没有,请使用 pip 安装:
pip install pandas
为了实验 Pandas,让我们创建一个名为 appendix-d-pandas 的笔记本,并使用它来运行这个附录中的代码。
首先,我们需要导入它:
import pandas as pd
与 NumPy 一样,我们遵循一个约定,并使用别名 pd 而不是全名。
我们从 Pandas 的核心数据结构开始探索:DataFrame 和 Series。
D.1.1 DataFrame
在 Pandas 中,DataFrame 简单来说就是一个表格:一个具有行和列的数据结构(图 D.1)。

图 D.1 Pandas 中的 DataFrame:一个包含五行八列的表格
要创建一个 DataFrame,我们首先需要创建一些我们将放入表中的数据。它可以是包含一些值的列表列表:
data = [
['Nissan', 'Stanza', 1991, 138, 4, 'MANUAL', 'sedan', 2000],
['Hyundai', 'Sonata', 2017, None, 4, 'AUTOMATIC', 'Sedan', 27150],
['Lotus', 'Elise', 2010, 218, 4, 'MANUAL', 'convertible', 54990],
['GMC', 'Acadia', 2017, 194, 4, 'AUTOMATIC', '4dr SUV', 34450],
['Nissan', 'Frontier', 2017, 261, 6, 'MANUAL', 'Pickup', 32340],
]
这份数据来自我们在第二章使用的价格预测数据集:我们有一些汽车特征,如型号、制造商、制造年份和变速类型。
在创建 DataFrame 时,我们需要知道每一列包含什么,所以让我们创建一个包含列名的列表:
columns = [
'Make', 'Model', 'Year', 'Engine HP', 'Engine Cylinders',
'Transmission Type', 'Vehicle_Style', 'MSRP'
]
现在我们已经准备好从它创建一个 DataFrame。为此,我们使用 pd.DataFrame:
df = pd.DataFrame(data, columns=columns)
它创建了一个包含五行八列的 DataFrame(图 D.1)。
我们可以用 DataFrame 做的第一件事是查看数据的前几行,以了解里面的内容。为此,我们使用 head 方法:
df.head(n=2)
它显示了 DataFrame 的前两行。显示的行数由 n 参数控制(图 D.2)。

图 D.2 使用 head 预览 DataFrame 的内容
或者,我们可以使用字典列表来创建一个 DataFrame:
data = [
{
"Make": "Nissan",
"Model": "Stanza",
"Year": 1991,
"Engine HP": 138.0,
"Engine Cylinders": 4,
"Transmission Type": "MANUAL",
"Vehicle_Style": "sedan",
"MSRP": 2000
},
... # more rows
]
df = pd.DataFrame(data)
在这种情况下,我们不需要指定列名:Pandas 会自动从字典的字段中获取它们。
D.1.2 Series
DataFrame 中的每一列都是一个 Series——一个用于包含单一类型值的特殊数据结构。在某种程度上,它与一维 NumPy 数组非常相似。
我们可以通过两种方式访问列的值。首先,我们可以使用点符号(图 D.3,A):
df.Make
另一种方式是使用括号符号(图 D.3,B):
df['Make']

(A) 点符号

(B) 括号符号
图 D.3 访问 DataFrame 列的两种方式:(A)点符号和(B)括号符号
结果完全相同:一个包含制造商列值的 Pandas Series。
如果列名包含空格或其他特殊字符,则我们只能使用方括号表示法。例如,要访问 Engine HP 列,我们只能使用方括号:
df['Engine HP']
方括号表示法也更加灵活。我们可以将列名保存在变量中,并使用它来访问其内容:
col_name = 'Engine HP'
df[col_name]
如果我们需要选择列的子集,我们再次使用方括号,但使用名称列表而不是单个字符串:
df[['Make', 'Model', 'MSRP']]
这将返回一个仅包含三个列的 DataFrame(图 D.4)。

图 D.4 要选择 DataFrame 的列子集,请使用带有名称列表的方括号。
要向 DataFrame 中添加列,我们同样使用方括号表示法:
df['id'] = ['nis1', 'hyu1', 'lot2', 'gmc1', 'nis2']
DataFrame 中有五行,因此值列表也应该有五个值。结果,我们又有了一个新列,id(图 D.5)。

图 D.5 要添加新列,请使用方括号表示法。
在这种情况下,id 不存在,因此我们在 DataFrame 的末尾添加了一个新列。如果 id 存在,则此代码将覆盖现有值:
df['id'] = [1, 2, 3, 4, 5]
现在 id 列的内容发生了变化(图 D.6)。

图 D.6 要更改列的内容,也请使用方括号表示法。
要删除列,请使用 del 操作符:
del df['id']
运行后,此列将从 DataFrame 中消失。
D.1.3 索引
DataFrame(图 D.7,A)和 Series(图 D.7,B)的左侧都有数字;这些数字被称为索引。索引描述了如何从 DataFrame(或 Series)中访问行。

图 D.7 DataFrame 和 Series 都有一个索引—左侧的数字。
我们可以使用 index 属性来获取 DataFrame 的索引:
df.index
因为我们在创建 DataFrame 时没有指定索引,它使用了默认的索引,即从 0 开始的自动递增数字序列:
RangeIndex(start=0, stop=5, step=1)
索引的行为与 Series 对象相同,因此适用于 Series 的所有操作也适用于索引。
虽然一个 Series 只有一个索引,但一个 DataFrame 有两个:一个用于访问行,另一个用于访问列。当我们从 DataFrame 中选择单个列时,我们已经使用了 Index:
df['Make'] ❶
❶ 使用列索引获取 Make 列
要获取列名,我们使用 columns 属性(图 D.8):
df.columns

图 D.8 columns 属性包含列名。
D.1.4 访问行
我们可以通过两种方式访问行:使用 iloc 和 loc。
首先,让我们从 iloc 开始。我们使用它通过位置数字访问 DataFrame 的行。例如,要访问 DataFrame 的第一行,请使用索引 0:
df.iloc[0]
这将返回第一行的内容:
Make Nissan
Model Stanza
Year 1991
Engine HP 138
Engine Cylinders 4
Transmission Type MANUAL
Vehicle_Style sedan
MSRP 2000
Name: 0, dtype: object
要获取行子集,请传递一个包含整数的列表—行号:
df.iloc[[2, 3, 0]]
结果是另一个仅包含所需行的 DataFrame(图 D.9)。

图 D.9 使用 iloc 访问 DataFrame 的行
我们可以使用iloc来洗牌 DataFrame 的内容。在我们的 DataFrame 中,我们有五行。因此,我们可以创建一个从 0 到 4 的整数列表并对其进行洗牌。然后我们可以使用洗牌后的列表在iloc中;这样,我们将得到一个所有行都洗牌的 DataFrame。
让我们实现它。首先,我们使用 NumPy 创建一个大小为 5 的范围:
import numpy as np
idx = np.arange(5)
它创建了一个从 0 到 4 的整数数组:
array([0, 1, 2, 3, 4])
现在我们可以对这个数组进行洗牌:
np.random.seed(2)
np.random.shuffle(idx)
结果,我们得到
array([2, 4, 1, 3, 0])
最后,我们使用这个数组与iloc一起按洗牌顺序获取行:
df.iloc[idx]
在结果中,行是按照idx中的数字重新排序的(图 D.10)。

图 D.10 使用iloc洗牌 DataFrame 的行
这不会改变我们df中已有的 DataFrame。但我们可以将df变量重新赋值给新的 DataFrame:
df = df.iloc[idx]
因此,df现在包含了一个洗牌后的 DataFrame。
在这个洗牌后的 DataFrame 中,我们仍然可以使用iloc通过它们的位号来获取行。例如,如果我们向iloc传递[0, 1, 2],我们将得到前三行(图 D.11)。

图 D.11 当使用iloc时,我们通过位置来获取行。
然而,你可能已经注意到左边的数字不再连续了:当洗牌 DataFrame 时,我们也洗牌了索引(图 D.12)。

图 D.12 当洗牌 DataFrame 的行时,我们也改变了索引:它不再连续。
让我们检查索引:
df.index
现在它不同了:
Int64Index([2, 4, 1, 3, 0], dtype='int64')
要使用此索引来访问行,我们需要loc而不是iloc。例如:
df.loc[[0, 1]]
因此,我们得到一个按 0 和 1 索引的 DataFrame——最后一行和中间的行(图 D.13)。

图 D.13 当使用loc时,我们通过索引而不是位置来获取行。
它与iloc非常不同:iloc不使用索引。让我们比较一下:
df.iloc[[0, 1]]
在这种情况下,我们也得到一个两行的 DataFrame,但这些都是前两行,分别按 2 和 4 索引(图 D.14)。

图 D.14 与loc不同,iloc通过位置而不是索引来获取行。在这种情况下,我们获取位置为 0 和 1 的行(分别按 2 和 4 索引)。
因此,iloc根本不查看索引;它只使用实际位置。
可以替换索引并将其重置为默认值。为此,我们可以使用reset_index方法:
df.reset_index(drop=True)
它创建了一个具有连续索引的新 DataFrame(图 D.15)。

图 D.15 我们可以通过使用reset_index来将索引重置为连续编号。
D.1.5 分割 DataFrame
我们也可以使用iloc来选择 DataFrame 的子集。假设我们想要将 DataFrame 分成三个部分:训练、验证和测试。我们将使用 60%的数据进行训练(三行),20%用于验证(一行),20%用于测试(一行):
n_train = 3
n_val = 1
n_test = 1
在选择行范围时,我们使用切片操作符(:)。它在 DataFrame 中的工作方式与在列表中的工作方式相同。
因此,对于分割 DataFrame,我们执行以下操作:
df_train = df.iloc[:n_train] ❶
df_val = df.iloc[n_train:n_train+n_val] ❷
df_test = df.iloc[n_train+n_val:] ❸
❶ 选择训练数据行
❷ 选择验证数据行
❸ 选择测试数据行
在 ❶ 中,我们得到训练集:iloc[:n_train] 从 DataFrame 的开始选择直到 n_train 前的行。对于 n_train=3,它选择行 0、1 和 2。行 3 不包括在内。
在 ❷ 中,我们得到验证集:iloc[n_train:n_train+n_val] 从 3 到 3 + 1 = 4 选择行。它是不包含的,所以它只选择行 3。
在 ❸ 中,我们得到测试集:iloc[n_train+n_val:] 从 3 + 1 = 4 开始选择直到 DataFrame 的末尾。在我们的例子中,它只选择行 4。
因此,我们有三个 DataFrame(图 D.16)。

图 D.16 使用 iloc 和冒号运算符将 DataFrame 分割为训练、验证和测试 DataFrame
更多有关 Python 中切片的信息,请参阅附录 B。
我们已经介绍了 Pandas 的基本数据结构,现在让我们看看我们可以用它们做什么。
D.2 操作
Pandas 是一个强大的数据处理工具,它支持各种操作。我们可以将这些操作分为逐元素操作、汇总操作、过滤、排序、分组等。在本节中,我们将介绍这些操作。
D.2.1 逐元素操作
在 Pandas 中,Series 支持 逐元素 操作。就像在 NumPy 中一样,逐元素操作应用于 Series 中的每个元素,我们得到另一个 Series 作为结果。
所有基本算术运算都是逐元素进行的:加法 (+)、减法 (–)、乘法 (*) 和除法 (/)。对于逐元素操作,我们不需要编写任何循环:Pandas 会为我们完成。
例如,我们可以将 Series 的每个元素乘以 2:
df['Engine HP'] * 2
结果是另一个 Series,每个元素都乘以 2(图 D.17)。

(A) 原始序列

(B) 乘法结果
图 D.17 与 NumPy 数组一样,Series 的所有基本算术运算都是逐元素进行的。
与算术运算一样,逻辑运算也是逐元素进行的:
df['Year'] > 2000
此表达式返回一个布尔 Series,对于大于 2000 的元素返回 True(图 D.18)。

(A) 原始序列

(B) 结果
图 D.18 逻辑运算逐元素应用:在结果中,对于满足条件的所有元素,我们都有 True。
我们可以将多个逻辑运算与逻辑与 (&) 或逻辑或 (|) 结合使用:
(df['Year'] > 2000) & (df['Make'] == 'Nissan')
结果也是一个 Series。逻辑运算对于过滤非常有用,我们将在下一节中介绍。
D.2.2 过滤
通常,我们需要根据某些标准选择行的一个子集。为此,我们使用布尔运算和括号表示法。
例如,要选择所有尼桑汽车,将条件放在括号内:
df[df['Make'] == 'Nissan']
因此,我们还有一个只包含尼桑汽车的 DataFrame(图 D.19)。

图 D.19 要过滤行,将过滤条件放在括号内。
如果我们需要更复杂的筛选条件,我们可以使用逻辑运算符如 and (&) 和 or (|) 组合多个条件。
例如,为了选择 2000 年后制造的自动挡汽车,我们使用 and 运算符(图 D.20):
df[(df['Year'] > 2010) & (df['Transmission Type'] == 'AUTOMATIC')]

图 D.20 要使用多个选择条件,使用逻辑与 (&) 将它们组合起来。
D.2.3 字符串操作
虽然对于 NumPy 数组来说,只能进行算术和逻辑元素级操作,但 Pandas 支持字符串操作:小写转换、替换子字符串以及所有可以在字符串对象上进行的其他操作。
让我们看看 DataFrame 中的 Vehicle_Style 列,它是其中的一个列。我们注意到数据中存在一些不一致性:有时名称以小写字母开头,有时以大写字母开头(图 D.21)。

图 D.21 数据中存在一些不一致性的 Vehicle_Style 列
为了解决这个问题,我们可以将所有内容转换为小写。对于常规 Python 字符串,我们会使用 lower 函数并将其应用于序列的所有元素。在 Pandas 中,我们不是编写循环,而是使用特殊的 str 访问器——它使字符串操作元素级,并允许我们避免显式编写 for 循环:
df['Vehicle_Style'].str.lower()
结果是一个新 Series,其中所有字符串都转换为小写(图 D.22)。

图 D.22 要将 Series 中的所有字符串转换为小写,使用 lower。
也可以通过多次使用 str 访问器链式执行多个字符串操作(图 D.23):
df['Vehicle_Style'].str.lower().str.replace(' ', '_')

图 D.23 要替换 Series 中所有字符串中的字符,使用 replace 方法。在一行中可以链式调用多个方法。
在这里,我们一次性将所有内容转换为小写并替换空格为下划线。
我们的 DataFrame 的列名也不一致:有时有空格,有时有下划线(图 D.24)。

图 D.24 DataFrame:列名不一致。
我们也可以使用字符串操作来标准化列名:
df.columns.str.lower().str.replace(' ', '_')
As a result, we have:
Index(['make', 'model', 'year', 'engine_hp', 'engine_cylinders',
'transmission_type', 'vehicle_style', 'msrp'],
dtype='object')
这行代码返回新的名称,但它不会改变 DataFrame 的列名。要修改它们,我们需要将结果重新赋值给 df.columns:
df.columns = df.columns.str.lower().str.replace(' ', '_')
当我们这样做时,列名会改变(图 D.25)。

图 D.25 标准化列名后的 DataFrame
我们可以在 DataFrame 的所有列中解决这种不一致性问题。为此,我们需要选择所有包含字符串的列并将它们标准化。
要选择所有字符串,我们可以使用 DataFrame 的 dtype 属性(图 D.26)。

图 D.26 dtypes 属性返回 DataFrame 每列的类型。
所有字符串列的 dtype 都设置为 object。因此,如果我们想选择它们,我们使用过滤:
df.dtypes[df.dtypes == 'object']
这给了我们一个只包含 object 数据类型列的 Series(图 D.27)。

图 D.27 要选择只有字符串的列,选择 object 数据类型。
实际名称存储在索引中,因此我们需要获取它们:
df.dtypes[df.dtypes == 'object'].index
这给我们以下列名:
Index(['make', 'model', 'transmission_type', 'vehicle_style'], dtype='object')
现在,我们可以使用此列表遍历字符串列,并对每个列分别应用归一化:
string_columns = df.dtypes[df.dtypes == 'object'].index
for col in string_columns:
df[col] = df[col].str.lower().str.replace(' ', '_')
这是我们运行后的结果(如图 D.28 所示)。

图 D.28 列名和值都已归一化:名称为小写,空格被下划线替换。
接下来,我们将介绍另一种类型的操作:汇总操作。
D.2.4 汇总操作
正如我们在 NumPy 中所做的那样,在 Pandas 中我们也有逐元素操作,这些操作产生另一个 Series,以及产生汇总的汇总操作——一个或多个数字。
汇总操作对于进行探索性数据分析非常有用。对于数值字段,操作类似于我们在 NumPy 中所拥有的。例如,要计算列中所有值的平均值,我们使用 mean 方法:
df.msrp.mean()
我们还可以使用的其他方法包括
-
sum: 计算所有值的总和 -
min: 获取 Series 中的最小数字 -
max: 获取 Series 中的最大数字 -
std: 计算标准差
而不是单独检查这些内容,我们可以使用 describe 一次性获取所有这些值:
df.msrp.describe()
它创建一个包含行数、平均值、最小值、最大值以及标准差和其他特性的汇总:
count 5.000000
mean 30186.000000
std 18985.044904
min 2000.000000
25% 27150.000000
50% 32340.000000
75% 34450.000000
max 54990.000000
Name: msrp, dtype: float64
当我们对整个 DataFrame 调用 mean 时,它计算所有数值列的平均值:
df.mean()
在我们的例子中,我们有四个数值列,因此我们得到每个的平均值:
year 2010.40
engine_hp 202.75
engine_cylinders 4.40
msrp 30186.00
dtype: float64
同样,我们可以在 DataFrame 上使用 describe:
df.describe()
因为 describe 已经返回一个 Series,当我们对 DataFrame 调用它时,我们也会得到一个 DataFrame(如图 D.29 所示)。

图 D.29 要获取所有数值特征的汇总统计信息,请使用 describe 方法。
D.2.5 缺失值
我们之前没有关注它,但我们的数据中有一个缺失值:我们不知道第 2 行的 engine_hp 值(如图 D.30 所示)。

图 D.30 在我们的 DataFrame 中有一个缺失值。
我们可以使用 isnull 方法查看哪些值缺失:
df.isnull()
此方法返回一个新的 DataFrame,其中如果对应的值在原始 DataFrame 中缺失,则单元格为 True(如图 D.31 所示)。

图 D.31 要查找缺失值,请使用 isnull 方法。
然而,当我们有大的 DataFrame 时,查看所有值是不切实际的。我们可以通过在结果上运行 sum 方法轻松地汇总它们:
df.isnull().sum()
它返回一个 Series,其中包含每列的缺失值数量。在我们的例子中,只有 engine_hp 有缺失值;其他没有(如图 D.32 所示)。

图 D.32 要查找具有缺失值的列,请使用 isnull 后跟 sum。
要用一些实际值替换缺失值,我们使用 fillna 方法。例如,我们可以用零填充缺失值:
df.engine_hp.fillna(0)
因此,我们得到一个新的 Series,其中 NaN 被替换为 0:
0 218.0
1 261.0
2 0.0
3 194.0
4 138.0
Name: engine_hp, dtype: float64
或者,我们可以通过获取平均值来替换它:
df.engine_hp.fillna(df.engine_hp.mean())
在这种情况下,NaN 被平均值替换:
0 218.00
1 261.00
2 202.75
3 194.00
4 138.00
Name: engine_hp, dtype: float64
fillna 方法返回一个新的 Series。因此,如果我们需要从我们的 DataFrame 中删除缺失值,我们需要将结果写回:
df.engine_hp = df.engine_hp.fillna(df.engine_hp.mean())
现在我们得到了一个没有缺失值的 DataFrame(图 D.33)。

图 D.33 无缺失值的 DataFrame
D.2.6 排序
我们之前讨论的操作主要用于 Series。我们也可以对 DataFrame 执行操作。
排序是这些操作之一:它重新排列 DataFrame 中的行,使得它们按某些列(或多个列)的值进行排序。
例如,让我们按 MSRP 对 DataFrame 进行排序。为此,我们使用 sort_values 方法:
df.sort_values(by='msrp')
结果是一个新的 DataFrame,其中行按最小的 MSRP(2000)到最大的(54990)排序(图 D.34)。

图 D.34 要对 DataFrame 的行进行排序,请使用 sort_values。
如果我们希望最大的值首先出现,我们将 ascending 参数设置为 False:
df.sort_values(by='msrp', ascending=False)
现在我们有第一行的 MSRP 为 54990,最后一行为 2000(图 D.35)。

图 D.35 要按降序对 DataFrame 的行进行排序,请使用 ascending=False。
D.2.7 分组
Pandas 提供了许多汇总操作:求和、平均值以及许多其他操作。我们之前已经看到如何将它们应用于整个 DataFrame 的汇总计算。有时,我们可能希望按组进行操作——例如,计算每种变速器的平均价格。
在 SQL 中,我们会写类似的东西:
SELECT
tranmission_type,
AVG(msrp)
FROM
cars
GROUP BY
transmission_type;
在 Pandas 中,我们使用 groupby 方法:
df.groupby('transmission_type').msrp.mean()
结果是每种变速器的平均价格:
transmission_type
automatic 30800.000000
manual 29776.666667
Name: msrp, dtype: float64
如果我们还想计算每种类型的记录数以及平均价格,在 SQL 中,我们会在 SELECT 子句中添加另一个语句:
SELECT
tranmission_type,
AVG(msrp),
COUNT(msrp)
FROM
cars
GROUP BY
transmission_type
在 Pandas 中,我们使用 groupby 后跟 agg(代表“聚合”):
df.groupby('transmission_type').msrp.agg(['mean', 'count'])
因此,我们得到一个新的 DataFrame(图 D.36)。

图 D.36 在分组时,我们可以使用 agg 方法应用多个聚合函数。
Pandas 是一个相当强大的数据处理工具,它通常用于在训练机器学习模型之前准备数据。有了本附录的信息,你应该更容易理解本书中的代码。
附录 E. AWS SageMaker
AWS SageMaker 是 AWS 提供的一套与机器学习相关的服务。SageMaker 使得在 AWS 上创建一个安装了 Jupyter 的服务器变得非常简单。笔记本已经配置好了:它们包含了我们需要的绝大多数库,包括 NumPy、Pandas、Scikit-learn 和 TensorFlow,因此我们可以直接在项目中使用它们!
E.1 AWS SageMaker 笔记本
SageMaker 的笔记本有两个特别有趣的原因,用于训练神经网络:
-
我们不需要担心设置 TensorFlow 和所有库。
-
可以租用带有 GPU 的计算机,这使我们能够更快地训练神经网络。
要使用 GPU,我们需要调整默认配额。在下一节中,我们将告诉你如何做到这一点。
E.1.1 增加 GPU 配额限制
AWS 上的每个账户都有配额限制。例如,如果我们的 GPU 实例数量配额限制是 10,我们就不能请求第 11 个带有 GPU 的实例。
默认情况下,配额限制为零,这意味着如果不更改配额限制,就无法租用 GPU 机器。
要请求增加,在 AWS 控制台中打开支持中心:点击右上角的“支持”并选择支持中心(图 E.1)。

图 E.1 要打开支持中心,点击支持 > 支持中心。
接下来,点击创建案例按钮(图 E.2)。

图 E.2 在支持中心,点击创建案例按钮。
现在选择服务限制增加选项。在案例详情部分,从限制类型下拉列表中选择 SageMaker(图 E.3)。

图 E.3 创建新案例时,选择服务限制增加 > SageMaker。
之后,填写配额增加表单(图 E.4):
-
区域:选择离你最近或最便宜的。你可以在这里看到价格:
aws.amazon.com/sagemaker/pricing/。资源类型:SageMaker 笔记本。 -
限制:一台具有一个 GPU 的 ml.p2.xlarge 实例。
-
新的限制值:1。

图 E.4 将 ml.p2.xlarge 的限制增加到一台实例。
最后,描述为什么需要增加配额限制。例如,你可以输入“我想使用 GPU 机器训练神经网络”(图 E.5)。

图 E.5 我们需要解释为什么想要增加限制。
我们已经准备好了;现在点击提交。
之后,我们看到了请求的一些详细信息。回到支持中心,我们在打开的案例列表中看到了新的案例(图 E.6)。

图 E.6 打开的支持案例列表
通常需要一到两天来处理请求并增加限制。
一旦限制增加,我们就可以创建一个带有 GPU 的 Jupyter Notebook 实例。
E.1.2 创建笔记本实例
要在 SageMaker 中创建 Jupyter Notebook,首先在服务列表中找到 SageMaker(图 E.7)。

图 E.7 在搜索框中输入 SageMaker 以找到 SageMaker。
注意 SageMaker 笔记本不包含在免费层中,因此租用 Jupyter Notebook 需要付费。
对于具有一个 GPU(ml.p2.xlarge)的实例,在撰写本文时的每小时费用为
-
法兰克福:$1.856
-
爱尔兰:$1.361
-
北弗吉尼亚:$1.26
第七章的项目需要一到两个小时才能完成。
注意 确保您位于您请求增加配额限制的区域。
在 SageMaker 中,选择“笔记本实例”,然后点击“创建笔记本实例”按钮(图 E.8)。

图 E.8 要创建 Jupyter Notebook,请点击“创建笔记本实例”。
接下来,我们需要配置实例。首先,输入实例名称以及实例类型。因为我们感兴趣的是 GPU 实例,所以在加速计算部分选择 ml.p2.xlarge(图 E.9)。

图 E.9 加速计算部分包含具有 GPU 的实例。
在“附加配置”中,在卷大小字段中写入 5 GB。这样,我们应该有足够的空间来存储数据集以及保存我们的模型。
如果您之前已使用 SageMaker 并已为其创建 IAM 角色,请在 IAM 角色部分选择它。
但如果您是第一次这样做,请选择“创建新角色”(图 E.10)。

图 E.10 要使用 SageMaker 笔记本,我们需要为其创建 IAM 角色。
在创建角色时,保留默认值,并点击“创建角色”按钮(图 E.11)。

图 E.11 新 IAM 角色的默认值足够。
保持其余选项不变:
-
根权限:启用
-
加密密钥:无自定义加密
-
网络:无 VPC
-
Git 仓库:无
最后,点击“创建笔记本实例”以启动它。
如果由于某些原因您看到 ResourceLimitExceeded 错误消息(图 E.12),请确保
-
您已请求增加 ml.p2.xlarge 实例类型的配额限制。
-
请求已处理。
-
您正在尝试在您请求增加配额限制的区域中创建笔记本。

图 E.12 如果您看到 ResourceLimitExceeded 错误消息,您需要增加配额限制。
创建实例后,笔记本将出现在笔记本实例列表中(图 E.13)。

图 E.13 成功!笔记本实例已创建。
现在我们需要等待笔记本状态从 Pending 变更为 InService;这可能需要一到两分钟。
一旦处于 InService 状态,即可使用(图 E.14)。点击“打开 Jupyter”以访问它。

图 E.14 新的笔记本实例已投入使用,并准备好使用。
接下来,我们将展示如何使用 TensorFlow。
E.1.3 训练模型
点击“打开 Jupyter”后,我们看到了熟悉的 Jupyter Notebook 界面。
要创建一个新的笔记本,点击“新建”,并选择 conda_tensorflow2_p36(图 E.15)。

图 E.15 要创建一个新的 TensorFlow 笔记本,请选择 conda_tensorflow2_p36。
这个笔记本有 Python 版本 3.6 和 TensorFlow 版本 2.1.0。在撰写本文时,这是 SageMaker 中可用的最新 TensorFlow 版本。
现在,导入 TensorFlow 并检查其版本:
import tensorflow as tf
tf.__version__
版本应该是 2.1.0 或更高(如图 E.16 所示)。

图 E.16 对于我们的示例,我们需要至少 TensorFlow 版本 2.1.0。
现在,转到第七章并训练一个神经网络!训练完成后,我们需要关闭笔记本。
E.1.4 关闭笔记本
要停止一个笔记本,首先选择你想要停止的实例,然后在操作下拉列表中选择 Stop(如图 E.17 所示)。

图 E.17 要关闭一个笔记本,请选择停止操作。
在完成此操作后,笔记本的状态将从 InService 变为 Stopping。它可能需要几分钟才能完全停止,并从 Stopping 状态变为 Stopped 状态。
注意:当我们停止一个笔记本时,所有的代码和数据都会被保存。下次启动时,我们可以从上次停止的地方继续。
重要提示:笔记本实例成本较高,所以请确保你不会意外地让它继续运行。SageMaker 不包含免费层,所以如果你忘记停止它,你将在月底收到一笔巨额账单。在 AWS 中设置预算可以避免巨额账单。请参阅 AWS 管理成本文档:docs.aws.amazon.com/awsaccountbilling/latest/aboutv2/budgets-managing-costs.html。请小心,当你不再需要笔记本时,请关闭它。
一旦你完成了一个项目的所有工作,你可以删除笔记本。选择一个笔记本,然后从下拉列表中选择删除(如图 E.18 所示)。笔记本必须处于 Stopped 状态才能删除。
它将首先从 Stopped 状态变为 Deleting,30 秒后将从笔记本列表中消失。

图 E.18 在完成第七章后,你可以删除笔记本。




浙公网安备 33010602011771号