轻松学特征工程-全-
轻松学特征工程(全)
原文:
annas-archive.org/md5/817912ba981171919811b1ecb5aec399译者:飞龙
前言
本书将涵盖特征工程这一主题。特征工程是数据科学和机器学习管道的重要组成部分,它包括识别、清理、构建和发现数据的新特征的能力,目的是为了解释和预测分析。
在本书中,我们将涵盖特征工程的整个流程,从检查到可视化、转换以及更多。我们将使用基本的和高级的数学度量来将我们的数据转换成机器和机器学习管道更容易消化和理解的形式。
通过发现和转换,作为数据科学家,我们将能够从全新的视角看待我们的数据,这不仅增强了我们的算法,也增强了我们的洞察力。
本书面向的对象
本书是为那些希望理解和利用机器学习和数据探索中特征工程实践的人而写的。
读者应该对机器学习和 Python 编程相当熟悉,以便能够舒适地通过逐步解释基础知识来深入探索新主题。
本书涵盖的内容
第一章,特征工程简介,介绍了特征工程的基本术语,并快速浏览了本书中将解决的问题的类型。
第二章,特征理解 – 我的数据集中有什么?,探讨了我们在野外可能会遇到的数据类型,以及如何分别或共同处理每一种类型。
第三章,特征改进 - 清理数据集,解释了各种填充缺失数据的方法,以及不同的技术如何导致数据结构发生变化,这可能导致机器学习性能下降。
第四章,特征构建,探讨了如何根据已经给出的信息创建新的特征,以努力增加数据的结构。
第五章,特征选择,展示了如何通过量化指标来决定哪些特征值得我们保留在数据管道中。
第六章,特征转换,运用高级线性代数和数学技术,对数据进行刚性结构化,以增强我们管道的性能。
第七章,特征学习,涵盖了使用最先进的机器学习和人工智能学习算法来发现数据中人类难以理解的潜在特征。
第八章,案例研究,展示了一系列案例研究,旨在巩固特征工程的概念。
为了最大限度地利用本书
我们需要为本书准备的内容:
-
本书使用 Python 来完成所有的代码示例。需要一个可以访问 Unix 风格终端并已安装 Python 2.7 的机器(Linux/Mac/Windows 均可)。
-
建议安装 Anaconda 发行版,因为它包含了示例中使用的大多数软件包。
下载示例代码文件
您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在www.packtpub.com上登录或注册。
-
选择 SUPPORT 标签。
-
点击代码下载与勘误。
-
在搜索框中输入书名,并遵循屏幕上的说明。
文件下载后,请确保使用最新版本的以下软件解压缩或提取文件夹:
-
Windows 版的 WinRAR/7-Zip
-
Mac 版的 Zipeg/iZip/UnRarX
-
Linux 版的 7-Zip/PeaZip
本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Feature-Engineering-Made-Easy。我们还有其他来自我们丰富图书和视频目录的代码包可供选择,网址为github.com/PacktPublishing/。请查看它们!
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/FeatureEngineeringMadeEasy_ColorImages.pdf。
使用的约定
本书使用了多种文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“假设进一步,给定这个数据集,我们的任务是能够接受三个属性(datetime、protocol和urgent)并能够准确预测malicious的值。用通俗易懂的话说,我们希望有一个系统可以将datetime、protocol和urgent的值映射到malicious中的值。”
代码块设置如下:
Network_features = pd.DataFrame({'datetime': ['6/2/2018', '6/2/2018', '6/2/2018', '6/3/2018'], 'protocol': ['tcp', 'http', 'http', 'http'], 'urgent': [False, True, True, False]})
Network_response = pd.Series([True, True, False, True])
Network_features
>>
datetime protocol urgent
0 6/2/2018 tcp False
1 6/2/2018 http True
2 6/2/2018 http True
3 6/3/2018 http False
Network_response
>>
0 True
1 True
2 False
3 True
dtype: bool
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
times_pregnant 0.221898
plasma_glucose_concentration 0.466581 diastolic_blood_pressure 0.065068
triceps_thickness 0.074752
serum_insulin 0.130548
bmi 0.292695
pedigree_function 0.173844
age 0.238356
onset_diabetes 1.000000
Name: onset_diabetes, dtype: float64
粗体:表示新术语、重要单词或您在屏幕上看到的单词。
警告或重要提示如下所示。
小技巧如下所示。
联系我们
我们读者的反馈总是受欢迎的。
一般反馈:请发送电子邮件至feedback@packtpub.com,并在邮件主题中提及书名。如果您对本书的任何方面有疑问,请通过电子邮件联系我们的questions@packtpub.com。
勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版: 如果您在互联网上以任何形式发现我们作品的非法副本,如果您能向我们提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packtpub.com 联系我们,并附上材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com.
评论
请留下评论。一旦您阅读并使用了这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 公司可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
如需了解 Packt 的更多信息,请访问 packtpub.com.
第一章:特征工程简介
近年来,工程师和执行人员一直在尝试实施机器学习(ML)和人工智能(AI)来解决大部分使用相当手动方法解决的问题。一个很好的例子就是自然语言处理(NLP)的进步,更具体地说是在自然语言生成和理解方面。更具体地说,我们指的是能够从用户那里读取原始文本的人工智能系统(可能是最新智能手机的不满用户),并且能够以人类的散文和机器的速度准确、清晰地回应。在本章中,我们将介绍特征工程等主题,例如:
-
为什么特征工程很重要的激励性例子
-
对机器学习的基本理解,包括性能、评估
-
本书包含的章节的详细列表
激励性例子 - AI 驱动的通信
来认识一下 Arty,我们的 AI 聊天系统,它能够处理和回应客户支持请求,就像我们任何人类员工一样。Arty 拥有我们公司的知识,随时准备出发。
下面是一个人类和 AI 客户支持系统之间样本对话的进行方式:
| 人类 | AI |
|---|---|
| 我的手机坏了。 | 很抱歉听到这个,它是怎么坏的? |
| 它冻住了,我无法重置它。 | 这是什么手机? |
| 新的 iDroid 28 | 哦,我明白了。按住电源和音量下键 20 秒,它应该会重置。 |
| 它工作了,谢谢! | 没问题,祝你有个美好的一天。 |
这些类型系统令人兴奋并颠覆主要市场的原因是这样一个复杂系统的简单性。让我们来分解一下。表面上,你可能认为,这是一个多么简单的问题! 这个人有一个简单的问题,有一个简单的解决方案。一个请求进来,一个响应出来。你好,我的手机冻住了,我该怎么办? 简单,只需重置它。当然,表面上,这里发生的就是这样:
from Arty import AI
AI.respond_to("my phone froze, what should I do?")
>> "reset it."
当从人工智能的角度来看时,问题就来了。它没有经历过我们经历的人类全部体验。它没有阅读过《伊利亚特》或甚至《大红狗克利福德》并学习内化它们的信息的特权。关键是,人工智能在阅读事物方面没有太多的经验。这个人工智能可能被给了几十万(甚至可能是几百万)以前人们的聊天对话,并被告诉去弄清楚。
以下是我们 AI 系统基于以前聊天日志给出的样本数据:
| 请求 | 响应 |
|---|---|
| 嗨 | 你好,有什么问题吗? |
| 我的手机不工作了!!!! | 哦不!!!它怎么了? |
| >等等,我得去遛狗。待会儿回来。 | 好的。我会等你的。 |
| 嗨。 | 你好。我叫马克,我能帮什么忙? |
数据被组织成两列,其中Request列表示最终用户在聊天支持对话中输入的内容。下一列,Response,表示客户支持代理对传入消息的回复。
在阅读数千个错别字、愤怒的消息和脱节的聊天之后,AI 开始认为自己已经掌握了这项客户支持工作。一旦发生这种情况,人类就会让 AI 处理新收到的聊天。人类没有意识到自己的错误,开始注意到 AI 还没有完全掌握这项工作。AI 似乎无法识别甚至简单的消息,并不断返回无意义的回复。人们很容易认为 AI 只需要更多的时间或更多的数据,但这些解决方案只是对更大问题的临时补救,而且往往甚至不能解决根本问题。
根本问题可能是,提供给 AI 的原始文本数据不够好,AI 无法捕捉到英语语言的细微差别。例如,可能的问题包括:
-
错别字人为地扩大了 AI 的词汇量,却没有原因。Helllo和hello是两个不同的词,彼此之间没有关联。
-
同义词对 AI 来说毫无意义。例如,hello和hey这两个词没有任何相似之处,因此人为地增加了问题的难度。
为什么特征工程很重要
数据科学家和机器学习工程师经常收集数据以解决问题。因为他们试图解决的问题通常与实际情况高度相关,并且在这个混乱的世界中自然存在,所以旨在代表问题的数据也可能非常混乱、未经筛选,并且常常不完整。
这就是为什么在过去的几年里,诸如数据工程师等头衔的职位不断涌现。这些工程师的独特任务是构建管道和架构,用于处理和转换原始数据,使其能够被公司其他部门使用,尤其是数据科学家和机器学习工程师。这项工作不仅与机器学习专家创建机器学习管道的工作一样重要,而且常常被忽视和低估。
由数据科学家进行的调查显示,他们超过 80%的时间用于捕获、清理和组织数据。剩余的不到 20%的时间用于创建这些最终主导对话的机器学习管道。此外,这些数据科学家的大部分时间都花在准备数据上;超过 75%的人还报告说,准备数据是他们流程中最不愉快的一部分。
以下是之前提到的调查结果:
以下图表显示了数据科学家花费最多时间做的事情:

从前面的图表中可以看出,我们将数据科学家的任务分解为以下百分比:
-
构建训练集:3%
-
清洗和整理数据:60%
-
收集数据集:19%
-
挖掘数据模式:9%
-
优化算法:5%
与数据科学中最不愉快部分相似的饼图:

从图表中可以看出,对于数据科学中最不愉快部分的类似调查结果如下:
-
构建训练集:10 %
-
清洗和整理数据:57%
-
收集数据集:21%
-
挖掘数据模式:3%
-
优化算法:4%
-
其他:5%
最上面的图表表示数据科学家在不同部分上花费的时间百分比。超过 80%的数据科学家时间用于准备数据以供进一步使用。下面的图表表示那些被调查的人报告的数据科学过程中最不愉快部分的比例。其中超过 75%的人报告说准备数据是他们最不愉快的一部分。
数据来源:whatsthebigdata.com/2016/05/01/data-scientists-spend-most-of-their-time-cleaning-data/.
一位杰出的数据科学家知道,准备数据不仅非常重要,占据了他们大部分的时间,而且他们也知道这是一个艰巨的过程,可能并不愉快。我们往往过于理所当然地接受机器学习竞赛和学术来源提供的干净数据。超过 90%的数据,有趣的数据,最有用的数据,都存在于这种原始格式中,就像之前描述的 AI 聊天系统中的数据一样。
准备数据可能是一个模糊的短语。准备包括捕获数据、存储数据、清洗数据等。如前述图表所示,数据科学家的大部分时间,尽管比例较小,但仍然是一大部分,用于清洗和整理数据。在这个过程中,我们的数据工程师对我们最有用。清洗是指将数据转换成可以被我们的云系统和数据库轻松解释的格式的过程。整理通常指的是更彻底的转换。整理往往涉及将整个数据集的格式改变成一个更整洁的格式,例如将原始聊天记录转换成表格的行/列结构。
下面是清洗和整理的示例:

顶部的转换表示清理包含数据和服务器上发生情况的文本解释的服务器日志样本。请注意,在清理过程中,Unicode 字符&被转换成了更易读的符号(&)。清理阶段使文档几乎保持了之前的完全相同的格式。底部的组织转换则是一个更为激进的转换。它将原始文档转换成了行/列结构,其中每一行代表服务器执行的单个操作,而列代表服务器操作的特征。在这种情况下,两个特征是日期和文本。
清洁和组织都属于数据科学的一个更广泛的类别,恰好这也是本书的主题,即特征工程。
什么是特征工程?
最后,本书的标题。
是的,朋友们,特征工程将是这本书的主题。我们将专注于为机器学习管道清理和组织数据的过程。我们还将超越这些概念,探讨数据更复杂的转换形式,如数学公式和神经理解,但我们现在有些过于超前了。让我们从高层次开始。
特征工程是将数据转换为更好地代表潜在问题的特征的过程,从而提高机器学习性能。
为了进一步解释这个定义,让我们看看特征工程究竟包含哪些内容:
-
转换数据的过程:请注意,我们并没有指定原始数据、未过滤数据等。特征工程可以应用于数据的任何阶段。通常情况下,我们将对数据分销者眼中的已处理数据进行特征工程。同样重要的是要提到,我们将要处理的数据通常将以表格格式存在。数据将被组织成行(观测值)和列(属性)。有时,我们将从数据的最原始形式开始,例如在之前提到的服务器日志示例中,但大部分情况下,我们将处理已经有一定程度清洁和组织的数据。
-
特征:在这本书中,显然会大量使用“特征”这个词。在最基本的意义上,特征是数据的一个属性,对机器学习过程有意义。很多时候,我们将诊断表格数据,并确定哪些列是特征,哪些仅仅是属性。
-
更好地代表潜在问题:我们将要处理的数据始终用于在特定领域代表特定问题。在执行这些技术的同时,我们确保不失去对整体情况的关注是很重要的。我们希望转换数据,使其更好地代表手头的大问题。
-
导致机器学习性能提升:特征工程是数据科学过程中的一个单独部分。正如我们所见,它是一个重要且经常被低估的部分。特征工程的最终目标是获取我们的学习算法能够从中提取模式并用于获得更好结果的数据。我们将在本书的后面深入讨论机器学习指标和结果,但到目前为止,要知道我们进行特征工程不仅是为了获得更干净的数据,而且是为了最终在我们的机器学习管道中使用这些数据。
我们知道你在想什么,为什么我应该花时间阅读关于一个人们说他们不喜欢做的事情的过程? 我们认为许多人不喜欢特征工程的过程,因为他们往往没有理解他们所做工作的结果的益处。
大多数公司都雇佣了数据工程师和机器学习工程师。数据工程师主要关注数据的准备和转换,而机器学习工程师通常对学习算法以及如何从已经清洗过的数据中挖掘模式有实际的知识。
他们的工作往往是分开的,但又是相互交织和迭代的。数据工程师将向机器学习工程师展示一个数据集,他们声称无法从中获得好的结果,并要求数据工程师进一步转换数据,等等。这个过程不仅可能单调且重复,还可能损害整体大局。
如果没有关于特征和机器学习工程的知识,整个过程可能不会像它本可以的那样有效。这就是这本书的作用所在。我们将讨论特征工程以及它与机器学习的直接关系。这将是一个以结果为导向的方法,我们将认为技术是有帮助的,如果并且只有如果它们能够提高性能。现在深入探讨数据、数据结构和机器学习的基础是值得的,以确保术语的标准化。
理解数据和机器学习的基础
当我们谈论数据时,我们通常处理的是表格数据,即组织成行和列的数据。想象一下,这些数据可以在像 Microsoft Excel 这样的电子表格技术中打开。每一行数据,也称为观察结果,代表了一个问题的单个实例/例子。如果我们的数据属于股票市场日内交易领域,一个观察结果可能代表整个市场及价格的一个小时的变化。
例如,在处理网络安全领域时,一个观察结果可能代表一次可能的攻击或通过无线系统发送的数据包。
以下展示了网络安全领域以及更具体地说,网络入侵领域的样本表格数据:
| 日期时间 | 协议 | 紧急 | 恶意 |
|---|---|---|---|
| 2018 年 6 月 2 日 | TCP | 否 | 是 |
| 2018 年 6 月 2 日 | HTTP | 是 | 是 |
| 2018 年 6 月 2 日 | HTTP | 是 | 否 |
| 2018 年 6 月 3 日 | HTTP | 否 | 是 |
我们可以看到,每一行或观察结果都包含一个网络连接,并且我们有四个观察属性:DateTime(日期时间)、Protocol(协议)、Urgent(紧急)和Malicious(恶意)。虽然我们不会深入探讨这些特定属性,但我们会注意到以表格格式给出的数据结构。
由于我们大部分时间都会将数据视为表格形式,我们还可以查看数据矩阵只有一列/属性的具体实例。例如,如果我们正在构建一个能够接收单个房间图像并输出该房间是否有人存在的软件,输入数据可能表示为一个单列矩阵,其中单列只是一个指向房间照片的 URL,没有其他内容。
例如,考虑以下只有一个名为Photo URL的列的表格。表格的值是照片的 URL(这些都是虚构的,不会导向任何地方,仅用于示例):
| 照片 URL |
|---|
photo-storage.io/room/1 |
photo-storage.io/room/2 |
photo-storage.io/room/3 |
photo-storage.io/room/4 |
输入到系统中的数据可能只有一列,例如在这个案例中。在我们创建能够分析图像的系统时,输入可能只是一个指向图像的 URL。作为数据科学家,我们将负责从 URL 中提取特征。
作为数据科学家,我们必须准备好处理可能很大、很小、宽泛、狭窄(就属性而言)、完成度低(可能存在缺失值)的数据,并准备好利用这些数据进行机器学习。现在是讨论这个话题的好时机。机器学习算法属于一类算法,它们通过从数据中提取和利用模式来完成基于历史训练数据的任务。这听起来很模糊,对吧?机器学习可以处理许多类型的任务,因此我们将机器学习的定义保持不变,并深入探讨一下。
我们通常将机器学习分为两种主要类型:监督学习和无监督学习。每种机器学习算法都可以从特征工程中受益,因此了解每种类型都很重要。
监督学习
很多次,我们在监督学习的特定背景下听到特征工程,也称为预测分析。监督学习算法专门处理使用数据的其他属性来预测一个值(通常是数据的一个属性)的任务。以表示网络入侵的数据集为例:
| DateTime | Protocol | Urgent | Malicious |
|---|---|---|---|
| 2018 年 6 月 2 日 | TCP | FALSE | TRUE |
| 2018 年 6 月 2 日 | HTTP | TRUE | TRUE |
| 2018 年 6 月 2 日 | HTTP | TRUE | FALSE |
| 2018 年 6 月 3 日 | HTTP | FALSE | TRUE |
这是之前相同的同一个数据集,但让我们在预测分析的背景下进一步剖析它。
注意,这个数据集有四个属性:DateTime、Protocol、Urgent和Malicious。假设现在恶意属性包含表示观察是否为恶意入侵尝试的值。所以,在我们的非常小的四个网络连接数据集中,第一个、第二个和第四个连接是试图入侵网络的恶意尝试。
假设进一步,给定这个数据集,我们的任务是能够接受三个属性(datetime、protocol和urgent)并能够准确预测恶意属性的值。用通俗的话说,我们希望有一个系统可以将datetime、protocol和urgent的值映射到恶意属性的值。这正是监督学习问题设置的方式:
Network_features = pd.DataFrame({'datetime': ['6/2/2018', '6/2/2018', '6/2/2018', '6/3/2018'], 'protocol': ['tcp', 'http', 'http', 'http'], 'urgent': [False, True, True, False]})
Network_response = pd.Series([True, True, False, True])
Network_features
>>
datetime protocol urgent
0 6/2/2018 tcp False
1 6/2/2018 http True
2 6/2/2018 http True
3 6/3/2018 http False
Network_response
>>
0 True
1 True
2 False
3 True
dtype: bool
当我们与监督学习一起工作时,我们通常称数据集中我们试图预测响应的属性(通常只有一个,但这不是必需的)为属性。数据集的其余属性被称为特征。
监督学习也可以被认为是试图利用数据结构的一类算法。通过这种方式,我们指的是机器学习算法试图从通常非常整洁和有序的数据中提取模式。如前所述,我们不应总是期望数据整齐有序;这正是特征工程发挥作用的地方。
但如果你不预测任何东西,机器学习有什么好处呢?我很高兴你问了。在机器学习能够利用数据结构之前,有时我们必须改变甚至创造结构。这就是无监督学习成为一项宝贵工具的地方。
无监督学习
监督学习全部关于做出预测。我们利用数据的特点,并使用它们来对数据的响应做出有信息的预测。如果我们不是通过探索结构来做出预测,我们就是在尝试从我们的数据中提取结构。我们通常通过应用数学变换到数据的数值矩阵表示或迭代过程来获得新的特征集。
这个概念可能比监督学习更难理解,所以我会提供一个激励性的例子来帮助阐明这一切是如何工作的。
无监督学习示例 - 营销细分市场
假设我们得到了一个大型(一百万行)数据集,其中每一行/观测值代表一个单独的人,包含基本的人口统计信息(年龄、性别等)以及购买商品的数量,这代表这个人从特定商店购买的商品数量:
| 年龄 | 性别 | 购买商品数量 |
|---|---|---|
| 25 | F | 1 |
| 28 | F | 23 |
| 61 | F | 3 |
| 54 | M | 17 |
| 51 | M | 8 |
| 47 | F | 3 |
| 27 | M | 22 |
| 31 | F | 14 |
这是一个我们的营销数据集的样本,其中每一行代表一个单独的客户,包含关于每个人的三个基本属性。我们的目标将是将这个数据集细分为不同类型或聚类的人群,以便进行数据分析的公司能更好地理解客户画像。
现在,当然,我们只展示了百万行中的一行,这可能会让人感到 daunting。当然,我们可以对这个数据集进行基本的描述性统计,并得到数值列的平均值、标准差等;然而,如果我们希望将这百万个人分成不同的类型,以便营销部门能更好地了解购物人群的类型,并为每个细分市场制作更合适的广告呢?
每种类型的客户都会表现出独特的品质,使该细分市场与众不同。例如,他们可能会发现,20%的客户属于他们喜欢称之为年轻且富有的类别,这些人通常较年轻,购买了几件商品。
这种分析和创建这些类型可以归入一种特定的无监督学习类型,称为聚类。我们将在本书的后面部分进一步讨论这种机器学习算法,但就目前而言,聚类将创建一个新的特征,将人们分离成不同的类型或聚类:
| 年龄 | 性别 | 购买商品数量 | 聚类 |
|---|---|---|---|
| 25 | F | 1 | 6 |
| 28 | F | 23 | 1 |
| 61 | F | 3 | 3 |
| 54 | M | 17 | 2 |
| 51 | M | 8 | 3 |
| 47 | F | 3 | 8 |
| 27 | M | 22 | 5 |
| 31 | F | 14 | 1 |
这显示了在应用聚类算法后的客户数据集。注意末尾的新列,称为cluster,它代表算法已识别的人的类型。想法是,属于相似聚类的人们在数据方面(年龄、性别、购买行为)会有相似的行为。也许可以将第六个聚类重命名为年轻买家。
这个聚类的例子告诉我们,有时我们并不关心预测任何事情,而是希望通过添加新的有趣特征或甚至删除无关特征,在更深的层面上理解我们的数据。
注意,我们之所以将每一列都称为特征,是因为在无监督学习中没有响应,因为没有预测发生。
现在这一切开始变得有道理了,不是吗?我们反复讨论的这些特征正是本书主要关注的内容。特征工程涉及对特征的理解和转换,这既与无监督学习相关,也与监督学习相关。
机器学习算法和特征工程流程的评估
重要的是要注意,在文献中,特征和属性这两个术语之间往往存在鲜明的对比。术语属性通常用于表格数据中的列,而术语特征通常仅用于对机器学习算法的成功有贡献的属性。也就是说,一些属性可能对我们的机器学习系统无益,甚至有害。例如,当预测一辆二手车在需要维修之前能使用多长时间时,车的颜色可能对这个值不太具有指示性。
在本书中,我们将通常将所有列都称为特征,直到它们被证明是无用的或有害的。当这种情况发生时,我们通常会在代码中将这些属性弃用。因此,考虑这个决策的基础非常重要。一个人如何评估机器学习系统,然后使用这种评估来执行特征工程?
特征工程流程的示例——真的有人能预测天气吗?
考虑一个旨在预测天气的机器学习流程。为了在引言章节中简化,假设我们的算法直接从传感器接收大气数据,并设置为预测两个值之一,太阳或雨。这个流程显然是一个分类流程,只能输出两个答案中的一个。我们将在每天开始时运行这个算法。如果算法输出太阳而当天大部分时间都是晴天,那么算法是正确的;同样,如果算法预测雨而当天大部分时间都是雨天,那么算法也是正确的。在任何其他情况下,算法将被认为是错误的。如果我们每天运行这个算法一个月,我们将获得近 30 个预测天气和实际观察天气的值。我们可以计算算法的准确率。也许算法在 30 天中有 20 天预测正确,这使我们将其标记为三分之二或大约 67%的准确率。使用这个标准化的值或准确率,我们可以调整我们的算法,看看准确率是上升还是下降。
当然,这是一个过于简化的说法,但想法是,对于任何机器学习流程,如果我们不能使用一组标准指标来评估其性能,那么它基本上是无用的。因此,应用于改进机器学习的特征工程,没有这种评估程序是不可能的。在本书中,我们将重新审视这种评估的想法;然而,让我们简要地谈谈我们通常将如何处理这个想法。
当面对特征工程的主题时,通常涉及转换我们的数据集(根据特征工程的定义)。为了确定特定的特征工程过程是否有助于我们的机器学习算法,我们将遵循以下章节中详细说明的步骤。
评估特征工程过程的步骤
评估特征工程过程的步骤:
-
在应用任何特征工程过程之前,获取机器学习模型的基线性能
-
应用特征工程和特征工程过程的组合
-
对于每次特征工程的应用,获取一个性能指标并将其与我们的基线性能进行比较
-
如果性能变化(delta)先于阈值(通常由人类定义),我们认为该过程是有益的,并将其应用于我们的机器学习流程
-
这种性能的变化通常以百分比来衡量(如果基线从 40%的准确率提高到 76%的准确率,那么这是一个 90%的提升)
在性能方面,这个想法在机器学习算法之间有所不同。大多数优秀的机器学习入门书籍都会告诉你,在数据科学实践中有数十种接受的指标。
在我们的案例中,因为这本书的重点不一定在机器学习,而是对特征的理解和转换,我们将使用基线机器学习算法和相关基线指标来评估特征工程过程。
评估监督学习算法
在进行预测建模,也称为监督学习时,性能直接与模型利用数据结构的能力以及使用该结构进行适当预测的能力相关。一般来说,我们可以将监督学习进一步细分为两种更具体的类型,分类(预测定性响应)和回归(预测定量响应)。
当我们评估分类问题时,我们将直接使用五折交叉验证计算逻辑回归模型的准确率:
# Example code for evaluating a classification problem
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
X = some_data_in_tabular_format
y = response_variable
lr = LinearRegression()
scores = cross_val_score(lr, X, y, cv=5, scoring='accuracy')
scores
>> [.765, .67, .8, .62, .99]
类似地,在评估回归问题时,我们将使用五折交叉验证的线性回归的均方误差(MSE):
# Example code for evaluating a regression problem
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import cross_val_score
X = some_data_in_tabular_format
y = response_variable
lr = LinearRegression()
scores = cross_val_score(lr, X, y, cv=5, scoring='mean_squared_error')
scores
>> [31.543, 29.5433, 32.543, 32.43, 27.5432]
我们将使用这两个线性模型而不是更新、更先进的模型,因为它们的速度更快,方差更低。这样,我们可以更有信心地认为任何性能的提升都是直接与特征工程过程相关,而不是与模型捕捉到隐秘和隐藏模式的能力相关。
评估无监督学习算法
这有点棘手。因为无监督学习不涉及预测,我们不能直接根据模型预测值的准确性来评估性能。话虽如此,如果我们正在进行聚类分析,例如在先前的市场细分示例中,那么我们通常会利用轮廓系数(一个介于-1 和 1 之间的聚类分离和凝聚度的度量)和一些人为驱动的分析来判断特征工程程序是否提高了模型性能,或者我们只是在浪费时间。
这里是一个使用 Python 和 scikit-learn 导入和计算一些虚假数据的轮廓系数的例子:
attributes = tabular_data
cluster_labels = outputted_labels_from_clustering
from sklearn.metrics import silhouette_score
silhouette_score(attributes, cluster_labels)
在本书的后续部分,我们将花更多的时间在无监督学习上,因为它变得更加相关。我们的大部分例子将围绕预测分析/监督学习展开。
重要的是要记住,我们标准化算法和指标的原因是为了展示特征工程的力量,以及让你能够成功重复我们的程序。实际上,你可能会优化除了准确率之外的其他东西(例如,例如真正的阳性率),并希望使用决策树而不是逻辑回归。这不仅是可以接受的,而且是鼓励的。然而,你应该始终记住,要遵循评估特征工程程序的标准步骤,并比较基线和工程后的性能。
有可能你不是为了提高机器学习性能而阅读这本书。特征工程在假设检验和一般统计学等其他领域也是有用的。在本书的一些例子中,我们将探讨特征工程和数据转换在应用于各种统计测试的统计显著性方面的应用。我们将探索诸如 R²和 p 值等指标,以便对我们的程序如何帮助做出判断。
通常,我们将从三个类别中量化特征工程的好处:
-
监督学习:也称为预测分析
-
回归分析——预测一个定量变量:
- 我们将利用均方误差(MSE)作为我们的主要度量指标
-
分类分析——预测一个定性变量
- 我们将利用准确率作为我们的主要度量指标
-
-
无监督学习:聚类——根据数据的特征分配元属性:
- 我们将利用轮廓系数作为我们的主要度量指标
-
统计测试:使用相关系数、t 检验、卡方检验等来评估和量化我们原始和转换数据的效用
在接下来的几节中,我们将查看本书将涵盖的内容。
特征理解——我的数据集中有什么?
在我们的第一个子主题中,我们将开始构建处理数据的基本知识。通过理解我们面前的数据,我们可以开始更好地了解下一步该去哪里。我们将开始探索不同类型的数据以及如何识别数据集中数据的类型。我们将查看来自几个领域的数据集,并确定它们彼此之间的不同之处以及它们之间的相似之处。一旦我们能够舒适地检查数据并识别不同属性的特征,我们就可以开始理解允许的类型以及承诺改进我们的机器学习算法的转换。
在不同的理解方法中,我们将探讨:
-
结构化数据与非结构化数据
-
数据的四个层次
-
识别缺失数据值
-
探索性数据分析
-
描述性统计
-
数据可视化
我们将从基本水平开始,通过识别我们面前数据的结构以及数据类型。一旦我们能够理解数据是什么,我们就可以开始解决数据的问题。例如,我们必须知道我们有多少数据是缺失的,以及当我们有缺失数据时应该做什么。
不要误解,数据可视化、描述性统计和探索性数据分析都是特征工程的一部分。我们将从机器学习工程师的角度探索这些程序中的每一个。每个程序都有能力增强我们的机器学习管道,我们将使用它们来测试和改变关于我们数据的假设。
特征改进——清理数据集
在这个主题中,我们利用我们对数据的理解,并使用这些理解来清理数据集。本书的大部分内容将以这种方式流动,使用前几节的结果来处理当前章节。在特征改进中,我们的理解将使我们开始对数据集进行第一次操作。我们将使用数学变换来增强给定的数据,但不会删除或插入任何新的属性(这将在下一章中讨论)。
在本节中,我们将探讨几个主题,包括:
-
结构化非结构化数据
-
数据插补——在之前没有数据的地方插入数据(缺失数据)
-
数据归一化:
-
标准化(称为 z 分数归一化)
-
Min-max 缩放
-
L1 和 L2 归一化(投影到不同的空间,很有趣)
-
到这本书的这一阶段,我们将能够确定我们的数据是否有结构。也就是说,我们的数据是否以整洁的表格格式存在。如果不是,这一章将为我们提供将数据转换为更表格化格式的工具。在尝试创建机器学习管道时,这是必不可少的。
数据插补是一个特别有趣的话题。在数据之前缺失的地方填补数据的能力比听起来要复杂。我们将提出各种解决方案,从非常简单的,仅仅删除整个列,Boom,不再有缺失数据,到复杂有趣的,使用机器学习在其余特征上填补缺失的部分。一旦我们填补了大量缺失数据,我们就可以衡量这如何影响我们的机器学习算法。
标准化使用(通常简单的)数学工具,用于改变我们数据的缩放。同样,这从简单的,如将英里转换为英尺或磅转换为千克,到更复杂的,如将我们的数据投影到单位球面上(关于这一点将在后面详细介绍)。
本章以及后续章节将更加侧重于我们的量化特征工程流程评估。几乎每次我们查看新的数据集或特征工程流程时,我们都会对其进行测试。我们将根据机器学习性能、速度和其他指标来评估各种特征工程方法的表现。本文本仅应作为参考,而不应作为根据难度和性能变化选择可忽略的特征工程流程的指南。每个新的数据任务都伴随着其自身的注意事项,可能需要与之前的数据任务不同的流程。
特征选择 – 对不良属性说“不”
到本章为止,我们将对处理新数据集感到更加自在。我们将具备理解和清理面前数据的技能。一旦我们能够处理给定的数据,我们就可以开始做出重大决策,例如,何时一个特征实际上是一个属性。回想一下,通过这种区分,特征与属性,真正的问题其实是,哪些列没有帮助我的机器学习流程,因此损害了我的流程,应该被移除?本章重点介绍用于决定在数据集中移除哪些属性的技巧。我们将探讨几种统计和迭代过程,这些过程将帮助我们做出这个决定。
这些过程包括:
-
相关系数
-
识别和消除多重共线性
-
卡方检验
-
安诺瓦检验
-
p 值的解释
-
迭代特征选择
-
使用机器学习来衡量熵和信息增益
所有这些流程都将尝试建议删除特征,并给出不同的理由。最终,将由我们,即数据科学家,来做出最终决定,决定哪些特征可以保留并贡献于我们的机器学习算法。
特征构建 – 我们能构建它吗?
在前面的章节中,我们主要关注移除对我们机器学习流程没有帮助的特征,而本章将探讨创建全新的特征并在我们的数据集中正确放置这些特征的技术。这些新特征理想情况下将包含新的信息,并生成新的模式,机器学习流程可以利用这些模式并用于提高性能。
这些创建的特征可以来自许多地方。通常,我们会从给定的现有特征中创建新的特征。我们可以通过对现有特征应用变换并放置结果向量与它们的先前对应向量旁边来创建新的特征。我们还将探讨从不同的系统添加新特征。例如,如果我们正在处理尝试根据购物行为对人群进行聚类的数据,那么添加来自公司及其购买数据之外的人口普查数据可能会对我们有所帮助。然而,这将带来一些问题:
-
如果人口普查知道 1,700 个约翰,而公司只知道 13 个,我们如何知道这 1,700 个人中的哪一个与这 13 个人匹配?这被称为实体匹配
-
人口普查数据将会相当大,实体匹配将需要非常长的时间
这些问题和更多的问题使得整个过程相当困难,但往往创造出非常密集且数据丰富的环境。
在本章中,我们将花一些时间来讨论通过高度无结构化的数据手动创建特征。两个大的例子是文本和图像。这些数据本身对机器学习和人工智能流程来说是不可理解的,因此我们需要手动创建代表图像/文本片段的特征。作为一个简单的例子,想象一下我们正在制作自动驾驶汽车的基础,首先,我们想要创建一个模型,它可以接收汽车前方看到的图像,并决定是否应该停车。原始图像不够好,因为机器学习算法不知道如何处理它。我们必须从其中手动构建特征。给定这个原始图像,我们可以以几种方式将其分割:
-
我们可以考虑每个像素的颜色强度,并将每个像素视为一个属性:
- 例如,如果汽车的摄像头产生 2,048 x 1,536 像素的图像,我们将有 3,145,728 列
-
我们可以将像素的每一行视为一个属性,每行的平均颜色作为其值:
- 在这种情况下,将只有 1,536 行
-
我们可以将这个图像投影到空间中,其中特征代表图像中的对象。这是三者中最难的,看起来可能像这样:
| 停止标志 | 猫 | 天空 | 道路 | 草地斑块 | 潜艇 |
|---|---|---|---|---|---|
| 1 | 0 | 1 | 1 | 4 | 0 |
其中每个特征都是一个可能或可能不在图像中的对象,其值表示该对象在图像中出现的次数。如果模型被提供了这些信息,停止就相当好了!
特征变换 – 进入数学达人
本章是事情变得数学化和有趣的地方。我们已经讨论了理解特征和清理它们。我们还探讨了如何删除和添加新特征。在我们的特征构建章节中,我们必须手动创建这些新特征。作为人类,我们必须用我们的大脑想出分解停车标志图像的那三种方法。当然,我们可以编写自动创建特征的代码,但最终我们选择了我们想要使用的特征。
本章将开始探讨这些特征在数学维度上的自动创建。如果我们把我们的数据视为 n 维空间(n 代表列数)中的向量,我们会问自己,我们能否在 k 维空间(k < n)中创建一个新的数据集,该数据集可以完全或近似地表示原始数据,但可能会在机器学习中提供速度提升或性能提升? 这里的目标是创建一个维度更小的数据集,其性能优于原始数据集在更大维度上的性能。
这里的第一个问题是,我们之前在特征选择时不是已经在创建低维度的数据了吗?如果我们从 17 个特征开始,去掉五个,我们不是已经将维度减少到 12 了吗? 当然是!然而,我们在这里讨论的不仅仅是去掉列,我们是在讨论使用复杂的数学变换(通常来自我们的线性代数研究)并将它们应用于我们的数据集。
我们将花一些时间讨论的一个显著例子被称为主成分分析(PCA)。*这是一种将我们的数据分解成三个不同数据集的变换,我们可以使用这些结果来创建全新的数据集,这些数据集可以超越我们的原始数据!
这里有一个来自普林斯顿大学研究实验的视觉示例,该实验使用了 PCA 来利用基因表达的模式。这是维度降低的一个很好的应用,因为有如此多的基因和基因组合,即使是世界上最复杂的算法也需要很长时间来处理:

在前面的屏幕截图中,A代表原始数据集,其中U、W和V^T代表奇异值分解的结果。然后,将这些结果组合起来,创建一个新的数据集,可以在一定程度上取代A。
特征学习 – 使用 AI 来提升我们的 AI
顶部的樱桃,一个由今天在机器学习和 AI 管道自动构建特征的最先进算法驱动的樱桃。
上一章讨论了使用数学公式自动创建特征,但再次强调,最终是我们人类选择公式并从中获益。本章将概述一些算法,这些算法本身并不是数学公式,而是一种试图以某种方式理解和建模数据以利用数据中的模式来创建新数据的架构。这听起来可能有些模糊,但我们希望让你对它感到兴奋!
我们将主要关注专门设计用于使用神经网络设计(节点和权重)的神经网络算法。这些算法将把特征强加到数据上,有时对人类来说可能难以理解,但对机器来说却非常有用。我们将探讨的一些主题包括:
-
限制性玻尔兹曼机
-
Word2Vec/GLoVe 用于词嵌入
Word2Vec 和 GLoVe 是将高维数据添加到文本中看似单词标记的两种方法。例如,如果我们查看 Word2Vec 算法结果的视觉表示,我们可能会看到以下内容:

通过将单词表示为欧几里得空间中的向量,我们可以实现类似数学的结果。在先前的例子中,通过添加这些自动生成的特征,我们可以通过添加和减去 Word2Vec 给出的向量表示来添加和减去单词。然后我们可以得出有趣的结论,例如king+man-woman=queen。酷!
摘要
特征工程是数据科学家和机器学习工程师要承担的巨大任务。这是成功和可投入生产的机器学习管道所必需的任务。在接下来的七个章节中,我们将探讨特征工程的六个主要方面:
-
特征理解:学习如何根据其质量和定量状态来识别数据
-
特征改进:清理和填充缺失数据值,以最大化数据集的价值
-
特征选择 - 统计选择和子集特征集,以减少我们数据中的噪声
-
特征构造 - 构建新的特征,目的是利用特征交互
-
特征转换 - 从数据集中提取潜在(隐藏)结构,以便将我们的数据集在数学上转换成新的(通常更好的)东西
-
特征学习 - 利用深度学习的力量以全新的视角看待数据,从而开辟新的问题来解决。
在这本书中,我们将探讨与我们的机器学习努力相关的特征工程。通过将这个大主题分解为我们的子主题,并在单独的章节中深入探讨每一个子主题,我们将能够获得更广泛、更有用的理解,了解这些过程是如何工作的,以及如何在 Python 中应用每一个过程。
在我们下一章中,我们将直接进入我们的第一个小节,特征理解。我们终于要接触一些真实数据了,所以让我们开始吧!
第二章:特征理解 – 我的数据集中有什么?
最后!我们可以开始深入研究一些真实的数据,一些真实的代码,以及一些真实的结果。具体来说,我们将更深入地探讨以下想法:
-
结构化数据与非结构化数据对比
-
定量数据与定性数据
-
数据的四个层次
-
探索性数据分析与数据可视化
-
描述性统计
这些每个主题都将使我们更好地了解我们得到的数据,数据集中有什么,数据集中没有什么,以及一些基本的概念,告诉我们如何从这里开始进行下一步。
如果你熟悉《数据科学原理》,那么其中很多内容都与那本书的第二章,数据类型相呼应。但话虽如此,在本章中,我们将从机器学习的角度,而不是整体的角度,具体地查看我们的数据。
数据的结构,或者缺乏结构
当你得到一个新的数据集时,首先重要的是要识别你的数据是有组织的还是非组织的:
-
结构化(有组织)数据:可以分解为观察和特征的数据。它们通常使用表格方法(其中行是观察,列是特征)进行组织。
-
非结构化(无组织)数据:以自由流动的实体存在的数据,不遵循标准组织层次结构,如表格化。通常,非结构化数据在我们看来像是一个数据块,或者是一个单一的特征(列)。
以下是一些突出显示结构化和非结构化数据之间差异的例子:
-
以原始文本形式存在的数据,包括服务器日志和推文,都是非结构化的。
-
由科学仪器精确测量的气象数据,由于其存在于表格化的行/列结构中,会被认为是高度结构化的。
非结构化数据的一个例子 – 服务器日志
作为非结构化数据的一个例子,我们从公共来源抽取了一些样本服务器日志,并将它们包含在一个文本文档中。我们可以一瞥这种非结构化数据的样子,以便我们将来能够识别它:
# Import our data manipulation tool, Pandas
import pandas as pd
# Create a pandas DataFrame from some unstructured Server Logs
logs = pd.read_table('../data/server_logs.txt', header=None, names=['Info'])
# header=None, specifies that the first line of data is the first data point, not a column name
# names=['Info] is me setting the column name in our DataFrame for easier access
我们在 pandas 中创建了一个名为logs的 DataFrame 来存储我们的服务器日志。为了查看,让我们调用.head()方法来查看前几行:
# Look at the first 5 rows
logs.head()
这将向我们展示我们的日志 DataFrame 中的前 5 行表格如下:
| 信息 | |
|---|---|
| 0 | 64.242.88.10 - - [07/Mar/2004:16:05:49 -0800] ... |
| 1 | 64.242.88.10 - - [07/Mar/2004:16:06:51 -0800] ... |
| 2 | 64.242.88.10 - - [07/Mar/2004:16:10:02 -0800] ... |
| 3 | 64.242.88.10 - - [07/Mar/2004:16:11:58 -0800] ... |
| 4 | 64.242.88.10 - - [07/Mar/2004:16:20:55 -0800] ... |
我们可以看到在我们的日志中,每一行代表一条单独的日志,并且只有一个列,即日志本身的文本。这并不是一个特征或其他东西,只是直接从服务器获取的原始日志。这是一个非结构化数据的绝佳例子。通常,以文本形式存在的数据通常是未结构化的。
重要的是要认识到,大多数非结构化数据可以通过一些操作转换为结构化数据,但这是我们在下一章将要解决的问题。
我们将在本书中处理的大部分数据将是结构化的。这意味着将有一种行和列的感觉。鉴于这一点,我们可以开始查看表格数据单元格中的值类型。
定性与定性数据
为了完成我们对各种类型数据的诊断,我们将从最高层次的分离开始。当我们处理结构化、表格数据时(我们通常是这样做的),我们通常问自己的第一个问题是值是数值的还是分类的。
定量数据是本质上是数值的数据。它们应该测量某物的数量。
定性数据是本质上是分类的数据。它们应该描述某物的质量。
基本示例:
-
以华氏度或摄氏度测量的天气是定量的
-
以多云或晴朗测量的天气是定性的
-
访问白宫的人的名字将是定性的
-
在献血活动中捐赠的血液量是定量的
前两个例子表明,我们可以使用定性和定量两方面的数据来描述类似的系统。事实上,在大多数数据集中,我们将同时处理定性和定量数据。
有时,数据可以被认为是定量的或定性的。例如,你给餐厅评的等级(一星到五星级)可以被认为是定量的或定性的。虽然它们是数字,但这些数字本身也可能代表类别。例如,如果餐厅评分应用要求你使用定量的星级系统来评分,那么餐厅的平均排名可能是一个小数,比如 4.71 星,这使得数据成为定量的。同时,如果应用要求你表达你对餐厅的“讨厌”、“一般”、“喜欢”、“热爱”或“非常热爱”,那么这些现在就是类别。由于这些定量和定性数据之间的模糊性,我们采用了一种更深入的方法,称为数据四层次法。在我们这样做之前,让我们介绍本章的第一个数据集,并真正巩固一些定性和定量数据的例子。
按职业分类的薪资范围
让我们先做一些导入语句:
# import packages we need for exploratory data analysis (EDA)
# to store tabular data
import pandas as pd
# to do some math
import numpy as np
# a popular data visualization tool
import matplotlib.pyplot as plt
# another popular data visualization tool
import seaborn as sns
# allows the notebook to render graphics
%matplotlib inline
# a popular data visualization theme
plt.style.use('fivethirtyeight')
然后,让我们导入我们的第一个数据集,该数据集将探讨旧金山不同职位的薪资。这个数据集是公开的,因此你被鼓励尽可能多地玩弄它:
# load in the data set
# https://data.sfgov.org/City-Management-and-Ethics/Salary-Ranges-by-Job-Classification/7h4w-reyq
salary_ranges = pd.read_csv('../data/Salary_Ranges_by_Job_Classification.csv')
# view the first few rows and the headers
salary_ranges.head()
让我们看一下以下表格,以更好地理解:
| SetID | Job Code | Eff Date | Sal End Date | Salary SetID | Sal Plan | Grade | Step | Biweekly High Rate | Biweekly Low Rate | Union Code | Extended Step | Pay Type | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | COMMN | 0109 | 2009 年 07 月 01 日 12:00:00 AM | 2010 年 06 月 30 日 12:00:00 AM | COMMN | SFM | 00000 | 1 | $0.00 | $0.00 | 330 | 0 | C | |
| 1 | COMMN | 0110 | 2009 年 07 月 01 日 12:00:00 AM | 2010 年 06 月 30 日 12:00:00 AM | COMMN | SFM | 00000 | 1 | $15.00 | $15.00 | 323 | 0 | D | |
| 2 | COMMN | 0111 | 2009 年 07 月 01 日 12:00:00 AM | 2010 年 06 月 30 日 12:00:00 AM | COMMN | SFM | 00000 | 1 | $25.00 | $25.00 | 323 | 0 | D | |
| 3 | COMMN | 0112 | 2009 年 07 月 01 日 12:00:00 AM | 2010 年 06 月 30 日 12:00:00 AM | COMMN | SFM | 00000 | 1 | $50.00 | $50.00 | 323 | 0 | D | |
| 4 | COMMN | 0114 | 2009 年 07 月 01 日 12:00:00 AM | 2010 年 06 月 30 日 12:00:00 AM | COMMN | SFM | 00000 | 1 | $100.00 | $100.00 | 323 | 0 | M |
我们可以看到,我们有一系列列,其中一些已经很明显是定量或定性列。让我们使用.info()命令来了解数据中有多少行数据:
# get a sense of how many rows of data there are, if there are any missing values, and what data type each column has
salary_ranges.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1356 entries, 0 to 1355
Data columns (total 13 columns):
SetID 1356 non-null object
Job Code 1356 non-null object
Eff Date 1356 non-null object
Sal End Date 1356 non-null object
Salary SetID 1356 non-null object
Sal Plan 1356 non-null object
Grade 1356 non-null object
Step 1356 non-null int64
Biweekly High Rate 1356 non-null object
Biweekly Low Rate 1356 non-null object
Union Code 1356 non-null int64
Extended Step 1356 non-null int64
Pay Type 1356 non-null object
dtypes: int64(3), object(10)
memory usage: 137.8+ KB
因此,我们有1356条记录(行)和13列。.info()命令还告诉我们每列中非空项的数量。这很重要,因为缺失数据是特征工程中最常见的问题之一。有时,我们处理的是不完整的数据集。在 pandas 中,我们有多种方法来找出我们是否在处理缺失数据,以及多种处理它们的方法。快速且常见的一种方法是运行:
# another method to check for missing values
salary_ranges.isnull().sum()
SetID 0
Job Code 0
Eff Date 0
Sal End Date 0
Salary SetID 0
Sal Plan 0
Grade 0
Step 0
Biweekly High Rate 0
Biweekly Low Rate 0
Union Code 0
Extended Step 0
Pay Type 0
dtype: int64
因此,我们看到在这个数据集中我们没有遗漏任何数据片段,谢天谢地(目前如此)。接下来,让我们运行describe方法来查看我们定量列的一些描述性统计信息(我们本应该有的)。请注意,describe方法默认描述定量列,但如果没有定量列,则会描述定性列:
# show descriptive stats:
salary_ranges.describe()
让我们看一下以下表格,以更好地理解这一点:
| Step | Union Code | Extended Step | |
|---|---|---|---|
| 计数 | 1356.000000 | 1356.000000 | 1356.000000 |
| 平均值 | 1.294985 | 392.676991 | 0.150442 |
| 标准差 | 1.045816 | 338.100562 | 1.006734 |
| 最小值 | 1.000000 | 1.000000 | 0.000000 |
| 25% | 1.000000 | 21.000000 | 0.000000 |
| 50% | 1.000000 | 351.000000 | 0.000000 |
| 75% | 1.000000 | 790.000000 | 0.000000 |
| 最大值 | 5.000000 | 990.000000 | 11.000000 |
根据 pandas,我们只有三个定量列:Step、Union Code和Extended Step。现在我们先忽略Step和Extended Step,同时注意Union Code实际上并不是定量列。虽然它是一个数字,但它并不真正代表某种数量的东西,它只是通过唯一的编码来描述联合。因此,我们需要做一些工作来理解我们更感兴趣的特性。最值得注意的是,比如说我们希望提取一个单一的定量列,即Biweekly High Rate,以及一个单一的定性列,即Grade(工作类型):
salary_ranges = salary_ranges[['Biweekly High Rate', 'Grade']]
salary_ranges.head()
以下是在前面的代码运行后的结果:

为了清理这些列,让我们从工资率中移除那些美元符号($),并确保列是正确的类型。当我们处理定量列时,我们通常希望它们是整数或浮点数(浮点数更受欢迎),而定性列通常是字符串或 Unicode 对象:
# Rate has dollar signs in a few of them, we need to clean that up..
salary_ranges['Biweekly High Rate'].describe()
count 1356
unique 593
top $3460.00
freq 12
Name: Biweekly High Rate, dtype: object
为了清理这个列,让我们使用 pandas 中的映射功能来高效地将函数映射到整个数据序列:
# need to clean our Biweekly High columns to remove the dollar sign in order to visualize
salary_ranges['Biweekly High Rate'] = salary_ranges['Biweekly High Rate'].map(lambda value: value.replace('$',''))
# Check to see the '$' has been removed
salary_ranges.head()
下表让我们更好地理解这里的情况:
| Biweekly High Rate | Grade | |
|---|---|---|
| 0 | 0.00 | 00000 |
| 1 | 15.00 | 00000 |
| 2 | 25.00 | 00000 |
| 3 | 50.00 | 00000 |
| 4 | 100.00 | 00000 |
为了完成我们对Biweekly High Rate列的转换,我们将整个列转换为float:
# Convert the Biweeky columns to float
salary_ranges['Biweekly High Rate'] = salary_ranges['Biweekly High Rate'].astype(float)
当我们在铸造时,也让Grade列作为一个字符串进行铸造:
# Convert the Grade columns to str
salary_ranges['Grade'] = salary_ranges['Grade'].astype(str)
# check to see if converting the data types worked
salary_ranges.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1356 entries, 0 to 1355
Data columns (total 2 columns):
Biweekly High Rate 1356 non-null float64
Grade 1356 non-null object
dtypes: float64(1), object(1)
memory usage: 21.3+ KB
我们看到我们现在总共有:
-
1,356 行(就像我们开始时一样)
-
两个列(我们选择的):
-
双周高率:一个指特定部门平均每周工资的定量列:
-
这个列是定量的,因为值是数值性的,描述了个人每周赚取的金额
-
它是浮点类型,我们将其转换为
-
-
Grade:工资所参考的部门:
-
这个列肯定是定性的,因为代码指的是一个部门,而不是任何类型的数量
-
它是对象类型,这是 pandas 在它是字符串时会规定的类型
-
-
为了进一步区分定量和定性数据,让我们深入了解数据的四个级别。
数据的四个级别
我们已经知道我们可以将数据识别为定性或定量。但是,从这里我们可以更进一步。数据的四个级别是:
-
名义级别
-
序数级别
-
间隔级别
-
比率级别
每个级别都伴随着不同级别的控制和数学可能性。知道数据处于哪个级别至关重要,因为它将决定你可以执行的可视化和操作类型。
名义级别
数据的第一级,名义级别,结构最弱。它由纯粹通过名称描述的数据组成。基本例子包括血型(A,O,AB)、动物物种或人名。这些类型的数据都是定性的。
一些其他例子包括:
-
在
SF Job Salary数据集中,Grade列将是名义的 -
给定一家公司的访客日志,访客的姓名和姓氏将是名义的
-
实验室动物物种将是名义的
允许的数学运算
在每个水平上,我们将简要描述允许的数学类型,更重要的是,不允许的数学类型。在这个水平上,我们不能执行任何定量数学运算,如加法或除法。这些都没有意义。由于缺乏加法和除法,我们显然不能在名义水平上找到平均值。没有平均名称或平均职业部门。
我们可以使用 pandas 的 value_counts 方法进行基本的计数:
# Basic Value Counts of the Grade column
salary_ranges['Grade'].value_counts().head()
00000 61
07450 12
06870 9
07170 9
07420 9
Name: Grade, dtype: int64
最常见的 Grade 是 00000,这意味着这是我们众数或最常出现的类别。由于我们在名义水平上能够计数,因此像条形图这样的图表对我们是可用的:
# Bar Chart of the Grade column salary_ranges['Grade'].value_counts().sort_values(ascending=False).head(20).plot(kind='bar')
以下为前述代码的结果:

在名义水平上,我们也可以使用饼图:
# Bar Chart of the Grade column as a pie chart (top 5 values only)
salary_ranges['Grade'].value_counts().sort_values(ascending=False).head(5).plot(kind='pie')
以下为前述代码的输出:

序数水平
名义水平为我们提供了许多进一步探索的能力。提升一个水平,我们现在处于序数尺度。序数尺度继承了名义水平的所有属性,但具有重要的附加属性:
-
序数水平的数据可以自然排序
-
这意味着列中的某些数据值可以被认为是比其他数据值更好或更大的
与名义水平一样,序数水平的数据本质上仍然是分类的,即使使用数字来表示类别。
允许的数学运算
与名义水平相比,我们在序数水平上有一些新的能力。在序数水平上,我们可能仍然像在名义水平上那样进行基本的计数,但我们可以引入比较和排序。因此,我们可能在这个层面上使用新的图表。我们可能使用条形图和饼图,就像在名义水平上那样,但由于我们现在有了排序和比较,我们可以计算中位数和百分位数。有了中位数和百分位数,茎叶图以及箱线图都是可能的。
序数水平数据的例子包括:
-
使用李克特量表(例如,在 1 到 10 的量表上对某物进行评分)
-
考试的等级水平(F,D,C,B,A)
为了提供一个序数尺度数据的现实世界例子,让我们引入一个新的数据集。这个数据集包含关于人们享受旧金山国际机场(SFO)的关键见解。这个数据集也公开在旧金山的开放数据库上(data.sfgov.org/Transportation/2013-SFO-Customer-Survey/mjr8-p6m5):
# load in the data set
customer = pd.read_csv('../data/2013_SFO_Customer_survey.csv')
这个 CSV 有很多列:
customer.shape
(3535, 95)
95列,确切地说。有关此数据集可用的列的更多信息,请查看网站上的数据字典(data.sfgov.org/api/views/mjr8-p6m5/files/FHnAUtMCD0C8CyLD3jqZ1-Xd1aap8L086KLWQ9SKZ_8?download=true&filename=AIR_DataDictionary_2013-SFO-Customer-Survey.pdf)
现在,让我们专注于一个单独的列,Q7A_ART。根据公开可用的数据字典描述,Q7A_ART是关于艺术品和展览的。可能的选项是 0,1,2,3,4,5,6,每个数字都有其含义:
-
1:不可接受
-
2:低于平均水平
-
3:平均
-
4:良好
-
5:杰出
-
6:从未使用或访问过
-
0:空白
我们可以表示如下:
art_ratings = customer['Q7A_ART']
art_ratings.describe()
count 3535.000000
mean 4.300707
std 1.341445
min 0.000000
25% 3.000000
50% 4.000000
75% 5.000000
max 6.000000
Name: Q7A_ART, dtype: float64
Pandas 正在考虑将列数值化,因为它充满了数字,然而,我们必须记住,尽管单元格的值是数字,但这些数字代表一个类别,因此这些数据属于定性方面,更具体地说,是序数。如果我们移除0和6类别,我们将剩下五个序数类别,基本上类似于餐厅评分的星级:
# only consider ratings 1-5
art_ratings = art_ratings[(art_ratings >=1) & (art_ratings <=5)]
我们然后将值转换为字符串:
# cast the values as strings
art_ratings = art_ratings.astype(str)
art_ratings.describe()
count 2656
unique 5
top 4
freq 1066
Name: Q7A_ART, dtype: object
现在我们已经将序数数据格式化正确,让我们看看一些可视化:
# Can use pie charts, just like in nominal level
art_ratings.value_counts().plot(kind='pie')
以下是对前面代码的结果:

我们也可以将其可视化如下:
# Can use bar charts, just like in nominal level
art_ratings.value_counts().plot(kind='bar')
以下是对前面代码的输出:

然而,现在我们也可以引入箱线图,因为我们处于序数级别:
# Boxplots are available at the ordinal level
art_ratings.value_counts().plot(kind='box')
以下是对前面代码的输出:

这个箱线图对于工资数据的Grade列是不可能的,因为找到中位数是不可能的。
序数水平
我们现在开始用煤气做饭了。在名义和序数水平上,我们处理的是定性数据。有些数据并没有描述真正的数量。在区间水平上,我们摆脱了这个概念,进入了定量数据。在区间数据水平上,我们处理的是数值数据,这些数据不仅像在序数水平上那样有顺序,而且值之间还有有意义的差异。这意味着在区间水平上,我们不仅可以排序和比较值,还可以加和减值。
示例:
数据在区间水平的经典例子是温度。如果德克萨斯州是 90 度,阿拉斯加是 40 度,那么我们可以在两个地点之间计算出 90-40=50 度的温差。这可能看起来是一个非常简单的例子,但回想一下前两个水平,我们以前从未对我们的数据有如此程度的控制。
非示例:
数据不在区间水平上的一个经典非例子是李克特量表。我们已经确定了李克特量表在序数水平上的能力,即其可排序性,但重要的是要注意,减法并没有真正的、一致的意义。如果我们从李克特量表上减去 5-3,得到的 2 实际上并不代表数字 2,也不代表类别 2。因此,李克特量表中的减法是困难的。
允许进行数学运算
记住,在区间水平上,我们有加法和减法可以操作。这是一个真正的变革。有了将值相加的能力,我们可以引入两个熟悉的概念,即算术平均数(简称为均值)和标准差。在区间水平上,这两个概念都对我们可用。为了看到这个的一个很好的例子,让我们引入一个新的数据集,一个关于气候变化的数据集:
# load in the data set
climate = pd.read_csv('../data/GlobalLandTemperaturesByCity.csv')
climate.head()
让我们查看以下表格以获得更好的理解:
| dt | 平均温度 | 平均温度不确定性 | 城市 | 国家 | 纬度 | 经度 | |
|---|---|---|---|---|---|---|---|
| 0 | 1743-11-01 | 6.068 | 1.737 | 奥胡斯 | 丹麦 | 57.05N | 10.33E |
| 1 | 1743-12-01 | NaN | NaN | 奥胡斯 | 丹麦 | 57.05N | 10.33E |
| 2 | 1744-01-01 | NaN | NaN | 奥胡斯 | 丹麦 | 57.05N | 10.33E |
| 3 | 1744-02-01 | NaN | NaN | 奥胡斯 | 丹麦 | 57.05N | 10.33E |
| 4 | 1744-03-01 | NaN | NaN | 奥胡斯 | 丹麦 | 57.05N | 10.33E |
这个数据集有 860 万行,其中每一行按月份量化了从 18 世纪以来世界各地城市的平均温度。注意,仅通过查看前五行,我们已经有了一些缺失值。现在让我们先移除它们,以便更好地观察:
# remove missing values
climate.dropna(axis=0, inplace=True)
climate.head() . # check to see that missing values are gone
下表能让我们更好地理解这一点:
| dt | 平均温度 | 平均温度不确定性 | 城市 | 国家 | 纬度 | 经度 | |
|---|---|---|---|---|---|---|---|
| 0 | 1743-11-01 | 6.068 | 1.737 | 奥胡斯 | 丹麦 | 57.05N | 10.33E |
| 5 | 1744-04-01 | 5.788 | 3.624 | 奥胡斯 | 丹麦 | 57.05N | 10.33E |
| 6 | 1744-05-01 | 10.644 | 1.283 | 奥胡斯 | 丹麦 | 57.05N | 10.33E |
| 7 | 1744-06-01 | 14.051 | 1.347 | 奥胡斯 | 丹麦 | 57.05N | 10.33E |
| 8 | 1744-07-01 | 16.082 | 1.396 | 奥胡斯 | 丹麦 | 57.05N | 10.33E |
让我们看看以下代码行是否有任何缺失值:
climate.isnull().sum()
dt 0
AverageTemperature 0
AverageTemperatureUncertainty 0
City 0
Country 0
Latitude 0
Longitude 0
year 0
dtype: int64
# All good
有关的问题列被称为 平均温度。数据在区间水平上的一个特点是,由于我们拥有太多的值,因此我们不能在这里使用柱状图/饼图:
# show us the number of unique items
climate['AverageTemperature'].nunique()
111994
111,994 个值绘制出来是荒谬的,而且也是荒谬的,因为我们知道数据是定量的。在这个水平上,最常用的图表可能是直方图。这种图表是柱状图的亲戚,它将数量分成桶,并显示这些桶的频率。
让我们看看全球平均温度的直方图,以从非常全面的角度查看温度分布:
climate['AverageTemperature'].hist()
下面的代码输出如下:

在这里,我们可以看到平均值为 20°C。让我们来确认这一点:
climate['AverageTemperature'].describe()
count 8.235082e+06 mean 1.672743e+01 std 1.035344e+01 min -4.270400e+01 25% 1.029900e+01 50% 1.883100e+01 75% 2.521000e+01 max 3.965100e+01 Name: AverageTemperature, dtype: float64
我们很接近。平均值似乎在 17°左右。让我们让这个更有趣一些,并添加新的列称为year和century,并且仅将数据子集为美国记录的温度:
# Convert the dt column to datetime and extract the year
climate['dt'] = pd.to_datetime(climate['dt'])
climate['year'] = climate['dt'].map(lambda value: value.year)
climate_sub_us['century'] = climate_sub_us['year'].map(lambda x: x/100+1)
# 1983 would become 20
# 1750 would become 18
# A subset the data to just the US
climate_sub_us = climate.loc[climate['Country'] == 'United States']
使用新的century列,让我们绘制四个温度直方图,每个世纪一个:
climate_sub_us['AverageTemperature'].hist(by=climate_sub_us['century'],
sharex=True, sharey=True,
figsize=(10, 10),
bins=20)
下面的代码输出如下:

在这里,我们有我们的四个直方图,显示平均温度略有上升。让我们来确认这一点:
climate_sub_us.groupby('century')['AverageTemperature'].mean().plot(kind='line')
下面的代码输出如下:

很有趣!由于在这个层面上差异是显著的,我们可以回答自 18 世纪以来,美国平均温度上升了多少的问题。首先,让我们将几个世纪的温度变化存储为其自己的 pandas Series 对象:
century_changes = climate_sub_us.groupby('century')['AverageTemperature'].mean()
century_changes
century 18 12.073243 19 13.662870 20 14.386622 21 15.197692 Name: AverageTemperature, dtype: float64
现在,让我们使用 Series 的索引来从 21 世纪的值中减去 18 世纪的值,以得到温度差异:
# 21st century average temp in US minus 18th century average temp in US
century_changes[21] - century_changes[18]
# average difference in monthly recorded temperature in the US since the 18th century
3.12444911546
在间隔级别绘制两个列
在具有间隔级别或更高级别的两个数据列的情况下,有一个很大的优势,那就是它使我们能够使用散点图,我们可以将两个数据列绘制在我们的轴上,并将数据点可视化为图上的实际点。我们的“气候变化”数据集的year和averageTemperature列都处于间隔级别,因为它们都有意义差异,所以让我们尝试绘制所有记录的美国月度温度的散点图,其中x轴将是年份,y轴将是温度。我们希望注意到温度的趋势性上升,正如之前的线图所暗示的:
x = climate_sub_us['year']
y = climate_sub_us['AverageTemperature']
fig, ax = plt.subplots(figsize=(10,5))
ax.scatter(x, y)
plt.show()
下面的代码输出如下:

哎呀,这看起来不太美观。似乎有很多噪声,这是可以预料的。每年都有多个城镇报告多个平均温度,因此我们每年看到许多垂直点是有道理的。
让我们使用年份列的groupby来消除大部分噪声:
# Let's use a groupby to reduce the amount of noise in the US
climate_sub_us.groupby('year').mean()['AverageTemperature'].plot()
下面的代码输出如下:

好多了!我们确实可以看到年份的变化,但让我们通过在年份上取滚动平均值来稍微平滑一下:
# A moving average to smooth it all out:
climate_sub_us.groupby('year').mean()['AverageTemperature'].rolling(10).mean().plot()
下面的代码输出如下:

因此,我们绘制间隔级别两个数据列的能力再次证实了之前的线图所暗示的;似乎美国平均温度确实有普遍上升的趋势。
数据的区间级别为我们提供了对数据的新理解层面,但我们还没有完成。
比率级别
最后,我们上升到最高级别,即比率级别。在这个层面上,我们可以说我们拥有最高程度的控制和可用数学。在比率级别,就像区间级别一样,我们仍在处理定量数据。我们从区间级别继承了加法和减法,但现在我们有一个关于真正零的概念,这使我们能够乘除数值。
允许的数学运算
在比率级别,我们可以一起乘除数值。这看起来可能不是什么大事,但它确实允许我们对这个层面的数据进行独特的观察,而在较低级别则无法做到。让我们通过一些例子来具体了解这意味着什么。
示例:
当处理财务数据时,我们几乎总是需要处理一些货币价值。货币处于比率层面,因为我们有一个“没有钱”的概念。因此,我们可能会做出如下陈述:
-
$100 是$50 的两倍,因为 100/50 = 2
-
10 毫克的青霉素是 20 毫克青霉素的一半,因为 10/20 = 0.5
正是因为存在零,比率在这个层面上才有意义。
非示例:
我们通常认为温度处于区间级别而不是比率级别,因为说 100 度是 50 度的两倍没有意义。这并不完全合理。温度是非常主观的,这并不是客观正确的。
可以认为摄氏度和华氏度有一个起点主要是因为我们可以将它们转换为开尔文,而开尔文确实有一个真正的零。实际上,因为摄氏度和华氏度允许负值,而开尔文不允许;所以摄氏度和华氏度都没有真正的真正零,而开尔文有。
回到旧金山的薪酬数据,我们现在看到薪酬周薪率处于比率级别,在那里我们可以开始做出新的观察。让我们从查看最高薪酬开始:
# Which Grade has the highest Biweekly high rate
# What is the average rate across all of the Grades
fig = plt.figure(figsize=(15,5))
ax = fig.gca()
salary_ranges.groupby('Grade')[['Biweekly High Rate']].mean().sort_values(
'Biweekly High Rate', ascending=False).head(20).plot.bar(stacked=False, ax=ax, color='darkorange')
ax.set_title('Top 20 Grade by Mean Biweekly High Rate')
以下是在前面的代码中的输出:

如果我们查看以下在旧金山公共记录中找到的最高薪酬:
我们看到它是总经理,公共交通部门。让我们采用类似的策略来查看最低薪酬的工作:
# Which Grade has the lowest Biweekly high rate
fig = plt.figure(figsize=(15,5))
ax = fig.gca()
salary_ranges.groupby('Grade')[['Biweekly High Rate']].mean().sort_values(
'Biweekly High Rate', ascending=False).tail(20).plot.bar(stacked=False, ax=ax, color='darkorange')
ax.set_title('Bottom 20 Grade by Mean Biweekly High Rate')
以下是在前面的代码中的输出:

再次,查看最低薪酬的工作,我们看到它是一个营地助理。
因为货币处于比率级别,我们也可以找到最高薪酬员工与最低薪酬员工的比率:
sorted_df = salary_ranges.groupby('Grade')[['Biweekly High Rate']].mean().sort_values(
'Biweekly High Rate', ascending=False)
sorted_df.iloc[0][0] / sorted_df.iloc[-1][0]
13.931919540229886
最高薪的员工是最低城市员工的 14 倍。感谢比率水平!
数据水平的总结
理解数据的各种水平是进行特征工程所必需的。当涉及到构建新特征或修复旧特征时,我们必须有识别如何处理每一列的方法。
这里是一个快速表格,总结了每个水平上可能和不可能的事情:
| 测量水平 | 属性 | 例子 | 描述性统计 | 图表 |
|---|---|---|---|---|
| 名义 | 离散 无序 | 二元响应(真或假)人的名字 油漆的颜色 | 频率/百分比 模式 | 条形图 饼图 |
| 有序 | 有序类别 比较对比 | 李克特量表 考试成绩 | 频率 模式 中位数 百分位数 | 条形图 饼图 茎叶图 |
| 间隔 | 有序值之间的差异具有意义 | 摄氏度或华氏度某些李克特量表(必须是具体的) | 频率 模式 中位数 平均值 标准差 | 条形图 饼图
茎叶图 箱线图 直方图
| 比率 | 连续的 0 允许比率陈述(例如,$100 是 $50 的两倍) | 货币 重量 | 平均值 标准差 | 直方图 箱线图 |
|---|
下表显示了每个水平允许的统计类型:
| 统计量 | 名义 | 有序 | 间隔 | 比率 |
|---|---|---|---|---|
| 模式 | √ | √ | √ | 有时 |
| 中位数 | X | √ | √ | √ |
| 范围、最小值 最大值 | X | √ | √ | √ |
| 平均值 | X | X | √ | √ |
| 标准差 | X | X | √ | √ |
最后,以下是一个表格,显示了每个水平上可能和不可能的图表:
| 图表 | 名义 | 有序 | 间隔 | 比率 |
|---|---|---|---|---|
| 条形图/饼图 | √ | √ | 有时 | X |
| 茎叶图 | X | √ | √ | √ |
| 箱线图 | X | √ | √ | √ |
| 直方图 | X | X | 有时 | √ |
面对新的数据集时,以下是一个基本的流程:
-
数据是有序的还是无序的?我们的数据是否以具有明确行和列的表格格式存在,或者是否以无结构的文本混乱存在?
-
每一列是定量还是定性?单元格中的值是代表数量的数字,还是不表示数量的字符串?
-
每一列数据处于什么水平?这些值是名义水平、有序水平、间隔水平还是比率水平?
-
我可以利用哪些图表来可视化我的数据——条形图、饼图、箱线图、直方图等等?
这里是这个流程的视觉表示:

摘要
理解我们正在处理的特征是特征工程的第一步。如果我们不能理解给定的数据,我们就永远无法希望修复、创建和利用特征,以便创建表现良好的机器学习管道。在本章中,我们能够识别并从我们的数据集中提取数据水平,并使用这些信息创建有用的和有意义的视觉图表,为我们的数据带来新的洞察。
在下一章中,我们将利用这些新发现的数据层级知识来开始改进我们的特性,并且我们将开始使用机器学习来有效地衡量我们特性工程流程的影响。
第三章:特征改进 - 清洗数据集
在前两章中,我们已从谈论特征工程的基本理解及其如何用于增强我们的机器学习流程,过渡到实际操作数据集,评估和理解在野外可能遇到的不同类型的数据。
在本章中,我们将运用所学知识,更进一步,开始改变我们使用的数据集。具体来说,我们将开始对数据集进行清洗和增强。通过清洗,我们通常指的是改变已经给定的列和行。通过增强,我们通常指的是从数据集中移除列和添加列的过程。正如往常一样,我们所有这些过程中的目标是提升我们的机器学习流程。
在接下来的章节中,我们将进行以下操作:
-
识别数据中的缺失值
-
移除有害数据
-
填充(补充)这些缺失值
-
正则化/标准化数据
-
构建全新的特征
-
手动和自动选择(移除)特征
-
使用数学矩阵计算将数据集转换为不同的维度
这些方法将帮助我们更好地了解数据中哪些特征是重要的。在本章中,我们将深入探讨前四种方法,并将其他三种方法留待未来章节讨论。
识别数据中的缺失值
我们识别缺失值的第一种方法是为了更好地理解如何处理现实世界的数据。通常,数据可能由于各种原因而存在缺失值,例如在调查数据中,一些观察结果可能没有被记录。对我们来说,分析数据并了解缺失值是什么,以便我们决定如何处理机器学习中的缺失值是很重要的。首先,让我们深入研究一个在本章期间我们将感兴趣的数据集,即皮马印第安糖尿病预测数据集。
皮马印第安糖尿病预测数据集
该数据集可在以下 UCI 机器学习仓库中找到:
archive.ics.uci.edu/ml/datasets/pima+indians+diabetes.
从主网站上,我们可以了解一些关于这个公开数据集的信息。我们共有九列和 768 个实例(行)。这个数据集主要用于预测 21 岁以上的皮马印第安女性在五年内是否会患上糖尿病,前提是提供她们的医疗详细信息。
该数据集旨在对应一个二元(双分类)机器学习问题。即,回答问题:这个人五年内会患上糖尿病吗? 列名如下(按顺序):
-
怀孕次数
-
口服葡萄糖耐量测试 2 小时后的血浆葡萄糖浓度
-
舒张压(毫米汞柱)
-
三角肌皮肤褶皱厚度(毫米)
-
2 小时血清胰岛素测量(微 U/ml)
-
体重指数(千克/(米)²)
-
糖尿病家系函数
-
年龄(年)
-
类变量(零或一)
数据集的目标是能够预测class变量的最后一列,该变量预测患者是否患有糖尿病,使用其他八个特征作为机器学习函数的输入。
我们将使用这个数据集有两个非常重要的原因:
-
我们将不得不处理缺失值
-
我们将使用的所有特征都将是有量的
目前来说,第一个点作为一个原因更有意义,因为本章的目的是处理缺失值。如果我们只选择处理定量数据,这种情况将仅限于本章。我们没有足够的工具来处理分类列中的缺失值。在下一章,当我们讨论特征构建时,我们将处理这个流程。
探索性数据分析(EDA)
为了识别我们的缺失值,我们将从我们的数据集的 EDA 开始。我们将使用一些有用的 Python 包,如 pandas 和 numpy,来存储我们的数据并进行一些简单的计算,以及一些流行的可视化工具来查看我们的数据分布情况。让我们开始并深入一些代码。首先,我们将进行一些导入:
# import packages we need for exploratory data analysis (EDA)
import pandas as pd # to store tabular data
import numpy as np # to do some math
import matplotlib.pyplot as plt # a popular data visualization tool
import seaborn as sns # another popular data visualization tool
%matplotlib inline
plt.style.use('fivethirtyeight') # a popular data visualization theme
我们将通过 CSV 导入我们的表格数据,如下所示:
# load in our dataset using pandas
pima = pd.read_csv('../data/pima.data')
pima.head()
head方法允许我们查看数据集的前几行。输出如下:
| 6 | 148 | 72 | 35 | 0 | 33.6 | 0.627 | 50 | 1 | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 1 | 85 | 66 | 29 | 0 | 26.6 | 0.351 | 31 | 0 |
| 1 | 8 | 183 | 64 | 0 | 0 | 23.3 | 0.627 | 32 | 1 |
| 2 | 1 | 89 | 66 | 23 | 94 | 28.1 | 0.167 | 21 | 0 |
| 3 | 0 | 137 | 40 | 35 | 168 | 43.1 | 2.288 | 33 | 1 |
| 4 | 5 | 116 | 74 | 0 | 0 | 25.6 | 0.201 | 30 | 0 |
这里有些不对劲,没有列名。CSV 文件中可能没有将列名嵌入到文件中。没关系,我们可以使用数据源的网站来填写这些信息,如下面的代码所示:
pima_column_names = ['times_pregnant', 'plasma_glucose_concentration', 'diastolic_blood_pressure', 'triceps_thickness', 'serum_insulin', 'bmi', 'pedigree_function', 'age', 'onset_diabetes']
pima = pd.read_csv('../data/pima.data', names=pima_column_names)
pima.head()
现在,再次使用head方法,我们可以看到带有适当标题的列。前面代码的输出如下:
| 怀孕次数 | 血浆葡萄糖浓度 | 舒张压 | 三头肌厚度 | 血清胰岛素 | BMI | 家系函数 | 年龄 | 糖尿病发病时间 | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 6 | 148 | 72 | 35 | 0 | 33.6 | 0.627 | 50 | 1 |
| 1 | 1 | 85 | 66 | 29 | 0 | 26.6 | 0.351 | 31 | 0 |
| 2 | 8 | 183 | 64 | 0 | 0 | 23.3 | 0.672 | 32 | 1 |
| 3 | 1 | 89 | 66 | 23 | 94 | 28.1 | 0.167 | 21 | 0 |
| 4 | 0 | 137 | 40 | 35 | 168 | 43.1 | 2.288 | 33 | 1 |
更好的是,现在我们可以使用列名来进行一些基本的统计、选择和可视化。让我们首先获取我们的空值准确率如下:
pima['onset_diabetes'].value_counts(normalize=True)
# get null accuracy, 65% did not develop diabetes
0 0.651042
1 0.348958
Name: onset_diabetes, dtype: float64
如果我们的最终目标是利用数据中的模式来预测糖尿病的发病,让我们尝试可视化那些患病和未患病的人之间的差异。我们的希望是直方图会揭示某种模式,或者在预测类别的值之间有明显的差异:
# get a histogram of the plasma_glucose_concentration column for
# both classes
col = 'plasma_glucose_concentration'
plt.hist(pima[pima['onset_diabetes']==0][col], 10, alpha=0.5, label='non-diabetes')
plt.hist(pima[pima['onset_diabetes']==1][col], 10, alpha=0.5, label='diabetes')
plt.legend(loc='upper right')
plt.xlabel(col)
plt.ylabel('Frequency')
plt.title('Histogram of {}'.format(col))
plt.show()
上述代码的输出如下:

这个直方图似乎在向我们展示两个预测类别之间血浆葡萄糖浓度的很大差异。让我们以相同的直方图风格展示多个列,如下所示:
for col in ['bmi', 'diastolic_blood_pressure', 'plasma_glucose_concentration']:
plt.hist(pima[pima['onset_diabetes']==0][col], 10, alpha=0.5, label='non-diabetes')
plt.hist(pima[pima['onset_diabetes']==1][col], 10, alpha=0.5, label='diabetes')
plt.legend(loc='upper right')
plt.xlabel(col)
plt.ylabel('Frequency')
plt.title('Histogram of {}'.format(col))
plt.show()
上述代码的输出将给我们以下三个直方图。第一个直方图展示了两个类别变量(非糖尿病和糖尿病)的BMI分布:

下一个出现的直方图将再次向我们展示两个类别变量之间在某个特征上的对比性不同的分布。这次我们正在查看舒张压:

最后一个图表将展示两个类别变量之间的血浆葡萄糖浓度差异:

只需通过查看几个直方图,我们就可以明显地看到一些主要差异。例如,对于最终会患上糖尿病的人来说,血浆葡萄糖浓度似乎有一个很大的跳跃。为了巩固这一点,也许我们可以通过可视化线性相关矩阵来尝试量化这些变量之间的关系。我们将使用在章节开头导入的可视化工具 seaborn 来创建以下的相关矩阵:
# look at the heatmap of the correlation matrix of our dataset
sns.heatmap(pima.corr())
# plasma_glucose_concentration definitely seems to be an interesting feature here
下面是我们数据集的相关矩阵。这显示了Pima数据集中不同列之间的相关性。输出如下:

这个相关矩阵显示了血浆葡萄糖浓度和发病糖尿病之间强烈的关联。让我们进一步查看发病糖尿病列的数值相关性,以下代码:
pima.corr()['onset_diabetes'] # numerical correlation matrix
# plasma_glucose_concentration definitely seems to be an interesting feature here
times_pregnant 0.221898
plasma_glucose_concentration 0.466581 diastolic_blood_pressure 0.065068
triceps_thickness 0.074752
serum_insulin 0.130548
bmi 0.292695
pedigree_function 0.173844
age 0.238356
onset_diabetes 1.000000
Name: onset_diabetes, dtype: float64
我们将在第四章中探索相关性的力量,特征构建,但现在我们正在使用探索性数据分析(EDA)来暗示血浆葡萄糖浓度这一列将是我们预测糖尿病发病的重要因素。
接下来,让我们关注更重要的任务,通过调用 pandas DataFrame 的内置isnull()方法来查看我们的数据集中是否有缺失值:
pima.isnull().sum()
>>>>
times_pregnant 0
plasma_glucose_concentration 0
diastolic_blood_pressure 0
triceps_thickness 0
serum_insulin 0
bmi 0
pedigree_function 0
age 0
onset_diabetes 0
dtype: int64
太好了!我们没有缺失值。让我们继续进行更多的 EDA,首先使用shape方法查看我们正在处理的行数和列数:
pima.shape . # (# rows, # cols)
(768, 9)
确认我们有9列(包括我们的响应变量)和768个数据观测值(行)。现在,让我们看一下患糖尿病的患者的百分比,使用以下代码:
pima['onset_diabetes'].value_counts(normalize=True)
# get null accuracy, 65% did not develop diabetes
0 0.651042
1 0.348958
Name: onset_diabetes, dtype: float64
这表明 65%的患者没有患上糖尿病,而大约 35%的患者患上了。我们可以使用 pandas DataFrame 的一个内置方法describe来查看一些基本描述性统计信息:
pima.describe() # get some basic descriptive statistics
我们得到以下输出:
| times_pregnant | plasma_glucose _concentration | diastolic_ blood_pressure | triceps _thickness | serum _insulin | bmi | pedigree _function | age | onset _diabetes | |
|---|---|---|---|---|---|---|---|---|---|
| count | 768.000000 | 768.000000 | 768.000000 | 768.000000 | 768.000000 | 768.000000 | 768.000000 | 768.000000 | 768.000000 |
| mean | 3.845052 | 120.894531 | 69.105469 | 20.536458 | 79.799479 | 31.992578 | 0.471876 | 33.240885 | 0.348958 |
| std | 3.369578 | 31.972618 | 19.355807 | 15.952218 | 115.244002 | 7.884160 | 0.331329 | 11.760232 | 0.476951 |
| min | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.078000 | 21.000000 | 0.000000 |
| 25% | 1.000000 | 99.000000 | 62.000000 | 0.000000 | 0.000000 | 27.300000 | 0.243750 | 24.000000 | 0.000000 |
| 50% | 3.000000 | 117.000000 | 72.000000 | 23.000000 | 30.500000 | 32.000000 | 0.372500 | 29.000000 | 0.000000 |
| 75% | 6.000000 | 140.250000 | 80.000000 | 32.000000 | 127.250000 | 36.600000 | 0.626250 | 41.000000 | 1.000000 |
| max | 17.000000 | 199.000000 | 122.000000 | 99.000000 | 846.000000 | 67.100000 | 2.420000 | 81.000000 | 1.000000 |
这很快地显示了一些基本统计信息,如平均值、标准差和一些不同的百分位数测量值。但请注意,BMI列的最小值是0。这在医学上是不可能的;这肯定有原因。也许数字零被编码为缺失值,而不是 None 值或缺失单元格。经过仔细检查,我们发现以下列的最小值出现了 0:
-
times_pregnant -
plasma_glucose_concentration -
diastolic_blood_pressure -
triceps_thickness -
serum_insulin -
bmi -
onset_diabetes
因为 0 是onset_diabetes的一个类别,而 0 实际上对于times_pregnant来说是一个有效的数字,所以我们可能得出结论,数字 0 用于编码以下变量的缺失值:
-
plasma_glucose_concentration -
diastolic_blood_pressure -
triceps_thickness -
serum_insulin -
bmi
因此,我们实际上确实有缺失值!显然,我们偶然发现零作为缺失值并不是运气,我们事先就知道。作为一名数据科学家,你必须始终保持警惕,并确保尽可能多地了解数据集,以便找到以其他符号编码的缺失值。务必阅读任何与公开数据集一起提供的所有文档,以防它们提到任何缺失值。
如果没有可用的文档,一些常用的值被用来代替缺失值:
-
0(用于数值变量)
-
unknown或Unknown(用于分类变量)
-
?(用于分类变量)
因此,我们有五个存在缺失值的列,现在我们可以深入讨论如何处理它们。
处理数据集中的缺失值
在处理数据时,数据科学家最常见的问题之一是缺失数据的问题。最常见的是指由于某种原因数据未采集的空单元格(行/列交叉点)。这可能会成为许多问题的原因;值得注意的是,当将学习算法应用于带有缺失值的数据时,大多数(不是所有)算法无法处理缺失值。
因此,数据科学家和机器学习工程师有很多技巧和建议来处理这个问题。尽管有许多方法变体,但我们处理缺失数据的主要两种方式是:
-
删除包含缺失值的行
-
填充(补全)缺失值
每种方法都会将我们的数据集清理到学习算法可以处理的程度,但每种方法都会有其优缺点。
首先,在我们走得太远之前,让我们去掉所有的零,并在 Python 中将它们全部替换为值None。这样,我们的fillna和dropna方法将正常工作。我们可以手动逐列将所有零替换为None,如下所示:
# Our number of missing values is (incorrectly) 0
pima['serum_insulin'].isnull().sum()
0
pima['serum_insulin'] = pima['serum_insulin'].map(lambda x:x if x != 0 else None)
# manually replace all 0's with a None value
pima['serum_insulin'].isnull().sum()
# check the number of missing values again
374
我们可以为每个带有错误标记缺失值的列重复此过程,或者我们可以使用for循环和内置的replace方法来加快速度,如下面的代码所示:
# A little faster now for all columns
columns = ['serum_insulin', 'bmi', 'plasma_glucose_concentration', 'diastolic_blood_pressure', 'triceps_thickness']
for col in columns:
pima[col].replace([0], [None], inplace=True)
因此,现在如果我们尝试使用isnull方法来计算缺失值的数量,我们应该开始看到缺失值被计数如下:
pima.isnull().sum() # this makes more sense now!
times_pregnant 0
plasma_glucose_concentration 5
diastolic_blood_pressure 35
triceps_thickness 227
serum_insulin 374
bmi 11
pedigree_function 0
age 0
onset_diabetes 0
dtype: int64
pima.head()
现在,查看数据集的前几行,我们得到以下输出:
| times_pregnant | plasma_glucose_concentration | diastolic_blood_pressure | triceps_thickness | serum_insulin | bmi | pedigree_function | age | onset_diabetes | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 6 | 148 | 72 | 35 | NaN | 33.6 | 0.627 | 50 | 1 |
| 1 | 1 | 85 | 66 | 29 | NaN | 26.6 | 0.351 | 31 | 0 |
| 2 | 8 | 183 | 64 | None | NaN | 23.3 | 0.672 | 32 | 1 |
| 3 | 1 | 89 | 66 | 23 | NaN | 28.1 | 0.167 | 21 | 0 |
| 4 | 0 | 137 | 40 | 35 | NaN | 43.1 | 2.288 | 33 | 1 |
好的,这开始变得更有意义。我们现在可以看到有五个列存在缺失值,数据缺失的程度令人震惊。一些列,如plasma_glucose_concentration,只缺失了五个值,但看看serum_insulin列;该列几乎缺失了其一半的值。
现在我们已经将数据集中的缺失值正确地注入,而不是使用数据集最初带的0占位符,我们的探索性数据分析将更加准确:
pima.describe() # grab some descriptive statistics
上述代码产生以下输出:
| times_pregnant | serum_insulin | pedigree_function | age | onset_diabetes | |
|---|---|---|---|---|---|
| 计数 | 768.000000 | 394.000000 | 768.000000 | 768.000000 | 768.000000 |
| 均值 | 3.845052 | 155.548223 | 0.471876 | 33.240885 | 0.348958 |
| 标准差 | 3.369578 | 118.775855 | 0.331329 | 11.760232 | 0.476951 |
| 最小值 | 0.000000 | 14.000000 | 0.078000 | 21.000000 | 0.000000 |
| 25% | 1.000000 | 76.250000 | 0.243750 | 24.000000 | 0.000000 |
| 50% | 3.000000 | 125.000000 | 0.372500 | 29.000000 | 0.000000 |
| 75% | 6.000000 | 190.000000 | 0.626250 | 41.000000 | 1.000000 |
| 最大值 | 17.000000 | 846.000000 | 2.420000 | 81.000000 | 1.000000 |
注意到describe方法不包括缺失值的列,虽然这不是理想的情况,但这并不意味着我们不能通过计算特定列的均值和标准差来获得它们,如下所示:
pima['plasma_glucose_concentration'].mean(), pima['plasma_glucose_concentration'].std()
(121.68676277850589, 30.53564107280403)
让我们继续讨论处理缺失数据的两种方法。
移除有害数据行
处理缺失数据最常见且最简单的方法可能是简单地删除任何缺失值的观测值。这样做,我们将只剩下所有数据都已填满的完整数据点。我们可以通过在 pandas 中调用dropna方法来获得一个新的 DataFrame,如下所示:
# drop the rows with missing values
pima_dropped = pima.dropna()
现在,当然,这里明显的问题是我们丢失了一些行。为了检查具体丢失了多少行,请使用以下代码:
num_rows_lost = round(100*(pima.shape[0] - pima_dropped.shape[0])/float(pima.shape[0]))
print "retained {}% of rows".format(num_rows_lost)
# lost over half of the rows!
retained 49.0% of rows
哇!我们从原始数据集中丢失了大约 51%的行,如果我们从机器学习的角度来看,尽管现在我们有干净的数据,所有数据都已填满,但我们通过忽略超过一半的数据观测值,实际上并没有学到尽可能多的东西。这就像一位医生试图了解心脏病发作的原因,却忽略了超过一半前来检查的患者。
让我们对数据集进行更多的探索性数据分析(EDA),并比较删除缺失值行前后的数据统计信息:
# some EDA of the dataset before it was dropped and after
# split of trues and falses before rows dropped
pima['onset_diabetes'].value_counts(normalize=True)
0 0.651042
1 0.348958
Name: onset_diabetes, dtype: float64
现在,让我们使用以下代码查看删除行后的相同拆分:
pima_dropped['onset_diabetes'].value_counts(normalize=True)
0 0.668367
1 0.331633
Name: onset_diabetes, dtype: float64
# the split of trues and falses stay relatively the same
看起来,在数据集的剧烈变化过程中,二进制响应保持相对稳定。让我们通过比较变换前后列的平均值来查看我们数据的形状,如下使用pima.mean函数:
# the mean values of each column (excluding missing values)
pima.mean()
times_pregnant 3.845052
plasma_glucose_concentration 121.686763
diastolic_blood_pressure 72.405184
triceps_thickness 29.153420
serum_insulin 155.548223
bmi 32.457464
pedigree_function 0.471876
age 33.240885
onset_diabetes 0.348958
dtype: float64
现在让我们使用pima_dropped.mean()函数查看删除行后的相同平均值:
# the mean values of each column (with missing values rows dropped)
pima_dropped.mean()
times_pregnant 3.301020
plasma_glucose_concentration 122.627551
diastolic_blood_pressure 70.663265
triceps_thickness 29.145408
serum_insulin 156.056122
bmi 33.086224
pedigree_function 0.523046
age 30.864796
onset_diabetes 0.331633
dtype: float64
为了更好地查看这些数字的变化,让我们创建一个新的图表来可视化每个列平均百分比的变化。首先,让我们创建一个表格,显示每个列平均值的百分比变化,如下所示:
# % change in means
(pima_dropped.mean() - pima.mean()) / pima.mean()
times_pregnant -0.141489
plasma_glucose_concentration 0.007731
diastolic_blood_pressure -0.024058
triceps_thickness -0.000275
serum_insulin 0.003265
bmi 0.019372
pedigree_function 0.108439
age -0.071481
onset_diabetes -0.049650
dtype: float64
现在让我们使用以下代码将这些变化可视化成条形图:
# % change in means as a bar chart
ax = ((pima_dropped.mean() - pima.mean()) / pima.mean()).plot(kind='bar', title='% change in average column values')
ax.set_ylabel('% change')
前面的代码产生以下输出:

我们可以看到,在删除缺失值后,times_pregnant变量的平均值下降了 14%,这是一个很大的变化!pedigree_function也上升了 11%,另一个大跳跃。我们可以看到删除行(观测值)如何严重影响数据的形状,我们应该尽量保留尽可能多的数据。在继续到处理缺失值的下一个方法之前,让我们引入一些实际的机器学习。
以下代码块(我们将在稍后逐行分析)将成为本书中一个非常熟悉的代码块。它描述并实现了一个机器学习模型在多种参数上的单次拟合,目的是在给定的特征下获得最佳模型:
# now lets do some machine learning
# note we are using the dataset with the dropped rows
from sklearn.neighbors import KNeighborsClassifier
from sklearn.model_selection import GridSearchCV
X_dropped = pima_dropped.drop('onset_diabetes', axis=1)
# create our feature matrix by removing the response variable
print "learning from {} rows".format(X_dropped.shape[0])
y_dropped = pima_dropped['onset_diabetes']
# our grid search variables and instances
# KNN parameters to try
knn_params = {'n_neighbors':[1, 2, 3, 4, 5, 6, 7]}
knn = KNeighborsClassifier() . # instantiate a KNN model
grid = GridSearchCV(knn, knn_params)
grid.fit(X_dropped, y_dropped)
print grid.best_score_, grid.best_params_
# but we are learning from way fewer rows..
好的,让我们逐行分析。首先,我们有两条新的导入语句:
from sklearn.neighbors import KNeighborsClassifier
from sklearn.model_selection import GridSearchCV
我们将利用 scikit-learn 的K-Nearest Neighbors(KNN)分类模型,以及一个网格搜索模块,该模块将自动找到最适合我们的数据并具有最佳交叉验证准确率的 KNN 模型的最佳参数组合(使用暴力搜索)。接下来,让我们取我们的删除数据集(已删除缺失值行)并为我们预测模型创建一个X和y变量。让我们从我们的X(我们的特征矩阵)开始:
X_dropped = pima_dropped.drop('onset_diabetes', axis=1)
# create our feature matrix by removing the response variable
print "learning from {} rows".format(X_dropped.shape[0])
learning from 392 rows
哎呀,这个方法的问题已经很明显了。我们的机器学习算法将要拟合和学习的观测数据比我们开始时使用的要少得多。现在让我们创建我们的y(响应序列):
y_dropped = pima_dropped['onset_diabetes']
现在我们有了X和y变量,我们可以引入我们需要成功运行网格搜索的变量和实例。我们将尝试的params数量设置为七个,以使本章的内容简单。对于我们尝试的每一种数据清理和特征工程方法(删除行,填充数据),我们将尝试将最佳 KNN 拟合到具有一到七个邻居复杂度的某个地方。我们可以这样设置这个模型:
# our grid search variables and instances
# KNN parameters to try
knn_params = {'n_neighbors':[1, 2, 3, 4, 5, 6, 7]}
接下来,我们将实例化一个网格搜索模块,如下面的代码所示,并将其拟合到我们的特征矩阵和响应变量。一旦这样做,我们将打印出最佳准确率以及用于学习的最佳参数:
grid = GridSearchCV(knn, knn_params)
grid.fit(X_dropped, y_dropped)
print grid.best_score_, grid.best_params_
# but we are learning from way fewer rows..
0.744897959184 {'n_neighbors': 7}
因此,似乎使用七个邻居作为其参数,我们的 KNN 模型能够达到 74.4%的准确率(比我们大约 65%的零准确率要好),但请记住,它只从原始数据的 49%中学习,那么谁知道它在剩余的数据上会表现如何。
这本书中我们第一次真正探讨使用机器学习。我们假设读者对机器学习和诸如交叉验证之类的统计过程有基本的了解。
很明显,虽然删除脏行可能并不完全等同于特征工程,但它仍然是一种我们可以利用的数据清洗技术,有助于净化我们的机器学习管道输入。让我们尝试一个稍微复杂一些的方法。
在数据中填充缺失值
填充是处理缺失值的更复杂的方法。通过填充,我们指的是用从现有知识/数据中确定的数值填充缺失数据值的行为。我们有几种方法可以填充这些缺失值,其中最常见的是用该列其余部分的平均值填充缺失值,如下面的代码所示:
pima.isnull().sum() # let's fill in the plasma column
times_pregnant 0
plasma_glucose_concentration 5 diastolic_blood_pressure 35
triceps_thickness 227
serum_insulin 374
bmi 11
pedigree_function 0
age 0
onset_diabetes 0
dtype: int64
让我们看看plasma_glucose_concentration缺失的五行:
empty_plasma_index = pima[pima['plasma_glucose_concentration'].isnull()].index
pima.loc[empty_plasma_index]['plasma_glucose_concentration']
75 None
182 None
342 None
349 None
502 None
Name: plasma_glucose_concentration, dtype: object
现在,让我们使用内置的fillna方法将所有None值替换为plasma_glucose_concentration列其余部分的平均值:
pima['plasma_glucose_concentration'].fillna(pima['plasma_glucose_concentration'].mean(), inplace=True)
# fill the column's missing values with the mean of the rest of the column
pima.isnull().sum() # the column should now have 0 missing values
times_pregnant 0
plasma_glucose_concentration 0 diastolic_blood_pressure 35
triceps_thickness 227
serum_insulin 374
bmi 11
pedigree_function 0
age 0
onset_diabetes 0
dtype: int64
如果我们检查该列,我们应该看到None值已被替换为之前为此列获得的平均值121.68:
pima.loc[empty_plasma_index]['plasma_glucose_concentration']
75 121.686763
182 121.686763
342 121.686763
349 121.686763
502 121.686763
Name: plasma_glucose_concentration, dtype: float64
太好了!但这可能有点麻烦。让我们使用 scikit-learn 预处理类中的一个模块(文档可以在scikit-learn.org/stable/modules/classes.html#module-sklearn.preprocessing找到)称为Imputer(恰如其名)。我们可以如下导入它:
from sklearn.preprocessing import Imputer
与大多数 scikit-learn 模块一样,我们有一些新的参数可以调整,但我将重点介绍其中一个,称为strategy。我们可以通过设置此参数来定义如何将值填充到我们的数据集中。对于定量值,我们可以使用内置的均值和中值策略来填充值。要使用Imputer,我们必须首先实例化对象,如下面的代码所示:
imputer = Imputer(strategy='mean')
然后,我们可以调用fit_transform方法来创建一个新的对象,如下面的代码所示:
pima_imputed = imputer.fit_transform(pima)
我们确实有一个小问题需要处理。Imputer的输出不是一个 pandas DataFrame,而是输出类型为NumPy数组:
type(pima_imputed) # comes out as an array
numpy.ndarray
这很容易处理,因为我们只需将数组转换为 DataFrame,如下面的代码所示:
pima_imputed = pd.DataFrame(pima_imputed, columns=pima_column_names)
# turn our numpy array back into a pandas DataFrame object
让我们看看我们的新 DataFrame:
pima_imputed.head() # notice for example the triceps_thickness missing values were replaced with 29.15342
上述代码产生以下输出:
| 怀孕次数 | 血浆葡萄糖浓度 | 舒张压 | 三头肌厚度 | 血清胰岛素 | BMI | 谱系功能 | 年龄 | 糖尿病发病时间 | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 6.0 | 148.0 | 72.0 | 35.00000 | 155.548223 | 33.6 | 0.627 | 50.0 | 1.0 |
| 1 | 1.0 | 85.0 | 66.0 | 29.00000 | 155.548223 | 26.6 | 0.351 | 31.0 | 0.0 |
| 2 | 8.0 | 183.0 | 64.0 | 29.15342 | 155.548223 | 23.3 | 0.672 | 32.0 | 1.0 |
| 3 | 1.0 | 89.0 | 66.0 | 23.00000 | 94.000000 | 28.1 | 0.167 | 21.0 | 0.0 |
| 4 | 0.0 | 137.0 | 40.0 | 35.00000 | 168.000000 | 43.1 | 2.288 | 33.0 | 1.0 |
让我们检查一下 plasma_glucose_concentration 列,以确保其值仍然用我们之前手动计算的相同平均值填充:
pima_imputed.loc[empty_plasma_index]['plasma_glucose_concentration']
# same values as we obtained with fillna
75 121.686763
182 121.686763
342 121.686763
349 121.686763
502 121.686763
Name: plasma_glucose_concentration, dtype: float64
作为最后的检查,我们的填充 DataFrame 应该没有缺失值,如下面的代码所示:
pima_imputed.isnull().sum() # no missing values
times_pregnant 0
plasma_glucose_concentration 0
diastolic_blood_pressure 0
triceps_thickness 0
serum_insulin 0
bmi 0
pedigree_function 0
age 0
onset_diabetes 0
dtype: int64
太棒了!Imputer 在将数据值填充到缺失槽位这项繁琐的任务上帮了大忙。让我们尝试填充几种类型的值,并观察其对我们的 KNN 分类模型的影响。我们先尝试一种更简单的填充方法。让我们用零来重新填充空值:
pima_zero = pima.fillna(0) # impute values with 0
X_zero = pima_zero.drop('onset_diabetes', axis=1)
print "learning from {} rows".format(X_zero.shape[0])
y_zero = pima_zero['onset_diabetes']
knn_params = {'n_neighbors':[1, 2, 3, 4, 5, 6, 7]}
grid = GridSearchCV(knn, knn_params)
grid.fit(X_zero, y_zero)
print grid.best_score_, grid.best_params_
# if the values stayed at 0, our accuracy goes down
learning from 768 rows
0.73046875 {'n_neighbors': 6}
如果我们保留值为 0,我们的准确率将低于删除缺失值行的情况。我们的目标是获得一个可以从所有 768 行中学习的机器学习管道,但性能优于只从 392 行中学习的模型。这意味着要打败的准确率是 0.745,或 74.5%。
在机器学习管道中填充值
如果我们希望将 Imputer 转移到生产就绪的机器学习管道中,我们需要简要地讨论管道的话题。
机器学习中的管道
当我们谈论机器学习中的 管道 时,我们通常指的是数据不仅通过原始的学习算法,而且还要通过各种预处理步骤,甚至在最终输出被解释之前通过多个学习算法。由于在单个机器学习管道中通常会有多个步骤、转换和预测,scikit-learn 有一个内置模块用于构建这些管道。
管道特别重要,因为在使用 Imputer 类填充值时,不使用管道实际上是 不恰当 的。这是因为学习算法的目标是从训练集中泛化模式,以便将这些模式应用到测试集。如果我们先对整个数据集进行填充值,然后再划分集合并应用学习算法,那么我们就是在作弊,我们的模型实际上并没有学习任何模式。为了可视化这个概念,让我们取一个单独的训练测试划分,这可能是交叉验证训练阶段中的许多潜在划分之一。
让我们复制 Pima 数据集的单个列,以便更剧烈地强调我们的观点,并且从 scikit-learn 中导入一个单独的划分模块:
from sklearn.model_selection import train_test_split
X = pima[['serum_insulin']].copy()
y = pima['onset_diabetes'].copy()
X.isnull().sum()
serum_insulin 374
dtype: int64
现在,让我们进行一次划分。但在这样做之前,我们将使用以下代码在整份数据集中填充 X 的平均值:
# the improper way.. imputing values BEFORE splitting
entire_data_set_mean = X.mean() # take the entire datasets mean
X = X.fillna(entire_data_set_mean) # and use it to fill in the missing spots
print entire_data_set_mean
serum_insulin 155.548223
dtype: float64
# Take the split using a random state so that we can examine the same split.
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=99)
现在,让我们将 KNN 模型拟合到训练集和测试集:
knn = KNeighborsClassifier()
knn.fit(X_train, y_train)
knn.score(X_test, y_test)
0.65625 # the accuracy of the improper split
注意,我们在这里没有实现任何网格搜索,只是进行了一个简单的拟合。我们看到我们的模型声称有 66%的准确率(并不出色,但这不是重点)。这里要注意的重要事情是,X的训练集和测试集都是使用整个X矩阵的平均值进行填充的。这直接违反了机器学习流程的一个核心原则。我们不能假设在预测测试集的响应值时知道整个数据集的平均值。简单来说,我们的 KNN 模型正在使用从测试集中获得的信息来拟合训练集。这是一个大红旗。
想了解更多关于管道及其为什么需要使用它们的信息,请查看数据科学原理(由 Packt Publishing 出版)www.packtpub.com/big-data-and-business-intelligence/principles-data-science
现在,让我们正确地来做这件事,首先计算训练集的平均值,然后使用训练集的平均值来填充测试集的值。再次强调,这个流程测试了模型使用训练数据的平均值来预测未见过的测试案例的能力:
# the proper way.. imputing values AFTER splitting
from sklearn.model_selection import train_test_split
X = pima[['serum_insulin']].copy()
y = pima['onset_diabetes'].copy()
# using the same random state to obtain the same split
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=99)
X.isnull().sum()
serum_insulin 374
dtype: int64
现在,我们不会计算整个X矩阵的平均值,而只会正确地只对训练集进行计算,并使用这个值来填充训练集和测试集中的缺失单元格:
training_mean = X_train.mean()
X_train = X_train.fillna(training_mean)
X_test = X_test.fillna(training_mean)
print training_mean
serum_insulin 158.546053
dtype: float64
# not the entire dataset's mean, it's much higher!!
最后,让我们在相同的数据集上对 KNN 模型进行评分,但这次是正确填充的,如下面的代码所示:
knn = KNeighborsClassifier()
knn.fit(X_train, y_train)
print knn.score(X_test, y_test)
0.4895
# lower accuracy, but much more honest in the mode's ability to generalize a pattern to outside data
当然,这是一个更低的准确率,但至少它是对模型从训练集特征中学习并应用到未见过的和保留的测试数据上的能力的更真实反映。Scikit-learn 的管道通过为我们的机器学习流程的步骤提供结构和顺序,使整个流程变得更加容易。让我们看看如何使用 scikit-learn 的Pipeline和Imputer的代码块:
from sklearn.pipeline import Pipeline
knn_params = {'classify__n_neighbors':[1, 2, 3, 4, 5, 6, 7]}
# must redefine params to fit the pipeline
knn = KNeighborsClassifier() . # instantiate a KNN model
mean_impute = Pipeline([('imputer', Imputer(strategy='mean')), ('classify', knn)])
X = pima.drop('onset_diabetes', axis=1)
y = pima['onset_diabetes']
grid = GridSearchCV(mean_impute, knn_params)
grid.fit(X, y)
print grid.best_score_, grid.best_params_
0.731770833333 {'classify__n_neighbors': 6}
mean_impute = Pipeline([('imputer', Imputer(strategy='mean')), ('classify', knn)])
有几点需要注意。首先,我们的Pipeline有两个步骤:
-
Imputer的strategy=mean -
类型为 KNN 的分类器
其次,我们必须重新定义我们的param字典,因为我们必须指定n_neighbors参数属于管道的哪个步骤:
knn_params = {'classify__n_neighbors':[1, 2, 3, 4, 5, 6, 7]}
除了这个之外,其他一切都是正常和恰当的。Pipeline类将为我们处理大部分流程。它将正确地处理从多个训练集中提取值,并使用这些值来填充测试集中的缺失值,正确地测试 KNN 在数据中泛化模式的能力,并最终输出表现最佳的模型,准确率为 73%,略低于我们想要超越的 0.745 的目标。现在我们已经掌握了这种语法,让我们再次尝试整个流程,但稍作修改,如下面的代码所示:
knn_params = {'classify__n_neighbors':[1, 2, 3, 4, 5, 6, 7]}
knn = KNeighborsClassifier() . # instantiate a KNN model
median_impute = Pipeline([('imputer', Imputer(strategy='median')), ('classify', knn)])
X = pima.drop('onset_diabetes', axis=1)
y = pima['onset_diabetes']
grid = GridSearchCV(median_impute, knn_params)
grid.fit(X, y)
print grid.best_score_, grid.best_params_
0.735677083333 {'classify__n_neighbors': 6}
这里,唯一的区别是,我们的管道将尝试不同的填充中位数的策略,其中缺失的值将用剩余值的中位数来填充。重要的是要重申,我们的准确率可能低于在删除的行上模型的拟合度,但它们是在比有缺失值的数据库大两倍的数据集上进行的!而且,它们仍然比将它们都设为 0 要好,因为数据最初是以这种方式呈现给我们的。
让我们花一分钟回顾一下我们使用适当管道所得到的分数:
| 管道描述 | 从 | 交叉验证准确率 |
|---|---|---|
| 删除缺失值行 | 392 | .74489 |
| 使用列的平均值填充值 | 768 | .7304 |
| 使用列的平均值填充值 | 768 | .7318 |
| 使用列的中位数填充值 | 768 | .7357 |
如果仅从准确率来看,似乎删除缺失值行是最佳程序。也许仅使用 scikit-learn 中的Pipeline和Imputer功能还不够。我们仍然希望尽可能看到所有 768 行的性能相当(如果不是更好)。为了实现这一点,让我们尝试引入一个全新的特征工程技巧,即标准化和归一化。
标准化和归一化
到目前为止,我们已经处理了识别数据类型以及数据缺失的方式,以及最后填充缺失数据的方式。现在,让我们谈谈如何操纵我们的数据(以及我们的特征)以进一步增强我们的机器管道。到目前为止,我们已经尝试了四种不同的数据集操纵方式,我们使用 KNN 模型实现的最佳交叉验证准确率是.745。如果我们回顾一下之前所做的某些 EDA,我们会注意到我们的特征:
impute = Imputer(strategy='mean')
# we will want to fill in missing values to see all 9 columns
pima_imputed_mean = pd.DataFrame(impute.fit_transform(pima), columns=pima_column_names)
现在,让我们使用标准直方图来查看所有九列的分布情况,如下所示,指定图的大小:
pima_imputed_mean.hist(figsize=(15, 15))
之前的代码产生以下输出:

很好,但有没有什么不对劲的地方?每个列都有截然不同的平均值、最小值、最大值和标准差。这也通过以下代码的describe方法明显:
pima_imputed_mean.describe()
输出如下:
| | 怀孕次数 | 血浆葡萄糖 |
浓度 | 舒张压 | 三头肌厚度 | 血清胰岛素 | BMI | 谱系功能 | 年龄 | 糖尿病发病时间 |
| 计数 | 768.000000 | 768.000000 | 768.000000 | 768.000000 | 768.000000 | 768.000000 | 768.000000 | 768.000000 | 768.000000 |
|---|---|---|---|---|---|---|---|---|---|
| 平均值 | 3.845052 | 121.686763 | 72.405184 | 29.153420 | 155.548223 | 32.457464 | 0.471876 | 33.240885 | 0.348958 |
| 标准差 | 3.369578 | 30.435949 | 12.096346 | 8.790942 | 85.021108 | 6.875151 | 0.331329 | 11.760232 | 0.476951 |
| 最小值 | 0.000000 | 44.000000 | 24.000000 | 7.000000 | 14.000000 | 18.200000 | 0.078000 | 21.000000 | 0.000000 |
| 25% | 1.000000 | 99.750000 | 64.000000 | 25.000000 | 121.500000 | 27.500000 | 0.243750 | 24.000000 | 0.000000 |
| 50% | 3.000000 | 117.000000 | 72.202592 | 29.153420 | 155.548223 | 32.400000 | 0.372500 | 29.000000 | 0.000000 |
| 75% | 6.000000 | 140.250000 | 80.000000 | 32.000000 | 155.548223 | 36.600000 | 0.626250 | 41.000000 | 1.000000 |
| max | 17.000000 | 199.000000 | 122.000000 | 99.000000 | 846.000000 | 67.100000 | 2.420000 | 81.000000 | 1.000000 |
但这为什么重要呢?嗯,一些机器学习模型依赖于受数据 scale 影响很大的学习方法,这意味着如果我们有一个像 diastolic_blood_pressure 这样的列,其值在 24 和 122 之间,而年龄列在 21 和 81 之间,那么我们的学习算法将无法最优地学习。为了真正看到尺度上的差异,让我们在直方图方法中调用两个可选参数,sharex 和 sharey,这样我们就可以看到每个图表都与其他图表在同一尺度上,使用以下代码:
pima_imputed_mean.hist(figsize=(15, 15), sharex=True)
# with the same x axis (the y axis is not as important here)
上述代码产生以下输出:

很明显,我们的数据都存在于截然不同的尺度上。数据工程师在我们的机器学习管道中处理这种问题的方法有多种,这些方法属于一个被称为 标准化 的操作家族。标准化操作旨在将列和行对齐并转换为一致的一组规则。例如,一种常见的标准化形式是将所有定量列转换为一致且静态的值域(例如所有值必须在 0 和 1 之间)。我们还可以施加数学规则,例如,所有列必须具有相同的均值和标准差,这样它们就可以在直方图上看起来很漂亮(不同于我们最近计算的 pima 直方图)。标准化技术旨在通过确保所有行和列在机器学习眼中都受到平等对待,来 平衡数据场。
我们将关注三种数据归一化的方法:
-
Z-score 标准化
-
Min-max 缩放
-
行标准化
前两种专门处理原地更改特征,而第三种选项实际上操作数据的行,但与前两种一样相关。
Z-score 标准化
标准化技术中最常见的是 z-score 标准化,它利用了一个非常简单的统计概念——z-score。z-score 标准化的输出是重新缩放的特征,其均值为零,标准差为 1。通过这样做,通过将我们的特征重新缩放到具有均匀的均值和方差(标准差的平方),我们允许像 KNN 这样的模型最优地学习,并且不会偏向于更大尺度的特征。公式很简单:对于每一列,我们用以下值替换单元格:
z = (x - μ) / σ
其中:
-
z 是我们的新值(z-score)
-
x 是单元格的前一个值
-
μ 是列的平均值
-
σ 是列的标准差
让我们通过缩放数据集中的plasma_glucose_concentration列来举一个例子:
print pima['plasma_glucose_concentration'].head()
0 148.0
1 85.0
2 183.0
3 89.0
4 137.0
Name: plasma_glucose_concentration, dtype: float64
现在让我们手动计算列中每个值的 z 分数,使用以下代码:
# get the mean of the column
mu = pima['plasma_glucose_concentration'].mean()
# get the standard deviation of the column
sigma = pima['plasma_glucose_concentration'].std()
# calculate z scores for every value in the column.
print ((pima['plasma_glucose_concentration'] - mu) / sigma).head()
0 0.864545
1 -1.205376
2 2.014501
3 -1.073952
4 0.503130
Name: plasma_glucose_concentration, dtype: float64
我们可以看到列中的每个值都将被替换,并且注意现在其中一些是负数。这是因为生成的值代表从平均值到的一个距离。所以,如果一个值最初低于列的平均值,生成的 z 分数将是负数。当然,在 scikit-learn 中,我们有一些内置对象可以帮助我们,如下面的代码所示:
# built in z-score normalizer
from sklearn.preprocessing import StandardScaler
让我们试试看,如下所示:
# mean and std before z score standardizing
pima['plasma_glucose_concentration'].mean(), pima['plasma_glucose_concentration'].std()
(121.68676277850591, 30.435948867207657)
ax = pima['plasma_glucose_concentration'].hist()
ax.set_title('Distribution of plasma_glucose_concentration')
前面的代码生成了以下输出:

在这里,我们可以看到在进行任何操作之前列的分布。现在,让我们应用 z 分数缩放,如下面的代码所示:
scaler = StandardScaler()
glucose_z_score_standardized = scaler.fit_transform(pima[['plasma_glucose_concentration']])
# note we use the double bracket notation [[ ]] because the transformer requires a dataframe
# mean of 0 (floating point error) and standard deviation of 1
glucose_z_score_standardized.mean(), glucose_z_score_standardized.std()
(-3.5619655373390441e-16, 1.0)
我们可以看到,在我们将我们的缩放器应用于列之后,平均值降至零,我们的标准差变为 1。此外,如果我们看一下我们最近缩放的数据值的分布:
ax = pd.Series(glucose_z_score_standardized.reshape(-1,)).hist()
ax.set_title('Distribution of plasma_glucose_concentration after Z Score Scaling')
输出如下:

我们会注意到,我们的x轴现在受到更多的约束,而我们的y轴没有变化。此外,数据的形状完全未变。让我们看一下我们对 DataFrame 的每个列应用 z 分数变换后的直方图。当我们这样做时,StandardScaler将为每个列分别计算平均值和标准差:
scale = StandardScaler() # instantiate a z-scaler object
pima_imputed_mean_scaled = pd.DataFrame(scale.fit_transform(pima_imputed_mean), columns=pima_column_names)
pima_imputed_mean_scaled.hist(figsize=(15, 15), sharex=True)
# now all share the same "space"
前面的代码生成了以下输出:

注意,现在我们的x轴在整个数据集中都受到了更多的约束。现在,让我们将一个StandardScaler插入到我们之前的机器学习管道中:
knn_params = {'imputer__strategy':['mean', 'median'], 'classify__n_neighbors':[1, 2, 3, 4, 5, 6, 7]}
mean_impute_standardize = Pipeline([('imputer', Imputer()), ('standardize', StandardScaler()), ('classify', knn)])
X = pima.drop('onset_diabetes', axis=1)
y = pima['onset_diabetes']
grid = GridSearchCV(mean_impute_standardize, knn_params)
grid.fit(X, y)
print grid.best_score_, grid.best_params_
0.7421875 {'imputer__strategy': 'median', 'classify__n_neighbors': 7}
注意这里的一些事情。我们向网格搜索中添加了一组新的参数,即填充缺失值的策略。现在,我正在寻找策略和与 z 分数缩放结合的 KNN 邻居数量的最佳组合,我们的结果是.742,到目前为止这是我们离目标.745 最近的分数,并且这个管道正在从所有 768 行中学习。现在让我们看看另一种列归一化方法。
最小-最大缩放方法
最小-最大缩放与 z 分数归一化相似,因为它将使用公式替换列中的每个值。在这种情况下,公式是:
m = (x -x[min]) / (x[max] -x[min])*
其中:
-
m 是我们的新值
-
x 是原始单元格值
-
x[min] 是列的最小值
-
x[max] 是列的最大值
使用这个公式,我们会看到每个列的值现在将在零和一之间。让我们用一个内置的 scikit-learn 模块来看一个例子:
# import the sklearn module
from sklearn.preprocessing import MinMaxScaler
#instantiate the class
min_max = MinMaxScaler()
# apply the Min Max Scaling
pima_min_maxed = pd.DataFrame(min_max.fit_transform(pima_imputed), columns=pima_column_names)
# spit out some descriptive statistics
pima_min_maxed.describe()
这是我们的describe方法的输出:
| | 怀孕次数 | 血浆葡萄糖
_ 浓度** | 舒张压
_ 压力** | 三头肌厚度 | 血清胰岛素 | BMI | 谱系功能 | 年龄 | 糖尿病发病时间 |
| count | 768.000000 | 768.000000 | 768.000000 | 768.000000 | 768.000000 | 768.000000 | 768.000000 | 768.000000 | 768.000000 |
|---|---|---|---|---|---|---|---|---|---|
| mean | 0.226180 | 0.501205 | 0.493930 | 0.240798 | 0.170130 | 0.291564 | 0.168179 | 0.204015 | 0.348958 |
| std | 0.198210 | 0.196361 | 0.123432 | 0.095554 | 0.102189 | 0.140596 | 0.141473 | 0.196004 | 0.476951 |
| min | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 |
| 25% | 0.058824 | 0.359677 | 0.408163 | 0.195652 | 0.129207 | 0.190184 | 0.070773 | 0.050000 | 0.000000 |
| 50% | 0.176471 | 0.470968 | 0.491863 | 0.240798 | 0.170130 | 0.290389 | 0.125747 | 0.133333 | 0.000000 |
| 75% | 0.352941 | 0.620968 | 0.571429 | 0.271739 | 0.170130 | 0.376278 | 0.234095 | 0.333333 | 1.000000 |
| max | 1.000000 | 1.000000 | 1.000000 | 1.000000 | 1.000000 | 1.000000 | 1.000000 | 1.000000 | 1.000000 |
注意到min都是零,而max值都是一。进一步注意到,标准差现在都非常小,这是这种缩放类型的一个副作用。这可能会损害某些模型,因为它减少了异常值的重要性。让我们将我们的新归一化技术插入到我们的管道中:
knn_params = {'imputer__strategy': ['mean', 'median'], 'classify__n_neighbors':[1, 2, 3, 4, 5, 6, 7]}
mean_impute_standardize = Pipeline([('imputer', Imputer()), ('standardize', MinMaxScaler()), ('classify', knn)])
X = pima.drop('onset_diabetes', axis=1)
y = pima['onset_diabetes']
grid = GridSearchCV(mean_impute_standardize, knn_params)
grid.fit(X, y)
print grid.best_score_, grid.best_params_
0.74609375 {'imputer__strategy': 'mean', 'classify__n_neighbors': 4}
哇,这是我们迄今为止在处理缺失数据并使用数据集中的所有 768 个原始行时获得的最准确度!看起来最小-最大缩放对我们的 KNN 帮助很大!太棒了;让我们尝试第三种归一化类型,这次我们将从归一化列转向归一化行。
行归一化方法
我们最终的归一化方法按行而不是按列进行。不是对每一列计算统计数据,如平均值、最小值、最大值等,行归一化技术将确保每一行数据都有一个 单位范数,这意味着每一行将具有相同的向量长度。想象一下,如果每一行数据都属于一个 n 维空间;每个都会有一个向量范数,或长度。另一种说法是,如果我们把每一行都看作空间中的一个向量:
x = (x[1], x[2], ..., x[n])
在 Pima 案例中,1, 2, ..., n 将对应于 8 个特征(不包括响应),规范将按以下方式计算:
||x|| = √(x[1]^(2 + x[2]^(2 + ... + x[n]²))
这被称为L-2 范数。存在其他类型的范数,但在此文本中我们将不涉及。相反,我们关注的是确保每一行都具有相同的范数。这在处理文本数据或聚类算法时特别有用。
在做任何事情之前,让我们看看我们的均值填充矩阵的平均范数,使用以下代码:
np.sqrt((pima_imputed**2).sum(axis=1)).mean()
# average vector length of imputed matrix
223.36222025823744
现在,让我们引入我们的行归一化器,如下面的代码所示:
from sklearn.preprocessing import Normalizer # our row normalizer
normalize = Normalizer()
pima_normalized = pd.DataFrame(normalize.fit_transform(pima_imputed), columns=pima_column_names)
np.sqrt((pima_normalized**2).sum(axis=1)).mean()
# average vector length of row normalized imputed matrix
1.0
归一化后,我们看到每一行现在都有一个范数为 1。让我们看看这种方法在我们的管道中的表现:
knn_params = {'imputer__strategy': ['mean', 'median'], 'classify__n_neighbors':[1, 2, 3, 4, 5, 6, 7]}
mean_impute_normalize = Pipeline([('imputer', Imputer()), ('normalize', Normalizer()), ('classify', knn)])
X = pima.drop('onset_diabetes', axis=1)
y = pima['onset_diabetes']
grid = GridSearchCV(mean_impute_normalize, knn_params)
grid.fit(X, y)
print grid.best_score_, grid.best_params_
0.682291666667 {'imputer__strategy': 'mean', 'classify__n_neighbors': 6}
哎呀,不是很好,但值得一试。既然我们已经看到了三种不同的数据归一化方法,让我们把它们全部放在一起,看看我们在这个数据集上的表现如何。
有许多学习算法会受到数据规模的影响。以下是一些受数据规模影响的一些流行学习算法列表:
-
KNN——由于其依赖于欧几里得距离
-
K-Means 聚类——与 KNN 相同的推理
-
逻辑回归、SVM、神经网络——如果你正在使用梯度下降来学习权重
-
主成分分析——特征向量将偏向于较大的列
综合起来
在处理我们数据集的各种问题之后,从识别隐藏为零的缺失值,填充缺失值,以及在不同规模上归一化数据,现在是时候将所有分数汇总到一个单独的表中,看看哪种特征工程组合表现最好:
| 管道描述 | # rows model learned from | 交叉验证准确度 |
|---|---|---|
| 删除缺失值行 | 392 | .7449 |
| 使用 0 填充值 | 768 | .7304 |
| 使用列均值填充值 | 768 | .7318 |
| 使用列中位数填充值 | 768 | .7357 |
| 使用中位数填充的 Z 分数归一化 | 768 | .7422 |
| 使用均值填充的最小-最大归一化 | 768 | .7461 |
| 使用均值填充的行归一化 | 768 | .6823 |
看起来,通过在我们的数据集上应用均值填充和最小-最大归一化,我们终于能够获得更高的准确度,并且仍然使用了所有768可用的行。太棒了!
摘要
特征改进是关于识别我们数据中的问题和改进区域,并找出哪些清理方法将是最有效的。我们主要的收获应该是用数据科学家的眼光看待数据。而不是立即删除有问题的行/列,我们应该考虑最佳修复这些问题的方法。很多时候,我们的机器学习性能最终会感谢我们。
本章包含了几种处理我们定量列问题的方法。下一章将处理分类列的填充,以及如何从现有特征中引入全新的特征。我们将使用混合数值和分类列的 scikit-learn 管道,真正扩展我们可以处理的数据类型。
第四章:特征构建
在上一章中,我们使用 Pima Indian Diabetes Prediction 数据集来更好地了解我们数据集中哪些给定的特征最有价值。使用我们可用的特征,我们识别了列中的缺失值,并采用了删除缺失值、填充以及归一化/标准化数据的技术,以提高我们机器学习模型的准确性。
需要注意的是,到目前为止,我们只处理了定量特征。现在,我们将转向处理除了具有缺失值的定量数据之外,还要处理分类数据。我们的主要焦点将是使用给定的特征来构建模型可以从中学习的新特征。
我们可以利用各种方法来构建我们的特征,最基本的方法是从 Python 中的 pandas 库开始,通过乘数来缩放现有特征。我们将深入研究一些更数学密集的方法,并使用通过 scikit-learn 库提供的各种包;我们还将创建自己的自定义类。随着我们进入代码,我们将详细介绍这些类。
我们将在我们的讨论中涵盖以下主题:
-
检查我们的数据集
-
填充分类特征
-
编码分类变量
-
扩展数值特征
-
文本特定特征构建
检查我们的数据集
为了演示目的,在本章中,我们将使用我们创建的数据集,这样我们就可以展示各种数据级别和类型。让我们设置我们的 DataFrame 并深入了解我们的数据。
我们将使用 pandas 创建我们将要工作的 DataFrame,因为这是 pandas 中的主要数据结构。pandas DataFrame 的优势在于,我们有几个属性和方法可用于对数据进行操作。这使我们能够逻辑地操作数据,以全面了解我们正在处理的内容,以及如何最好地构建我们的机器学习模型:
- 首先,让我们导入
pandas:
# import pandas as pd
- 现在,我们可以设置我们的
DataFrame X。为此,我们将利用 pandas 中的DataFrame方法,该方法创建一个表格数据结构(具有行和列的表格)。此方法可以接受几种类型的数据(例如 NumPy 数组或字典)。在这里,我们将传递一个字典,其键为列标题,值为列表,每个列表代表一列:
X = pd.DataFrame({'city':['tokyo', None, 'london', 'seattle', 'san francisco', 'tokyo'],
'boolean':['yes', 'no', None, 'no', 'no', 'yes'],
'ordinal_column':['somewhat like', 'like', 'somewhat like', 'like', 'somewhat like', 'dislike'],
'quantitative_column':[1, 11, -.5, 10, None, 20]})
- 这将给我们一个具有四列和六行的 DataFrame。让我们打印我们的 DataFrame
X并查看数据:
print X
我们得到以下输出:
| 布尔值 | 城市 | 有序列 | 定量列 | |
|---|---|---|---|---|
| 0 | 是 | 东京 | 有点像 | 1.0 |
| 1 | 否 | 无 | 喜欢的 | 11.0 |
| 2 | 无 | 伦敦 | 有点像 | -0.5 |
| 3 | 否 | 西雅图 | 喜欢的 | 10.0 |
| 4 | 否 | 旧金山 | 有点像 | NaN |
| 5 | 是 | 东京 | 不喜欢的 | 20.0 |
让我们看一下我们的列,并确定我们的数据级别和类型:
-
布尔:这个列由二元分类数据(是/否)表示,处于名称级别 -
城市:这个列由分类数据表示,也处于名称级别 -
序号列:正如你可能从列名猜到的,这个列由序数数据表示,处于序数级别 -
定量列:这个列由整数在比例级别表示
填充分类特征
现在我们已经了解了我们正在处理的数据,让我们看看我们的缺失值:
-
为了做到这一点,我们可以使用 pandas 为我们提供的
isnull方法。此方法返回一个与值大小相同的boolean对象,指示值是否为空。 -
然后,我们将
sum这些值以查看哪些列有缺失数据:
X.isnull().sum()
>>>>
boolean 1
city 1
ordinal_column 0
quantitative_column 1
dtype: int64
在这里,我们可以看到我们有三列数据缺失。我们的行动方案将是填充这些缺失值。
如果你还记得,我们在上一章中实现了 scikit-learn 的Imputer类来填充数值数据。Imputer确实有一个分类选项,most_frequent,然而它只适用于已经被编码为整数的分类数据。
我们可能并不总是想以这种方式转换我们的分类数据,因为它可能会改变我们解释分类信息的方式,因此我们将构建自己的转换器。在这里,我们所说的转换器是指一种方法,通过这种方法,列将填充缺失值。
事实上,在本章中,我们将构建几个自定义转换器,因为它们对于对我们的数据进行转换非常有用,并为我们提供了在 pandas 或 scikit-learn 中不可轻易获得的选择。
让我们从我们的分类列城市开始。正如我们用均值填充缺失行来填充数值数据,我们也有一个类似的方法用于分类数据。为了填充分类数据的值,用最常见的类别填充缺失行。
要这样做,我们需要找出城市列中最常见的类别:
注意,我们需要指定我们正在处理的列来应用一个名为value_counts的方法。这将返回一个按降序排列的对象,因此第一个元素是最频繁出现的元素。
我们将只获取对象中的第一个元素:
# Let's find out what our most common category is in our city column
X['city'].value_counts().index[0]
>>>>
'tokyo'
我们可以看到东京似乎是最常见的城市。现在我们知道要使用哪个值来填充我们的缺失行,让我们填充这些空位。有一个fillna函数允许我们指定我们想要如何填充缺失值:
# fill empty slots with most common category
X['city'].fillna(X['city'].value_counts().index[0])
城市列现在看起来是这样的:
0 tokyo
1 tokyo
2 london
3 seattle
4 san francisco
5 tokyo
Name: city, dtype: object
太好了,现在我们的城市列不再有缺失值。然而,我们的其他分类列布尔仍然有。与其重复同样的方法,让我们构建一个能够处理所有分类数据填充的自定义填充器。
自定义填充器
在我们深入代码之前,让我们快速回顾一下管道:
-
管道允许我们按顺序应用一系列转换和一个最终估计器
-
管道的中间步骤必须是 转换器,这意味着它们必须实现
fit和transform方法 -
最终估计器只需要实现
fit
管道的目的是组装几个可以一起交叉验证的步骤,同时设置不同的参数。一旦我们为需要填充的每一列构建了自定义的转换器,我们将它们全部通过管道传递,以便我们的数据可以一次性进行转换。让我们从构建自定义类别填充器开始。
自定义类别填充器
首先,我们将利用 scikit-learn 的 TransformerMixin 基类来创建我们自己的自定义类别填充器。这个转换器(以及本章中的所有其他自定义转换器)将作为一个具有 fit 和 transform 方法的管道元素工作。
以下代码块将在本章中变得非常熟悉,因此我们将逐行详细讲解:
from sklearn.base import TransformerMixin
class CustomCategoryImputer(TransformerMixin):
def __init__(self, cols=None):
self.cols = cols
def transform(self, df):
X = df.copy()
for col in self.cols:
X[col].fillna(X[col].value_counts().index[0], inplace=True)
return X
def fit(self, *_):
return self
这个代码块中发生了很多事情,所以让我们逐行分解:
- 首先,我们有一个新的
import语句:
from sklearn.base import TransformerMixin
- 我们将从 scikit-learn 继承
TransformerMixin类,它包括一个.fit_transform方法,该方法调用我们将创建的.fit和.transform方法。这允许我们在转换器中保持与 scikit-learn 相似的结构。让我们初始化我们的自定义类:
class CustomCategoryImputer(TransformerMixin):
def __init__(self, cols=None):
self.cols = cols
- 我们已经实例化了我们的自定义类,并有了我们的
__init__方法,该方法初始化我们的属性。在我们的情况下,我们只需要初始化一个实例属性self.cols(它将是我们指定的参数中的列)。现在,我们可以构建我们的fit和transform方法:
def transform(self, df):
X = df.copy()
for col in self.cols:
X[col].fillna(X[col].value_counts().index[0], inplace=True)
return X
- 在这里,我们有我们的
transform方法。它接受一个 DataFrame,第一步是复制并重命名 DataFrame 为X。然后,我们将遍历我们在cols参数中指定的列来填充缺失的槽位。fillna部分可能感觉熟悉,因为我们已经在第一个例子中使用了这个函数。我们正在使用相同的函数,并设置它,以便我们的自定义类别填充器可以一次跨多个列工作。在填充了缺失值之后,我们返回填充后的 DataFrame。接下来是我们的fit方法:
def fit(self, *_):
return self
我们已经设置了我们的 fit 方法简单地 return self,这是 scikit-learn 中 .fit 方法的标准。
- 现在我们有一个自定义方法,允许我们填充我们的类别数据!让我们通过我们的两个类别列
city和boolean来看看它的实际效果:
# Implement our custom categorical imputer on our categorical columns.
cci = CustomCategoryImputer(cols=['city', 'boolean'])
- 我们已经初始化了我们的自定义类别填充器,现在我们需要将这个填充器
fit_transform到我们的数据集中:
cci.fit_transform(X)
我们的数据集现在看起来像这样:
| boolean | city | ordinal_column | quantitative_column | |
|---|---|---|---|---|
| 0 | yes | tokyo | somewhat like | 1.0 |
| 1 | no | tokyo | like | 11.0 |
| 2 | no | london | somewhat like | -0.5 |
| 3 | no | seattle | like | 10.0 |
| 4 | no | san francisco | somewhat like | NaN |
| 5 | yes | tokyo | dislike | 20.0 |
太好了!我们的city和boolean列不再有缺失值。然而,我们的定量列仍然有 null 值。由于默认的填充器不能选择列,让我们再做一个自定义的。
自定义定量填充器
我们将使用与我们的自定义分类填充器相同的结构。这里的主要区别是我们将利用 scikit-learn 的Imputer类来实际上在我们的列上执行转换:
# Lets make an imputer that can apply a strategy to select columns by name
from sklearn.preprocessing import Imputer
class CustomQuantitativeImputer(TransformerMixin):
def __init__(self, cols=None, strategy='mean'):
self.cols = cols
self.strategy = strategy
def transform(self, df):
X = df.copy()
impute = Imputer(strategy=self.strategy)
for col in self.cols:
X[col] = impute.fit_transform(X[[col]])
return X
def fit(self, *_):
return self
对于我们的CustomQuantitativeImputer,我们增加了一个strategy参数,这将允许我们指定我们想要如何为我们的定量数据填充缺失值。在这里,我们选择了mean来替换缺失值,并且仍然使用transform和fit方法。
再次,为了填充我们的数据,我们将调用fit_transform方法,这次指定了列和用于填充的strategy:
cqi = CustomQuantitativeImputer(cols=['quantitative_column'], strategy='mean')
cqi.fit_transform(X)
或者,而不是分别调用和fit_transform我们的CustomCategoryImputer和CustomQuantitativeImputer,我们也可以将它们设置在一个 pipeline 中,这样我们就可以一次性转换我们的 dataset。让我们看看如何:
- 从我们的
import语句开始:
# import Pipeline from sklearn
from sklearn.pipeline import Pipeline
- 现在,我们可以传递我们的自定义填充器:
imputer = Pipeline([('quant', cqi), ('category', cci)]) imputer.fit_transform(X)
让我们看看我们的 dataset 在 pipeline 转换后看起来像什么:
| boolean | city | ordinal_column | quantitative_column | |
|---|---|---|---|---|
| 0 | yes | tokyo | somewhat like | 1.0 |
| 1 | no | tokyo | like | 11.0 |
| 2 | no | london | somewhat like | -0.5 |
| 3 | no | seattle | like | 10.0 |
| 4 | no | san francisco | somewhat like | 8.3 |
| 5 | yes | tokyo | dislike | 20.0 |
现在我们有一个没有缺失值的 dataset 可以工作了!
编码分类变量
回顾一下,到目前为止,我们已经成功填充了我们的 dataset——包括我们的分类和定量列。在这个时候,你可能想知道,我们如何利用分类数据与机器学习算法结合使用?
简而言之,我们需要将这个分类数据转换为数值数据。到目前为止,我们已经确保使用最常见的类别来填充缺失值。现在这件事已经完成,我们需要更进一步。
任何机器学习算法,无论是线性回归还是使用欧几里得距离的 KNN,都需要数值输入特征来学习。我们可以依赖几种方法将我们的分类数据转换为数值数据。
名义级别的编码
让我们从名义级别的数据开始。我们主要的方法是将我们的分类数据转换为虚拟变量。我们有两种方法来做这件事:
-
利用 pandas 自动找到分类变量并将它们转换为虚拟变量
-
使用虚拟变量创建我们自己的自定义转换器以在 pipeline 中工作
在我们深入探讨这些选项之前,让我们先了解一下虚拟变量究竟是什么。
虚拟变量取值为零或一,以表示类别的缺失或存在。它们是代理变量,或数值替代变量,用于定性数据。
考虑一个简单的回归分析来确定工资。比如说,我们被给出了性别,这是一个定性变量,以及教育年限,这是一个定量变量。为了看看性别是否对工资有影响,我们会在女性时将虚拟编码为女性 = 1,在男性时将女性编码为 0。
在使用虚拟变量时,重要的是要意识到并避免虚拟变量陷阱。虚拟变量陷阱是指你拥有独立的变量是多线性的,或者高度相关的。简单来说,这些变量可以从彼此预测。所以,在我们的性别例子中,虚拟变量陷阱就是如果我们同时包含女性作为(0|1)和男性作为(0|1),实际上创建了一个重复的分类。可以推断出 0 个女性值表示男性。
为了避免虚拟变量陷阱,只需省略常数项或其中一个虚拟类别。省略的虚拟变量可以成为与其他变量比较的基础类别。
让我们回到我们的数据集,并采用一些方法将我们的分类数据编码为虚拟变量。pandas 有一个方便的get_dummies方法,实际上它会找到所有的分类变量,并为我们进行虚拟编码:
pd.get_dummies(X,
columns = ['city', 'boolean'], # which columns to dummify
prefix_sep='__') # the separator between the prefix (column name) and cell value
我们必须确保指定我们想要应用到的列,因为它也会对序数列进行虚拟编码,这不会很有意义。我们将在稍后更深入地探讨为什么对序数数据进行虚拟编码没有意义。
我们的数据,加上我们的虚拟编码列,现在看起来是这样的:
| ordinal_column | quantitative_column | city__london | city_san francisco | city_seattle | city_tokyo | boolean_no | boolean_yes | |
|---|---|---|---|---|---|---|---|---|
| 0 | somewhat like | 1.0 | 0 | 0 | 0 | 1 | 0 | 1 |
| 1 | like | 11.0 | 0 | 0 | 0 | 0 | 1 | 0 |
| 2 | somewhat like | -0.5 | 1 | 0 | 0 | 0 | 0 | 0 |
| 3 | like | 10.0 | 0 | 0 | 1 | 0 | 1 | 0 |
| 4 | somewhat like | NaN | 0 | 1 | 0 | 0 | 1 | 0 |
| 5 | dislike | 20.0 | 0 | 0 | 0 | 1 | 0 | 1 |
我们对数据进行虚拟编码的另一种选择是创建自己的自定义虚拟化器。创建这个虚拟化器允许我们设置一个管道,一次将整个数据集转换。
再次强调,我们将使用与之前两个自定义填充器相同的结构。在这里,我们的transform方法将使用方便的 pandas get_dummies方法为指定的列创建虚拟变量。在这个自定义虚拟化器中,我们唯一的参数是cols:
# create our custom dummifier
class CustomDummifier(TransformerMixin):
def __init__(self, cols=None):
self.cols = cols
def transform(self, X):
return pd.get_dummies(X, columns=self.cols)
def fit(self, *_):
return self
我们的定制虚拟化器模仿 scikit-learn 的OneHotEncoding,但具有在完整 DataFrame 上工作的附加优势。
对序数级别的编码
现在,让我们看看我们的有序列。这里仍然有一些有用的信息,然而,我们需要将字符串转换为数值数据。在有序级别,由于数据具有特定的顺序,因此使用虚拟变量是没有意义的。为了保持顺序,我们将使用标签编码器。
通过标签编码器,我们指的是在我们的有序数据中,每个标签都将与一个数值相关联。在我们的例子中,这意味着有序列值(dislike、somewhat like和like)将被表示为0、1和2。
以最简单的方式,代码如下所示:
# set up a list with our ordinal data corresponding the list index
ordering = ['dislike', 'somewhat like', 'like'] # 0 for dislike, 1 for somewhat like, and 2 for like
# before we map our ordering to our ordinal column, let's take a look at the column
print X['ordinal_column']
>>>>
0 somewhat like
1 like
2 somewhat like
3 like
4 somewhat like
5 dislike
Name: ordinal_column, dtype: object
在这里,我们设置了一个列表来排序我们的标签。这是关键,因为我们将利用列表的索引来将标签转换为数值数据。
在这里,我们将在我们的列上实现一个名为map的函数,它允许我们指定我们想要在列上实现的函数。我们使用一个称为lambda的结构来指定这个函数,它本质上允许我们创建一个匿名函数,或者一个没有绑定到名称的函数:
lambda x: ordering.index(x)
这段特定的代码创建了一个函数,该函数将我们的列表ordering的索引应用于每个元素。现在,我们将此映射到我们的有序列:
# now map our ordering to our ordinal column:
print X['ordinal_column'].map(lambda x: ordering.index(x))
>>>>
0 1
1 2
2 1
3 2
4 1
5 0
Name: ordinal_column, dtype: int64
我们的有序列现在被表示为标记数据。
注意,scikit-learn 有一个LabelEncoder,但我们没有使用这种方法,因为它不包括排序类别的能力(0表示不喜欢,1表示有点喜欢,2表示喜欢),正如我们之前所做的那样。相反,默认是排序方法,这不是我们在这里想要使用的。
再次,让我们创建一个自定义标签编码器,使其适合我们的管道:
class CustomEncoder(TransformerMixin):
def __init__(self, col, ordering=None):
self.ordering = ordering
self.col = col
def transform(self, df):
X = df.copy()
X[self.col] = X[self.col].map(lambda x: self.ordering.index(x))
return X
def fit(self, *_):
return self
我们在本章中维护了其他自定义转换器的结构。在这里,我们使用了前面详细说明的map和lambda函数来转换指定的列。注意关键参数ordering,它将确定标签将编码成哪些数值。
让我们称我们的自定义编码器为:
ce = CustomEncoder(col='ordinal_column', ordering = ['dislike', 'somewhat like', 'like'])
ce.fit_transform(X)
经过这些转换后,我们的数据集看起来如下所示:
| 布尔值 | 城市 | 有序列 | 数量列 | |
|---|---|---|---|---|
| 0 | yes | tokyo | 1 | 1.0 |
| 1 | no | None | 2 | 11.0 |
| 2 | None | london | 1 | -0.5 |
| 3 | no | seattle | 2 | 10.0 |
| 4 | no | san francisco | 1 | NaN |
| 5 | yes | tokyo | 0 | 20.0 |
我们的有序列现在被标记。
到目前为止,我们已经相应地转换了以下列:
-
布尔值、城市: 虚拟编码 -
有序列: 标签编码
将连续特征分桶到类别中
有时,当你有连续数值数据时,将连续变量转换为分类变量可能是有意义的。例如,假设你有年龄,但使用年龄范围可能更有用。
pandas 有一个有用的函数cut,可以为你对数据进行分箱。通过分箱,我们指的是它将为你的数据创建范围。
让我们看看这个函数如何在我们的quantitative_column上工作:
# name of category is the bin by default
pd.cut(X['quantitative_column'], bins=3)
cut函数对我们定量列的输出看起来是这样的:
0 (-0.52, 6.333]
1 (6.333, 13.167]
2 (-0.52, 6.333]
3 (6.333, 13.167]
4 NaN
5 (13.167, 20.0]
Name: quantitative_column, dtype: category
Categories (3, interval[float64]): [(-0.52, 6.333] < (6.333, 13.167] < (13.167, 20.0]]
当我们指定bins为整数(bins = 3)时,它定义了X范围内的等宽bins的数量。然而,在这种情况下,X的范围在每边扩展了 0.1%,以包括X的最小值或最大值。
我们也可以将labels设置为False,这将只返回bins的整数指标:
# using no labels
pd.cut(X['quantitative_column'], bins=3, labels=False)
这里是我们quantitative_column的整数指标看起来是这样的:
0 0.0
1 1.0
2 0.0
3 1.0
4 NaN
5 2.0
Name: quantitative_column, dtype: float64
使用cut函数查看我们的选项,我们还可以为我们的管道构建自己的CustomCutter。再次,我们将模仿我们的转换器的结构。我们的transform方法将使用cut函数,因此我们需要将bins和labels作为参数设置:
class CustomCutter(TransformerMixin):
def __init__(self, col, bins, labels=False):
self.labels = labels
self.bins = bins
self.col = col
def transform(self, df):
X = df.copy()
X[self.col] = pd.cut(X[self.col], bins=self.bins, labels=self.labels)
return X
def fit(self, *_):
return self
注意,我们已经将默认的labels参数设置为False。初始化我们的CustomCutter,指定要转换的列和要使用的bins数量:
cc = CustomCutter(col='quantitative_column', bins=3)
cc.fit_transform(X)
使用我们的CustomCutter转换quantitative_column,我们的数据现在看起来是这样的:
| boolean | city | ordinal_column | quantitative_column | |
|---|---|---|---|---|
| 0 | yes | tokyo | somewhat like | 1.0 |
| 1 | no | None | like | 11.0 |
| 2 | None | london | somewhat like | -0.5 |
| 3 | no | seattle | like | 10.0 |
| 4 | no | san francisco | somewhat like | NaN |
| 5 | yes | tokyo | dislike | 20.0 |
注意,我们的quantitative_column现在是序数,因此不需要对数据进行空编码。
创建我们的管道
为了回顾,到目前为止,我们已经以以下方式转换了数据集中的列:
-
boolean, city: 空编码 -
ordinal_column: 标签编码 -
quantitative_column: 序数级别数据
由于我们现在已经为所有列设置了转换,让我们将所有内容组合到一个管道中。
从导入我们的Pipeline类开始,来自 scikit-learn:
from sklearn.pipeline import Pipeline
我们将汇集我们创建的每个自定义转换器。以下是我们在管道中遵循的顺序:
-
首先,我们将使用
imputer来填充缺失值 -
接下来,我们将对分类列进行空编码
-
然后,我们将对
ordinal_column进行编码 -
最后,我们将对
quantitative_column进行分桶
让我们按照以下方式设置我们的管道:
pipe = Pipeline([("imputer", imputer), ('dummify', cd), ('encode', ce), ('cut', cc)])
# will use our initial imputer
# will dummify variables first
# then encode the ordinal column
# then bucket (bin) the quantitative column
为了查看我们使用管道对数据进行完整转换的样子,让我们看看零转换的数据:
# take a look at our data before fitting our pipeline
print X
这是我们数据在开始时,在执行任何转换之前的样子:
| boolean | city | ordinal_column | quantitative_column | |
|---|---|---|---|---|
| 0 | yes | tokyo | somewhat like | 1.0 |
| 1 | no | None | like | 11.0 |
| 2 | None | london | somewhat like | -0.5 |
| 3 | no | seattle | like | 10.0 |
| 4 | no | san francisco | somewhat like | NaN |
| 5 | yes | tokyo | dislike | 20.0 |
我们现在可以fit我们的管道:
# now fit our pipeline
pipe.fit(X)
>>>>
Pipeline(memory=None,
steps=[('imputer', Pipeline(memory=None,
steps=[('quant', <__main__.CustomQuantitativeImputer object at 0x128bf00d0>), ('category', <__main__.CustomCategoryImputer object at 0x13666bf50>)])), ('dummify', <__main__.CustomDummifier object at 0x128bf0ed0>), ('encode', <__main__.CustomEncoder object at 0x127e145d0>), ('cut', <__main__.CustomCutter object at 0x13666bc90>)])
我们已经创建了管道对象,让我们转换我们的 DataFrame:
pipe.transform(X)
在所有适当的列变换之后,我们的最终数据集看起来是这样的:
| ordinal_column | quantitative_column | boolean_no | boolean_yes | city_london | city_san francisco | city_seattle | city_tokyo | |
|---|---|---|---|---|---|---|---|---|
| 0 | 1 | 0 | 0 | 1 | 0 | 0 | 0 | 1 |
| 1 | 2 | 1 | 1 | 0 | 0 | 0 | 0 | 1 |
| 2 | 1 | 0 | 1 | 0 | 1 | 0 | 0 | 0 |
| 3 | 2 | 1 | 1 | 0 | 0 | 0 | 1 | 0 |
| 4 | 1 | 1 | 1 | 0 | 0 | 1 | 0 | 0 |
| 5 | 0 | 2 | 0 | 1 | 0 | 0 | 0 | 1 |
扩展数值特征
数值特征可以通过各种方法进行扩展,从而创建出更丰富的特征。之前,我们看到了如何将连续数值数据转换为有序数据。现在,我们将进一步扩展我们的数值特征。
在我们深入探讨这些方法之前,我们将引入一个新的数据集进行操作。
从单个胸挂加速度计数据集进行活动识别
这个数据集收集了十五名参与者进行七种活动的可穿戴加速度计上的数据。加速度计的采样频率为 52 Hz,加速度计数据未经校准。
数据集按参与者分隔,包含以下内容:
-
顺序号
-
x 加速度
-
y 加速度
-
z 加速度
-
标签
标签用数字编码,代表一个活动,如下所示:
-
在电脑上工作
-
站立、行走和上/下楼梯
-
站立
-
行走
-
上/下楼梯
-
与人边走边谈
-
站立时说话
更多关于这个数据集的信息可以在 UCI 机器学习仓库上找到:
archive.ics.uci.edu/ml/datasets/Activity+Recognition+from+Single+Chest-Mounted+Accelerometer
让我们来看看我们的数据。首先,我们需要加载我们的 CSV 文件并设置列标题:
df = pd.read_csv('../data/activity_recognizer/1.csv', header=None)
df.columns = ['index', 'x', 'y', 'z', 'activity']
现在,让我们使用.head方法检查前几行,默认为前五行,除非我们指定要显示的行数:
df.head()
这表明:
| index | x | y | z | activity | |
|---|---|---|---|---|---|
| 0 | 0.0 | 1502 | 2215 | 2153 | 1 |
| 1 | 1.0 | 1667 | 2072 | 2047 | 1 |
| 2 | 2.0 | 1611 | 1957 | 1906 | 1 |
| 3 | 3.0 | 1601 | 1939 | 1831 | 1 |
| 4 | 4.0 | 1643 | 1965 | 1879 | 1 |
这个数据集旨在训练模型,根据智能手机等设备上的加速度计的x、y和z位置来识别用户的当前身体活动。根据网站信息,activity列的选项如下:
-
1: 在电脑上工作
-
2: 站立并上/下楼梯
-
3: 站立
-
4: 走路
-
5: 上/下楼梯
-
6: 与人边走边谈
-
7: 站立时说话
activity列将是我们将尝试预测的目标变量,使用其他列。让我们确定我们的机器学习模型中要击败的零准确率。为此,我们将调用value_counts方法,并将normalize选项设置为True,以给出最常见的活动作为百分比:
df['activity'].value_counts(normalize=True)
7 0.515369
1 0.207242
4 0.165291
3 0.068793
5 0.019637
6 0.017951
2 0.005711
0 0.000006
Name: activity, dtype: float64
要击败的零准确率是 51.53%,这意味着如果我们猜测七个(站立时说话),那么我们正确的时间会超过一半。现在,让我们来进行一些机器学习!让我们逐行进行,设置我们的模型。
首先,我们有我们的import语句:
from sklearn.neighbors import KNeighborsClassifier
from sklearn.model_selection import GridSearchCV
你可能对上一章中的这些导入语句很熟悉。再一次,我们将利用 scikit-learn 的K-Nearest Neighbors(KNN)分类模型。我们还将使用网格搜索模块,该模块自动找到最适合我们数据的 KNN 模型的最佳参数组合。接下来,我们为我们的预测模型创建一个特征矩阵(X)和一个响应变量(y):
X = df[['x', 'y', 'z']]
# create our feature matrix by removing the response variable
y = df['activity']
一旦我们的X和y设置好,我们就可以引入我们成功运行网格搜索所需的变量和实例:
# our grid search variables and instances
# KNN parameters to try
knn_params = {'n_neighbors':[3, 4, 5, 6]}
接下来,我们将实例化一个 KNN 模型和一个网格搜索模块,并将其拟合到我们的特征矩阵和响应变量:
knn = KNeighborsClassifier()
grid = GridSearchCV(knn, knn_params)
grid.fit(X, y)
现在,我们可以print出最佳的准确率和用于学习的参数:
print grid.best_score_, grid.best_params_
0.720752487677 {'n_neighbors': 5}
使用五个邻居作为其参数,我们的 KNN 模型能够达到 72.07%的准确率,比我们大约 51.53%的零准确率要好得多!也许我们可以利用另一种方法将我们的准确率进一步提高。
多项式特征
处理数值数据并创建更多特征的关键方法是通过 scikit-learn 的PolynomialFeatures类。在其最简单的形式中,这个构造函数将创建新的列,这些列是现有列的乘积,以捕捉特征交互。
更具体地说,这个类将生成一个新的特征矩阵,包含所有小于或等于指定度的特征的多项式组合。这意味着,如果你的输入样本是二维的,如下所示:[a, b],那么二次多项式特征如下:[1, a, b, a², ab, b²]。
参数
在实例化多项式特征时,有三个参数需要考虑:
-
degree
-
interaction_only -
include_bias
度数对应于多项式特征的度数,默认设置为二。
interaction_only是一个布尔值,当为 true 时,只产生交互特征,这意味着是不同度数特征的乘积。interaction_only的默认值是 false。
include_bias也是一个布尔值,当为 true(默认)时,包括一个bias列,即所有多项式幂为零的特征,添加一列全为 1。
让我们先导入类并使用我们的参数实例化,来设置一个多项式特征实例。首先,让我们看看当将 interaction_only 设置为 False 时我们得到哪些特征:
from sklearn.preprocessing import PolynomialFeatures
poly = PolynomialFeatures(degree=2, include_bias=False, interaction_only=False)
现在,我们可以将这些多项式特征 fit_transform 到我们的数据集中,并查看扩展数据集的 shape:
X_poly = poly.fit_transform(X)
X_poly.shape
(162501, 9)
我们的数据集现在扩展到了 162501 行和 9 列。
让我们把数据放入一个 DataFrame 中,设置列标题为 feature_names,并查看前几行:
pd.DataFrame(X_poly, columns=poly.get_feature_names()).head()
这显示给我们:
| x0 | x1 | x2 | x0² | x0 x1 | x0 x2 | x1² | x1 x2 | x2² | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 1502.0 | 2215.0 | 2153.0 | 2256004.0 | 3326930.0 | 3233806.0 | 4906225.0 | 4768895.0 | 4635409.0 |
| 1 | 1667.0 | 2072.0 | 2047.0 | 2778889.0 | 3454024.0 | 3412349.0 | 4293184.0 | 4241384.0 | 4190209.0 |
| 2 | 1611.0 | 1957.0 | 1906.0 | 2595321.0 | 3152727.0 | 3070566.0 | 3829849.0 | 3730042.0 | 3632836.0 |
| 3 | 1601.0 | 1939.0 | 1831.0 | 2563201.0 | 3104339.0 | 2931431.0 | 3759721.0 | 3550309.0 | 3352561.0 |
| 4 | 1643.0 | 1965.0 | 1879.0 | 2699449.0 | 3228495.0 | 3087197.0 | 3861225.0 | 3692235.0 | 3530641.0 |
探索性数据分析
现在我们可以进行一些探索性数据分析。由于多项式特征的目的是在原始数据中获得更好的特征交互感,最佳的可视化方式是通过相关性 heatmap。
我们需要导入一个数据可视化工具,以便我们可以创建 heatmap:
%matplotlib inline
import seaborn as sns
Matplotlib 和 Seaborn 是流行的数据可视化工具。我们现在可以如下可视化我们的相关性 heatmap:
sns.heatmap(pd.DataFrame(X_poly, columns=poly.get_feature_names()).corr())
.corr 是一个我们可以调用我们的 DataFrame 的函数,它给我们一个特征的相关矩阵。让我们看一下我们的特征交互:

热图上的颜色基于纯数值;颜色越深,特征的相关性越大。
到目前为止,我们已经查看了我们设置 interaction_only 参数为 False 时的多项式特征。让我们将其设置为 True 并看看没有重复变量时我们的特征看起来如何。
我们将按照之前的方式设置这个多项式特征实例。注意唯一的区别是 interaction_only 现在是 True:
poly = PolynomialFeatures(degree=2, include_bias=False, interaction_only=True) X_poly = poly.fit_transform(X) print X_poly.shape
(162501, 6)
我们现在有 162501 行和 6 列。让我们看一下:
pd.DataFrame(X_poly, columns=poly.get_feature_names()).head()
DataFrame 现在看起来如下:
| x0 | x1 | x2 | x0 x1 | x0 x2 | x1 x2 | |
|---|---|---|---|---|---|---|
| 0 | 1502.0 | 2215.0 | 2153.0 | 3326930.0 | 3233806.0 | 4768895.0 |
| 1 | 1667.0 | 2072.0 | 2047.0 | 3454024.0 | 3412349.0 | 4241384.0 |
| 2 | 1611.0 | 1957.0 | 1906.0 | 3152727.0 | 3070566.0 | 3730042.0 |
| 3 | 1601.0 | 1939.0 | 1831.0 | 3104339.0 | 2931431.0 | 3550309.0 |
| 4 | 1643.0 | 1965.0 | 1879.0 | 3228495.0 | 3087197.0 | 3692235.0 |
由于这次interaction_only被设置为True,因此x0²、x1²和x2²消失了,因为它们是重复变量。现在让我们看看我们的相关矩阵现在是什么样子:
sns.heatmap(pd.DataFrame(X_poly,
columns=poly.get_feature_names()).corr())
我们得到了以下结果:

我们能够看到特征是如何相互作用的。我们还可以使用新的多项式特征对 KNN 模型进行网格搜索,这些特征也可以在管道中进行网格搜索:
- 让我们先设置管道参数:
pipe_params = {'poly_features__degree':[1, 2, 3], 'poly_features__interaction_only':[True, False], 'classify__n_neighbors':[3, 4, 5, 6]}
- 现在,实例化我们的
Pipeline:
pipe = Pipeline([('poly_features', poly), ('classify', knn)])
- 从这里,我们可以设置我们的网格搜索并打印出最佳得分和参数以供学习:
grid = GridSearchCV(pipe, pipe_params)
grid.fit(X, y)
print grid.best_score_, grid.best_params_
0.721189408065 {'poly_features__degree': 2, 'poly_features__interaction_only': True, 'classify__n_neighbors': 5}
我们现在的准确率是 72.12%,这比我们使用多项式特征扩展特征时的准确率有所提高!
文本特定特征构建
到目前为止,我们一直在处理分类数据和数值数据。虽然我们的分类数据以字符串的形式出现,但文本一直是单一类别的一部分。现在我们将更深入地研究更长的文本数据。这种文本数据的形式比单一类别的文本数据要复杂得多,因为我们现在有一系列类别,或称为标记。
在我们进一步深入处理文本数据之前,让我们确保我们清楚当我们提到文本数据时我们指的是什么。考虑一个像 Yelp 这样的服务,用户会撰写关于餐厅和企业的评论来分享他们的体验。这些评论,都是以文本格式编写的,包含大量对机器学习有用的信息,例如,在预测最佳餐厅访问方面。
在当今世界,我们的大部分沟通都是通过书面文字进行的,无论是在消息服务、社交媒体还是电子邮件中。因此,通过建模可以从这些信息中获得很多。例如,我们可以从 Twitter 数据中执行情感分析。
这种类型的工作可以被称为自然语言处理(NLP)。这是一个主要关注计算机与人类之间交互的领域,特别是计算机可以被编程来处理自然语言。
现在,正如我们之前提到的,需要注意的是,所有机器学习模型都需要数值输入,因此当我们处理文本并将此类数据转换为数值特征时,我们必须富有创意并战略性地思考。有几种方法可以实现这一点,让我们开始吧。
词袋表示
scikit-learn 有一个方便的模块叫做feature_extraction,它允许我们,正如其名所示,从机器学习算法支持的格式中提取文本等数据的特征。这个模块为我们提供了在处理文本时可以利用的方法。
展望未来,我们可能会将我们的文本数据称为语料库,具体来说,是指文本内容或文档的集合。
将语料库转换为数值表示的一种最常见方法,称为向量化,是通过一种称为词袋模型的方法实现的。词袋模型背后的基本思想是,文档由单词出现来描述,而完全忽略单词在文档中的位置。在其最简单形式中,文本被表示为一个袋,不考虑语法或单词顺序,并作为一个集合维护,给予多重性以重要性。词袋模型表示通过以下三个步骤实现:
-
分词
-
计数
-
正则化
让我们从分词开始。这个过程使用空白和标点符号将单词分开,将它们转换为标记。每个可能的标记都被赋予一个整数 ID。
接下来是计数。这一步只是简单地计算文档中标记的出现次数。
最后是正则化,这意味着当标记在大多数文档中出现时,它们的权重会随着重要性的降低而降低。
让我们考虑更多用于向量化的方法。
CountVectorizer
CountVectorizer是将文本数据转换为它们的向量表示的最常用方法。在某种程度上,它与虚拟变量相似,因为CountVectorizer将文本列转换为矩阵,其中列是标记,单元格值是每个文档中每个标记的出现次数。得到的矩阵被称为文档-词矩阵,因为每一行将代表一个文档(在这种情况下,是一条推文),每一列代表一个术语(一个单词)。
让我们查看一个新的数据集,看看CountVectorizer是如何工作的。Twitter 情感分析数据集包含 1,578,627 条分类推文,每行标记为正情感为 1,负情感为 0。
关于此数据集的更多信息可以在thinknook.com/twitter-sentiment-analysis-training-corpus-dataset-2012-09-22/找到。
让我们使用 pandas 的read_csv方法加载数据。请注意,我们指定了一个可选的encoding参数,以确保我们正确处理推文中的所有特殊字符:
tweets = pd.read_csv('../data/twitter_sentiment.csv', encoding='latin1')
这使我们能够以特定格式加载数据,并适当地映射文本字符。
看看数据的前几行:
tweets.head()
我们得到以下数据:
| 项目 ID | 情感 | 情感文本 | |
|---|---|---|---|
| 0 | 1 | 0 | 我为我的 APL 朋友感到难过... |
| 1 | 2 | 0 | 我错过了新月天体... |
| 2 | 3 | 1 | omg 它已经 7:30 😮 |
| 3 | 4 | 0 | .. Omgaga. Im sooo im gunna CRy. I'... |
| 4 | 5 | 0 | 我觉得我的 bf 在欺骗我!!! ... |
我们只关心情感和情感文本列,所以现在我们将删除项目 ID列:
del tweets['ItemID']
我们的数据看起来如下:
| 情感 | 情感文本 | |
|---|---|---|
| 0 | 0 | 我为我的 APL 朋友感到难过... |
| 1 | 0 | 我错过了新月天体... |
| 2 | 1 | omg its already 7:30 😮 |
| 3 | 0 | .. Omgaga. Im sooo im gunna CRy. I'... |
| 4 | 0 | i think mi bf is cheating on me!!! ... |
现在,我们可以导入CountVectorizer,更好地理解我们正在处理文本:
from sklearn.feature_extraction.text import CountVectorizer
让我们设置我们的X和y:
X = tweets['SentimentText']
y = tweets['Sentiment']
CountVectorizer类与迄今为止我们一直在使用的自定义转换器非常相似,并且有一个fit_transform函数来处理数据:
vect = CountVectorizer()
_ = vect.fit_transform(X)
print _.shape
(99989, 105849)
在我们的CountVectorizer转换我们的数据后,我们有 99,989 行和 105,849 列。
CountVectorizer有许多不同的参数可以改变构建的特征数量。让我们简要回顾一下这些参数,以更好地了解这些特征是如何创建的。
CountVectorizer 参数
我们将要讨论的一些参数包括:
-
stop_words -
min_df -
max_df -
ngram_range -
analyzer
stop_words是CountVectorizer中常用的一个参数。你可以向该参数传递字符串english,并使用内置的英语停用词列表。你也可以指定一个单词列表。这些单词将被从标记中删除,并且不会出现在你的数据中的特征中。
这里有一个例子:
vect = CountVectorizer(stop_words='english') # removes a set of english stop words (if, a, the, etc)
_ = vect.fit_transform(X)
print _.shape
(99989, 105545)
你可以看到,当没有使用停用词时,特征列从 105,849 减少到 105,545,当设置了英语停用词时。使用停用词的目的是从特征中去除噪声,并移除那些在模型中不会带来太多意义的常用词。
另一个参数称为min_df。该参数用于通过忽略低于给定阈值或截止值的文档频率较低的术语来筛选特征数量。
这里是带有min_df的我们的CountVectorizer实现:
vect = CountVectorizer(min_df=.05) # only includes words that occur in at least 5% of the corpus documents
# used to skim the number of features
_ = vect.fit_transform(X)
print _.shape
(99989, 31)
这是一个用于显著减少创建的特征数量的方法。
同样还有一个参数称为max_df:
vect = CountVectorizer(max_df=.8) # only includes words that occur at most 80% of the documents
# used to "Deduce" stop words
_ = vect.fit_transform(X)
print _.shape
(99989, 105849)
这类似于试图了解文档中存在哪些停用词。
接下来,让我们看看ngram_range参数。该参数接受一个元组,其中 n 值的范围的下限和上限表示要提取的不同 n-gram 的数量。N-gram 代表短语,所以一个值代表一个标记,然而两个值则代表两个标记一起。正如你可以想象的那样,这将显著扩大我们的特征集:
vect = CountVectorizer(ngram_range=(1, 5)) # also includes phrases up to 5 words
_ = vect.fit_transform(X)
print _.shape # explodes the number of features
(99989, 3219557)
看看,我们现在有 3,219,557 个特征。由于单词集(短语)有时可以传达更多的意义,使用 n-gram 范围对于建模是有用的。
你还可以在CountVectorizer中将分析器作为一个参数设置。分析器确定特征应该由单词或字符 n-gram 组成。默认情况下是单词:
vect = CountVectorizer(analyzer='word') # default analyzer, decides to split into words
_ = vect.fit_transform(X)
print _.shape
(99989, 105849)
由于默认情况下是单词,我们的特征列数量与原始数据变化不大。
我们甚至可以创建自己的自定义分析器。从概念上讲,单词是由词根或词干构建的,我们可以构建一个考虑这一点的自定义分析器。
词干提取是一种常见的自然语言处理方法,它允许我们将词汇表简化,或者通过将单词转换为它们的词根来缩小它。有一个名为 NLTK 的自然语言工具包,它有几个包允许我们对文本数据进行操作。其中一个包就是 stemmer。
让我们看看它是如何工作的:
- 首先,导入我们的
stemmer并初始化它:
from nltk.stem.snowball import SnowballStemmer
stemmer = SnowballStemmer('english')
- 现在,让我们看看一些词是如何进行词根提取的:
stemmer.stem('interesting')
u'interest'
- 因此,单词
interesting可以被缩减到其词根。现在我们可以使用这个来创建一个函数,允许我们将单词标记为其词根:
# define a function that accepts text and returns a list of lemmas
def word_tokenize(text, how='lemma'):
words = text.split(' ') # tokenize into words
return [stemmer.stem(word) for word in words]
- 让我们看看我们的函数输出的是什么:
word_tokenize("hello you are very interesting")
[u'hello', u'you', u'are', u'veri', u'interest']
- 我们现在可以将这个标记函数放入我们的分析器参数中:
vect = CountVectorizer(analyzer=word_tokenize)
_ = vect.fit_transform(X)
print _.shape # fewer features as stemming makes words smaller
(99989, 154397)
这给我们带来了更少的功能,这在直观上是有意义的,因为我们的词汇量随着词干提取而减少。
CountVectorizer 是一个非常有用的工具,可以帮助我们扩展特征并将文本转换为数值特征。还有一个常见的向量化器我们将要探讨。
Tf-idf 向量化器
Tf-idfVectorizer 可以分解为两个组件。首先,是 tf 部分,它代表 词频,而 idf 部分则意味着 逆文档频率。这是一种在信息检索和聚类中应用的词—权重方法。
一个权重被赋予以评估一个词在语料库中的文档中的重要性。让我们更深入地看看每个部分:
-
tf:词频:衡量一个词在文档中出现的频率。由于文档的长度可能不同,一个词在较长的文档中可能出现的次数比在较短的文档中多得多。因此,词频通常被除以文档长度,或者文档中的总词数,作为归一化的方式。
-
idf:逆文档频率:衡量一个词的重要性。在计算词频时,所有词都被视为同等重要。然而,某些词,如 is、of 和 that,可能出现很多次,但重要性很小。因此,我们需要减少频繁词的权重,同时增加罕见词的权重。
为了再次强调,TfidfVectorizer 与 CountVectorizer 相同,即它从标记中构建特征,但它更进一步,将计数归一化到语料库中出现的频率。让我们看看这个动作的一个例子。
首先,我们的导入:
from sklearn.feature_extraction.text import TfidfVectorizer
为了引用之前的代码,一个普通的 CountVectorizer 将输出一个文档-词矩阵:
vect = CountVectorizer()
_ = vect.fit_transform(X)
print _.shape, _[0,:].mean()
(99989, 105849) 6.61319426731e-05
我们的 TfidfVectorizer 可以设置如下:
vect = TfidfVectorizer()
_ = vect.fit_transform(X)
print _.shape, _[0,:].mean() # same number of rows and columns, different cell values
(99989, 105849) 2.18630609758e-05
我们可以看到,这两个向量化器输出相同数量的行和列,但在每个单元格中产生不同的值。这是因为 TfidfVectorizer 和 CountVectorizer 都用于将文本数据转换为定量数据,但它们填充单元格值的方式不同。
在机器学习管道中使用文本
当然,我们的向量器的最终目标是将它们用于使文本数据可被我们的机器学习管道摄取。因为CountVectorizer和TfidfVectorizer就像我们在本书中使用的任何其他转换器一样,我们将不得不利用 scikit-learn 管道来确保我们的机器学习管道的准确性和诚实性。在我们的例子中,我们将处理大量的列(数以万计),因此我将使用在这种情况下已知更有效的分类器,即朴素贝叶斯模型:
from sklearn.naive_bayes import MultinomialNB # for faster predictions with large number of features...
在我们开始构建管道之前,让我们获取响应列的空准确率,该列要么为零(消极),要么为一(积极):
# get the null accuracy
y.value_counts(normalize=True)
1 0.564632 0 0.435368 Name: Sentiment, dtype: float64
使准确率超过 56.5%。现在,让我们创建一个包含两个步骤的管道:
-
使用
CountVectorizer对推文进行特征提取 -
MultiNomialNB朴素贝叶斯模型用于区分积极和消极情绪
首先,让我们设置我们的管道参数如下,然后按照以下方式实例化我们的网格搜索:
# set our pipeline parameters
pipe_params = {'vect__ngram_range':[(1, 1), (1, 2)], 'vect__max_features':[1000, 10000], 'vect__stop_words':[None, 'english']}
# instantiate our pipeline
pipe = Pipeline([('vect', CountVectorizer()), ('classify', MultinomialNB())])
# instantiate our gridsearch object
grid = GridSearchCV(pipe, pipe_params)
# fit the gridsearch object
grid.fit(X, y)
# get our results
print grid.best_score_, grid.best_params_
0.755753132845 {'vect__ngram_range': (1, 2), 'vect__stop_words': None, 'vect__max_features': 10000}
我们得到了 75.6%,这很棒!现在,让我们加快速度,并引入TfidfVectorizer。而不是使用 tf-idf 重建管道而不是CountVectorizer,让我们尝试使用一些不同的东西。scikit-learn 有一个FeatureUnion模块,它促进了特征的横向堆叠(并排)。这允许我们在同一个管道中使用多种类型的文本特征提取器。
例如,我们可以在我们的推文中运行一个featurizer,它同时运行一个TfidfVectorizer和一个CountVectorizer,并将它们水平连接(保持行数不变但增加列数):
from sklearn.pipeline import FeatureUnion
# build a separate featurizer object
featurizer = FeatureUnion([('tfidf_vect', TfidfVectorizer()), ('count_vect', CountVectorizer())])
一旦我们构建了featurizer,我们就可以用它来查看它如何影响我们数据的形状:
_ = featurizer.fit_transform(X)
print _.shape # same number of rows , but twice as many columns as either CV or TFIDF
(99989, 211698)
我们可以看到,将两个特征提取器合并会导致具有相同行数的数据集,但将CountVectorizer或TfidfVectorizer的数量翻倍。这是因为结果数据集实际上是两个数据集并排放置。这样,我们的机器学习模型可以同时从这两组数据中学习。让我们稍微改变一下featurizer对象的params,看看它会产生什么差异:
featurizer.set_params(tfidf_vect__max_features=100, count_vect__ngram_range=(1, 2),
count_vect__max_features=300)
# the TfidfVectorizer will only keep 100 words while the CountVectorizer will keep 300 of 1 and 2 word phrases
_ = featurizer.fit_transform(X)
print _.shape # same number of rows , but twice as many columns as either CV or TFIDF
(99989, 400)
让我们构建一个更全面的管道,它结合了两个向量器的特征合并:
pipe_params = {'featurizer__count_vect__ngram_range':[(1, 1), (1, 2)], 'featurizer__count_vect__max_features':[1000, 10000], 'featurizer__count_vect__stop_words':[None, 'english'],
'featurizer__tfidf_vect__ngram_range':[(1, 1), (1, 2)], 'featurizer__tfidf_vect__max_features':[1000, 10000], 'featurizer__tfidf_vect__stop_words':[None, 'english']}
pipe = Pipeline([('featurizer', featurizer), ('classify', MultinomialNB())])
grid = GridSearchCV(pipe, pipe_params)
grid.fit(X, y)
print grid.best_score_, grid.best_params_
0.758433427677 {'featurizer__tfidf_vect__max_features': 10000, 'featurizer__tfidf_vect__stop_words': 'english', 'featurizer__count_vect__stop_words': None, 'featurizer__count_vect__ngram_range': (1, 2), 'featurizer__count_vect__max_features': 10000, 'featurizer__tfidf_vect__ngram_range': (1, 1)}
很好,甚至比单独使用CountVectorizer还要好!还有一点值得注意的是,CountVectorizer的最佳ngram_range是(1, 2),而TfidfVectorizer是(1, 1),这意味着单独的单词出现并不像两个单词短语的出现那样重要。
到目前为止,应该很明显,我们可以通过以下方式使我们的管道变得更加复杂:
-
- 对每个向量器进行数十个参数的网格搜索
-
- 在我们的管道中添加更多步骤,例如多项式特征构造
但这对本文来说会很繁琐,而且在大多数商业笔记本电脑上运行可能需要数小时。请随意扩展这个管道,并超越我们的分数!
呼,这可真不少。文本处理可能会很困难。在讽刺、拼写错误和词汇量方面,数据科学家和机器学习工程师的工作量很大。本指南将使您,作为读者,能够对自己的大型文本数据集进行实验,并获得自己的结果!
摘要
到目前为止,我们已经介绍了在分类和数值数据中填充缺失值的方法,对分类变量进行编码,以及创建自定义转换器以适应管道。我们还深入探讨了针对数值数据和基于文本数据的特征构造方法。
在下一章中,我们将查看我们构建的特征,并考虑选择适当的方法来选择用于我们的机器学习模型的正确特征。
第五章:特征选择
我们已经完成了文本的一半,并且我们已经处理了大约一打数据集,看到了许多我们作为数据科学家和机器学习工程师在工作和生活中可能利用的特征选择方法,以确保我们能够从预测建模中获得最大收益。到目前为止,在处理数据时,我们已经使用了包括以下方法在内的方法:
-
通过识别数据级别来理解特征
-
特征改进和缺失值填充
-
特征标准化和归一化
上述每种方法都在我们的数据处理流程中占有一席之地,而且往往两种或更多方法会相互配合使用。
文本的剩余部分将专注于其他特征工程方法,这些方法在本质上比本书前半部分更为数学化和复杂。随着先前工作流程的增长,我们将尽力避免让读者了解我们调用的每一个统计测试的内部机制,而是传达一个更广泛的测试目标图景。作为作者和讲师,我们始终欢迎您就本工作的任何内部机制提出问题。
在我们讨论特征的过程中,我们经常遇到一个问题,那就是噪声。我们常常不得不处理那些可能不是高度预测响应的特征,有时甚至可能阻碍我们的模型在预测响应方面的性能。我们使用标准化和归一化等工具来尝试减轻这种损害,但最终,噪声必须得到处理。
在本章中,我们将讨论一种称为特征选择的特征工程子集,这是从原始特征集中选择哪些特征在模型预测流程中是最佳的过程。更正式地说,给定 n 个特征,我们寻找一个包含 k 个特征(其中 k < n)的子集,以改善我们的机器学习流程。这通常归结为以下陈述:
特征选择旨在去除数据中的噪声并消除它。
特征选择的定义涉及两个必须解决的问题:
-
我们可能找到的 k 个特征子集的方法
-
在机器学习背景下更好的定义
本章的大部分内容致力于探讨我们如何找到这样的特征子集以及这些方法运作的基础。本章将特征选择方法分为两大类:基于统计和基于模型的特征选择。这种划分可能无法完全捕捉特征选择这一科学和艺术领域的复杂性,但它有助于在我们的机器学习流程中产生真实且可操作的结果。
在我们深入探讨许多这些方法之前,让我们首先讨论如何更好地理解和定义更好的概念,因为它将界定本章的其余部分,以及界定本文本的其余部分。
我们在本章中将涵盖以下主题:
-
在特征工程中实现更好的性能
-
创建一个基线机器学习管道
-
特征选择类型
-
选择正确的特征选择方法
在特征工程中实现更好的性能
在整本书中,我们在实施各种特征工程方法时,都依赖于对更好的基本定义。我们的隐含目标是实现更好的预测性能,这种性能仅通过简单的指标来衡量,例如分类任务的准确率和回归任务的 RMSE(主要是准确率)。我们还可以测量和跟踪其他指标来衡量预测性能。例如,我们将使用以下指标进行分类:
-
真阳性率和假阳性率
-
灵敏度(也称为真阳性率)和特异性
-
假阴性率和假阳性率
对于回归,将应用以下指标:
-
均方误差
-
R²
这些列表将继续,虽然我们不会放弃通过如前所述的指标量化性能的想法,但我们也可以测量其他元指标,或者不直接与模型预测性能相关的指标,而是所谓的元指标试图衡量预测周围的性能,包括以下想法:
-
模型需要拟合/训练到数据的时间
-
调整模型以预测新数据实例所需的时间
-
如果数据必须持久化(存储以供以后使用),则数据的大小
这些想法将丰富我们对更好的机器学习的定义,因为它们有助于涵盖我们机器学习管道(除了模型预测性能之外)的更广阔的图景。为了帮助我们跟踪这些指标,让我们创建一个足够通用的函数来评估多个模型,但同时又足够具体,可以为我们每个模型提供指标。我们将我们的函数命名为get_best_model_and_accuracy,它将执行许多工作,例如:
-
它将搜索所有给定的参数以优化机器学习管道
-
它将输出一些指标,帮助我们评估输入管道的质量
让我们定义这样一个函数,以下代码将提供帮助:
# import out grid search module
from sklearn.model_selection import GridSearchCV
def get_best_model_and_accuracy(model, params, X, y):
grid = GridSearchCV(model, # the model to grid search
params, # the parameter set to try
error_score=0.) # if a parameter set raises an error, continue and set the performance as a big, fat 0
grid.fit(X, y) # fit the model and parameters
# our classical metric for performance
print "Best Accuracy: {}".format(grid.best_score_)
# the best parameters that caused the best accuracy
print "Best Parameters: {}".format(grid.best_params_)
# the average time it took a model to fit to the data (in seconds)
print "Average Time to Fit (s): {}".format(round(grid.cv_results_['mean_fit_time'].mean(), 3))
# the average time it took a model to predict out of sample data (in seconds)
# this metric gives us insight into how this model will perform in real-time analysis
print "Average Time to Score (s): {}".format(round(grid.cv_results_['mean_score_time'].mean(), 3))
这个函数的整体目标是作为一个基准,我们将用它来评估本章中的每个特征选择方法,以给我们一个评估标准化的感觉。这实际上与我们之前所做的是一样的,但现在我们将我们的工作正式化为一个函数,并且还使用除了准确率之外的指标来评估我们的特征选择模块和机器学习管道。
一个案例研究——信用卡违约数据集
通过从数据中智能提取最重要的信号并忽略噪声,特征选择算法实现了两个主要成果:
-
改进模型性能:通过移除冗余数据,我们不太可能基于噪声和不相关数据做出决策,这也使得我们的模型能够专注于重要特征,从而提高模型管道的预测性能
-
减少训练和预测时间:通过将管道拟合到更少的数据,这通常会导致模型拟合和预测时间的改进,从而使我们的管道整体运行更快
为了获得对噪声数据如何以及为何会阻碍我们的现实理解,让我们介绍我们的最新数据集,一个信用卡违约数据集。我们将使用 23 个特征和一个响应变量。该响应变量将是一个布尔值,意味着它将是 True 或 False。我们使用 23 个特征的原因是我们想看看哪些特征可以帮助我们在机器学习管道中,哪些会阻碍我们。我们可以使用以下代码导入数据集:
import pandas as pd
import numpy as np
# we will set a random seed to ensure that whenever we use random numbers
# which is a good amount, we will achieve the same random numbers
np.random.seed(123)
首先,让我们引入两个常用的模块,numpy 和 pandas,并设置一个随机种子以确保结果的一致性。现在,让我们引入最新的数据集,使用以下代码:
# archive.ics.uci.edu/ml/datasets/default+of+credit+card+clients
# import the newest csv
credit_card_default = pd.read_csv('../data/credit_card_default.csv')
让我们继续进行一些必要的探索性数据分析。首先,让我们检查我们正在处理的数据集有多大,使用以下代码:
# 30,000 rows and 24 columns
credit_card_default.shape
因此,我们拥有 30,000 行(观测值)和 24 列(1 个响应变量和 23 个特征)。我们在此不会深入描述列的含义,但我们鼓励读者查看数据来源(archive.ics.uci.edu/ml/datasets/default+of+credit+card+clients#)。目前,我们将依靠传统的统计方法来获取更多信息:
# Some descriptive statistics
# We invoke the .T to transpose the matrix for better viewing
credit_card_default.describe().T
输出如下:
| count | mean | std | min | 25% | 50% | 75% | max | |
|---|---|---|---|---|---|---|---|---|
| LIMIT_BAL | 30000.0 | 167484.322667 | 129747.661567 | 10000.0 | 50000.00 | 140000.0 | 240000.00 | 1000000.0 |
| 性别 | 30000.0 | 1.603733 | 0.489129 | 1.0 | 1.00 | 2.0 | 2.00 | 2.0 |
| 教育程度 | 30000.0 | 1.853133 | 0.790349 | 0.0 | 1.00 | 2.0 | 2.00 | 6.0 |
| 婚姻状况 | 30000.0 | 1.551867 | 0.521970 | 0.0 | 1.00 | 2.0 | 2.00 | 3.0 |
| 年龄 | 30000.0 | 35.485500 | 9.217904 | 21.0 | 28.00 | 34.0 | 41.00 | 79.0 |
| PAY_0 | 30000.0 | -0.016700 | 1.123802 | -2.0 | -1.00 | 0.0 | 0.00 | 8.0 |
| PAY_2 | 30000.0 | -0.133767 | 1.197186 | -2.0 | -1.00 | 0.0 | 0.00 | 8.0 |
| PAY_3 | 30000.0 | -0.166200 | 1.196868 | -2.0 | -1.00 | 0.0 | 0.00 | 8.0 |
| PAY_4 | 30000.0 | -0.220667 | 1.169139 | -2.0 | -1.00 | 0.0 | 0.00 | 8.0 |
| PAY_5 | 30000.0 | -0.266200 | 1.133187 | -2.0 | -1.00 | 0.0 | 0.00 | 8.0 |
| PAY_6 | 30000.0 | -0.291100 | 1.149988 | -2.0 | -1.00 | 0.0 | 0.00 | 8.0 |
| BILL_AMT1 | 30000.0 | 51223.330900 | 73635.860576 | -165580.0 | 3558.75 | 22381.5 | 67091.00 | 964511.0 |
| BILL_AMT2 | 30000.0 | 49179.075167 | 71173.768783 | -69777.0 | 2984.75 | 21200.0 | 64006.25 | 983931.0 |
| BILL_AMT3 | 30000.0 | 47013.154800 | 69349.387427 | -157264.0 | 2666.25 | 20088.5 | 60164.75 | 1664089.0 |
| BILL_AMT4 | 30000.0 | 43262.948967 | 64332.856134 | -170000.0 | 2326.75 | 19052.0 | 54506.00 | 891586.0 |
| BILL_AMT5 | 30000.0 | 40311.400967 | 60797.155770 | -81334.0 | 1763.00 | 18104.5 | 50190.50 | 927171.0 |
| BILL_AMT6 | 30000.0 | 38871.760400 | 59554.107537 | -339603.0 | 1256.00 | 17071.0 | 49198.25 | 961664.0 |
| PAY_AMT1 | 30000.0 | 5663.580500 | 16563.280354 | 0.0 | 1000.00 | 2100.0 | 5006.00 | 873552.0 |
| PAY_AMT2 | 30000.0 | 5921.163500 | 23040.870402 | 0.0 | 833.00 | 2009.0 | 5000.00 | 1684259.0 |
| PAY_AMT3 | 30000.0 | 5225.681500 | 17606.961470 | 0.0 | 390.00 | 1800.0 | 4505.00 | 891586.0 |
| PAY_AMT4 | 30000.0 | 4826.076867 | 15666.159744 | 0.0 | 296.00 | 1500.0 | 4013.25 | 621000.0 |
| PAY_AMT5 | 30000.0 | 4799.387633 | 15278.305679 | 0.0 | 252.50 | 1500.0 | 4031.50 | 426529.0 |
| PAY_AMT6 | 30000.0 | 5215.502567 | 17777.465775 | 0.0 | 117.75 | 1500.0 | 4000.00 | 528666.0 |
| default payment next month | 30000.0 | 0.221200 | 0.415062 | 0.0 | 0.00 | 0.0 | 0.00 | 1.0 |
下个月的默认付款是我们的响应列,其余的都是特征/潜在的预测因子。很明显,我们的特征存在于截然不同的尺度上,这将是我们处理数据和选择模型的一个因素。在前面章节中,我们大量处理了不同尺度的数据和特征,使用了如StandardScaler和归一化等解决方案来缓解这些问题;然而,在本章中,我们将主要选择忽略这些问题,以便专注于更相关的问题。
在本书的最后一章中,我们将关注几个案例研究,这些研究将几乎将本书中的所有技术结合在一起,对数据集进行长期分析。
正如我们在前面的章节中看到的,我们知道在处理机器学习时,空值是一个大问题,所以让我们快速检查一下,确保我们没有要处理的空值:
# check for missing values, none in this dataset
credit_card_default.isnull().sum()
LIMIT_BAL 0
SEX 0
EDUCATION 0
MARRIAGE 0
AGE 0
PAY_0 0
PAY_2 0
PAY_3 0
PAY_4 0
PAY_5 0
PAY_6 0
BILL_AMT1 0
BILL_AMT2 0
BILL_AMT3 0
BILL_AMT4 0
BILL_AMT5 0
BILL_AMT6 0
PAY_AMT1 0
PAY_AMT2 0
PAY_AMT3 0
PAY_AMT4 0
PAY_AMT5 0
PAY_AMT6 0
default payment next month 0
dtype: int64
呼!这里没有缺失值。同样,我们将在未来的案例研究中再次处理缺失值,但现在我们还有更重要的事情要做。让我们继续设置一些变量,用于我们的机器学习流程,使用以下代码:
# Create our feature matrix
X = credit_card_default.drop('default payment next month', axis=1)
# create our response variable
y = credit_card_default['default payment next month']
如同往常,我们创建了我们的X和y变量。我们的X矩阵将有 30,000 行和 23 列,而y始终是一个 30,000 长的 pandas Series。因为我们将会进行分类,所以我们通常需要确定一个空值准确率,以确保我们的机器学习模型的表现优于基线。我们可以使用以下代码来获取空值准确率:
# get our null accuracy rate
y.value_counts(normalize=True)
0 0.7788
1 0.2212
因此,在这个案例中需要超越的准确率是77.88%,这是没有违约(0 表示没有违约)的人的百分比。
创建基线机器学习流程
在前面的章节中,我们向读者提供了一个单一的机器学习模型在整个章节中使用。在本章中,我们将做一些工作来找到最适合我们需求的机器学习模型,然后通过特征选择来增强该模型。我们将首先导入四个不同的机器学习模型:
-
逻辑回归
-
K-最近邻
-
决策树
-
随机森林
导入学习模型的代码如下所示:
# Import four machine learning models
from sklearn.linear_model import LogisticRegression
from sklearn.neighbors import KNeighborsClassifier
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier
一旦我们完成这些模块的导入,我们将通过我们的get_best_model_和_accuracy函数运行它们,以获得每个模块处理原始数据的基线。为此,我们首先需要建立一些变量。我们将使用以下代码来完成这项工作:
# Set up some parameters for our grid search
# We will start with four different machine learning model parameters
# Logistic Regression
lr_params = {'C':[1e-1, 1e0, 1e1, 1e2], 'penalty':['l1', 'l2']}
# KNN
knn_params = {'n_neighbors': [1, 3, 5, 7]}
# Decision Tree
tree_params = {'max_depth':[None, 1, 3, 5, 7]}
# Random Forest
forest_params = {'n_estimators': [10, 50, 100], 'max_depth': [None, 1, 3, 5, 7]}
如果你以上列出的任何模型感到不舒服,我们建议阅读相关文档,或者参考 Packt 出版的《数据科学原理》一书,www.packtpub.com/big-data-and-business-intelligence/principles-data-science,以获得算法的更详细解释。
因为我们将把每个模型通过我们的函数发送,该函数调用网格搜索模块,我们只需要创建没有设置自定义参数的空白状态模型,如下所示:
# instantiate the four machine learning models
lr = LogisticRegression()
knn = KNeighborsClassifier()
d_tree = DecisionTreeClassifier()
forest = RandomForestClassifier()
现在,我们将运行每个四个机器学习模型通过我们的评估函数,看看它们在我们的数据集上的表现如何(或不好)。回想一下,我们目前要超越的数字是.7788,这是基线零准确率。我们将使用以下代码来运行这些模型:
get_best_model_and_accuracy(lr, lr_params, X, y)
Best Accuracy: 0.809566666667
Best Parameters: {'penalty': 'l1', 'C': 0.1}
Average Time to Fit (s): 0.602
Average Time to Score (s): 0.002
我们可以看到,逻辑回归已经使用原始数据超越了零准确率,平均而言,只需要 6/10 秒来拟合训练集,并且只需要 20 毫秒来评分。如果我们知道在scikit-learn中,逻辑回归必须创建一个大的矩阵存储在内存中,但为了预测,它只需要将标量相乘和相加,这是有道理的。
现在,让我们使用以下代码对 KNN 模型做同样的事情:
get_best_model_and_accuracy(knn, knn_params, X, y)
Best Accuracy: 0.760233333333
Best Parameters: {'n_neighbors': 7}
Average Time to Fit (s): 0.035
Average Time to Score (s): 0.88
我们的 KNN 模型,正如预期的那样,在拟合时间上表现更好。这是因为,为了拟合数据,KNN 只需要以某种方式存储数据,以便在预测时可以轻松检索,这会在时间上造成损失。还值得一提的是一个显而易见的事实,即准确率甚至没有超过零准确率!你可能想知道为什么,如果你说“嘿,等等,KNN 不是利用欧几里得距离来做出预测吗,这可能会被非标准化数据所影响,而其他三个机器学习模型都没有这个问题”,那么你完全正确。
KNN 是一种基于距离的模型,它使用空间中相似度的度量,假设所有特征都在相同的尺度上,但我们已经知道我们的数据并不是这样的。因此,对于 KNN,我们将不得不构建一个更复杂的管道来更准确地评估其基线性能,以下代码展示了如何实现:
# bring in some familiar modules for dealing with this sort of thing
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
# construct pipeline parameters based on the parameters
# for KNN on its own
knn_pipe_params = {'classifier__{}'.format(k): v for k, v in knn_params.iteritems()}
# KNN requires a standard scalar due to using Euclidean distance # as the main equation for predicting observations
knn_pipe = Pipeline([('scale', StandardScaler()), ('classifier', knn)])
# quick to fit, very slow to predict
get_best_model_and_accuracy(knn_pipe, knn_pipe_params, X, y)
print knn_pipe_params # {'classifier__n_neighbors': [1, 3, 5, 7]}
Best Accuracy: 0.8008
Best Parameters: {'classifier__n_neighbors': 7}
Average Time to Fit (s): 0.035
Average Time to Score (s): 6.723
首先要注意的是,我们修改后的代码管道,现在包括了一个StandardScalar(它通过 z 分数标准化我们的特征),至少在 null accuracy 上有所提升,但同时也严重影响了我们的预测时间,因为我们增加了一个预处理步骤。到目前为止,逻辑回归在最佳准确率和更好的整体管道时间上处于领先地位。让我们继续前进,看看我们的两个基于树的模型,先从两个模型中较简单的一个开始,即决策树,以下代码将提供帮助:
get_best_model_and_accuracy(d_tree, tree_params, X, y)
Best Accuracy: 0.820266666667
Best Parameters: {'max_depth': 3}
Average Time to Fit (s): 0.158
Average Time to Score (s): 0.002
太棒了!我们已经在新准确率上取得了领先,而且决策树在拟合和预测方面都很快。事实上,它在拟合时间上击败了逻辑回归,在预测时间上击败了 KNN。让我们通过以下代码评估随机森林来完成我们的测试:
get_best_model_and_accuracy(forest, forest_params, X, y)
Best Accuracy: 0.819566666667
Best Parameters: {'n_estimators': 50, 'max_depth': 7}
Average Time to Fit (s): 1.107
Average Time to Score (s): 0.044
比逻辑回归或 KNN 都要好,但不如决策树。让我们汇总这些结果,看看我们应该在优化时使用哪种模型:
| 模型名称 | 准确率 (%) | 拟合时间 (s) | 预测时间 (s) |
|---|---|---|---|
| 逻辑回归 | .8096 | .602 | .002 |
| KNN(缩放) | .8008 | .035 | 6.72 |
| 决策树 | .8203 | .158 | .002 |
| 随机森林 | .8196 | 1.107 | .044 |
决策树在准确率上排名第一,与逻辑回归在预测时间上并列第一,而经过缩放的 KNN 在拟合我们的数据方面速度最快。总的来说,决策树似乎是我们继续前进的最佳模型,因为它在我们的两个最重要的指标上排名第一:
-
我们肯定希望获得最佳准确率,以确保样本外预测的准确性
-
考虑到模型将被用于实时生产使用,预测时间是有用的
我们采取的方法是在选择任何特征之前选择一个模型。这不是必须的工作方式,但我们发现,在时间紧迫的情况下,这种方式通常可以节省最多的时间。就你的目的而言,我们建议你同时实验多种模型,不要将自己限制在单一模型上。
知道我们将使用决策树来完成本章的剩余部分,我们还知道两件事:
-
新的基线准确率是.8203,这是树在拟合整个数据集时获得的准确率
-
我们不再需要使用
StandardScaler,因为决策树在模型性能方面不受其影响
特征选择类型
回想一下,我们进行特征选择的目标是通过提高预测能力和减少时间成本来提升我们的机器学习能力。为了实现这一点,我们引入了两种广泛的特征选择类别:基于统计和基于模型。基于统计的特征选择将严重依赖于与我们的机器学习模型分开的统计测试,以便在我们的管道训练阶段选择特征。基于模型的选择依赖于一个预处理步骤,该步骤涉及训练一个二级机器学习模型,并使用该模型的预测能力来选择特征。
这两种类型的特征选择都试图通过从原始特征中仅选择具有最高预测能力的最佳特征来减少我们的数据集大小。我们可能可以智能地选择哪种特征选择方法最适合我们,但现实中,在这个领域工作的一个非常有效的方法是逐一研究每种方法的示例,并衡量结果管道的性能。
首先,让我们看看依赖于统计测试从数据集中选择可行特征的特性选择模块的子类。
基于统计的特征选择
统计为我们提供了相对快速和简单的方法来解释定量和定性数据。我们在前面的章节中使用了一些统计度量来获取关于我们数据的新知识和视角,特别是我们认识到均值和标准差作为度量,使我们能够计算 z 分数并缩放我们的数据。在本章中,我们将依靠两个新概念来帮助我们进行特征选择:
-
皮尔逊相关性
-
假设检验
这两种方法都被称为特征选择的单变量方法,这意味着当问题是要一次选择一个单个特征以创建更好的机器学习管道数据集时,它们既快又方便。
使用皮尔逊相关性选择特征
我们在这本书中已经讨论过相关性,但不是在特征选择的背景下。我们已经知道,我们可以通过调用以下方法在 pandas 中调用相关性计算:
credit_card_default.corr()
前面代码的输出结果是以下内容:

作为前一个表的延续,我们有:

皮尔逊相关系数(这是 pandas 的默认值)衡量列之间的线性关系。系数的值在-1 和+1 之间变化,其中 0 表示它们之间没有相关性。接近-1 或+1 的相关性表示非常强的线性关系。
值得注意的是,皮尔逊的相关性通常要求每个列都是正态分布的(我们并没有假设这一点)。我们也可以在很大程度上忽略这一要求,因为我们的数据集很大(超过 500 是阈值)。
pandas .corr() 方法为每一列与其他每一列计算皮尔逊相关系数。这个 24 列乘以 24 行的矩阵非常混乱,在过去,我们使用 热图 来尝试使信息更易于理解:
# using seaborn to generate heatmaps
import seaborn as sns
import matplotlib.style as style
# Use a clean stylizatino for our charts and graphs
style.use('fivethirtyeight')
sns.heatmap(credit_card_default.corr())
生成的 热图 将如下所示:

注意,热图 函数自动选择了与我们最相关的特征来显示。话虽如此,我们目前关注的是特征与响应变量的相关性。我们将假设一个特征与响应的相关性越强,它就越有用。任何相关性不那么强的特征对我们来说就不那么有用。
相关系数也用于确定特征交互和冗余。减少机器学习中过拟合的关键方法之一是发现并去除这些冗余。我们将在基于模型的选择方法中解决这个问题。
让我们使用以下代码来隔离特征与响应变量之间的相关性:
# just correlations between every feature and the response
credit_card_default.corr()['default payment next month']
LIMIT_BAL -0.153520
SEX -0.039961
EDUCATION 0.028006
MARRIAGE -0.024339
AGE 0.013890
PAY_0 0.324794
PAY_2 0.263551
PAY_3 0.235253
PAY_4 0.216614
PAY_5 0.204149
PAY_6 0.186866
BILL_AMT1 -0.019644
BILL_AMT2 -0.014193
BILL_AMT3 -0.014076
BILL_AMT4 -0.010156
BILL_AMT5 -0.006760
BILL_AMT6 -0.005372
PAY_AMT1 -0.072929
PAY_AMT2 -0.058579
PAY_AMT3 -0.056250
PAY_AMT4 -0.056827
PAY_AMT5 -0.055124
PAY_AMT6 -0.053183
default payment next month 1.000000
我们可以忽略最后一行,因为它表示响应变量与自身完全相关的响应变量。我们正在寻找相关系数值接近 -1 或 +1 的特征。这些是我们可能认为有用的特征。让我们使用 pandas 过滤器来隔离至少有 .2 相关性(正或负)的特征。
我们可以通过首先定义一个 pandas 掩码 来实现这一点,它将作为我们的过滤器,使用以下代码:
# filter only correlations stronger than .2 in either direction (positive or negative)
credit_card_default.corr()['default payment next month'].abs() > .2
LIMIT_BAL False
SEX False
EDUCATION False
MARRIAGE False
AGE False
PAY_0 True
PAY_2 True
PAY_3 True
PAY_4 True
PAY_5 True
PAY_6 False
BILL_AMT1 False
BILL_AMT2 False
BILL_AMT3 False
BILL_AMT4 False
BILL_AMT5 False
BILL_AMT6 False
PAY_AMT1 False
PAY_AMT2 False
PAY_AMT3 False
PAY_AMT4 False
PAY_AMT5 False
PAY_AMT6 False
default payment next month True
在前面的 pandas Series 中,每个 False 都代表一个相关值在 -0.2 到 0.2(包括)之间的特征,而 True 值对应于相关值在 0.2 或更小于 -0.2 的特征。让我们将这个掩码插入到我们的 pandas 过滤器中,使用以下代码:
# store the features
highly_correlated_features = credit_card_default.columns[credit_card_default.corr()['default payment next month'].abs() > .2]
highly_correlated_features
Index([u'PAY_0', u'PAY_2', u'PAY_3', u'PAY_4', u'PAY_5',
u'default payment next month'],
dtype='object')
变量 highly_correlated_features 应该包含与响应变量高度相关的特征;然而,我们必须去掉响应列的名称,因为将其包含在我们的机器学习管道中将是作弊:
# drop the response variable
highly_correlated_features = highly_correlated_features.drop('default payment next month')
highly_correlated_features
Index([u'PAY_0', u'PAY_2', u'PAY_3', u'PAY_4', u'PAY_5'], dtype='object')
因此,现在我们从原始数据集中提取了五个特征,这些特征旨在预测响应变量,让我们在以下代码的帮助下尝试一下:
# only include the five highly correlated features
X_subsetted = X[highly_correlated_features]
get_best_model_and_accuracy(d_tree, tree_params, X_subsetted, y)
# barely worse, but about 20x faster to fit the model
Best Accuracy: 0.819666666667
Best Parameters: {'max_depth': 3}
Average Time to Fit (s): 0.01
Average Time to Score (s): 0.002
我们的准确率肯定比要打败的准确率 .8203 差,但也要注意,拟合时间大约增加了 20 倍。我们的模型能够用只有五个特征来学习,几乎与整个数据集一样好。此外,它能够在更短的时间内学习到同样多的知识。
让我们把我们的 scikit-learn 管道重新引入,并将我们的相关性选择方法作为预处理阶段的一部分。为此,我们必须创建一个自定义转换器,它将调用我们刚刚经历的逻辑,作为一个管道就绪的类。
我们将把我们的类命名为 CustomCorrelationChooser,它必须实现拟合和转换逻辑,如下所示:
-
拟合逻辑将选择特征矩阵中高于指定阈值的列
-
转换逻辑将子集任何未来的数据集,只包括被认为重要的列
from sklearn.base import TransformerMixin, BaseEstimator
class CustomCorrelationChooser(TransformerMixin, BaseEstimator):
def __init__(self, response, cols_to_keep=[], threshold=None):
# store the response series
self.response = response
# store the threshold that we wish to keep
self.threshold = threshold
# initialize a variable that will eventually
# hold the names of the features that we wish to keep
self.cols_to_keep = cols_to_keep
def transform(self, X):
# the transform method simply selects the appropiate
# columns from the original dataset
return X[self.cols_to_keep]
def fit(self, X, *_):
# create a new dataframe that holds both features and response
df = pd.concat([X, self.response], axis=1)
# store names of columns that meet correlation threshold
self.cols_to_keep = df.columns[df.corr()[df.columns[-1]].abs() > self.threshold]
# only keep columns in X, for example, will remove response variable
self.cols_to_keep = [c for c in self.cols_to_keep if c in X.columns]
return self
让我们用以下代码来试用我们新的相关特征选择器:
# instantiate our new feature selector
ccc = CustomCorrelationChooser(threshold=.2, response=y)
ccc.fit(X)
ccc.cols_to_keep
['PAY_0', 'PAY_2', 'PAY_3', 'PAY_4', 'PAY_5']
我们的这个类别选择了我们之前找到的相同的五列。让我们通过在X矩阵上调用它来测试转换功能,以下代码如下:
ccc.transform(X).head()
上述代码产生以下表格作为输出:
| PAY_0 | PAY_2 | PAY_3 | PAY_4 | PAY_5 | |
|---|---|---|---|---|---|
| 0 | 2 | 2 | -1 | -1 | -2 |
| 1 | -1 | 2 | 0 | 0 | 0 |
| 2 | 0 | 0 | 0 | 0 | 0 |
| 3 | 0 | 0 | 0 | 0 | 0 |
| 4 | -1 | 0 | -1 | 0 | 0 |
我们看到transform方法已经消除了其他列,只保留了满足我们.2相关阈值的特征。现在,让我们在以下代码的帮助下将所有这些整合到我们的管道中:
# instantiate our feature selector with the response variable set
ccc = CustomCorrelationChooser(response=y)
# make our new pipeline, including the selector
ccc_pipe = Pipeline([('correlation_select', ccc),
('classifier', d_tree)])
# make a copy of the decisino tree pipeline parameters
ccc_pipe_params = deepcopy(tree_pipe_params)
# update that dictionary with feature selector specific parameters
ccc_pipe_params.update({
'correlation_select__threshold':[0, .1, .2, .3]})
print ccc_pipe_params #{'correlation_select__threshold': [0, 0.1, 0.2, 0.3], 'classifier__max_depth': [None, 1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21]}
# better than original (by a little, and a bit faster on
# average overall
get_best_model_and_accuracy(ccc_pipe, ccc_pipe_params, X, y)
Best Accuracy: 0.8206
Best Parameters: {'correlation_select__threshold': 0.1, 'classifier__max_depth': 5}
Average Time to Fit (s): 0.105
Average Time to Score (s): 0.003
哇!我们在特征选择的第一尝试中已经超过了我们的目标(尽管只是略微)。我们的管道显示,如果我们以0.1为阈值,我们就已经消除了足够的噪声以改善准确性,并且还减少了拟合时间(从没有选择器的 0.158 秒)。让我们看看我们的选择器决定保留哪些列:
# check the threshold of .1
ccc = CustomCorrelationChooser(threshold=0.1, response=y)
ccc.fit(X)
# check which columns were kept
ccc.cols_to_keep
['LIMIT_BAL', 'PAY_0', 'PAY_2', 'PAY_3', 'PAY_4', 'PAY_5', 'PAY_6']
看起来我们的选择器决定保留我们找到的五列,以及另外两列,即LIMIT_BAL和PAY_6列。太棒了!这是 scikit-learn 中自动化管道网格搜索的美丽之处。它允许我们的模型做它们最擅长的事情,并直觉到我们自己无法做到的事情。
使用假设检验进行特征选择
假设检验是统计学中的一种方法,它允许对单个特征进行更复杂的统计检验。通过假设检验进行特征选择将尝试从数据集中选择最佳特征,正如我们在自定义相关选择器中所做的那样,但这些测试更多地依赖于形式化的统计方法,并通过所谓的p 值进行解释。
假设检验是一种统计检验,用于确定在给定数据样本的情况下,我们是否可以应用对整个总体适用的某个条件。假设检验的结果告诉我们是否应该相信假设,或者拒绝它以选择另一个假设。基于来自总体的样本数据,假设检验确定是否拒绝零假设。我们通常使用p 值(一个非负的小数,其上界为 1,基于我们的显著性水平)来得出这个结论。
在特征选择的情况下,我们希望检验的假设是这样的:“这个特征与响应变量无关。” 我们希望对每个特征进行这种假设检验,并决定这些特征在预测响应变量时是否具有某种重要性。从某种意义上说,这就是我们处理相关逻辑的方式。我们基本上说,如果一个列与响应变量的相关性太弱,那么我们就说该特征无关的假设是正确的。如果相关系数足够强,那么我们就可以拒绝该特征无关的假设,转而支持一个替代假设,即该特征确实与响应变量有关。
要开始使用这个工具处理我们的数据,我们需要引入两个新的模块:SelectKBest 和 f_classif,使用以下代码:
# SelectKBest selects features according to the k highest scores of a given scoring function
from sklearn.feature_selection import SelectKBest
# This models a statistical test known as ANOVA
from sklearn.feature_selection import f_classif
# f_classif allows for negative values, not all do
# chi2 is a very common classification criteria but only allows for positive values
# regression has its own statistical tests
SelectKBest 实际上只是一个包装器,它保留了一定数量的特征,这些特征是根据某些标准排序最高的。在这种情况下,我们将使用完成假设检验的 p 值作为排序标准。
解释 p 值
p 值是介于 0 和 1 之间的十进制数,表示在假设检验下,给定的数据偶然发生的概率。简单来说,p 值越低,我们拒绝零假设的机会就越大。对于我们的目的来说,p 值越小,特征与我们的响应变量相关的可能性就越大,我们应该保留它。
对于更深入的统计检验处理,请参阅 Packt 出版的《数据科学原理》Principles of Data Science,www.packtpub.com/big-data-and-business-intelligence/principles-data-science。
从这个例子中我们可以得出一个重要的结论:f_classif 函数将对每个特征单独执行 ANOVA 测试(一种假设检验类型),并为该特征分配一个 p 值。SelectKBest 将根据那个 p 值(越低越好)对特征进行排序,并仅保留最好的 k 个(由人工输入)特征。让我们在 Python 中尝试一下。
p 值排序
让我们先实例化一个 SelectKBest 模块。我们将手动输入一个 k 值,5,这意味着我们希望只保留根据结果 p 值得出的前五个最佳特征:
# keep only the best five features according to p-values of ANOVA test
k_best = SelectKBest(f_classif, k=5)
然后,我们可以拟合并转换我们的 X 矩阵,选择我们想要的特征,就像我们之前使用自定义选择器所做的那样。
# matrix after selecting the top 5 features
k_best.fit_transform(X, y)
# 30,000 rows x 5 columns
array([[ 2, 2, -1, -1, -2],
[-1, 2, 0, 0, 0],
[ 0, 0, 0, 0, 0],
...,
[ 4, 3, 2, -1, 0],
[ 1, -1, 0, 0, 0],
[ 0, 0, 0, 0, 0]])
如果我们想直接检查 p-values 并查看哪些列被选中,我们可以深入了解选择 k_best 变量:
# get the p values of columns
k_best.pvalues_
# make a dataframe of features and p-values
# sort that dataframe by p-value
p_values = pd.DataFrame({'column': X.columns, 'p_value': k_best.pvalues_}).sort_values('p_value')
# show the top 5 features
p_values.head()
上述代码将生成以下表格作为输出:
| 列 | p 值 | |
|---|---|---|
| 5 | PAY_0 | 0.000000e+00 |
| 6 | PAY_2 | 0.000000e+00 |
| 7 | PAY_3 | 0.000000e+00 |
| 8 | PAY_4 | 1.899297e-315 |
| 9 | PAY_5 | 1.126608e-279 |
我们可以看到,我们的选择器再次选择了 PAY_X 列作为最重要的列。如果我们看一下我们的 p-value 列,我们会注意到我们的值非常小,接近于零。p-value 的一个常见阈值是 0.05,这意味着小于 0.05 的值可能被认为是显著的,根据我们的测试,这些列非常显著。我们还可以直接使用 pandas 过滤方法看到哪些列达到了 0.05 的阈值:
# features with a low p value
p_values[p_values['p_value'] < .05]
上述代码生成了以下表格作为输出:
| 列 | p_value | |
|---|---|---|
| 5 | PAY_0 | 0.000000e+00 |
| 6 | PAY_2 | 0.000000e+00 |
| 7 | PAY_3 | 0.000000e+00 |
| 8 | PAY_4 | 1.899297e-315 |
| 9 | PAY_5 | 1.126608e-279 |
| 10 | PAY_6 | 7.296740e-234 |
| 0 | LIMIT_BAL | 1.302244e-157 |
| 17 | PAY_AMT1 | 1.146488e-36 |
| 18 | PAY_AMT2 | 3.166657e-24 |
| 20 | PAY_AMT4 | 6.830942e-23 |
| 19 | PAY_AMT3 | 1.841770e-22 |
| 21 | PAY_AMT5 | 1.241345e-21 |
| 22 | PAY_AMT6 | 3.033589e-20 |
| 1 | SEX | 4.395249e-12 |
| 2 | EDUCATION | 1.225038e-06 |
| 3 | MARRIAGE | 2.485364e-05 |
| 11 | BILL_AMT1 | 6.673295e-04 |
| 12 | BILL_AMT2 | 1.395736e-02 |
| 13 | BILL_AMT3 | 1.476998e-02 |
| 4 | AGE | 1.613685e-02 |
大多数列的 p-value 都很低,但并非所有。让我们使用以下代码查看具有更高 p_value 的列:
# features with a high p value
p_values[p_values['p_value'] >= .05]
上述代码生成了以下表格作为输出:
| 列 | p_value | |
|---|---|---|
| 14 | BILL_AMT4 | 0.078556 |
| 15 | BILL_AMT5 | 0.241634 |
| 16 | BILL_AMT6 | 0.352123 |
这三个列的 p-value 非常高。让我们使用我们的 SelectKBest 在管道中查看我们是否可以通过网格搜索进入更好的机器学习管道,以下代码将有所帮助:
k_best = SelectKBest(f_classif)
# Make a new pipeline with SelectKBest
select_k_pipe = Pipeline([('k_best', k_best),
('classifier', d_tree)])
select_k_best_pipe_params = deepcopy(tree_pipe_params)
# the 'all' literally does nothing to subset
select_k_best_pipe_params.update({'k_best__k':range(1,23) + ['all']})
print select_k_best_pipe_params # {'k_best__k': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 'all'], 'classifier__max_depth': [None, 1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21]}
# comparable to our results with correlationchooser
get_best_model_and_accuracy(select_k_pipe, select_k_best_pipe_params, X, y)
Best Accuracy: 0.8206
Best Parameters: {'k_best__k': 7, 'classifier__max_depth': 5}
Average Time to Fit (s): 0.102
Average Time to Score (s): 0.002
我们的 SelectKBest 模块似乎达到了与我们的自定义转换器相同的准确度,但达到这个准确度要快一些!让我们看看我们的测试选择了哪些列,以下代码将有所帮助:
k_best = SelectKBest(f_classif, k=7)
# lowest 7 p values match what our custom correlationchooser chose before
# ['LIMIT_BAL', 'PAY_0', 'PAY_2', 'PAY_3', 'PAY_4', 'PAY_5', 'PAY_6']
p_values.head(7)
上述代码生成了以下表格作为输出:
| 列 | p_value | |
|---|---|---|
| 5 | PAY_0 | 0.000000e+00 |
| 6 | PAY_0 | 0.000000e+00 |
| 7 | PAY_0 | 0.000000e+00 |
| 8 | PAY_0 | 1.899297e-315 |
| 9 | PAY_0 | 1.126608e-279 |
| 10 | PAY_0 | 7.296740e-234 |
| 0 | LIMIT_BAL | 1.302244e-157 |
它们似乎是我们其他统计方法选择的相同列。我们的统计方法可能限制为不断为我们选择这七个列。
除了 ANOVA 之外,还有其他测试可用,例如 Chi² 以及其他回归任务,它们都包含在 scikit-learn 的文档中。有关通过单变量测试进行特征选择的更多信息,请查看 scikit-learn 的以下文档:
scikit-learn.org/stable/modules/feature_selection.html#univariate-feature-selection
在我们转向基于模型的特征选择之前,进行一次快速的合理性检查是有帮助的,以确保我们走在正确的道路上。到目前为止,我们已经看到了两种用于特征选择的统计方法,它们为我们提供了相同的七个列以实现最佳精度。但如果我们排除这七个列以外的所有列呢?我们应该预期精度会大大降低,整体流程也会变得更差,对吧?让我们确保这一点。以下代码帮助我们实现合理性检查:
# sanity check
# If we only the worst columns
the_worst_of_X = X[X.columns.drop(['LIMIT_BAL', 'PAY_0', 'PAY_2', 'PAY_3', 'PAY_4', 'PAY_5', 'PAY_6'])]
# goes to show, that selecting the wrong features will
# hurt us in predictive performance
get_best_model_and_accuracy(d_tree, tree_params, the_worst_of_X, y)
Best Accuracy: 0.783966666667
Best Parameters: {'max_depth': 5}
Average Time to Fit (s): 0.21
Average Time to Score (s): 0.002
好吧,通过选择除了这七个列以外的列,我们不仅看到了更差的精度(几乎和零精度一样差),而且平均拟合时间也更慢。有了这一点,我相信我们可以继续到我们的下一个特征选择技术子集,即基于模型的方法。
基于模型的特征选择
我们上一节讨论了使用统计方法和测试来从原始数据集中选择特征,以提高我们的机器学习流程,无论是在预测性能上还是在时间复杂度上。在这个过程中,我们亲自见证了使用特征选择的效果。
自然语言处理简要回顾
如果从本章一开始就谈论特征选择听起来很熟悉,几乎就像我们在开始相关系数和统计测试之前就已经在做这件事一样,那么,你并没有错。在第四章,特征构建中,当我们处理特征构建时,我们介绍了CountVectorizer的概念,它是 scikit-learn 中的一个模块,用于从文本列中构建特征并在机器学习流程中使用它们。
CountVectorizer有许多参数可以调整以寻找最佳流程。具体来说,有几个内置的特征选择参数:
-
max_features:这个整数设置了一个硬限制,即特征提取器可以记住的最大特征数量。被记住的特征是基于一个排名系统决定的,其中令牌的排名是该令牌在语料库中的计数。 -
min_df:这个浮点数通过规定一个规则来限制特征的数量,即一个令牌只有在语料库中以高于min_df的比率出现时才可以在数据集中出现。 -
max_df:与min_df类似,这个浮点数通过仅允许在语料库中以低于max_df设置的值严格出现的令牌来限制特征的数量。 -
stop_words:通过将其与静态令牌列表进行匹配来限制允许的令牌类型。如果发现存在于stop_words集合中的令牌,无论其是否以min_df和max_df允许的数量出现,都将忽略该词。
在上一章中,我们简要介绍了一个旨在仅基于推文中单词预测推文情感的数据集。让我们花些时间来刷新我们对如何使用这些参数的记忆。让我们通过以下代码开始引入我们的tweet数据集:
# bring in the tweet dataset
tweets = pd.read_csv('../data/twitter_sentiment.csv',
encoding='latin1')
为了刷新我们的记忆,让我们查看前五个tweets,以下代码将提供帮助:
tweets.head()
上述代码生成了以下表格作为输出:
| ItemID | Sentiment | SentimentText | |
|---|---|---|---|
| 0 | 1 | 0 | 我的好朋友 APL 真是太伤心了... |
| 1 | 2 | 0 | 我错过了新月观测... |
| 2 | 3 | 1 | omg 它已经 7:30 了 😮 |
| 3 | 4 | 0 | .. Omgaga. Im sooo im gunna CRy. I'... |
| 4 | 5 | 0 | 我想我的男朋友在骗我!!! ... |
让我们创建一个特征和一个响应变量。回想一下,因为我们正在处理文本,所以我们的特征变量将仅仅是文本列,而不是通常的二维矩阵:
tweets_X, tweets_y = tweets['SentimentText'], tweets['Sentiment']
让我们设置一个管道,并使用本章中我们一直在使用的相同函数来评估它,以下代码将提供帮助:
from sklearn.feature_extraction.text import CountVectorizer
# import a naive bayes to help predict and fit a bit faster
from sklearn.naive_bayes import MultinomialNB
featurizer = CountVectorizer()
text_pipe = Pipeline([('featurizer', featurizer),
('classify', MultinomialNB())])
text_pipe_params = {'featurizer__ngram_range':[(1, 2)],
'featurizer__max_features': [5000, 10000],
'featurizer__min_df': [0., .1, .2, .3],
'featurizer__max_df': [.7, .8, .9, 1.]}
get_best_model_and_accuracy(text_pipe, text_pipe_params,
tweets_X, tweets_y)
Best Accuracy: 0.755753132845
Best Parameters: {'featurizer__min_df': 0.0, 'featurizer__ngram_range': (1, 2), 'featurizer__max_df': 0.7, 'featurizer__max_features': 10000}
Average Time to Fit (s): 5.808
Average Time to Score (s): 0.957
一个不错的分数(记住,基线准确度为.564),但我们通过在上一个章节中使用FeatureUnion模块结合TfidfVectorizer和CountVectorizer的特征来击败了这个分数。
为了尝试本章中我们看到的技术,让我们继续在一个带有CountVectorizer的管道中应用SelectKBest。让我们看看我们是否可以不依赖于内置的CountVectorizer特征选择参数,而是依赖统计测试:
# Let's try a more basic pipeline, but one that relies on SelectKBest as well
featurizer = CountVectorizer(ngram_range=(1, 2))
select_k_text_pipe = Pipeline([('featurizer', featurizer),
('select_k', SelectKBest()),
('classify', MultinomialNB())])
select_k_text_pipe_params = {'select_k__k': [1000, 5000]}
get_best_model_and_accuracy(select_k_text_pipe,
select_k_text_pipe_params,
tweets_X, tweets_y)
Best Accuracy: 0.755703127344
Best Parameters: {'select_k__k': 10000}
Average Time to Fit (s): 6.927
Average Time to Score (s): 1.448
看起来SelectKBest在文本标记上表现不佳,没有FeatureUnion,我们无法与上一章的准确度分数竞争。无论如何,对于这两个管道,值得注意的是,拟合和预测所需的时间都非常差。这是因为统计单变量方法对于非常大量的特征(如从文本向量化中获得的特征)来说并不理想。
使用机器学习来选择特征
当你处理文本时,使用CountVectorizer内置的特征选择工具是很好的;然而,我们通常处理的是已经嵌入行/列结构中的数据。我们已经看到了使用纯统计方法进行特征选择的强大之处,现在让我们看看我们如何调用机器学习的强大功能,希望做到更多。在本节中,我们将使用基于树和线性模型作为特征选择的主要机器学习模型。它们都有特征排名的概念,这在子集特征集时非常有用。
在我们继续之前,我们认为再次提到这些方法是有价值的,尽管它们在选择方法上有所不同,但它们都在尝试找到最佳特征子集以改进我们的机器学习流程。我们将深入探讨的第一种方法将涉及算法(如决策树和随机森林模型)在拟合训练数据时生成的内部重要性指标。
基于树的模型特征选择指标
当拟合决策树时,树从根节点开始,并在每个节点贪婪地选择最优分割,以优化节点纯度的某个指标。默认情况下,scikit-learn 在每一步优化基尼指标。在创建分割的过程中,模型会跟踪每个分割对整体优化目标的帮助程度。因此,基于树的模型在根据此类指标选择分割时,具有特征重要性的概念。
为了进一步说明这一点,让我们使用以下代码将决策树拟合到我们的数据中,并输出特征重要性:
# create a brand new decision tree classifier
tree = DecisionTreeClassifier()
tree.fit(X, y)
当我们的树拟合到数据后,我们可以调用feature_importances_ 属性来捕获特征相对于树拟合的重要性:
# note that we have some other features in play besides what our last two selectors decided for us
importances = pd.DataFrame({'importance': tree.feature_importances_, 'feature':X.columns}).sort_values('importance', ascending=False)
importances.head()
上述代码产生以下表格作为输出:
| 特征 | 重要性 | |
|---|---|---|
| 5 | PAY_0 | 0.161829 |
| 4 | AGE | 0.074121 |
| 11 | BILL_AMT1 | 0.064363 |
| 0 | LIMIT_BAL | 0.058788 |
| 19 | PAY_AMT3 | 0.054911 |
这个表格告诉我们,在拟合过程中最重要的特征是列PAY_0,这与我们在本章前面提到的统计模型所告诉我们的相匹配。更值得注意的是第二、第三和第五个最重要的特征,因为在使用我们的统计测试之前,它们并没有真正出现。这是一个很好的迹象,表明这种特征选择方法可能为我们带来一些新的结果。
记得,之前我们依赖于一个内置的 scikit-learn 包装器,称为 SelectKBest,根据排名函数(如 ANOVA p 值)来捕获前k个特征。我们将介绍另一种类似风格的包装器,称为SelectFromModel,它就像SelectKBest一样,将捕获前 k 个最重要的特征。然而,它将通过监听机器学习模型内部的特征重要性指标来完成,而不是统计测试的 p 值。我们将使用以下代码来定义SelectFromModel:
# similar to SelectKBest, but not with statistical tests
from sklearn.feature_selection import SelectFromModel
SelectFromModel和SelectKBest在用法上的最大区别是,SelectFromModel不接收一个整数 k,它代表要保留的特征数,而是SelectFromModel使用一个阈值进行选择,这个阈值充当被选中重要性的硬性下限。通过这种方式,本章中的基于模型的筛选器能够从保留人类输入的特征数转向依赖相对重要性,只包含管道需要的特征数。让我们如下实例化我们的类:
# instantiate a class that choses features based
# on feature importances according to the fitting phase
# of a separate decision tree classifier
select_from_model = SelectFromModel(DecisionTreeClassifier(),
threshold=.05)
让我们将SelectFromModel类拟合到我们的数据中,并调用 transform 方法来观察我们的数据在以下代码的帮助下被子集化:
selected_X = select_from_model.fit_transform(X, y)
selected_X.shape
(30000, 9)
现在我们已经了解了模块的基本机制,让我们在管道中使用它来为我们选择特征。回想一下,我们要打败的准确率是.8206,这是我们通过相关选择器和 ANOVA 测试得到的(因为它们都返回了相同的特征):
# to speed things up a bit in the future
tree_pipe_params = {'classifier__max_depth': [1, 3, 5, 7]}
from sklearn.pipeline import Pipeline
# create a SelectFromModel that is tuned by a DecisionTreeClassifier
select = SelectFromModel(DecisionTreeClassifier())
select_from_pipe = Pipeline([('select', select),
('classifier', d_tree)])
select_from_pipe_params = deepcopy(tree_pipe_params)
select_from_pipe_params.update({
'select__threshold': [.01, .05, .1, .2, .25, .3, .4, .5, .6, "mean", "median", "2.*mean"],
'select__estimator__max_depth': [None, 1, 3, 5, 7]
})
print select_from_pipe_params # {'select__threshold': [0.01, 0.05, 0.1, 'mean', 'median', '2.*mean'], 'select__estimator__max_depth': [None, 1, 3, 5, 7], 'classifier__max_depth': [1, 3, 5, 7]}
get_best_model_and_accuracy(select_from_pipe,
select_from_pipe_params,
X, y)
# not better than original
Best Accuracy: 0.820266666667
Best Parameters: {'select__threshold': 0.01, 'select__estimator__max_depth': None, 'classifier__max_depth': 3}
Average Time to Fit (s): 0.192
Average Time to Score (s): 0.002
首先要注意的是,作为阈值参数的一部分,我们可以包含一些保留词,而不是表示最小重要性的浮点数。例如,mean的阈值仅选择重要性高于平均值的特征。同样,将median作为阈值仅选择比中值更重要特征的值。我们还可以包含这些保留词的倍数,因此2.*mean将仅包含比两倍平均重要性值更重要特征的值。
让我们看看我们的基于决策树的选择器为我们选择了哪些特征。我们可以通过调用SelectFromModel中的get_support()方法来实现。它将返回一个布尔数组,每个原始特征列一个,并告诉我们它决定保留哪些特征,如下所示:
# set the optimal params to the pipeline
select_from_pipe.set_params(**{'select__threshold': 0.01,
'select__estimator__max_depth': None,
'classifier__max_depth': 3})
# fit our pipeline to our data
select_from_pipe.steps[0][1].fit(X, y)
# list the columns that the SVC selected by calling the get_support() method from SelectFromModel
X.columns[select_from_pipe.steps[0][1].get_support()]
[u'LIMIT_BAL', u'SEX', u'EDUCATION', u'MARRIAGE', u'AGE', u'PAY_0', u'PAY_2', u'PAY_3', u'PAY_6', u'BILL_AMT1', u'BILL_AMT2', u'BILL_AMT3', u'BILL_AMT4', u'BILL_AMT5', u'BILL_AMT6', u'PAY_AMT1', u'PAY_AMT2', u'PAY_AMT3', u'PAY_AMT4', u'PAY_AMT5', u'PAY_AMT6']
哇!所以树决定保留除了两个特征之外的所有特征,并且仍然和没有选择任何特征时的树一样做得好:
关于决策树及其如何使用基尼指数或熵进行拟合的更多信息,请查阅 scikit-learn 文档或其他更深入探讨此主题的文本。
我们可以继续尝试其他基于树的模型,例如 RandomForest、ExtraTreesClassifier 等,但也许我们可以通过利用非基于树的模型来做得更好。
线性模型和正则化
SelectFromModel 选择器能够处理任何在拟合后暴露 feature_importances_ 或 coef_ 属性的机器学习模型。基于树的模型暴露前者,而线性模型暴露后者。拟合后,如线性回归、逻辑回归、支持向量机等线性模型都将系数放在表示该特征斜率或该特征变化时对响应影响程度的特征之前。SelectFromModel 可以将此等同于特征重要性,并根据在拟合过程中分配给特征的系数来选择特征。
然而,在我们可以使用这些模型之前,我们必须引入一个称为 正则化 的概念,这将帮助我们选择真正最重要的特征。
正则化简介
在线性模型中,正则化是一种对学习模型施加额外约束的方法,其目标是防止过拟合并提高数据的泛化能力。这是通过向正在优化的 损失函数 中添加额外项来实现的,这意味着在拟合过程中,正则化的线性模型可能会严重减少,甚至破坏特征。有两种广泛使用的正则化方法,称为 L1 和 L2 正则化。这两种正则化技术都依赖于 L-p 范数,它对于一个向量定义为:

-
L1 正则化,也称为 lasso 正则化,使用 L1 范数,根据上述公式,可以简化为向量元素的绝对值之和,以限制系数,使其可能完全消失并变为 0。如果特征的系数降至 0,那么该特征在预测新数据观测值时将没有任何发言权,并且肯定不会被
SelectFromModel选择器选中。 -
L2 正则化,也称为 ridge 正则化,将 L2 范数作为惩罚(向量元素平方和)施加,这样系数就不能降至 0,但可以变得非常非常小。
正则化也有助于解决多重共线性问题,即在数据集中存在多个线性相关的特征。Lasso 惩罚(L1)将迫使依赖特征的系数变为 0,确保它们不会被选择模块选中,从而帮助对抗过拟合。
线性模型系数作为另一个特征重要性指标
我们可以使用 L1 和 L2 正则化来找到特征选择的最佳系数,就像我们在基于树的模型中做的那样。让我们使用逻辑回归模型作为我们的基于模型的选择器,并在 L1 和 L2 范数之间进行网格搜索:
# a new selector that uses the coefficients from a regularized logistic regression as feature importances
logistic_selector = SelectFromModel(LogisticRegression())
# make a new pipeline that uses coefficients from LogistisRegression as a feature ranker
regularization_pipe = Pipeline([('select', logistic_selector),
('classifier', tree)])
regularization_pipe_params = deepcopy(tree_pipe_params)
# try l1 regularization and l2 regularization
regularization_pipe_params.update({
'select__threshold': [.01, .05, .1, "mean", "median", "2.*mean"],
'select__estimator__penalty': ['l1', 'l2'],
})
print regularization_pipe_params # {'select__threshold': [0.01, 0.05, 0.1, 'mean', 'median', '2.*mean'], 'classifier__max_depth': [1, 3, 5, 7], 'select__estimator__penalty': ['l1', 'l2']}
get_best_model_and_accuracy(regularization_pipe,
regularization_pipe_params,
X, y)
# better than original, in fact the best so far, and much faster on the scoring side
Best Accuracy: 0.821166666667 Best Parameters: {'select__threshold': 0.01, 'classifier__max_depth': 5, 'select__estimator__penalty': 'l1'}
Average Time to Fit (s): 0.51
Average Time to Score (s): 0.001
最后!我们的准确率超过了我们的统计测试选择器。让我们看看我们的基于模型的选择器通过再次调用 SelectFromModel 的 get_support() 方法决定保留哪些特征:
# set the optimal params to the pipeline
regularization_pipe.set_params(**{'select__threshold': 0.01,
'classifier__max_depth': 5,
'select__estimator__penalty': 'l1'})
# fit our pipeline to our data
regularization_pipe.steps[0][1].fit(X, y)
# list the columns that the Logisti Regression selected by calling the get_support() method from SelectFromModel
X.columns[regularization_pipe.steps[0][1].get_support()]
[u'SEX', u'EDUCATION', u'MARRIAGE', u'PAY_0', u'PAY_2', u'PAY_3', u'PAY_4', u'PAY_5']
太神奇了!我们基于逻辑回归的选取器保留了大部分PAY_X列,同时还能推断出人的性别、教育和婚姻状况将在预测中起到作用。让我们继续我们的冒险之旅,使用一个额外的模型和我们的SelectFromModel选取器模块,一个支持向量机分类器。
如果你不太熟悉支持向量机,它们是尝试在空间中绘制线性边界以分离二元标签的分类模型。这些线性边界被称为支持向量。目前,逻辑回归和支持向量分类器之间最重要的区别是,SVC 通常更适合优化系数以最大化二元分类任务的准确率,而逻辑回归在建模二元分类任务的概率属性方面更出色。让我们像对决策树和逻辑回归所做的那样,从 scikit-learn 实现一个线性 SVC 模型,看看它的表现如何,以下代码如下:
# SVC is a linear model that uses linear supports to
# seperate classes in euclidean space
# This model can only work for binary classification tasks
from sklearn.svm import LinearSVC
# Using a support vector classifier to get coefficients
svc_selector = SelectFromModel(LinearSVC())
svc_pipe = Pipeline([('select', svc_selector),
('classifier', tree)])
svc_pipe_params = deepcopy(tree_pipe_params)
svc_pipe_params.update({
'select__threshold': [.01, .05, .1, "mean", "median", "2.*mean"],
'select__estimator__penalty': ['l1', 'l2'],
'select__estimator__loss': ['squared_hinge', 'hinge'],
'select__estimator__dual': [True, False]
})
print svc_pipe_params # 'select__estimator__loss': ['squared_hinge', 'hinge'], 'select__threshold': [0.01, 0.05, 0.1, 'mean', 'median', '2.*mean'], 'select__estimator__penalty': ['l1', 'l2'], 'classifier__max_depth': [1, 3, 5, 7], 'select__estimator__dual': [True, False]}
get_best_model_and_accuracy(svc_pipe,
svc_pipe_params,
X, y)
# better than original, in fact the best so far, and much faster on the scoring side
Best Accuracy: 0.821233333333
Best Parameters: {'select__estimator__loss': 'squared_hinge', 'select__threshold': 0.01, 'select__estimator__penalty': 'l1', 'classifier__max_depth': 5, 'select__estimator__dual': False}
Average Time to Fit (s): 0.989
Average Time to Score (s): 0.001
太棒了!我们迄今为止得到的最佳准确率。我们可以看到拟合时间有所下降,但如果我们对此可以接受,将迄今为止的最佳准确率与出色的快速预测时间相结合,我们就拥有了出色的机器学习管道;一个利用支持向量分类中正则化力量的管道,将显著特征输入到决策树分类器中。让我们看看我们的选取器选择了哪些特征,以给我们迄今为止的最佳准确率:
# set the optimal params to the pipeline
svc_pipe.set_params(**{'select__estimator__loss': 'squared_hinge',
'select__threshold': 0.01,
'select__estimator__penalty': 'l1',
'classifier__max_depth': 5,
'select__estimator__dual': False})
# fit our pipeline to our data
svc_pipe.steps[0][1].fit(X, y)
# list the columns that the SVC selected by calling the get_support() method from SelectFromModel
X.columns[svc_pipe.steps[0][1].get_support()]
[u'SEX', u'EDUCATION', u'MARRIAGE', u'PAY_0', u'PAY_2', u'PAY_3', u'PAY_5']
这些特征与我们的逻辑回归得到的特征之间的唯一区别是PAY_4列。但我们可以看到,即使删除单个列也能影响我们整个管道的性能。
选择正确的特征选择方法
到目前为止,你可能会觉得本章中的信息有点令人不知所措。我们介绍了几种执行特征选择的方法,一些基于纯统计,一些基于二级机器学习模型的输出。自然地,你会想知道如何决定哪种特征选择方法最适合你的数据。理论上,如果你能够尝试多种选项,就像我们在本章中所做的那样,那将是理想的,但我们理解这可能并不切实际。以下是一些你可以遵循的经验法则,当你试图确定哪个特征选择模块更有可能提供更好的结果时:
-
如果你的特征大多是分类的,你应该首先尝试实现一个带有 Chi²排名器的
SelectKBest或基于树的模型选取器。 -
如果你的特征大多是定量(就像我们的一样),使用基于模型的线性模型作为选取器,并依赖于相关性,往往会产生更好的结果,正如本章所展示的。
-
如果您正在解决一个二元分类问题,使用支持向量分类(SVC)模型以及
SelectFromModel选择器可能会非常合适,因为 SVC 试图找到优化二元分类任务的系数。 -
一点点的探索性数据分析(EDA)在手动特征选择中可以发挥很大的作用。在数据来源领域拥有领域知识的重要性不容小觑。
话虽如此,这些(内容)仅作为指导方针。作为一名数据科学家,最终决定保留哪些特征以优化您选择的指标的是您自己。本文中提供的方法旨在帮助您发现被噪声和多重共线性隐藏的特征的潜在力量。
摘要
在本章中,我们学习了关于选择特征子集的方法,以提高我们的机器学习管道在预测能力和时间复杂度方面的性能。
我们选择的数据集特征数量相对较少。然而,如果从大量特征(超过一百个)中选择,那么本章中的方法可能会变得过于繁琐。在本章中,当我们尝试优化一个CountVectorizer管道时,对每个特征进行单变量测试所需的时间不仅天文数字般的长;而且,仅仅因为巧合,我们特征中的多重共线性风险会更大。
在下一章中,我们将介绍我们可以应用于数据矩阵的纯数学变换,以减轻处理大量特征或少量难以解释的特征的麻烦。我们将开始处理一些不同于之前所见的数据集,例如图像数据、主题建模数据等。
第六章:特征转换
到目前为止,在这篇文本中,我们已经从看似所有可能的角度遇到了特征工程工具。从分析表格数据以确定数据级别,到使用统计量构建和选择列以优化我们的机器学习流程,我们经历了一段处理数据中特征的非凡旅程。
值得再次提及的是,机器学习的改进形式多种多样。我们通常将我们的两个主要指标视为准确性和预测/拟合时间。这意味着,如果我们能够利用特征工程工具使我们的流程在交叉验证设置中具有更高的准确性,或者能够更快地拟合和/或预测数据,那么我们可以将其视为成功。当然,我们最终的希望是优化准确性和时间,从而为我们提供一个更好的工作流程。
过去的五章内容处理了被认为是经典的特征工程。到目前为止,我们已经研究了特征工程的五个主要类别/步骤:
-
探索性数据分析:在我们开始使用机器学习流程的工作之初,甚至在接触机器学习算法或特征工程工具之前,鼓励我们对数据集执行一些基本的描述性统计,并创建可视化,以更好地理解数据的本质。
-
特征理解:一旦我们对数据的规模和形状有了概念,我们应该仔细查看我们的数据集(如果可能的话)中的每一列,并概述特征,包括数据的级别,因为这将决定在必要时如何清理特定的列。
-
特征改进:这一阶段是关于通过根据列的级别插补缺失值,并在可能的情况下执行虚拟变量转换和缩放操作,来改变数据值和整个列。
-
特征构建:一旦我们有了最佳可能的数据集,我们就可以考虑构建新的列来解释特征交互。
-
特征选择:在我们的流程选择阶段,我们取所有原始和构建的列,并执行(通常是单变量)统计测试,以隔离表现最佳的列,目的是去除噪声并加快计算速度。
下面的图总结了这一过程,并展示了我们如何思考过程中的每一步:

机器学习流程
这是一个使用本文前面方法示例的机器学习流程。它由五个主要步骤组成:分析、理解、改进、构建和选择。在接下来的章节中,我们将关注一种新的数据转换方法,这种方法部分打破了这种经典观念。
在本书的这个阶段,读者已经准备好以合理的信心和期望去处理世界上的数据集。接下来的两个章节,第六章,特征转换,和第七章,特征学习,将重点关注特征工程中的两个子集,这两个子集在编程和数学上都非常重要,特别是线性代数。我们将一如既往地尽力解释本章中使用的所有代码行,并在必要时仅描述数学过程。
本章将处理特征转换,这是一套旨在改变数据内部结构以产生数学上更优的超级列的算法,而下一章将专注于使用非参数算法(不依赖于数据形状的算法)来自动学习新特征的特征学习。本文的最后一章包含几个案例研究,以展示特征工程的端到端过程及其对机器学习管道的影响。
现在,让我们从对特征转换的讨论开始。正如我们之前提到的,特征转换是一组矩阵算法,将结构性地改变我们的数据,并产生一个本质上全新的数据矩阵。基本思想是,数据集的原始特征是数据点的描述符/特征,我们应该能够创建一组新的特征,这些特征能够像原始特征一样,甚至可能更好地解释数据点,而且列数更少。
想象一个简单的、矩形的房间。房间是空的,除了一个站在中央的人体模特。人体模特从不移动,并且始终朝同一个方向。你被赋予了 24/7 监控这个房间的任务。当然,你提出了在房间里安装安全摄像头的想法,以确保所有活动都被捕捉和记录。你将一个摄像头放置在房间的角落,向下对着人体模特的脸,在这个过程中,捕捉到房间的大部分区域。使用一个摄像头,你几乎可以看到房间的所有方面。问题是,摄像头有盲点。例如,你将无法直接看到摄像头下方(由于物理上的无法看到那里)和人体模特后面(因为人体模特本身阻挡了摄像头的视线)。作为一个聪明人,你向对面的角落添加了第二个摄像头,位于人体模特后面,以补偿第一个摄像头的盲点。使用两个摄像头,你现在可以从安全办公室看到超过 99%的房间。
在这个例子中,房间代表数据的原始特征空间,而人体模特代表一个数据点,位于特征空间的一个特定部分。更正式地说,我要求你考虑一个三维特征空间和一个单一的数据点:
[X, Y, Z]
要尝试用单个相机捕捉这个数据点就像将我们的数据集压缩成只有一个新维度,即相机一看到的数据:
[X, Y, Z] ≈ [C1]
然而,仅使用一个维度可能是不够的,因为我们能够构想出那个单相机的盲点,所以我们添加了第二个相机:
[X, Y, Z] ≈ [C1, C2]
这两个相机(由特征转换产生的新维度)以新的方式捕捉数据,但只用两列而不是三列就提供了我们所需的大部分信息。特征转换中最困难的部分是我们对原始特征空间是最佳空间的信念的放弃。我们必须开放地接受可能存在其他数学轴和系统,它们可以用更少的特征,甚至可能更好地描述我们的数据。
维度降低 – 特征转换与特征选择与特征构造
在上一节中,我提到了如何将数据集压缩成更少的列,以用新的方式描述数据。这听起来与特征选择的概念相似:从我们的原始数据集中删除列,通过剔除噪声并增强信号列来创建不同的、可能更好的数据集视图。虽然特征选择和特征转换都是执行降维的方法,但它们在方法论上却大相径庭。
特征选择过程仅限于从原始列集中选择特征,而特征转换算法使用这些原始列并以有用的方式将它们组合起来,创建出比原始数据集中任何单列都更好地描述数据的新的列。因此,特征选择方法通过隔离信号列和忽略噪声列来降低维度。
特征转换方法通过使用原始数据集中的隐藏结构来创建新的列,从而产生一个完全不同、结构上不同的数据集。这些算法创建了全新的列,它们如此强大,我们只需要其中的一小部分就能准确地解释整个数据集。
我们还提到,特征转换通过产生新的列来捕捉数据的本质(方差)。这与特征构造的核心思想相似:为了捕捉数据中的潜在结构而创建新的特征。再次强调,这两个不同的过程使用截然不同的方法实现了类似的结果。
特征构建再次仅限于通过每次在几个列之间进行简单操作(加法、乘法等)来构建新的列。这意味着任何使用经典特征构建方法构建的特征都只使用原始数据集的一小部分列。如果我们目标是创建足够多的特征来捕捉所有可能的特征交互,那么可能需要大量的额外列。例如,如果给定的数据集有 1,000 个特征或更多,我们需要创建数以万计的列来构建足够多的特征来捕捉所有可能特征交互的子集。
特征转换方法能够利用每个新超列中所有列的一小部分信息,因此我们不需要创建大量的新列来捕捉潜在的特征交互。由于特征转换算法的性质及其对矩阵/线性代数的使用,特征转换方法永远不会创建比我们开始时更多的列,并且仍然能够提取特征构建列试图提取的潜在结构。
特征转换算法能够通过选择所有列中的最佳列并组合这种潜在结构与一些全新的列来构建新的特征。通过这种方式,我们可以将特征转换视为本文中将要讨论的最强大的算法集之一。话虽如此,现在是时候介绍本书中的第一个算法和数据集了:主成分分析(PCA)和iris数据集。
主成分分析
主成分分析是一种技术,它将具有多个相关特征的数据集投影到一个具有较少相关特征的坐标(轴)系统上。这些新的、不相关的特征(我之前称之为超列)被称为主成分。主成分作为替代坐标系统,用于原始特征空间,它需要更少的特征,并尽可能多地捕捉方差。如果我们回顾我们关于相机的例子,主成分就是相机本身。
换句话说,PCA 的目标是在数据集中识别模式和潜在结构,以便创建新的列,并使用这些列而不是原始特征。正如在特征选择中,如果我们从一个大小为 n x d 的数据矩阵开始,其中 n 是观测值的数量,d 是原始特征的数目,我们将这个矩阵投影到一个大小为 n x k 的矩阵上(其中 k < d)。
我们的主成分产生新的列,这些列最大化了数据中的方差。这意味着每一列都在尝试解释数据的形状。主成分按解释的方差排序,因此第一个主成分对解释数据的方差贡献最大,而第二个成分对解释数据的方差贡献次之。目标是利用尽可能多的成分来优化机器学习任务,无论是监督学习还是无监督学习:

特征转换是将数据集转换成具有相同行数但特征数量减少的矩阵。这与特征选择的目的类似,但在这个情况下,我们关注的是创建全新的特征。
PCA 本身是一个无监督任务,这意味着它不利用响应列来执行投影/转换。这很重要,因为我们将要处理的第二个特征转换算法将是监督的,并将利用响应变量以不同的方式创建优化预测任务的超级列。
PCA 是如何工作的
PCA 通过调用协方差矩阵的特征值分解过程来工作。这一数学原理最早在 20 世纪 30 年代发表,涉及一些多元微积分和线性代数。为了本文本的目的,我们将跳过这部分内容,直接进入重点。
PCA 也可能适用于相关矩阵。如果你选择使用相关矩阵,那么特征处于相似尺度时更为合适,而协方差矩阵在处理不同尺度时更有用。我们通常推荐使用缩放数据的协方差矩阵。
这个过程分为四个步骤:
-
创建数据集的协方差矩阵
-
计算协方差矩阵的特征值
-
保留最大的k个特征值(按特征值降序排列)
-
使用保留的特征向量来转换新的数据点
让我们通过一个名为iris数据集的例子来看看这一点。在这个相对较小的数据集中,我们将逐步查看 PCA 的性能,然后是 scikit-learn 的实现。
使用 Iris 数据集的 PCA – 手动示例
iris数据集包含 150 行和四列。每一行/观测值代表一朵花,而列/特征代表花的四个不同的定量特征。数据集的目标是拟合一个分类器,尝试根据四个特征预测三种类型的iris。这朵花可以是 setosa、virginica 或 versicolor 之一。
这个数据集在机器学习教学领域非常常见,scikit-learn 内置了一个模块用于下载数据集:
- 让我们先导入模块,然后将数据集提取到一个名为
iris的变量中:
# import the Iris dataset from scikit-learn
from sklearn.datasets import load_iris
# import our plotting module
import matplotlib.pyplot as plt
%matplotlib inline
# load the Iris dataset
iris = load_iris()
- 现在,让我们将提取的数据矩阵和响应变量分别存储到两个新变量
iris_X和iris_y中:
# create X and y variables to hold features and response column
iris_X, iris_y = iris.data, iris.target
- 让我们看看我们试图预测的花的名称:
# the names of the flower we are trying to predict.
iris.target_names
array(['setosa', 'versicolor', 'virginica'], dtype='|S10')
- 除了花的名称外,我们还可以查看我们用于做出这些预测的特征的名称:
# Names of the features
iris.feature_names
['sepal length (cm)',
'sepal width (cm)',
'petal length (cm)',
'petal width (cm)']
- 为了了解我们的数据看起来像什么,让我们编写一些代码来显示四个特征中的两个的数据点:
# for labelling
label_dict = {i: k for i, k in enumerate(iris.target_names)}
# {0: 'setosa', 1: 'versicolor', 2: 'virginica'}
def plot(X, y, title, x_label, y_label):
ax = plt.subplot(111)
for label,marker,color in zip(
range(3),('^', 's', 'o'),('blue', 'red', 'green')):
plt.scatter(x=X[:,0].real[y == label],
y=X[:,1].real[y == label],
color=color,
alpha=0.5,
label=label_dict[label]
)
plt.xlabel(x_label)
plt.ylabel(y_label)
leg = plt.legend(loc='upper right', fancybox=True)
leg.get_frame().set_alpha(0.5)
plt.title(title)
plot(iris_X, iris_y, "Original Iris Data", "sepal length (cm)", "sepal width (cm)")
以下为前述代码的输出:

现在我们对iris数据集执行 PCA,以获得我们的主成分。请记住,这需要四个步骤。
创建数据集的协方差矩阵
要计算iris的协方差矩阵,我们首先将计算特征均值向量(用于未来使用),然后使用 NumPy 计算我们的协方差矩阵。
协方差矩阵是一个d x d矩阵(具有与行数和列数相同数量的特征的方阵),它表示每个特征之间的特征交互。它与相关矩阵非常相似:
# Calculate a PCA manually
# import numpy
import numpy as np
# calculate the mean vector
mean_vector = iris_X.mean(axis=0)
print mean_vector
[ 5.84333333 3.054 3.75866667 1.19866667]
# calculate the covariance matrix
cov_mat = np.cov((iris_X-mean_vector).T)
print cov_mat.shape
(4, 4)
变量cov_mat存储我们的 4 x 4 协方差矩阵。
计算协方差矩阵的特征值
NumPy 是一个方便的函数,可以计算特征向量和特征值,我们可以使用它来获取iris数据集的主成分:
# calculate the eigenvectors and eigenvalues of our covariance matrix of the iris dataset
eig_val_cov, eig_vec_cov = np.linalg.eig(cov_mat)
# Print the eigen vectors and corresponding eigenvalues
# in order of descending eigenvalues
for i in range(len(eig_val_cov)):
eigvec_cov = eig_vec_cov[:,i]
print 'Eigenvector {}: \n{}'.format(i+1, eigvec_cov)
print 'Eigenvalue {} from covariance matrix: {}'.format(i+1, eig_val_cov[i])
print 30 * '-'
Eigenvector 1:
[ 0.36158968 -0.08226889 0.85657211 0.35884393]
Eigenvalue 1 from covariance matrix: 4.22484076832
------------------------------
Eigenvector 2:
[-0.65653988 -0.72971237 0.1757674 0.07470647]
Eigenvalue 2 from covariance matrix: 0.242243571628
------------------------------
Eigenvector 3:
[-0.58099728 0.59641809 0.07252408 0.54906091]
Eigenvalue 3 from covariance matrix: 0.0785239080942
------------------------------
Eigenvector 4:
[ 0.31725455 -0.32409435 -0.47971899 0.75112056]
Eigenvalue 4 from covariance matrix: 0.023683027126
------------------------------
保留最大的 k 个特征值(按特征值降序排序)
现在我们有了四个特征值,我们将选择适当的数量来考虑它们作为主成分。如果我们愿意,可以选择所有四个,但通常我们希望选择一个小于原始特征数量的数字。但正确的数字是多少?我们可以使用网格搜索并通过暴力方法找到答案,然而,我们还有另一个工具,称为scree 图。
Scree 图是一个简单的折线图,显示了每个主成分解释的数据中的总方差百分比。为了构建这个图,我们将特征值按降序排序,并绘制每个组件及其之前所有组件解释的累积方差。在iris的情况下,我们的 scree 图上将有四个点,每个主成分一个。每个组件单独解释了捕获的总方差的一部分,当百分比相加时,应该占数据集中总方差的 100%。
让我们通过将每个特征向量(主成分)关联的特征值除以所有特征值的总和来计算每个特征向量(主成分)解释的方差百分比:
# the percentages of the variance captured by each eigenvalue
# is equal to the eigenvalue of that components divided by
# the sum of all eigen values
explained_variance_ratio = eig_val_cov/eig_val_cov.sum()
explained_variance_ratio
array([ 0.92461621, 0.05301557, 0.01718514, 0.00518309])
这告诉我们,我们的四个主成分在解释方差的数量上差异很大。第一个主成分作为一个单独的特征/列,能够解释数据中超过 92%的方差。这是惊人的!这意味着这个单独的超列理论上可以完成四个原始列几乎所有的任务。
为了可视化我们的斯克里普图,让我们创建一个图,其中四个主成分在 x 轴上,累积方差解释在 y 轴上。对于每一个数据点,y 位置将代表使用所有主成分直到该点的总百分比方差:
# Scree Plot
plt.plot(np.cumsum(explained_variance_ratio))
plt.title('Scree Plot')
plt.xlabel('Principal Component (k)')
plt.ylabel('% of Variance Explained <= k')
以下为前述代码的输出:

这告诉我们,前两个成分单独就几乎解释了原始数据集总方差的 98%,这意味着如果我们只使用前两个特征向量并将它们作为新的主成分,那么我们会做得很好。我们能够将数据集的大小减半(从四列减到两列),同时保持性能的完整性并加快性能。我们将在接下来的章节中通过机器学习的例子来更详细地研究这些理论概念。
特征值分解总是会产生与我们特征数量一样多的特征向量。一旦计算完毕,选择我们希望使用的多少个主成分取决于我们。这突出了 PCA,就像本文中的大多数其他算法一样,是半监督的,需要一些人工输入。
使用保留的特征向量来转换新的数据点
一旦我们决定保留两个主成分(无论我们使用网格搜索模块还是通过斯克里普图分析来找到最佳数量无关紧要),我们必须能够使用这些成分来转换进入样本外的数据点。为此,让我们首先隔离前两个特征向量并将它们存储在一个名为 top_2_eigenvectors 的新变量中:
# store the top two eigenvectors in a variable
top_2_eigenvectors = eig_vec_cov[:,:2].T
# show the transpose so that each row is a principal component, we have two rows == two components
top_2_eigenvectors
array([[ 0.36158968, -0.08226889, 0.85657211, 0.35884393],
[-0.65653988, -0.72971237, 0.1757674 , 0.07470647]])
此数组表示前两个特征向量:
-
[ 0.36158968, -0.08226889, 0.85657211, 0.35884393] -
[-0.65653988, -0.72971237, 0.1757674 , 0.07470647]]
在这些向量就位的情况下,我们可以通过将两个矩阵相乘来使用它们将我们的数据投影到新的、改进的超数据集:iris_X 和 top_2_eigenvectors。以下图像展示了我们如何确保数字正确:

前面的图示展示了如何利用主成分将数据集从原始特征空间转换到新的坐标系。在iris的情况下,我们取原始的 150 x 4 数据集,并将其乘以前两个特征向量的转置。我们使用转置来确保矩阵的大小匹配。结果是具有相同行数但列数减少的矩阵。每一行都乘以两个主成分。
通过将这些矩阵相乘,我们正在将原始数据集投影到这个二维空间:
# to transform our data from having shape (150, 4) to (150, 2)
# we will multiply the matrices of our data and our eigen vectors together
np.dot(iris_X, top_2_eigenvectors.T)[:5,]
array([[ 2.82713597, -5.64133105],
[ 2.79595248, -5.14516688],
[ 2.62152356, -5.17737812],
[ 2.7649059 , -5.00359942],
[ 2.78275012, -5.64864829]])
就这样。我们已经将四维的 iris 数据转换成了一个只有两列的新矩阵。这个新矩阵可以在我们的机器学习流程中代替原始数据集。
Scikit-learn 的 PCA
如往常一样,scikit-learn 通过实现一个易于使用的转换器来拯救这一天,这样我们就不必每次使用这个强大的过程时都进行手动处理:
- 我们可以从 scikit-learn 的分解模块中导入它:
# scikit-learn's version of PCA
from sklearn.decomposition import PCA
- 为了模拟我们在
iris数据集上执行的过程,让我们实例化一个只有两个成分的PCA对象:
# Like any other sklearn module, we first instantiate the class
pca = PCA(n_components=2)
- 现在,我们可以将我们的 PCA 拟合到数据上:
# fit the PCA to our data
pca.fit(iris_X)
- 让我们查看 PCA 对象的一些属性,看看它们是否与我们在手动过程中实现的结果相匹配。让我们查看对象的
components_属性,看看它是否与没有top_2_eigenvectors变量相匹配:
pca.components_
array([[ 0.36158968, -0.08226889, 0.85657211, 0.35884393],
[ 0.65653988, 0.72971237, -0.1757674 , -0.07470647]])
# note that the second column is the negative of the manual process
# this is because eignevectors can be positive or negative
# It should have little to no effect on our machine learning pipelines
-
我们的两个成分几乎与之前的变量
top_2_eigenvectors相匹配。我们说几乎是因为第二个成分实际上是计算出的特征向量的负值。这是可以的,因为从数学上讲,这两个特征向量都是 100%有效的,并且仍然实现了创建不相关列的主要目标。 -
到目前为止,这个过程比我们之前所做的方法痛苦少得多。为了完成这个过程,我们需要使用 PCA 对象的 transform 方法将数据投影到新的二维平面上:
pca.transform(iris_X)[:5,]
array([[-2.68420713, 0.32660731],
[-2.71539062, -0.16955685],
[-2.88981954, -0.13734561],
[-2.7464372 , -0.31112432],
[-2.72859298, 0.33392456]])
# sklearn PCA centers the data first while transforming, so these numbers won't match our manual process.
注意,我们的投影数据与之前得到的投影数据完全不匹配。这是因为 scikit-learn 版本的 PCA 在预测阶段自动将数据居中,这改变了结果。
- 我们可以通过修改我们版本中的一行来模拟这个过程:
# manually centering our data to match scikit-learn's implementation of PCA
np.dot(iris_X-mean_vector, top_2_eigenvectors.T)[:5,]
array([[-2.68420713, -0.32660731],
[-2.71539062, 0.16955685],
[-2.88981954, 0.13734561],
[-2.7464372 , 0.31112432],
[-2.72859298, -0.33392456]])
- 让我们快速绘制一下投影后的
iris数据,并比较在投影到新的坐标系前后数据集看起来如何:
# Plot the original and projected data
plot(iris_X, iris_y, "Original Iris Data", "sepal length (cm)", "sepal width (cm)")
plt.show()
plot(pca.transform(iris_X), iris_y, "Iris: Data projected onto first two PCA components", "PCA1", "PCA2")
下面的代码是前面的输出:

在我们的原始数据集中,我们可以看到在第一、第二列中沿原始特征空间的花。注意,在我们的投影空间中,花朵彼此之间分离得更多,并且在其轴上略有旋转。看起来数据簇是“直立”的。这种现象是因为我们的主成分正在努力捕捉数据中的方差,这在我们的图中显示出来。
我们可以提取每个成分解释的方差量,就像我们在手动示例中所做的那样:
# percentage of variance in data explained by each component
# same as what we calculated earlier
pca.explained_variance_ratio_
array([ 0.92461621, 0.05301557])
现在,我们已经可以使用 scikit-learn 的 PCA 执行所有基本功能,让我们利用这些信息来展示 PCA 的一个主要好处:特征去相关。
按照本质,在特征值分解过程中,得到的主成分彼此垂直,这意味着它们彼此之间线性无关。
这是一个主要的好处,因为许多机器学习模型和预处理技术都假设输入的特征是独立的,而利用 PCA 可以确保这一点。
为了展示这一点,让我们创建原始 iris 数据集的相关矩阵,并找出每个特征之间的平均线性相关系数。然后,我们将对 PCA 投影数据集做同样的事情,并比较这些值。我们预计投影数据集的平均相关性应该接近于零,这意味着它们都是线性无关的。
让我们首先计算原始 iris 数据集的相关矩阵:
- 它将是一个 4x4 的矩阵,其中的值代表每个特征与其他每个特征之间的相关系数:
# show how pca attempts to eliminate dependence between columns
# show the correlation matrix of the original dataset
np.corrcoef(iris_X.T)
array([[ 1\. , -0.10936925, 0.87175416, 0.81795363],
[-0.10936925, 1\. , -0.4205161 , -0.35654409],
[ 0.87175416, -0.4205161 , 1\. , 0.9627571 ],
[ 0.81795363, -0.35654409, 0.9627571 , 1\. ]])
- 然后,我们将提取所有对角线以上 1 的值,以使用它们来找出所有特征之间的平均相关性:
# correlation coefficients above the diagonal
np.corrcoef(iris_X.T)[[0, 0, 0, 1, 1], [1, 2, 3, 2, 3]]
array([-0.10936925, 0.87175416, 0.81795363, -0.4205161 , -0.35654409])
- 最后,我们将取这个数组的平均值:
# average correlation of original iris dataset.
np.corrcoef(iris_X.T)[[0, 0, 0, 1, 1], [1, 2, 3, 2, 3]].mean()
0.16065567094168495
- 原始特征的平均相关系数是
.16,相当小,但绝对不是零。现在,让我们创建一个包含所有四个主成分的完整 PCA:
# capture all four principal components
full_pca = PCA(n_components=4)
# fit our PCA to the iris dataset
full_pca.fit(iris_X)
- 一旦我们完成这个操作,我们将使用之前的方法来计算新、理论上线性无关的列之间的平均相关系数:
pca_iris = full_pca.transform(iris_X)
# average correlation of PCAed iris dataset.
np.corrcoef(pca_iris.T)[[0, 0, 0, 1, 1], [1, 2, 3, 2, 3]].mean()
# VERY close to 0 because columns are independent from one another
# This is an important consequence of performing an eigenvalue decomposition
7.2640855025557061e-17 # very close to 0
这显示了数据投影到主成分上最终会有更少的相关特征,这在机器学习中通常是有帮助的。
数据中心化和缩放如何影响主成分分析(PCA)
就像我们在本文中之前使用过的许多转换一样,特征的缩放往往对转换有很大影响。PCA 也不例外。之前我们提到,scikit-learn 版本的 PCA 在预测阶段自动对数据进行中心化,但为什么在拟合时不这样做呢?如果 scikit-learn 的 PCA 模块在预测方法中费尽心机对数据进行中心化,为什么不在计算特征向量时这样做呢?这里的假设是数据中心化不会影响主成分。让我们来测试这个假设:
- 让我们导入 scikit-learn 的
StandardScaler模块并将iris数据集进行中心化:
# import our scaling module
from sklearn.preprocessing import StandardScaler
# center our data, not a full scaling
X_centered = StandardScaler(with_std=False).fit_transform(iris_X)
X_centered[:5,]
array([[-0.74333333, 0.446 , -2.35866667, -0.99866667], [-0.94333333, -0.054 , -2.35866667, -0.99866667], [-1.14333333, 0.146 , -2.45866667, -0.99866667], [-1.24333333, 0.046 , -2.25866667, -0.99866667], [-0.84333333, 0.546 , -2.35866667, -0.99866667]])
- 让我们看一下现在中心化的数据集:
# Plot our centered data
plot(X_centered, iris_y, "Iris: Data Centered", "sepal length (cm)", "sepal width (cm)")
我们得到以下代码输出:

- 我们可以将之前实例化的 PCA 类拟合到我们的中心化
iris数据集,其中n_components设置为2:
# fit our PCA (with n_components still set to 2) on our centered data
pca.fit(X_centered)
- 完成此操作后,我们可以调用 PCA 模块的
components_ 属性,并将得到的特征成分与使用原始iris数据集得到的 PCs 进行比较:
# same components as before
pca.components_
array([[ 0.36158968, -0.08226889, 0.85657211, 0.35884393], [ 0.65653988, 0.72971237, -0.1757674 , -0.07470647]])
- 看起来,由中心化数据产生的主成分(PCs)与我们之前看到的是完全相同的。为了澄清这一点,让我们使用 PCA 模块对中心化数据进行转换,并查看前五行,看看它们是否与之前获得的投影对应:
# same projection when data are centered because PCA does this automatically
pca.transform(X_centered)[:5,]
array([[-2.68420713, 0.32660731], [-2.71539062, -0.16955685], [-2.88981954, -0.13734561], [-2.7464372 , -0.31112432], [-2.72859298, 0.33392456]])
- 行与行对应!如果我们查看投影中心化数据的图表和解释方差比,我们会发现这些也对应:
# Plot PCA projection of centered data, same as previous PCA projected data
plot(pca.transform(X_centered), iris_y, "Iris: Data projected onto first two PCA components with centered data", "PCA1", "PCA2")
我们得到以下输出:

对于百分比方差,我们实现以下操作:
# percentage of variance in data explained by each component
pca.explained_variance_ratio_
array([ 0.92461621, 0.05301557])
发生这种情况的原因是因为矩阵与其中心化对应矩阵具有相同的协方差矩阵。如果两个矩阵具有相同的协方差矩阵,那么它们将具有相同的特征值分解。这就是为什么 scikit-learn 版本的 PCA 在寻找特征值和特征向量时不需要对数据进行中心化,因为无论是否中心化,它们都会找到相同的值,所以为什么还要增加一个额外的、不必要的步骤?
现在,让我们看看当我们使用标准 z-score 缩放数据时,我们的主成分会发生什么变化:
# doing a normal z score scaling
X_scaled = StandardScaler().fit_transform(iris_X)
# Plot scaled data
plot(X_scaled, iris_y, "Iris: Data Scaled", "sepal length (cm)", "sepal width (cm)")
我们得到以下输出:

值得注意的是,到目前为止,我们已经在原始格式、中心化和现在完全缩放的情况下绘制了 iris 数据。在每个图表中,数据点都是完全相同的,但坐标轴是不同的。这是预期的。中心化和缩放数据不会改变数据的形状,但它确实会影响我们的特征工程和机器学习管道中的特征交互。
让我们在新缩放的数据上拟合我们的 PCA 模块,看看我们的 PCs 是否不同:
# fit our 2-dimensional PCA on our scaled data
pca.fit(X_scaled)
# different components as cenetered data
pca.components_
array([[ 0.52237162, -0.26335492, 0.58125401, 0.56561105], [ 0.37231836, 0.92555649, 0.02109478, 0.06541577]])
这些是与之前不同的成分。PCA 是尺度不变的,这意味着尺度会影响成分。请注意,当我们说缩放时,我们指的是中心化和除以标准差。让我们将我们的数据集投影到我们的新成分上,并确保新投影的数据确实不同:
# different projection when data are scaled
pca.transform(X_scaled)[:5,]
array([[-2.26454173, 0.5057039 ], [-2.0864255 , -0.65540473], [-2.36795045, -0.31847731], [-2.30419716, -0.57536771], [-2.38877749, 0.6747674 ]])
最后,让我们看一下我们的解释方差比:
# percentage of variance in data explained by each component
pca.explained_variance_ratio_
array([ 0.72770452, 0.23030523])
这很有趣。在执行特征工程/机器学习时,对数据进行缩放通常是一个好主意,我们通常也向我们的读者推荐这样做,但为什么我们的第一个成分的解释方差比比之前低得多?
这是因为一旦我们缩放了数据,列之间的协方差变得更加一致,每个主成分解释的方差被分散开来,而不是固化在一个单独的 PC 中。在实际生产和实践中,我们通常推荐缩放,但测试您的管道在缩放和非缩放数据上的性能是一个好主意。
让我们用查看缩放数据上的投影 iris 数据来结束本节:
# Plot PCA projection of scaled data
plot(pca.transform(X_scaled), iris_y, "Iris: Data projected onto first two PCA components", "PCA1", "PCA2")
我们得到以下输出:

这很微妙,但如果你看看这个图表,并将其与原始数据和中心数据下之前的项目数据图表进行比较,你会注意到它们之间有细微的差异。
深入了解主成分
在我们查看第二个特征变换算法之前,重要的是要看看如何解释主成分:
- 我们的
iris数据集是一个 150 x 4 的矩阵,当我们计算n_components设置为2时的 PCA 组件时,我们得到了一个2 x 4大小的组件矩阵:
# how to interpret and use components
pca.components_ # a 2 x 4 matrix
array([[ 0.52237162, -0.26335492, 0.58125401, 0.56561105], [ 0.37231836, 0.92555649, 0.02109478, 0.06541577]])
- 就像我们在手动计算特征向量的例子中一样,
components_属性可以通过矩阵乘法来投影数据。我们通过将原始数据集与components_ 矩阵的转置相乘来实现这一点:
# Multiply original matrix (150 x 4) by components transposed (4 x 2) to get new columns (150 x 2)
np.dot(X_scaled, pca.components_.T)[:5,]
array([[-2.26454173, 0.5057039 ], [-2.0864255 , -0.65540473], [-2.36795045, -0.31847731], [-2.30419716, -0.57536771], [-2.38877749, 0.6747674 ]])
- 我们在这里调用转置函数,以便矩阵维度匹配。在底层发生的事情是,对于每一行,我们都在计算原始行与每个主成分之间的点积。点积的结果成为新行的元素:
# extract the first row of our scaled data
first_scaled_flower = X_scaled[0]
# extract the two PC's
first_Pc = pca.components_[0]
second_Pc = pca.components_[1]
first_scaled_flower.shape # (4,)
print first_scaled_flower # array([-0.90068117, 1.03205722, -1.3412724 , -1.31297673])
# same result as the first row of our matrix multiplication
np.dot(first_scaled_flower, first_Pc), np.dot(first_scaled_flower, second_Pc)
(-2.2645417283949003, 0.50570390277378274)
- 幸运的是,我们可以依赖内置的 transform 方法来为我们完成这项工作:
# This is how the transform method works in pca
pca.transform(X_scaled)[:5,]
array([[-2.26454173, 0.5057039 ], [-2.0864255 , -0.65540473], [-2.36795045, -0.31847731], [-2.30419716, -0.57536771], [-2.38877749, 0.6747674 ]])
换句话说,我们可以将每个成分解释为原始列的组合。在这种情况下,我们的第一个主成分是:
[ 0.52237162, -0.26335492, 0.58125401, 0.56561105]
第一个缩放后的花是:
[-0.90068117, 1.03205722, -1.3412724 , -1.31297673]
要得到投影数据第一行的第一个元素,我们可以使用以下公式:

事实上,一般来说,对于任何坐标为(a,b,c,d)的花朵,其中 a 是鸢尾花的萼片长度,b 是萼片宽度,c 是花瓣长度,d 是花瓣宽度(这个顺序是从之前的iris.feature_names中取的),新坐标系统的第一个值可以通过以下公式计算:

让我们更进一步,并在空间中将这些成分与我们的数据可视化。我们将截断原始数据,只保留其原始特征中的两个,即萼片长度和萼片宽度。我们这样做的原因是,这样我们可以更容易地可视化数据,而不用担心四个维度:
# cut out last two columns of the original iris dataset
iris_2_dim = iris_X[:,2:4]
# center the data
iris_2_dim = iris_2_dim - iris_2_dim.mean(axis=0)
plot(iris_2_dim, iris_y, "Iris: Only 2 dimensions", "sepal length", "sepal width")
我们得到了以下输出:

我们可以看到左下角有一簇setosa花,右上角有一簇较大的versicolor和virginicia花。一开始很明显,数据整体上沿着从左下角到右上角的对角线拉伸。希望我们的主成分也能捕捉到这一点,并相应地重新排列我们的数据。
让我们实例化一个保留两个主成分的 PCA 类,然后使用这个类将我们的截断iris 数据转换成新的列:
# instantiate a PCA of 2 components
twodim_pca = PCA(n_components=2)
# fit and transform our truncated iris data
iris_2_dim_transformed = twodim_pca.fit_transform(iris_2_dim)
plot(iris_2_dim_transformed, iris_y, "Iris: PCA performed on only 2 dimensions", "PCA1", "PCA2")
我们得到了以下输出:

PCA 1,我们的第一个主成分,应该包含大部分方差,这就是为什么投影数据主要分布在新的x轴上。注意x轴的刻度在-3 到 3 之间,而y轴只在-0.4 到 0.6 之间。为了进一步阐明这一点,以下代码块将绘制原始和投影的鸢尾花散点图,以及将twodim_pca的主成分叠加在它们之上,既在原始坐标系中,也在新坐标系中。
目标是将成分解释为引导向量,显示数据移动的方式以及这些引导向量如何成为垂直坐标系:
# This code is graphing both the original iris data and the projected version of it using PCA.
# Moreover, on each graph, the principal components are graphed as vectors on the data themselves
# The longer of the arrows is meant to describe the first principal component and
# the shorter of the arrows describes the second principal component
def draw_vector(v0, v1, ax):
arrowprops=dict(arrowstyle='->',linewidth=2,
shrinkA=0, shrinkB=0)
ax.annotate('', v1, v0, arrowprops=arrowprops)
fig, ax = plt.subplots(2, 1, figsize=(10, 10))
fig.subplots_adjust(left=0.0625, right=0.95, wspace=0.1)
# plot data
ax[0].scatter(iris_2_dim[:, 0], iris_2_dim[:, 1], alpha=0.2)
for length, vector in zip(twodim_pca.explained_variance_, twodim_pca.components_):
v = vector * np.sqrt(length) # elongdate vector to match up to explained_variance
draw_vector(twodim_pca.mean_,
twodim_pca.mean_ + v, ax=ax[0])
ax[0].set(xlabel='x', ylabel='y', title='Original Iris Dataset',
xlim=(-3, 3), ylim=(-2, 2))
ax[1].scatter(iris_2_dim_transformed[:, 0], iris_2_dim_transformed[:, 1], alpha=0.2)
for length, vector in zip(twodim_pca.explained_variance_, twodim_pca.components_):
transformed_component = twodim_pca.transform([vector])[0] # transform components to new coordinate system
v = transformed_component * np.sqrt(length) # elongdate vector to match up to explained_variance
draw_vector(iris_2_dim_transformed.mean(axis=0),
iris_2_dim_transformed.mean(axis=0) + v, ax=ax[1])
ax[1].set(xlabel='component 1', ylabel='component 2',
title='Projected Data',
xlim=(-3, 3), ylim=(-1, 1))
这是原始鸢尾花数据集和使用 PCA 投影的数据:

顶部图表显示了原始数据轴系统中的主成分。它们不是垂直的,并且指向数据自然遵循的方向。我们可以看到,两个向量中较长的第一个主成分明显遵循鸢尾花数据最遵循的对角线方向。
次级主成分指向一个解释数据形状一部分但不是全部的方差方向。底部图表显示了将鸢尾花数据投影到这些新成分上,并伴随着相同的成分,但作为垂直坐标系。它们已成为新的x轴和y轴。
PCA 是一种特征转换工具,它允许我们通过先前特征的线性组合构建全新的超级特征。我们已经看到,这些成分包含最大量的方差,并作为我们数据的新坐标系。我们的下一个特征转换算法与此类似,因为它也会从我们的数据中提取成分,但它以机器学习的方式这样做。
线性判别分析
线性判别分析(LDA)是一种特征转换技术,也是一种监督分类器。它通常用作分类流程的预处理步骤。LDA 的目标,就像 PCA 一样,是提取一个新的坐标系并将数据集投影到低维空间。LDA 与 PCA 的主要区别在于,与 PCA 关注数据的整体方差不同,LDA 优化低维空间以实现最佳类别可分性。这意味着新的坐标系在寻找分类模型的决策边界方面更有用,这对于我们构建分类流程来说非常完美。
LDA 之所以极其有用,是因为基于类别可分性的分离有助于我们在机器学习流程中避免过拟合。这也被称为防止维度灾难。LDA 还可以降低计算成本。
LDA 是如何工作的
LDA 作为一个降维工具,就像 PCA 一样工作,然而,LDA 不是计算整个数据集协方差矩阵的特征值,而是计算类内和类间散布矩阵的特征值和特征向量。执行 LDA 可以分为五个步骤:
-
计算每个类别的均值向量
-
计算类内和类间散布矩阵
-
为
计算特征值和特征向量 -
通过按降序排列特征值来保留最大的 k 个特征向量
-
使用最大的特征向量将数据投影到新的空间
让我们来看一个例子。
计算每个类别的均值向量
首先,我们需要为我们的每个类别计算一个列向量的均值向量。一个用于setosa,一个用于versicolor,另一个用于virginica:
# calculate the mean for each class
# to do this we will separate the iris dataset into three dataframes
# one for each flower, then we will take one's mean columnwise
mean_vectors = []
for cl in [0, 1, 2]:
class_mean_vector = np.mean(iris_X[iris_y==cl], axis=0)
mean_vectors.append(class_mean_vector)
print label_dict[cl], class_mean_vector
setosa [ 5.006 3.418 1.464 0.244]
versicolor [ 5.936 2.77 4.26 1.326]
virginica [ 6.588 2.974 5.552 2.026]
计算类内和类间散布矩阵
我们现在将计算一个类内散布矩阵,其定义为以下:

其中我们定义 S[i] 为:

在这里,m[i] 代表第 i 类的均值向量,以及以下定义的类间散布矩阵:

m 是数据集的整体均值,m[i] 是每个类别的样本均值,N[i] 是每个类别的样本大小(每个类别的观测数):
# Calculate within-class scatter matrix
S_W = np.zeros((4,4))
# for each flower
for cl,mv in zip([0, 1, 2], mean_vectors):
# scatter matrix for every class, starts with all 0's
class_sc_mat = np.zeros((4,4))
# for each row that describes the specific flower
for row in iris_X[iris_y == cl]:
# make column vectors
row, mv = row.reshape(4,1), mv.reshape(4,1)
# this is a 4x4 matrix
class_sc_mat += (row-mv).dot((row-mv).T)
# sum class scatter matrices
S_W += class_sc_mat
S_W
array([[ 38.9562, 13.683 , 24.614 , 5.6556], [ 13.683 , 17.035 , 8.12 , 4.9132], [ 24.614 , 8.12 , 27.22 , 6.2536], [ 5.6556, 4.9132, 6.2536, 6.1756]])
# calculate the between-class scatter matrix
# mean of entire dataset
overall_mean = np.mean(iris_X, axis=0).reshape(4,1)
# will eventually become between class scatter matrix
S_B = np.zeros((4,4))
for i,mean_vec in enumerate(mean_vectors):
# number of flowers in each species
n = iris_X[iris_y==i,:].shape[0]
# make column vector for each specied
mean_vec = mean_vec.reshape(4,1)
S_B += n * (mean_vec - overall_mean).dot((mean_vec - overall_mean).T)
S_B
array([[ 63.2121, -19.534 , 165.1647, 71.3631], [ -19.534 , 10.9776, -56.0552, -22.4924], [ 165.1647, -56.0552, 436.6437, 186.9081], [ 71.3631, -22.4924, 186.9081, 80.6041]])
类内和类间散布矩阵是 ANOVA 测试中一个步骤的推广(如前一章所述)。这里的想法是将我们的鸢尾花数据集分解成两个不同的部分。
一旦我们计算了这些矩阵,我们就可以进行下一步,即使用矩阵代数来提取线性判别式。
计算 SW-1SB 的特征值和特征向量
正如我们在 PCA 中所做的那样,我们依赖于特定矩阵的特征值分解。在 LDA 的情况下,我们将分解矩阵
:
# calculate eigenvalues and eigenvectors of S−1W x SB
eig_vals, eig_vecs = np.linalg.eig(np.dot(np.linalg.inv(S_W), S_B))
eig_vecs = eig_vecs.real
eig_vals = eig_vals.real
for i in range(len(eig_vals)):
eigvec_sc = eig_vecs[:,i]
print 'Eigenvector {}: {}'.format(i+1, eigvec_sc)
print 'Eigenvalue {:}: {}'.format(i+1, eig_vals[i])
print
Eigenvector 1: [-0.2049 -0.3871 0.5465 0.7138]
Eigenvalue 1: 32.2719577997 Eigenvector 2: [ 0.009 0.589 -0.2543 0.767 ] Eigenvalue 2: 0.27756686384 Eigenvector 3: [ 0.2771 -0.3863 -0.4388 0.6644] Eigenvalue 3: -6.73276389619e-16 . # basically 0 Eigenvector 4: [ 0.2771 -0.3863 -0.4388 0.6644] Eigenvalue 4: -6.73276389619e-16 . # basically 0
注意第三个和第四个特征值基本上为零。这是因为 LDA 试图通过在我们类别之间绘制决策边界来工作。因为我们只有三个鸢尾花类别,所以我们可能只能绘制两个决策边界。一般来说,将 LDA 拟合到具有 n 个类别的数据集将只产生至多 n-1 个成分。
通过按降序排列特征值来保留最大的 k 个特征向量
正如 PCA 一样,我们只想保留做大部分工作的特征向量:
# keep the top two linear discriminants
linear_discriminants = eig_vecs.T[:2]
linear_discriminants
array([[-0.2049, -0.3871, 0.5465, 0.7138], [ 0.009 , 0.589 , -0.2543, 0.767 ]])
我们可以通过将每个特征值除以所有特征值的总和来查看每个成分/线性判别式的解释方差比:
#explained variance ratios
eig_vals / eig_vals.sum()
array([ .99147, .0085275, -2.0685e-17, -2.0685e-17])
看起来第一个成分做了大部分工作,并且独自占据了超过 99%的信息。
使用最大的特征向量将数据投影到新的空间
现在我们有了这些成分,让我们通过首先使用特征向量将原始数据投影到新空间,然后调用我们的绘图函数来绘制投影的鸢尾花数据:
# LDA projected data
lda_iris_projection = np.dot(iris_X, linear_discriminants.T)
lda_iris_projection[:5,]
plot(lda_iris_projection, iris_y, "LDA Projection", "LDA1", "LDA2")
我们得到以下输出:

注意,在这个图中,数据几乎完全垂直(甚至比 PCA 投影数据更垂直),就像 LDA 成分试图通过绘制这些决策边界并提供特征向量/线性判别分析来尽可能帮助机器学习模型分离花朵一样。这有助于我们将数据投影到尽可能分离类的空间中。
如何在 scikit-learn 中使用 LDA
LDA 在 scikit-learn 中有一个实现,以避免这个过程非常繁琐。它很容易导入:
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
从那里,让我们用它来拟合和转换我们的原始鸢尾花数据,并绘制结果投影数据集,以便我们可以将其与使用 PCA 的投影进行比较。在下面的代码块中要注意的最大事情是 fit 函数需要两个输入。
回想一下我们提到 LDA 实际上是一个伪装成特征变换算法的分类器。与 PCA 不同,PCA 以无监督的方式(没有响应变量)找到成分,而 LDA 将尝试找到最佳坐标系,该坐标系相对于响应变量优化了类可分性。这意味着 LDA 只在我们有响应变量时才起作用。如果有,我们将响应作为 fit 方法的第二个输入,让 LDA 做其事:
# instantiate the LDA module
lda = LinearDiscriminantAnalysis(n_components=2)
# fit and transform our original iris data
X_lda_iris = lda.fit_transform(iris_X, iris_y)
# plot the projected data
plot(X_lda_iris, iris_y, "LDA Projection", "LDA1", "LDA2")
我们得到以下输出:

此图是手动 LDA 的镜像。这是可以的。回想一下在 PCA 中,我们手动版本的特征向量具有相反的符号(正与负)。这不会影响我们的机器学习流程。在 LDA 模块中,我们有一些差异需要注意。我们没有 .components_ 属性,而是有 .scalings_ 属性,它几乎以相同的方式工作:
# essentially the same as pca.components_, but transposed (4x2 instead of 2x4)
lda.scalings_
array([[ 0.81926852, 0.03285975], [ 1.5478732 , 2.15471106], [-2.18494056, -0.93024679], [-2.85385002, 2.8060046 ]])
# same as manual calculations
lda.explained_variance_ratio_
array([ 0.9915, 0.0085])
两个线性判别分析的解释方差比与之前计算出的完全相同,并且注意它们省略了第三个和第四个特征值,因为它们几乎为零。
然而,这些成分乍一看与之前我们得到的手动特征向量完全不同。原因是 scikit-learn 计算特征向量的方式产生了相同的特征向量,但通过一个标量进行了缩放,如下所示:
# show that the sklearn components are just a scalar multiplication from the manual components we calculateda
for manual_component, sklearn_component in zip(eig_vecs.T[:2], lda.scalings_.T):
print sklearn_component / manual_component
[-3.9982 -3.9982 -3.9982 -3.9982] [ 3.6583 3.6583 3.6583 3.6583]
scikit-learn 中的线性判别分析是对手动特征向量的标量乘积,这意味着它们都是有效的特征向量。唯一的区别在于投影数据的缩放。
这些成分被组织成一个 4 x 2 的矩阵,而不是 PCA 成分,PCA 成分以 2 x 4 矩阵的形式提供给我们。这是在开发模块时做出的选择,对我们来说并不会影响数学计算。LDA,就像 PCA 一样,具有缩放不变性,因此数据的缩放很重要。
让我们将 LDA 模块拟合到缩放后的鸢尾花数据,并查看组件以查看差异:
# fit our LDA to scaled data
X_lda_iris = lda.fit_transform(X_scaled, iris_y)
lda.scalings_ # different scalings when data are scaled
array([[ 0.67614337, 0.0271192 ], [ 0.66890811, 0.93115101], [-3.84228173, -1.63586613], [-2.17067434, 2.13428251]])
缩放 _ 属性(类似于 PCA 的components_ 属性)向我们展示了不同的数组,这意味着投影也将不同。为了完成我们对 LDA(线性判别分析)的(更简短的)描述,让我们应用与 PCA 相同的代码块,并将缩放 _ 数组解释为 PCA 的components_ 属性。
让我们先对截断的鸢尾花数据集进行 LDA 拟合和转换,我们只保留了前两个特征:
# fit our LDA to our truncated iris dataset
iris_2_dim_transformed_lda = lda.fit_transform(iris_2_dim, iris_y)
让我们看看我们投影数据集的前五行:
# project data
iris_2_dim_transformed_lda[:5,]
array([[-6.04248571, 0.07027756], [-6.04248571, 0.07027756], [-6.19690803, 0.28598813], [-5.88806338, -0.14543302], [-6.04248571, 0.07027756]])
我们的缩放 _ 矩阵现在是一个 2 x 2 矩阵(2 行 2 列),其中列是组件(而不是 PCA 中行是组件)。为了调整这一点,让我们创建一个新的变量components,它包含缩放 _ 属性的转置版本:
# different notation
components = lda.scalings_.T # transposing gives same style as PCA. We want rows to be components
print components
[[ 1.54422328 2.40338224] [-2.15710573 5.02431491]]
np.dot(iris_2_dim, components.T)[:5,] # same as transform method
array([[-6.04248571, 0.07027756], [-6.04248571, 0.07027756], [-6.19690803, 0.28598813], [-5.88806338, -0.14543302], [-6.04248571, 0.07027756]])
我们可以看到,它使用与 PCA components_ 属性相同的方式使用组件变量。这意味着投影是原始列的另一个线性组合,就像在 PCA 中一样。还值得注意的是,LDA 仍然去相关特征,就像 PCA 一样。为了展示这一点,让我们计算原始截断的鸢尾花数据和投影数据的协方差矩阵:
# original features are highly correlated
np.corrcoef(iris_2_dim.T)
array([[ 1\. , 0.9627571],
[ 0.9627571, 1\. ]])
# new LDA features are highly uncorrelated, like in PCA
np.corrcoef(iris_2_dim_transformed_lda.T)
array([[ 1.00000000e+00, 1.03227536e-15], [ 1.03227536e-15, 1.00000000e+00]])
注意到在每个矩阵的右上角值,原始矩阵显示的是高度相关的特征,而使用 LDA 投影的数据具有高度独立的特点(考虑到接近零的协方差系数)。在我们转向真正的乐趣(使用 PCA 和 LDA 进行机器学习)之前,让我们看一下 LDA 的缩放 _ 属性的可视化,就像我们对 PCA 所做的那样:
# This code is graphing both the original iris data and the projected version of it using LDA.
# Moreover, on each graph, the scalings of the LDA are graphed as vectors on the data themselves
# The longer of the arrows is meant to describe the first scaling vector and
# the shorter of the arrows describes the second scaling vector
def draw_vector(v0, v1, ax):
arrowprops=dict(arrowstyle='->',
linewidth=2,
shrinkA=0, shrinkB=0)
ax.annotate('', v1, v0, arrowprops=arrowprops)
fig, ax = plt.subplots(2, 1, figsize=(10, 10))
fig.subplots_adjust(left=0.0625, right=0.95, wspace=0.1)
# plot data
ax[0].scatter(iris_2_dim[:, 0], iris_2_dim[:, 1], alpha=0.2)
for length, vector in zip(lda.explained_variance_ratio_, components):
v = vector * .5
draw_vector(lda.xbar_, lda.xbar_ + v, ax=ax[0]) # lda.xbar_ is equivalent to pca.mean_
ax[0].axis('equal')
ax[0].set(xlabel='x', ylabel='y', title='Original Iris Dataset',
xlim=(-3, 3), ylim=(-3, 3))
ax[1].scatter(iris_2_dim_transformed_lda[:, 0], iris_2_dim_transformed_lda[:, 1], alpha=0.2)
for length, vector in zip(lda.explained_variance_ratio_, components):
transformed_component = lda.transform([vector])[0]
v = transformed_component * .1
draw_vector(iris_2_dim_transformed_lda.mean(axis=0), iris_2_dim_transformed_lda.mean(axis=0) + v, ax=ax[1])
ax[1].axis('equal')
ax[1].set(xlabel='lda component 1', ylabel='lda component 2',
title='Linear Discriminant Analysis Projected Data',
xlim=(-10, 10), ylim=(-3, 3))
我们得到以下输出:

注意到组件,而不是随着数据的方差变化,几乎垂直于它;它遵循类别的分离。还要注意它几乎与左右两侧花朵之间的间隙平行。LDA 试图捕捉类别的分离。
在顶部图表中,我们可以看到原始的鸢尾花数据集,其缩放向量叠加在数据点上。较长的向量几乎与左下角塞托萨花和右上角其他花朵之间的大间隙平行。这表明 LDA 正在尝试指出在原始坐标系中分离花朵类别的最佳方向。
这里需要注意的是,LDA 的scalings_属性与 PCA 中的新坐标系不呈 1:1 的相关性。这是因为scalings_的目标不是创建一个新的坐标系,而只是指向数据中边界方向,这些方向优化了类分离性。我们不会像 PCA 那样详细讨论这些新坐标系的计算。理解 PCA 和 LDA 的主要区别就足够了,PCA 是一个无监督方法,它捕捉数据的整体方差,而 LDA 是一个监督方法,它使用响应变量来捕捉类分离性。
监督特征转换(如 LDA)的局限性意味着它们无法帮助诸如聚类等任务,而 PCA 则可以。这是因为聚类是一个无监督任务,没有 LDA 可以使用的响应变量。
LDA 与 PCA – 爱 ris 数据集
最后,我们来到了一个时刻,可以尝试在我们的机器学习管道中使用 PCA 和 LDA。因为我们在这章中广泛使用了iris数据集,我们将继续展示 LDA 和 PCA 作为监督和未监督机器学习的特征转换预处理步骤的效用。
我们将从监督机器学习开始,并尝试构建一个分类器,根据四种定量花性状来识别花的种类:
- 我们首先从 scikit-learn 导入三个模块:
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import Pipeline
from sklearn.model_selection import cross_val_score
我们将使用 KNN 作为我们的监督模型,并使用管道模块将我们的 KNN 模型与特征转换工具结合,以创建可以使用cross_val_score模块进行交叉验证的机器学习管道。我们将尝试几个不同的机器学习管道并记录它们的性能:
- 让我们从创建三个新变量开始,一个用于存储我们的 LDA,一个用于存储我们的 PCA,另一个用于存储 KNN 模型:
# Create a PCA module to keep a single component
single_pca = PCA(n_components=1)
# Create a LDA module to keep a single component
single_lda = LinearDiscriminantAnalysis(n_components=1)
# Instantiate a KNN model
knn = KNeighborsClassifier(n_neighbors=3)
- 让我们不使用任何转换技术来调用 KNN 模型,以获取基线准确率。我们将使用这个结果来比较两个特征转换算法:
# run a cross validation on the KNN without any feature transformation
knn_average = cross_val_score(knn, iris_X, iris_y).mean()
# This is a baseline accuracy. If we did nothing, KNN on its own achieves a 98% accuracy
knn_average
0.98039215686274517
- 我们需要击败的基线准确率是 98.04%。让我们使用我们的 LDA,它只保留最强大的组件:
lda_pipeline = Pipeline([('lda', single_lda), ('knn', knn)])
lda_average = cross_val_score(lda_pipeline, iris_X, iris_y).mean()
# better prediction accuracy than PCA by a good amount, but not as good as original
lda_average
0.9673202614379085
- 似乎仅使用单个线性判别分析不足以击败我们的基线准确率。现在让我们尝试 PCA。我们的假设是,PCA 不会优于 LDA,仅仅是因为 PCA 不是像 LDA 那样试图优化类分离:
# create a pipeline that performs PCA
pca_pipeline = Pipeline([('pca', single_pca), ('knn', knn)])
pca_average = cross_val_score(pca_pipeline, iris_X, iris_y).mean()
pca_average
0.8941993464052288
肯定是目前最差的。
值得探索的是,添加另一个 LDA 组件是否会帮助我们:
# try LDA with 2 components
lda_pipeline = Pipeline([('lda', LinearDiscriminantAnalysis(n_components=2)),
('knn', knn)])
lda_average = cross_val_score(lda_pipeline, iris_X, iris_y).mean()
# Just as good as using original data
lda_average
0.98039215686274517
使用两个组件,我们能够达到原始准确率!这很好,但我们想做得比基线更好。让我们看看上一章中的特征选择模块是否能帮助我们。让我们导入并使用SelectKBest模块,看看统计特征选择是否能优于我们的 LDA 模块:
# compare our feature transformation tools to a feature selection tool
from sklearn.feature_selection import SelectKBest
# try all possible values for k, excluding keeping all columns
for k in [1, 2, 3]:
# make the pipeline
select_pipeline = Pipeline([('select', SelectKBest(k=k)), ('knn', knn)])
# cross validate the pipeline
select_average = cross_val_score(select_pipeline, iris_X, iris_y).mean()
print k, "best feature has accuracy:", select_average
# LDA is even better than the best selectkbest
1 best feature has accuracy: 0.953839869281 2 best feature has accuracy: 0.960784313725 3 best feature has accuracy: 0.97385620915
我们具有两个成分的 LDA 迄今为止是获胜的。在生产中,同时使用无监督和监督特征变换是很常见的。让我们设置一个GridSearch模块,以找到以下最佳组合:
-
数据缩放(带或不带均值/标准差)
-
PCA 成分
-
LDA 成分
-
KNN 邻居
下面的代码块将设置一个名为get_best_model_and_accuracy的函数,该函数将接受一个模型(scikit-learn 管道或其他),一个字典形式的参数网格,我们的X和y数据集,并输出网格搜索模块的结果。输出将是模型的最佳性能(以准确率衡量),导致最佳性能的最佳参数,拟合的平均时间和预测的平均时间:
def get_best_model_and_accuracy(model, params, X, y):
grid = GridSearchCV(model, # the model to grid search
params, # the parameter set to try
error_score=0.) # if a parameter set raises an error, continue and set the performance as a big, fat 0
grid.fit(X, y) # fit the model and parameters
# our classical metric for performance
print "Best Accuracy: {}".format(grid.best_score_)
# the best parameters that caused the best accuracy
print "Best Parameters: {}".format(grid.best_params_)
# the average time it took a model to fit to the data (in seconds)
avg_time_fit = round(grid.cv_results_['mean_fit_time'].mean(), 3)
print "Average Time to Fit (s): {}".format(avg_time_fit)
# the average time it took a model to predict out of sample data (in seconds)
# this metric gives us insight into how this model will perform in real-time analysis
print "Average Time to Score (s): {}".format(round(grid.cv_results_['mean_score_time'].mean(), 3))
一旦我们设置了接受模型和参数的函数,让我们使用它来测试我们的管道,包括缩放、PCA、LDA 和 KNN 的组合:
from sklearn.model_selection import GridSearchCV
iris_params = {
'preprocessing__scale__with_std': [True, False],
'preprocessing__scale__with_mean': [True, False],
'preprocessing__pca__n_components':[1, 2, 3, 4], # according to scikit-learn docs, max allowed n_components for LDA is number of classes - 1
'preprocessing__lda__n_components':[1, 2],
'clf__n_neighbors': range(1, 9)
}
# make a larger pipeline
preprocessing = Pipeline([('scale', StandardScaler()), ('pca', PCA()), ('lda', LinearDiscriminantAnalysis())])
iris_pipeline = Pipeline(steps=[('preprocessing', preprocessing),('clf', KNeighborsClassifier())])
get_best_model_and_accuracy(iris_pipeline, iris_params, iris_X, iris_y)
Best Accuracy: 0.986666666667 Best Parameters: {'preprocessing__scale__with_std': False, 'preprocessing__pca__n_components': 3, 'preprocessing__scale__with_mean': True, 'preprocessing__lda__n_components': 2, 'clf__n_neighbors': 3} Average Time to Fit (s): 0.002 Average Time to Score (s): 0.001
到目前为止最佳准确率(接近 99%)使用缩放、PCA 和 LDA 的组合。正确使用这三个算法在同一管道中,并执行超参数调整以微调过程是很常见的。这表明,很多时候,最佳的生产就绪机器学习管道实际上是由多种特征工程方法组合而成的。
摘要
为了总结我们的发现,PCA 和 LDA 都是我们工具箱中的特征变换工具,用于寻找最佳的新特征。LDA 特别优化类分离,而 PCA 以无监督方式工作,以在更少的列中捕获数据中的方差。通常,这两个算法与监督管道结合使用,正如我们在鸢尾花管道中所示。在最后一章,我们将通过两个更长的案例研究来介绍,这两个案例研究都利用了 PCA 和 LDA 进行文本聚类和面部识别软件。
PCA 和 LDA 是极其强大的工具,但存在局限性。两者都是线性变换,这意味着它们只能创建线性边界并捕获数据中的线性特性。它们也是静态变换。无论我们输入什么数据到 PCA 或 LDA 中,输出都是预期和数学的。如果我们使用的数据不适合 PCA 或 LDA(例如,它们表现出非线性特性,它们是圆形的),那么这两个算法将不会帮助我们,无论我们进行多少网格搜索。
下一章将重点介绍特征学习算法。这些算法可以说是最强大的特征工程算法。它们旨在根据输入数据学习新特征,而不假设 PCA 和 LDA 等特性。在本章中,我们将使用包括神经网络在内的复杂结构,以达到迄今为止最高级别的特征工程。
第七章:特征学习
在我们的最后一章中,我们将探讨特征工程技术,我们将查看我们可能拥有的最强大的特征工程工具。特征学习算法能够接受清洗后的数据(是的,你仍然需要做一些工作)并利用数据中的潜在结构创建全新的特征。如果这一切听起来很熟悉,那是因为我们在上一章中用于特征转换的描述。这两个算法家族之间的区别在于它们在尝试创建新特征时所做的参数假设。
我们将涵盖以下主题:
-
数据的参数假设
-
限制性玻尔兹曼机
-
伯努利 RBM
-
从 MNIST 中提取 RBM 组件
-
在机器学习管道中使用 RBMs
-
学习文本特征——词向量化
数据的参数假设
当我们提到参数假设时,我们指的是算法对数据形状的基本假设。在上一章中,当我们探索主成分分析(PCA)时,我们发现算法的最终结果产生了我们可以用来通过单次矩阵乘法转换数据的组件。我们所做的假设是原始数据具有可以被分解并由单个线性变换(矩阵运算)表示的形状。但如果这不是真的呢?如果 PCA 无法从原始数据集中提取有用的特征呢?像 PCA 和线性判别分析(LDA)这样的算法总是能够找到特征,但它们可能根本无用。此外,这些算法依赖于预定的方程,并且每次运行时都会输出相同的特征。这就是为什么我们将 LDA 和 PCA 都视为线性变换。
特征学习算法试图通过去除那个参数假设来解决这个问题。它们不对输入数据的形状做出任何假设,并依赖于随机学习。这意味着,它们不会每次都将相同的方程应用于数据矩阵,而是会通过反复查看数据点(在时代中)来尝试找出最佳的特征提取方法,并收敛到一个解决方案(在运行时可能是不同的)。
关于随机学习(以及随机梯度下降)的工作原理的更多信息,请参阅 Sinan Ozdemir 所著的《数据科学的原理》:
这使得特征学习算法可以绕过 PCA 和 LDA 等算法所做的参数假设,使我们能够解决比之前在文本中能解决的更难的问题。这样一个复杂的思想(绕过参数假设)需要使用复杂的算法。深度学习算法是许多数据科学家和机器学习专家从原始数据中学习新特征的选择。
我们将假设读者对神经网络架构有基本的了解,以便专注于这些架构在特征学习方面的应用。以下表格总结了特征学习和转换之间的基本差异:
| 参数化? | 易于使用? | 创建新的特征集? | 深度学习? | |
|---|---|---|---|---|
| 特征转换算法 | 是 | 是 | 是 | 否 |
| 特征学习算法 | 否 | 否(通常) | 是 | 是(通常) |
事实是,特征学习和特征转换算法都创建了新的特征集,这意味着我们将它们都视为属于特征提取的范畴。以下图显示了这种关系:

特征提取作为特征学习和特征转换的超集。这两个算法家族都致力于利用潜在结构,将原始数据转换成新的特征集
由于特征学习和特征转换都属于特征提取的范畴,因为它们都试图从原始数据的潜在结构中创建新的特征集。尽管如此,它们允许工作的方法却是主要的区别点。
非参数谬误
需要强调的是,一个模型是非参数的并不意味着在训练过程中模型没有任何假设。
尽管我们将在本章中介绍的一些算法放弃了关于数据形状的假设,但它们仍然可能在数据的其他方面做出假设,例如,单元格的值。
本章的算法
在本章中,我们将重点关注两个特征学习领域:
-
受限玻尔兹曼机(RBM):一个简单的深度学习架构,它基于数据遵循的概率模型来学习一定数量的新维度。这些机器实际上是一系列算法,其中只有一个是 scikit-learn 中实现的。伯努利 RBM可能是一个非参数特征学习器;然而,正如其名称所暗示的,对数据集单元格的值有一些期望。
-
词嵌入:自然语言处理/理解/生成领域近年来由深度学习推动的进步中,最大的贡献者之一可能是将字符串(单词和短语)投影到 n 维特征集中,以便把握语境和措辞的细微差别。我们将使用
gensimPython 包来准备我们自己的词嵌入,然后使用预训练的词嵌入来查看这些词嵌入如何被用来增强我们与文本的交互方式。
所有这些例子都有共同之处。它们都涉及从原始数据中学习全新的特征。然后,它们使用这些新特征来增强与数据的交互方式。对于后两个例子,我们将不得不离开 scikit-learn,因为这些更高级的技术(尚未)在最新版本中实现。相反,我们将看到 TensorFlow 和 Keras 中实现的深度学习神经网络架构的例子。
对于所有这些技术,我们将更多地关注模型如何解释数据,而不是非常低级的内部工作原理。我们将按顺序进行,并从唯一一个有 scikit-learn 实现的算法——限制性玻尔兹曼机算法系列开始。
限制性玻尔兹曼机
RBMs 是一族无监督特征学习算法,使用概率模型来学习新特征。像 PCA 和 LDA 一样,我们可以使用 RBMs 从原始数据中提取新的特征集,并使用它们来增强机器学习流程。RBMs 提取的特征通常在跟随线性模型(如线性回归、逻辑回归、感知器等)时效果最佳。
RBMs 的无监督特性非常重要,因为它们与 PCA 算法更相似,而不是与 LDA 相似。它们不需要为数据点提取新特征而提供真实标签。这使得它们在更广泛的机器学习问题中非常有用。
从概念上讲,RBMs 是浅层(两层)神经网络。它们被认为是称为深度信念网络(DBN)的一类算法的构建块。遵循标准术语,有一个可见层(第一层),然后是一个隐藏层(第二层)。这些是网络中唯一的两个层:

限制性玻尔兹曼机的设置。圆圈代表图中的节点
就像任何神经网络一样,我们的两个层中都有节点。网络的第一层可见层与输入特征维度一样多。在我们即将到来的例子中,我们将处理 28 x 28 的图像,这需要输入层中有 784(28 x 28)个节点。隐藏层中的节点数是一个人为选择的数字,代表我们希望学习的特征数量。
不一定是降维
在主成分分析(PCA)和线性判别分析(LDA)中,我们提取的组件数量受到了严格的限制。对于 PCA,我们受限于原始特征的数量(我们只能使用原始列数或更少的数量),而 LDA 则实施了更为严格的限制,即提取的特征数量不得超过真实数据集中类别数减一。
RBM 允许学习的特征数量的唯一限制是,它们受限于运行网络的计算机的计算能力和人类的解释。RBM 可以学习比我们最初开始时更多的或更少的特征。要学习的确切特征数量取决于问题,并且可以通过网格搜索来确定。
限制性玻尔兹曼机(RBM)的图
到目前为止,我们已经看到了 RBM 的可见层和隐藏层,但我们还没有看到它们是如何学习特征的。可见层中的每个节点都从要学习的数据集中接收一个特征。然后,通过权重和偏置,这些数据从可见层传递到隐藏层:

这个 RBM 的可视化显示了单个数据点通过单个隐藏节点在图中的移动
前面的 RBM 可视化显示了单个数据点通过图和单个隐藏节点的移动。可见层有四个节点,代表原始数据的四列。每条箭头代表数据点的单个特征通过 RBM 第一层的四个可见节点移动。每个特征值都乘以与该特征关联的权重,并加在一起。这个计算也可以通过输入数据向量和权重向量之间的点积来总结。数据的结果加权总和加到一个偏置变量上,并通过一个激活函数(如 Sigmoid 函数)传递。结果存储在一个名为a的变量中。
以 Python 为例,以下代码展示了单个数据点(inputs)是如何乘以我们的weights向量,并与bias变量结合以创建激活变量a:
import numpy as np
import math
# sigmoidal function
def activation(x):
return 1 / (1 + math.exp(-x))
inputs = np.array([1, 2, 3, 4])
weights = np.array([0.2, 0.324, 0.1, .001])
bias = 1.5
a = activation(np.dot(inputs.T, weights) + bias)
print a
0.9341341524806636
在真实的 RBM 中,每个可见节点都与每个隐藏节点相连,其结构看起来像这样:

由于每个可见节点的输入都传递给每个隐藏节点,因此 RBM 可以被定义为一种对称的二分图。对称性源于可见节点与每个隐藏节点都相连。二分图意味着它由两部分(层)组成。
玻尔兹曼机的限制
我们已经看到了两个可见和隐藏节点层的层间连接(层间连接),但我们还没有看到同一层中节点之间的连接(层内连接)。这是因为没有这样的连接。RBM 的限制是我们不允许任何层内通信。这允许节点独立创建权重和偏置,最终成为(希望是)我们数据独立的特征。
数据重建
在这个网络的前向传递中,我们可以看到数据是如何通过网络(从可见层到隐藏层)前向传递的,但这并不能解释 RBM 是如何从我们的数据中学习新特征的,而没有地面实况。这是通过在可见层和隐藏层之间通过网络进行多次正向和反向传递来实现的。
在重建阶段,我们改变网络的结构,让隐藏层成为输入层,并让它使用相同的权重但新的偏置将激活变量(a)反向传递到可见层。在正向传递过程中计算出的激活变量随后被用来重建原始输入向量。以下可视化展示了如何使用相同的权重和不同的偏置将激活信号反向传递通过我们的图:

这成为了网络自我评估的方式。通过将激活信号反向传递通过网络并获得原始输入的近似值,网络可以调整权重以使近似值更接近原始输入。在训练的初期,由于权重是随机初始化的(这是标准做法),近似值可能会非常不准确。反向传播通过网络,与我们的正向传递方向相同(我们知道这很令人困惑),然后调整权重以最小化原始输入和近似值之间的距离。这个过程然后重复进行,直到近似值尽可能接近原始输入。这种来回传递过程发生的次数被称为迭代次数。
这个过程的最终结果是,网络为每个数据点都有一个“替身”。要转换数据,我们只需将其通过网络,检索激活变量,并将这些称为新特征。这个过程是一种类型的生成学习,它试图学习生成原始数据的概率分布,并利用知识来为我们提供原始数据的新的特征集。
例如,如果我们被给了一个数字的图片并要求我们分类这个图片是哪个数字(0-9),网络的正向传播会问这样的问题:给定这些像素,我应该期望哪个数字?在反向传播中,网络会问给定一个数字,我应该期望哪些像素?这被称为联合概率,它是给定x时y的概率和给定y时x的概率的联合,它通过我们网络中两层之间的共享权重来表示。
让我们引入我们的新数据集,并让它阐明 RBM 在特征学习中的有用性。
MNIST 数据集
MNIST数据集包含 6000 张 0 到 9 的手写数字图片以及一个用于学习的真实标签。它与其他我们一直在使用的其他数据集类似,我们试图将机器学习模型拟合到响应变量,给定一组数据点。这里的主要区别在于我们处理的是非常低级的特征,而不是更可解释的特征。每个数据点将包含 784 个特征(灰度图像中的像素值)。
- 让我们从导入开始:
# import numpy and matplotlib
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline
from sklearn import linear_model, datasets, metrics
# scikit-learn implementation of RBM
from sklearn.neural_network import BernoulliRBM
from sklearn.pipeline import Pipeline
- 新的导入是 BernoulliRBM,这是目前 scikit-learn 中唯一的 RBM 实现。正如其名所示,我们将需要进行一些预处理以确保我们的数据符合所需的假设。让我们直接将我们的数据集导入到 NumPy 数组中:
# create numpy array from csv
images = np.genfromtxt('../data/mnist_train.csv', delimiter=',')
- 我们可以验证我们正在处理的行数和列数:
# 6000 images and 785 columns, 28X28 pixels + 1 response
images.shape
(6000, 785)
- 785 由 784 个像素和一个起始处的单个响应列组成。除了响应列之外,每一列都持有介于 0 到 255 之间的值,代表像素强度,其中 0 表示白色背景,255 表示全黑像素。我们可以通过将第一列与其他数据分开来从数据中提取
X和y变量:
# extract the X and y variable
images_X, images_y = images[:,1:], images[:,0]
# values are much larger than 0-1 but scikit-learn RBM version assumes 0-1 scaling
np.min(images_X), np.max(images_X)
(0.0, 255.0)
- 如果我们看一下第一张图片,我们会看到我们正在处理的内容:
plt.imshow(images_X[0].reshape(28, 28), cmap=plt.cm.gray_r)
images_y[0]
图表如下:

看起来不错。因为 scikit-learn 对受限玻尔兹曼机的实现不允许值超出 0-1 的范围,我们不得不做一些预处理工作。
BernoulliRBM
限制玻尔兹曼机(RBM)的唯一 scikit-learn 实现版本被称为BernoulliRBM,因为它对其可以学习的概率分布类型施加了约束。伯努利分布允许数据值在零到一之间。scikit-learn 文档指出,该模型假设输入是二进制值或零到一之间的值。这是为了表示节点值代表节点被激活或未激活的概率。这允许更快地学习特征集。为了解决这个问题,我们将调整我们的数据集,只考虑硬编码的白色/黑色像素强度。通过这样做,每个单元格的值要么是零要么是一(白色或黑色),以使学习更加稳健。我们将通过以下两个步骤来完成这项工作:
-
我们将把像素值的范围缩放到零到一之间
-
我们将就地更改像素值,如果值超过
0.5则为真,否则为假
让我们先对像素值进行缩放,使其介于 0 和 1 之间:
# scale images_X to be between 0 and 1
images_X = images_X / 255.
# make pixels binary (either white or black)
images_X = (images_X > 0.5).astype(float)
np.min(images_X), np.max(images_X)
(0.0, 1.0)
让我们看看之前看到的相同的数字 5,用我们新更改的像素:
plt.imshow(images_X[0].reshape(28, 28), cmap=plt.cm.gray_r)
images_y[0]
图如下:

我们可以看到图像的模糊性已经消失,我们剩下的是一个非常清晰的数字来进行分类。现在让我们尝试从我们的数字数据集中提取特征。
从 MNIST 中提取 PCA 组件
在我们转向我们的 RBM 之前,让我们看看当我们对数据集应用 PCA 时会发生什么。就像我们在上一章做的那样,我们将取我们的特征(784 个像素,要么开启要么关闭)并对矩阵进行特征值分解,从数据集中提取特征数字。
让我们从可能的 784 个组件中取出 100 个,并绘制这些组件以查看提取的特征是什么样的。我们将通过导入我们的 PCA 模块,用 100 个组件拟合我们的数据,并创建一个 matplotlib 图库来显示我们可用的前 100 个组件:
# import Principal Components Analysis module
from sklearn.decomposition import PCA
# extract 100 "eigen-digits"
pca = PCA(n_components=100)
pca.fit(images_X)
# graph the 100 components
plt.figure(figsize=(10, 10))
for i, comp in enumerate(pca.components_):
plt.subplot(10, 10, i + 1)
plt.imshow(comp.reshape((28, 28)), cmap=plt.cm.gray_r)
plt.xticks(())
plt.yticks(())
plt.suptitle('100 components extracted by PCA')
plt.show()
下面的图是前面代码块的图:

这个图像图库向我们展示了协方差矩阵的特征值在重塑为与原始图像相同维度时的样子。这是当我们将算法集中在图像数据集上时,提取的组件的一个例子。窥视 PCA 组件如何尝试从数据集中获取线性变换是非常有趣的。每个组件都试图理解图像的某个“方面”,这将转化为可解释的知识。例如,第一个(也是最重要的)特征图像很可能是捕捉到图像的 0 质量,即数字看起来像 0 的程度。
同时,很明显,前十个组件似乎保留了一些数字的形状,之后,它们似乎开始退化成看似无意义的图像。到画廊结束时,我们似乎在观察黑白像素的随机组合旋转。这可能是由于 PCA(以及 LDA)是参数化变换,它们在从复杂的数据集(如图像)中提取信息的能力上有限。
如果我们查看前 30 个组件解释了多少方差,我们会发现它们能够捕捉到大部分信息:
# first 30 components explain 64% of the variance
pca.explained_variance_ratio_[:30].sum()
.637414
这告诉我们,前几十个组件在捕捉数据的本质方面做得很好,但之后,组件可能没有增加太多。
这可以通过一个显示我们 PCA 组件累积解释方差的 scree 图进一步看出:
# Scree Plot
# extract all "eigen-digits"
full_pca = PCA(n_components=784)
full_pca.fit(images_X)
plt.plot(np.cumsum(full_pca.explained_variance_ratio_))
# 100 components captures about 90% of the variance
下图是特征值累积图,其中 PCA 组件的数量位于x轴上,而解释的累积方差则位于y轴上:

正如我们在上一章中看到的,PCA 所做的变换是通过乘以 PCA 模块的组件属性与数据来通过单个线性矩阵操作完成的。我们将通过使用拟合到 100 个特征的 scikit-learn PCA 对象并使用它来转换单个 MNIST 图像来再次展示这一点。我们将取那个转换后的图像,并将其与原始图像乘以 PCA 模块的components_属性的结果进行比较:
# Use the pca object, that we have already fitted, to transform the first image in order to pull out the 100 new features
pca.transform(images_X[:1])
array([[ 0.61090568, 1.36377972, 0.42170385, -2.19662828, -0.45181077, -1.320495 , 0.79434677, 0.30551126, 1.22978985, -0.72096767, ...
# reminder that transformation is a matrix multiplication away
np.dot(images_X[:1]-images_X.mean(axis=0), pca.components_.T)
array([[ 0.61090568, 1.36377972, 0.42170385, -2.19662828, -0.45181077, -1.320495 , 0.79434677, 0.30551126, 1.22978985, -0.72096767,
从 MNIST 中提取 RBM 组件
现在我们将在 scikit-learn 中创建我们的第一个 RBM。我们将首先实例化一个模块,从我们的MNIST数据集中提取 100 个组件。
我们还将设置verbose参数为True,以便我们可以看到训练过程,以及将random_state参数设置为0。random_state参数是一个整数,它允许代码的可重复性。它固定了随机数生成器,并在每次同时随机设置权重和偏差。我们最后将n_iter设置为20。这是我们希望进行的迭代次数,即网络的来回传递次数:
# instantiate our BernoulliRBM
# we set a random_state to initialize our weights and biases to the same starting point
# verbose is set to True to see the fitting period
# n_iter is the number of back and forth passes
# n_components (like PCA and LDA) represent the number of features to create
# n_components can be any integer, less than , equal to, or greater than the original number of features
rbm = BernoulliRBM(random_state=0, verbose=True, n_iter=20, n_components=100)
rbm.fit(images_X)
[BernoulliRBM] Iteration 1, pseudo-likelihood = -138.59, time = 0.80s
[BernoulliRBM] Iteration 2, pseudo-likelihood = -120.25, time = 0.85s [BernoulliRBM] Iteration 3, pseudo-likelihood = -116.46, time = 0.85s ... [BernoulliRBM] Iteration 18, pseudo-likelihood = -101.90, time = 0.96s [BernoulliRBM] Iteration 19, pseudo-likelihood = -109.99, time = 0.89s [BernoulliRBM] Iteration 20, pseudo-likelihood = -103.00, time = 0.89s
一旦训练完成,我们可以探索过程的最终结果。RBM 也有一个components模块,就像 PCA 一样:
# RBM also has components_ attribute
len(rbm.components_)
100
我们还可以绘制模块学习到的 RBM 组件,以查看它们与我们自己的特征数字有何不同:
# plot the RBM components (representations of the new feature sets)
plt.figure(figsize=(10, 10))
for i, comp in enumerate(rbm.components_):
plt.subplot(10, 10, i + 1)
plt.imshow(comp.reshape((28, 28)), cmap=plt.cm.gray_r)
plt.xticks(())
plt.yticks(())
plt.suptitle('100 components extracted by RBM', fontsize=16)
plt.show()
下面的代码是上述代码的结果:

这些特征看起来非常有趣。PCA 组件在一段时间后变成了视觉扭曲,而 RBM 组件似乎每个组件都在提取不同的形状和笔触。乍一看,我们似乎有重复的特征(例如,特征 15、63、64 和 70)。我们可以快速使用 NumPy 检查是否有任何组件实际上是重复的,或者它们只是非常相似。
这段代码将检查rbm.components_中存在多少唯一元素。如果结果形状中有 100 个元素,这意味着 RBM 的每个组件实际上都是不同的:
# It looks like many of these components are exactly the same but
# this shows that all components are actually different (albiet some very slightly) from one another
np.unique(rbm.components_.mean(axis=1)).shape
(100,)
这验证了我们的组件彼此之间都是独特的。我们可以使用 RBM 来转换数据,就像我们可以使用 PCA 一样,通过在模块中使用transform方法:
# Use our Boltzman Machine to transform a single image of a 5
image_new_features = rbm.transform(images_X[:1]).reshape(100,)
image_new_features
array([ 2.50169424e-16, 7.19295737e-16, 2.45862898e-09, 4.48783657e-01, 1.64530318e-16, 5.96184335e-15, 4.60051698e-20, 1.78646959e-08, 2.78104276e-23, ...
我们还可以看到,这些组件并不是像 PCA 那样被使用的,这意味着简单的矩阵乘法不会产生与调用模块内嵌入的transform方法相同的转换:
# not the same as a simple matrix multiplication anymore
# uses neural architecture (several matrix operations) to transform features
np.dot(images_X[:1]-images_X.mean(axis=0), rbm.components_.T)
array([[ -3.60557365, -10.30403384, -6.94375031, 14.10772267, -6.68343281, -5.72754674, -7.26618457, -26.32300164, ...
现在我们知道了我们有 100 个新特征可以工作,并且我们已经看到了它们,让我们看看它们如何与我们的数据互动。
让我们先从抓取数据集中第一张图像,即数字 5 的图像中,最常出现的 20 个特征开始:
# get the most represented features
top_features = image_new_features.argsort()[-20:][::-1]
print top_features
[56 63 62 14 69 83 82 49 92 29 21 45 15 34 28 94 18 3 79 58]
print image_new_features[top_features]
array([ 1\. , 1\. , 1\. , 1\. , 1\. , 1\. , 1\. , 0.99999999, 0.99999996, 0.99999981, 0.99996997, 0.99994894, 0.99990515, 0.9996504 , 0.91615702, 0.86480507, 0.80646422, 0.44878366, 0.02906352, 0.01457827])
在这种情况下,我们实际上有七个特征,其中 RBM 有 100%的覆盖率。在我们的图表中,这意味着将这些 784 个像素输入到可见层时,节点 56、63、62、14、69、83 和 82 会完全点亮。让我们隔离这些特征:
# plot the RBM components (representations of the new feature sets) for the most represented features
plt.figure(figsize=(25, 25))
for i, comp in enumerate(top_features):
plt.subplot(5, 4, i + 1)
plt.imshow(rbm.components_[comp].reshape((28, 28)), cmap=plt.cm.gray_r)
plt.title("Component {}, feature value: {}".format(comp, round(image_new_features[comp], 2)), fontsize=20)
plt.suptitle('Top 20 components extracted by RBM for first digit', fontsize=30)
plt.show()
我们得到了前面代码的以下结果:

看一下这些图表中的一些,它们非常有道理。组件 45似乎隔离了数字5的左上角,而组件 21似乎抓取了数字的底部环。组件 82和组件 34似乎一次性抓取了几乎整个数字 5。让我们通过隔离这些像素通过 RBM 图时亮起的底部 20 个特征,来看看数字 5 的底部桶状部分是什么样子:
# grab the least represented features
bottom_features = image_new_features.argsort()[:20]
plt.figure(figsize=(25, 25))
for i, comp in enumerate(bottom_features):
plt.subplot(5, 4, i + 1)
plt.imshow(rbm.components_[comp].reshape((28, 28)), cmap=plt.cm.gray_r)
plt.title("Component {}, feature value: {}".format(comp, round(image_new_features[comp], 2)), fontsize=20)
plt.suptitle('Bottom 20 components extracted by RBM for first digit', fontsize=30)
plt.show()
我们得到了前面代码的以下图表:

组件 13、组件 4、组件 97以及其他组件似乎试图揭示不同的数字而不是 5,因此这些组件没有被这种像素强度的组合点亮是有道理的。
在机器学习管道中使用 RBM
当然,我们想看看 RBM 在我们的机器学习管道中的表现,不仅是为了可视化模型的工作原理,还要看到特征学习的具体结果。为此,我们将创建并运行三个管道:
-
仅在原始像素强度上运行的逻辑回归模型
-
在提取的 PCA 组件上运行的逻辑回归
-
在提取的 RBM 组件上运行的逻辑回归
这些管道将跨多个组件(对于 PCA 和 RBM)以及逻辑回归的C参数进行网格搜索。让我们从最简单的管道开始。我们将运行原始像素值通过逻辑回归,看看线性模型是否足以分离出数字。
在原始像素值上使用线性模型
首先,我们将运行原始像素值通过逻辑回归模型,以获得某种基准模型。我们想看看利用 PCA 或 RBM 组件是否能让相同的线性分类器表现更好或更差。如果我们能发现提取的潜在特征表现更好(就线性模型的准确率而言),那么我们可以确信是我们在管道中使用的特征工程增强了我们的管道,而不是其他因素。
首先,我们将创建我们的实例化模块:
# import logistic regression and gridsearch module for some machine learning
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GridSearchCV
# create our logistic regression
lr = LogisticRegression()
params = {'C':[1e-2, 1e-1, 1e0, 1e1, 1e2]}
# instantiate a gridsearch class
grid = GridSearchCV(lr, params)
一旦我们完成这个步骤,我们就可以将我们的模块拟合到我们的原始图像数据中。这将给我们一个大致的了解,原始像素数据在机器学习管道中的表现:
# fit to our data
grid.fit(images_X, images_y)
# check the best params
grid.best_params_, grid.best_score_
({'C': 0.1}, 0.88749999999999996)
仅凭逻辑回归本身就能相当好地使用原始像素值来识别数字,给出了大约88.75%的交叉验证准确率。
在提取的 PCA 组件上使用线性模型
让我们看看我们是否可以在管道中添加一个 PCA 组件来提高这个准确性。我们将再次开始,设置我们的变量。这次我们需要创建一个 scikit-learn 管道对象,以容纳 PCA 模块以及我们的线性模型。我们将保持与线性分类器相同的参数,并为我们的 PCA 添加新参数。我们将尝试在 10、100 和 200 个组件之间找到最佳组件数量。试着花点时间,猜测这三个中哪一个最终会是最好的(提示:回想一下之前的 scree 图和解释方差):
# Use PCA to extract new features
lr = LogisticRegression()
pca = PCA()
# set the params for the pipeline
params = {'clf__C':[1e-1, 1e0, 1e1],
'pca__n_components': [10, 100, 200]}
# create our pipeline
pipeline = Pipeline([('pca', pca), ('clf', lr)])
# instantiate a gridsearh class
grid = GridSearchCV(pipeline, params)
现在我们可以将 gridsearch 对象拟合到我们的原始图像数据中。请注意,管道将自动提取并转换我们的原始像素数据:
# fit to our data
grid.fit(images_X, images_y)
# check the best params
grid.best_params_, grid.best_score_
({'clf__C': 1.0, 'pca__n_components': 100}, 0.88949999999999996)
我们最终得到了(略微更好的)88.95%交叉验证准确率。如果我们仔细想想,我们不应该对 100 是 10、100 和 200 中的最佳选择感到惊讶。从我们之前章节中的 scree 图简短分析中,我们发现 64%的数据仅由 30 个组件解释,所以 10 个组件肯定不足以很好地解释图像。scree 图也大约在 100 个组件时开始平缓,这意味着在 100 个组件之后,解释方差实际上并没有增加多少,所以 200 个组件太多,会导致一些过拟合。这使我们得出结论,100 个 PCA 组件是使用最佳的数量。应该注意的是,我们可以进一步尝试一些超参数调整,以找到更优的组件数量,但就目前而言,我们将保持我们的管道不变,并转向使用 RBM 组件。
在提取的 RBM 组件上使用线性模型
即使是最佳数量的 PCA 组件也无法在准确率方面大幅超越单独的逻辑回归。让我们看看我们的 RBM 表现如何。为了构建以下管道,我们将保持逻辑回归模型的相同参数,并在 10、100 和 200(就像我们在 PCA 管道中所做的那样)之间找到最佳组件数量。请注意,我们可以尝试将特征数量扩展到原始像素数(784)以上,但我们不会尝试这样做。
我们以同样的方式开始,设置我们的变量:
# Use the RBM to learn new features
rbm = BernoulliRBM(random_state=0)
# set up the params for our pipeline.
params = {'clf__C':[1e-1, 1e0, 1e1],
'rbm__n_components': [10, 100, 200]
}
# create our pipeline
pipeline = Pipeline([('rbm', rbm), ('clf', lr)])
# instantiate a gridsearch class
grid = GridSearchCV(pipeline, params)
将这个网格搜索拟合到我们的原始像素上,将揭示最佳组件数量:
# fit to our data
grid.fit(images_X, images_y)
# check the best params
grid.best_params_, grid.best_score_
({'clf__C': 1.0, 'rbm__n_components': 200}, 0.91766666666666663)
我们的 RBM 模块,具有91.75%的交叉验证准确率,能够从我们的数字中提取 200 个新特征,并通过在我们的管道中添加 BernoulliRBM 模块,在不做任何其他事情的情况下,将准确率提高了三个百分点(这是一个很大的提升!)。
200 是最佳组件数量的事实表明,我们甚至可以通过尝试提取超过 200 个组件来获得更高的性能。我们将把这个留作读者的练习。
这证明了特征学习算法在处理非常复杂任务(如图像识别、音频处理和自然语言处理)时工作得非常好。这些大型且有趣的数据集具有隐藏的组件,这些组件对于 PCA 或 LDA 等线性变换来说很难提取,但对于 RBM 等非参数算法来说却可以。
学习文本特征 – 词向量化
我们的第二个特征学习示例将远离图像,转向文本和自然语言处理。当机器学习读写时,它们面临一个非常大的问题,即上下文。在前面的章节中,我们已经能够通过计算每个文档中出现的单词数量来向量化文档,并将这些向量输入到机器学习管道中。通过构建新的基于计数的特征,我们能够在我们的监督机器学习管道中使用文本。这非常有效,直到某个点。我们局限于只能将文本理解为一个词袋模型(BOW)。这意味着我们将文档视为仅仅是无序单词的集合。
更重要的是,每个单词本身并没有意义。只有在一个由其他单词组成的集合中,文档在使用CountVectorizer和TfidfVectorizer等模块时才能具有意义。正因为如此,我们将把注意力从 scikit-learn 转向一个名为gensim的模块,用于计算词嵌入。
词嵌入
到目前为止,我们使用 scikit-learn 将文档(推文、评论、URL 等)嵌入到向量格式中,将标记(单词、n-gram)视为特征,并将文档视为具有一定数量的这些标记。例如,如果我们有 1,583 个文档,并告诉我们的CountVectorizer从ngram_range的一到五学习前 1,000 个标记,我们最终会得到一个形状为(1583, 1000)的矩阵,其中每一行代表一个单独的文档,而 1,000 列代表在语料库中找到的原始 n-gram。但如何达到更低的理解层次?我们如何开始教机器在上下文中理解单词的含义?
例如,如果我们问你以下问题,你可能会给出以下答案:
问:如果我们取一个国王,去掉它的男性特征,并用女性来替换,我们会得到什么?
答:王后
问:伦敦对英格兰就像巴黎对 ____。
答:法国
你,作为一个人类,可能会觉得这些问题很简单,但如果没有知道单词在上下文中的含义,机器如何解决这个问题呢?这实际上是我们面临的最大挑战之一,在自然语言处理(NLP)任务中。
词嵌入是帮助机器理解上下文的一种方法。词嵌入是将单个词在 n 维特征空间中的向量表示,其中n代表一个词可能具有的潜在特征数量。这意味着我们词汇表中的每个词不再仅仅是字符串,而本身就是一个向量。例如,如果我们从每个词中提取了 n=5 个特征,那么我们词汇表中的每个词就会对应一个 1 x 5 的向量。例如,我们可能会有以下向量表示:
# set some fake word embeddings
king = np.array([.2, -.5, .7, .2, -.9])
man = np.array([-.5, .2, -.2, .3, 0.])
woman = np.array([.7, -.3, .3, .6, .1])
queen = np.array([ 1.4, -1\. , 1.2, 0.5, -0.8])
利用这些向量表示,我们可以通过以下操作来处理问题“如果我们取一个国王,去掉它的男性特征,并用女性来替换,我们会得到什么?”:
king - man + woman
在代码中,这看起来会是这样:
np.array_equal((king - man + woman), queen)
True
这看起来很简单,但有一些注意事项:
-
上下文(以词嵌入的形式)和词义一样,从语料库到语料库都在变化。这意味着静态的词嵌入本身并不总是最有用的
-
词嵌入依赖于它们从中学习到的语料库
词嵌入允许我们对单个词进行非常精确的计算,以达到我们可能认为的上下文。
两种词嵌入方法 - Word2vec 和 GloVe
有两种算法家族主导着词嵌入的空间。它们被称为 Word2vec 和 GloVe。这两种方法都通过从非常大的语料库(文本文档集合)中学习来生成词嵌入。这两种算法之间的主要区别是,GloVe 算法(来自斯坦福大学)通过一系列矩阵统计来学习词嵌入,而 Word2vec(来自谷歌)通过深度学习方法来学习。这两种算法都有其优点,我们的文本将专注于使用 Word2vec 算法来学习词嵌入。
Word2Vec - 另一个浅层神经网络
为了学习和提取词嵌入,Word2vec 将实现另一个浅层神经网络。这次,我们不会将新数据通用地投入可见层,而是会故意放入正确数据以获得正确的词嵌入。不深入细节的话,想象一个具有以下结构的神经网络架构:

与 RBM 类似,我们有一个可见输入层和一个隐藏层。在这种情况下,我们的输入层节点数量与我们要学习的词汇长度相同。如果我们有一个包含数百万词汇的语料库,但只想学习其中的一小部分,这会非常有用。在前面的图中,我们将学习 5,000 个词汇的上下文。这个图中的隐藏层代表我们希望了解的每个词汇的特征数量。在这种情况下,我们将词汇嵌入到 300 维的空间中。
与我们用于 RBM 的神经网络相比,这个神经网络的主要区别在于存在一个输出层。请注意,在我们的图中,输出层的节点数量与输入层相同。这并非巧合。词嵌入模型通过基于参考词的存在来预测附近的词汇。例如,如果我们想要预测单词 calculus,我们希望最终的 math 节点被点亮得最亮。这给人一种监督机器学习算法的表象。
我们随后在这个结构上训练图,并最终通过输入单词的单热向量并提取隐藏层的输出向量来学习 300 维的词表示。在生产环境中,由于输出节点数量非常大,之前的图示非常低效。为了使这个过程在计算上更加高效,利用文本结构的不同损失函数。
用于创建 Word2vec 嵌入的 gensim 包
我们不会实现一个完整的、执行词嵌入过程的神经网络,然而我们将使用一个名为 gensim 的 Python 包来为我们完成这项工作:
# import the gensim package
import gensim
gensim可以接受一个文本语料库,并为我们运行前面的神经网络结构,仅用几行代码就获得单词嵌入。为了看到这个动作,让我们导入一个标准语料库以开始。让我们在我们的笔记本中设置一个记录器,以便我们可以更详细地看到训练过程:
import logging
logging.basicConfig(format='%(asctime)s : %(levelname)s : %(message)s', level=logging.INFO)
现在,让我们创建我们的语料库:
from gensim.models import word2vec, Word2Vec
sentences = word2vec.Text8Corpus('../data/text8')
注意术语word2vec。这是一个用于计算单词嵌入的特定算法,也是gensim使用的主要算法。它是单词嵌入的标准之一。
为了让gensim完成其工作,句子需要是任何可迭代的(列表、生成器、元组等),它包含已经分词的句子。一旦我们有了这样的变量,我们就可以通过学习单词嵌入来使用gensim:
# instantiate a gensim module on the sentences from above
# min_count allows us to ignore words that occur strictly less than this value
# size is the dimension of words we wish to learn
model = gensim.models.Word2Vec(sentences, min_count=1, size=20)
2017-12-29 16:43:25,133 : INFO : collecting all words and their counts
2017-12-29 16:43:25,136 : INFO : PROGRESS: at sentence #0, processed 0 words, keeping 0 word types
2017-12-29 16:43:31,074 : INFO : collected 253854 word types from a corpus of 17005207 raw words and 1701 sentences
2017-12-29 16:43:31,075 : INFO : Loading a fresh vocabulary
2017-12-29 16:43:31,990 : INFO : min_count=1 retains 253854 unique words (100% of original 253854, drops 0)
2017-12-29 16:43:31,991 : INFO : min_count=1 leaves 17005207 word corpus (100% of original 17005207, drops 0)
2017-12-29 16:43:32,668 : INFO : deleting the raw counts dictionary of 253854 items
2017-12-29 16:43:32,676 : INFO : sample=0.001 downsamples 36 most-common words
2017-12-29 16:43:32,678 : INFO : downsampling leaves estimated 12819131 word corpus (75.4% of prior 17005207)
2017-12-29 16:43:32,679 : INFO : estimated required memory for 253854 words and 20 dimensions: 167543640 bytes
2017-12-29 16:43:33,431 : INFO : resetting layer weights
2017-12-29 16:43:36,097 : INFO : training model with 3 workers on 253854 vocabulary and 20 features, using sg=0 hs=0 sample=0.001 negative=5 window=5
2017-12-29 16:43:37,102 : INFO : PROGRESS: at 1.32% examples, 837067 words/s, in_qsize 5, out_qsize 0
2017-12-29 16:43:38,107 : INFO : PROGRESS: at 2.61% examples, 828701 words/s,
... 2017-12-29 16:44:53,508 : INFO : PROGRESS: at 98.21% examples, 813353 words/s, in_qsize 6, out_qsize 0 2017-12-29 16:44:54,513 : INFO : PROGRESS: at 99.58% examples, 813962 words/s, in_qsize 4, out_qsize 0
... 2017-12-29 16:44:54,829 : INFO : training on 85026035 raw words (64096185 effective words) took 78.7s, 814121 effective words/s
这行代码将启动学习过程。如果你传递了一个大的语料库,这可能需要一段时间。现在gensim模块已经完成拟合,我们可以使用它。我们可以通过将字符串传递给word2vec对象来获取单个嵌入:
# get the vectorization of a word
model.wv['king']
array([-0.48768288, 0.66667134, 2.33743191, 2.71835423, 4.17330408, 2.30985498, 1.92848825, 1.43448424, 3.91518641, -0.01281452, 3.82612252, 0.60087812, 6.15167284, 4.70150518, -1.65476751, 4.85853577, 3.45778084, 5.02583361, -2.98040175, 2.37563372], dtype=float32)
gensim内置了获取我们单词嵌入最大价值的方法。例如,为了回答关于king的问题,我们可以使用most_similar方法:
# woman + king - man = queen
model.wv.most_similar(positive=['woman', 'king'], negative=['man'], topn=10)
[(u'emperor', 0.8988120555877686), (u'prince', 0.87584388256073), (u'consul', 0.8575721979141235), (u'tsar', 0.8558996319770813), (u'constantine', 0.8515684604644775), (u'pope', 0.8496872782707214), (u'throne', 0.8495982885360718), (u'elector', 0.8379884362220764), (u'judah', 0.8376096487045288), (u'emperors', 0.8356839418411255)]
嗯,不幸的是,这并没有给出我们预期的答案:queen。让我们尝试Paris单词联想:
# London is to England as Paris is to ____
model.wv.most_similar(positive=['Paris', 'England'], negative=['London'], topn=1)
KeyError: "word 'Paris' not in vocabulary"
看起来单词Paris甚至没有被学习过,因为它没有出现在我们的语料库中。我们可以开始看到这个过程的局限性。我们的嵌入将与我们选择的语料库和用于计算这些嵌入的机器一样好。在我们的数据目录中,我们提供了一个包含 300 维的预训练词汇表,它跨越了在 Google 索引的网站上找到的 3,000,000 个单词。
让我们继续导入这些预训练的嵌入。我们可以通过使用gensim中的内置导入工具来完成这项工作:
# use a pretrained vocabulary with 3,000,000 words
import gensim
model = gensim.models.KeyedVectors.load_word2vec_format('../data/GoogleNews-vectors-negative300.bin', binary=True)
# 3,000,000 words in our vocab
len(model.wv.vocab)
3000000
这些嵌入是通过比我们家里任何机器都强大的机器训练的,并且持续了更长的时间。现在让我们尝试我们的单词问题:
# woman + king - man = queen
model.wv.most_similar(positive=['woman', 'king'], negative=['man'], topn=1)
[(u'queen', 0.7118192911148071)]
# London is to England as Paris is to ____
model.wv.most_similar(positive=['Paris', 'England'], negative=['London'], topn=1)
[(u'France', 0.6676377654075623)]
太棒了!看起来这些单词嵌入已经训练得足够好,可以让我们回答这些复杂的单词谜题。正如之前使用的那样,most_similar方法将返回词汇表中与提供的单词最相似的标记。positive列表中的单词是相互添加的向量,而negative列表中的单词是从结果向量中减去的。以下图表提供了我们如何使用单词向量提取意义的视觉表示:

在这里,我们以“king”的向量表示开始,并添加“woman”的概念(向量)。从那里,我们通过添加向量的负值来减去“man”向量,以获得点向量。这个向量与“queen”的向量表示最相似。这就是我们获得公式的途径:
king + woman - man = queen
gensim还有其他我们可以利用的方法,例如doesnt_match。这种方法会找出不属于单词列表的单词。它是通过隔离平均与其他单词最不相似的单词来做到这一点的。例如,如果我们给这个方法四个单词,其中三个是动物,另一个是植物,我们希望它能找出哪个不属于:
# Pick out the oddball word in a sentence
model.wv.doesnt_match("duck bear cat tree".split())
'tree'
该包还包括计算单个单词之间 0-1 相似度分数的方法,这些分数可以用来即时比较单词:
# grab a similarity score between 0 and 1
# the similarity between the words woman and man, pretty similar
model.wv.similarity('woman', 'man')
0.766401223
# similarity between the words tree and man, not very similar
model.wv.similarity('tree', 'man')
0.229374587
在这里,我们可以看到man比man与tree更相似。我们可以使用这些有用的方法来实现一些其他情况下无法实现的有用应用,例如单词嵌入:
单词嵌入的应用 - 信息检索
单词嵌入有无数的应用,其中之一是信息检索领域。当人类在搜索引擎中输入关键词和短语时,搜索引擎能够召回并展示与这些关键词完全匹配的特定文章/故事。例如,如果我们搜索关于狗的文章,我们会得到提到狗这个词的文章。但如果我们搜索“canine”这个词呢?基于 canines 是狗的事实,我们仍然应该期望看到关于狗的文章。让我们实现一个简单的信息检索系统来展示单词嵌入的力量。
让我们创建一个函数,尝试从我们的 gensim 包中提取单个单词的嵌入,如果这个查找失败则返回 None:
# helper function to try to grab embeddings for a word and returns None if that word is not found
def get_embedding(string):
try:
return model.wv[string]
except:
return None
现在,让我们创建三个文章标题,一个关于dog,一个关于cat,还有一个关于绝对nothing的干扰项:
# very original article titles
sentences = [
"this is about a dog",
"this is about a cat",
"this is about nothing"
]
目标是输入一个与dog或cat相似的参考词,并能够抓取更相关的标题。为此,我们首先将为每个句子创建一个 3 x 300 的向量矩阵。我们将通过取句子中每个单词的平均值,并使用得到的平均值向量作为整个句子向量化的估计。一旦我们有了每个句子的向量化,我们就可以通过取它们之间的点积来比较这些向量。最接近的向量是点积最大的那个:
# Zero matrix of shape (3,300)
vectorized_sentences = np.zeros((len(sentences),300))
# for every sentence
for i, sentence in enumerate(sentences):
# tokenize sentence into words
words = sentence.split(' ')
# embed whichever words that we can
embedded_words = [get_embedding(w) for w in words]
embedded_words = filter(lambda x:x is not None, embedded_words)
# Take a mean of the vectors to get an estimate vectorization of the sentence
vectorized_sentence = reduce(lambda x,y:x+y, embedded_words)/len(embedded_words)
# change the ith row (in place) to be the ith vectorization
vectorized_sentences[i:] = vectorized_sentence
vectorized_sentences.shape
(3, 300)
这里需要注意的一点是,我们正在创建文档(单词集合)的向量化,而不是考虑单词的顺序。这与使用CountVectorizer或TfidfVectorizer来获取基于计数的文本向量化相比有什么好处?gensim 方法试图将我们的文本投影到由单个单词的上下文学习到的潜在结构上,而 scikit-learn 向量器只能使用我们可用的词汇来创建我们的向量化。在这三个句子中,只有七个独特的单词:
this, is, about, a, dog, cat, nothing
因此,我们的 CountVectorizer 或 TfidfVectorizer 可以投射的最大形状是 (3, 7)。让我们尝试找到与单词 dog 最相关的句子:
# we want articles most similar to the reference word "dog"
reference_word = 'dog'
# take a dot product between the embedding of dof and our vectorized matrix
best_sentence_idx = np.dot(vectorized_sentences, get_embedding(reference_word)).argsort()[-1]
# output the most relevant sentence
sentences[best_sentence_idx]
'this is about a dog'
那个很简单。给定单词 dog,我们应该能够检索到关于 dog 的句子。如果我们输入单词 cat,这也应该是正确的:
reference_word = 'cat'
best_sentence_idx = np.dot(vectorized_sentences, get_embedding(reference_word)).argsort()[-1]
sentences[best_sentence_idx]
'this is about a cat'
现在,让我们尝试一个更难的例子。让我们输入单词 canine 和 tiger,看看我们是否能分别得到 dog 和 cat 的句子:
reference_word = 'canine'
best_sentence_idx = np.dot(vectorized_sentences, get_embedding(reference_word)).argsort()[-1]
print sentences[best_sentence_idx]
'this is about a dog'
reference_word = 'tiger'
best_sentence_idx = np.dot(vectorized_sentences, get_embedding(reference_word)).argsort()[-1]
print sentences[best_sentence_idx]
'this is about a cat'
让我们尝试一个稍微有趣一点的例子。以下是从 Sinan 的第一本书,《数据科学原理》中的章节标题:
# Chapter titles from Sinan's first book, "The Principles of Data Science
sentences = """How to Sound Like a Data Scientist
Types of Data
The Five Steps of Data Science
Basic Mathematics
A Gentle Introduction to Probability
Advanced Probability
Basic Statistics
Advanced Statistics
Communicating Data
Machine Learning Essentials
Beyond the Essentials
Case Studies """.split('\n')
这将给我们一个包含 12 个不同章节标题的列表,以便检索。那么目标将是使用参考词对章节进行排序并提供最相关的三个章节标题来阅读,给定主题。例如,如果我们要求我们的算法给我们与 math 相关的章节,我们可能会期望推荐关于基础数学、统计学和概率的章节。
让我们尝试看看哪些章节最适合阅读,给定人类输入。在我们这样做之前,让我们计算一个向量化的文档矩阵,就像我们之前对前三个句子所做的那样:
# Zero matrix of shape (3,300)
vectorized_sentences = np.zeros((len(sentences),300))
# for every sentence
for i, sentence in enumerate(sentences):
# tokenize sentence into words
words = sentence.split(' ')
# embed whichever words that we can
embedded_words = [get_embedding(w) for w in words]
embedded_words = filter(lambda x:x is not None, embedded_words)
# Take a mean of the vectors to get an estimate vectorization of the sentence
vectorized_sentence = reduce(lambda x,y:x+y, embedded_words)/len(embedded_words)
# change the ith row (in place) to be the ith vectorization
vectorized_sentences[i:] = vectorized_sentence
vectorized_sentences.shape
(12, 300)
现在,让我们找到与 math 最相关的章节:
# find chapters about math
reference_word = 'math'
best_sentence_idx = np.dot(vectorized_sentences, get_embedding(reference_word)).argsort()[-3:][::-1]
[sentences[b] for b in best_sentence_idx]
['Basic Mathematics', 'Basic Statistics', 'Advanced Probability ']
现在,假设我们正在做一个关于数据的演讲,并想知道哪些章节在这个领域最有帮助:
# which chapters are about giving talks about data
reference_word = 'talk'
best_sentence_idx = np.dot(vectorized_sentences, get_embedding(reference_word)).argsort()[-3:][::-1]
[sentences[b] for b in best_sentence_idx]
['Communicating Data ', 'How to Sound Like a Data Scientist', 'Case Studies ']
最后,哪些章节是关于 AI 的:
# which chapters are about AI
reference_word = 'AI'
best_sentence_idx = np.dot(vectorized_sentences, get_embedding(reference_word)).argsort()[-3:][::-1]
[sentences[b] for b in best_sentence_idx]
['Advanced Probability ', 'Advanced Statistics', 'Machine Learning Essentials']
我们可以看到如何使用词嵌入根据从文本宇宙中学习到的上下文检索文本形式的信息。
摘要
本章重点介绍了两种特征学习工具:RBM 和词嵌入过程。
这两个过程都利用了深度学习架构,基于原始数据学习新的特征集。这两种技术都利用了浅层网络来优化训练时间,并使用在拟合阶段学习到的权重和偏差来提取数据的潜在结构。
我们下一章将展示四个在公开互联网上收集的实时数据特征工程的例子,以及我们在这本书中学到的工具将如何帮助我们创建最佳的机器学习管道。
第八章:案例研究
本书已经经过了几种不同的特征工程算法,并且我们与许多不同的数据集进行了合作。在本章中,我们将通过几个案例研究来帮助你加深对书中所涵盖主题的理解。我们将从头到尾完成两个完整的案例研究,以进一步了解特征工程任务如何帮助我们为实际应用创建机器学习流程。对于每个案例研究,我们将进行以下步骤:
-
我们正在努力实现的应用
-
我们正在使用的数据
-
简要的探索性数据分析
-
设置我们的机器学习流程并收集指标
此外,我们还将探讨以下案例:
-
面部识别
-
预测酒店评论数据
让我们开始吧!
案例研究 1 - 面部识别
我们的第一项案例研究将是使用名为 scikit-learn 库中Wild数据集的Labeled Faces的流行数据集来预测图像数据的标签。该数据集被称为Olivetti Face数据集,它包含了名人的面部照片,并附有相应的标签。我们的任务是面部识别,这是一个监督机器学习模型,能够根据人脸图像预测人的名字。
面部识别的应用
图像处理和面部识别具有广泛的应用。从人群中的视频/图像中快速识别人的面部对于物理安全和大型社交媒体公司至关重要。像 Google 这样的搜索引擎,凭借其图像搜索功能,正在使用图像识别算法来匹配图像并量化相似度,直到我们可以上传某人的照片来获取该人的所有其他图像。
数据
让我们从加载我们的数据集和我们将用于绘制数据的几个其他导入语句开始。在 Jupyter 笔记本(iPython)中开始使用所有你将使用的导入语句是一种良好的做法。显然,你可能在进行工作的中途意识到你需要导入一个新的包;同样,为了保持组织有序,将它们放在你工作的开头是一个好主意。
以下代码块包含了我们将用于本案例研究的import语句。我们将利用示例中的每个导入,随着我们例子的展开,它们各自的作用将变得对你来说很清晰:
# the olivetti face dataset
from sklearn.datasets import fetch_lfw_people
# feature extraction modules
from sklearn.decomposition import PCA
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
# feature scaling module
from sklearn.preprocessing import StandardScaler
# standard python modules
from time import time
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline # this ensures that your plotting will show directly in your jupyter notebook
# scikit-learn model selection modules from sklearn.model_selection import train_test_split, GridSearchCV, cross_val_score
# metrics
from sklearn.metrics import classification_report, confusion_matrix, accuracy_score
# machine learning modules
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
现在,我们可以开始了!我们按以下步骤进行:
- 首先,让我们加载我们的数据集,看看我们正在处理什么。我们将使用 scikit-learn 内置的
fetch_flw_people函数:
lfw_people = fetch_lfw_people(min_faces_per_person=70, resize=0.4)
如您所见,我们有一些可选参数已经调用,特别是min_faces_per_person和resize。第一个参数将仅保留我们指定的人的最小数量的不同图片。我们将此设置为每人至少70张不同图片。resize参数是用于调整每个面部图片的缩放比例。
- 让我们检查图像数组以找到用于绘制图像的形状。我们可以使用以下代码来完成:
n_samples, h, w = lfw_people.images.shape
n_samples, h, w
(1288, 50, 37)
我们看到我们有1288个样本(图像),每个图像的高度为50像素,宽度为37像素。
- 现在,让我们设置我们的机器学习流程中的
X和y。我们将获取lfw_people对象的data属性:
# for machine learning we use the data directly (as relative pixel positions info is ignored by this model)
X = lfw_people.data
y = lfw_people.target
n_features = X.shape[1]
n_features
1850
n_features最终有1,850列的事实源于以下事实:

现在,我们可以看到我们数据的完整形状,如下所示:
X.shape
(1288, 1850)
一些数据探索
我们有 1,288 行和 1,850 列。为了进行一些简要的探索性分析,我们可以使用以下代码绘制其中一张图像:
# plot one of the faces
plt.imshow(X[0].reshape((h, w)), cmap=plt.cm.gray)
lfw_people.target_names[y[0]]
这将给我们以下标签:
'Hugo Chavez'
图像如下所示:

现在,让我们绘制应用缩放模块后的相同图像,如下所示:
plt.imshow(StandardScaler().fit_transform(X)[0].reshape((h, w)), cmap=plt.cm.gray)
lfw_people.target_names[y[0]]
这给我们以下输出:
'Hugo Chavez'
以下代码给出了以下图像:

在这里,您可以看到图像略有不同,面部周围有较暗的像素。现在,让我们设置预测的标签:
# the label to predict is the id of the person
target_names = lfw_people.target_names
n_classes = target_names.shape[0]
print "Total dataset size:"
print "n_samples: %d" % n_samples
print "n_features: %d" % n_features
print "n_classes: %d" % n_classes
这给我们以下输出:
Total dataset size:
n_samples: 1288
n_features: 1850
n_classes: 7
应用面部识别
现在,我们可以继续到将用于创建我们的面部识别模型的机器学习流程:
- 我们可以开始创建数据集中的
train、test和split,如下所示:
# let's split our dataset into training and testing
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=1)
- 我们准备好对我们的数据集执行主成分分析(PCA)。我们首先需要实例化一个
PCA,并在流程中应用 PCA 之前确保我们scale我们的数据。这可以按以下方式完成:
# instantiate the PCA module
pca = PCA(n_components=200, whiten=True)
# create a pipeline called preprocessing that will scale data and then apply PCA
preprocessing = Pipeline([('scale', StandardScaler()), ('pca', pca)])
- 现在,我们可以
fit我们的流程:
print "Extracting the top %d eigenfaces from %d faces" % (200, X_train.shape[0])
# fit the pipeline to the training set
preprocessing.fit(X_train)
# grab the PCA from the pipeline
extracted_pca = preprocessing.steps[1][1]
- 输出将是我们的打印语句:
Extracting the top 200 eigenfaces from 966 faces
- 让我们看看散点图:
# Scree Plot
plt.plot(np.cumsum(extracted_pca.explained_variance_ratio_))
我们可以看到,从 100 个组件开始,可以捕捉到超过 90%的方差,与原始的 1,850 个特征相比。
- 我们可以创建一个函数来绘制我们的 PCA 组件,如下所示:
comp = extracted_pca.components_
image_shape = (h, w)
def plot_gallery(title, images, n_col, n_row):
plt.figure(figsize=(2\. * n_col, 2.26 * n_row))
plt.suptitle(title, size=16)
for i, comp in enumerate(images):
plt.subplot(n_row, n_col, i + 1)
vmax = max(comp.max(), -comp.min())
plt.imshow(comp.reshape(image_shape), cmap=plt.cm.gray,
vmin=-vmax, vmax=vmax)
plt.xticks(())
plt.yticks(())
plt.subplots_adjust(0.01, 0.05, 0.99, 0.93, 0.04, 0.)
plt.show()
- 我们现在可以调用我们的
plot_gallery函数,如下所示:
plot_gallery('PCA components', comp[:16], 4,4)
输出给我们以下图像:

这让我们可以看到特定行和列的 PCA 组件!这些主成分脸是 PCA 模块找到的人类提取特征。与我们在第七章,“特征学习”中使用 PCA 提取主成分数字的结果进行比较。每个组件都旨在存储有关面部的重要信息,这些信息可以用来区分不同的人。例如:
-
第三行,第四列的主成分似乎在突出显示胡须和 beard 区域,以量化面部毛发在区分我们的类别中能起到多大帮助
-
第一行,第四列的主成分似乎显示了背景和面部之间的对比,给图像的照明情况赋予了一个数值
当然,这些都是我们的解读,对于不同的面部数据集,不同的主成分将输出不同的图像/组件。我们将继续创建一个函数,使我们能够清晰地打印出带有热标签和归一化选项的更易读的混淆矩阵:
import itertools
def plot_confusion_matrix(cm, classes,
normalize=False,
title='Confusion matrix',
cmap=plt.cm.Blues):
plt.imshow(cm, interpolation='nearest', cmap=cmap)
plt.title(title)
plt.colorbar()
tick_marks = np.arange(len(classes))
plt.xticks(tick_marks, classes, rotation=45)
plt.yticks(tick_marks, classes)
thresh = cm.max() / 2.
for i, j in itertools.product(range(cm.shape[0]), range(cm.shape[1])):
plt.text(j, i, cm[i, j],
horizontalalignment="center",
color="white" if cm[i, j] > thresh else "black")
plt.ylabel('True label')
plt.xlabel('Predicted label')
现在,我们可以不使用 PCA 进行拟合,看看会有什么不同。我们将调用我们的plot_confusion_matrix函数,以便我们可以可视化我们模型的准确率:
# fit without using PCA to see what the difference will be
t0 = time()
param_grid = {'C': [1e-2, 1e-1,1e0,1e1, 1e2]}
clf = GridSearchCV(logreg, param_grid)
clf = clf.fit(X_train, y_train)
best_clf = clf.best_estimator_
# Predicting people's names on the test set
y_pred = best_clf.predict(X_test)
print accuracy_score(y_pred, y_test), "Accuracy score for best estimator"
print(classification_report(y_test, y_pred, target_names=target_names))
print plot_confusion_matrix(confusion_matrix(y_test, y_pred, labels=range(n_classes)), target_names)
print round((time() - t0), 1), "seconds to grid search and predict the test set"
输出如下:
0.813664596273 Accuracy score for best estimator
precision recall f1-score support
Ariel Sharon 0.72 0.68 0.70 19
Colin Powell 0.85 0.71 0.77 55
Donald Rumsfeld 0.62 0.72 0.67 25
George W Bush 0.88 0.91 0.89 142
Gerhard Schroeder 0.79 0.84 0.81 31
Hugo Chavez 0.87 0.81 0.84 16
Tony Blair 0.71 0.71 0.71 34
avg / total 0.82 0.81 0.81 322
None
39.9 seconds to grid search and predict the test set
我们得到的图如下:

仅使用原始像素,我们的线性模型能够达到81.3%的准确率。这次,让我们应用 PCA 来看看会有什么不同。我们将硬编码要提取的组件数量为 200:
t0 = time()
face_pipeline = Pipeline(steps=[('PCA', PCA(n_components=200)), ('logistic', logreg)])
pipe_param_grid = {'logistic__C': [1e-2, 1e-1,1e0,1e1, 1e2]}
clf = GridSearchCV(face_pipeline, pipe_param_grid)
clf = clf.fit(X_train, y_train)
best_clf = clf.best_estimator_
# Predicting people's names on the test set
y_pred = best_clf.predict(X_test)
print accuracy_score(y_pred, y_test), "Accuracy score for best estimator"
print(classification_report(y_test, y_pred, target_names=target_names))
print plot_confusion_matrix(confusion_matrix(y_test, y_pred, labels=range(n_classes)), target_names)
print round((time() - t0), 1), "seconds to grid search and predict the test set"
使用 PCA 的输出看起来如下:
0.739130434783 Accuracy score for best estimator
precision recall f1-score support
Ariel Sharon 0.67 0.63 0.65 19
Colin Powell 0.69 0.60 0.64 55
Donald Rumsfeld 0.74 0.68 0.71 25
George W Bush 0.76 0.88 0.82 142
Gerhard Schroeder 0.77 0.77 0.77 31
Hugo Chavez 0.62 0.62 0.62 16
Tony Blair 0.77 0.50 0.61 34
avg / total 0.74 0.74 0.73 322
None
74.5 seconds to grid search and predict the test set
我们得到的图如下:

真是令人惊讶!我们可以看到,通过应用主成分分析(PCA),我们的准确率下降到了73.9%,而预测时间有所增加。然而,我们不应该气馁;这很可能意味着我们还没有找到使用最佳组件数量的方法。
让我们绘制测试集中一些预测名称与真实名称的对比图,以查看我们模型产生的某些错误/正确标签:

这是在处理图像时可视化结果的一个很好的方法。
现在,让我们实现一个网格搜索来找到我们数据最佳模型和准确率。首先,我们将创建一个函数,该函数将为我们执行网格搜索并清晰地打印出准确率、参数、平均拟合时间和平均评分时间。这个函数创建如下:
def get_best_model_and_accuracy(model, params, X, y):
grid = GridSearchCV(model, # the model to grid search
params, # the parameter set to try
error_score=0.) # if a parameter set raises an error, continue and set the performance as a big, fat 0
grid.fit(X, y) # fit the model and parameters
# our classical metric for performance
print "Best Accuracy: {}".format(grid.best_score_)
# the best parameters that caused the best accuracy
print "Best Parameters: {}".format(grid.best_params_)
# the average time it took a model to fit to the data (in seconds)
print "Average Time to Fit (s): {}".format(round(grid.cv_results_['mean_fit_time'].mean(), 3))
# the average time it took a model to predict out of sample data (in seconds)
# this metric gives us insight into how this model will perform in real-time analysis
print "Average Time to Score (s): {}".format(round(grid.cv_results_['mean_score_time'].mean(), 3))
现在,我们可以创建一个更大的网格搜索管道,包括许多更多的组件,具体如下:
-
一个缩放模块
-
一个 PCA 模块,用于提取捕捉数据变异性最佳的特征
-
一个线性判别分析(LDA)模块,用于创建最佳特征,以区分彼此的面部
-
我们的线性分类器,将利用我们三个特征工程模块的优势,并尝试区分我们的面部
创建大型网格搜索管道的代码如下:
# Create a larger pipeline to gridsearch
face_params = {'logistic__C':[1e-2, 1e-1, 1e0, 1e1, 1e2],
'preprocessing__pca__n_components':[100, 150, 200, 250, 300],
'preprocessing__pca__whiten':[True, False],
'preprocessing__lda__n_components':range(1, 7)
# [1, 2, 3, 4, 5, 6] recall the max allowed is n_classes-1
}
pca = PCA()
lda = LinearDiscriminantAnalysis()
preprocessing = Pipeline([('scale', StandardScaler()), ('pca', pca), ('lda', lda)])
logreg = LogisticRegression()
face_pipeline = Pipeline(steps=[('preprocessing', preprocessing), ('logistic', logreg)])
get_best_model_and_accuracy(face_pipeline, face_params, X, y)
这里是结果:
Best Accuracy: 0.840062111801
Best Parameters: {'logistic__C': 0.1, 'preprocessing__pca__n_components': 150, 'preprocessing__lda__n_components': 5, 'preprocessing__pca__whiten': False}
Average Time to Fit (s): 0.214
Average Time to Score (s): 0.009
我们可以看到,我们的模型准确率有显著提高,而且预测和训练时间非常快!
案例研究 2 - 预测酒店评论数据的主题
我们的第二个案例研究将研究酒店评论数据,并尝试将评论聚类到主题中。我们将使用潜在语义分析(LSA),这是一种在稀疏文本文档—词矩阵上应用 PCA 的过程。这是为了在文本中找到潜在结构,以便进行分类和聚类。
文本聚类的应用
文本聚类是将不同的主题分配给文本片段的行为,目的是为了理解文档的内容。想象一下,一家大型酒店连锁店每周都会收到来自世界各地的数千条评论。酒店的员工希望了解人们都在说什么,以便更好地了解他们做得好的地方和需要改进的地方。
当然,这里的限制因素是人类快速且正确地阅读所有这些文本的能力。我们可以训练机器识别人们谈论的事物类型,然后预测新评论和即将到来的评论的主题,以自动化这一过程。
酒店评论数据
我们将使用来自 Kaggle 的数据集来实现这一结果,可以在以下链接找到:www.kaggle.com/datafiniti/hotel-reviews。它包含来自世界各地 1,000 家不同酒店的超过 35,000 条独特的评论。我们的工作将是隔离评论的文本,并识别主题(人们谈论的内容)。然后,我们将创建一个机器学习模型,可以预测/识别即将到来的评论的主题:
首先,让我们组织我们的导入语句,如下所示:
# used for row normalization
from sklearn.preprocessing import Normalizer
# scikit-learn's KMeans clustering module
from sklearn.cluster import KMeans
# data manipulation tool
import pandas as pd
# import a sentence tokenizer from nltk
from nltk.tokenize import sent_tokenize
# feature extraction module (TruncatedSVD will be explained soon)
from sklearn.decomposition import PCA from sklearn.decomposition import TruncatedSVD
现在,让我们加载我们的数据,如下面的代码片段所示:
hotel_reviews = pd.read_csv('../data/7282_1.csv')
一旦我们导入了数据,让我们来看看我们的原始文本数据是什么样的。
数据探索
让我们看看我们数据集的shape:
hotel_reviews.shape
(35912, 19)
这表明我们正在处理 35,912 行和 19 列的数据。最终,我们只会关注包含文本数据的列,但就目前而言,让我们看看前几行看起来是什么样子,以便更好地了解我们的数据中包含的内容:
hotel_reviews.head()
这给我们以下表格:
| 地址 | 类别 | 城市 | 国家 | 纬度 | 经度 | 名称 | 邮政编码 | 省份 | 评论日期 | 评论添加日期 | 推荐 | 评论 ID | 评分 | 评论内容 | 评论标题 | 评论者城市 | 评论者用户名 | 评论者省份 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | Riviera San Nicol 11/a | 酒店 | Mableton | 美国 | 45.421611 | 12.376187 | Hotel Russo Palace | 30126 | GA | 2013-09-22T00:00:00Z | 2016-10-24T00:00:25Z | NaN | NaN | 4.0 | 沿着海边的愉快 10 分钟步行 | 离人群远的好位置 | NaN | Russ (kent) | NaN |
| 1 | Riviera San Nicol 11/a | 酒店 | Mableton | 美国 | 45.421611 | 12.376187 | Hotel Russo Palace | 30126 | GA | 2015-04-03T00:00:00Z | 2016-10-24T00:00:25Z | NaN | NaN | 5.0 | 真的很棒的一家酒店。我们住在顶楼。… | 有按摩浴缸的酒店太棒了! | NaN | A Traveler | NaN |
| 2 | Riviera San Nicol 11/a | 酒店 | Mableton | 美国 | 45.421611 | 12.376187 | Hotel Russo Palace | 30126 | GA | 2014-05-13T00:00:00Z | 2016-10-24T00:00:25Z | NaN | NaN | 5.0 | 非常好的酒店。唯一降低评分的是… | 地点安静。 | NaN | Maud | NaN |
| 3 | Riviera San Nicol 11/a | 酒店 | Mableton | 美国 | 45.421611 | 12.376187 | Hotel Russo Palace | 30126 | GA | 2013-10-27T00:00:00Z | 2016-10-24T00:00:25Z | NaN | NaN | 5.0 | 我们在十月份在这里住了四晚。… | 位置很好,在 Lido 上。 | NaN | Julie | NaN |
| 4 | Riviera San Nicol 11/a | 酒店 | Mableton | 美国 | 45.421611 | 12.376187 | Hotel Russo Palace | 30126 | GA | 2015-03-05T00:00:00Z | 2016-10-24T00:00:25Z | NaN | NaN | 5.0 | 我们在十月份在这里住了四晚。… | ������ ��������������� | NaN | sungchul | NaN |
为了尝试只包括英语评论,让我们只包括来自美国的评论。首先,让我们像这样绘制我们的数据:
# plot the lats and longs of reviews
hotel_reviews.plot.scatter(x='longitude', y='latitude')
输出看起来像这样:

为了使我们的数据集更容易处理,让我们使用 pandas 来对评论进行子集化,只包括来自美国的评论:
# Filter to only include reviews within the US
hotel_reviews = hotel_reviews[((hotel_reviews['latitude']<=50.0) & (hotel_reviews['latitude']>=24.0)) & ((hotel_reviews['longitude']<=-65.0) & (hotel_reviews['longitude']>=-122.0))]
# Plot the lats and longs again
hotel_reviews.plot.scatter(x='longitude', y='latitude')
# Only looking at reviews that are coming from the US
输出如下:

它看起来像是美国的地图!现在让我们shape我们的过滤数据集:
hotel_reviews.shape
我们有 30,692 行和 19 列。当我们为酒店写评论时,我们通常在同一个评论中写关于不同的事情。因此,我们将尝试将主题分配给单个句子,而不是整个评论。
要做到这一点,让我们从我们的数据中获取文本列,像这样:
texts = hotel_reviews['reviews.text']
聚类模型
我们可以将文本分词成句子,这样就可以扩展我们的数据集。我们从 nltk(自然语言工具包)包中导入了一个名为 sent_tokenize 的函数。此函数将接受一个字符串并输出句子,作为由标点符号分隔的有序句子列表。例如:
sent_tokenize("hello! I am Sinan. How are you??? I am fine")
['hello!', 'I am Sinan.', 'How are you???', 'I am fine']
我们将使用 Python 中的某些 reduce 逻辑将此函数应用于整个语料库。本质上,我们正在将 sent_tokenize 函数应用于每个评论,创建一个名为 sentences 的单个列表,该列表将包含我们所有的句子:
sentences = reduce(lambda x, y:x+y, texts.apply(lambda x: sent_tokenize(str(x).decode('utf-8'))))
我们现在可以看到我们有多少句子:
# the number of sentences
len(sentences)
118151
这给我们带来了 118,151 — 我们要处理的句子数量。为了创建一个文档-词矩阵,让我们在我们的句子中使用 TfidfVectorizer:
from sklearn.feature_extraction.text import TfidfVectorizer
tfidf = TfidfVectorizer(ngram_range=(1, 2), stop_words='english')
tfidf_transformed = tfidf.fit_transform(sentences)
tfidf_transformed
我们得到以下结果:
<118151x280901 sparse matrix of type '<type 'numpy.float64'>'
with 1180273 stored elements in Compressed Sparse Row format>
现在,让我们尝试将 PCA 应用于这些数据,像这样:
# try to fit PCA
PCA(n_components=1000).fit(tfidf_transformed)
运行此代码后,我们得到以下错误:
TypeError: PCA does not support sparse input. See TruncatedSVD for a possible alternative.
这个错误告诉我们,对于 PCA,我们不能有稀疏的输入,并建议我们使用 TruncatedSVD。奇异值分解(SVD)是一个矩阵 技巧,用于计算(当数据居中时)与 PCA 相同的成分,使我们能够处理稀疏矩阵。让我们接受这个建议并使用 TruncatedSVD 模块。
SVD 与 PCA 成分比较
在我们继续使用我们的酒店数据之前,让我们对我们的 iris 数据进行一个快速实验,看看我们的 SVD 和 PCA 是否真的给出了相同的成分:
- 让我们从获取我们的 iris 数据并创建一个居中和缩放版本开始:
# import the Iris dataset from scikit-learn
from sklearn.datasets import load_iris
# load the Iris dataset
iris = load_iris()
# seperate the features and response variable
iris_X, iris_y = iris.data, iris.target
X_centered = StandardScaler(with_std=False).fit_transform(iris_X)
X_scaled = StandardScaler().fit_transform(iris_X)
- 让我们通过实例化一个
SVD和一个PCA对象来继续:
# test if we get the same components by using PCA and SVD
svd = TruncatedSVD(n_components=2)
pca = PCA(n_components=2)
- 现在,让我们将
SVD和PCA都应用于我们的原始iris数据、居中版本和缩放版本以进行比较:
# check if components of PCA and TruncatedSVD are same for a dataset
# by substracting the two matricies and seeing if, on average, the elements are very close to 0
print (pca.fit(iris_X).components_ - svd.fit(iris_X).components_).mean()
0.130183123094 # not close to 0
# matrices are NOT the same
# check if components of PCA and TruncatedSVD are same for a centered dataset
print (pca.fit(X_centered).components_ - svd.fit(X_centered).components_).mean()
1.73472347598e-18 # close to 0
# matrices ARE the same
# check if components of PCA and TruncatedSVD are same for a scaled dataset
print (pca.fit(X_scaled).components_ - svd.fit(X_scaled).components_).mean()
-1.59160878921e-16 # close to 0
# matrices ARE the same
- 这表明,如果我们的数据是缩放的,SVD 模块将返回与 PCA 相同的成分,但在使用原始未缩放数据时,成分将不同。让我们继续使用我们的酒店数据:
svd = TruncatedSVD(n_components=1000)
svd.fit(tfidf_transformed)
输出如下:
TruncatedSVD(algorithm='randomized', n_components=1000, n_iter=5,
random_state=None, tol=0.0)
- 让我们制作一个类似于我们 PCA 模块的 scree 图,以查看我们的 SVD 成分的解释方差:
# Scree Plot
plt.plot(np.cumsum(svd.explained_variance_ratio_))
这给我们以下图表:

我们可以看到,1000 个成分捕捉了大约 30% 的方差。现在,让我们设置我们的 LSA 管道。
潜在语义分析
潜在语义分析(LSA)是一个特征提取工具。它对于文本是一系列这三个步骤的情况很有帮助,这些步骤我们已经在本书中学过了:
-
tfidf 向量化
-
PCA(在这种情况下使用 SVD 以处理文本的稀疏性)
-
行归一化
我们可以创建一个 scikit-learn 管道来执行 LSA:
tfidf = TfidfVectorizer(ngram_range=(1, 2), stop_words='english')
svd = TruncatedSVD(n_components=10) # will extract 10 "topics"
normalizer = Normalizer() # will give each document a unit norm
lsa = Pipeline(steps=[('tfidf', tfidf), ('svd', svd), ('normalizer', normalizer)])
现在,我们可以拟合和转换我们的句子数据,如下所示:
lsa_sentences = lsa.fit_transform(sentences)
lsa_sentences.shape
(118151, 10)
我们有 118151 行和 10 列。这 10 列来自 10 个提取的 PCA/SVD 成分。我们现在可以对我们的 lsa_sentences 应用 KMeans 聚类,如下所示:
cluster = KMeans(n_clusters=10)
cluster.fit(lsa_sentences)
我们假设读者对聚类有基本的了解。有关聚类和聚类如何工作的更多信息,请参阅 Packt 的《数据科学原理》:www.packtpub.com/big-data-and-business-intelligence/principles-data-science
应该注意的是,我们选择了 KMeans 和我们的 PCA 中的 10。这不是必要的。通常,你可能在 SVD 模块中提取更多的列。有了这 10 个簇,我们在这里基本上是在说,我认为有 10 个主题是人们正在讨论的。请将每个句子分配给这些主题之一。
输出如下:
KMeans(algorithm='auto', copy_x=True, init='k-means++', max_iter=300,
n_clusters=10, n_init=10, n_jobs=1, precompute_distances='auto',
random_state=None, tol=0.0001, verbose=0)
让我们为我们的原始文档-词矩阵(形状为 118151, 280901)和我们的潜在语义分析(形状为 118151, 10)计时,以查看差异:
- 首先,原始数据集:
%%timeit
# time it takes to cluster on the original document-term matrix of shape (118151, 280901)
cluster.fit(tfidf_transformed)
这给我们:
1 loop, best of 3: 4min 15s per loop
- 我们还将计时
Kmeans的预测:
%%timeit
# also time the prediction phase of the Kmeans clustering
cluster.predict(tfidf_transformed)
这给我们:
10 loops, best of 3: 120 ms per loop
- 现在,LSA:
%%timeit
# time the time to cluster after latent semantic analysis of shape (118151, 10)
cluster.fit(lsa_sentences)
这给我们:
1 loop, best of 3: 3.6 s per loop
- 我们可以看到,LSA 在拟合原始 tfidf 数据集上比原来快了 80 多倍。假设我们用 LSA 来预测聚类的耗时,如下所示:
%%timeit
# also time the prediction phase of the Kmeans clustering after LSA was performed
cluster.predict(lsa_sentences)
这给我们:
10 loops, best of 3: 34 ms per loop
我们可以看到,LSA 数据集在预测上比在原始 tfidf 数据集上快了四倍以上。
- 现在,让我们将文本转换为一个聚类距离空间,其中每一行代表一个观察结果,如下所示:
cluster.transform(lsa_sentences).shape
(118151, 10)
predicted_cluster = cluster.predict(lsa_sentences)
predicted_cluster
输出给我们:
array([2, 2, 2, ..., 2, 2, 6], dtype=int32)
- 现在,我们可以得到主题的分布,如下所示:
# Distribution of "topics"
pd.Series(predicted_cluster).value_counts(normalize=True)# create DataFrame of texts and predicted topics
texts_df = pd.DataFrame({'text':sentences, 'topic':predicted_cluster})
texts_df.head()
print "Top terms per cluster:"
original_space_centroids = svd.inverse_transform(cluster.cluster_centers_)
order_centroids = original_space_centroids.argsort()[:, ::-1]
terms = lsa.steps[0][1].get_feature_names()
for i in range(10):
print "Cluster %d:" % i
print ', '.join([terms[ind] for ind in order_centroids[i, :5]])
print
lsa.steps[0][1]
- 这给我们每个主题一个包含最有趣短语(根据我们的
TfidfVectorizer)的列表:
Top terms per cluster:
Cluster 0:
good, breakfast, breakfast good, room, great
Cluster 1:
hotel, recommend, good, recommend hotel, nice hotel
Cluster 2:
clean, room clean, rooms, clean comfortable, comfortable
Cluster 3:
room, room clean, hotel, nice, good
Cluster 4:
great, location, breakfast, hotel, stay
Cluster 5:
stay, hotel, good, enjoyed stay, enjoyed
Cluster 6:
comfortable, bed, clean comfortable, bed comfortable, room
Cluster 7:
nice, room, hotel, staff, nice hotel
Cluster 8:
hotel, room, good, great, stay
Cluster 9:
staff, friendly, staff friendly, helpful, friendly helpful
我们可以看到每个聚类的顶级术语,其中一些非常有意义。例如,聚类 1 似乎是在讨论人们如何向家人和朋友推荐这家酒店,而聚类 9 则是关于员工以及他们如何友好和乐于助人。为了完成这个应用,我们希望能够用主题来预测新的评论。
现在,我们可以尝试预测一条新评论的聚类,如下所示:
# topic prediction
print cluster.predict(lsa.transform(['I definitely recommend this hotel']))
print cluster.predict(lsa.transform(['super friendly staff. Love it!']))
输出给我们第一个预测的聚类 1 和第二个预测的聚类 9,如下所示:
[1]
[9]
太棒了!Cluster 1 对应以下内容:
Cluster 1:
hotel, recommend, good, recommend hotel, nice hotel
Cluster 9 对应以下内容:
Cluster 9:
staff, friendly, staff friendly, helpful, friendly helpful
看起来 Cluster 1 是在推荐酒店,而 Cluster 9 则更侧重于员工。我们的预测似乎相当准确!
摘要
在本章中,我们看到了两个来自截然不同领域的不同案例研究,使用了本书中学到的许多特征工程方法。
我们确实希望您觉得这本书的内容有趣,并且会继续您的学习!我们将探索特征工程、机器学习和数据科学的世界留给您。希望这本书能成为您进一步学习的催化剂,去学习更多关于这个主题的知识。
在阅读完这本书之后,我强烈推荐查阅一些知名的数据科学书籍和博客,例如:
-
Sinan Ozdemir 著的数据科学原理,可在 Packt 购买:
www.packtpub.com/big-data-and-business-intelligence/principles-data-science -
机器学习和人工智能博客,KD-nuggets (
www.kdnuggets.com/)


计算特征值和特征向量
浙公网安备 33010602011771号