Python-真实世界的数据科学-全-
Python 真实世界的数据科学(全)
零、课程路线图和时间表
在开始之前,这是整个课程计划的视图。 该网格为您提供了整个课程及其模块的主题概述,因此您可以看到我们将如何在学习使用 Python 的特定阶段过渡,您将在学习过程中学习哪些技能以及如何使用这些技能 每一点的技能。 我还为您提供了每个模块可能需要的时间估计,尽管这在很大程度上取决于您的学习方式,您每周能提供多少课程!

一、简介和第一步——深呼吸
“你交给某人一个程序,你将折磨他一整天;你教某人如何编写程序,你将折磨他一辈子。”
——程序员谚语
根据维基百科,计算机编程 为:
“ ...从计算问题的原始表述到可执行计算机程序的过程。编程涉及诸如分析,发展理解,生成算法,验证算法要求(包括其正确性和资源消耗)的活动,以及 目标编程语言实现算法(通常称为编码)”。
简而言之,编码就是告诉计算机使用它理解的语言来执行某项操作。
计算机是非常强大的工具,但不幸的是,它们无法自己思考。 因此,他们需要被告知一切。 他们需要被告知如何执行任务,如何评估条件以决定要遵循的路径,如何处理来自网络或磁盘等设备的数据,以及在发生意外情况时如何应对。 ,有东西损坏或丢失。
您可以使用许多不同的样式和语言进行编码。 难吗? 我会说“是”和“否”。 有点像写作。 每个人都可以学习如何写作,您也可以。 但是,如果您想成为一名诗人怎么办? 仅仅写作是不够的。 您必须掌握其他全部技能,这将需要更长的时间和更大的努力。
最后,一切都取决于您要走多远。 编码不仅仅是将一些有效的指令组合在一起。 不仅如此!
好的代码简短,快速,优美,易于阅读和理解,简单,易于修改和扩展,易于缩放和重构以及易于测试。 能够同时编写具有所有这些特性的代码需要花费一些时间,但是好消息是,您现在正在阅读此模块,迈出了第一步。 我毫不怀疑您可以做到。 实际上,任何人都可以一直编程,只是我们不知道它。
您想举个例子吗?
假设您要煮速溶咖啡。 您必须得到一个杯子,速溶咖啡罐,茶匙,水和水壶。 即使您不知道它,您也在评估大量数据。 您要确保水壶中有水,并且水壶已插入电源,杯子是干净的,并且罐中有足够的咖啡。 然后,您将水烧开,同时在杯子中倒入咖啡。 准备好水后,将其倒入杯子中并搅拌。
那么,如何编程?
好吧,我们收集了资源(水壶,咖啡,水,茶匙和杯子),并验证了其中的一些条件(水壶已插入,杯子很干净,有足够的咖啡)。 然后,我们开始执行两个操作(将水煮沸并将咖啡倒入杯子中),当两个动作都完成时,我们最终通过将水倒入杯子中并搅拌来结束该过程。
你能看见它吗? 我刚刚描述了咖啡程序的高级功能。 这并不难,因为这是大脑整天要做的事情:评估条件,决定采取行动,执行任务,重复其中的某些任务并在某个时刻停止。 清洁物体,放回它们,依此类推。
您现在所要做的就是学习如何解构在现实生活中自动执行的所有这些操作,以便计算机可以真正理解它们。 而且您还需要学习一种语言来进行指导。
这就是本模块的用途。 我将告诉您如何执行此操作,并通过许多简单但重点突出的示例(我最喜欢的示例)来尝试执行此操作。
适当的介绍
当我教编码时,我喜欢参考现实世界。 我相信它们可以帮助人们更好地保留这些概念。 但是,现在该变得更加严格了,从更技术的角度来看什么是编码。
编写代码时,我们在指导计算机执行什么操作。 动作在哪里发生? 在许多地方:计算机内存,硬盘驱动器,网络电缆,CPU 等。 它是一个完整的“世界”,大部分时间是真实世界的子集的表示。
如果编写允许人们在线购买衣服的软件,则必须在程序的范围内代表真实的人,真实的衣服,真实的品牌,尺寸等等。
为此,您将需要在正在编写的程序中创建和处理对象。 一个人可以是一个对象。 汽车是物体。 一双袜子是一个对象。 幸运的是,Python 非常了解对象。
任何对象具有的两个主要功能是属性和方法。 让我们以一个人物对象为例。 通常,在计算机程序中,您会将人们表示为客户或雇员。 您针对他们存储的属性包括名称,SSN,年龄,是否具有驾驶执照,电子邮件,性别等。 在计算机程序中,您存储了为了将对象用于服务目的所需的所有数据。 如果您正在编写一个网站来销售服装,则可能要存储客户的身高和体重以及其他度量,以便为他们建议合适的服装。 因此,属性是对象的特征。 我们一直在使用它们:“你能把那支笔递给我吗?” - “哪一个?” - “黑色的那个。” 在这里,我们使用笔的“黑色”属性来识别它(最有可能在蓝色和红色之间)。
方法是对象可以做的事情。 作为一个人,我有说话,走路,睡眠,唤醒,吃等方法 ,梦,写,读等。 我可以做的所有事情都可以看作代表我的对象的方法。
因此,既然您知道对象是什么,并且它们公开了可以运行的方法以及可以检查的属性,那么您就可以开始编码了。 实际上,编码只是关于管理生活在我们在软件中复制的世界子集中的那些对象。 您可以根据需要创建,使用,重用和删除对象。
根据官方 Python 文档中的数据模型一章:
“对象是 Python 的数据抽象。Python 程序中的所有数据都由对象或对象之间的关系表示。”
在下一章中,我们将仔细研究 Python 对象。 现在,我们只需要知道 Python 中的每个对象都有一个 ID(或标识),一个类型和一个值即可。
创建后,就永远不会更改对象的身份。 它是它的唯一标识符,当我们要使用它时,Python 在后台使用它来检索对象。
类型也不会改变。 类型告诉对象支持哪些操作以及可以分配给它的可能值。
该值可以更改,也可以不更改。 如果可以,则称该对象是可变的,而如果不能,则称该对象是不可变的。
我们如何使用对象? 我们给它起个名字! 给对象命名时,可以使用该名称检索对象并使用它。
从更一般的意义上讲,对象(例如数字,字符串(文本),集合等)与名称相关联。 通常,我们说这个名称是变量的名称。 您可以看到变量就像一个盒子,可以用来保存数据。
因此,您拥有了所需的所有对象:现在呢? 好吧,我们需要使用它们,对吗? 我们可能希望通过网络连接发送它们或将它们存储在数据库中。 也许将它们显示在网页上或将其写入文件。 为此,我们需要对用户填写表格,按下按钮或打开网页并执行搜索做出反应。 我们通过运行代码,评估条件来选择执行哪些部分,执行多少次以及在哪种情况下做出反应。
为此,基本上我们需要一种语言。 这就是 Python 的目的。 Python 是我们将在整个模块中一起使用的语言,指示计算机为我们做一些事情。
现在,足够多的理论知识,让我们开始吧。
输入 Python
Python 是荷兰计算机科学家和数学家 Guido Van Rossum 的奇妙生物,他决定通过 1989 年圣诞节期间向他赠送的一个项目向世界赠送礼物。该语言于 1991 年左右在公众面前露面。 现已发展成为当今世界上使用的领先编程语言之一。
我 7 岁那年开始在 Commodore VIC 20 上编程,后来被其较大的兄弟 Commodore 64 取代。该语言是 BASIC。 后来,我登陆了 Pascal,Assembly,C,C ++,Java,JavaScript,Visual Basic,PHP,ASP,ASP .NET,C#和我什至不记得的其他次要语言,但是只有当我登陆 Python 时,我终于 当您在商店中找到合适的沙发时,就会有那种感觉。 当您所有的身体部位都大喊大叫时,“买这个!这个对我们来说很完美!”
我花了一天的时间来适应它。 它的语法与我以前使用的语法略有不同,通常,我很少使用定义缩进范围的语言。 但是在摆脱了最初的不适感(例如穿新鞋)后,我爱上了它。 深。 让我们看看为什么。
关于 Python
在深入探讨的细节之前,让我们先了解一下为什么有人想要使用 Python(我建议您阅读 Wikipedia 上的 Python 页面以获得更详细的介绍)。
在我看来,Python 具有以下特质。
便携性
Python 在任何地方都可以运行,将程序从 Linux 移植到 Windows 或 Mac 通常只是固定路径和设置的问题。 Python 专为可移植性而设计,它可以处理接口后面的操作系统(OS)特定的怪癖,从而使您免于必须编写针对特定平台的代码的痛苦 。
连贯性
Python 非常逻辑一致。 您可以看到它是由一位杰出的计算机科学家设计的。 大多数时候,如果您不知道该方法,则只能猜测该方法的调用方式。
您可能没有意识到这是多么重要,特别是如果您刚开始的时候,但这是一个主要功能。 这意味着更少的头脑混乱,更少的文档浏览,以及更少的代码编写需求。
开发人员的生产力
根据 Mark Lutz(学习 Python,第 5 版,O'Reilly Media ),Python 程序的大小通常是等效 Java 程序的五分之一到三分之一,或者 C ++ 代码。 这意味着工作可以更快地完成。 更快是好的。 更快意味着对市场的反应更快。 更少的代码不仅意味着更少的代码编写,还意味着更少的代码阅读(专业编码人员阅读的内容远远超过他们编写的内容),更少的代码需要维护,调试和重构。
另一个重要的方面是,Python 无需冗长且费时的编译和链接步骤即可运行,因此您不必等待查看工作结果。
内容丰富的图书馆
Python 具有令人难以置信的标准库(据说它带有“包括电池”)。 如果这还不够的话,全世界的 Python 社区都会维护大量针对特定需求的第三方库,您可以在 Python 软件包索引(PyPI)。 当您编写 Python 代码并意识到自己需要某种功能时,在大多数情况下,至少有一个已经为您实现了该功能的库。
软件质量
Python 非常注重的可读性,一致性和质量。 语言的统一性使代码具有较高的可读性,这对于当今的代码至关重要,因为代码更多的是集体努力而不是个人经验。 Python 的另一个重要方面是其固有的多范式性质。 您可以将其用作脚本语言,但也可以利用面向对象,命令式和函数式编程样式。 它是多功能的。
软件集成
的另一个重要方面是 Python 可以与许多其他语言进行扩展和集成,这意味着,即使一家公司使用其他语言作为其主流工具,Python 仍可以在其中充当胶粘剂。 需要以某种方式互相交谈的复杂应用。 这是一个高级主题,但是在现实世界中,此功能非常重要。
满意和享受
最后但并非最不重要的是,它的乐趣! 使用 Python 很有趣。 我可以编写 8 个小时的代码,让办公室感到满意和满意,这与其他编码人员必须忍受的苦难相提并论,因为他们使用的语言无法为他们提供相同数量的精心设计的数据结构和构造。 毫无疑问,Python 使编码变得有趣。 乐趣可以提高动力和生产力。
这些就是为什么我会向所有人推荐 Python 的主要方面。 当然,我可以讨论许多其他技术和高级功能,但它们实际上与这样的介绍性部分无关。 在本模块中,它们会自然地出现。
有哪些缺点?
可能不是由于个人喜好而在 Python 中发现的唯一缺点是执行速度。 通常,Python 比其编译的兄弟慢。 运行应用时,Python 的标准实现产生称为字节码(扩展名为.pyc)的源代码的编译版本,然后由 Python 解释器运行。 这种方法的优点是可移植性,由于 Python 没有像其他语言一样被编译到机器级别,因此我们为此付出了缓慢的代价。
但是,Python 速度在今天几乎不再是问题,因此,不管这种次优功能如何,它的广泛使用。 发生的事情是,在现实生活中,硬件成本不再是问题,而且通常很容易通过并行化任务来提高速度。 不过,在进行数字运算时,可以切换到更快的 Python 实现,例如 PyPy,它可以通过实施高级编译技术将的平均速度提高 7 倍(请查看这个页面供参考)。
在进行数据科学时,您很可能会发现,与 Python 一起使用的库(例如 Pandas 和 Numpy)由于实现方式而达到了本机速度。
如果这还不够好,那么您始终可以认为 Python 正在驱动着性能关注的 Spotify 和 Instagram 等服务的后端。 但是,Python 可以完美地完成其工作。
今天谁在使用 Python?
还没说服? 让我们以来简要介绍一下今天使用 Python 的公司:Google,YouTube,Dropbox,Yahoo,Zope Corporation,Industrial Light & Magic,Walt Disney Feature Animation,Pixar,NASA,NSA,Red Hat, 诺基亚,IBM,Netflix,Yelp,英特尔,思科,惠普,高通和摩根大通(JPMorgan Chase)仅举几例。
甚至《战地 2》,,《文明 4》* 和 QuArK 之类的游戏也都使用 Python 实现。*
Python 用于许多不同的环境中,例如系统编程,Web 编程,GUI 应用,游戏和机器人技术,快速原型设计,系统集成,数据科学,数据库应用等等。
设置环境
在讨论在您的系统上安装 Python 之前,让我告诉您我将在此模块中使用哪个 Python 版本。
Python 2 vs Python 3 –精彩的辩论
Python 有两个主要的版本-过去的 Python 2 和现在的 Python 3。 这两个版本虽然非常相似,但在某些方面不兼容。
在现实世界中,Python 2 实际上与过去已经相去甚远。 简而言之,即使 Python 3 自 2008 年问世以来,过渡阶段仍未结束。 这主要是由于 Python 2 在业界得到广泛使用的事实,当然,公司并没有那么热衷于为更新而急于更新其系统,如果没问题的话, 不要解决哲学。 您可以在 Web 上阅读有关两个版本之间的过渡的全部信息。
阻碍过渡的另一个问题是第三方库的可用性。 通常,一个 Python 项目依赖于数十个外部库,当然,当您开始一个新项目时,您需要确保已经存在与版本 3 兼容的库,可以满足可能出现的任何业务需求。 如果不是这种情况,那么在 Python 3 中启动一个全新的项目就意味着引入潜在的风险,许多公司对此并不满意。
在撰写本文时,大多数最广泛使用的库已移植到 Python 3,在大多数情况下,在 Python 3 中启动项目是相当安全的。 许多库已被重写,以使其与两个版本兼容,主要是利用了六(2 x 3)库的功能,这有助于根据所使用的版本进行内省和适应行为。
本模块中的所有示例都将使用此 Python 3.4.0 运行。 它们中的大多数也可以在 Python 2 中运行(我也安装了版本 2.7.6),而那些不需要的版本只需进行一些细微调整即可适应两个版本之间的微小不兼容性。
不过,不必担心该版本的问题:实际上这不是一个大问题。
注意
如果在您阅读本课程时,如果我要指向您的网址或资源不再存在,请记住:Google 是您的朋友。
本课程需要什么
如您所见,开始时有太多的要求,所以我准备了一张表,它为您概述了该课程每个模块的需求:
|模块 1
|
模块 2
|
模块 3
|
模块 4
|
| --- | --- | --- | --- |
| 本模块中的所有示例均依赖于 Python 3 解释器。 本模块中的某些示例依赖于 Python 不附带的第三方库。 这些在使用时在模块中引入,因此您无需提前安装。 但是,出于完整性考虑,以下是列表:
- 点子
- 要求
- 枕头
- 位数组
| 尽管所有示例都可以在 Python Shell 中交互运行,但我们建议对该模块使用 IPython。 该模块中使用的库版本为:
- NumPy 1.9.2
- Pandas 0.16.2
- matplotlib 1.4.3
- 表 3.2.2
- pymongo 3.0.3
- Redis 2.10.3
- scikit 学习 0.16.1
| 任何现代处理器(大约从 2010 年开始)和 4 GB 的 RAM 就足够了,并且您可能也可以在较慢的系统上运行几乎所有代码。最后两章是一个例外。 在这些章节中,我将逐步使用 Amazon Web Services(AWS)运行代码。 这可能会花费您一些钱,但是优点是系统设置比在本地运行代码少。如果您不想为这些服务付费,那么所使用的工具都可以在本地计算机上设置,但是您肯定需要一个现代化的系统来运行它。 至少需要内置 2012 年且 RAM 大于 4 GB 的处理器。 | 尽管代码示例也将与 Python 2.7 兼容,但是最好安装最新版本的 Python 3(可能是 3.4.3 或更高版本)。 |
安装 Python
Python 是一种很棒的,通用的,易于使用的语言。 它可用于所有三个主要操作系统(Microsoft Windows,Mac OS X 和 Linux),并且安装程序以及文档可从官方 Python 网站下载。
注意
Windows 用户将需要设置一个环境变量,以便从命令行使用 Python。 首先,找到 Python 3 的安装位置; 默认位置为C:\Python34。 接下来,在命令行(cmd程序)中输入此命令:将环境设置为PYTHONPATH=%PYTHONPATH%;C:\Python34。 如果 Python 安装在其他目录中,请记住要更改C:\Python34。
在系统上运行 Python 之后,您应该能够打开命令提示符并运行以下代码:
$ python3
Python 3.4.0 (default, Apr 11 2014, 13:05:11)
[GCC 4.8.2] on Linux
Type "help", "copyright", "credits" or "license" for more information.
>>> print("Hello, world!")
Hello, world!
>>> exit()
请注意,我们将使用美元符号($)表示要在终端中键入命令(在 Windows 中也称为 shell 或 cmd)。 您不需要键入此字符(或它后面的空格)。 只需键入该行的其余部分,然后按输入即可。
获得上述“您好,世界!”之后 运行示例,退出程序并继续安装更高级的环境来运行 Python 代码 IPython Notebook。
安装 IPython
IPython 是用于 Python 开发的平台,其中包含许多用于运行 Python 的工具和环境,并且具有比标准解释器更多的功能。 它包含功能强大的 IPython Notebook,可让您在 Web 浏览器中编写程序。 它还格式化您的代码,显示输出,并允许您注释脚本。 这是探索数据集的绝佳工具。
要在计算机上安装 IPython,可以在命令行提示符下(而不是 Python)键入以下内容:
$ pip install ipython[all]
您需要管理员权限才能在整个系统范围内进行安装。 如果您不想(或不能)进行系统范围的更改,可以通过运行以下命令为当前用户安装它:
$ pip install --user ipython[all]
这会将 IPython 软件包安装到用户特定的位置-您将可以使用它,但是计算机上没有其他人可以使用。 如果您在安装时遇到困难,请查看官方文档以获取更多详细的安装说明。
安装 IPython Notebook 后,您可以使用以下命令启动它:
$ ipython3 notebook
这将做两件事。 首先,它将创建一个 IPython Notebook 实例,该实例将在您刚刚使用的命令提示符下运行。 其次,它将启动您的 Web 浏览器并连接到该实例,从而允许您创建一个新的笔记本。 看起来类似于以下屏幕截图(其中home/bob将被您当前的工作目录替换):

要停止运行 IPython Notebook,请打开正在运行实例的命令提示符(您之前用于运行 IPython 命令的实例)。 然后,按 Ctrl +C,将提示您Shutdown this notebook server (y/[n])?。 输入y并按输入,IPython Notebook 将关闭。
安装其他软件包
Python 3.4 将包含一个名为pip的程序,该程序是一个程序包管理器,可帮助您在系统上安装新的库。 您可以通过运行$ pip3 freeze命令来验证pip是否在系统上运行,该命令会告诉您系统上已安装了哪些软件包。
附加软件包可以通过pip安装程序安装,该程序自 Python 3.3 起已成为 Python 标准库的一部分。 有关pip的更多信息,请参见这个页面。
成功安装 Python 之后,我们可以从命令行终端执行pip来安装其他 Python 软件包:
pip install SomePackage
可以通过--upgrade标志更新已安装的软件包:
pip install SomePackage --upgrade
强烈推荐的用于科学计算的 Python 替代发行版是 Continuum Analytics 的 Anaconda。 Anaconda 是一种免费的(包括商业用途的)企业级 Python 发行版,它将用于数据科学,数学和工程学的所有基本 Python 软件包捆绑在一个用户友好的跨平台发行版中。 您可以从这个页面下载 Anaconda 安装程序,并可以从这个页面。
成功安装 Anaconda 之后,我们可以使用以下命令安装新的 Python 软件包:
conda install SomePackage
可以使用以下命令更新现有软件包:
conda update SomePackage
下面列出了用于编写本课程的主要 Python 软件包:
- NumPy
- 科学
- scikit 学习
- matplotlib
- Pandas
- 桌子
- pymongo
- 重复
由于这些软件包都托管在 Python 软件包索引 PyPI 上,因此可以很容易地通过pip安装。 要安装 NumPy,请运行:
$ pip install numpy
要安装 scikit-learn,请运行:
$ pip3 install -U scikit-learn
注意
重要
Windows 用户可能需要先安装 NumPy 和 SciPy 库,然后才能安装 scikit-learn。 这些用户可以在这个页面上获得安装说明。
大型 Linux 发行版(例如 Ubuntu 或 Red Hat)的用户可能希望从其软件包管理器中安装官方软件包。 并非所有发行版都具有 scikit-learn 的最新版本,因此请在安装前检查版本。
那些希望通过编译源代码来安装最新版本或查看更详细的安装说明的人员,可以转到这个页面以查看有关安装的正式文档。 scikit 学习。
大多数库都将具有版本的属性,因此,如果您已经安装了库,则可以快速检查其版本:
>>> import redis
>>> redis.__version__
'2.10.3'
这对于大多数库来说效果很好。 少数(例如 pymongo)使用其他属性(pymongo 仅使用版本,不带下划线)。
如何运行 Python 程序
中有几种不同的方法可以运行 Python 程序。
运行 Python 脚本
可以将 Python 用作脚本语言。 实际上,它总是证明自己非常有用。 脚本是通常执行以执行类似任务的文件(通常是较小尺寸的文件)。 许多开发人员最终拥有自己的工具库,需要执行任务时便会触发这些工具。 例如,您可以使用脚本来解析某种格式的数据并将其呈现为另一种不同的格式。 或者,您可以使用脚本来处理文件和文件夹。 您可以创建或修改配置文件,等等。 从技术上讲,在脚本中没有太多不能做的事情。
在服务器上的准确时间运行脚本是很常见的。 例如,如果您的网站数据库需要每 24 小时清洁一次(例如,存储用户会话的表,该会话很快就会过期,但不会自动清除),则可以设置一个 cron 作业,在 3 时触发脚本: 凌晨 00 每天。
注意
根据 Wikipedia 的说法,软件实用程序 Cron 是类似 Unix 的计算机操作系统中的基于时间的作业调度程序。 设置和维护软件环境的人员使用 cron 安排作业(命令或 shell 脚本)以固定的时间,日期或间隔定期运行。
我有 Python 脚本来完成所有繁琐的工作,这些工作可能要花我几分钟甚至更多的时间来手动完成,在某个时候,我决定实现自动化。 例如,我有一台没有 Fn 键来打开和关闭触摸板的笔记本电脑。 我觉得这很烦人,我不想在需要时单击几个菜单,所以我写了一个小巧的脚本,它很聪明,可以告诉我的系统切换触摸板的活动状态,现在 只需单击启动器即可完成此操作 无价。
运行 Python 交互式 shell
运行 Python 的另一种方法是调用交互式 shell。 在控制台的命令行上键入python时,我们已经看到了这一点。
因此,打开一个控制台,激活您的虚拟环境(现在应该是您的自然环境吧?),然后键入python。 您将看到几行看起来像这样的行(如果您使用的是 Linux):
Python 3.4.0 (default, Apr 11 2014, 13:05:11)
[GCC 4.8.2] on linux
Type "help", "copyright", "credits" or "license" for more information.
那些>>>是 shell 的提示符。 他们告诉您 Python 正在等待您键入内容。 如果您键入一条简单的指令,而该指令仅适合一行,那么您将看到的全部内容。 但是,如果您键入的内容需要多于一行代码,则 Shell 会将提示符更改为...,从而为您提供了直观的线索,表明您正在键入多行语句(或任何需要多于一行的代码) 代码)。
继续尝试一下,让我们做一些基本的数学运算:
>>> 2 + 4
6
>>> 10 / 4
2.5
>>> 2 ** 1024
179769313486231590772930519078902473361797697894230657273430081157732675805500963132708477322407536021120113879871393357658789768814416622492847430639474124377767893424865485276302219601246094119453082952085005768838150682342462881473913110540827237163350510684586298239947245938479716304835356329624224137216
最后一个操作向您展示了一些不可思议的东西。 我们将 2 提高到 1024 的幂,Python 可以毫不费力地处理此任务。 尝试使用 Java,C ++ 或 C#进行操作。 除非您使用特殊的库来处理这么大的数字,否则它将无法正常工作。
我每天都使用交互式 shell。 快速调试非常有用,例如,检查数据结构是否支持操作。 或者检查或运行一段代码。
当您使用 Django(Web 框架)时,交互式外壳与之耦合,并允许您逐步使用框架工具,检查数据库中的数据以及更多其他内容。 您会发现交互式外壳将很快成为您踏上旅途中最亲爱的朋友之一。
以更好的图形布局出现的另一种解决方案是使用 IDLE(集成开发环境)。 这是一个非常简单的 IDE,主要供初学者使用。 它的功能集比您在控制台中获得的裸机交互外壳要大一些,因此您可能需要对其进行探索。 它在 Windows Python 安装程序中免费提供,您可以轻松地在其他任何系统中安装它。 您可以在 Python 网站上找到有关它的信息。
吉多·范·罗苏姆(Guido Van Rossum)以英国喜剧团 Monty Python 的名字命名 Python,因此有传言说 IDLE 被选中是为了纪念 Monty Python 的创始成员之一 Erik Idle。
将 Python 作为服务运行
除了作为脚本运行之外,并且在外壳程序范围内,Python 可以进行编码并作为适当的软件运行。 在整个模块中,我们将看到有关此模式的许多示例。 当我们谈论 Python 代码的组织和运行方式时,我们将对此有更多的了解。
将 Python 作为 GUI 应用运行
Python 也可以作为 GUI(图形用户界面)运行。 有几种可用的框架,其中一些是跨平台的,另一些是特定于平台的。
Tk 是一个图形用户界面工具包,它将桌面应用开发提升到比传统方法更高的水平。 它是工具命令语言(TCL)的标准 GUI,同时也是许多其他动态语言的标准 GUI,可以生成可在 Windows,Linux 和 Mac 上无缝运行的丰富本机应用 OS X 等。
Tkinter 与 Python 捆绑在一起,因此它使程序员可以轻松访问 GUI 世界,由于这些原因,我选择它作为本模块中将要介绍的 GUI 示例的框架。
在其他 GUI 框架中,我们发现以下是最广泛使用的框架:
- PyQt
- wxPython
- PyGtk
详细描述它们不在本模块的范围内,但是您可以在 GUI Programming 部分的 Python 网站上找到所需的所有信息。 如果您正在寻找 GUI,请记住要根据一些原则选择所需的 GUI。 确保他们:
- 提供开发项目可能需要的所有功能
- 在您可能需要支持的所有平台上运行
- 依靠一个尽可能广泛和活跃的社区
- 包装可以轻松安装/访问的图形驱动程序/工具
Python 代码是如何组织的
让我们谈谈 Python 代码的组织方式。 在本段中,我们将开始做一些进一步的介绍,并介绍一些技术名称和概念。
从基础开始,Python 代码是如何组织的? 当然,您可以将代码写入文件中。 当保存扩展名为.py的文件时,该文件被称为 Python 模块。
注意
如果您使用的是 Windows 或 Mac,通常会向用户隐藏文件扩展名,请确保您更改了配置,以便可以看到文件的完整名称。 严格来说,这不是必须的要求,而是一个丰盛的建议。
将软件运行所需的所有代码保存在一个文件中是不切实际的。 该解决方案适用于脚本,这些脚本通常不超过几百行(通常比这短很多)。
一个完整的 Python 应用可以由成千上万行代码组成,因此您必须将其分散在不同的模块中。 更好,但还不够好。 事实证明,即使这样,使用代码仍然是不切实际的。 因此,Python 为您提供了另一种结构,称为包,该结构允许您将模块分组在一起。 包只不过是一个文件夹,该文件夹必须包含一个特殊文件__init__.py,该文件不需要保存任何代码,但需要存在该文件才能告诉 Python 该文件夹不仅是某个文件夹,而且 实际上是一个软件包(请注意,从 Python 3.3 __init__.py开始不再严格要求)。
与往常一样,示例将使所有这些变得更加清晰。 我在模块项目中以及当我在 Linux 控制台中键入内容时创建了一个示例结构:
$ tree -v example
我得到了ch1/example文件夹内容的树形表示,其中包含本章示例的代码。 实际的简单应用的结构如下所示:
example/
├── core.py
├── run.py
└── util
├── __init__.py
├── db.py
├── math.py
└── network.py
您可以看到在此示例的根目录中,我们有两个模块core.py和run.py以及一个包:util。 在core.py中,可能存在我们应用的核心逻辑。 另一方面,在run.py模块中,我们可能可以找到启动应用的逻辑。 我希望在util包中找到各种实用工具,实际上,我们可以猜测那里的模块是由它们所拥有的工具的类型来调用的:db.py将拥有与数据库一起使用的工具,math.py 当然会拥有数学工具(也许我们的应用处理财务数据),network.py可能会拥有在网络上发送/接收数据的工具。
如前所述,__init__.py文件只是用来告诉 Python util是一个软件包,而不仅仅是一个文件夹。
如果仅将该软件组织在模块中,则推断其结构将更加困难。 我在ch1/files_only文件夹下放了一个仅模块示例,自己看看:
$ tree -v files_only
这向我们展示了完全不同的图片:
files_only/
├── core.py
├── db.py
├── math.py
├── network.py
└── run.py
很难猜测每个模块的作用,对吗? 现在,考虑一下这只是一个简单的示例,因此您可以猜测,如果我们不能在包和模块中组织代码,那么理解一个真实的应用将变得更加困难。
我们如何使用模块和软件包
当开发人员正在编写应用时,他们很有可能需要在其不同部分应用相同的逻辑。 例如,当为来自用户可以填写网页的表格的数据编写解析器时,应用将必须验证某个字段是否包含数字。 无论如何编写这种验证的逻辑,很可能在多个地方都需要它。 例如,在民意测验应用中,向用户询问了许多问题,很可能其中几个用户将需要一个数字答案。 例如:
- 你几岁
- 您拥有多少只宠物
- 你有几个孩子
- 你结婚了多少次
在我们希望得到数字答案的每个地方复制粘贴(或更正确地说:重复)验证逻辑将是非常糟糕的做法。 这将违反 DRY(不要重复自己)的原则,该原则规定,在您的应用中,同一条代码不应重复多次。 我感到有必要强调这一原则的重要性:在您的应用中,您绝不应重复执行同一段代码(具有讽刺意味?)。
重复同一条逻辑可能会很糟糕的原因有几个,其中最重要的是:
- 逻辑中可能存在错误,因此,您必须在应用逻辑的每个位置进行纠正。
- 您可能需要修改执行验证的方式,并且再次必须在应用验证的每个位置进行更改。
- 您可能会忘记修复/修改逻辑,因为在搜索所有逻辑时都错过了它。 这将在您的应用中留下错误/不一致的行为。
- 没有充分的理由,您的代码将比所需的时间更长。
Python 是一种出色的语言,为您提供应用所有编码最佳实践所需的所有工具。 对于此特定示例,我们需要能够重用一段代码。 为了能够重用一段代码,我们需要一个可以为我们保存代码的构造,以便每次需要重复其中的逻辑时可以调用该构造。 该构造存在,称为函数。
我在这里不做详细介绍,所以请记住,函数是用于执行任务的有组织,可重用的代码块。 根据函数所属的环境,函数可以采用多种形式和名称,但是现在这并不重要。 稍后,当我们能够在模块中欣赏它们时,将看到这些细节。 函数是应用中模块化的构建块,它们几乎是必不可少的(除非您正在编写一个超级简单的脚本,否则您将一直使用函数)。
如前所述,Python 带有非常广泛的库。 现在,也许是时候定义一个库了:库 是功能和对象的集合,这些函数和对象提供了丰富语言功能的功能。
例如,在 Python 的math库中,我们可以找到许多函数,其中factorial函数就是其中之一,该函数当然会计算数字的阶乘。
注意
在数学上,非负整数N的阶乘 表示为 N! 定义为所有小于或等于 N 的正整数的乘积。例如,阶乘 5 的计算公式为:
5! = 5 * 4 * 3 * 2 * 1 = 120
0的阶乘是0! = 1,以遵守关于空产品的约定。
因此,如果您想在代码中使用此函数,则只需导入并用正确的输入值调用它即可。 如果现在还不清楚输入值和调用的概念,请不要太担心,请专注于导入部分。
注意
我们通过从库中导入所需内容来使用库,然后使用它。
在 Python 中,要计算数字 5 的阶乘,我们只需要以下代码:
>>> from math import factorial
>>> factorial(5)
120
注意
无论我们在 shell 中键入什么内容(如果它具有可打印的表示形式),都将在控制台上为我们打印(在这种情况下,函数调用的结果为:120)。
因此,让我们回到的示例,即具有core.py,run.py,util等的示例。
在我们的示例中,包util是我们的实用程序库。 我们的定制公用带可存放我们在应用中所需的所有那些可重复使用的工具(即功能)。 其中一些将处理数据库(db.py),一些将处理网络(network.py),另一些将执行数学计算(math.py),这些计算不在 Python 标准math库的范围内,因此,我们 必须自己编写代码。
现在让我们讨论另一个非常重要的概念:Python 的执行模型。
Python 的执行模型
在本段中,我希望向您介绍一些非常重要的概念,例如作为范围,名称和名称空间。 当然,您可以在官方语言参考中阅读有关 Python 执行模型的全部信息,但是我认为它是技术性和抽象性很强的,所以让我先给您一个不太正式的解释。
名称和名称空间
假设您正在寻找一个模块,因此您去了图书馆,问某人要获取的模块。 他们告诉您类似“第二层 X 分区第三行”的信息。 因此,您上楼梯,寻找 X 区段,依此类推。
进入图书馆,将所有书籍以随机的顺序堆放在一个大房间里,这是完全不同的。 没有楼层,没有部分,没有行,没有顺序。 提取模块将非常困难。
在编写代码时,我们会遇到同样的问题:我们必须尝试对其进行组织,以便对以前没有相关知识的人轻松找到所需的内容。 如果软件的结构正确,它也会促进代码重用。 另一方面,杂乱无章的软件更有可能暴露出分散的重复逻辑。
首先,让我们从模块开始。 我们通过模块名称和 Python 术语来引用模块,这就是名称。 Python 名称是其他语言称为变量的最接近的抽象。 名称基本上是指对象,并通过名称绑定操作引入。 让我们举一个简单的例子(注意,#之后的所有内容都是注释):
>>> n = 3 # integer number
>>> address = "221b Baker Street, NW1 6XE, London" # S. Holmes
>>> employee = {
... 'age': 45,
... 'role': 'CTO',
... 'SSN': 'AB1234567',
... }
>>> # let's print them
>>> n
3
>>> address
'221b Baker Street, NW1 6XE, London'
>>> employee
{'role': 'CTO', 'SSN': 'AB1234567', 'age': 45}
>>> # what if I try to print a name I didn't define?
>>> other_name
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name 'other_name' is not defined
我们在前面的代码中定义了三个对象(您还记得每个 Python 对象具有的三个功能吗?):
- 整数
n(类型:int,值:3) - 字符串
address(类型:str,值:Sherlock Holmes 的地址) - 字典
employee(类型:dict,值:包含三个键/值对的字典)
别担心,我知道您不应该知道字典是什么。 我们将在中看到下一章,它是 Python 数据结构之王。
注意
您输入我的员工定义时,提示是否从>>>更改为...? 这是因为定义跨越多行。
那么n,address和employee是什么? 它们是名称。 我们可以用来在代码中检索数据的名称。 它们需要保存在某个地方,以便每当我们需要检索那些对象时,我们都可以使用它们的名称来获取它们。 我们需要一些空间来容纳它们,因此:命名空间!
因此,名称空间是从名称到对象的映射。 示例包括一组内置名称(包含始终可以在任何 Python 程序中免费访问的函数),模块中的全局名称以及函数中的本地名称。 甚至对象的属性集也可以视为名称空间。
命名空间的优点在于,它们使您可以清晰地定义和组织名称,而不会出现重叠或干扰。 例如,与我们在库中寻找的模块相关联的名称空间可用于导入模块本身,如下所示:
from library.second_floor.section_x.row_three import module
我们从library命名空间开始,借助点(.)运算符,我们进入该命名空间。 在此命名空间中,我们寻找second_floor,然后再次使用.运算符进入它。 然后,我们进入section_x,最后进入最后一个命名空间row_tree,找到我们要寻找的名称:module。
当我们要处理真实的代码示例时,遍历命名空间将更加清晰。 现在,请记住,名称空间是名称与对象关联的地方。
还有一个与命名空间紧密相关的概念,我想简单地谈一谈:范围。
范围
根据 Python 的文档,范围是 Python 程序的文本区域,可直接访问名称空间。 直接访问意味着当您要查找对名称的不合格引用时,Python 会尝试在名称空间中找到它。
作用域是静态确定的,但实际上在运行时会动态使用它们。 这意味着,通过检查源代码,您可以知道对象的范围是什么,但这不会阻止软件在运行时进行更改。 Python 提供了四种不同的作用域(当然,它们不一定同时存在):
- 本地 范围,其中是最内部的范围,并包含本地名称。
- 包含和范围的,即是任何包含函数的范围。 它包含非本地名称以及非全局名称。
- 全局 范围包含全局名称。
- 内置 范围包含内置名称。 Python 附带了一组可以以现成的方式使用的函数,例如
print,all,abs等。 他们生活在内置范围内。
规则如下:当我们引用名称时,Python 开始在当前名称空间中查找它。 如果未找到名称,Python 将继续搜索到封闭范围,并继续进行直到搜索内置范围。 如果在搜索内置作用域后未找到名称,那么 Python 会引发一个NameError 异常,这基本上意味着该名称尚未定义(您在 前面的示例)。
因此,查找名称时扫描名称空间的顺序为:本地,包含,全局,内置 HTG8](LEGB)。
这都是非常理论上的,所以让我们看一个例子。 为了向您显示 Local 和 Enclosing 名称空间,我将必须定义一些函数。 只要记住以下代码,当您看到def时,就意味着我正在定义一个函数。
scopes1.py
# Local versus Global
# we define a function, called local
def local():
m = 7
print(m)
m = 5
print(m)
# we call, or `execute` the function local
local()
在前面的示例中,我们在全局范围和本地范围(由函数 local 定义的范围)中都定义了相同的名称m。 当我们使用以下命令执行该程序时(您是否激活了 virtualenv?):
$ python scopes1.py
我们在控制台上看到两个数字:5和7。
发生的是 Python 解释器从上到下解析文件。 首先,它找到几个注释行,将其跳过,然后解析函数local的定义。 调用该函数时,它会做两件事:它为代表数字 7 的对象设置名称并进行打印。 Python 解释器不断前进,并找到了另一个名称绑定。 这次绑定发生在全局范围内,值为 5。下一行是对print函数的调用,该函数将被执行(因此,我们将在控制台上打印出第一个值:5)。
此后,将调用函数local。 此时,Python 执行该函数,因此此时发生绑定m = 7并打印出来。
需要注意的一件非常重要的事情是,属于函数 local 的定义的代码部分由右侧的四个空格缩进。 实际上,Python 通过缩进代码来定义作用域。 您可以通过缩进进入范围,而通过不缩进可以退出范围。 一些编码器使用两个空格,另一些使用三个空格,但是建议使用的空格数为四个。 这是最大化可读性的好方法。 稍后我们将讨论在编写 Python 代码时应遵循的所有约定。
如果我们删除该m = 7行会发生什么? 记住 LEGB 规则。 Python 将开始在本地范围(功能local)中寻找m,但找不到它,它将进入下一个封闭的范围。 在这种情况下,下一个是全局的,因为local周围没有封装函数。 因此,我们将在控制台上看到两个数字5。 让我们实际看一下代码的样子:
scopes2.py
# Local versus Global
def local():
# m doesn't belong to the scope defined by the local function
# so Python will keep looking into the next enclosing scope.
# m is finally found in the global scope
print(m, 'printing from the local scope')
m = 5
print(m, 'printing from the global scope')
local()
运行scopes2.py将打印以下内容:
(.lpvenv)fab@xps:ch1$ python scopes2.py
5 printing from the global scope
5 printing from the local scope
正如预期的那样,Python 第一次打印m,然后在调用函数local时,在其范围内找不到m,因此 Python 会按照 LEGB 链查找它,直到m被 在全球范围内找到。
让我们看一个带有附加层的示例,即封闭范围:
scopes3.py
# Local, Enclosing and Global
def enclosing_func():
m = 13
def local():
# m doesn't belong to the scope defined by the local
# function so Python will keep looking into the next
# enclosing scope. This time m is found in the enclosing
# scope
print(m, 'printing from the local scope')
# calling the function local
local()
m = 5
print(m, 'printing from the global scope')
enclosing_func()
运行scopes3.py将在控制台上打印:
(.lpvenv)fab@xps:ch1$ python scopes3.py
5 printing from the global scope
13 printing from the local scope
如您所见,功能local的print指令与以前一样是指m。 m仍未在函数本身中定义,因此 Python 会按照 LEGB 顺序启动作用域。 这次在封闭范围内找到了m。
如果现在还不清楚,请不要担心。 我们将通过本模块中的示例为您提供帮助。 Python 教程(官方文档)的类部分包含有关范围和名称空间的有趣段落。 如果您希望对本主题有更深入的了解,请确保在一定时间阅读。
在结束本章之前,我想再多谈一些对象。 毕竟,Python 中的基本上所有东西都是对象,因此我认为它们值得更多关注。
有关如何编写良好代码的准则
编写好的代码似乎并不像那样容易。 就像我之前说过的,好的代码暴露出一长串的质量,这些质量很难组合在一起。 在某种程度上,编写好的代码是一门艺术。 无论您乐于在何处定居,都有可以拥抱的东西可以使您的代码立即变得更好:PEP8。
根据维基百科:
“ Python 的开发主要通过 Python 增强建议(PEP)流程进行。PEP 流程是提出主要新功能,收集社区对问题的意见以及记录设计决策的主要机制。 已经进入 Python。”
在所有 PEP 中,可能最著名的是 PEP8。 它列出了一套简单而有效的准则来定义 Python 美学,以便我们编写漂亮的 Python 代码。 如果您从本章中提出一个建议,请按照以下说明操作:使用它。 接受它。 您稍后会感谢我。
今天的编码不再是登记入住/退房业务。 相反,这更多是一种社会努力。 几个开发人员通过 git 和 mercurial 之类的工具协作编写一段代码,结果是许多不同的人掌握了这些代码。
注意
Git 和 Mercurial 可能是当今使用最广泛的分布式修订控制系统。 它们是必不可少的工具,旨在帮助开发人员团队在同一软件上进行协作。
如今,我们比以往任何时候都需要一种一致的代码编写方式,以使可读性最大化。 当一家公司的所有开发人员都遵守 PEP8 时,他们中的任何一个使用一段代码就认为自己编写了代码并不少见。 实际上,它一直在我身上发生(我总是忘记自己编写的代码)。
这具有巨大的优势:当您阅读可以编写的代码时,便可以轻松阅读。 如果没有约定,每个编码人员都将按照他们最喜欢的方式或只是按照他们的教导或习惯的方式来构造代码,这意味着必须根据他人的风格来解释每一行。 这意味着必须花更多的时间去尝试理解它。 多亏了 PEP8,我们可以避免这种情况。 我非常喜欢它,如果代码不遵守它,我不会签署代码审查。 因此,请花时间研究它,这非常重要。
在本模块的示例中,我将尽可能地尊重它。 不幸的是,我不能使用 79 个字符(这是 PEP *建议的最大行长),我将不得不减少空白行和其他内容,但是我保证您会尝试布局我的 代码,以使其尽可能具有可读性。
Python 文化
Python 已在所有编码行业中广泛采用。 许多公司将其用于许多不同的目的,并且在教育中也使用了它(由于它的许多特质和易于学习的事实,因此是一种出色的语言)。
Python 之所以如此流行的原因之一是,它周围的社区辽阔,生机勃勃,充满了才华横溢的人们。 世界各地组织了许多活动,其中大多数活动围绕 Python 或其主要网络框架 Django 进行。
Python 是开放的,拥护它的人通常也是如此。 在 Python 网站上查看社区页面以获取更多信息并参与其中!
Python 的另一个方面围绕着 Pythonic 的概念。 这与以下事实有关:Python 允许您使用其他地方没有的某些习语,至少以不同的形式或易用性使用(当我不得不使用非 Python 语言编写代码时,我会产生幽闭恐惧症) 现在)。
无论如何,这些年来,Pythonic 的概念应运而生,据我所知,它就像一样,以应有的方式在 Python 中完成工作。
为了帮助您进一步了解 Python 的文化和成为 Python 语言,我将向您展示 Python 的 Zen。 一个非常受欢迎的可爱的复活节彩蛋。 打开 Python 控制台,然后输入import this。 以下是该行的结果:
>>> import this
The Zen of Python, by Tim Peters
Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one-- and preferably only one --obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea -- let's do more of those!
注意
下载示例代码
该课程所有四个部分的代码文件都可以在这个页面上找到。
这里有两个阅读级别。 一种是将其视为以一种有趣的方式制定的一组准则。 另一个是要记住它,并可能不时阅读它,以试图理解它对更深层次的意义。 为了按照预期的方式编写 Python,您必须深刻理解一些 Python 特性。 从有趣的级别开始,然后进行更深入的研究。 总是更深入。
关于 IDE 的说明
关于集成开发环境(IDE)的简短说明。 要遵循本模块中的示例,您不需要一个示例,任何文本编辑器都可以。 如果要具有语法着色和自动完成等更高级的功能,则必须获取自己的 IDE。 您可以在 Python 网站上找到完整的开源 IDE 列表(只是 Google 的“ python ides”)。 我个人使用 Sublime Text 编辑器。 免费试用,价格仅为几美元。 我一生中尝试了许多 IDE,但这是使我效率最高的一种。
两个非常重要的建议:
- 无论您选择使用哪种 IDE,都请尝试好好学习它,以便可以利用它的优势,但是并不依赖于它。 练习自己不时地与 VIM(或任何其他文本编辑器)一起工作,学习能够使用任何工具在任何平台上进行一些工作。
- 无论使用哪种文本编辑器/ IDE,在编写 Python 时,缩进都是四个空格。 不要使用制表符,不要将它们与空格混合。 使用四个空格,而不是两个,三个或五个。 只需使用四个。 整个世界都是这样工作的,并且您不想成为被淘汰者,因为您喜欢三空间布局。


二、面向对象设计
在软件开发中,通常将设计视为编程之前的步骤。 这不是真的。 实际上,分析,编程和设计往往会重叠,合并和交织。 在本章中,我们将介绍以下主题:
- 面向对象的含义
- 面向对象设计和面向对象编程之间的区别
- 面向对象设计的基本原理
- 基本的统一建模语言(UML)以及它不是邪恶的时候
引入面向对象
每个人都知道对象是什么—我们可以感知,感觉和操纵的有形事物。 我们与之互动的最早物体通常是婴儿玩具。 木块,塑料形状和超大拼图块是常见的首要对象。 婴儿很快就会知道某些物体会执行某些操作:铃响,按钮按下以及操纵杆拉动。
在软件开发中,对象的定义没有太大不同。 软件对象通常不是您可以拾取,感知或感觉到的有形事物,但是它们是可以执行某些操作并对它们执行某些操作的模型。 形式上,对象是数据和关联的行为的集合。
那么,知道什么是对象,面向对象的含义是什么? 定向简单是指将定向为。 因此,面向对象意味着在功能上指向建模对象。 这是通过描述交互对象的数据和行为来收集对象的集合,从而为复杂系统建模的众多技术之一。
如果您读过任何炒作,您可能会遇到术语“面向对象的分析”,“面向对象的设计”,“面向对象的分析和设计以及面向对象的编程”。 这些都是在通用的面向对象框架下的高度相关的概念。
实际上,分析,设计和编程都是软件开发的所有阶段。 称它们为面向对象仅指正在追求的软件开发风格。
面向对象分析(OOA)是查看问题,系统或任务(某人希望将其转变为应用)并识别的过程。 对象以及这些对象之间的交互。 分析阶段全部与有关,需要完成什么。
分析阶段的输出是一组需求。 如果我们要一步完成分析阶段,那么我们会将一项任务(例如我需要一个网站)转变为一组需求。 例如:
网站访问者必须能够(斜体代表操作,粗体代表对象):
- 回顾我们的历史
- 将应用于职位
- 浏览,比较和订购产品
在某些方面,分析是错误的。 我们前面讨论的婴儿不分析积木和拼图。 相反,它将探索其环境,操纵形状并查看它们可能适合的位置。 更好的措辞可能是面向对象的探索。 在软件开发中,分析的初始阶段包括采访客户,研究他们的流程以及消除可能性。
面向对象设计(OOD)是将此类要求转换为实现规范的过程。 设计人员必须命名对象,定义行为并正式指定哪些对象可以激活其他对象上的特定行为。 设计阶段是关于如何完成的。
设计阶段的输出是实现规范。 如果我们要一步一步完成设计阶段,我们将把在面向对象分析过程中定义的需求转换为可以用(理想情况下)任何面向对象编程语言实现的一组类和接口。
面向对象程序设计(OOP)是将这种完全定义的设计转换成可以执行 CEO 最初要求的工作程序的过程。
是的,对! 如果世界实现了这一理想,那就太好了,我们可以按照所有旧教科书告诉我们的那样,以完美的顺序一步一步地走过这些阶段。 像往常一样,现实世界更加模糊。 无论我们多么努力地分离这些阶段,我们在设计时总是会发现需要进一步分析的事物。 在进行编程时,我们会发现需要在设计中进行澄清的功能。
二十一世纪的大多数发展都发生在迭代发展模型中。 在迭代开发中,仅对任务的一小部分进行建模,设计和编程,然后对程序进行审查和扩展以改进每个功能,并在一系列短的开发周期中包括新功能。
本模块的其余部分与面向对象编程有关,但是在本章中,我们将介绍设计上下文中的基本面向对象原理。 这使我们能够理解这些(相当简单的)概念,而不必与软件语法或 Python 解释器争论。
对象和类
因此,对象是具有相关行为的数据的集合。 我们如何区分对象类型? 苹果和橘子都是对象,但是这是一句常见的格言,它们无法比较。 苹果和橘子在计算机编程中不是很常用的模型,但让我们假设我们正在为一个水果农场进行库存应用。 为了简化示例,我们可以假设苹果装在桶中,橘子装在篮子中。
现在,我们有四种对象:苹果,橙子,篮子和桶。 在面向对象的建模中,用于类对象的术语是类。 因此,从技术上讲,我们现在有四类对象。
对象和类之间的有什么区别? 类描述对象。 它们就像创建对象的蓝图。 您前面的桌子上可能坐着三个橙子。 每个橙子都是一个不同的对象,但是所有三个橙子都具有与一类相关的属性和行为:普通橙子。
可以使用统一建模语言(始终称为 UML)来描述,这是库存系统中四类对象之间的关系,因为三个字母的首字母缩写永远不会消失 样式)类图。 这是我们的一流图:

该图显示了橙色以某种方式与篮子关联,并且苹果也以某种方式与桶关联。 关联是两个类关联的最基本方法。
UML 在管理人员中非常流行,偶尔会被程序员贬低。 UML 图的语法通常很明显。 您无需阅读教程即可(主要)了解看到的内容。 UML 也很容易绘制,并且非常直观。 毕竟,许多人在描述类及其关系时,自然会在它们之间画线并绘制框。 具有基于这些直观图表的标准,可使程序员轻松与设计师,经理和彼此进行沟通。
但是,一些程序员认为 UML 是浪费时间。 他们以迭代开发为例,他们认为,花哨的 UML 图中完成的正式规范在实施之前将是多余的,维护这些正式图只会浪费时间,而不会使任何人受益。
根据所涉及的公司结构,这可能是正确的,也可能不是正确的。 但是,每个由一个以上人员组成的编程团队有时都必须坐下来,逐一讨论当前正在研究的子系统的详细信息。 在这些集思广益的会议中,UML 对于快速,轻松地进行交流非常有用。 即使是那些嘲笑正式类图的组织,也倾向于在他们的设计会议或团队讨论中使用一些非正式的 UML 版本。
此外,您将不得不与之沟通的最重要的人就是您自己。 我们所有人都认为我们可以记住我们所做的设计决策,但是总会有为什么这样做? 时刻藏在我们的未来中。 如果我们保留纸屑,就在开始设计时就进行了初步的制图,那么我们最终会发现它们是有用的参考。
但是,本章并不旨在成为 UML 的教程。 Internet 上有许多此类书籍,以及有关该主题的大量书籍。 UML 涵盖的范围远远超过类图和对象图。 它还具有用例,部署,状态更改和活动的语法。 在此面向对象设计的讨论中,我们将处理一些常见的类图语法。 您会发现可以通过示例了解结构,并且可以在自己的团队或个人设计会议中下意识地选择 UML 启发式语法。
我们最初的图虽然正确,但并没有提醒我们苹果在桶中进入或单个苹果可以进入多少桶。它仅告诉我们苹果与桶相关。 类之间的关联通常很明显,不需要进一步解释,但是我们可以选择根据需要添加进一步的说明。
UML 的优点在于,大多数事情都是可选的。 我们只需要在图中指定尽可能多的信息以适合当前情况即可。 在快速的白板会议中,我们可能会在框之间快速画线。 在正式文档中,我们可能会更详细。 对于苹果和木桶,我们可以很确信该关联为:许多苹果放入一桶中,但只是为了确保没有人与之混淆,一个苹果将破坏一桶[,我们可以增强该图,如下所示:

该图告诉我们,橙子装在篮子中,带有一个小箭头,该箭头指示所要装的东西。 它还告诉我们可以在关系两侧的关联中使用的对象的数量。 一个篮子可以容纳许多(以 ***** 表示)橙色对象。 任何一个橙色都可以恰好放入一个篮子中。 此数字称为对象的多重性。 您可能还会听到它被描述为基数。 这些实际上是稍微不同的术语。 基数是指集合中项目的实际数量,而多重性则指定该数量可以多小。
我经常忘记多重性在关系的哪一边。 最接近一个类的多样性是可以与关联另一端的中的任何一个对象关联的该类对象的数量。 对于苹果进入桶关联,从左到右读取,苹果类的许多实例(即许多苹果对象)可以进入任何一个苹果桶。 从右向左读取,可以将一个桶与任何一个苹果关联。
指定属性和行为
现在,我们掌握了一些基本的面向对象术语。 对象是可以相互关联的类的实例。 对象实例是具有其自己的数据和行为集的特定对象。 我们面前桌子上的特定橙子被称为是普通橙子的一个实例。 这很简单,但是与每个对象关联的这些数据和行为是什么?
数据描述对象
让我们从数据开始。 数据通常代表特定对象的个体特征。 类可以定义该类的所有对象共享的特定特征集。 对于给定的特性,任何特定对象都可以具有不同的数据值。 例如,我们桌上的三个橙子(如果我们还没有吃过)可能各自重量不同。 然后,橙色类可以具有权重属性。 Orange 类的所有实例都具有 weight 属性,但是每个 Orange 对此属性都有不同的值。 但是,属性不必唯一。 任何两个橙子的重量相同。 作为一个更现实的示例,代表不同客户的两个对象的“名字”属性可能具有相同的值。
属性通常称为成员或属性。 一些作者建议这些术语具有不同的含义,通常属性是可设置的,而属性是只读的。 在 Python 中,“只读”的概念毫无意义,因此在本模块中,我们将看到两个术语可以互换使用。 另外,正如我们将在第 5 章,“何时使用面向对象的编程”中讨论的那样,属性关键字在 Python 中对于特定类型的属性具有特殊含义。

在我们的水果清单应用中,果农可能想知道橙子来自哪个果园,何时采摘以及它的重量。 他们可能还想保持每个篮子的存放位置。 苹果可能具有颜色属性,并且桶的尺寸可能会不同。 其中一些属性可能还属于多个类(我们也可能想知道何时摘苹果),但是对于第一个示例,我们仅向类图添加一些不同的属性:

根据设计的详细程度,我们还可以为每个属性指定类型。 属性类型通常是大多数编程语言的标准原语,例如整数,浮点数,字符串,字节或布尔值。 但是,它们也可以表示数据结构,例如列表,树或图形,或者最值得注意的是其他类。 这是设计阶段可以与编程阶段重叠的区域。 一种编程语言中可用的各种原语或对象可能与其他语言中可用的不同。
通常,我们不需要在设计阶段就过度关注数据类型,因为在编程阶段会选择特定于实现的细节。 通用名称通常足以进行设计。 如果我们的设计需要列表容器类型,则 Java 程序员可以在实现时选择使用LinkedList或ArrayList,而 Python 程序员(就是我们!)可以在list内置和[ 一个tuple。
到目前为止,在我们的水果农场示例中,我们的属性都是基本的基本元素。 但是,我们可以使一些隐式属性成为显式属性-关联。 对于给定的橙色,我们可能具有一个属性,其中包含保存该橙色的购物篮。
行为就是行动
现在,我们知道什么是数据,但是什么是行为? 行为是可能在对象上发生的动作。 可以在特定类别的对象上执行的行为称为方法。 在编程级别,方法类似于结构化编程中的函数,但是它们神奇地可以访问与此对象关联的所有数据。 像函数一样,方法也可以接受参数并返回值。
方法的参数是对象的列表,需要将传递给并传递给被调用的方法(从调用对象传递来的对象通常称为自变量)。 方法使用这些对象来执行其打算执行的任何行为或任务。 返回的值是该任务的结果。
我们已经将“比较苹果和橘子”的例子扩展到一个基本的(如果牵强)清单应用中。 让我们进一步伸展一下,看看它是否破裂。 可以将与橙色关联的一种动作是选择动作。 如果您考虑实现,则选择会通过更新橙色的篮子属性,并将该橙色添加到桔子列表中,将橙色放入篮子中 在篮子上。 因此,选秀权需要知道它正在处理哪个篮筐。 为此,我们为选择方法提供了篮子参数。 由于我们的水果农也出售果汁,因此我们可以在橙中添加挤压方法。 挤压时,挤压可能会返回取回的果汁量,同时还从其中的篮子中取出橙色。
篮子可以有卖出动作。 当篮子被出售后,我们的库存系统可能会更新尚未指定的对象上的某些数据,以进行会计和利润计算。 另外,我们的橙子篮子在出售之前可能会变质,因此我们添加了丢弃方法。 让我们将这些方法添加到图中:

将模型和方法添加到单个对象可以使我们创建交互对象的系统。 系统中的每个对象都是某个类的成员。 这些类指定对象可以保存哪些数据类型以及可以在对象上调用哪些方法。 每个对象中的数据可以处于与同一类的其他对象不同的状态,并且由于状态不同,每个对象对方法调用的反应也可能不同。
面向对象的分析和设计都是关于弄清楚那些对象是什么以及它们应该如何交互。 下一节将介绍可用于使这些交互尽可能简单直观的原理。
隐藏详细信息并创建公共界面
在面向对象的设计中对对象建模的关键的目的是确定该对象的公共接口是什么。 接口是其他对象可以用来与该对象进行交互的属性和方法的集合。 它们不需要(通常也不允许)访问对象的内部工作原理。 电视是现实世界中常见的示例。 我们与电视的接口是遥控器。 遥控器上的每个按钮代表一种可以在电视对象上调用的方法。 当我们作为调用对象访问这些方法时,我们不知道或不在乎电视是从天线,电缆连接还是卫星天线接收信号。 我们不在乎发送什么电子信号来调节音量,也不管声音是发给扬声器还是耳机。 如果我们打开电视来访问内部工作原理(例如,将输出信号分配到外部扬声器和一组耳机),将使保修无效。
隐藏对象的实现或功能性详细信息的处理适当地称为信息隐藏。 有时也将其称为封装,但是封装实际上是一个更全面的术语。 封装的数据不一定是隐藏的。 封装实际上就是创建一个胶囊,因此可以考虑创建一个时间胶囊。 如果将一堆信息放入时间囊中,将其锁定并掩埋,则它们将被封装并且信息将被隐藏。 另一方面,如果时间胶囊没有被掩埋并被解锁或由透明塑料制成,则其内部的物品仍将被封装,但是不会隐藏任何信息。
封装与信息隐藏之间的区别在很大程度上无关紧要,尤其是在设计级别上。 许多实用参考文献可互换使用这些术语。 作为 Python 程序员,我们实际上并没有隐藏真正的信息,(我们将在第 2 章和 Python 中的对象中讨论其原因),因此更笼统的定义是 用于封装是合适的。
但是,公共接口非常重要。 需要对其进行仔细设计,因为将来很难对其进行更改。 更改接口将破坏正在调用该接口的所有客户端对象。 我们可以更改我们喜欢的所有内部结构,例如使其更有效,或者通过网络以及本地访问数据,并且客户端对象仍可以使用公共接口与其进行对话,而无需进行修改。 另一方面,如果我们通过更改公共访问的属性名称或通过更改方法可以接受的参数的顺序或类型来更改接口,则也必须修改所有客户端对象。 在讨论公共接口时,请保持简单。 始终根据对象的易用性(而不是编码的难度)来设计对象的界面(此建议也适用于用户界面)。
请记住,程序对象可能代表真实的对象,但是并不能使它们成为真实的对象。 他们是模特。 建模最大的天赋之一就是能够忽略无关的细节。 我小时候建造的模型车看起来像是一辆 1956 年的雷鸟,但它没有运转,并且传动轴也没有转动。 在我开始开车之前,这些细节过于复杂且无关紧要。 该模型是真实概念的抽象。
抽象是另一个与封装和信息隐藏有关的面向对象的概念。 简而言之,抽象意味着处理最适合给定任务的细节级别。 这是从内部细节中提取公共接口的过程。 汽车驾驶员需要与转向,油门踏板和制动器互动。 电动机,传动系统和制动子系统的工作对驾驶员而言无关紧要。 另一方面,机械师则在不同的抽象水平上工作,调整引擎并释放刹车。 这是汽车的两个抽象级别的示例:

现在,我们有几个新术语引用了相似的概念。 将所有这些术语概括为两句话:抽象是使用独立的公共接口和私有接口封装信息的过程。 专用接口可能会隐藏信息。
从所有这些定义中吸取的重要教训是,使我们的模型对于其他必须与之交互的对象而言是可以理解的。 这意味着会细心注意细节。 确保方法和属性的名称合理。 分析系统时,对象通常代表原始问题中的名词,而方法通常是动词。 属性通常可以用作形容词,尽管如果属性引用的是当前对象一部分的另一个对象,则它仍可能是名词。 相应地命名类,属性和方法。
不要尝试对将来可能有用的对象或动作建模。 对系统需要执行的那些任务进行精确建模,并且设计自然会倾向于具有适当抽象级别的任务。 这并不是说我们不应该考虑将来可能进行的设计修改。 我们的设计应该是开放式的,以便可以满足将来的要求。 但是,在抽象接口时,请尝试完全建模需要建模的对象,仅此而已。
设计界面时,请尝试将自己放在对象的鞋子中,并想象该对象对隐私具有强烈的偏好。 除非您认为拥有它们符合您的最大利益,否则不要让其他对象访问有关您的数据。 除非确定您希望他们能够对您执行此操作,否则不要给他们一个界面来强迫您执行特定任务。
组成
到目前为止,我们学会了将系统设计为一组交互对象,其中每个交互都涉及在适当的抽象级别上查看对象。 但是我们还不知道如何创建这些抽象级别。 有多种方法可以做到这一点。 但是,即使大多数设计模式都依赖于两种基本的面向对象的原理,即组成和继承。 合成比较简单,因此让我们开始吧。
合成是将多个对象收集在一起以创建一个新对象的行为。 当一个对象是另一对象的一部分时,合成通常是一个不错的选择。 在机械示例中,我们已经看到了构图的第一个提示。 汽车由发动机,变速器,起动机,前大灯和挡风玻璃以及许多其他部分组成。 发动机又由活塞,曲轴和气门组成。 在此示例中,组合是提供抽象级别的好方法。 汽车对象可以提供驾驶员所需的界面,同时还可以访问其组成部分,从而提供了适合技工的更深层次的抽象。 如果技工需要更多信息来诊断问题或调整发动机,则这些零件当然可以进一步分解。
这是组成的常见入门示例,但在设计计算机系统时并不太有用。 物理对象很容易分解为组件对象。 至少从古希腊人最初假设原子是物质的最小单位开始,人们就开始这样做了(当然,他们没有机会使用粒子加速器)。 计算机系统通常不如物理对象复杂,但是在这种系统中识别组件对象并不是自然而然的事情。
面向对象系统中的对象有时代表物理对象,例如人,书或电话。 但是,它们更多时候代表抽象的想法。 人们有名字,书有书名,电话是用来打电话的。 呼叫,标题,帐户,名称,约会和付款通常不被视为物理世界中的对象,但它们都是计算机系统中经常建模的组件。
让我们尝试建模一个更加面向计算机的示例,以查看实际的合成。 我们将研究计算机化的国际象棋游戏的设计。 在 80 年代和 90 年代,这是一种非常流行的消遣方式。 人们预言,计算机将有一天能够击败人类的国际象棋大师。 当这种情况在 1997 年发生(IBM 的深蓝击败世界象棋冠军加里·卡斯帕罗夫)时,尽管在计算机和人类象棋玩家之间仍存在竞争,对这个问题的兴趣却减弱了。 (计算机通常会赢。)
作为基本的高级分析,使用棋盘组在 8 X 8 网格中包含 64 个位置,在两个玩家之间进行棋盘游戏。 棋盘可以有两组可移动的十六块棋子,由两个玩家以不同的方式交替交替进行。 每块可以取其他块。 每次转动后,将要求该板在计算机屏幕上进行绘制。
我已经使用斜体标识了描述中的某些可能对象,并使用了粗体标识了一些关键方法。 这是将面向对象的分析转变为设计的第一步。 在这一点上,为了强调构图,我们将专注于董事会,而不必过多担心球员或不同类型的棋子。
让我们从尽可能高的抽象水平开始。 我们有两个玩家轮流做出动作,与国际象棋互动:

这是什么? 它看起来不太像我们以前的类图。 那是因为它不是一个类图! 这是和对象图,也称为实例图。 它描述了处于特定时间状态的系统,并描述了对象的特定实例,而不是类之间的交互。 记住,两个玩家都是同一个班级的成员,因此班级图看起来有些不同:

该图显示,恰好有两个玩家可以与一个国际象棋互动。 这也表明任何一位玩家一次只能玩一副国际象棋。
但是,我们讨论的不是 UML 的组成,因此让我们考虑一下国际象棋集合的组成。 我们目前不在乎玩家的组成。 我们可以假设玩家具有心脏和大脑以及其他器官,但是这些与我们的模型无关。 确实,没有什么能阻止所说的球员成为深蓝的人,既没有心脏也没有大脑。
这样,国际象棋的集合由一个棋盘和 32 个棋子组成。 董事会还包括 64 个职位。 您可能会争辩说棋子不是国际象棋集合的一部分,因为您可以用另一组棋子替换国际象棋集合中的棋子。 尽管在计算机化的国际象棋版本中这不太可能或不可能,但它为我们介绍了聚合。
聚合几乎完全像一样。 区别在于聚合对象可以独立存在。 一个职位不可能与另一个棋盘相关联,因此我们说棋盘是由职位组成的。 但是据说这些棋子可能独立于国际象棋棋盘而存在,但据说与该棋盘存在着总体关系。
区分聚集和组成的另一种方法是考虑对象的寿命。 如果复合(外部)对象控制何时(HTG0)创建和销毁相关(内部)对象,则最适合使用合成。 如果相关对象是独立于复合对象创建的,或者可以使该对象持久存在,则聚合关系更有意义。 另外,请记住,组成是聚合。 聚集只是组合的一种更一般的形式。 任何复合关系也是一种聚合关系,但反之亦然。
让我们描述当前的国际象棋集合组成,并向对象添加一些属性以保持复合关系:

组成关系在 UML 中表示为实心菱形。 空心菱形代表总体关系。 您会注意到,棋盘和棋子是作为国际象棋集合的一部分存储的,与对它们的引用作为属性存储在国际象棋集合上的完全相同。 这再次表明,在实践中,一旦超过设计阶段,聚集和组成之间的区别通常就不重要了。 实施后,它们的行为几乎相同。 但是,当您的团队讨论不同对象如何交互时,它可以帮助区分两者。 通常,您可以将它们视为同一事物,但是当您需要区分它们时,很高兴知道它们之间的区别(这是工作中的抽象)。
继承
我们讨论了对象之间的三种关系类型:关联,组合和聚合。 但是,我们尚未完全指定我们的国际象棋,并且这些工具似乎没有提供我们所需的全部功能。 我们讨论了玩家可能是人类还是可能是具有人工智能功能的软件的可能性。 似乎说玩家是与人相关联的,或者说人工智能实现是玩家对象的的一部分,这似乎是不对的。 我们真正需要的是能够说“深蓝是播放器”或“加里·卡斯帕罗夫是播放器”的能力。**
是通过继承形成的关系。 继承是面向对象程序设计中最著名,最著名和过度使用的关系。 继承有点像家谱。 我祖父的姓氏是 Phillips ,父亲继承了这个名字。 我从他那里继承了它(还有蓝色的眼睛和对写作的爱好)。 在面向对象的编程中,一个类可以从另一个类继承属性和方法,而不是从一个人继承特征和行为。
例如,我们的国际象棋中有 32 个棋子,但是只有六种不同类型的棋子(棋子,车子,主教,骑士,国王和王后),每一种棋子在移动时的行为都不同。 所有这些类别的棋子都具有属性,例如颜色和它们所属的棋盘,但是在棋盘上绘制时它们也具有独特的形状,并且可以进行不同的移动。 让我们看看如何从 Piece 类继承六种类型的片断:

空心箭头表示各个类别的碎片均继承自碎片类别。 所有子类型都自动具有从基类继承的国际象棋集和颜色属性。 每一块都提供不同的形状属性(在渲染板时在屏幕上绘制),并且每转一圈都有不同的移动方法将其移动到板上的新位置。
实际上,我们知道 Piece 类的所有子类都需要具有 move 方法。 否则,当木板试图移动一块时,就会感到困惑。 我们可能想创建一个新版本的国际象棋游戏,其中又增加了一块(向导)。 我们当前的设计允许我们在不使用 move 方法的情况下设计该作品。 当董事会要求棋子移动时,棋盘就会窒息。
我们可以通过在 Piece 类上创建一个伪移动方法来实现这一点。 然后,子类可以使用更具体的实现来覆盖此方法。 例如,默认实现可能会弹出一条错误消息:该部件无法移动。
子类型中的重写方法允许开发非常强大的面向对象的系统。 例如,如果我们想用人工智能实现一个玩家类,我们可以提供一个calculate_move方法,该方法采用 Board 对象并决定将哪一块移动到哪里。 一个非常基础的班级可能会随机选择一个片段和一个方向并相应地移动它。 然后,我们可以使用 Deep Blue 实现在子类中重写此方法。 头等舱适合与未加工的初学者对抗,后者将挑战大师级。 重要的是,该类中的其他方法(例如,通知董事会有关选择了哪个动作的方法)无需更改; 该实现可以在两个类之间共享。
对于国际象棋棋子,提供 move 方法的默认实现实际上没有任何意义。 我们需要做的就是指定所有子类都需要 move 方法。 这可以通过使用声明为抽象的 move 方法使 Piece 成为抽象类来实现。 抽象方法基本上说:“我们要求此方法存在于任何非抽象子类中,但我们拒绝在此类中指定实现。”
实际上,可以创建一个根本不实现任何方法的类。 这样的课程只会告诉我们该课程应该做什么,但绝对不提供有关如何操作的建议。 在面向对象的说法中,此类称为接口。
继承提供抽象
让我们探讨面向对象的 Argot 中最长的单词。 多态性是根据实现的子类来区别对待类的能力。 我们已经在所描述的 Pieces 系统中看到了。 如果进一步进行设计,我们可能会看到开发板对象可以接受来自播放器的移动,并在该棋子上调用移动功能。 董事会不必知道要处理的是哪种类型。 它所要做的只是调用 move 方法,适当的子类将把它作为 Knight 或 Pawn 来处理。
多态性很酷,但是这个词在 Python 编程中很少使用。 Python 超越了一个额外的步骤,允许将对象的子类视为父类。 用 Python 实现的板可以采用具有移动方法的任何对象,无论是主教块,汽车还是鸭子。 当移动时,Bishop 将在板上沿对角线移动,汽车将行驶到某个位置,而鸭子将根据其心情游泳或飞行。
Python 中这种类型的多态性通常称为鸭子键入:“如果它走路像鸭子一样游泳,或者像鸭子一样游泳,那就是鸭子”。 我们不在乎它是否真的是是鸭(继承),只是它会游泳或走路。 鹅和天鹅可能很容易提供我们想要的鸭子般的行为。 这使未来的设计人员可以创建新的鸟类类型,而无需实际指定水生鸟类的继承层次。 它还使他们能够创建完全不同于原始设计人员计划的插入行为。 例如,未来的设计师可能能够制作出具有相同界面的步行游泳企鹅,而不会暗示企鹅是鸭子。
多重继承
当我们想到我们自己的家族树中的继承时,我们可以看到我们从多个父母那里继承了特征。 当陌生人告诉一个骄傲的母亲她的儿子有“他的父亲的眼睛”时,她通常会回答“是的,但他让我流鼻涕”。
面向对象的设计还可以具有多重继承的功能,它允许子类从多个父类继承功能。 实际上,多重继承可能是一件棘手的事情,并且某些编程语言(最著名的是 Java)严格禁止这样做。 但是,多重继承可以有其用途。 通常,它可用于创建具有两组不同行为的对象。 例如,可以通过从两个单独的scanner和faxer对象继承来创建旨在连接到扫描仪并发送扫描文档传真的对象。
只要两个类具有不同的接口,子类从这两个类继承通常不会有害。 但是,如果我们从提供重叠接口的两个类继承,则会变得混乱。 例如,如果我们有一个具有move方法的摩托车类,并且也具有move方法的船类,并且想要将它们合并到最终的两栖车辆中,那么所得的类如何知道该怎么做 当我们叫move时? 在设计级别,需要对此进行解释,在实现级别,每种编程语言都有不同的方式来决定调用哪个父类的方法或以什么顺序进行调用。
通常,处理它的最好方法是避免它。 如果您的设计如此显示,则可能是做错了。 退后一步,再次分析系统,看看是否可以删除多继承关系,而采用其他关联或复合设计。
继承是扩展行为的强大工具。 与早期的范例相比,它也是面向对象设计最可销售的进步之一。 因此,它通常是面向对象的程序员所追求的第一个工具。 但是,重要的是要认识到,拥有锤子不会将螺钉变成钉子。 继承是完美的解决方案,因为显然是关系,但是可以滥用。 程序员经常使用继承在两种对象之间共享代码,而这两种对象之间只有很远的关系,而与的关系就不存在了。 尽管这不一定是一个糟糕的设计,但它是一个极好的机会,询问他们为什么决定以这种方式设计,以及使用其他关系或设计模式是否更合适。
案例研究
让我们通过在一个真实世界的示例上进行几次面向对象设计的迭代,将的所有新的面向对象知识结合在一起。 我们将要建模的系统是一个图书馆目录。 几个世纪以来,图书馆一直在追踪其库存,最初使用的是卡片目录,最近使用的是电子清单。 现代图书馆有基于网络的目录,我们可以在家里查询。
让我们从分析开始。 当地的图书管理员要求我们编写一个新的卡片目录程序,因为他们古老的基于 DOS 的程序丑陋且已过时。 这不会给我们太多的细节,但是在我们开始寻求更多信息之前,让我们考虑一下我们已经了解的关于库目录的知识。
目录包含书籍清单。 人们搜索它们以查找具有特定标题或特定作者的某些主题的书籍。 可以通过国际标准书号(ISBN)来唯一标识书籍。 每个模块都有一个杜威十进制系统(DDS)编号,以帮助您在特定的机架上找到它。
这个简单的分析告诉我们系统中一些明显的对象。 我们迅速将图书识别为最重要的对象,并已经提到了几个属性,例如作者,书名,主题,ISBN 和 DDS 编号,以及目录是图书的管理者。
我们还注意到可能需要或不需要在系统中建模的其他一些对象。 为了进行分类,我们需要按作者搜索模块的就是模块上的author_name属性。 但是,作者也是对象,我们可能要存储有关作者的其他数据。 在思考这一点时,我们可能还记得有些书有多位作者。 突然之间,在对象上具有单个author_name属性的想法似乎有些愚蠢。 与每个模块相关的作者列表显然是一个更好的主意。
作者和模块之间的关系显然是关联的,因为您永远不会说“模块是作者”(这不是继承),并且说“模块有作者”,尽管在语法上是正确的,但这并不意味着作者是其中的一部分 书(不是汇总)。 实际上,任何一位作者可能与多本书相关联。
我们还应注意名词(名词总是对象的良好候选者)架子。 架子是需要在编目系统中建模的对象吗? 我们如何识别单个架子? 如果某个模块存储在一个架子的末尾,然后又由于在上一个架子中插入了另一个模块而又移到下一个架子的开头,会发生什么情况?
DDS 旨在帮助在图书馆中查找实体书籍。 因此,与模块一起存储 DDS 属性应该足以找到它,而不管它存储在哪个架子上。 因此,至少在目前,我们可以从竞争对象列表中删除货架。
系统中的另一个可疑对象是用户。 我们是否需要了解有关特定用户的任何信息,例如其名称,地址或过期书籍列表? 到目前为止,图书馆员只告诉我们他们想要目录。 他们对跟踪订阅或过期通知一无所知。 在我们的内心深处,我们还注意到作者和用户都是特定的人。 将来可能会有有用的继承关系。
为了进行分类,我们决定暂时不需要识别用户。 我们可以假设用户将搜索目录,但是除了提供一个允许他们搜索的界面之外,我们不必在系统中对其进行建模。
我们已经在模块上确定了一些属性,但是目录具有哪些属性? 有没有一个图书馆有多个目录? 我们需要唯一地标识它们吗? 显然,目录必须以某种方式包含其中包含的书籍的集合,但是此列表可能不是,也不是公共接口的一部分。
行为呢? 目录显然需要一种搜索方法,可能需要针对作者,标题和主题的搜索方法。 书上有行为吗? 需要预览方法吗? 还是可以通过首页属性而不是方法来标识预览?
前面讨论中的问题都是面向对象分析阶段的一部分。 但是与这些问题混杂在一起,我们已经确定了设计中的一些关键对象。 确实,您刚刚看到的是分析和设计之间的一些微迭代。
这些迭代很可能都在与图书馆员的初次会面中发生。 但是,在这次会议之前,我们已经可以为我们具体确定的对象设计出最基本的设计:

有了这个基本图表和一支铅笔来进行交互式改进,我们与图书馆员会面。 他们告诉我们,这是一个好的开始,但是图书馆不仅提供书籍,还提供 DVD,杂志和 CD,而这些书都没有 ISBN 或 DDS 编号。 但是,所有这些类型的项目都可以通过 UPC 编号唯一标识。 我们提醒馆员,他们必须在架子上找到物品,而这些物品可能不是 UPC 整理的。 馆员解释说,每种类型的组织方式都不同。 CD 大多是有声读物,库存只有几十张,因此按作者的姓氏组织。 DVD 分为各种类型,并按标题进一步组织。 杂志按标题分类,然后按卷数和发行号进行细化。 正如我们猜想的那样,书籍是按 DDS 编号整理的。
没有以前的面向对象设计经验,我们可能会考虑将 DVD,CD,杂志和书籍的单独列表添加到我们的目录中,然后依次搜索每个列表。 麻烦的是,除了某些扩展属性之外,并标识项目的物理位置,这些项目的行为都一样。 这是继承的工作! 我们快速更新我们的 UML 图:

图书馆员理解我们的示意图的要点,但对定位功能有些困惑。 我们使用一个特定的用例来解释,其中用户正在搜索单词“ bunnies”。 用户首先将搜索请求发送到目录。 目录查询其内部项目列表,并找到标题中带有“ bunnies”的模块和 DVD。 此时,目录并不关心目录中是否装有 DVD,模块,CD 或杂志。 就目录而言,所有项目都是相同的。 但是,用户想知道如何查找实际项目,因此如果仅返回标题列表,则该目录将被删除。 因此,它在已发现的两个项目上调用定位方法。 模块的定位方法返回一个 DDS 编号,该编号可用于查找容纳模块的架子。 通过返回 DVD 的类型和标题来定位 DVD。 然后,用户可以访问 DVD 部分,找到包含该类型的部分,并找到按标题排序的特定 DVD。
正如我们所解释的,我们绘制了一个 UML 序列图的示意图,解释了各种对象如何通信:

其中,类图描述了类之间的关系,而序列图描述了对象之间传递的特定消息序列。 悬挂在每个对象上的虚线是生命线,用于描述对象的生存期。 每条生命线上的较宽方框表示该对象中的活动处理(没有方框的情况下,该对象基本上处于空闲状态,等待发生的事情)。 生命线之间的水平箭头表示特定的消息。 实线箭头表示被调用的方法,而带实心头的虚线箭头表示方法的返回值。
半箭头指示发送到对象或从对象发送的异步消息。 异步消息通常意味着第一个对象在第二个对象上调用一个方法,该方法立即返回。 经过一些处理后,第二个对象在第一个对象上调用一个方法来为其赋予一个值。 这与常规方法调用形成对照,后者在方法中进行处理并立即返回一个值。
像所有 UML 图一样,序列图最好仅在需要时使用。 为了绘制图表,在中绘制 UML 图毫无意义。 但是,当您需要在两个对象之间进行一系列交互时,顺序图是一个非常有用的工具。
不幸的是,到目前为止,我们的类图仍然是一个混乱的设计。 我们注意到 DVD 上的演员和 CD 上的艺术家都是各种类型的人,但是与模块作者的待遇不同。 馆员还提醒我们,它们的大多数 CD 是有声读物,有作者而不是艺术家。
我们如何与交往促成头衔的各种人士? 一个明显的实现是使用人物的姓名和其他相关细节创建一个Person类,然后为艺术家,作家和演员创建该子类。 但是,在这里继承真的必要吗? 出于搜索和分类目的,我们并不在乎表演和写作是两个截然不同的活动。 如果我们正在进行经济模拟,则可以给定演员和作者类,以及不同的calculate_income和perform_job方法,这很有意义,但是出于分类目的,知道这个人如何对物品做出贡献可能就足够了。 我们认识到所有项目都有一个或多个Contributor对象,因此我们将作者关系从模块移至其父类:

贡献者 / LibraryItem 关系的多重性是多对多,如两端的 ***** 字符所示 关系。 任何一个图书馆项目都可能有多个贡献者(例如,DVD 中的几个演员和一个导演)。 许多作者写了许多书,因此它们会附加到多个图书馆项目中。
尽管的微小更改看上去更简洁,更简单,但是却丢失了一些重要信息。 我们仍然可以确定是谁为特定库项目做出了贡献,但是我们不知道他们是如何做出贡献的。 他们是导演还是演员? 他们是写音频模块,还是为模块叙述的声音?
如果我们只需在贡献者类上添加contributor_type属性,那将是很好的选择,但是在与既创作书籍又执导电影的多才多艺的人打交道时,这种方法将分崩离析。
一种选择是向每个 LibraryItem 子类添加属性,以保存我们所需的信息,例如图书上的作者或 Artist ]放在 CD 上,然后使与这些属性的关系都指向 Contributor 类。 问题在于,我们失去了很多多态的优雅。 如果要列出某个项目的贡献者,则必须寻找该项目的特定属性,例如作者或 Actors。 我们可以通过在子类可以覆盖的 LibraryItem 类上添加 GetContributors 方法来缓解这种情况。 这样,目录就不必知道对象正在查询什么属性。 我们已经抽象了公共接口:

仅查看这个类图,就好像我们在做错什么。 它又大又脆弱。 它可以满足我们的所有需求,但感觉很难维护或扩展。 关系太多,修改任何一个类别都会影响太多的类别。 看起来像意大利面条和肉丸。
现在,我们已经探讨了将继承作为一种选择,并发现了它的需要,我们可以回顾一下以前的基于组合的图表,其中贡献者直接附加到 LibraryItem 上。 经过一番思考,我们可以看到我们实际上只需要向一个全新的类添加一个关系即可确定贡献者的类型。 这是面向对象设计中的重要一步。 现在,我们在设计中添加了一个类,该类旨在支持其他对象,而不是对初始需求的任何部分进行建模。 我们正在重构设计,以简化系统中的对象,而不是现实生活中的对象。 重构是程序或设计维护中必不可少的过程。 重构的目的是通过移动代码,删除重复的代码或复杂的关系来改进设计,以支持更简单,更优雅的设计。
这个新的类由贡献者和一个额外的属性组成,该属性标识人员对给定的 LibraryItem 做出的贡献的类型。 一个特定的 LibraryItem 可以有很多这样的贡献,并且一个贡献者可以以相同的方式为不同的条目做出贡献。 该图很好地传达了这种设计:

首先,这种组合关系看起来不如基于继承的关系自然。 但是,它的优点是允许我们添加新的贡献类型而无需在设计中添加新的类。 当子类具有某种专长时,继承最有用。 专业化是在子类上创建或更改属性或行为,以使其与父类有所不同。 创建一堆只用于识别不同类型对象的空类似乎很愚蠢(这种态度在 Java 和其他“一切都是对象”程序员中并不普遍,但在更实际的 Python 设计人员中很常见)。 如果我们看一下图的继承版本,我们可以看到一堆实际上不做任何事情的子类:

有时,识别何时不使用面向对象的原理很重要。 这个何时不使用继承的示例很好地提醒了对象只是工具,而不是规则。




三、Python 对象
因此,我们现在手头有一个设计,可以将其变成可行的程序了! 当然,这种情况通常不会发生。 我们将在整个模块中看到有关良好软件设计的示例和提示,但我们的重点是面向对象的编程。 因此,让我们看一下允许我们创建面向对象软件的 Python 语法。
完成本章后,我们将了解:
- 如何在 Python 中创建类和实例化对象
- 如何向 Python 对象添加属性和行为
- 如何将类组织成包和模块
- 如何建议人们不要破坏我们的数据
创建 Python 类
我们没有编写太多 Python 代码来意识到 Python 是一种非常“干净”的语言。 当我们想做某事时,我们只需要做,而无需进行大量设置。 您可能已经看到,Python 中无处不在的“ hello world”仅仅是一行。
同样,Python 3 中最简单的类如下所示:
class MyFirstClass:
pass
这是我们第一个面向对象的程序! 类定义以class关键字开头。 这之后是(由我们选择的)标识类的名称,并以冒号结尾。
注意
类名必须遵循标准的 Python 变量命名规则(它必须以字母或下划线开头,并且只能由字母,下划线或数字组成)。 此外,Python 样式指南(在网络上搜索“ PEP 8”)建议使用 CamelCase 表示法将类命名为(以大写字母开头;任何后续单词也应以 首都)。
在类定义行之后紧跟着类内容。 与其他 Python 构造一样,缩进用于分隔类,而不是许多其他语言使用的花括号或方括号。 除非有令人信服的理由,否则使用四个空格进行缩进(例如,与使用制表符作为缩进的其他人的代码相适应)。 只要按下选项卡键,任何体面的程序编辑器都可以配置为插入四个空格。
由于我们的第一堂课实际上什么也没做,因此我们仅在第二行使用pass关键字来表示不需要采取进一步的措施。
我们可能认为这个最基本的类没有什么可以做的,但是它确实允许我们实例化该类的对象。 我们可以将类加载到 Python 3 解释器中,以便我们可以交互地进行播放。 为此,将前面提到的类定义保存到名为first_class.py的文件中,然后运行命令python -i first_class.py。 -i参数告诉 Python“运行代码,然后放到交互式解释器中”。 以下解释器会话演示了与此类的基本交互:
>>> a = MyFirstClass()
>>> b = MyFirstClass()
>>> print(a)
<__main__.MyFirstClass object at 0xb7b7faec>
>>> print(b)
<__main__.MyFirstClass object at 0xb7b7fbac>
>>>
此代码从新类实例化了两个对象,分别名为a和b。 创建类的实例很简单,只需键入类名,然后输入一对括号即可。 它看起来很像一个普通的函数调用,但是 Python 知道我们是在“调用”一个类而不是一个函数,因此它知道它的工作是创建一个新对象。 当打印时,这两个对象告诉我们它们是哪个类,以及它们住在什么内存地址。 内存地址在 Python 代码中使用不多,但是在这里,它们表明涉及两个不同的对象。
添加属性
现在,我们有了一个基本类,但是它毫无用处。 它不包含任何数据,并且不执行任何操作。 要为给定对象分配属性,我们该怎么做?
事实证明,我们不必在类定义中做任何特别的事情。 我们可以使用点表示法在实例化对象上设置任意属性:
class Point:
pass
p1 = Point()
p2 = Point()
p1.x = 5
p1.y = 4
p2.x = 3
p2.y = 6
print(p1.x, p1.y)
print(p2.x, p2.y)
如果运行此代码,最后的两个print语句将告诉我们两个对象的新属性值:
5 4
3 6
此代码创建一个空的Point类,不包含任何数据或行为。 然后,它创建该类的两个实例,并为每个实例分配x和y坐标以标识二维点。 为对象的属性分配值所需要做的就是使用 <对象>。 <属性> = <值> 语法。 有时将其称为点表示法。 该值可以是任何值:Python 原语,内置数据类型或其他对象。 它甚至可以是一个函数或另一个类!
让它做某事
现在,具有属性的对象很棒,但是面向对象的编程实际上是关于对象之间的交互的。 我们对调用导致这些属性发生事情的操作感兴趣。 现在是时候将行为添加到我们的类中了。
让我们在Point类上模拟几个动作。 我们可以从名为reset的方法开始,该方法将点移动到原点(原点是x和y均为零的点)。 这是一个很好的介绍性操作,因为它不需要任何参数:
class Point:
def reset(self):
self.x = 0
self.y = 0
p = Point()
p.reset()
print(p.x, p.y)
此print语句向我们显示了属性上的两个零:
0 0
Python 中的方法与函数的格式相同。 它以关键字def开头,后跟一个空格和方法名称。 接下来是一组包含参数列表的括号(我们稍后将讨论self参数),并以冒号结尾。 下一行缩进以包含方法内部的语句。 这些语句可以是在对象本身上操作的任意 Python 代码,以及在方法认为合适时传递的任何参数。
自言自语
方法和普通函数之间的区别是所有方法都有一个必填参数。 这个参数通常被命名为self。 我从未见过程序员为此变量使用任何其他名称(约定是非常强大的功能)。 但是,没有什么可以阻止您将其称为this甚至Martha。
方法的 self参数只是对该方法在其上调用的对象的引用。 我们可以像访问其他对象一样访问该对象的属性和方法。 这正是我们在设置self对象的x和y属性时在reset方法中所做的事情。
注意,当我们调用p.reset()方法时,不必将self参数传递给它。 Python 自动为我们解决了这一问题。 它知道我们正在p对象上调用方法,因此它将自动将该对象传递给该方法。
但是,方法实际上只是一个碰巧在类上的函数。 除了在对象上调用方法之外,我们还可以在类上调用函数,将我们的对象作为self参数显式传递:
p = Point()
Point.reset(p)
print(p.x, p.y)
输出与前面的示例相同,因为在内部,发生了完全相同的过程。
如果我们忘记在类定义中包含self参数,会发生什么? Python 将保释并显示一条错误消息:
>>> class Point:
... def reset():
... pass
...
>>> p = Point()
>>> p.reset()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: reset() takes no arguments (1 given)
错误消息可能不那么清晰(“傻瓜,您忘记了self参数”会提供更多信息)。 只要记住,当您看到指示缺少参数的错误消息时,首先要检查的是您是否在方法定义中忘记了self。
更多参数
那么,如何将多个参数传递给方法? 让我们添加一个新方法,该方法允许我们将点移动到任意位置,而不仅仅是原点。 我们还可以包含一个接受另一个Point对象作为输入并返回它们之间的距离的对象:
import math
class Point:
def move(self, x, y):
self.x = x
self.y = y
def reset(self):
self.move(0, 0)
def calculate_distance(self, other_point):
return math.sqrt(
(self.x - other_point.x)**2 +
(self.y - other_point.y)**2)
# how to use it:
point1 = Point()
point2 = Point()
point1.reset()
point2.move(5,0)
print(point2.calculate_distance(point1))
assert (point2.calculate_distance(point1) ==
point1.calculate_distance(point2))
point1.move(3,4)
print(point1.calculate_distance(point2))
print(point1.calculate_distance(point1))
最后的print语句为我们提供以下输出:
5.0
4.472135955
0.0
在这里发生了很多事情。 该类现在具有三种方法。 move方法接受x和y这两个参数,并在self对象上设置值,就像上一个示例中的旧reset方法一样。 现在,旧的reset方法调用move,因为重置只是向特定已知位置的移动。
calculate_distance方法使用不太复杂的勾股定理来计算两点之间的距离。 希望您理解数学(**2表示平方,math.sqrt计算平方根),但这不是我们当前关注的,学习如何编写方法的要求。
上一示例末尾的示例代码显示了如何使用参数调用方法:只需在括号内包含参数,然后使用相同的点符号即可访问该方法。 我只是选择了一些随机位置来测试方法。 测试代码调用每种方法,并将结果打印在控制台上。 assert功能是一个简单的测试工具; 如果assert之后的语句为False(或零,空或None),则程序将失败。 在这种情况下,无论哪个点称为另一点的calculate_distance方法,我们都使用它来确保距离是相同的。
初始化对象
如果我们没有使用move或直接访问它们,而没有明确在我们的Point对象上设置x和y位置,则我们会出现一个断点,没有实际位置。 当我们尝试访问它时会发生什么?
好吧,让我们尝试一下看看。 “尝试一下,看看”是 Python 学习中非常有用的工具。 打开您的交互式解释器,然后键入。 以下交互式会话显示了如果我们尝试访问缺少的属性会发生什么。 如果您将先前的示例另存为文件或正在使用该模块随附的示例,则可以使用命令python -i filename.py将其加载到 Python 解释器中:
>>> point = Point()
>>> point.x = 5
>>> print(point.x)
5
>>> print(point.y)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'Point' object has no attribute 'y'
好吧,至少它抛出了一个有用的例外。 我们将在第 4 章,“异常”中详细介绍异常。 您可能之前已经看过它们(尤其是普遍存在的 SyntaxError,意味着您键入的内容不正确!)。 此时,只需注意这意味着出了点问题。
输出对于调试很有用。 在交互式解释器中,它告诉我们在第 1 行发生的错误,该错误仅部分正确(在交互式会话中,一次仅执行一行)。 如果我们在文件中运行脚本,它将告诉我们确切的行号,从而很容易找到有问题的代码。 此外,它告诉我们错误是AttributeError,并给出了有用的信息,告诉我们该错误意味着什么。
我们可以捕获并从此错误中恢复,但是在这种情况下,感觉就像我们应该指定某种默认值一样。 也许每个新对象在默认情况下都应为reset(),或者如果我们可以强迫用户告诉我们在创建对象时这些位置应该是什么,那就更好了。
大多数面向对象的编程语言都具有构造函数的概念,这是一种特殊的方法,可在创建对象时创建并初始化该对象。 Python 有点不同; 它有一个构造函数和一个初始化程序。 除非您要执行某些操作,否则很少使用构造函数。 因此,我们将从初始化方法开始讨论。
Python 初始化方法与其他任何方法都相同,只是它有一个特殊的名称__init__。 前导和尾随的双下划线表示这是一种特殊的方法,Python 解释器会将其视为特殊情况。
注意
切勿使用前后双下划线来命名自己的函数。 对于 Python 而言,这可能毫无意义,但 Python 的设计人员将来总有可能会添加一个具有特定用途的功能,并且使用该名称时,您的代码将被破坏。
让我们从Point类上的初始化函数开始,该函数要求用户在实例化Point对象时提供x和y坐标:
class Point:
def __init__(self, x, y):
self.move(x, y)
def move(self, x, y):
self.x = x
self.y = y
def reset(self):
self.move(0, 0)
# Constructing a Point
point = Point(3, 5)
print(point.x, point.y)
现在,我们的观点永远离不开y坐标! 如果我们尝试在不包含适当初始化参数的情况下构造一个点,则它将失败,并出现参数不足错误,这与我们先前忘记self参数时收到的错误相似。
如果我们不想将这两个参数都设为必需,该怎么办? 好了,那么我们可以使用 Python 函数提供默认参数的相同语法。 关键字参数语法在每个变量名称后附加一个等号。 如果调用对象不提供此参数,则使用默认参数。 变量仍可用于该函数,但它们将具有在参数列表中指定的值。 这是一个例子:
class Point:
def __init__(self, x=0, y=0):
self.move(x, y)
在的大部分时间里,我们将初始化语句放在__init__函数中。 但是如前所述,Python 除了其初始化函数外,还具有构造函数。 您可能永远不需要使用其他 Python 构造函数,但这有助于了解它的存在,因此我们将简要介绍它。
相对于__init__,构造函数称为__new__,并且只接受一个参数。 所构造的类(在构造对象的之前将其称为,因此没有self参数)。 它还必须返回新创建的对象。 当涉及到元编程的复杂技术时,这具有有趣的可能性,但在日常编程中不是很有用。 在实践中,几乎不需要使用__new__,并且__init__就足够了。
自我解释
Python 是一种非常易于阅读的编程语言。 有人可能会说这是自我记录。 但是,在进行面向对象的编程时,编写 API 文档以清楚地总结每个对象和方法的工作非常重要。 使文档保持最新是很困难的。 最好的方法是将其正确写入我们的代码中。
Python 通过使用文档字符串支持此功能。 每个类,函数或方法标头都可以将标准 Python 字符串作为定义之后的第一行(以冒号结尾的行)。 该行的缩进应与以下代码相同。
Docstrings 只是用撇号(')或引号(")括起来的 Python 字符串。 通常,文档字符串很长,并且跨越多行(样式指南建议该行的长度不应超过 80 个字符),可以将其格式化为多行字符串,并用匹配的三撇号(''')或三引号( """)字符。
文档字符串应清楚,简洁地概述其描述的类或方法的目的。 它应该说明使用不立即明显的任何参数,也是包含如何使用 API的简短示例的好地方。 毫无疑问的 API 用户应该注意的任何警告或问题。
为了说明文档字符串的用法,我们将在本节结尾以我们完整记录的Point类:
import math
class Point:
'Represents a point in two-dimensional geometric coordinates'
def __init__(self, x=0, y=0):
'''Initialize the position of a new point. The x and y coordinates can be specified. If they are not, the
point defaults to the origin.'''
self.move(x, y)
def move(self, x, y):
"Move the point to a new location in 2D space."
self.x = x
self.y = y
def reset(self):
'Reset the point back to the geometric origin: 0, 0'
self.move(0, 0)
def calculate_distance(self, other_point):
"""Calculate the distance from this point to a second
point passed as a parameter.
This function uses the Pythagorean Theorem to calculate the distance between the two points. The distance is
returned as a float."""
return math.sqrt(
(self.x - other_point.x)**2 +
(self.y - other_point.y)**2)
尝试在交互式解释器中键入或加载(请记住,它是python -i filename.py)此文件。 然后,在 Python 提示符下输入help(Point)<enter>。 您应该可以很好地看到该类的格式文档,如以下屏幕截图所示:

模块和软件包
现在,我们知道了如何创建类和实例化对象,但是我们如何组织它们呢? 对于小型程序,我们可以将所有类放入一个文件中,并在文件末尾添加一个小脚本以启动它们的交互。 但是,随着项目的发展,在我们定义的许多类中很难找到一个需要编辑的类。 这是模块进入的地方。模块只是 Python 文件,仅此而已。 我们的小程序中的单个文件是一个模块。 两个 Python 文件是两个模块。 如果我们在同一文件夹中有两个文件,则可以从一个模块加载一个类,以在另一个模块中使用。
例如,如果我们正在构建一个电子商务系统,则可能会将大量数据存储在数据库中。 我们可以将所有与数据库访问相关的类和函数放在一个单独的文件中(我们称其为database.py)。 然后,我们的其他模块(例如,客户模型,产品信息和库存)可以从该模块导入类以访问数据库。
import语句用于从模块导入模块或特定的类或函数。 在上一节的Point类中,我们已经看到了这样的示例。 我们使用import语句获取 Python 的内置math模块,并在distance计算中使用其sqrt函数。
这是一个具体示例。 假设我们有一个名为database.py的模块,其中包含一个名为Database的类,以及另一个名为products.py的模块,该模块负责与产品相关的查询。 在这一点上,我们不需要考虑这些文件的内容。 我们所知道的是,products.py需要从database.py实例化Database类,以便它可以对数据库中的产品表执行查询。
import语句语法有多种变体,可用于访问该类:
import database
db = database.Database()
# Do queries on db
该版本将database模块导入到products名称空间(模块或函数中当前可访问的名称列表),因此可以使用database.<something>表示法访问database模块中的任何类或函数。 另外,我们可以使用from ... import语法仅导入所需的一个类:
from database import Database
db = Database()
# Do queries on db
如果由于某种原因products已经有一个名为Database的类,并且我们不想混淆这两个名称,则可以在products模块中使用该类时重命名该类:
from database import Database as DB
db = DB()
# Do queries on db
我们还可以在一个语句中导入多个项目。 如果我们的database模块还包含Query类,则可以使用以下两种方法导入这两个类:
from database import Database, Query
一些消息来源说,我们可以使用以下语法从database模块导入所有类和函数:
from database import *
不要这样 每个有经验的 Python 程序员都会告诉您,永远不要使用此语法。 他们将使用晦涩的理由,例如“它弄乱了名称空间”,这对于初学者来说没有太大意义。 了解为什么避免使用这种语法的一种方法是使用它,并在两年后尝试理解您的代码。 但是,现在我们可以快速解释一下,从而节省一些时间和两年编写不佳的代码!
当我们使用from database import Database在文件顶部显式导入database类时,我们可以轻松地查看Database类的来源。 我们可能会在文件的后面使用db = Database() 400 行,我们可以快速查看导入内容,以查看Database类的来源。 然后,如果需要澄清如何使用Database类,则可以访问原始文件(或在交互式解释器中导入模块并使用help(database.Database)命令)。 但是,如果使用from database import *语法,则查找该类的位置会花费更长的时间。 代码维护成为一场噩梦。
此外,如果使用常规导入,大多数编辑器都可以提供额外的功能,例如可靠的代码完成,跳转到类的定义或内联文档的功能。 import *语法通常会完全破坏其可靠执行此操作的能力。
最后,使用import *语法可以将意外对象带入我们的本地名称空间。 当然,它将导入要从中导入的模块中定义的所有类和函数,但是还将导入本身已导入到该文件中的所有类或模块!
模块中使用的每个名称都应来自明确指定的位置,无论是在该模块中定义的名称,还是从另一个模块显式导入的名称。 应该没有凭空浮出水面的魔术变量。 我们应该始终能够立即识别当前名称空间中名称的起源。 我保证,如果您使用这种邪恶的语法,那么您将有一天会经历极其沮丧的时刻:“该类来自何处?”。
整理模块
随着项目成长为越来越多的模块的集合,我们可能会发现我们想添加另一个抽象级别,即模块级别上的某种嵌套层次结构。 但是,我们不能将模块放在模块内部; 一个文件毕竟只能容纳一个文件,而模块无非就是 Python 文件。
但是,文件可以放在文件夹中,而模块也可以。 包是文件夹中模块的集合。 程序包的名称是文件夹的名称。 我们要告诉 Python 文件夹是一个包的所有工作,就是将一个文件(通常为空)放在名为__init__.py的文件夹中。 如果我们忘记了该文件,将无法从该文件夹导入模块。
让我们将模块放入工作文件夹中的ecommerce包中,其中还将包含main.py文件以启动程序。 让我们另外在ecommerce包中添加另一个包,以获取各种付款方式。 文件夹层次结构如下所示:
parent_directory/
main.py
ecommerce/
__init__.py
database.py
products.py
payments/
__init__.py
square.py
stripe.py
在包之间导入模块或类时,我们必须谨慎使用语法。 在 Python 3 中,有两种导入模块的方法:绝对导入和相对导入。
绝对进口
绝对导入指定到要导入的模块,功能或路径的完整路径。 如果需要访问products模块内部的Product类,则可以使用以下任何一种语法进行绝对导入:
import ecommerce.products
product = ecommerce.products.Product()
或者
from ecommerce.products import Product
product = Product()
或者
from ecommerce import products
product = products.Product()
import语句使用句点运算符分隔包或模块。
这些语句可在任何模块中使用。 我们可以在main.py,database模块或两个支付模块中的任何一个中使用此语法实例化Product类。 确实,假设这些软件包可用于 Python,它将能够导入它们。 例如,也可以将软件包安装到 Python 站点的 packages 文件夹中,或者可以自定义PYTHONPATH环境变量以动态告诉 Python 要搜索要导入的软件包和模块的文件夹。
那么,通过这些选择,我们选择哪种语法? 这取决于您的个人品味和手头的应用。 如果要在products模块中使用许多类和函数,则通常会使用from ecommerce import products语法导入模块名称,然后使用products.Product访问各个类。 如果我只需要products模块中的一两个类,则可以使用from ecommerce.proucts import Product语法直接导入它们。 除非我遇到某种名称冲突,否则我个人不会经常使用第一种语法(例如,我需要访问两个名为products的完全不同的模块,并且需要将它们分开)。 尽您所能使代码看起来更优雅。
相对进口
在将与相关模块一起使用时,指定完整路径似乎很愚蠢; 我们知道父模块的名称。 这是相对导入出现的地方。相对导入基本上是一种表达的方式,它表示相对于当前模块定位的类,函数或模块。 例如,如果我们正在使用products模块,并且想从其旁边的database模块导入Database类,则可以使用相对导入:
from .database import Database
database前面的句号为“使用当前包内的数据库模块”。 在这种情况下,当前软件包是包含我们当前正在编辑的products.py文件的软件包,即ecommerce软件包。
如果我们正在编辑ecommerce.payments包内的paypal模块,我们想说“使用父包内的数据库包”。 只需两个周期即可轻松完成,如下所示:
from ..database import Database
我们可以使用更多的时间来进一步提高层次结构。 当然,我们也可以一侧向下,另一侧向上。 我们没有足够深入的示例层次结构来正确地说明这一点,但是如果我们有一个包含email模块的ecommerce.contact包并且想将send_mail函数导入到我们的paypal中,则以下内容是有效的导入 ] 模块:
from ..contact.email import send_mail
此导入使用两个句点来表示付款包的父级,然后使用正常的package.module语法将返回到联系包中 。
最后,我们可以直接从包中导入代码,而不仅仅是包中的模块。 在此示例中,我们有一个ecommerce包,其中包含两个名为database.py和products.py的模块。 数据库模块包含一个db变量,可以从很多地方访问该变量。 如果可以将其导入为import ecommerce.db而不是import ecommerce.database.db会不会很方便?
还记得将目录定义为包的__init__.py文件吗? 该文件可以包含我们喜欢的任何变量或类声明,它们将作为软件包的一部分提供。 在我们的示例中,如果ecommerce/__init__.py文件包含以下行:
from .database import db
然后,我们可以使用此导入从main.py或任何其他文件访问db属性:
from ecommerce import db
如果__init__.py文件是模块而不是包,则将其视为ecommerce.py文件可能会有所帮助。 如果将所有代码放在一个模块中,然后又决定将其分解为模块包,则这也很有用。 新程序包的__init__.py文件仍然可以是与其通讯的其他模块的主要联系点,但是该代码可以在内部组织为几个不同的模块或子程序包。
但是,我建议不要将所有代码都放在__init__.py文件中。 程序员不希望该文件中发生实际的逻辑,并且与from x import *一样,如果程序员正在寻找特定代码的声明,并且直到检查__init__.py时才能找到它,它可能使它们跳闸。 。
组织模块内容
在任何一个模块中,我们都可以指定变量,类或函数。 它们可以是一种方便的方式来存储全局状态而不会导致名称空间冲突。 例如,我们已经将Database类导入到各种模块中,然后实例化它,但是从database模块中全局仅提供一个database对象可能更有意义。 database模块可能如下所示:
class Database:
# the database implementation
pass
database = Database()
然后,我们可以使用我们讨论的任何导入方法来访问database对象,例如:
from ecommerce.database import database
上一类的问题是,database对象是在首次导入模块时立即创建的,通常是在程序启动时创建的。 这并不总是理想的,因为连接到数据库可能要花一些时间,这会减慢启动速度,或者数据库连接信息可能尚不可用。 通过调用initialize_database函数来创建模块级变量,我们可以将数据库的创建推迟到实际需要时:
class Database:
# the database implementation
pass
database = None
def initialize_database():
global database
database = Database()
global关键字告诉 Python initialize_database内部的数据库变量是我们刚刚定义的模块级别。 如果我们没有将变量指定为全局变量,Python 将创建一个新的局部变量,该局部变量在方法退出时将被丢弃,而模块级别的值保持不变。
如这两个示例所示,所有模块级代码在导入时都会立即执行。 但是,如果它在方法或函数内部,则将创建该函数,但是在调用该函数之前,将不会执行其内部代码。 对于执行脚本的脚本(例如我们电子商务示例中的主脚本)来说,这可能是一件棘手的事情。 通常,我们会写一个做一些有用的事情的程序,然后发现我们想在另一个程序中从该模块导入一个函数或类。 但是,一旦导入,模块级的任何代码都会立即执行。 如果我们不小心的话,当我们确实只打算访问该模块中的几个函数时,就可以运行第一个程序。
为了解决这个问题,我们应该始终将启动代码放在一个函数中(通常称为main),并且仅在知道我们将模块作为脚本运行时才执行该函数,而不是在导入代码时执行 来自不同的脚本。 但是我们怎么知道呢?
class UsefulClass:
'''This class might be useful to other modules.'''
pass
def main():
'''creates a useful class and does something with it for our module.'''
useful = UsefulClass()
print(useful)
if __name__ == "__main__":
main()
每个模块都有一个__name__特殊变量(请记住,Python 对特殊变量使用双下划线,例如类的__init__方法),用于指定导入模块时的模块名称。 当直接用python module.py执行该模块时,永远不会导入该模块,因此__name__被任意设置为字符串"__main__"。 将所有脚本包装在if __name__ == "__main__":测试中作为一项策略,以防万一您编写一个函数时发现有一天可以被其他代码导入有用。
因此,方法进入类,进入模块,进入包。 这就是全部吗?
其实没有 这是 Python 程序中典型的事物顺序,但这不是唯一可能的布局。 类别可以在任何地方定义。 它们通常在模块级别定义,但也可以在函数或方法内部定义,如下所示:
def format_string(string, formatter=None):
'''Format a string using the formatter object, which
is expected to have a format() method that accepts
a string.'''
class DefaultFormatter:
'''Format a string in title case.'''
def format(self, string):
return str(string).title()
if not formatter:
formatter = DefaultFormatter()
return formatter.format(string)
hello_string = "hello world, how are you today?"
print(" input: " + hello_string)
print("output: " + format_string(hello_string))
输出将如下所示:
input: hello world, how are you today?
output: Hello World, How Are You Today?
format_string函数接受一个字符串和可选的格式化程序对象,然后将格式化程序应用于该字符串。 如果没有提供格式化程序,它将创建自己的格式化程序作为本地类并将其实例化。 由于是在函数范围内创建的,因此无法从该函数之外的任何位置访问此类。 类似地,也可以在其他函数内部定义函数。 通常,任何 Python 语句都可以随时执行。
这些内部类和函数有时对于一次性项目很有用,这些一次性项目在模块级别不需要或不应该拥有自己的范围,或者仅在单个方法内有意义。 但是,经常使用这种技术的 Python 代码并不常见。
谁可以访问我的数据?
大多数面向对象的编程语言都有访问控制的概念。 这与抽象有关。 对象上的某些属性和方法被标记为私有,这意味着只有该对象才能访问它们。 其他标记为受保护,这意味着仅该类和任何子类具有访问权限。 其余的都是公共的,这意味着任何其他对象都可以访问它们。
Python 不会这样做。 Python 并不真正相信执行某天可能会妨碍您的法律。 相反,它提供了未强制执行的准则和最佳实践。 从技术上讲,类的所有方法和属性都是公开可用的。 如果我们建议某个方法不应该公开使用,则可以在文档字符串中添加注释,表明该方法仅供内部使用(最好解释一下面向公众的 API 是如何工作的!)。
按照约定,我们还应该在属性或方法前加上下划线字符_。 Python 程序员会将其解释为“这是一个内部变量,在直接访问它之前要三思而行”。 但是,如果他们认为这样做符合他们的最大利益,那么解释器内部没有任何东西可以阻止他们访问它。 因为如果他们这么想,我们为什么要阻止他们? 我们可能不知道将来使用我们的类会有什么用。
您可以做另一件事来强烈建议外部对象不要访问属性或方法:在其前面加上双下划线__。 这将对相关属性执行名称处理。 这基本上意味着,如果外部对象确实想执行此方法,则仍然可以调用该方法,但是它需要额外的工作,并且强烈表明您要求属性保持私有。 例如:
class SecretString:
'''A not-at-all secure way to store a secret string.'''
def __init__(self, plain_string, pass_phrase):
self.__plain_string = plain_string
self.__pass_phrase = pass_phrase
def decrypt(self, pass_phrase):
'''Only show the string if the pass_phrase is correct.'''
if pass_phrase == self.__pass_phrase:
return self.__plain_string
else:
return ''
如果我们加载此类并在交互式解释器中对其进行测试,我们可以看到它隐藏了外界的纯文本字符串:
>>> secret_string = SecretString("ACME: Top Secret", "antwerp")
>>> print(secret_string.decrypt("antwerp"))
ACME: Top Secret
>>> print(secret_string.__plain_string)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'SecretString' object has no attribute
'__plain_text'
看起来可行; 没有密码,没有人可以访问我们的plain_text属性,因此它必须安全。 不过,在让我们兴奋不已之前,让我们看看破解我们的安全性有多么容易:
>>> print(secret_string._SecretString__plain_string)
ACME: Top Secret
不好了! 有人入侵了我们的机密字符串。 我们检查的好东西! 这是工作中的 Python 名称修饰。 当我们使用双下划线时,该属性以_<classname>为前缀。 当类中的方法内部访问变量时,它们将自动取消绑定。 当外部类希望访问它时,他们必须自己改名。 因此,名称改写并不能保证隐私,它只是强烈建议您这样做。 大多数 Python 程序员不会碰到另一个对象上的双下划线变量,除非他们有非常有说服力的理由这样做。
但是,大多数 Python 程序员也不会在没有令人信服的理由的情况下接触单个下划线变量。 因此,很少有充分的理由在 Python 中使用名称混用变量,这样做会引起悲伤。 例如,名称混用的变量可能对子类很有用,并且它必须自行处理。 如果其他对象愿意,可以使用单下划线前缀或一些清晰的文档字符串让其他对象访问您的隐藏信息,您认为这不是一个好主意。
第三方库
Python 附带带有一个可爱的标准库,该库是在运行 Python 的每台计算机上可用的软件包和模块的集合。 但是,您很快就会发现它不包含所需的一切。 发生这种情况时,您有两种选择:
- 自己写一个支持包
- 使用别人的代码
我们不会介绍将程序包转换为库的详细信息,但是,如果您有问题需要解决,并且不想编写代码(最好的程序员非常懒惰,并且喜欢重用现有的,经过验证的代码, 而不是自己编写),您可以在这个页面。 确定要安装的软件包后,可以使用名为pip的工具进行安装。 但是,pip并不随 Python 一起提供,但是 Python 3.4 包含一个名为ensurepip的有用工具,它将安装该工具:
python -m ensurepip
在 Linux,Mac OS 或其他 Unix 系统上,这可能会失败,在这种情况下,您需要成为 root 用户才能使其工作。 在大多数现代 Unix 系统上,可以使用sudo python -m ensurepip完成此操作。
注意
如果您使用的 Python 版本高于 Python 3.4,则需要自行下载并安装pip,因为ensurepip不存在。 您可以按照这个页面上的说明进行操作。
一旦安装了pip,并且知道要安装的软件包的名称,则可以使用以下语法来安装它:
pip install requests
但是,如果执行此操作,则将第三方库直接安装到系统 Python 目录中,或者更有可能出现错误消息,表明您没有这样做的权限。 您可以强制以管理员身份进行安装,但是 Python 社区中的普遍共识是,您应仅使用系统安装程序将第三方库安装到系统 Python 目录中。
相反,Python 3.4 提供了venv工具。 该实用程序基本上可以在您的工作目录中提供一个名为虚拟环境的微型 Python 安装。 当您激活迷你 Python 时,与 Python 相关的命令将在该目录而不是系统目录上运行。 因此,当您运行pip或python时,它根本不会碰到系统 Python。 使用方法如下:
cd project_directory
python -m venv env
source env/bin/activate # on Linux or MacOS
env/bin/activate.bat # on Windows
通常,您将为您处理的每个 Python 项目创建一个不同的虚拟环境。 您可以将虚拟环境存储在任何地方,但是我将自己的虚拟环境与其余项目文件保存在同一目录中(但在版本控制中忽略了该目录),因此首先将cd放入该目录。 然后,我们运行venv实用程序创建一个名为env的虚拟环境。 最后,我们使用最后两行之一(取决于操作系统,如注释中所示)来激活环境。 每次我们想使用该特定 virtualenv 时,都需要执行此行,然后在完成该项目时使用命令deactivate。
虚拟环境是一种使第三方依赖关系分离的好方法。 通常有不同的项目依赖于特定库的不同版本(例如,较旧的网站可能在 Django 1.5 上运行,而较新的版本可能在 Django 1.8 上运行)。 将每个项目保留在单独的 virtualenvs 中,可以轻松在任一版本的 Django 中工作。 此外,如果尝试使用其他工具安装相同的软件包,它可以防止系统安装的软件包与pip安装的软件包之间发生冲突。
案例研究
为了将所有捆绑在一起,让我们构建一个简单的命令行笔记本应用。 这是一个相当简单的任务,因此我们不会尝试使用多个软件包。 但是,我们将看到类,函数,方法和文档字符串的常见用法。
让我们从快速分析开始:笔记是存储在笔记本中的简短备忘录。 每个便笺应记录其写作日期,并可以添加标签以方便查询。 应该可以修改注释。 我们还需要能够搜索注释。 所有这些事情都应该从命令行完成。
显而易见的对象是Note对象; 不太明显的一个是Notebook容器对象。 标签和日期似乎也是对象,但是我们可以使用 Python 标准库中的日期和标签的逗号分隔字符串。 为了避免复杂性,在原型中,我们不要为这些对象定义单独的类。
Note对象具有memo本身,tags和creation_date的属性。 每个音符还需要一个唯一的整数id,以便用户可以在菜单界面中选择它们。 笔记可以有一种修改笔记内容的方法,另一种是为标签修改的方法,或者我们可以让笔记本直接访问那些属性。 为了使搜索更容易,我们应该在Note对象上放置match方法。 此方法将接受字符串,并且可以在不直接访问属性的情况下告诉我们注释是否与字符串匹配。 这样,如果我们要修改搜索参数(例如,搜索标签而不是注释内容,或者使搜索不区分大小写),则只需要在一个地方进行即可。
Notebook对象显然具有注释列表作为属性。 它还需要一个搜索方法,该方法返回已过滤笔记的列表。
但是我们如何与这些对象互动? 我们已经指定了一个命令行应用,这意味着我们可以使用不同的选项来运行程序以添加或编辑命令,或者可以通过某种菜单来选择要在笔记本上进行的操作。 我们应该尝试对其进行设计,以便支持任何一个接口,并且将来可以添加将来的接口,例如 GUI 工具箱或基于 Web 的接口。
作为设计决策,我们现在将实现菜单界面,但是将牢记命令行选项版本,以确保在设计Notebook类时要牢记可扩展性。
如果我们有两个命令行界面,每个命令行界面都与Notebook对象进行交互,则Notebook将需要一些与这些界面进行交互的方法。 除了已经讨论过的search方法之外,我们还需要add一个新的音符,以及id一个id的现有音符。 接口还需要能够列出所有注释,但是它们可以通过直接访问notes list 属性来做到这一点。
我们可能遗漏了一些细节,但这使我们对需要编写的代码有了很好的概览。 我们可以在一个简单的类图中总结所有这些内容:

在编写任何代码之前,让我们为该项目定义文件夹结构。 菜单界面显然应该在其自己的模块中,因为它将是一个可执行脚本,并且将来我们可能还会有其他可执行脚本访问笔记本。 Notebook和Note对象可以一起生活在一个模块中。 这些模块都可以存在于同一顶级目录中,而不必将它们放在包中。 空的command_option.py模块可以帮助提醒我们将来我们打算添加新的用户界面。
parent_directory/
notebook.py
menu.py
command_option.py
现在,让我们看一些代码。 我们从定义Note类开始,因为这似乎最简单。 以下示例完整介绍了Note。 示例中的文档字符串说明了它们如何组合在一起。
import datetime
# Store the next available id for all new notes
last_id = 0
class Note:
'''Represent a note in the notebook. Match against a
string in searches and store tags for each note.'''
def __init__(self, memo, tags=''):
'''initialize a note with memo and optional
space-separated tags. Automatically set the note's
creation date and a unique id.'''
self.memo = memo
self.tags = tags
self.creation_date = datetime.date.today()
global last_id
last_id += 1
self.id = last_id
def match(self, filter):
'''Determine if this note matches the filter
text. Return True if it matches, False otherwise.
Search is case sensitive and matches both text and
tags.'''
return filter in self.memo or filter in self.tags
在继续进行之前,我们应该迅速启动交互式解释器并测试我们的代码。 经常且经常进行测试,因为事情永远不会以您期望的方式发挥作用。 确实,当我测试此示例的第一个版本时,我发现我忘记了match函数中的self自变量! 现在,使用解释器检查一些事情:
>>> from notebook import Note
>>> n1 = Note("hello first")
>>> n2 = Note("hello again")
>>> n1.id
1
>>> n2.id
2
>>> n1.match('hello')
True
>>> n2.match('second')
False
看起来一切都按预期进行。 接下来创建笔记本:
class Notebook:
'''Represent a collection of notes that can be tagged,
modified, and searched.'''
def __init__(self):
'''Initialize a notebook with an empty list.'''
self.notes = []
def new_note(self, memo, tags=''):
'''Create a new note and add it to the list.'''
self.notes.append(Note(memo, tags))
def modify_memo(self, note_id, memo):
'''Find the note with the given id and change its
memo to the given value.'''
for note in self.notes:
if note.id == note_id:
note.memo = memo
break
def modify_tags(self, note_id, tags):
'''Find the note with the given id and change its
tags to the given value.'''
for note in self.notes:
if note.id == note_id:
note.tags = tags
break
def search(self, filter):
'''Find all notes that match the given filter
string.'''
return [note for note in self.notes if
note.match(filter)]
我们将在一分钟内清理。 首先,让我们对其进行测试以确保其正常工作:
>>> from notebook import Note, Notebook
>>> n = Notebook()
>>> n.new_note("hello world")
>>> n.new_note("hello again")
>>> n.notes
[<notebook.Note object at 0xb730a78c>, <notebook.Note object at
0xb73103ac>]
>>> n.notes[0].id
1
>>> n.notes[1].id
2
>>> n.notes[0].memo
'hello world'
>>> n.search("hello")
[<notebook.Note object at 0xb730a78c>, <notebook.Note object at
0xb73103ac>]
>>> n.search("world")
[<notebook.Note object at 0xb730a78c>]
>>> n.modify_memo(1, "hi world")
>>> n.notes[0].memo
'hi world'
确实有效。 虽然代码有点混乱; 我们的modify_tags和modify_memo方法几乎相同。 那不是好的编码习惯。 让我们看看如何改进它。
两种方法都试图在对笔记进行处理之前识别具有给定 ID 的笔记。 因此,让我们添加一种方法来查找具有特定 ID 的笔记。 我们将在方法名称前添加下划线,以表明该方法仅供内部使用,但是,如果需要,我们的菜单界面可以访问该方法:
def _find_note(self, note_id):
'''Locate the note with the given id.'''
for note in self.notes:
if note.id == note_id:
return note
return None
def modify_memo(self, note_id, memo):
'''Find the note with the given id and change its
memo to the given value.'''
self._find_note(note_id).memo = memo
现在应该可以使用。 让我们看一下菜单界面。 该界面只需要显示一个菜单并允许用户输入选择即可。 这是我们的第一次尝试:
import sys
from notebook import Notebook, Note
class Menu:
'''Display a menu and respond to choices when run.'''
def __init__(self):
self.notebook = Notebook()
self.choices = {
"1": self.show_notes,
"2": self.search_notes,
"3": self.add_note,
"4": self.modify_note,
"5": self.quit
}
def display_menu(self):
print("""
Notebook Menu
1\. Show all Notes
2\. Search Notes
3\. Add Note
4\. Modify Note
5\. Quit
""")
def run(self):
'''Display the menu and respond to choices.'''
while True:
self.display_menu()
choice = input("Enter an option: ")
action = self.choices.get(choice)
if action:
action()
else:
print("{0} is not a valid choice".format(choice))
def show_notes(self, notes=None):
if not notes:
notes = self.notebook.notes
for note in notes:
print("{0}: {1}\n{2}".format(
note.id, note.tags, note.memo))
def search_notes(self):
filter = input("Search for: ")
notes = self.notebook.search(filter)
self.show_notes(notes)
def add_note(self):
memo = input("Enter a memo: ")
self.notebook.new_note(memo)
print("Your note has been added.")
def modify_note(self):
id = input("Enter a note id: ")
memo = input("Enter a memo: ")
tags = input("Enter tags: ")
if memo:
self.notebook.modify_memo(id, memo)
if tags:
self.notebook.modify_tags(id, tags)
def quit(self):
print("Thank you for using your notebook today.")
sys.exit(0)
if __name__ == "__main__":
Menu().run()
此代码首先使用绝对导入来导入笔记本对象。 相对导入将不起作用,因为我们没有将代码放入包中。 Menu类的run方法重复显示菜单,并通过调用笔记本计算机上的函数来响应选择。 这是使用 Python 特有的习惯用法完成的。 用户输入的选项是字符串。 在菜单的__init__方法中,我们创建了一个字典,该字典将字符串映射到菜单对象本身上的函数。 然后,当用户做出选择时,我们从字典中检索对象。 action变量实际上是指特定的方法,并通过在变量后附加空括号(因为这些方法均不需要参数)来调用。 当然,用户可能输入了不合适的选项,因此我们在调用操作之前先检查该操作是否确实存在。
各种方法中的每一种都要求用户输入并在与之关联的Notebook对象上调用适当的方法。 对于search实现,我们注意到在过滤掉注释之后,我们需要将它们显示给用户,因此使show_notes函数具有双重职责; 它接受可选的notes参数。 如果提供,则仅显示已过滤的注释,如果未提供,则显示所有注释。 由于notes参数是可选的,因此show_notes仍可以在没有参数的情况下作为空白菜单项被调用。
如果测试此代码,我们会发现修改注释不起作用。 有两个错误,即:
- 当我们输入不存在的笔记 ID 时,笔记本崩溃。 我们绝不应该信任我们的用户输入正确的数据!
- 即使我们输入正确的 ID,它也会崩溃,因为注释 ID 是整数,但是我们的菜单正在传递字符串。
后一种错误可以通过修改Notebook类的_find_note方法来解决,以使用字符串而不是注释中存储的整数来比较值,如下所示:
def _find_note(self, note_id):
'''Locate the note with the given id.'''
for note in self.notes:
if str(note.id) == str(note_id):
return note
return None
在比较它们之前,我们只需将输入(note_id)和音符的 ID 都转换为字符串。 我们也可以将输入转换为整数,但是如果用户输入字母“ a”而不是数字“ 1”,那么我们将遇到麻烦。
可以通过更改笔记本上的两个modify方法来检查_find_note是否返回笔记,从而解决用户输入不存在的笔记 ID 的问题,如下所示:
def modify_memo(self, note_id, memo):
'''Find the note with the given id and change its
memo to the given value.'''
note = self._find_note(note_id)
if note:
note.memo = memo
return True
return False
此方法已更新为返回True或False,具体取决于是否已找到便笺。 如果用户输入了无效的注释,菜单可能会使用此返回值显示错误。 这段代码有点笨拙。 如果它引发了异常,看起来会更好一些。 我们将在第 4 章中介绍异常。



四、相同的对象
在编程世界中,重复代码被认为是邪恶的。 我们不应在不同的地方拥有相同或相似代码的多个副本。
有很多方法可以合并具有相似功能的代码段或对象。 在本章中,我们将介绍最著名的面向对象原理:继承。 正如第 1 章,“面向对象设计”中讨论的那样,继承使我们可以创建,即两个或多个类之间的关系,将通用逻辑抽象为超类并进行管理 子类中的特定详细信息。 特别是,我们将介绍以下方面的 Python 语法和原理:
- 基本继承
- 从内置继承
- 多重继承
- 多态性和鸭子打字
基本继承
从技术上讲,我们创建的每个类都使用继承。 所有 Python 类都是名为object的特殊类的子类。 此类提供的数据和行为很少(它提供的行为都是仅供内部使用的双下划线方法),但是它确实允许 Python 以相同的方式处理所有对象。
如果我们不显式继承其他类,则我们的类将自动继承object。 但是,我们可以公开声明我们的类是使用以下语法从object派生的:
class MySubClass(object):
pass
这就是继承! 从技术上讲,该示例与第 2 章和 Python 中的对象的第一个示例没有什么不同,因为如果我们未明确提供[ 不同的超类。 超类或父类是从其继承的类。 子类是从超类继承的类。 在这种情况下,超类是object,而MySubClass是子类。 子类也被称为是从其父类派生的,或者该子类扩展了父类。
正如您从示例中发现的一样,继承比基本类定义所需的语法更少。 只需在类名之后但在冒号终止类定义之前的括号内包括父类的名称。 这就是我们要告诉 Python 新类应该从给定的超类派生的全部操作。
我们如何在实践中应用继承? 继承最简单,最明显的用途是向现有类添加功能。 让我们从一个简单的联系人管理器开始,该管理器跟踪几个人的姓名和电子邮件地址。 联系人类负责维护类变量中所有联系人的列表,并初始化单个联系人的姓名和地址:
class Contact:
all_contacts = []
def __init__(self, name, email):
self.name = name
self.email = email
Contact.all_contacts.append(self)
这个例子向我们介绍了类变量。 由于all_contacts列表是类定义的一部分,因此该类的所有实例都共享它。 这意味着只有一个Contact.all_contacts列表,我们可以将其作为Contact.all_contacts进行访问。 不太明显的是,我们还可以在从Contact实例化的任何对象上以self.all_contacts的形式访问它。 如果在对象上找不到该字段,那么它将在类上找到,因此引用相同的单个列表。
注意
请谨慎使用此语法,因为如果使用self.all_contacts设置了变量,则实际上将创建仅与该对象关联的新的实例变量。 class 变量将保持不变,并可以通过Contact.all_contacts进行访问。
这是一个简单的类,它使我们可以跟踪有关每个联系人的几个数据。 但是,如果我们的某些联系人也是我们需要从其订购耗材的供应商怎么办? 我们可以在Contact类中添加order方法,但这将使人们意外地从客户或家人朋友的联系人那里订购商品。 相反,让我们创建一个新的Supplier类,其作用类似于我们的Contact类,但是具有一个附加的order方法:
class Supplier(Contact):
def order(self, order):
print("If this were a real system we would send "
"'{}' order to '{}'".format(order, self.name))
现在,如果我们在可靠的解释器中测试此类,我们将看到所有联系人(包括供应商)都在其__init__中接受姓名和电子邮件地址,但只有供应商具有功能上的订购方法:
>>> c = Contact("Some Body", "somebody@example.net")
>>> s = Supplier("Sup Plier", "supplier@example.net")
>>> print(c.name, c.email, s.name, s.email)
Some Body somebody@example.net Sup Plier supplier@example.net
>>> c.all_contacts
[<__main__.Contact object at 0xb7375ecc>,
<__main__.Supplier object at 0xb7375f8c>]
>>> c.order("I need pliers")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'Contact' object has no attribute 'order'
>>> s.order("I need pliers")
If this were a real system we would send 'I need pliers' order to
'Sup Plier '
因此,现在我们的Supplier类可以完成联系人可以做的所有事情(包括将自己添加到all_contacts列表中)以及作为供应商需要处理的所有特殊事项。 这就是继承之美。
扩展内置
这种继承的一种有趣的用法是向内置类添加功能。 在前面看到的Contact类中,我们将联系人添加到所有联系人的列表中。 如果我们还想按名称搜索该列表怎么办? 好吧,我们可以在Contact类上添加一个方法来搜索它,但是感觉这个方法实际上属于列表本身。 我们可以使用继承来做到这一点:
class ContactList(list):
def search(self, name):
'''Return all contacts that contain the search value
in their name.'''
matching_contacts = []
for contact in self:
if name in contact.name:
matching_contacts.append(contact)
return matching_contacts
class Contact:
all_contacts = ContactList()
def __init__(self, name, email):
self.name = name
self.email = email
self.all_contacts.append(self)
我们没有实例化普通列表作为我们的类变量,而是创建了一个扩展内置list的新ContactList类。 然后,我们将此子类实例化为all_contacts列表。 我们可以如下测试新的搜索功能:
>>> c1 = Contact("John A", "johna@example.net")
>>> c2 = Contact("John B", "johnb@example.net")
>>> c3 = Contact("Jenna C", "jennac@example.net")
>>> [c.name for c in Contact.all_contacts.search('John')]
['John A', 'John B']
您是否想知道我们如何将内置语法[]更改为可以继承的语法? 实际上,使用[]创建一个空列表是使用list()创建一个空列表的简写; 这两种语法的行为相同:
>>> [] == list()
True
实际上,[]语法实际上是所谓的语法糖,它在后台调用了list()构造函数。 list数据类型是我们可以扩展的类。 实际上,列表本身扩展了object类:
>>> isinstance([], object)
True
作为第二个示例,我们可以扩展dict类,该类与列表类似,是使用{}语法简写构造的类:
class LongNameDict(dict):
def longest_key(self):
longest = None
for key in self:
if not longest or len(key) > len(longest):
longest = key
return longest
这在交互式解释器中很容易测试:
>>> longkeys = LongNameDict()
>>> longkeys['hello'] = 1
>>> longkeys['longest yet'] = 5
>>> longkeys['hello2'] = 'world'
>>> longkeys.longest_key()
'longest yet'
大多数内置类型都可以类似地扩展。 通常扩展的内置是object,list,set,dict,file和str。 有时也会继承诸如int和float之类的数字类型。
覆盖和超级
因此,对于来说,继承是一个不错的,它将新行为添加到现有类中,但是会改变行为呢? 我们的contact类仅允许使用名称和电子邮件地址。 对于大多数联系人而言,这可能就足够了,但是如果我们想为我们的密友添加电话号码怎么办?
正如我们在第 2 章,Python 中的对象中所看到的那样,我们可以通过在构造接触后在接触上设置一个phone属性来轻松地做到这一点。 但是,如果要使第三个变量在初始化时可用,则必须重写__init__。 覆盖表示更改或用子类中的新方法(具有相同名称)替换超类的方法。 无需特殊语法即可执行此操作; 子类的新创建的方法会自动调用,而不是超类的方法。 例如:
class Friend(Contact):
def __init__(self, name, email, phone):
self.name = name
self.email = email
self.phone = phone
可以覆盖的任何方法,而不仅仅是__init__。 但是,在继续之前,我们需要在此示例中解决一些问题。 我们的Contact和Friend类具有重复的代码来设置name和email属性; 这可能会使代码维护变得复杂,因为我们必须在两个或多个位置更新代码。 更令人震惊的是,我们的Friend类忽略了将自身添加到我们在Contact类上创建的all_contacts列表中。
我们真正需要的是一种在Contact类上执行原始__init__方法的方法。 这就是super功能的作用; 它返回该对象作为父类的实例,从而使我们可以直接调用父方法:
class Friend(Contact):
def __init__(self, name, email, phone):
super().__init__(name, email)
self.phone = phone
本示例首先使用super获取父对象的实例,然后在该对象上调用__init__,并传入期望的参数。 然后,它执行自己的初始化,即设置phone属性。
注意
请注意,super()语法在旧版本的 Python 中不起作用。 像列表和字典的[]和{}语法一样,它是更复杂的构造的简写形式。 在讨论多重继承时,我们将在短期内了解更多信息,但是现在知道在 Python 2 中,您必须调用super(EmailContact, self).__init__()。 特别注意,第一个参数是子类的名称,而不是某些人希望的作为要调用的父类的名称。 另外,请记住类在对象之前。 我总是忘了顺序,因此 Python 3 中的新语法为我节省了很多时间来查找它。
可以在任何方法内进行super()调用,而不仅仅是__init__。 这意味着可以通过覆盖和调用super来修改所有方法。 也可以在方法的任何点处调用super。 我们不必将调用作为方法的第一行。 例如,我们可能需要在将传入参数转发给超类之前对其进行操作或验证。
多重继承
多重继承是敏感的主题。 从原则上讲,这非常简单:从多个父类继承的子类能够从它们两个中访问功能。 实际上,这没有听起来那么有用,许多专家程序员建议不要使用它。
注意
根据经验,如果您认为需要多重继承,那么您可能错了,但是如果您知道需要继承,那么您可能是对的。
最简单且最有用的多重继承形式称为 mixin。 mixin 通常是一个超类,它本身并不存在,但可以被其他一些类继承以提供额外的功能。 例如,假设我们要向Contact类添加功能,该功能允许向self.email发送电子邮件。 发送电子邮件是我们可能想在其他许多类上使用的一项常见任务。 因此,我们可以编写一个简单的 mixin 类来为我们发送电子邮件:
class MailSender:
def send_mail(self, message):
print("Sending mail to " + self.email)
# Add e-mail logic here
为简便起见,我们将不在此处包括实际的电子邮件逻辑; 如果您对研究如何完成感兴趣,请参阅 Python 标准库中的smtplib模块。
该类没有做任何特殊的事情(实际上,它几乎不能作为一个独立的类起作用),但是它确实允许我们使用多重继承定义一个描述Contact和MailSender的新类:
class EmailableContact(Contact, MailSender):
pass
多重继承的语法看起来像类定义中的参数列表。 我们没有在括号中包含一个基类,而是包含了两个(或多个),并以逗号分隔。 我们可以测试这个新的混合动力以查看混合工作:
>>> e = EmailableContact("John Smith", "jsmith@example.net")
>>> Contact.all_contacts
[<__main__.EmailableContact object at 0xb7205fac>]
>>> e.send_mail("Hello, test e-mail here")
Sending mail to jsmith@example.net
Contact初始化程序为仍将新联系人添加到all_contacts列表中,并且 mixin 能够向self.email发送邮件,因此我们知道一切正常。
这并不难,您可能想知道关于多重继承的可怕警告是什么。 我们将在一分钟内介绍复杂性,但让我们考虑一下我们拥有的其他选项,而不是在此处使用 mixin:
- 我们本可以使用单一继承并将
send_mail函数添加到子类中。 此处的缺点是,对于所有其他需要电子邮件的类,电子邮件功能必须重复。 - 我们可以创建一个独立的 Python 函数来发送电子邮件,并在需要发送电子邮件时使用提供的正确电子邮件地址作为参数来调用该函数。
- 我们本来可以探索使用组合而不是继承的几种方法。 例如,
EmailableContact可以具有MailSender对象,而不是从其继承。 - 我们可以进行猴子补丁(在第 7 章和 Python 面向对象的快捷方式中简要介绍猴子补丁),在
Contact类之后添加send_mail方法 类已创建。 这是通过定义一个接受self参数的函数并将其设置为现有类的属性来完成的。
当混合来自不同类的方法时,多重继承可以正常工作,但是当我们必须在超类上调用方法时,它会变得非常混乱。 有多个超类。 我们怎么知道该叫哪一个? 我们怎么知道用什么顺序打电话给他们?
让我们通过在Friend类中添加家庭住址来探讨这些问题。 我们可能会采用几种方法。 地址是代表联系人的街道,城市,国家和其他相关详细信息的字符串的集合。 我们可以将每个字符串作为参数传递给Friend类的__init__方法。 我们还可以将这些字符串存储在元组或字典中,并将它们作为单个参数传递到__init__中。 如果不需要在地址中添加任何方法,这可能是最好的做法。
另一个选择是创建一个新的Address类以将这些字符串保持在一起,然后将该类的实例传递到Friend类的__init__方法中。 此解决方案的优点是我们可以向数据添加行为(例如,提供指导或打印地图的方法),而不仅仅是静态地存储它。 正如我们在第 1 章,“面向对象设计”中讨论的那样,这是组成的示例。 组成的“具有”关系是解决此问题的完美可行的解决方案,它使我们可以在建筑物,企业或组织等其他实体中重用Address类。
但是,继承也是可行的解决方案,这就是我们要探讨的内容。 让我们添加一个包含地址的新类。 我们将这个新类称为“ AddressHolder”,而不是“ Address”,因为继承定义了是关系。 说“朋友”是“地址”是不正确的,但是由于朋友可以有“地址”,因此我们可以说“朋友”是“ AddressHolder”。 以后,我们可以创建其他拥有地址的实体(公司,建筑物)。 这是我们的AddressHolder类:
class AddressHolder:
def __init__(self, street, city, state, code):
self.street = street
self.city = city
self.state = state
self.code = code
很简单; 我们只是获取所有数据,并在初始化时将其扔到实例变量中。
钻石问题
我们可以使用多重继承将该新类添加为现有Friend类的父类。 棘手的是,我们现在有两个父级__init__方法,这两个方法都需要初始化。 并且它们需要使用不同的参数进行初始化。 我们如何做到这一点? 好吧,我们可以从一个幼稚的方法开始:
class Friend(Contact, AddressHolder):
def __init__(
self, name, email, phone,street, city, state, code):
Contact.__init__(self, name, email)
AddressHolder.__init__(self, street, city, state, code)
self.phone = phone
在此示例中,我们直接在每个超类上调用__init__函数,并显式传递self参数。 这个例子在技术上是可行的。 我们可以直接在类上访问不同的变量。 但是有一些问题。
首先,如果我们忽略显式调用初始化器,则超类可能未初始化为。 这不会破坏本示例,但在常见情况下可能导致难以调试的程序崩溃。 想象一下,尝试将数据插入尚未连接的数据库中。
其次,更危险的是,由于类层次结构的组织,可能会多次调用超类。 看一下这个继承图:

Friend类中的__init__方法首先调用Contact上的__init__,这将隐式初始化object超类(请记住,所有类均源自object)。 Friend然后在AddressHolder上调用__init__,这又隐式地初始化object超类。 这意味着父类已设置两次。 对于object类,这是相对无害的,但是在某些情况下,它可能会带来灾难。 想象一下,对于每个请求尝试两次连接到数据库!
基类只能调用一次。 曾经,是的,但是什么时候? 我们先叫Friend,然后叫Contact,然后叫Object,然后叫AddressHolder? 还是Friend,然后是Contact,然后是AddressHolder,然后是Object?
注意
通过修改类的 __mro__(方法解析顺序)属性,可以即时调整方法的调用顺序。 这超出了本模块的范围。 如果您认为需要了解它,我建议 Expert Python 编程,TarekZiadé,Packt Publishing 或阅读有关该主题的原始文档,网址为 [] http://www.python.org/download/releases/2.3/mro/](http://www.python.org/download/releases/2.3/mro/) 。
让我们看第二个人为的例子,它更清楚地说明了这个问题。 在这里,我们有一个基类,它具有一个名为call_me的方法。 两个子类重写该方法,然后另一个子类使用多重继承扩展这两个方法。 由于类图的菱形形状,这被称为菱形继承:

让我们将此图转换为代码; 此示例显示了何时调用方法:
class BaseClass:
num_base_calls = 0
def call_me(self):
print("Calling method on Base Class")
self.num_base_calls += 1
class LeftSubclass(BaseClass):
num_left_calls = 0
def call_me(self):
BaseClass.call_me(self)
print("Calling method on Left Subclass")
self.num_left_calls += 1
class RightSubclass(BaseClass):
num_right_calls = 0
def call_me(self):
BaseClass.call_me(self)
print("Calling method on Right Subclass")
self.num_right_calls += 1
class Subclass(LeftSubclass, RightSubclass):
num_sub_calls = 0
def call_me(self):
LeftSubclass.call_me(self)
RightSubclass.call_me(self)
print("Calling method on Subclass")
self.num_sub_calls += 1
此示例仅确保每个重写的call_me方法都直接调用具有相同名称的父方法。 每次将信息打印到屏幕上时,它就会让我们知道方法的调用。 它还会更新类上的静态变量,以显示其已被调用多少次。 如果我们实例化一个Subclass对象并对其调用一次,则将得到以下输出:
>>> s = Subclass()
>>> s.call_me()
Calling method on Base Class
Calling method on Left Subclass
Calling method on Base Class
Calling method on Right Subclass
Calling method on Subclass
>>> print(
... s.num_sub_calls,
... s.num_left_calls,
... s.num_right_calls,
... s.num_base_calls)
1 1 1 2
因此,我们可以清楚地看到基类的call_me方法被调用了两次。 如果该方法进行两次实际工作(例如存入银行帐户),则可能会导致一些隐患。
多重继承要记住的是,我们只想在类层次结构中调用“ next”方法,而不是“ parent”方法。 实际上,该下一个方法可能不在当前类的父级或祖先上。 super关键字再次帮助我们。 实际上,super最初是为了使复杂形式的多重继承而开发的。 这是使用super编写的相同代码:
class BaseClass:
num_base_calls = 0
def call_me(self):
print("Calling method on Base Class")
self.num_base_calls += 1
class LeftSubclass(BaseClass):
num_left_calls = 0
def call_me(self):
super().call_me()
print("Calling method on Left Subclass")
self.num_left_calls += 1
class RightSubclass(BaseClass):
num_right_calls = 0
def call_me(self):
super().call_me()
print("Calling method on Right Subclass")
self.num_right_calls += 1
class Subclass(LeftSubclass, RightSubclass):
num_sub_calls = 0
def call_me(self):
super().call_me()
print("Calling method on Subclass")
self.num_sub_calls += 1
的更改很小。 我们简单地将朴素的直接调用替换为对super()的调用,尽管底部子类仅调用super一次,而不必对左右两个调用。 更改很简单,但是请看一下执行时的区别:
>>> s = Subclass()
>>> s.call_me()
Calling method on Base Class
Calling method on Right Subclass
Calling method on Left Subclass
Calling method on Subclass
>>> print(s.num_sub_calls, s.num_left_calls, s.num_right_calls,
s.num_base_calls)
1 1 1 1
看起来不错,我们的基本方法仅被调用一次。 但是super()在这里实际上在做什么? 由于print语句是在super调用之后执行的,因此打印输出按实际执行每种方法的顺序排列。 让我们从后到前查看输出,看看谁在打电话。
首先,Subclass的call_me调用super().call_me(),碰巧引用了LeftSubclass.call_me()。 然后LeftSubclass.call_me()方法调用super().call_me(),但是在这种情况下,super()指的是RightSubclass.call_me()。
请特别注意:super调用是而不是调用LeftSubclass的超类(即BaseClass)上的方法。 而是,它正在调用RightSubclass,即使它不是LeftSubclass的直接父代! 这是下一个方法,而不是父方法。 然后RightSubclass调用BaseClass,并且super调用已确保类层次结构中的每个方法执行一次。
不同的参数集
回到Friend多继承示例时,使事情变得复杂。 在Friend的__init__方法中,我们最初为两个父类调用了带有不同参数集的__init__:
Contact.__init__(self, name, email)
AddressHolder.__init__(self, street, city, state, code)
使用super时如何管理不同的参数集? 我们不一定知道哪个类super将首先尝试初始化。 即使我们做到了,我们也需要一种传递“额外”参数的方法,以便在其他子类上对super的后续调用接收正确的参数。
具体来说,如果第一次调用super将name和email参数传递给Contact.__init__,然后Contact.__init__然后调用super,则它必须能够将与地址相关的参数传递给“ 下一个”方法,即AddressHolder.__init__。
每当我们想用相同的名称但使用不同的参数集调用超类方法时,这就是一个问题。 通常,您唯一想调用带有完全不同的参数集的超类的时间是__init__,就像我们在这里所做的那样。 即使使用常规方法,我们也可能希望添加仅对一个子类或一组子类有意义的可选参数。
可悲的是,解决此问题的唯一方法是从头开始进行规划。 我们必须设计我们的基类参数列表,以接受每个子类实现不需要的任何参数的关键字参数。 最后,我们必须确保该方法自由地接受意外的参数,并将其传递给其super调用,以防它们对于继承顺序中的后续方法是必需的。
Python 的功能参数语法提供了我们执行此操作所需的所有工具,但它使整体代码显得笨重。 看一下Friend多重继承代码的正确版本:
class Contact:
all_contacts = []
def __init__(self, name='', email='', **kwargs):
super().__init__(**kwargs)
self.name = name
self.email = email
self.all_contacts.append(self)
class AddressHolder:
def __init__(self, street='', city='', state='', code='',
**kwargs):
super().__init__(**kwargs)
self.street = street
self.city = city
self.state = state
self.code = code
class Friend(Contact, AddressHolder):
def __init__(self, phone='', **kwargs):
super().__init__(**kwargs)
self.phone = phone
通过将空字符串作为默认值,我们将所有参数更改为关键字参数。 我们还确保包含一个**kwargs参数,以捕获我们的特定方法不知道如何处理的任何其他参数。 它将通过super调用将这些参数传递到下一个类。
注意
如果您不熟悉**kwargs语法,则它基本上会收集传递到该方法中但未在参数列表中明确列出的所有关键字参数。 这些参数存储在名为kwargs的字典中(我们可以随意调用该变量,但习惯上建议使用kw或kwargs)。 当我们使用**kwargs语法调用其他方法(例如super().__init__)时,它将解压缩字典并将结果作为常规关键字参数传递给该方法。 我们将在第 7 章和 Python 面向对象的快捷方式中对此进行详细介绍。
前面的示例完成了应做的工作。 但是它开始显得凌乱,并且已经很难回答以下问题:我们需要将哪些参数传递给 Friend.__init__? 对于任何打算使用该类的人来说,这是最重要的问题,因此应在方法中添加一个文档字符串以说明正在发生的情况。
此外,如果我们想重用父类中的变量,那么即使实现也不足够。 当我们将**kwargs变量传递给super时,词典不包含任何作为显式关键字参数包含的变量。 例如,在Friend.__init__中,对super的调用在kwargs词典中没有phone。 如果其他任何类需要phone参数,则需要确保它在传递的字典中。 更糟糕的是,如果我们忘记这样做,那么调试将很困难,因为超类不会抱怨,而只会将默认值(在这种情况下为空字符串)分配给变量。
有几种方法可以确保将变量向上传递。 出于某种原因,假设Contact类确实需要使用phone参数进行初始化,并且Friend类也需要对其进行访问。 我们可以执行以下任一操作:
- 不要将
phone用作显式关键字参数。 而是将其保留在kwargs词典中。Friend可以使用语法kwargs['phone']进行查找。 当它将**kwargs传递给super呼叫时,phone仍将在词典中。 - 将
phone设为显式关键字参数,但使用标准字典语法kwargs['phone'] = phone将kwargs字典传递给super之前,先对其进行更新。 - 使
phone为显式关键字参数,但使用kwargs.update方法更新kwargs词典。 如果您有几个要更新的参数,这将很有用。 您可以使用dict(phone=phone)构造函数或字典语法{'phone': phone}创建传递到update的字典。 - 使
phone为显式关键字参数,但使用语法super().__init__(phone=phone, **kwargs)将其显式传递给超级调用。
我们已经讨论了 Python 中涉及多重继承的许多警告。 当我们需要考虑所有可能的情况时,我们必须为它们做计划,我们的代码会变得混乱。 基本的多重继承可能很方便,但是在很多情况下,我们可能希望选择一种更透明的方式来组合两个不同的类,通常使用合成或我们将在第 10 章中介绍的一种设计模式。 ,“Python 设计模式 I” 和第 11 章,“Python 设计模式 II”。
多态性
在第 1 章,“面向对象设计”中向我们介绍了多态性。 这是一个很简单的名字,描述了一个简单的概念:根据所使用的子类而发生不同的行为,而不必明确知道该子类实际上是什么。 例如,假设有一个播放音频文件的程序。 媒体播放器可能需要先加载AudioFile对象,然后再加载play。 我们在对象上放置了play()方法,该方法负责解压缩或提取音频并将其路由到声卡和扬声器。 播放AudioFile的操作可能很简单,例如:
audio_file.play()
但是,对于不同类型的文件,解压缩和提取音频文件的过程非常不同。 .wav文件未压缩地存储,而.mp3,.wma和.ogg文件均具有完全不同的压缩算法。
我们可以将继承与多态一起使用以简化设计。 每种类型的文件都可以由AudioFile的不同子类表示,例如WavFile,MP3File。 每个文件都有一个play()方法,但是对于每个文件,该方法将以不同的方式实现,以确保遵循正确的提取过程。 媒体播放器对象将永远不需要知道它所指的是AudioFile的哪个子类。 它只是调用play()并以多态方式让对象处理播放的实际细节。 让我们看一个快速的骨架,显示它的外观:
class AudioFile:
def __init__(self, filename):
if not filename.endswith(self.ext):
raise Exception("Invalid file format")
self.filename = filename
class MP3File(AudioFile):
ext = "mp3"
def play(self):
print("playing {} as mp3".format(self.filename))
class WavFile(AudioFile):
ext = "wav"
def play(self):
print("playing {} as wav".format(self.filename))
class OggFile(AudioFile):
ext = "ogg"
def play(self):
print("playing {} as ogg".format(self.filename))
所有音频文件都会检查以确保在初始化时给出了有效的扩展名。 但是您是否注意到父类中的__init__方法如何能够从不同的子类访问ext类变量? 那就是工作中的多态性。 如果文件名后缀名不正确,则会引发异常(下一章将详细讨论例外情况)。 AudioFile实际上没有存储对ext变量的引用这一事实并不能阻止它能够在子类上对其进行访问。
此外,AudioFile的每个子类都以不同的方式实现play()(此示例实际上并未播放音乐;音频压缩算法确实值得一个单独的模块!)。 这也是行动中的多态性。 媒体播放器可以使用完全相同的代码来播放文件,而不管文件的类型是什么。 它不在乎它正在查看AudioFile的哪个子类。 解压缩音频文件的详细信息封装在[HTG4]中。 如果我们测试此示例,它将按我们希望的那样工作:
>>> ogg = OggFile("myfile.ogg")
>>> ogg.play()
playing myfile.ogg as ogg
>>> mp3 = MP3File("myfile.mp3")
>>> mp3.play()
playing myfile.mp3 as mp3
>>> not_an_mp3 = MP3File("myfile.ogg")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "polymorphic_audio.py", line 4, in __init__
raise Exception("Invalid file format")
Exception: Invalid file format
看看AudioFile.__init__如何能够在不真正知道其引用的子类的情况下检查文件类型?
实际上,多态是有关面向对象编程的最酷的事情之一,它使某些编程设计变得显而易见,而这在早期的范例中是不可能的。 但是,由于使用鸭子输入,Python 使多态性变得不那么酷。 在 Python 中进行鸭子输入可以让我们使用任何提供所需行为的对象,而不必强制其成为子类。 Python 的动态特性使其变得无关紧要。 以下示例未扩展AudioFile,但可以使用完全相同的接口在 Python 中进行交互:
class FlacFile:
def __init__(self, filename):
if not filename.endswith(".flac"):
raise Exception("Invalid file format")
self.filename = filename
def play(self):
print("playing {} as flac".format(self.filename))
我们的媒体播放器可以像扩展AudioFile一样轻松地播放此对象。
多态性是在许多面向对象的上下文中使用继承的最重要原因之一。 由于任何提供正确接口的对象都可以在 Python 中互换使用,因此减少了对多态通用超类的需求。 继承对于共享代码仍然有用,但是,如果要共享的只是公共接口,那么只需要鸭子输入即可。 对继承的需求的减少也减少了对多重继承的需求。 通常,当多重继承看起来是一个有效的解决方案时,我们可以使用鸭子类型来模仿多个超类之一。
当然,仅仅因为一个对象满足特定的接口(通过提供所需的方法或属性)并不意味着它会在所有情况下都能正常工作。 它必须以整个系统中有意义的方式实现该接口。 仅仅因为对象提供play()方法并不意味着它会自动与媒体播放器一起使用。 例如,我们来自第 1 章,“面向对象设计”的国际象棋 AI 对象可能具有移动棋子的play()方法。 即使它满足界面要求,但如果我们尝试将其插入媒体播放器,则此类可能会以惊人的方式中断!
鸭子类型的另一个有用的功能是,鸭子类型的对象仅需要提供那些实际上已被访问的方法和属性。 例如,如果我们需要创建一个假文件对象来读取数据,则可以创建一个具有read()方法的新对象; 如果将要与该对象交互的代码仅从文件中读取,则不必重写write方法。 更简洁地说,鸭子类型不需要提供可用对象的整个接口,它只需要满足实际访问的接口即可。
抽象基类
鸭子类型很有用,但要预先告知某个类是否将满足您所需的协议并不总是那么容易。 因此,Python 引入了抽象基类的思想。 抽象基类或 ABC 定义了一个类必须实现的一组方法和属性,才能被视为该类的鸭子型实例。 该类可以扩展抽象基类本身,以便用作该类的实例,但是它必须提供所有适当的方法。
在实践中,几乎没有必要创建新的抽象基类,但我们可能会发现实现现有 ABC 实例的机会。 我们将首先介绍如何实现 ABC,然后简要介绍如何在需要时创建自己的 ABC。
使用抽象基类
Python 标准库中存在的大多数抽象基类都位于collections模块中。 最简单的一种是Container类。 让我们在 Python 解释器中检查它,以了解此类需要哪些方法:
>>> from collections import Container
>>> Container.__abstractmethods__
frozenset(['__contains__'])
因此,Container类只有一种需要实现的抽象方法__contains__。 您可以发出help(Container.__contains__)来查看功能签名的外观:
Help on method __contains__ in module _abcoll:__contains__(self, x) unbound _abcoll.Container method
因此,我们看到__contains__需要接受一个参数。 不幸的是,帮助文件没有告诉我们该参数应该是什么,但是从 ABC 的名称和它实现的单个方法中可以很明显地看出,该参数是用户正在检查以查看容器是否容纳的值 。
该方法由list,str和dict实现,以指示给定值是否在该数据结构中。 但是,我们还可以定义一个愚蠢的容器,该容器告诉我们给定值是否在奇数整数集中:
class OddContainer:
def __contains__(self, x):
if not isinstance(x, int) or not x % 2:
return False
return True
现在,我们可以实例化OddContainer对象并确定,即使我们没有扩展Container,类也是 Container对象:
>>> from collections import Container
>>> odd_container = OddContainer()
>>> isinstance(odd_container, Container)
True
>>> issubclass(OddContainer, Container)
True
这就是为什么鸭子类型比经典多态性更出色的原因。 我们可以创建是关系,而没有使用继承(或更糟糕的是,多重继承)的开销。
关于Container ABC 的有趣之处在于,实现它的任何类都可以免费使用in关键字。 实际上,in只是委派给__contains__方法的语法糖。 具有__contains__方法的任何类都是Container,因此可以通过in关键字查询,例如:
>>> 1 in odd_container
True
>>> 2 in odd_container
False
>>> 3 in odd_container
True
>>> "a string" in odd_container
False
创建抽象基类
正如我们先前看到的一样,不必具有抽象基类来启用鸭子类型。 但是,想象一下我们正在创建一个带有第三方插件的媒体播放器。 在这种情况下,建议创建一个抽象基类以记录第三方插件应提供的 API。 abc模块提供了执行此操作所需的工具,但是我会提前警告您,这需要一些 Python 最神秘的概念:
import abc
class MediaLoader(metaclass=abc.ABCMeta):
@abc.abstractmethod
def play(self):
pass
@abc.abstractproperty
def ext(self):
pass
@classmethod
def __subclasshook__(cls, C):
if cls is MediaLoader:
attrs = set(dir(C))
if set(cls.__abstractmethods__) <= attrs:
return True
return NotImplemented
这是一个复杂的示例,其中包含一些 Python 功能,直到本模块稍后再介绍。 为了完整起见,此处包含了它,但是您无需了解所有内容就可以了解如何创建自己的 ABC。
第一个奇怪的是传递给类的metaclass关键字参数,通常您会在该类中看到父类的列表。 这是从元类编程的神秘艺术中很少使用的构造。 我们不会在本模块中介绍元类,因此您所需要知道的是,通过分配ABCMeta元类,您可以赋予您的类超能力(或至少是超类)能力。
接下来,我们看到@abc.abstractmethod和@abc.abstractproperty构造。 这些是 Python 装饰器。 我们将在第 5 章,“何时使用面向对象编程”中讨论这些内容。 现在,只知道通过将方法或属性标记为抽象,就可以说明该类的任何子类都必须实现该方法或提供该属性,才能被视为该类的适当成员。
看看如果实现提供或不提供这些属性的子类会发生什么:
>>> class Wav(MediaLoader):
... pass
...
>>> x = Wav()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: Can't instantiate abstract class Wav with abstract methods ext, play
>>> class Ogg(MediaLoader):
... ext = '.ogg'
... def play(self):
... pass
...
>>> o = Ogg()
由于Wav类无法实现抽象属性,因此无法实例化该类。 该类仍然是合法的抽象类,但是您必须对其进行子类化才能实际执行任何操作。 Ogg类提供这两个属性,因此它可以干净地实例化。
回到MediaLoader ABC,我们来剖析__subclasshook__方法。 基本上是说,提供该 ABC 所有抽象属性的具体实现的任何类都应被视为MediaLoader的子类,即使它实际上不是从MediaLoader类继承的也是如此。
更常见的面向对象语言在接口和类的实现之间有着清晰的分隔。 例如,某些语言提供了显式的interface关键字,该关键字使我们可以定义类必须具有的方法而无需任何实现。 在这样的环境中,抽象类是一种既提供接口又提供某些但不是全部方法的具体实现的类。 任何类都可以明确声明其实现了给定的接口。
Python 的 ABC 有助于提供接口功能,而不会影响鸭子输入的好处。
揭开魔术的神秘面纱
如果要创建满足此特定约定的抽象类,则可以在不了解子类代码的情况下复制并粘贴。 我们将在整个模块中介绍大多数不寻常的语法,但让我们逐行对其进行概述。
@classmethod
该装饰器将该方法标记为类方法。 本质上说,可以在类上而不是实例化对象上调用该方法:
def __subclasshook__(cls, C):
这定义了__subclasshook__类方法。 Python 解释器调用此特殊方法来回答问题:类 C 是此类的子类吗?
if cls is MediaLoader:
我们检查该方法是否专门在此类上调用,而不是说此类的子类。 例如,这可以防止Wav类被视为Ogg类的父类:
attrs = set(dir(C))
此行所做的只是获取类具有的方法和属性集,包括其类层次结构中的所有父类:
if set(cls.__abstractmethods__) <= attrs:
此行使用集合符号来查看是否在候选类中提供了此类中的抽象方法集。 请注意,它不会检查方法是否已实现,即使它们在那里也是如此。 因此,一个类有可能是一个子类,而本身仍然是一个抽象类。
return True
如果提供了所有抽象方法,则候选类为该类的子类,我们返回True。 该方法可以合法地返回三个值之一:True,False或NotImplemented。 True和False表示该类绝对是该类的子类:
return NotImplemented
如果未满足任何条件(即,该类不是MediaLoader或未提供所有抽象方法),则返回NotImplemented。 这告诉 Python 机制使用默认机制(候选类是否显式扩展了该类?)进行子类检测。
简而言之,我们现在可以将Ogg类定义为MediaLoader类的子类,而无需实际扩展MediaLoader类:
>>> class Ogg():
... ext = '.ogg'
... def play(self):
... print("this will play an ogg file")
...
>>> issubclass(Ogg, MediaLoader)
True
>>> isinstance(Ogg(), MediaLoader)
True
案例研究
让我们尝试将我们学到的所有内容与一个更大的例子联系在一起。 我们将设计一个简单的房地产应用,使代理可以管理可用于购买或出租的财产。 物业将分为两种:公寓和房屋。 代理需要能够输入有关新属性的一些相关详细信息,列出所有当前可用的属性,以及将属性标记为已出售或已租赁。 为简便起见,我们无需担心在出售后编辑属性详细信息或重新激活属性的问题。
该项目将允许代理使用 Python 解释器提示与对象进行交互。 在这个图形用户界面和 Web 应用的世界里,您可能想知道为什么我们要创建这种老式外观的程序。 简而言之,窗口化程序和 Web 应用都需要大量开销知识和样板代码才能使它们执行所需的操作。 如果我们使用这两种范例中的任何一种来开发软件,那么我们会在 GUI 编程或 Web 编程中迷失方向,以至于忽视了我们试图掌握的面向对象原理。
幸运的是,大多数 GUI 和 Web 框架都采用了面向对象的方法,而我们现在正在研究的原理将有助于将来理解这些系统。 我们将在第 13 章,“并发”中简要讨论它们,但是完整的细节远远超出了单个模块的范围。
查看我们的要求,似乎有很多名词可以表示系统中的对象类别。 显然,我们需要表示一个属性。 房屋和公寓可能需要单独的课程。 租金和购买似乎也需要单独的代表。 由于我们现在专注于继承,因此我们将研究使用继承或多继承共享行为的方法。
House和Apartment都是属性的类型,因此Property可以是这两个类的超类。 Rental和Purchase需要额外考虑; 如果我们使用继承,则需要有单独的类,例如HouseRental和HousePurchase,并使用多重继承将它们组合在一起。 与基于合成或基于关联的设计相比,这感觉有些笨拙,但是让我们一起运行它,看看我们提出了什么。
现在,哪些属性可能与Property类相关联? 无论是公寓还是房屋,大多数人都想知道平方英尺,卧室数量和浴室数量。 (还有许多其他可以建模的属性,但对于我们的原型,我们将使其保持简单。)
如果该物业是一栋房屋,它将要宣传楼层的数量,是否有车库(有,独立或无人车库)以及院子是否被围起来。 公寓将要指出是否有阳台,以及洗衣房是套房式,硬币式还是非现场式。
这两种属性类型都需要一种方法来显示该属性的特征。 目前,没有其他行为可见。
租赁物业将需要存储每月的租金,物业是否配备,是否包括公用事业,如果不包含,则应估算其租金。 购买的物业将需要存储购买价格和估计的年度物业税。 对于我们的应用,我们只需要显示此数据,因此我们只需添加类似于其他类中使用的display()方法就可以摆脱困境。
最后,我们需要一个Agent对象,该对象保存所有属性的列表,显示这些属性,并允许我们创建新属性。 创建属性将需要提示用户输入每种属性类型的相关详细信息。 可以在Agent对象中完成此操作,但随后Agent将需要了解许多有关属性类型的信息。 这没有利用多态性。 另一种选择是将提示放在每个类的初始化器甚至是构造函数中,但这将不允许将来在 GUI 或 Web 应用中应用这些类。 更好的主意是创建一个执行提示并返回提示参数字典的静态方法。 然后,Agent所要做的就是提示用户输入财产的类型和付款方式,并要求正确的类实例化自己。
大量的设计! 下面的类图可以更清晰地传达我们的设计决策:

哇,那是很多继承箭头! 我认为不加箭头就不可能添加另一个继承级别。 即使在设计阶段,多重继承也很麻烦。
这些类最棘手的方面将是确保在继承层次结构中调用超类方法。 让我们从Property实现开始:
class Property:
def __init__(self, square_feet='', beds='',
baths='', **kwargs):
super().__init__(**kwargs)
self.square_feet = square_feet
self.num_bedrooms = beds
self.num_baths = baths
def display(self):
print("PROPERTY DETAILS")
print("================")
print("square footage: {}".format(self.square_feet))
print("bedrooms: {}".format(self.num_bedrooms))
print("bathrooms: {}".format(self.num_baths))
print()
def prompt_init():
return dict(square_feet=input("Enter the square feet: "),
beds=input("Enter number of bedrooms: "),
baths=input("Enter number of baths: "))
prompt_init = staticmethod(prompt_init)
这个类非常简单。 我们已经在__init__中添加了额外的**kwargs参数,因为我们知道它将在多重继承情况下使用。 如果我们不是多重继承链中的最后一个调用,我们还包括了对super().__init__的调用。 在这种情况下,我们正在使用关键字参数,因为我们知道在继承层次结构的其他级别上将不需要它们。
我们在prompt_init方法中看到了一些新内容。 最初创建此方法后,立即将其变为静态方法。 静态方法仅与类(类似于类变量)相关联,而不与特定的对象实例相关联。 因此,它们没有self参数。 因此,super关键字将不起作用(没有父对象,只有父类),因此我们直接在父类上直接调用 static 方法。 此方法使用 Python dict构造函数创建一个值字典,该值可以传递到__init__中。 调用input会提示每个键的值。
Apartment类扩展了Property,其结构类似:
class Apartment(Property):
valid_laundries = ("coin", "ensuite", "none")
valid_balconies = ("yes", "no", "solarium")
def __init__(self, balcony='', laundry='', **kwargs):
super().__init__(**kwargs)
self.balcony = balcony
self.laundry = laundry
def display(self):
super().display()
print("APARTMENT DETAILS")
print("laundry: %s" % self.laundry)
print("has balcony: %s" % self.balcony)
def prompt_init():
parent_init = Property.prompt_init()
laundry = ''
while laundry.lower() not in \
Apartment.valid_laundries:
laundry = input("What laundry facilities does "
"the property have? ({})".format(
", ".join(Apartment.valid_laundries)))
balcony = ''
while balcony.lower() not in \
Apartment.valid_balconies:
balcony = input(
"Does the property have a balcony? "
"({})".format(
", ".join(Apartment.valid_balconies)))
parent_init.update({
"laundry": laundry,
"balcony": balcony
})
return parent_init
prompt_init = staticmethod(prompt_init)
display()和__init__()方法使用super()调用各自的父类方法,以确保Property类被正确初始化。
prompt_init静态方法现在从父类获取字典值,然后添加其自身的一些其他值。 它调用dict.update方法将新的字典值合并到第一个字典中。 但是,该prompt_init方法看起来很丑陋。 它会循环两次,直到用户使用结构相似的代码但变量不同来输入有效输入。 提取此验证逻辑会很好,因此我们只能将其维护在一个位置。 这对以后的课程也很有用。
关于继承的所有讨论,我们可能会认为这是使用 mixin 的好地方。 相反,我们有机会研究继承不是最佳解决方案的情况。 我们要创建的方法将在静态方法中使用。 如果要从提供验证功能的类继承,则也必须以不访问该类上任何实例变量的静态方法提供该功能。 如果它不访问任何实例变量,那么使其完全成为类的意义何在? 为什么我们不只是将此验证功能设为接受输入字符串和有效答案列表的模块级功能,而只保留它呢?
让我们探讨一下此验证函数的外观:
def get_valid_input(input_string, valid_options):
input_string += " ({}) ".format(", ".join(valid_options))
response = input(input_string)
while response.lower() not in valid_options:
response = input(input_string)
return response
我们可以在解释器中测试此功能,而与我们一直在研究的所有其他类无关。 这是一个好兆头,这意味着我们设计的不同部分之间没有紧密耦合,并且可以在不影响其他代码部分的情况下独立进行改进。
>>> get_valid_input("what laundry?", ("coin", "ensuite", "none"))
what laundry? (coin, ensuite, none) hi
what laundry? (coin, ensuite, none) COIN
'COIN'
现在,让我们快速更新Apartment.prompt_init方法以使用此新功能进行验证:
def prompt_init():
parent_init = Property.prompt_init()
laundry = get_valid_input(
"What laundry facilities does "
"the property have? ",
Apartment.valid_laundries)
balcony = get_valid_input(
"Does the property have a balcony? ",
Apartment.valid_balconies)
parent_init.update({
"laundry": laundry,
"balcony": balcony
})
return parent_init
prompt_init = staticmethod(prompt_init)
与我们的原始版本相比,更易于阅读(和维护!)。 现在我们准备构建House类。 此类具有与Apartment并行的结构,但是引用了不同的提示和变量:
class House(Property):
valid_garage = ("attached", "detached", "none")
valid_fenced = ("yes", "no")
def __init__(self, num_stories='',
garage='', fenced='', **kwargs):
super().__init__(**kwargs)
self.garage = garage
self.fenced = fenced
self.num_stories = num_stories
def display(self):
super().display()
print("HOUSE DETAILS")
print("# of stories: {}".format(self.num_stories))
print("garage: {}".format(self.garage))
print("fenced yard: {}".format(self.fenced))
def prompt_init():
parent_init = Property.prompt_init()
fenced = get_valid_input("Is the yard fenced? ",
House.valid_fenced)
garage = get_valid_input("Is there a garage? ",
House.valid_garage)
num_stories = input("How many stories? ")
parent_init.update({
"fenced": fenced,
"garage": garage,
"num_stories": num_stories
})
return parent_init
prompt_init = staticmethod(prompt_init)
这里没有的新知识,因此让我们继续进行Purchase和Rental类。 尽管目的明显不同,但它们在设计上也与我们刚刚讨论的目的相似:
class Purchase:
def __init__(self, price='', taxes='', **kwargs):
super().__init__(**kwargs)
self.price = price
self.taxes = taxes
def display(self):
super().display()
print("PURCHASE DETAILS")
print("selling price: {}".format(self.price))
print("estimated taxes: {}".format(self.taxes))
def prompt_init():
return dict(
price=input("What is the selling price? "),
taxes=input("What are the estimated taxes? "))
prompt_init = staticmethod(prompt_init)
class Rental:
def __init__(self, furnished='', utilities='',
rent='', **kwargs):
super().__init__(**kwargs)
self.furnished = furnished
self.rent = rent
self.utilities = utilities
def display(self):
super().display()
print("RENTAL DETAILS")
print("rent: {}".format(self.rent))
print("estimated utilities: {}".format(
self.utilities))
print("furnished: {}".format(self.furnished))
def prompt_init():
return dict(
rent=input("What is the monthly rent? "),
utilities=input(
"What are the estimated utilities? "),
furnished = get_valid_input(
"Is the property furnished? ",
("yes", "no")))
prompt_init = staticmethod(prompt_init)
这两个类没有超类(object除外),但我们仍将其称为super().__init__,因为它们将与其他类结合使用,并且我们不知道super的顺序 将进行调用。该接口类似于House和Apartment所使用的接口,当我们将这四个类的功能组合到单独的子类中时,该接口非常有用。 例如:
class HouseRental(Rental, House):
def prompt_init():
init = House.prompt_init()
init.update(Rental.prompt_init())
return init
prompt_init = staticmethod(prompt_init)
这有点令人惊讶,因为类本身既没有__init__也没有display方法! 因为两个父类都在这些方法中适当地调用了super,所以我们仅需扩展这些类,并且这些类将以正确的顺序运行。 当然,prompt_init并非如此,因为它是一个静态方法,不会调用super,因此我们明确实现了这一方法。 在编写其他三个组合之前,我们应该测试此类以确保其行为正确:
>>> init = HouseRental.prompt_init()
Enter the square feet: 1
Enter number of bedrooms: 2
Enter number of baths: 3
Is the yard fenced? (yes, no) no
Is there a garage? (attached, detached, none) none
How many stories? 4
What is the monthly rent? 5
What are the estimated utilities? 6
Is the property furnished? (yes, no) no
>>> house = HouseRental(**init)
>>> house.display()
PROPERTY DETAILS
================
square footage: 1
bedrooms: 2
bathrooms: 3
HOUSE DETAILS
# of stories: 4
garage: none
fenced yard: no
RENTAL DETAILS
rent: 5
estimated utilities: 6
furnished: no
看起来工作正常。 prompt_init方法正在提示所有超类的初始化程序,display()也正在协作调用所有三个超类。
注意
前面示例中继承类的顺序很重要。 如果我们写的是class HouseRental(House, Rental)而不是class HouseRental(Rental, House),则display()不会调用Rental.display()! 在我们的HouseRental版本上调用display时,它指的是该方法的Rental版本,该方法调用super.display()获得House版本,然后再次调用super.display()获得属性版本。 。 如果我们将其反转,则display将引用House类的display()。 调用 super 时,它将调用Property父类的方法。 但是Property在其display方法中没有对super的调用。 这意味着将不会调用Rental类的display方法! 通过按照我们执行的顺序放置继承列表,我们确保Rental调用super,这将处理层次结构的House端。 您可能以为我们可以在Property.display()中添加super调用,但这会失败,因为Property的下一个超类是object,并且object没有display方法。 解决此问题的另一种方法是允许Rental和Purchase扩展Property类,而不是直接从object派生。 (或者我们可以动态修改方法的解析顺序,但这超出了本模块的范围。)
现在我们已经测试了它,我们准备创建其余的组合子类:
class ApartmentRental(Rental, Apartment):
def prompt_init():
init = Apartment.prompt_init()
init.update(Rental.prompt_init())
return init
prompt_init = staticmethod(prompt_init)
class ApartmentPurchase(Purchase, Apartment):
def prompt_init():
init = Apartment.prompt_init()
init.update(Purchase.prompt_init())
return init
prompt_init = staticmethod(prompt_init)
class HousePurchase(Purchase, House):
def prompt_init():
init = House.prompt_init()
init.update(Purchase.prompt_init())
return init
prompt_init = staticmethod(prompt_init)
那应该是我们最紧张的设计! 现在,我们要做的就是创建Agent类,该类负责创建新列表并显示现有列表。 让我们从更简单的属性存储和列出开始:
class Agent:
def __init__(self):
self.property_list = []
def display_properties(self):
for property in self.property_list:
property.display()
添加属性需要首先查询属性的类型以及属性是用于购买还是出租。 我们可以通过显示一个简单的菜单来做到这一点。 一旦确定了这一点,我们就可以使用我们已经开发的prompt_init层次结构提取正确的子类并提示所有详细信息。 听起来很简单? 它是。 首先,向Agent类添加一个字典类变量:
type_map = {
("house", "rental"): HouseRental,
("house", "purchase"): HousePurchase,
("apartment", "rental"): ApartmentRental,
("apartment", "purchase"): ApartmentPurchase
}
那是一些漂亮的有趣的代码。 这是一本字典,其中的键是两个不同字符串的元组,而值是类对象。 类对象? 是的,可以像普通对象或原始数据类型一样,将类传递,重命名并存储在容器中。 使用这个简单的字典,我们可以简单地劫持我们先前的get_valid_input方法,以确保获得正确的字典键并查找适当的类,如下所示:
def add_property(self):
property_type = get_valid_input(
"What type of property? ",
("house", "apartment")).lower()
payment_type = get_valid_input(
"What payment type? ",
("purchase", "rental")).lower()
PropertyClass = self.type_map[
(property_type, payment_type)]
init_args = PropertyClass.prompt_init()
self.property_list.append(PropertyClass(**init_args))
这也可能看起来很有趣! 我们在字典中查找该类,并将其存储在名为PropertyClass的变量中。 我们不确切知道哪个类可用,但是该类自己知道,因此我们可以多态调用prompt_init以获取适合传递给构造函数的值的字典。 然后,我们使用关键字参数语法将字典转换为参数,并构造新对象以加载正确的数据。
现在,我们的用户可以使用此Agent类添加和查看属性列表。 添加功能以将属性标记为可用或不可用,或者编辑和删除属性都不需要太多的工作。 我们的原型现在处于足够好的状态,可以带入房地产agent并演示其功能。 这是演示会话的工作方式:
>>> agent = Agent()
>>> agent.add_property()
What type of property? (house, apartment) house
What payment type? (purchase, rental) rental
Enter the square feet: 900
Enter number of bedrooms: 2
Enter number of baths: one and a half
Is the yard fenced? (yes, no) yes
Is there a garage? (attached, detached, none) detached
How many stories? 1
What is the monthly rent? 1200
What are the estimated utilities? included
Is the property furnished? (yes, no) no
>>> agent.add_property()
What type of property? (house, apartment) apartment
What payment type? (purchase, rental) purchase
Enter the square feet: 800
Enter number of bedrooms: 3
Enter number of baths: 2
What laundry facilities does the property have? (coin, ensuite,
one) ensuite
Does the property have a balcony? (yes, no, solarium) yes
What is the selling price? $200,000
What are the estimated taxes? 1500
>>> agent.display_properties()
PROPERTY DETAILS
================
square footage: 900
bedrooms: 2
bathrooms: one and a half
HOUSE DETAILS
# of stories: 1
garage: detached
fenced yard: yes
RENTAL DETAILS
rent: 1200
estimated utilities: included
furnished: no
PROPERTY DETAILS
================
square footage: 800
bedrooms: 3
bathrooms: 2
APARTMENT DETAILS
laundry: ensuite
has balcony: yes
PURCHASE DETAILS
selling price: $200,000
estimated taxes: 1500




五、异常处理
程序非常脆弱。 如果代码始终返回有效结果,但是有时无法计算出有效结果,那将是理想的选择。 例如,不可能用零除或访问五项列表中的第八项。
在过去,解决此问题的唯一方法是严格检查每个功能的输入以确保它们有意义。 通常,函数具有特殊的返回值以指示错误情况。 例如,他们可以返回负数以表示无法计算正值。 不同的数字可能意味着发生了不同的错误。 任何调用此函数的代码都必须明确检查错误情况并采取相应措施。 很多代码都没有去做,程序只是崩溃了。 但是,在面向对象的世界中,情况并非如此。
在本章中,我们将研究异常,这些特殊错误对象仅在有意义的情况下才需要处理。 特别是,我们将介绍:
- 如何导致异常发生
- 发生异常时如何恢复
- 如何以不同方式处理不同的异常类型
- 发生异常时进行清理
- 创建新的异常类型
- 使用异常语法进行流控制
引发异常
原则上,异常只是一个对象。 有许多不同的异常类可用,我们可以轻松定义更多自己的异常类。 它们共有的一件事是它们从称为BaseException的内置类继承。 这些异常对象在程序的控制流中处理时会变得特殊。 当发生异常时,原本应该发生的一切都不会发生,除非在发生异常时原本应该发生。 有道理? 不用担心,它将!
导致异常发生的最简单方法是做一些愚蠢的事情! 您可能已经完成了此操作,并看到了异常输出。 例如,任何时候 Python 在程序中遇到它无法理解的行时,它都会以SyntaxError保释,这是一种异常。 这是一个常见的例子:
>>> print "hello world"
File "<stdin>", line 1
print "hello world"
^
SyntaxError: invalid syntax
该print语句在 Python 2 和早期版本中是有效的命令,但是在 Python 3 中,因为print现在是一个函数,所以必须将参数括在括号中。 因此,如果将前面的命令键入 Python 3 解释器,则会得到SyntaxError。
除了SyntaxError以外,以下示例还显示了我们可以处理的其他一些常见异常:
>>> x = 5 / 0
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ZeroDivisionError: int division or modulo by zero
>>> lst = [1,2,3]
>>> print(lst[3])
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
IndexError: list index out of range
>>> lst + 2
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: can only concatenate list (not "int") to list
>>> lst.add
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'list' object has no attribute 'add'
>>> d = {'a': 'hello'}
>>> d['b']
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
KeyError: 'b'
>>> print(this_is_not_a_var)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name 'this_is_not_a_var' is not defined
有时,这些异常表明我们的程序有问题(在这种情况下,我们将转到指定的行号并进行修复),但在合法情况下也会发生。 ZeroDivisionError并不总是表示我们收到了无效的输入。 这也可能意味着我们收到了不同的意见。 用户可能错误或故意输入了零,也可能代表合法值,例如空银行帐户或新生婴儿的年龄。
您可能已经注意到前面所有内置的异常都以名称Error结尾。 在 Python 中,error和exception这两个词几乎可以互换使用。 有时错误被认为比异常更为可怕,但是它们的处理方式完全相同。 实际上,前面示例中的所有错误类都将Exception(扩展了BaseException)作为其超类。
引发异常
我们将在一分钟内处理异常,但首先,让我们发现如果我们正在编写一个需要通知用户或调用函数某些输入无效的程序,该怎么办。 如果我们可以使用 Python 使用的相同机制,那不是很好吗? 好吧,我们可以! 这是一个简单的类,仅当项目为偶数整数时才将其添加到列表中:
class EvenOnly(list):
def append(self, integer):
if not isinstance(integer, int):
raise TypeError("Only integers can be added")
if integer % 2:
raise ValueError("Only even numbers can be added")
super().append(integer)
此类扩展了内置的list,正如我们在第 2 章,“Python 中的对象”中所讨论的那样,并重写了append方法以检查两个条件来确保 该项目是一个偶数整数。 我们首先检查输入是否为int类型的实例,然后使用模运算符确保将其除以 2。 如果两个条件中的任何一个都不满足,则raise关键字将导致发生异常。 raise关键字后面紧跟着引发异常的对象。 在前面的示例中,从内置类TypeError和ValueError重新构造了两个对象。 引发的对象可以很容易地成为我们自己创建的新异常类的实例(很快就会看到),在其他地方定义的异常,甚至是先前已经引发和处理的异常对象。 如果我们在 Python 解释器中测试该类,我们可以看到发生异常时,它正在输出有用的错误信息,就像以前一样:
>>> e = EvenOnly()
>>> e.append("a string")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "even_integers.py", line 7, in add
raise TypeError("Only integers can be added")
TypeError: Only integers can be added
>>> e.append(3)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "even_integers.py", line 9, in add
raise ValueError("Only even numbers can be added")
ValueError: Only even numbers can be added
>>> e.append(2)
注意
虽然此类对于演示实际发生的异常非常有效,但它的工作并不是很好。 仍然可以使用索引符号或切片符号将其他值添加到列表中。 可以通过重写其他适当的方法来避免这些问题,其中一些方法是双下划线方法。
异常的影响
引发异常时,它似乎立即停止程序执行。 引发异常后应该运行的任何行都不会执行,并且除非处理了异常,否则程序将退出并显示一条错误消息。 看一下这个简单的函数:
def no_return():
print("I am about to raise an exception")
raise Exception("This is always raised")
print("This line will never execute")
return "I won't be returned"
如果执行此函数,则会看到第一个print调用已执行,然后引发了异常。 第二条print语句永远不会执行,return语句也永远不会执行:
>>> no_return()
I am about to raise an exception
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "exception_quits.py", line 3, in no_return
raise Exception("This is always raised")
Exception: This is always raised
此外,如果我们有一个函数调用另一个引发异常的函数,则在调用第二个函数的位置之后,第一个函数将不执行任何操作。 引发异常将停止所有通过函数调用堆栈执行的操作,直到该异常被处理或迫使解释器退出为止。 为了演示,让我们添加第二个函数来调用较早的函数:
def call_exceptor():
print("call_exceptor starts here...")
no_return()
print("an exception was raised...")
print("...so these lines don't run")
当我们调用此函数时,我们看到第一个print语句以及no_return函数的第一行都被执行。 但是,一旦引发异常,便不会执行其他任何操作:
>>> call_exceptor()
call_exceptor starts here...
I am about to raise an exception
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "method_calls_excepting.py", line 9, in call_exceptor
no_return()
File "method_calls_excepting.py", line 3, in no_return
raise Exception("This is always raised")
Exception: This is always raised
我们很快就会看到,当解释器实际上并没有采取快捷方式并立即退出时,我们可以对这两种方法中的异常进行反应和处理。 确实,异常在最初引发之后可以在任何级别上进行处理。
从底部到顶部查看异常的输出(称为回溯),并注意如何列出这两种方法。 在no_return内部,最初引发了异常。 然后,在其上方,我们看到在call_exceptor内部,调用了令人讨厌的no_return函数,并且该异常冒泡到调用方法中。 从那里,它又上升到主解释器的另一层,主解释器不知道该怎么做,就放弃并打印了回溯。
处理异常
现在让我们来看看在异常硬币的尾部。 如果遇到异常情况,我们的代码应如何应对或从中恢复? 我们通过将可能抛出一个代码的任何代码(无论是异常代码本身,还是对其内部可能引发异常的任何函数或方法的调用)包装在try ... except子句中来处理异常。 最基本的语法如下所示:
try:
no_return()
except:
print("I caught an exception")
print("executed after the exception")
如果我们使用我们现有的no_return函数运行此简单脚本,众所周知,该函数始终会引发异常,则将得到以下输出:
I am about to raise an exception
I caught an exception
executed after the exception
no_return函数高兴地通知我们它即将引发异常,但是我们欺骗了它并捕获了异常。 一旦被抓住,我们就可以自己清理(在这种情况下,通过输出我们正在处理的情况),并继续前进,而不会受到进攻职能的干扰。 no_return函数中的其余代码仍未执行,但是调用该函数的代码能够恢复并继续。
注意try和except周围的缩进。 try子句包装可能引发异常的所有代码。 然后,except子句返回与try行相同的缩进级别。 在except子句之后缩进任何用于处理异常的代码。 然后,普通代码将以原始缩进级别恢复。
前面的代码的问题在于它将捕获任何类型的异常。 如果我们正在编写一些可以同时引发TypeError和ZeroDivisionError的代码怎么办? 我们可能想捕获ZeroDivisionError,但让TypeError传播到控制台。 你能猜出语法吗?
这是一个相当愚蠢的功能,它可以做到这一点:
def funny_division(divider):
try:
return 100 / divider
except ZeroDivisionError:
return "Zero is not a good idea!"
print(funny_division(0))
print(funny_division(50.0))
print(funny_division("hello"))
该函数已通过print语句进行了测试,这些语句表明其行为符合预期:
Zero is not a good idea!
2.0
Traceback (most recent call last):
File "catch_specific_exception.py", line 9, in <module>
print(funny_division("hello"))
File "catch_specific_exception.py", line 3, in funny_division
return 100 / anumber
TypeError: unsupported operand type(s) for /: 'int' and 'str'.
输出的第一行显示,如果输入0,则会得到正确的模拟。 如果我们使用有效的数字进行调用(请注意,它不是整数,但仍然是有效的除数),则它可以正常运行。 但是,如果我们输入一个字符串(您想知道如何获取TypeError,不是吗?),它会失败并出现异常。 如果我们使用了没有指定ZeroDivisionError的空except子句,那么它将在向我们发送字符串时指责我们将其除以零,这根本不是正确的行为。
我们甚至可以捕获两个或更多不同的异常,并使用相同的代码处理它们。 这是一个引发三种不同类型异常的示例。 它使用相同的异常处理程序处理TypeError和ZeroDivisionError,但是如果您提供数字13,它也可能会引发ValueError:
def funny_division2(anumber):
try:
if anumber == 13:
raise ValueError("13 is an unlucky number")
return 100 / anumber
except (ZeroDivisionError, TypeError):
return "Enter a number other than zero"
for val in (0, "hello", 50.0, 13):
print("Testing {}:".format(val), end=" ")
print(funny_division2(val))
底部的for循环在多个测试输入上循环并打印结果。 如果您想知道print语句中的end参数,它将仅将默认的尾随换行符转换为空格,以便将其与下一行的输出结合在一起。 这是程序的运行:
Testing 0: Enter a number other than zero
Testing hello: Enter a number other than zero
Testing 50.0: 2.0
Testing 13: Traceback (most recent call last):
File "catch_multiple_exceptions.py", line 11, in <module>
print(funny_division2(val))
File "catch_multiple_exceptions.py", line 4, in funny_division2
raise ValueError("13 is an unlucky number")
ValueError: 13 is an unlucky number
数字0和字符串都被except子句捕获,并显示适当的错误消息。 无法捕获数字13中的异常,因为它是ValueError,未包含在要处理的异常类型中。 这一切都很好,但是如果我们想捕获不同的异常并对它们执行不同的操作怎么办? 还是我们想做一个例外处理,然后让它继续冒泡至父函数,就好像从未被捕获一样? 我们不需要任何新的语法来处理这些情况。 可以堆叠except子句,并且仅执行第一个匹配项。 对于第二个问题,如果我们已经在异常处理程序中,则不带任何参数的raise关键字将引发最后一个异常。 观察以下代码:
def funny_division3(anumber):
try:
if anumber == 13:
raise ValueError("13 is an unlucky number")
return 100 / anumber
except ZeroDivisionError:
return "Enter a number other than zero"
except TypeError:
return "Enter a numerical value"
except ValueError:
print("No, No, not 13!")
raise
最后一行重新显示ValueError,因此在输出No, No, not 13!之后,它将再次引发异常; 我们仍然会在控制台上获得原始堆栈跟踪。
如果像在上一个示例中那样堆叠异常子句,即使只有多个匹配子句适合,也将仅运行第一个匹配子句。 多个子句如何匹配? 请记住,异常是对象,因此可以被子类化。 正如我们将在下一节中看到的那样,大多数异常扩展了Exception类(它本身是从BaseException派生的)。 如果我们在捕获TypeError之前捕获了Exception,则仅会执行Exception处理程序,因为从继承的角度来看TypeError是Exception。
在我们要专门处理一些异常,然后再处理所有其余异常的情况下,这可能会派上用场。 我们可以在捕获所有特定异常之后简单地捕获Exception并在那里处理一般情况。
有时,当我们捕获到异常时,我们需要引用Exception对象本身。 当我们使用自定义参数定义自己的异常时,这种情况最经常发生,但也可能与标准异常有关。 大多数异常类在其构造函数中接受一组参数,我们可能希望在异常处理程序中访问这些属性。 如果定义了自己的异常类,则甚至可以在捕获到异常类时对其调用自定义方法。 将异常捕获为变量的语法使用as关键字:
try:
raise ValueError("This is an argument")
except ValueError as e:
print("The exception arguments were", e.args)
如果运行此简单代码段,它将在初始化时打印出传递给ValueError的字符串参数。
我们已经看到了用于处理异常的语法的几种变体,但是无论是否发生异常,我们仍然不知道如何执行代码。 我们也不能指定仅在没有异常发生时才应执行的代码。 另外两个关键字finally和else可以提供缺少的内容。 没有人接受任何额外的论点。 下面的示例随机选择一个异常引发并引发它。 然后,运行一些不太复杂的异常处理代码,以说明新引入的语法:
import random
some_exceptions = [ValueError, TypeError, IndexError, None]
try:
choice = random.choice(some_exceptions)
print("raising {}".format(choice))
if choice:
raise choice("An error")
except ValueError:
print("Caught a ValueError")
except TypeError:
print("Caught a TypeError")
except Exception as e:
print("Caught some other error: %s" %
( e.__class__.__name__))
else:
print("This code called if there is no exception")
finally:
print("This cleanup code is always called")
如果我们运行此示例(说明几乎所有可能的异常处理方案)几次,则每次都会得到不同的输出,具体取决于random选择的异常。 以下是一些示例运行:
$ python finally_and_else.py
raising None
This code called if there is no exception
This cleanup code is always called
$ python finally_and_else.py
raising <class 'TypeError'>
Caught a TypeError
This cleanup code is always called
$ python finally_and_else.py
raising <class 'IndexError'>
Caught some other error: IndexError
This cleanup code is always called
$ python finally_and_else.py
raising <class 'ValueError'>
Caught a ValueError
This cleanup code is always called
请注意,无论发生什么情况,如何执行finally子句中的 print语句。 当我们需要在代码完成运行后执行某些任务时(即使发生了异常),这非常有用。 一些常见的示例包括:
- 清理打开的数据库连接
- 关闭打开的文件
- 通过网络发送关闭握手
当我们从try子句内部执行return语句时,finally子句也非常重要。 返回值之前,finally句柄仍将执行。
另外,在不引发异常的情况下,请注意输出:else和finally子句均被执行。 else子句似乎是多余的,因为仅当没有引发异常时才应执行的代码可以放在整个try ... except块之后。 不同之处在于,如果捕获并处理了异常,else块仍将执行。 稍后讨论使用异常作为流控制时,我们将对此进行更多介绍。
在try块之后,可以省略except,else和finally子句中的任何子句(尽管else本身无效)。 如果包含多个,则必须先出现except子句,然后是else子句,最后是finally子句。 except子句的顺序通常从最具体到最通用。
异常层次结构
我们已经看到了几种最常见的内置异常,您可能会在常规 Python 开发过程中遇到其余的异常。 正如我们前面所注意到的,大多数例外是Exception类的子类。 但是,并非所有例外情况都是如此。 Exception本身实际上继承自名为BaseException的类。 实际上,所有异常都必须扩展BaseException类或其子类之一。
SystemExit和KeyboardInterrupt这是两个主要例外,它们直接源自BaseException而不是Exception。 每当程序自然退出时,都会引发SystemExit异常,这通常是因为我们在代码中的某个地方调用了sys.exit函数(例如,当用户选择退出菜单项时,单击了窗口上的“关闭”按钮,或者 输入了关闭服务器的命令)。 该异常旨在允许我们在程序最终退出之前清除代码,因此我们通常无需显式处理它(因为清除代码发生在finally子句中)。
如果我们确实处理了该异常,则通常会引发该异常,因为捕获该异常会阻止程序退出。 当然,在某些情况下,我们可能希望停止程序退出,例如,如果有未保存的更改,并且我们想在用户确实要退出时提示用户。 通常,如果我们完全处理SystemExit,那是因为我们要对它进行特殊处理,或者直接对其进行预期。 我们尤其不希望它被捕获所有普通异常的泛型子句意外地捕获。 这就是为什么它直接源自BaseException的原因。
KeyboardInterrupt异常在命令行程序中很常见。 当用户使用与操作系统相关的组合键(通常为 Ctrl +C)显式中断程序执行时,抛出该错误。 这是用户有意中断正在运行的程序的标准方法,并且像SystemExit一样,它几乎总是应通过终止程序来做出响应。 另外,像SystemExit一样,它应该处理finally块中的所有清理任务。
这是一个类图,充分说明了异常层次结构:

当我们使用except:子句而不指定任何异常类型时,它将捕获BaseException的所有子类; 也就是说,它将捕获所有异常,包括两个特殊异常。 由于我们几乎总是希望它们得到特殊处理,因此不带参数使用except:语句是不明智的。 如果要捕获SystemExit和KeyboardInterrupt以外的所有异常,请显式捕获Exception。
此外,如果您确实想捕获所有异常,我建议使用语法except BaseException:而不是原始的except:。 这有助于明确告知将来的代码读者您有意处理特殊情况的异常。
定义我们自己的例外
通常,当我们想要引发异常时,我们发现没有合适的内置异常。 幸运的是,定义我们自己的新异常很简单。 通常,该类的名称旨在传达问题所在,并且我们可以在初始化程序中提供任意参数以包含其他信息。
我们要做的就是从Exception类继承。 我们甚至不必在课程中添加任何内容! 当然,我们可以直接扩展BaseException,但是它将不会被通用except Exception子句捕获。
这是我们可能在银行应用中使用的一个简单例外:
class InvalidWithdrawal(Exception):
pass
raise InvalidWithdrawal("You don't have $50 in your account")
最后一行说明了如何引发新定义的异常。 我们能够将任意数量的参数传递给异常。 通常会使用字符串消息,但是可以存储在以后的异常处理程序中可能有用的任何对象。 Exception.__init__方法旨在接受任何参数,并将它们作为元组存储在名为args的属性中。 这使异常更易于定义,而无需覆盖__init__。
当然,如果我们确实想自定义初始化程序,则可以随意进行。 这是一个例外情况,其初始化程序接受当前余额和用户要提取的金额。 另外,它添加了一种方法来计算请求的透支程度:
class InvalidWithdrawal(Exception):
def __init__(self, balance, amount):
super().__init__("account doesn't have ${}".format(
amount))
self.amount = amount
self.balance = balance
def overage(self):
return self.amount - self.balance
raise InvalidWithdrawal(25, 50)
最后的raise语句说明了如何构造此异常。 如您所见,除了可以处理其他对象外,我们可以做任何事情。 我们可以捕获异常并将其作为工作对象传递,尽管更常见的是将对工作对象的引用作为异常的属性包括进来,然后将其传递。
如果引发一个InvalidWithdrawal异常,这是我们将如何处理:
try:
raise InvalidWithdrawal(25, 50)
except InvalidWithdrawal as e:
print("I'm sorry, but your withdrawal is "
"more than your balance by "
"${}".format(e.overage()))
在这里,我们看到as关键字的有效用法。 按照惯例,大多数 Python 编码器都将异常变量命名为e,尽管您通常可以随意将其命名为ex,exception或aunt_sally。
定义我们自己的例外有很多原因。 将信息添加到异常或以某种方式记录日志通常很有用。 但是,在创建旨在供其他程序员访问的框架,库或 API 时,自定义异常的实用程序真正发挥作用。 在这种情况下,请务必确保您的代码提出了对客户端程序员有意义的异常。 它们应该易于处理并清楚地描述发生了什么。 客户端程序员应该容易地看到如何解决错误(如果它反映了他们的代码中的错误)或处理异常(如果是这种情况,则需要使他们意识到)。
异常不是例外。 新手程序员倾向于认为异常仅对特殊情况有用。 但是,特殊情况的定义可能会含糊不清,并可能需要解释。 请考虑以下两个功能:
def divide_with_exception(number, divisor):
try:
print("{} / {} = {}".format(
number, divisor, number / divisor * 1.0))
except ZeroDivisionError:
print("You can't divide by zero")
def divide_with_if(number, divisor):
if divisor == 0:
print("You can't divide by zero")
else:
print("{} / {} = {}".format(
number, divisor, number / divisor * 1.0))
这些两个功能的行为相同。 如果divisor为零,则会显示一条错误消息;否则,将显示错误消息。 否则,显示打印除法结果的消息。 通过使用if语句进行测试,可以避免抛出ZeroDivisionError。 类似地,我们可以通过显式检查参数是否在列表的范围内来避免使用IndexError,而通过检查键是否在字典中来避免使用KeyError。
但是我们不应该这样做。 一方面,我们可能会编写if语句来检查索引是否低于列表的参数,但忘记检查负值。
注意
请记住,Python 列表支持否定索引。 -1引用列表中的最后一个元素。
最终,我们将发现这一点,并且必须找到检查代码的所有位置。 但是,如果我们只是捕获并处理了IndexError,我们的代码就可以工作。
Python 程序员倾向于遵循寻求宽恕而不是许可的模型,也就是说,他们执行代码,然后处理任何出错的地方。 通常,对于在跳到之前先看一眼的选择不屑一顾。 造成这种情况的原因有很多,但是主要的原因是,不必花费大量的 CPU 周期来寻找在正常的代码路径中不会出现的异常情况。 因此,明智的做法是在例外情况下使用例外,即使这些情况只是一点例外。 更进一步,我们实际上可以看到异常语法对于流控制也是有效的。 像if语句一样,可以将异常用于决策,分支和消息传递。
想象一个库存应用,该应用销售一家小部件和小工具的公司。 当客户购买商品时,该商品可以被使用,在这种情况下,该商品将从库存中移除并退回剩余的商品数量,或者它可能无货。 现在,缺货是库存应用中发生的完全正常的事情。 当然,这不是例外情况。 但是,如果缺货我们会退货吗? 字符串说缺货? 负数? 在这两种情况下,调用方法都必须检查返回值是正整数还是其他值,以确定是否缺货。 似乎有点混乱。 相反,我们可以提高OutOfStockException并使用try语句来指导程序流控制。 有道理? 此外,我们要确保不会将同一商品卖给两个不同的客户,也不会出售没有库存的商品。 简化此操作的一种方法是锁定每种类型的项目,以确保一次只有一个人可以更新它。 用户必须锁定该物品,操纵该物品(购买,添加库存,盘点剩余物品……),然后解锁该物品。 这是一个带有文档字符串的不完整Inventory示例,该示例描述了某些方法应该执行的操作:
class Inventory:
def lock(self, item_type):
'''Select the type of item that is going to
be manipulated. This method will lock the
item so nobody else can manipulate the
inventory until it's returned. This prevents
selling the same item to two different
customers.'''
pass
def unlock(self, item_type):
'''Release the given type so that other
customers can access it.'''
pass
def purchase(self, item_type):
'''If the item is not locked, raise an
exception. If the item_type does not exist,
raise an exception. If the item is currently
out of stock, raise an exception. If the item
is available, subtract one item and return
the number of items left.'''
pass
我们可以将的对象原型交给开发人员,让他们实现所需的方法以完全按照他们所说的进行操作,同时处理需要购买的代码。 我们将使用 Python 强大的异常处理来考虑不同的分支,具体取决于购买方式:
item_type = 'widget'
inv = Inventory()
inv.lock(item_type)
try:
num_left = inv.purchase(item_type)
except InvalidItemType:
print("Sorry, we don't sell {}".format(item_type))
except OutOfStock:
print("Sorry, that item is out of stock.")
else:
print("Purchase complete. There are "
"{} {}s left".format(num_left, item_type))
finally:
inv.unlock(item_type)
请注意如何使用所有可能的异常处理子句来确保正确的操作在正确的时间发生。 即使OutOfStock并不是非常例外的情况,我们也可以使用异常来适当地处理它。 可以使用if ... elif ... else结构编写相同的代码,但是它不那么容易阅读或维护。
我们还可以使用异常在不同方法之间传递消息。 例如,如果我们想通知客户该物料预计在什么日期再次进货,我们可以确保OutOfStock对象在构造时需要back_in_stock参数。 然后,当我们处理异常时,我们可以检查该值并向客户提供其他信息。 附加到对象的信息可以轻松地在程序的两个不同部分之间传递。 该异常甚至可以提供一种指示库存对象重新订购或补货的方法。
将异常用于流控制可以使一些方便的程序设计成为可能。 从此讨论中获取的重要信息是,异常不是我们应该避免的不好的事情。 发生异常并不意味着您应该防止这种特殊情况的发生。 而是,这只是在可能不会直接相互调用的两个代码段之间传递信息的有效方法。
案例研究
我们一直在以相当低的详细程度(语法和定义)研究的使用和异常处理。 此案例研究将有助于将其与我们前面的章节联系在一起,以便我们了解如何在对象,继承和模块的更大上下文中使用异常。
今天,我们将设计一个简单的中央身份验证和授权系统。 整个系统将放置在一个模块中,其他代码将能够查询该模块对象以进行身份验证和授权。 我们应该从一开始就承认我们不是安全专家,并且我们正在设计的系统可能充满安全漏洞。 我们的目的是研究异常,而不是保护系统。 但是,对于一个基本的登录和许可系统,其他代码可以与之交互就足够了。 以后,如果需要使其他代码更安全,我们可以请安全性或加密专家审查或重写我们的模块,最好不更改 API。
身份验证是确保用户确实是他们所说的人的过程。 我们将沿用当今常见的 Web 系统,使用用户名和私人密码组合。 其他身份验证方法包括语音识别,指纹或视网膜扫描仪和身份证。
另一方面,授权是关于确定是否允许给定(经过身份验证的)用户执行特定操作的。 我们将创建一个基本的权限列表系统,其中存储了允许执行每个操作的特定人员的列表。
此外,我们将添加一些管理功能,以允许将新用户添加到系统中。 为简便起见,添加密码后,我们将省去密码的编辑或更改权限,但是将来肯定可以添加这些(非常必要的)功能。
有一个简单的分析; 现在让我们继续设计。 显然,我们需要一个User类来存储用户名和加密密码。 该类还将允许用户通过检查提供的密码是否有效来登录。 我们可能不需要Permission类,因为这些类可以只是使用字典映射到用户列表的字符串。 我们应该有一个中央的Authenticator类来处理用户管理以及登录或注销。 难题的最后一部分是Authorizor类,该类处理权限并检查用户是否可以执行活动。 我们将在auth模块中为每个此类提供一个实例,以便其他模块可以使用此中心机制来满足其所有身份验证和授权需求。 当然,如果他们想实例化这些类的私有实例,则对于非中央授权活动,他们可以自由地这样做。
我们还将定义几个例外。 我们将从一个特殊的AuthException基类开始,该基类接受username和可选的user对象作为参数; 我们大多数的自定义异常都将从该异常继承。
让我们首先构建User类; 看起来很简单。 可以使用用户名和密码初始化新用户。 密码将以加密方式存储,以减少被盗的机会。 我们还需要一种check_password方法来测试提供的密码是否正确。 这是完整的课程:
import hashlib
class User:
def __init__(self, username, password):
'''Create a new user object. The password
will be encrypted before storing.'''
self.username = username
self.password = self._encrypt_pw(password)
self.is_logged_in = False
def _encrypt_pw(self, password):
'''Encrypt the password with the username and return
the sha digest.'''
hash_string = (self.username + password)
hash_string = hash_string.encode("utf8")
return hashlib.sha256(hash_string).hexdigest()
def check_password(self, password):
'''Return True if the password is valid for this
user, false otherwise.'''
encrypted = self._encrypt_pw(password)
return encrypted == self.password
由于__init__和check_password都需要用于加密密码的代码,因此我们将其拉出自己的方法。 这样,只有在有人意识到它不安全并且需要改进时,才需要在一个地方进行更改。 该类可以轻松扩展为包括必填或可选的个人详细信息,例如姓名,联系信息和生日。
在编写代码以添加用户之前(这将在尚未定义的Authenticator类中发生),我们应该检查一些用例。 如果一切顺利,我们可以添加一个用户名和密码。 创建User对象并将其插入字典中。 但是,在什么方面都不能顺利? 好吧,显然,我们不想添加用户名与字典中已经存在的用户相同的用户。 如果这样做,我们将覆盖现有用户的数据,新用户可能会访问该用户的特权。 因此,我们需要一个UsernameAlreadyExists异常。 另外,为了安全起见,如果密码太短,我们可能应该引发异常。 这两个异常都将扩展AuthException,这是我们前面提到的。 因此,在编写Authenticator类之前,让我们定义以下三个异常类:
class AuthException(Exception):
def __init__(self, username, user=None):
super().__init__(username, user)
self.username = username
self.user = user
class UsernameAlreadyExists(AuthException):
pass
class PasswordTooShort(AuthException):
pass
AuthException需要用户名并具有可选的用户参数。 第二个参数应该是与该用户名关联的User类的实例。 我们正在定义的两个特定异常只需要告知调用类异常情况,因此我们不需要向其添加任何其他方法。
现在让我们从Authenticator类开始。 它可以只是用户名到用户对象的映射,因此我们将从初始化函数中的字典开始。 添加用户的方法需要在创建新的User实例并将其添加到字典之前检查两个条件(密码长度和以前存在的用户):
class Authenticator:
def __init__(self):
'''Construct an authenticator to manage
users logging in and out.'''
self.users = {}
def add_user(self, username, password):
if username in self.users:
raise UsernameAlreadyExists(username)
if len(password) < 6:
raise PasswordTooShort(username)
self.users[username] = User(username, password)
当然,如果需要,我们可以扩展的密码验证,以引发太容易以其他方式破解的密码例外。 现在让我们准备login方法。 如果我们现在不考虑异常,则可能只希望该方法返回True或False,具体取决于登录是否成功。 但是我们正在考虑例外情况,这可能是在不太例外的情况下使用例外情况的好地方。 我们可能会提出不同的例外情况,例如,如果用户名不存在或密码不匹配。 这样,任何试图登录用户的人都可以使用try / except / else子句轻松地处理这种情况。 因此,首先我们添加以下新异常:
class InvalidUsername(AuthException):
pass
class InvalidPassword(AuthException):
pass
然后,我们可以为Authenticator类定义一个简单的login方法,该方法在必要时引发这些异常。 如果没有,它将user标记为已登录并返回:
def login(self, username, password):
try:
user = self.users[username]
except KeyError:
raise InvalidUsername(username)
if not user.check_password(password):
raise InvalidPassword(username, user)
user.is_logged_in = True
return True
注意如何处理KeyError。 可以使用if username not in self.users:处理此问题,但我们选择直接处理该异常。 我们最终吃掉了第一个例外,并提出了一个全新的,更适合面向用户的 API 的例外。
我们还可以添加一种方法来检查是否登录了特定的用户名。在此处决定是否使用异常更为棘手。 如果用户名不存在,是否应该引发异常? 如果用户未登录,是否应该引发异常?
要回答这些问题,我们需要考虑如何访问该方法。 通常,此方法将用于回答“是/否”问题:“我应该允许他们访问 <还是> 吗?” 答案将是“是,用户名有效且已登录”,或“否,用户名无效或未登录”。 因此,布尔返回值就足够了。 此处仅出于使用异常的目的而无需使用异常。
def is_logged_in(self, username):
if username in self.users:
return self.users[username].is_logged_in
return False
最后,我们可以向模块添加默认的身份验证器实例,以便客户端代码可以使用auth.authenticator轻松访问它:
authenticator = Authenticator()
该行在任何类定义之外的模块级别进入,因此可以通过auth.authenticator来访问 authenticator 变量。 现在我们可以从Authorizor类开始,该类将权限映射到用户。 如果用户未登录,则Authorizor类不应允许用户访问权限,因此,他们需要引用特定的身份验证器。 我们还需要在初始化时设置权限字典:
class Authorizor:
def __init__(self, authenticator):
self.authenticator = authenticator
self.permissions = {}
现在,我们可以编写添加新权限以及设置与每个权限相关联的用户的方法:
def add_permission(self, perm_name):
'''Create a new permission that users
can be added to'''
try:
perm_set = self.permissions[perm_name]
except KeyError:
self.permissions[perm_name] = set()
else:
raise PermissionError("Permission Exists")
def permit_user(self, perm_name, username):
'''Grant the given permission to the user'''
try:
perm_set = self.permissions[perm_name]
except KeyError:
raise PermissionError("Permission does not exist")
else:
if username not in self.authenticator.users:
raise InvalidUsername(username)
perm_set.add(username)
第一个方法允许我们创建一个新的权限,除非它已经存在,在这种情况下会引发异常。 第二个允许我们将用户名添加到权限中,除非该权限或用户名尚不存在。
我们使用set代替list作为用户名,因此,即使您多次授予用户权限,集合的性质也意味着该用户仅在集合中一次。 我们将在下一章中进一步讨论集合。
两种方法都将引发PermissionError。 这个新错误不需要用户名,因此我们将使其直接扩展为Exception,而不是我们的自定义AuthException:
class PermissionError(Exception):
pass
最后,我们可以添加一种方法来检查用户是否具有特定的permission。 为了使他们具有访问权限,必须将它们既登录到身份验证器中,又要登录到已被授予访问该特权的人员集中。 如果不满足这些条件之一,则会引发异常:
def check_permission(self, perm_name, username):
if not self.authenticator.is_logged_in(username):
raise NotLoggedInError(username)
try:
perm_set = self.permissions[perm_name]
except KeyError:
raise PermissionError("Permission does not exist")
else:
if username not in perm_set:
raise NotPermittedError(username)
else:
return True
这里有两个新的例外; 它们都使用用户名,因此我们将它们定义为AuthException的子类:
class NotLoggedInError(AuthException):
pass
class NotPermittedError(AuthException):
pass
最后,我们可以添加默认的authorizor与我们的默认身份验证器一起使用:
authorizor = Authorizor(authenticator)
从而完成了基本身份验证/授权系统。 我们可以在 Python 提示符下测试系统,检查是否允许用户joe在油漆部门执行任务:
>>> import auth
>>> auth.authenticator.add_user("joe", "joepassword")
>>> auth.authorizor.add_permission("paint")
>>> auth.authorizor.check_permission("paint", "joe")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "auth.py", line 109, in check_permission
raise NotLoggedInError(username)
auth.NotLoggedInError: joe
>>> auth.authenticator.is_logged_in("joe")
False
>>> auth.authenticator.login("joe", "joepassword")
True
>>> auth.authorizor.check_permission("paint", "joe")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "auth.py", line 116, in check_permission
raise NotPermittedError(username)
auth.NotPermittedError: joe
>>> auth.authorizor.check_permission("mix", "joe")
Traceback (most recent call last):
File "auth.py", line 111, in check_permission
perm_set = self.permissions[perm_name]
KeyError: 'mix'
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "auth.py", line 113, in check_permission
raise PermissionError("Permission does not exist")
auth.PermissionError: Permission does not exist
>>> auth.authorizor.permit_user("mix", "joe")
Traceback (most recent call last):
File "auth.py", line 99, in permit_user
perm_set = self.permissions[perm_name]
KeyError: 'mix'
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "auth.py", line 101, in permit_user
raise PermissionError("Permission does not exist")
auth.PermissionError: Permission does not exist
>>> auth.authorizor.permit_user("paint", "joe")
>>> auth.authorizor.check_permission("paint", "joe")
True
虽然非常详细,但显示了我们的所有代码和大多数正在使用的异常,但是要真正理解我们定义的 API,我们应该编写一些实际使用它的异常处理代码。 这是一个基本的菜单界面,允许某些用户更改或测试程序:
import auth
# Set up a test user and permission
auth.authenticator.add_user("joe", "joepassword")
auth.authorizor.add_permission("test program")
auth.authorizor.add_permission("change program")
auth.authorizor.permit_user("test program", "joe")
class Editor:
def __init__(self):
self.username = None
self.menu_map = {
"login": self.login,
"test": self.test,
"change": self.change,
"quit": self.quit
}
def login(self):
logged_in = False
while not logged_in:
username = input("username: ")
password = input("password: ")
try:
logged_in = auth.authenticator.login(
username, password)
except auth.InvalidUsername:
print("Sorry, that username does not exist")
except auth.InvalidPassword:
print("Sorry, incorrect password")
else:
self.username = username
def is_permitted(self, permission):
try:
auth.authorizor.check_permission(
permission, self.username)
except auth.NotLoggedInError as e:
print("{} is not logged in".format(e.username))
return False
except auth.NotPermittedError as e:
print("{} cannot {}".format(
e.username, permission))
return False
else:
return True
def test(self):
if self.is_permitted("test program"):
print("Testing program now...")
def change(self):
if self.is_permitted("change program"):
print("Changing program now...")
def quit(self):
raise SystemExit()
def menu(self):
try:
answer = ""
while True:
print("""
Please enter a command:
\tlogin\tLogin
\ttest\tTest the program
\tchange\tChange the program
\tquit\tQuit
""")
answer = input("enter a command: ").lower()
try:
func = self.menu_map[answer]
except KeyError:
print("{} is not a valid option".format(
answer))
else:
func()
finally:
print("Thank you for testing the auth module")
Editor().menu()
这个相当长的示例在概念上非常简单。 is_permitted方法可能是最有趣的; 这是test和change都调用的一种内部方法,以确保在继续操作之前允许用户访问。 当然,这两种方法都是存根,但是我们这里不是在编写编辑器。 我们通过测试身份验证和授权框架来说明异常和异常处理程序的使用



六、何时使用面向对象的编程
在前面的章节中,我们介绍了面向对象编程的许多定义功能。 现在,我们了解了面向对象设计的原理和范例,并且介绍了 Python 中面向对象编程的语法。
但是,我们并不确切地知道如何以及何时在实践中利用这些原理和语法。 在本章中,我们将讨论所获得知识的一些有用应用,并逐步提出一些新主题:
- 如何识别物体
- 数据和行为,再一次
- 使用属性将数据包装为行为
- 使用行为限制数据
- 不要重复自己的原则
- 识别重复的代码
将对象视为对象
这似乎很明显。 通常,应该在问题域中为单独的对象提供代码中的特殊类。 在前几章的案例研究中,我们已经看到了这样的例子。 首先,我们确定问题中的对象,然后对它们的数据和行为进行建模。
在面向对象的分析和编程中,识别对象是一项非常重要的任务。 但这并不总是像我们一直在做的那样简单地在短段中计算名词一样容易。 记住,对象是既具有数据又具有行为的事物。 如果仅处理数据,通常最好将其存储在列表,集合,字典或其他 Python 数据结构中(我们将在第 6 章,“Python 数据结构”中进行全面介绍)。 另一方面,如果我们仅处理行为,而不处理存储的数据,则更简单的功能是更合适的。
但是,对象既具有数据又具有行为。 精通 Python 的程序员使用内置的数据结构,除非(或直到)显然需要定义一个类。 如果没有帮助组织我们的代码,则没有理由添加额外的抽象级别。 另一方面,“显而易见的”需求并不总是不言而喻的。
我们通常可以通过将数据存储在几个变量中来启动 Python 程序。 随着程序的扩展,我们以后会发现我们正在将相同的一组相关变量传递给一组函数。 现在是时候考虑将变量和函数都分组到一个类中了。 如果我们要设计一个在二维空间中对多边形建模的程序,则可以从将每个多边形表示为点列表开始。 这些点将被建模为两个元组(x,y),以描述该点的位置。 这是所有数据,存储在一组嵌套数据结构(特别是元组列表)中:
square = [(1,1), (1,2), (2,2), (2,1)]
现在,如果我们要计算多边形周围的距离,我们只需要对两点之间的距离求和即可。 为此,我们还需要一个函数来计算两点之间的距离。 这是两个这样的功能:
import math
def distance(p1, p2):
return math.sqrt((p1[0]-p2[0])**2 + (p1[1]-p2[1])**2)
def perimeter(polygon):
perimeter = 0
points = polygon + [polygon[0]]
for i in range(len(polygon)):
perimeter += distance(points[i], points[i+1])
return perimeter
现在,作为面向对象的程序员,我们清楚地认识到polygon类可以封装点列表(数据)和perimeter函数(行为)。 此外,point类(例如我们在第 2 章,Python 中的对象中定义的)可能封装了x和y坐标以及distance方法。 问题是:这样做有价值吗?
对于先前的代码,也许是,也许不是。 凭借我们在面向对象原理方面的最新经验,我们可以在记录时间内编写一个面向对象的版本。 让我们比较一下
import math
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def distance(self, p2):
return math.sqrt((self.x-p2.x)**2 + (self.y-p2.y)**2)
class Polygon:
def __init__(self):
self.vertices = []
def add_point(self, point):
self.vertices.append((point))
def perimeter(self):
perimeter = 0
points = self.vertices + [self.vertices[0]]
for i in range(len(self.vertices)):
perimeter += points[i].distance(points[i+1])
return perimeter
从突出显示的部分可以看出,这里的代码是早期版本的两倍,尽管我们可以认为add_point方法不是严格必需的。
现在,为了更好地理解这些差异,让我们比较两个使用中的 API。 以下是使用面向对象的代码计算正方形的周长的方法:
>>> square = Polygon()
>>> square.add_point(Point(1,1))
>>> square.add_point(Point(1,2))
>>> square.add_point(Point(2,2))
>>> square.add_point(Point(2,1))
>>> square.perimeter()
4.0
您可能会认为这相当简洁且易于阅读,但让我们将其与基于函数的代码进行比较:
>>> square = [(1,1), (1,2), (2,2), (2,1)]
>>> perimeter(square)
4.0
嗯,也许面向对象的 API 并不是那么紧凑! 就是说,我认为比更容易读懂函数示例:我们如何知道第二个版本中的元组列表应该代表什么? 我们应该如何记住应该传递给perimeter函数的哪种对象(两个元组的列表?这不直观!)? 我们将需要大量文档来解释如何使用这些功能。
相反,面向对象的代码是相对自记录的,我们只需要查看方法及其参数的列表即可了解对象的功能以及如何使用它。 到我们编写功能版本的所有文档时,它可能比面向对象的代码还要长。
最后,代码长度不是代码复杂度的良好指标。 一些程序员迷上了复杂的“单一代码”,它们在一行代码中完成了大量工作。 这可能是一个有趣的练习,但是结果通常是难以理解的,即使是第二天的原始作者也是如此。 减少代码量通常可以使程序更易于阅读,但不要盲目地认为是这种情况。
幸运的是,这种折衷是不必要的。 我们可以使面向对象的Polygon API 与功能实现一样易于使用。 我们要做的就是更改Polygon类,以便可以用多个点构造它。 让我们给它一个初始化器,它接受Point对象的列表。 实际上,让我们也允许它接受元组,如果需要,我们可以自己构造Point对象:
def __init__(self, points=None):
points = points if points else []
self.vertices = []
for point in points:
if isinstance(point, tuple):
point = Point(*point)
self.vertices.append(point)
该初始化程序将遍历列表,并确保将任何元组都转换为点。 如果该对象不是元组,则假定它已经是Point对象,或者可以充当Point对象的未知鸭子类型对象,则将其保留不变。
但是,此代码的面向对象版本和面向数据版本之间并没有明显的赢家。 他们俩都做同样的事情。 如果我们有接受多边形参数的新函数,例如area(polygon)或point_in_polygon(polygon, x, y),则面向对象代码的好处将变得越来越明显。 同样,如果将其他属性(例如color或texture)添加到多边形,则将数据封装到单个类中变得越来越有意义。
区别是一项设计决策,但通常,一组数据越复杂,它具有针对该数据的多个功能的可能性就越大,并且使用具有属性和 方法代替。
在做出此决定时,还需要考虑如何使用该类。 如果我们只是试图在一个更大的问题的背景下计算一个多边形的周长,那么使用一个函数可能会最快地编写代码,并且更容易使用“仅一次”。 另一方面,如果我们的程序需要以多种方式操纵多个多边形(计算周长,面积,与其他多边形的相交,移动或缩放等),则我们肯定可以确定一个对象。 需要非常通用的一种。
此外,请注意对象之间的交互。 寻找继承关系; 没有类就无法优雅地进行继承建模,因此请确保使用它们。 寻找我们在第 1 章,“面向对象设计”,关联和组合中讨论的其他类型的关系。 从技术上讲,可以仅使用数据结构对构成进行建模; 例如,我们可以有一个保存元组值的字典列表,但是创建一些对象类通常不那么复杂,尤其是当存在与数据相关联的行为时。
注意
不要仅仅因为可以使用一个对象而急于使用一个对象,但是当需要使用一个类时,绝不会忽略创建一个类。
为具有属性的类数据添加行为
在整个模块中,我们一直专注于行为和数据的分离。 这在面向对象的编程中非常重要,但是我们将看到,在 Python 中,这种区别可能会变得非常模糊。 Python 非常擅长模糊区分。 它并不能完全帮助我们“跳出框框思考”。 相反,它教会我们停止思考盒子。
在深入探讨细节之前,让我们讨论一些糟糕的面向对象理论。 许多面向对象的语言(Java 最臭名昭著)告诉我们永远不要直接访问属性。 他们坚持要求我们这样写属性访问:
class Color:
def __init__(self, rgb_value, name):
self._rgb_value = rgb_value
self._name = name
def set_name(self, name):
self._name = name
def get_name(self):
return self._name
变量前面带有下划线,表示它们是私有的(其他语言实际上会强制它们私有)。 然后,get 和 set 方法提供对每个变量的访问。 该类将在实践中如下使用:
>>> c = Color("#ff0000", "bright red")
>>> c.get_name()
'bright red'
>>> c.set_name("red")
>>> c.get_name()
'red'
它不像 Python 支持的直接访问版本那样可读:
class Color:
def __init__(self, rgb_value, name):
self.rgb_value = rgb_value
self.name = name
c = Color("#ff0000", "bright red")
print(c.name)
c.name = "red"
那么,为什么有人会坚持基于方法的语法呢? 他们的理由是,有一天我们可能想在设置或检索值时添加额外的代码。 例如,我们可以决定缓存一个值并返回缓存的值,或者我们可能想验证该值是否是合适的输入。
在代码中,我们可以决定更改set_name()方法,如下所示:
def set_name(self, name):
if not name:
raise Exception("Invalid Name")
self._name = name
现在,在 Java 和类似语言中,如果我们编写了原始代码来进行直接属性访问,然后又将其更改为类似于上一个方法,则我们将遇到一个问题:任何编写过以下代码的人 直接访问属性现在将必须访问该方法。 如果他们没有将访问样式从属性访问更改为函数调用,则其代码将被破坏。 这些语言的口头禅是,我们绝不应将公共成员设为私人。 在 Python 中这没有多大意义,因为没有任何真正的私有成员概念!
Python 为我们提供了property关键字,以使方法看起来像属性。 因此,我们可以编写代码以使用直接成员访问,并且如果我们意外地需要更改实现以在获取或设置该属性的值时进行一些计算,则可以在不更改接口的情况下进行操作。 让我们看看它的外观:
class Color:
def __init__(self, rgb_value, name):
self.rgb_value = rgb_value
self._name = name
def _set_name(self, name):
if not name:
raise Exception("Invalid Name")
self._name = name
def _get_name(self):
return self._name
name = property(_get_name, _set_name)
如果我们从较早的基于非方法的类开始,该类直接设置了name属性,则我们以后可以将代码更改为类似于前面的代码。 我们首先将name属性更改为(半)私有_name属性。 然后,我们再添加两个(半)私有方法来获取并设置该变量,并在设置变量时进行验证。
最后,我们在底部有property声明。 这是魔术。 它在Color类上创建了一个名为name的新属性,该属性现在替换了先前的name属性。 它将这个属性设置为一个属性,只要访问或更改属性,它就会调用我们刚刚创建的两个方法。 可以使用与先前版本完全相同的方式来使用Color类的新版本,但是现在我们在设置name属性时可以进行验证:
>>> c = Color("#0000ff", "bright red")
>>> print(c.name)
bright red
>>> c.name = "red"
>>> print(c.name)
red
>>> c.name = ""
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "setting_name_property.py", line 8, in _set_name
raise Exception("Invalid Name")
Exception: Invalid Name
因此,如果我们先前编写了代码来访问name属性,然后将其更改为使用我们的property对象,则除非发送空的property值,否则先前的代码仍然可以使用。 我们首先要禁止的行为。 成功!
请记住,即使使用name属性,以前的代码也不是 100%安全的。 人们仍然可以直接访问_name属性,并根据需要将其设置为空字符串。 但是,如果他们访问一个变量,我们已经在其中明确标记了下划线以表明该变量是私有的,那么他们就是那些必须处理后果的人,而不是我们。
详细属性
将property函数视为返回一个对象,该对象可以通过我们指定的方法代理任何设置或访问属性值的请求。 property关键字类似于此类对象的构造函数,并且该对象设置为给定属性的面向公众的成员。
该 property构造函数实际上可以接受两个附加参数,即删除函数和该属性的文档字符串。 实际上,delete函数很少提供,但对于记录已删除的值或在我们有理由这样做时可以否决删除的记录很有用。 docstring 只是描述属性作用的字符串,与我们在第 2 章和 Python 中的对象中讨论的 docstring 相同。 如果我们不提供此参数,则将从第一个参数的文档字符串中复制文档字符串:getter 方法。 这是一个愚蠢的示例,仅在任何方法被调用时简单声明:
class Silly:
def _get_silly(self):
print("You are getting silly")
return self._silly
def _set_silly(self, value):
print("You are making silly {}".format(value))
self._silly = value
def _del_silly(self):
print("Whoah, you killed silly!")
del self._silly
silly = property(_get_silly, _set_silly,
_del_silly, "This is a silly property")
如果我们实际使用此类,则当我们要求时,它确实会打印出正确的字符串:
>>> s = Silly()
>>> s.silly = "funny"
You are making silly funny
>>> s.silly
You are getting silly
'funny'
>>> del s.silly
Whoah, you killed silly!
此外,如果我们查看Silly类的帮助文件(通过在解释器提示符处发出help(silly)),它将为我们显示silly属性的自定义文档字符串:
Help on class Silly in module __main__:
class Silly(builtins.object)
| Data descriptors defined here:
|
| __dict__
| dictionary for instance variables (if defined)
|
| __weakref__
| list of weak references to the object (if defined)
|
| silly
| This is a silly property
再一次,一切都按我们的计划进行。 实际上,通常只使用前两个参数来定义属性:getter 和 setter 函数。 如果要为属性提供文档字符串,可以在 getter 函数上定义它; 属性代理会将其复制到自己的文档字符串中。 删除功能通常留空,因为对象属性很少被删除。 如果编码人员确实尝试删除未指定删除功能的属性,则会引发异常。 因此,如果有正当理由删除我们的财产,我们应该提供该功能。
装饰器–创建属性的另一种方法
如果您以前从未使用过 Python 装饰器,那么在第 10 章和 Python 设计模式 I 中讨论装饰器模式之后,您可能希望跳过本节并返回。。 但是,您无需了解使用装饰器语法使属性方法更具可读性的情况。
属性函数可以与装饰器语法一起使用,以将 get 函数转换为属性:
class Foo:
@property
def foo(self):
return "bar"
这将property函数用作装饰器,并且等效于先前的foo = property(foo)语法。 从可读性的角度来看,主要区别在于我们可以将foo函数标记为方法顶部的属性,而不是在定义之后将其容易忽略的属性。 这也意味着我们不必仅使用下划线前缀创建私有方法来定义属性。
更进一步,我们可以为新属性指定一个 setter 函数,如下所示:
class Foo:
@property
def foo(self):
return self._foo
@foo.setter
def foo(self, value):
self._foo = value
尽管意图很明显,但此语法看起来很奇怪。 首先,我们将foo方法修饰为吸气剂。 然后,通过应用最初装饰的foo方法的setter属性,装饰具有完全相同名称的第二种方法! property函数返回一个对象; 该对象始终带有自己的setter属性,然后可以将其用作装饰器以使用其他功能。 不需要为 get 和 set 方法使用相同的名称,但这确实有助于将访问一个属性的多个方法组合在一起。
我们还可以使用@foo.deleter指定删除功能。 我们无法使用property装饰器指定文档字符串,因此我们需要依赖于从初始 getter 方法复制文档字符串的属性。
这是我们先前的Silly类,重写为使用property作为装饰器:
class Silly:
@property
def silly(self):
"This is a silly property"
print("You are getting silly")
return self._silly
@silly.setter
def silly(self, value):
print("You are making silly {}".format(value))
self._silly = value
@silly.deleter
def silly(self):
print("Whoah, you killed silly!")
del self._silly
此类的操作与我们的早期版本完全相同,包括帮助文本。 您可以使用任何感觉更易读和优雅的语法。
决定何时使用属性
内置的属性使行为和数据之间的划分变得模糊不清,从而使您难以选择哪个。 我们前面看到的示例用例是属性的最常见用法之一。 我们在某个类上有一些数据,稍后我们要向其添加行为。 在决定使用物业时,还需要考虑其他因素。
从技术上讲,在 Python 中,数据,属性和方法都是类的属性。 方法是可调用的这一事实并未将其与其他类型的属性区分开; 确实,我们将在第 7 章和 Python 面向对象的快捷方式中看到,可以创建可以像函数一样调用的普通对象。 我们还将发现函数和方法本身就是普通的对象。
方法只是可调用的属性,而属性只是可定制的属性,这一事实可以帮助我们做出此决定。 方法通常应代表行动; 可以对对象执行或由对象执行的事情。 当调用一个方法时,即使只有一个参数,它也应该做。 方法名称通常是动词。
确认属性不是动作后,我们需要在标准数据属性和属性之间做出决定。 通常,请始终使用标准属性,直到您需要以某种方式控制对该属性的访问。 无论哪种情况,您的属性通常都是一个名词。 属性和属性之间的唯一区别是,当检索,设置或删除属性时,我们可以自动调用自定义操作。
让我们看一个更现实的例子。 自定义行为的常见需求是缓存一个难以计算或查找成本很高的值(例如,需要网络请求或数据库查询)。 目标是将值存储在本地,以避免重复调用昂贵的计算。
我们可以通过在属性上使用自定义 getter 来做到这一点。 第一次检索该值时,我们执行查找或计算。 然后,我们可以将值作为对象的私有属性在本地缓存(或在专用缓存软件中),并且下次请求该值时,我们将返回存储的数据。 这是我们缓存网页的方法:
from urllib.request import urlopen
class WebPage:
def __init__(self, url):
self.url = url
self._content = None
@property
def content(self):
if not self._content:
print("Retrieving New Page...")
self._content = urlopen(self.url).read()
return self._content
我们可以测试此代码以查看页面仅被检索一次:
>>> import time
>>> webpage = WebPage("http://ccphillips.net/")
>>> now = time.time()
>>> content1 = webpage.content
Retrieving New Page...
>>> time.time() - now
22.43316888809204
>>> now = time.time()
>>> content2 = webpage.content
>>> time.time() - now
1.9266459941864014
>>> content2 == content1
True
最初测试此代码时,我处于糟糕的卫星连接中,并且第一次加载内容时花了 20 秒。 第二次,我在 2 秒内得到了结果(这实际上只是将行输入到解释器中所花费的时间)。
自定义获取器对于基于其他对象属性需要动态计算的属性也很有用。 例如,我们可能要计算整数列表的平均值:
class AverageList(list):
@property
def average(self):
return sum(self) / len(self)
这个非常简单的类继承自list,因此我们免费获得类似列表的行为。 我们只向类添加一个属性,然后,我们的列表可以具有平均值:
>>> a = AverageList([1,2,3,4])
>>> a.average
2.5
当然,我们可以使它成为方法,但是由于方法表示动作,因此应将其命名为calculate_average()。 但是称为average的属性更合适,既易于键入,又易于阅读。
正如我们已经看到的,自定义设置器对很有用,可用于验证,但是它们也可以用于将值代理到另一个位置。 例如,我们可以在WebPage类中添加一个内容设置器,该设置器将自动登录到我们的 Web 服务器并在设置该值时上传一个新页面。
管理器对象
我们一直专注于对象及其属性和方法。 现在,我们来看看设计更高级别的对象:管理其他对象的对象的类型。 将所有东西绑在一起的物体。
这些对象与到目前为止我们看到的大多数示例之间的区别在于,我们的示例倾向于代表具体的思想。 管理对象更像是办公室经理。 他们不会在地板上进行实际的“可见”工作,但是如果没有他们,部门之间将无法进行沟通,也没人会知道他们应该做什么(尽管如果组织非常糟糕,无论如何这都是正确的) 管理!)。 类似地,管理类上的属性倾向于引用其他做“可见”工作的对象; 这样一个类的行为会在适当的时候委托给其他类,并在它们之间传递消息。
作为示例,我们将编写一个程序,对存储在压缩 ZIP 文件中的文本文件执行查找和替换操作。 我们需要对象来表示 ZIP 文件和每个单独的文本文件(幸运的是,我们不必编写这些类,它们在 Python 标准库中可用)。 manager 对象将负责确保按顺序执行三个步骤:
- 解压缩压缩文件。
- 执行查找和替换操作。
- 压缩新文件。
该类使用.zip文件名初始化,然后搜索并替换字符串。 我们创建一个临时目录来存储解压缩的文件,以便该文件夹保持干净。 Python 3.4 pathlib库可帮助处理文件和目录。 我们将在第 8 章,“字符串和序列化”中了解更多有关该内容的信息,但是在以下示例中,该接口应该非常清楚:
import sys
import shutil
import zipfile
from pathlib import Path
class ZipReplace:
def __init__(self, filename, search_string, replace_string):
self.filename = filename
self.search_string = search_string
self.replace_string = replace_string
self.temp_directory = Path("unzipped-{}".format(
filename))
然后,我们为三个步骤中的每个步骤创建一个整体的“经理”方法。 此方法将责任委托给其他方法。 显然,我们可以在一个方法中,甚至在一个脚本中完成所有三个步骤,而无需创建对象。 分离这三个步骤有几个优点:
- 可读性:每个步骤的代码均位于一个易于阅读和理解的独立单元中。 方法名称描述了该方法的作用,并且需要较少的其他文档来了解所发生的情况。
- 可扩展性:如果子类希望使用压缩的 TAR 文件而不是 ZIP 文件,则它可以覆盖
zip和unzip方法,而不必重复find_replace方法。 - 分区:外部类可以创建此类的实例,然后直接在某个文件夹上调用
find_replace方法,而无需zip内容。
委托方法是以下代码中的第一个; 为了完整起见,包括了其余方法:
def zip_find_replace(self):
self.unzip_files()
self.find_replace()
self.zip_files()
def unzip_files(self):
self.temp_directory.mkdir()
with zipfile.ZipFile(self.filename) as zip:
zip.extractall(str(self.temp_directory))
def find_replace(self):
for filename in self.temp_directory.iterdir():
with filename.open() as file:
contents = file.read()
contents = contents.replace(
self.search_string, self.replace_string)
with filename.open("w") as file:
file.write(contents)
def zip_files(self):
with zipfile.ZipFile(self.filename, 'w') as file:
for filename in self.temp_directory.iterdir():
file.write(str(filename), filename.name)
shutil.rmtree(str(self.temp_directory))
if __name__ == "__main__":
ZipReplace(*sys.argv[1:4]).zip_find_replace()
为简便起见,稀疏地记录了用于压缩和解压缩文件的代码。 我们目前的重点是面向对象的设计。 如果您对zipfile模块的内部细节感兴趣,请在线或通过在交互式解释器中键入import zipfile ; help(zipfile)来参考标准库中的文档。 请注意,此示例仅搜索 ZIP 文件中的顶级文件。 如果解压缩后的内容中包含任何文件夹,则将不会对其进行扫描,也不会扫描这些文件夹中的任何文件。
该示例的最后两行允许我们通过传递zip文件名,搜索字符串并将替换字符串作为参数来从命令行运行程序:
python zipsearch.py hello.zip hello hi
当然,不必从命令行创建该对象。 它可以从另一个模块导入(以执行批处理 ZIP 文件),也可以作为 GUI 界面的一部分访问,甚至可以作为更高级的管理对象来访问,该对象知道从何处获取 ZIP 文件(例如,从 FTP 服务器或 将它们备份到外部磁盘)。
随着程序变得越来越复杂,被建模的对象越来越像物理对象。 属性是其他抽象对象,方法是更改这些抽象对象状态的动作。 但是,无论多么复杂,每个对象的核心都是一组具体的属性和定义明确的行为。
删除重复的代码
通常,诸如ZipReplace之类的管理样式类中的代码都是非常通用的,可以通过多种方式应用。 可以使用组合或继承来帮助将此代码保存在一个地方,从而消除重复的代码。 在查看任何此类示例之前,我们先讨论一些理论。 具体来说,为什么重复代码是一件坏事?
有多种原因,但是它们全都归结为可读性和可维护性。 当我们编写与以前的代码相似的新代码时,最简单的操作是复制旧代码并更改需要更改的内容(变量名,逻辑,注释)以使其在新代码中起作用 地点。 或者,如果我们正在编写看起来与项目中其他地方的代码相似但不相同的新代码,则通常更容易编写具有类似行为的新代码,而不是弄清楚如何提取重叠的功能。
但是,一旦有人必须阅读并理解代码,并且遇到重复的块,就会面临困境。 必须突然理解可能有意义的代码。 一个部分与另一个部分有何不同? 它们如何相同? 在什么条件下称为一节? 我们什么时候打给对方? 您可能会争辩说,您是唯一阅读代码的人,但是如果您八个月不接触该代码,对于您来说,就像对新编码员一样,您将难以理解。 当我们尝试阅读两个相似的代码片段时,我们必须了解它们为什么不同,以及它们如何不同。 这浪费了读者的时间。 代码应始终被编写为可读性强。
注意
我曾经不得不尝试理解某人的代码,这些代码具有相同的 300 行非常糟糕的代码的三个完全相同的副本。 在我终于理解这三个“相同”版本实际上执行稍有不同的税收计算之前,我已经使用了一个月的代码。 某些细微的差异是有意为之的,但在某些明显的领域中,某人已在一个函数中更新了计算而未更新其他两个函数。 无法计算代码中微妙的,难以理解的错误的数量。 我最终将所有 900 行替换为 20 行左右的易于阅读的功能。
读取这样的重复代码可能会很麻烦,但是代码维护更加痛苦。 如前所述,将两个相似的代码保持最新可能是一场噩梦。 我们必须记住每当更新两个部分时都要更新两个部分,并且必须记住多个部分的不同之处,以便在编辑每个部分时可以修改更改。 如果我们忘记更新这两个部分,最终将导致极其烦人的错误,这些错误通常会表现为:“但是我已经解决了这一问题,为什么它仍然会发生?”
结果是,与我们最初以非重复的方式编写代码相比,正在阅读或维护我们的代码的人们必须花费大量的时间来理解和测试它。 当我们进行维护时,这更加令人沮丧。 我们发现自己在说:“为什么我第一次没有这样做呢?” 通过复制粘贴现有代码节省的时间在我们第一次维护它时就丢失了。 与被编写的代码相比,代码被读取和修改的次数更多,并且更改的频率也更高。 易懂的代码应始终至关重要。
这就是为什么程序员,尤其是 Python 程序员(倾向于重视优雅代码而不是平均水平)遵循不要重复自己(DRY)原理的原因。 DRY 代码是可维护的代码。 我对初学者的建议是不要使用其编辑器的复制和粘贴功能。 对于中级程序员,我建议他们在按 Ctrl +C之前,应该考虑三次。
但是我们应该做什么而不是代码重复呢? 最简单的解决方案通常是将代码移到接受参数的函数中,以说明各个部分是否不同。 这不是一个非常面向对象的解决方案,但是它通常是最佳的。
例如,如果我们有两段代码将一个 ZIP 文件解压缩到两个不同的目录中,那么我们可以轻松地编写一个函数,该函数接受应该将其解压缩到的目录的参数。 这可能会使函数本身更难以阅读,但是一个好的函数名和文档字符串可以轻松地弥补这一点,并且调用该函数的任何代码都将更易于阅读。
这当然够理论了! 这个故事的寓意是:始终努力将代码重构为易于阅读,而不是编写仅易于编写的不良代码。
实践中
让我们探索两种可以重用现有代码的方式。 在写完代码以替换包含文本文件的 ZIP 文件中的字符串后,我们后来签订了将 ZIP 文件中的所有图像缩放到 640 x 480 的合同。看起来我们可以使用与ZipReplace。 第一个冲动可能是保存该文件的副本,然后将find_replace方法更改为scale_image或类似的方法。
但是,那太酷了。 如果有一天我们想更改unzip和zip方法以也打开 TAR 文件怎么办? 也许我们想为临时文件使用一个保证唯一的目录名。 无论哪种情况,我们都必须在两个不同的地方进行更改!
我们将首先说明该问题的基于继承的解决方案。 首先,我们将我们的原始ZipReplace类修改为用于处理常规 ZIP 文件的超类:
import os
import shutil
import zipfile
from pathlib import Path
class ZipProcessor:
def __init__(self, zipname):
self.zipname = zipname
self.temp_directory = Path("unzipped-{}".format(
zipname[:-4]))
def process_zip(self):
self.unzip_files()
self.process_files()
self.zip_files()
def unzip_files(self):
self.temp_directory.mkdir()
with zipfile.ZipFile(self.zipname) as zip:
zip.extractall(str(self.temp_directory))
def zip_files(self):
with zipfile.ZipFile(self.zipname, 'w') as file:
for filename in self.temp_directory.iterdir():
file.write(str(filename), filename.name)
shutil.rmtree(str(self.temp_directory))
我们将的filename属性更改为zipname,以避免与各种方法中的filename局部变量混淆。 即使实际上不是设计更改,这也有助于使代码更具可读性。
我们还将ZipReplace特有的两个参数分别丢给了__init__(search_string和replace_string)。 然后,我们将zip_find_replace方法重命名为process_zip,并使其(尚未定义)称为process_files方法而不是find_replace; 这些名称的更改有助于证明我们新班级的更普遍的性质。 注意,我们已经完全删除了find_replace方法; 该代码特定于ZipReplace,在这里没有业务。
这个新的ZipProcessor类实际上没有定义process_files方法; 因此,如果我们直接运行它,它将引发异常。 因为它不打算直接运行,所以我们删除了原始脚本底部的 main 调用。
现在,在继续进行图像处理应用之前,让我们修复原始的zipsearch类以使用此父类:
from zip_processor import ZipProcessor
import sys
import os
class ZipReplace(ZipProcessor):
def __init__(self, filename, search_string,
replace_string):
super().__init__(filename)
self.search_string = search_string
self.replace_string = replace_string
def process_files(self):
'''perform a search and replace on all files in the
temporary directory'''
for filename in self.temp_directory.iterdir():
with filename.open() as file:
contents = file.read()
contents = contents.replace(
self.search_string, self.replace_string)
with filename.open("w") as file:
file.write(contents)
if __name__ == "__main__":
ZipReplace(*sys.argv[1:4]).process_zip()
该代码比原始版本短,因为它从父类继承了其 ZIP 处理功能。 我们首先导入刚刚编写的基类,然后使ZipReplace扩展该类。 然后,我们使用super()初始化父类。 find_replace方法仍然在这里,但是我们将其重命名为process_files,以便父类可以从其管理界面调用它。 由于此名称不像旧名称那样具有描述性,因此我们添加了一个文档字符串来描述其功能。
现在,考虑到我们现在所拥有的只是一个功能上与我们开始时没有区别的程序,这是相当多的工作! 但是完成这项工作后,现在我们可以轻松编写在 ZIP 存档中的文件上运行的其他类,例如(假设要求的)照片缩放器。 此外,如果我们想改善或错误修复 zip 功能,我们可以通过仅更改一个ZipProcessor基类来对所有类都做到这一点。 维护将更加有效。
看看现在创建利用ZipProcessor功能的照片缩放类非常简单。 (注意:此类需要第三方pillow库来获取PIL模块。您可以使用pip install pillow安装它。)
from zip_processor import ZipProcessor
import sys
from PIL import Image
class ScaleZip(ZipProcessor):
def process_files(self):
'''Scale each image in the directory to 640x480'''
for filename in self.temp_directory.iterdir():
im = Image.open(str(filename))
scaled = im.resize((640, 480))
scaled.save(str(filename))
if __name__ == "__main__":
ScaleZip(*sys.argv[1:4]).process_zip()
看看这个课程有多简单! 我们之前所做的所有工作都得到了回报。 我们要做的就是打开每个文件(假设它是一个图像;如果无法打开文件,它将毫无意外地崩溃),缩放它并保存回去。 ZipProcessor类负责压缩和解压缩,而无需我们做任何额外的工作。
案例研究
对于本案例,我们将尝试进一步探讨以下问题:“何时选择对象还是内置类型?” 我们将为可能在文本编辑器或文字处理器中使用的Document类建模。 它应该具有哪些对象,功能或属性?
我们可能从Document内容的str开始,但是在 Python 中,字符串是不可变的(可以更改)。 一旦定义了str,它将永远存在。 如果不创建全新的字符串对象,则无法在其中插入或删除字符。 那会留下很多str对象占用内存,直到 Python 的垃圾收集器认为适合清除我们的内存为止。
因此,我们将使用一个字符列表,而不是字符串,可以随意对其进行修改。 另外,Document类将需要知道列表中的当前光标位置,并且可能还应该存储文档的文件名。
注意
实际文本编辑器使用称为rope的基于二叉树的数据结构来建模其文档内容。 该模块的标题不是“高级数据结构”,因此,如果您想了解更多有关此有趣主题的信息,则可能需要在网上搜索绳索数据结构。
现在,它应该有什么方法? 我们可能想对文本文档做很多事情,包括插入,删除和选择字符,剪切,复制,粘贴,选择以及保存或关闭文档。 看起来数据和行为都很多,因此将所有这些内容放入自己的Document类是有意义的。
一个相关的问题是:此类是否应该由一堆基本的 Python 对象组成,例如str文件名,int光标位置和list字符? 还是其中的某些或全部本身就是专门定义的对象? 个别的线条和字符呢,他们需要自己的类吗?
我们将在回答这些问题的同时,首先让我们从最简单的Document类开始,然后看看它可以做什么:
class Document:
def __init__(self):
self.characters = []
self.cursor = 0
self.filename = ''
def insert(self, character):
self.characters.insert(self.cursor, character)
self.cursor += 1
def delete(self):
del self.characters[self.cursor]
def save(self):
with open(self.filename, 'w') as f:
f.write(''.join(self.characters))
def forward(self):
self.cursor += 1
def back(self):
self.cursor -= 1
这个简单的类使我们可以完全控制编辑基本文档。 看一下它的作用:
>>> doc = Document()
>>> doc.filename = "test_document"
>>> doc.insert('h')
>>> doc.insert('e')
>>> doc.insert('l')
>>> doc.insert('l')
>>> doc.insert('o')
>>> "".join(doc.characters)
'hello'
>>> doc.back()
>>> doc.delete()
>>> doc.insert('p')
>>> "".join(doc.characters)
'hellp'
看起来正在运作。 我们可以将键盘的字母和箭头键连接到这些方法,并且文档可以很好地跟踪所有内容。
但是,如果我们不仅要连接箭头键,该怎么办。 如果我们也想连接 Home 和 End 键,该怎么办? 我们可以向Document类添加更多方法来向前或向后搜索字符串中的换行符(在 Python 中为换行符,或\n代表一行的末尾和新行的开头),然后跳转 给他们,但是如果我们针对所有可能的移动动作(逐字移动,逐句移动,向上翻页,向下翻页,行尾,空格开头等等)进行操作 ),该课程将非常庞大。 将这些方法放在单独的对象上也许会更好。 因此,让我们将 cursor 属性变成一个知道其位置并可以操纵该位置的对象。 我们可以将前进和后退方法移至该类,并为 Home 和 End 键添加更多:
class Cursor:
def __init__(self, document):
self.document = document
self.position = 0
def forward(self):
self.position += 1
def back(self):
self.position -= 1
def home(self):
while self.document.characters[
self.position-1] != '\n':
self.position -= 1
if self.position == 0:
# Got to beginning of file before newline
break
def end(self):
while self.position < len(self.document.characters
) and self.document.characters[
self.position] != '\n':
self.position += 1
此类将文档作为初始化参数,因此这些方法可以访问文档字符列表的内容。 然后,它提供了简单的方法来像以前一样向前和向后移动,以及移动到home和end位置。
注意
此代码不是很安全。 您可以轻松地移至结束位置,如果尝试返回一个空文件,它将崩溃。 这些示例简短易读,但并不代表防御性! 您可以通过练习来改进此代码的错误检查。 这可能是扩展您的异常处理技能的绝佳机会。
Document类本身几乎没有更改,除了删除了移到Cursor类的两个方法:
class Document:
def __init__(self):
self.characters = []
self.cursor = Cursor(self)
self.filename = ''
def insert(self, character):
self.characters.insert(self.cursor.position,
character)
self.cursor.forward()
def delete(self):
del self.characters[self.cursor.position]
def save(self):
f = open(self.filename, 'w')
f.write(''.join(self.characters))
f.close()
我们只是将访问旧游标整数的所有内容更新为使用新对象。 我们可以测试home方法是否真的移至换行符:
>>> d = Document()
>>> d.insert('h')
>>> d.insert('e')
>>> d.insert('l')
>>> d.insert('l')
>>> d.insert('o')
>>> d.insert('\n')
>>> d.insert('w')
>>> d.insert('o')
>>> d.insert('r')
>>> d.insert('l')
>>> d.insert('d')
>>> d.cursor.home()
>>> d.insert("*")
>>> print("".join(d.characters))
hello
*world
现在,由于经常使用该字符串join函数(以连接字符,以便我们可以看到实际的文档内容),因此可以向Document类添加属性以提供完整的信息 细绳:
@property
def string(self):
return "".join(self.characters)
这使我们的测试更加简单:
>>> print(d.string)
hello
world
这个框架很简单(尽管可能会很费时!),可以扩展以创建和编辑完整的纯文本文档。 现在,让我们将其扩展为适用于富文本格式; 可以使用粗体,带下划线或斜体字符的文本。
我们可以通过两种方式处理此问题; 第一种是将“伪”字符插入我们的字符列表中,这些字符的行为类似于指令,例如“粗体字符,直到找到停止的粗体字符”。 第二种是向每个字符添加信息,以指示其应采用的格式。 尽管前一种方法可能更常见,但我们将实现后一种解决方案。 为此,我们显然需要一个字符类。 此类将具有表示字符的属性,以及三个表示粗体,斜体或带下划线的布尔属性。
嗯,等等! 这个Character类将具有任何方法吗? 如果没有,也许我们应该使用许多 Python 数据结构之一; 一个元组或命名元组可能就足够了。 我们要对角色执行或对角色调用任何动作吗?
好吧,很明显,我们可能想对字符进行处理,例如删除或复制它们,但是这些是需要在Document级别处理的事情,因为它们实际上是在修改字符列表。 是否需要对单个角色执行某些操作?
实际上,现在我们正在考虑Character类实际上是什么...这是什么? 可以肯定地说Character类是字符串吗? 也许我们应该在这里使用继承关系? 然后,我们可以利用str实例随附的众多方法。
我们在谈论什么样的方法? 有startswith,strip,find和lower等。 这些方法中的大多数都希望对包含多个字符的字符串起作用。 相反,如果Character是str的子类,那么如果提供了多字符字符串,我们最好重写__init__以引发异常。 因为我们免费获得的所有这些方法实际上都不会应用于我们的Character类,所以毕竟我们似乎不需要使用继承。
这使我们回到了最初的问题; Character应该是一类吗? object类上有一个非常重要的特殊方法,我们可以利用它来表示字符。 此方法称为__str__(两个下划线,如__init_ _),用于诸如print和str构造函数之类的字符串操作函数中,可将任何类转换为字符串。 默认实现会执行一些无聊的工作,例如在内存中打印模块和类的名称及其地址。 但是,如果我们覆盖它,我们可以使它打印出我们喜欢的任何东西。 在我们的实现中,我们可以使用特殊字符作为前缀字符,以表示它们是粗体,斜体还是带下划线。 因此,我们将创建一个表示字符的类,这里是:
class Character:
def __init__(self, character,
bold=False, italic=False, underline=False):
assert len(character) == 1
self.character = character
self.bold = bold
self.italic = italic
self.underline = underline
def __str__(self):
bold = "*" if self.bold else ''
italic = "/" if self.italic else ''
underline = "_" if self.underline else ''
return bold + italic + underline + self.character
此类允许我们创建字符,并在将str()功能应用于它们时给它们添加特殊字符前缀。 那里没有什么太令人兴奋的。 我们只需要对Document和Cursor类进行一些小的修改即可使用该类。 在Document类中,我们在insert方法的开头添加以下两行:
def insert(self, character):
if not hasattr(character, 'character'):
character = Character(character)
这是有点奇怪的代码。 其基本目的是检查传入的字符是Character还是str。 如果是字符串,则将其包装在Character类中,因此列表中的所有对象均为Character对象。 但是,使用我们的代码的人很可能想通过鸭子类型使用既不是Character也不是字符串的类。 如果对象具有字符属性,则假定它是“ Character -like”对象。 但是,如果不是这样,我们假定它是“类似于str的对象”,并将其包装在Character中。 这有助于程序利用鸭子类型和多态性。 只要对象具有字符属性,就可以在Document类中使用它。
例如,如果我们想使用语法突出显示功能来使程序员的编辑器,这种通用检查可能非常有用:我们需要字符上的额外数据,例如字符所属的语法标记类型。 请注意,如果我们进行了大量此类比较,则最好将Character作为具有适当__subclasshook__的抽象基类来实现,如第 3 章,中所述。
另外,我们需要修改Document的字符串属性以接受新的Character值。 我们需要做的就是在加入每个字符之前调用str():
@property
def string(self):
return "".join((str(c) for c in self.characters))
此代码使用生成器表达式,我们将在第 9 章,“迭代器模式”中进行讨论。 这是对序列中的所有对象执行特定操作的快捷方式。
最后,当我们查看Character.character和end函数是否匹配换行符时,还需要检查Character.character,而不仅仅是我们之前存储的字符串字符:
def home(self):
while self.document.characters[
self.position-1].character != '\n':
self.position -= 1
if self.position == 0:
# Got to beginning of file before newline
break
def end(self):
while self.position < len(
self.document.characters) and \
self.document.characters[
self.position
].character != '\n':
self.position += 1
这样就完成了字符的格式化。 我们可以对其进行测试以查看它是否有效:
>>> d = Document()
>>> d.insert('h')
>>> d.insert('e')
>>> d.insert(Character('l', bold=True))
>>> d.insert(Character('l', bold=True))
>>> d.insert('o')
>>> d.insert('\n')
>>> d.insert(Character('w', italic=True))
>>> d.insert(Character('o', italic=True))
>>> d.insert(Character('r', underline=True))
>>> d.insert('l')
>>> d.insert('d')
>>> print(d.string)
he`l`lo
/w/o_rld
>>> d.cursor.home()
>>> d.delete()
>>> d.insert('W')
>>> print(d.string)
he`l`lo
W/o_rld
>>> d.characters[0].underline = True
>>> print(d.string)
_he`l`lo
W/o_rld
不出所料,每当我们打印字符串时,每个粗体字符前面都会带有*字符,每个斜体字符之前带有/字符,每个带下划线的字符之前都带有_字符。 我们所有的功能似乎都可以正常工作,事实发生后我们可以修改列表中的字符。 我们有一个工作的富文本文档对象,可以将其插入适当的用户界面中,并与用于输入的键盘和用于输出的屏幕挂钩。 自然,我们希望在屏幕上显示真实的粗体,斜体和带下划线的字符,而不是使用__str__方法,但这足以满足我们要求的基本测试要求。




七、Python 数据结构
到目前为止,在我们的示例中,我们已经看到了许多内置的 Python 数据结构在起作用。 您可能还在入门书籍或教程中介绍了其中许多内容。 在本章中,我们将讨论这些数据结构的面向对象功能,何时使用它们而不是常规类以及何时不使用它们。 特别是,我们将介绍:
- 元组和命名元组
- 辞典
- 清单和集合
- 如何以及为什么扩展内置对象
- 三种队列
空物体
让我们从最基本的 Python 内置开始,我们已经看过很多次了,在我们创建的每个类中都扩展了一个object。 从技术上讲,我们可以在不编写子类的情况下实例化object:
>>> o = object()
>>> o.x = 5
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'object' object has no attribute 'x'
不幸的是,如您所见,无法在直接实例化的object上设置任何属性。 这不是因为 Python 开发人员想强迫我们编写自己的类,或者如此险恶的东西。 他们这样做是为了节省内存。 很多内存。 当 Python 允许对象具有任意属性时,它会占用一定数量的系统内存来跟踪每个对象具有的属性,以存储属性名称及其值。 即使未存储任何属性,也会为潜在的新属性分配内存。 给定一个典型的 Python 程序中的数十个,数百个或数千个对象(每个类都扩展了对象); 这种少量的内存将很快变成大量的内存。 因此,默认情况下,Python 会禁用object和其他几个内置组件上的任意属性。
注意
可以使用插槽在我们自己的类上限制任意属性。 插槽超出了本模块的范围,但是如果您要查找更多信息,现在可以使用搜索词。 在正常使用中,使用插槽并没有太多好处,但是,如果您要编写一个将在整个系统中重复数千次的对象,则它们可以像object一样帮助节省内存。
但是,创建我们自己的空对象类是微不足道的。 我们在最早的示例中看到了它:
class MyObject:
pass
而且,正如我们已经看到的,可以在此类上设置属性:
>>> m = MyObject()
>>> m.x = "hello"
>>> m.x
'hello'
如果我们想将属性分组在一起,则可以将它们存储在这样的空对象中。 但是通常最好使用其他用于存储数据的内置函数。 在整个模块中已强调,仅当要同时指定和数据和行为时才使用类和对象。 编写空类的主要原因是要迅速阻止某些内容,因为我们知道稍后会添加行为。 使行为适应类比将数据结构替换为对象并更改对该对象的所有引用要容易得多。 因此,重要的是从一开始就确定数据仅仅是数据还是变相对象。 一旦做出设计决定,其余的设计自然就会落到位。
元组和命名元组
元组是对象,可以依次存储特定数量的其他对象。 它们是不可变的,因此我们不能即时添加,删除或替换对象。 这似乎是一个巨大的限制,但事实是,如果您需要修改元组,则使用了错误的数据类型(通常,列表会更合适)。 元组不变性的主要好处是,我们可以将它们用作字典中的键以及对象需要哈希值的其他位置的键。
元组用于存储数据; 行为不能存储在元组中。 如果需要行为来操作元组,则必须将元组传递给执行该操作的函数(或另一个对象的方法)。
元组通常应存储彼此有所不同的值。 例如,我们不会在一个元组中放置三个股票代号,但可以创建一个股票代号,当前价格,当天的最高价和最低价的元组。 元组的主要目的是将不同的数据片段聚合到一个容器中。 因此,元组可能是替换“无数据对象”的最简单工具。
我们可以通过用逗号分隔值来创建元组。 通常,将元组用括号括起来以使其易于阅读并将它们与表达式的其他部分分开,但这并不总是强制性的。 以下两个分配是相同的(它们记录了一个盈利的公司的股票,当前价格,最高价和最低价):
>>> stock = "FB", 75.00, 75.03, 74.90
>>> stock2 = ("FB", 75.00, 75.03, 74.90)
如果我们要在其他对象(例如函数调用,列表推导或生成器)中对元组进行分组,则需要使用括号。 否则,解释器将不可能知道它是元组还是下一个函数参数。 例如,以下函数接受一个元组和一个日期,并返回日期和股票的高值和低值之间的中间值的元组:
import datetime
def middle(stock, date):
symbol, current, high, low = stock
return (((high + low) / 2), date)
mid_value, date = middle(("FB", 75.00, 75.03, 74.90),
datetime.date(2014, 10, 31))
通过用逗号分隔值并将整个元组括在括号中,可以直接在函数调用内部创建元组。 然后,该元组后面用逗号将其与第二个参数分开。
此示例还说明了元组拆包。 函数内的第一行将stock参数解压缩为四个不同的变量。 元组的长度必须与变量的数量完全相同,否则将引发异常。 我们还可以在最后一行看到元组拆包的示例,其中将函数内部返回的元组拆包为两个值mid_value和date。 当然,这是一件很奇怪的事情,因为我们首先将日期提供给函数,但这给了我们一次查看工作中的拆包的机会。
解包是在 Python 中非常有用的功能。 我们可以将变量分组在一起,以使存储和传递它们变得更加简单,但是当我们需要访问所有变量时,我们可以将它们分解为单独的变量。 当然,有时我们只需要访问元组中的变量之一即可。 我们可以使用与其他序列类型(例如列表和字符串)相同的语法来访问单个值:
>>> stock = "FB", 75.00, 75.03, 74.90
>>> high = stock[2]
>>> high
75.03
我们甚至可以使用切片符号来提取较大的元组片段:
>>> stock[1:3]
(75.00, 75.03)
这些示例在说明元组可以有多灵活的同时,还显示了其主要缺点之一:可读性。 读取此代码的人如何知道特定元组的第二个位置是什么? 他们可以从我们分配给它的变量的名称中猜测它是某种high,但是如果我们只是在计算中访问了元组值而没有分配它,则不会有这样的指示。 他们将不得不仔细检查代码,以找到元组在哪里声明,然后才能发现它的作用。
在某些情况下,直接访问元组成员是好的,但是不要养成习惯。 这种所谓的“幻数”(似乎是凭空冒出来的数字,在代码中没有明显的含义)是许多编码错误的根源,并导致数小时的调试工作受挫。 仅在知道所有值都将立即有用并且通常在访问它时将它们解包时,才尝试使用元组。 如果您必须直接访问成员或使用切片,而该值的用途并不立即明显,请至少添加一条注释,说明其来源。
命名元组
因此,当我们想将值分组在一起但知道我们经常需要分别访问它们时,和会做什么? 好吧,我们可以使用一个空对象,如上一节中所述(但是除非我们稍后会期望添加行为,否则它很少有用),或者我们可以使用字典(如果我们不知道确切的数量或具体的对象,则非常有用。 数据将被存储),我们将在下一部分中介绍。
但是,如果我们不需要向对象添加行为,并且我们事先知道需要存储哪些属性,则可以使用命名元组。 元组是具有态度的元组。 它们是将只读数据分组在一起的好方法。
构造一个命名的元组比普通的元组需要更多的工作。 首先,我们必须导入namedtuple,因为默认情况下它不在名称空间中。 然后,我们通过命名命名元组并概述其属性来描述命名元组。 这将返回一个类类对象,我们可以根据需要多次实例化所需的值:
from collections import namedtuple
Stock = namedtuple("Stock", "symbol current high low")
stock = Stock("FB", 75.00, high=75.03, low=74.90)
namedtuple构造函数接受两个参数。 第一个是命名元组的标识符。 第二个是命名元组可以具有的以空格分隔的属性字符串。 应该列出第一个属性,然后是一个空格(如果您愿意,可以用逗号),然后是第二个属性,然后是另一个空格,依此类推。 结果是可以像调用普通对象类一样实例化其他对象的对象。 构造函数必须具有正确数量的参数,可以作为参数或关键字参数传递。 与普通对象一样,我们可以根据需要创建任意数量的“类”实例,每个实例具有不同的值。
然后可以将生成的namedtuple打包,拆包或以其他方式像普通元组一样对待,但是我们也可以像对待对象一样访问其上的各个属性:
>>> stock.high
75.03
>>> symbol, current, high, low = stock
>>> current
75.00
注意
请记住,创建命名元组是一个两步过程。 首先,使用collections.namedtuple创建一个类,然后构造该类的实例。
元组是许多“仅数据”表示形式的理想选择,但并不是在所有情况下都理想。 像元组和字符串一样,命名元组是不可变的,因此一旦设置了属性,我们将无法对其进行修改。 例如,自从我们开始讨论以来,我公司股票的当前价值已经下降,但是我们不能设置新的价值:
>>> stock.current = 74.98
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: can't set attribute
如果我们需要能够更改存储的数据,则可能需要字典。
字典
字典是有用的容器,它使我们可以将对象直接映射到其他对象。 具有属性的空对象是一种字典。 属性的名称映射到属性值。 实际上,这比听起来更接近真理。 在内部,对象通常将属性表示为字典,其中值是对象上的属性或方法(如果您不相信我,请参见__dict__属性)。 甚至模块上的属性都在内部存储在字典中。
给定映射到该值的特定键对象,字典在查找值时非常有效。 当您要基于其他对象找到一个对象时,应始终使用它们。 被存储的对象称为值; 用作索引的对象称为键。 在前面的一些示例中,我们已经看到了字典语法。
可以使用dict()构造函数或{}语法快捷方式创建字典。 实际上,后一种格式几乎总是被使用。 我们可以通过使用冒号将键与值分开,并使用逗号分隔键值对来预填充字典。
例如,在股票申请中,我们通常希望通过股票代码查找价格。 我们可以创建一个字典,使用股票代码作为键,并使用 current,high 和 low 的元组作为值,如下所示:
stocks = {"GOOG": (613.30, 625.86, 610.50),
"MSFT": (30.25, 30.70, 30.19)}
正如我们在前面的示例中所看到的,我们可以通过在方括号内请求键来在字典中查找值。 如果键不在字典中,它将引发异常:
>>> stocks["GOOG"]
(613.3, 625.86, 610.5)
>>> stocks["RIM"]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
KeyError: 'RIM'
当然,我们可以捕获并处理KeyError。 但是我们还有其他选择。 请记住,字典是对象,即使其主要目的是保存其他对象。 因此,它们具有几种与之相关的行为。 这些方法中最有用的一种是get方法。 它接受键作为第一个参数,如果键不存在,则接受可选的默认值:
>>> print(stocks.get("RIM"))
None
>>> stocks.get("RIM", "NOT FOUND")
'NOT FOUND'
为了获得更多控制,我们可以使用setdefault方法。 如果键在字典中,则此方法的行为类似于get; 它返回该键的值。 否则,如果键不在字典中,它不仅会返回我们在方法调用中提供的默认值(就像get一样),还会将键设置为相同的值。 另一种思考的方式是,setdefault仅在先前未设置该值的情况下才在字典中设置该值。 然后,它返回字典中的值,要么是已有的值,要么是新提供的默认值。
>>> stocks.setdefault("GOOG", "INVALID")
(613.3, 625.86, 610.5)
>>> stocks.setdefault("BBRY", (10.50, 10.62, 10.39))
(10.50, 10.62, 10.39)
>>> stocks["BBRY"]
(10.50, 10.62, 10.39)
GOOG库存已经在字典中,因此当我们尝试将setdefault库存到无效值时,它只是返回字典中已经存在的值。 BBRY不在词典中,因此setdefault返回了默认值,并为我们在词典中设置了新值。 然后,我们检查新库存是否确实在词典中。
keys(),values()和items()这三种非常有用的字典方法是非常有用的。 前两个返回字典中所有键和所有值的列表。 如果我们要处理所有键或值,则可以使用这些类似列表或在for循环中使用。 items()方法可能是最有用的。 对于字典中的每个项目,它会针对(key, value)对的元组返回一个迭代器。 这非常适合for循环中的元组解包,以循环关联的键和值。 这个例子只是用当前值打印字典中的每只股票:
>>> for stock, values in stocks.items():
... print("{} last value is {}".format(stock, values[0]))
...
GOOG last value is 613.3
BBRY last value is 10.50
MSFT last value is 30.25
每个键/值元组都被解压缩为两个名为stock和values的变量(我们可以使用我们想要的任何变量名,但它们看起来都合适),然后以格式化字符串打印。
请注意,股票的显示顺序与插入时的顺序不同。 由于用于使键查找如此快速的高效算法(称为散列),字典本身是未排序的。
因此,一旦字典被实例化,有很多方法可以从字典中检索数据。 我们可以使用方括号作为索引语法,get方法,setdefault方法或迭代items方法等。
最后,您可能已经知道,我们可以使用与检索值相同的索引语法在字典中设置值:
>>> stocks["GOOG"] = (597.63, 610.00, 596.28)
>>> stocks['GOOG']
(597.63, 610.0, 596.28)
Google 的价格今天较低,因此我更新了字典中的元组值。 我们可以使用此索引语法为任何键设置一个值,而不管该键是否在字典中。 如果在字典中,则旧值将被新值替换; 否则,将创建一个新的键/值对。
到目前为止,我们一直在使用字符串作为字典键,但是我们不仅限于字符串键。 通常将字符串用作键,尤其是当我们将数据存储在字典中以将其收集在一起时(而不是使用具有命名属性的对象)。 但是我们也可以使用元组,数字甚至是我们自己定义为字典键的对象。 我们甚至可以在一个字典中使用不同类型的键:
random_keys = {}
random_keys["astring"] = "somestring"
random_keys[5] = "aninteger"
random_keys[25.2] = "floats work too"
random_keys[("abc", 123)] = "so do tuples"
class AnObject:
def __init__(self, avalue):
self.avalue = avalue
my_object = AnObject(14)
random_keys[my_object] = "We can even store objects"
my_object.avalue = 12
try:
random_keys[[1,2,3]] = "we can't store lists though"
except:
print("unable to store list\n")
for key, value in random_keys.items():
print("{} has value {}".format(key, value))
此代码显示了我们可以提供给字典的几种不同类型的键。 它还显示了一种无法使用的对象。 我们已经广泛使用了列表,我们将在下一部分中看到它们的更多详细信息。 因为列表可以随时更改(例如,通过添加或删除项目),所以它们不能哈希为特定值。
可对进行哈希处理的对象 基本上具有已定义的算法,该算法将该对象转换为唯一的整数值以进行快速查找。 该哈希实际上是用于在字典中查找值的内容。 例如,字符串基于字符串中的字符映射为整数,而元组将元组内各项的哈希组合在一起。 以某种方式被认为相等的任何两个对象(例如具有相同字符的字符串或具有相同值的元组)应具有相同的哈希值,并且对象的哈希值永远不应改变。 但是,列表可以更改其内容,这将更改其哈希值(两个列表仅在其内容相同时才应相等)。 因此,它们不能用作字典键。 由于相同的原因,词典不能用作其他词典的键。
相反,可以用作字典值的对象类型没有限制。 例如,我们可以使用映射到列表值的字符串键,也可以将嵌套的词典作为另一个词典中的值。
词典用例
字典具有多种用途,并且用途广泛。 可以使用两种主要方法来使用字典。 第一个是字典,其中所有键代表相似对象的不同实例; 例如,我们的股票字典。 这是一个索引系统。 我们使用股票代码作为值的索引。 这些值甚至可能是复杂的自定义对象,而不是我们的简单元组,它们会做出买卖决定或设置止损。
第二种设计是字典,其中每个键代表单个结构的某些方面; 在这种情况下,我们可能会为每个对象使用一个单独的字典,并且它们都具有相似(尽管通常不相同)的键集。 后一种情况通常也可以通过命名元组解决。 当我们确切地知道数据必须存储什么属性,并且知道必须立即提供所有数据片段(在构造项目时)时,通常应使用这些属性。 但是,如果我们需要随着时间的推移来创建或更改字典键,或者我们不确切知道键可能是什么,那么字典更合适。
使用 defaultdict
我们已经看到如果不存在键,如何使用setdefault设置默认值,但是如果每次查找值时都需要设置默认值,这可能会有些单调。 例如,如果我们正在编写计算给定句子中字母出现次数的代码,则可以执行以下操作:
def letter_frequency(sentence):
frequencies = {}
for letter in sentence:
frequency = frequencies.setdefault(letter, 0)
frequencies[letter] = frequency + 1
return frequencies
每次访问字典时,我们都需要检查它是否已经有一个值,如果没有,则将其设置为零。 当每次需要输入空键时都需要执行此类操作时,我们可以使用另一种版本的字典,称为defaultdict:
from collections import defaultdict
def letter_frequency(sentence):
frequencies = defaultdict(int)
for letter in sentence:
frequencies[letter] += 1
return frequencies
此代码似乎无法正常运行。 defaultdict在其构造函数中接受一个函数。 每当访问字典中尚不存在的键时,它都会调用该函数(不带参数)以创建默认值。
在这种情况下,它调用的函数是int,它是整数对象的构造函数。 通常,整数是简单地通过在代码中键入一个整数来创建的,如果确实使用int构造函数创建一个整数,则将要创建的项目传递给它(例如,将数字字符串转换为整数) )。 但是,如果我们不带任何参数调用int,它将方便地返回数字零。 在此代码中,如果defaultdict中不存在字母,则在我们访问它时将返回数字零。 然后,向该数字添加一个,以表示我们找到了该字母的一个实例,下次找到一个字母时,该数字将被返回,并且可以再次增加该值。
defaultdict对于创建容器字典很有用。 如果我们要创建过去 30 天的股价字典,可以使用股票符号作为关键字并将价格存储在list中; 第一次访问股票价格时,我们希望它创建一个空列表。 只需将list传递到defaultdict,则每次访问空键时都会调用它。 如果要将集合与键相关联,我们可以对集合甚至空字典进行类似的操作。
当然,我们也可以编写自己的函数并将其传递给defaultdict。 假设我们要创建一个defaultdict,其中每个新元素都包含当时插入字典中的项数的元组和一个用于容纳其他内容的空列表。 没有人知道为什么要创建这样的对象,但让我们看一下:
from collections import defaultdict
num_items = 0
def tuple_counter():
global num_items
num_items += 1
return (num_items, [])
d = defaultdict(tuple_counter)
运行此代码时,我们可以访问空键,并在一条语句中全部插入到列表中:
>>> d = defaultdict(tuple_counter)
>>> d['a'][1].append("hello")
>>> d['b'][1].append('world')
>>> d
defaultdict(<function tuple_counter at 0x82f2c6c>,
{'a': (1, ['hello']), 'b': (2, ['world'])})
当我们最后打印dict时,我们看到计数器确实在工作。
注意
这个例子虽然简短地演示了如何为defaultdict创建我们自己的函数,但实际上并不是很好的代码; 使用全局变量意味着如果我们创建四个不同的defaultdict段,每个段都使用tuple_counter,它将计算所有词典中条目的数量,而不是每个条目都有不同的计数。 最好创建一个类并将该类上的方法传递给defaultdict。
计数器
您可能会认为不会比defaultdict(int)简单得多,但“我想在一个可迭代的实例中计算特定实例”用例非常普遍,以至于 Python 开发人员为该类创建了一个特定的类。 它。 前面计算字符串中字符数的代码可以很容易地在一行中计算出:
from collections import Counter
def letter_frequency(sentence):
return Counter(sentence)
Counter对象的行为类似于增强字典,其中键是要计数的项目,值是此类项目的数量。 most_common()方法是最有用的功能之一。 它返回按计数顺序排序的(键,计数)元组的列表。 您可以选择将整数参数传递给most_common(),以仅请求最常见的元素。 例如,您可以编写一个简单的轮询应用,如下所示:
from collections import Counter
responses = [
"vanilla",
"chocolate",
"vanilla",
"vanilla",
"caramel",
"strawberry",
"vanilla"
]
print(
"The children voted for {} ice cream".format(
Counter(responses).most_common(1)[0][0]
)
)
大概,您将从数据库中获得响应,或者使用复杂的视觉算法来计算举手的孩子。 在这里,我们对其进行硬编码,以便可以测试most_common方法。 它返回仅包含一个元素的列表(因为我们在参数中请求了一个元素)。 该元素将最佳选择的名称存储在零位置,因此在调用结束时存储了双精度[0][0]。 我认为它们看起来像是一张惊讶的脸,不是吗? 您的计算机可能很惊讶,它可以如此轻松地对数据进行计数。 它的祖先是霍勒里斯(Hollerith)在 1890 年美国人口普查中使用的制表机,一定非常嫉妒!
列表
列表是最少的面向对象的 Python 数据结构。 虽然列表本身就是对象,但 Python 中有很多语法可以使列表的使用尽可能轻松。 与许多其他面向对象的语言不同,Python 中的列表仅可用。 我们不需要导入它们,并且很少需要在它们上调用方法。 我们可以遍历列表而无需显式请求迭代器对象,并且可以使用自定义语法构造一个列表(与字典一样)。 此外,列表理解和生成器表达式将它们变成了名副其实的计算功能的瑞士军刀。
我们不会过多地介绍语法。 您已经在网上的入门教程和本模块的先前示例中看到了它。 如果不学习如何使用列表,就不能花很长时间编写 Python! 相反,我们将介绍何时应使用列表以及它们作为对象的性质。 如果您不知道如何创建或追加到列表,如何从列表中检索项目,或者“切片符号”是什么,我将指导您直接使用 Python 官方教程。 可以在这个页面在线找到。
在 Python 中,当我们要存储对象的“相同”类型的多个实例时,通常应使用列表。 字符串列表或数字列表; 最常见的是我们自己定义的对象列表。 当我们要以某种顺序存储项目时,应始终使用列表。 通常,这是它们插入的顺序,但是也可以按照某些条件对其进行排序。
当我们需要修改内容时,列表也非常有用:在列表的任意位置插入或删除列表,或更新列表中的值。
像字典一样,Python 列表使用非常有效且经过良好调整的内部数据结构,因此我们可以担心存储的内容而不是存储的方式。 许多面向对象的语言为队列,堆栈,链接列表和基于数组的列表提供了不同的数据结构。 如果需要优化对大量数据的访问,Python 确实提供了其中一些类的特殊实例。 但是,通常,列表数据结构可以立即满足所有这些目的,并且编码人员可以完全控制它们的访问方式。
不要将列表用于来收集各个项目的不同属性。 例如,我们不需要特定形状具有的属性列表。 元组,命名元组,字典和对象都将更适合于此目的。 在某些语言中,他们可能会创建一个列表,其中每个替代项都是不同的类型。 例如,他们可能在我们的字母频率列表中写['a', 1, 'b', 3]。 他们必须使用一个奇怪的循环来一次访问列表中的两个元素,或者使用一个模数运算符来确定要访问的位置。
不要在 Python 中执行此操作。 我们可以像上一节中那样(如果排序顺序无关紧要),使用字典将相关项目分组在一起,或者使用元组列表。 这是一个令人费解的示例,演示了如何使用列表进行频率示例。 它比字典示例复杂得多,并且说明了选择正确(或错误)数据结构对代码可读性的影响:
import string
CHARACTERS = list(string.ascii_letters) + [" "]
def letter_frequency(sentence):
frequencies = [(c, 0) for c in CHARACTERS]
for letter in sentence:
index = CHARACTERS.index(letter)
frequencies[index] = (letter,frequencies[index][1]+1)
return frequencies
此代码以可能的字符列表开头。 string.ascii_letters属性按顺序提供所有字母的字符串,包括小写和大写。 我们将其转换为列表,然后使用列表串联(加号运算符使两个列表合并为一个)再添加一个字符,即空格。 这些是频率列表中的可用字符(如果我们尝试添加不在列表中的字母,则代码会中断,但是可以使用异常处理程序解决此问题)。
函数内部的第一行使用列表推导将CHARACTERS列表转换为元组列表。 列表推导是 Python 中重要的,非面向对象的工具; 我们将在下一章详细介绍它们。
然后,我们在句子中的每个字符上循环。 我们首先在CHARACTERS列表中查找字符的索引,因为我们刚刚从第一个列表创建了第二个列表,所以我们知道它在频率列表中具有相同的索引。 然后,我们通过创建一个新的元组(而不是原始的元组)来更新频率列表中的索引。 除了垃圾回收和内存浪费的问题外,这还很难阅读!
像字典一样,列表也是对象,它们具有可以在其上调用的几种方法。 这是一些常见的:
append(element)方法将元素添加到列表的末尾insert(index, element)方法将项目插入特定位置count(element)方法告诉我们元素在列表中出现了多少次index()方法告诉我们列表中某项的索引,如果找不到该项,则会引发异常find()方法执行相同的操作,但是返回-1而不是引发缺少项的异常reverse()方法完全按照它说的去做-将列表翻转sort()方法具有一些相当复杂的面向对象的行为,我们现在将讨论
排序列表
如果没有任何参数,sort通常会完成预期的工作。 如果是字符串列表,它将按字母顺序放置。 此操作区分大小写,因此所有大写字母将在小写字母之前排序,即Z在a之前。 如果是数字列表,则将按数字顺序对其进行排序。 如果提供了元组列表,则该列表将按每个元组中的第一个元素进行排序。 如果提供了包含无法分类项目的混合物,则分类将引发TypeError异常。
如果我们要放置对象,则将自己定义为一个列表并使这些对象可排序,我们需要做更多的工作。 应该在类上定义代表“小于”的特殊方法__lt__,以使该类的实例具有可比性。 列表中的sort方法将在每个对象上访问此方法,以确定它在列表中的位置。 如果我们的类在某种程度上小于传递的参数,则此方法应返回True,否则返回False。 这是一个很愚蠢的类,可以根据字符串或数字进行排序:
class WeirdSortee:
def __init__(self, string, number, sort_num):
self.string = string
self.number = number
self.sort_num = sort_num
def __lt__(self, object):
if self.sort_num:
return self.number < object.number
return self.string < object.string
def __repr__(self):
return"{}:{}".format(self.string, self.number)
__repr__方法使我们在打印列表时很容易看到两个值。 __lt__方法的实现将对象与同一类的另一个实例进行比较(或具有string,number和sort_num属性的任何鸭子类型的对象;如果缺少这些属性,它将失败)。 以下输出说明了有关排序的实际类:
>>> a = WeirdSortee('a', 4, True)
>>> b = WeirdSortee('b', 3, True)
>>> c = WeirdSortee('c', 2, True)
>>> d = WeirdSortee('d', 1, True)
>>> l = [a,b,c,d]
>>> l
[a:4, b:3, c:2, d:1]
>>> l.sort()
>>> l
[d:1, c:2, b:3, a:4]
>>> for i in l:
... i.sort_num = False
...
>>> l.sort()
>>> l
[a:4, b:3, c:2, d:1]
我们第一次将称为sort,它是按数字排序的,因为在所有要比较的对象上sort_num是True。 第二次,它按字母排序。 __lt__方法是我们需要实现的唯一一种启用排序的方法。 但是,从技术上讲,如果实现了,则该类通常也应该实现类似的__gt__,__eq__,__ne__,__ge__和__le__方法,以便所有<,> ,==,!=,>=和<=运算符也可以正常工作。 您可以通过实现__lt__和__eq__,然后应用@total_ordering类修饰器来提供其余内容,以免费获得此功能:
from functools import total_ordering
@total_ordering
class WeirdSortee:
def __init__(self, string, number, sort_num):
self.string = string
self.number = number
self.sort_num = sort_num
def __lt__(self, object):
if self.sort_num:
return self.number < object.number
return self.string < object.string
def __repr__(self):
return"{}:{}".format(self.string, self.number)
def __eq__(self, object):
return all((
self.string == object.string,
self.number == object.number,
self.sort_num == object.number
))
如果我们希望能够在对象上使用运算符,这将很有用。 但是,如果我们要做的只是自定义排序顺序,那么这太过分了。 对于这种用例,sort方法可以采用可选的key参数。 此参数是一个函数,可以将列表中的每个对象转换为可以以某种方式进行比较的对象。 例如,我们可以使用str.lower作为键参数对字符串列表执行不区分大小写的排序:
>>> l = ["hello", "HELP", "Helo"]
>>> l.sort()
>>> l
['HELP', 'Helo', 'hello']
>>> l.sort(key=str.lower)
>>> l
['hello', 'Helo', 'HELP']
请记住,尽管尽管lower是字符串对象的一种方法,但它也是一个可以接受单个参数self的函数。 换句话说,str.lower(item)等效于item.lower()。 当我们将此函数作为键传递时,它将对小写值执行比较,而不是执行默认的区分大小写的比较。
Python 团队提供了一些常见的排序键操作,因此您不必自己编写它们。 例如,通常用除列表中第一项之外的其他方式对元组列表进行排序。 operator.itemgetter方法可以用作执行此操作的键:
>>> from operator import itemgetter
>>> l = [('h', 4), ('n', 6), ('o', 5), ('p', 1), ('t', 3), ('y', 2)]
>>> l.sort(key=itemgetter(1))
>>> l
[('p', 1), ('y', 2), ('t', 3), ('h', 4), ('o', 5), ('n', 6)]
itemgetter函数是最常用的函数(如果对象也是字典也可以使用),但是有时您会发现attrgetter和methodcaller的用法,它们返回对象的属性和方法调用的结果 出于相同的目的。 有关更多信息,请参见operator模块文档。
套装
列表是非常适合的通用工具,适用于大多数容器对象应用。 但是,当我们要确保列表中的对象唯一时,它们就没有用。 例如,歌曲库可能包含同一位艺术家的许多歌曲。 如果要对库进行排序并创建所有艺术家的列表,则必须在再次添加艺术家之前检查该列表,看看是否已经添加了艺术家。
这就是集合的来源。集合来自数学,它们代表无序的一组(通常)唯一数字。 我们可以将一个数字添加到集合中五次,但它只会在集合中显示一次。
在 Python 中,集合可以保存任何可哈希对象,而不仅仅是数字。 可哈希对象与可用作字典中键的对象相同; 如此一来,列表和字典就消失了。 像数学集一样,它们只能存储每个对象的一个副本。 因此,如果我们尝试创建歌曲艺术家列表,则可以创建一组字符串名称并将其简单地添加到该字符串名称中。 此示例以(歌曲,艺术家)元组的列表开始,并创建一组艺术家:
song_library = [("Phantom Of The Opera", "Sarah Brightman"),
("Knocking On Heaven's Door", "Guns N' Roses"),
("Captain Nemo", "Sarah Brightman"),
("Patterns In The Ivy", "Opeth"),
("November Rain", "Guns N' Roses"),
("Beautiful", "Sarah Brightman"),
("Mal's Song", "Vixy and Tony")]
artists = set()
for song, artist in song_library:
artists.add(artist)
print(artists)
空列表没有内置语法,列表和字典也没有内置语法。 我们使用set()构造函数创建一个集合。 但是,我们可以使用花括号(从字典语法中借用)来创建一个集合,只要该集合包含值即可。 如果我们使用冒号分隔值对,则它是一个字典,如{'key': 'value', 'key2': 'value2'}所示。 如果我们只用逗号分隔值,则它是一个集合,如{'value', 'value2'}所示。 可以使用add方法将项目单独添加到集合中。 如果运行此脚本,我们将看到该集合按公布的方式工作:
{'Sarah Brightman', "Guns N' Roses", 'Vixy and Tony', 'Opeth'}
如果您关注输出,则会注意到这些项目没有按照它们添加到集合中的顺序进行打印。 像字典一样,集合是无序的。 它们都使用底层的基于散列的数据结构来提高效率。 因为它们是无序的,所以集合不能具有按索引查找的项目。 集合的主要目的是将世界分为两组:“集合中的事物”和“集合中不存在的事物”。 检查项目是否在集合中或在集合中的项目上循环很容易,但是如果我们要对它们进行排序或排序,则必须将集合转换为列表。 此输出显示所有这三个活动:
>>> "Opeth" in artists
True
>>> for artist in artists:
... print("{} plays good music".format(artist))
...
Sarah Brightman plays good music
Guns N' Roses plays good music
Vixy and Tony play good music
Opeth plays good music
>>> alphabetical = list(artists)
>>> alphabetical.sort()
>>> alphabetical
["Guns N' Roses", 'Opeth', 'Sarah Brightman', 'Vixy and Tony']
集合的主要特征是唯一的,但这不是其主要目的。 当集合中的两个或多个结合使用时,集合最有用。 集合类型上的大多数方法都可以在其他集合上使用,从而使我们可以有效地组合或比较两个或更多集合中的项目。 这些方法使用奇怪的名称,因为它们使用的数学术语相同。 我们将从三个返回相同结果的方法开始,而不管哪个是调用集,哪个是被调用集。
union方法是最常见且最容易理解的方法。 它使用第二个集合作为参数,并返回一个新集合,该集合包含两个集合中或中的所有元素; 如果元素在两个原始集中都存在,那么它在新集中只会出现一次。 联合就像一个逻辑上的or操作,实际上,如果您不喜欢调用方法,|运算符可以用于两个集合上以执行联合操作。
相反,交集方法接受第二个集合并返回一个新集合,该集合仅包含两个集中的中的元素。 它类似于逻辑and操作,也可以使用&运算符进行引用。
最后, symmetric_difference方法告诉我们还剩下什么; 它是一组或另一组中的一组对象,但不是两者都存在。 下面的示例通过比较我的歌曲库中的一些艺术家和姐姐的艺术家中的艺术家来说明这些方法:
my_artists = {"Sarah Brightman", "Guns N' Roses",
"Opeth", "Vixy and Tony"}
auburns_artists = {"Nickelback", "Guns N' Roses",
"Savage Garden"}
print("All: {}".format(my_artists.union(auburns_artists)))
print("Both: {}".format(auburns_artists.intersection(my_artists)))
print("Either but not both: {}".format(
my_artists.symmetric_difference(auburns_artists)))
如果我们运行此代码,我们将看到这三种方法可以执行 print 语句建议的操作:
All: {'Sarah Brightman', "Guns N' Roses", 'Vixy and Tony',
'Savage Garden', 'Opeth', 'Nickelback'}
Both: {"Guns N' Roses"}
Either but not both: {'Savage Garden', 'Opeth', 'Nickelback',
'Sarah Brightman', 'Vixy and Tony'}
这些方法都返回相同的结果,而不管哪个集合调用另一个。 我们可以说my_artists.union(auburns_artists)或auburns_artists.union(my_artists)并获得相同的结果。 还有一些方法可以返回不同的结果,具体取决于谁是调用者,谁是参数。
这些方法包括issubset和issuperset,它们彼此相反。 两者都返回bool。 如果调用集中的所有项目也都在作为参数传递的集中,则issubset方法返回True。 如果参数中的所有项目也都在调用集中,则issuperset方法将返回True。 因此,s.issubset(t)和t.issuperset(s)是相同的。 如果t包含s中的所有元素,它们都将返回True。
最后,difference方法返回调用集中的所有元素,但不作为参数传递的集中的所有元素; 这就像symmetric_difference的一半。 difference方法也可以由-运算符表示。 以下代码说明了这些方法的实际作用:
my_artists = {"Sarah Brightman", "Guns N' Roses",
"Opeth", "Vixy and Tony"}
bands = {"Guns N' Roses", "Opeth"}
print("my_artists is to bands:")
print("issuperset: {}".format(my_artists.issuperset(bands)))
print("issubset: {}".format(my_artists.issubset(bands)))
print("difference: {}".format(my_artists.difference(bands)))
print("*"*20)
print("bands is to my_artists:")
print("issuperset: {}".format(bands.issuperset(my_artists)))
print("issubset: {}".format(bands.issubset(my_artists)))
print("difference: {}".format(bands.difference(my_artists)))
当从另一组调用时,此代码简单地显示为即可打印出每种方法的响应。 运行它会为我们提供以下输出:
my_artists is to bands:
issuperset: True
issubset: False
difference: {'Sarah Brightman', 'Vixy and Tony'}
********************
bands is to my_artists:
issuperset: False
issubset: True
difference: set()
在第二种情况下,difference方法返回一个空集,因为bands中没有my_artists中没有的项目。
union,intersection和difference方法都可以采用多个集合作为参数。 如我们所料,它们将返回在所有参数上调用该操作时创建的集合。
因此,集合上的方法清楚地表明,集合是要在其他集合上运行的,而不仅仅是容器。 如果我们有来自两个不同来源的数据,并且需要以某种方式快速组合它们,以确定数据重叠或不同之处,则可以使用设置操作来有效地比较它们。 或者,如果我们收到的数据可能包含已经处理过的数据的重复项,则可以使用集合比较两者并仅处理新数据。
最后,知道有价值,当使用in关键字检查成员资格时,集合比列表更有效。 如果在集合或列表上使用语法value in container,则如果container中的元素之一等于value,则返回True,否则返回False。 但是,在列表中,它将查看容器中的每个对象,直到找到值为止;而在集合中,它只是对值进行哈希处理并检查成员资格。 这意味着无论容器有多大,集合都将在相同的时间内找到该值,但是随着列表包含越来越多的值,列表将花费越来越长的时间来搜索值。
扩展内置
现在,我们将更详细地说明何时需要这样做。
当我们有一个要添加功能的内置容器对象时,我们有两个选择。 我们可以创建一个新对象,将该容器作为属性保存(组成),也可以对内置对象进行子类化,并在其上添加或修改方法以完成我们想要的工作(继承)。
如果我们要做的就是使用容器来使用该容器的功能来存储一些对象,那么通常是最好的替代方法。 这样,就很容易将该数据结构传递给其他方法,并且他们将知道如何与之交互。 但是,如果我们想改变容器的实际工作方式,就需要使用继承。 例如,如果我们要确保list中的每个项目都是一个正好包含五个字符的字符串,则需要扩展list并覆盖append()方法以引发无效输入的异常。 我们还必须至少重写__setitem__(self, index, value),这是列表上的一种特殊方法,每当我们使用x[index] = "value"语法和extend()方法时都会调用该方法。
是的,列表是对象。 我们一直在寻找用于访问列表或字典键,遍历容器以及类似的任务的所有特殊的非面向对象的语法,实际上都是“语法糖”,它映射到下面的面向对象范例。 我们可能会问 Python 设计师为什么这样做。 面向对象编程总是不是更好吗? 这个问题很容易回答。 在下面的假设示例中,以程序员的身份更容易阅读? 哪个需要更少的输入?
c = a + b
c = a.add(b)
l[0] = 5
l.setitem(0, 5)
d[key] = value
d.setitem(key, value)
for x in alist:
#do something with x
it = alist.iterator()
while it.has_next():
x = it.next()
#do something with x
突出显示的部分显示了面向对象的代码的外观(实际上,这些方法实际上作为关联对象上的特殊双下划线方法存在)。 Python 程序员一致认为,非面向对象的语法更易于阅读和编写。 然而,所有前面的 Python 语法在后台都映射到面向对象的方法。 这些方法有特殊的名称(前后都有双下划线),以提醒我们那里有更好的语法。 但是,它为我们提供了覆盖这些行为的手段。 例如,我们可以创建一个特殊的整数,当我们将两个整数相加时,该整数总是返回0:
class SillyInt(int):
def __add__(self, num):
return 0
当然,这是一件极其奇怪的事情,但是它完美地说明了这些面向对象的原则在行动:
>>> a = SillyInt(1)
>>> b = SillyInt(2)
>>> a + b
0
关于__add__方法的很棒的事情是我们可以将其添加到我们编写的任何类中,如果我们在该类的实例上使用+运算符,则将调用它。 例如,这就是字符串,元组和列表串联的工作方式。
所有特殊方法都是如此。 如果我们要对自定义对象使用x in myobj语法,则可以实现__contains__。 如果要使用myobj[i] = value语法,则提供__setitem__方法,如果要使用something = myobj[i],则实现__getitem__。
list类上有这些特殊方法中的 33 种。 我们可以使用dir函数查看所有这些信息:
>>> dir(list)
['__add__', '__class__', '__contains__', '__delattr__','__delitem__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getitem__', '__gt__', '__hash__', '__iadd__', '__imul__', '__init__', '__iter__', '__le__', '__len__', '__lt__', '__mul__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__reversed__', '__rmul__', '__setattr__', '__setitem__', '__sizeof__', '__str__', '__subclasshook__', 'append', 'count', 'extend', 'index', 'insert', 'pop', 'remove', 'reverse', 'sort'
此外,如果我们需要有关这些方法的工作方式的附加信息,可以使用help函数:
>>> help(list.__add__)
Help on wrapper_descriptor:
__add__(self, value, /)
Return self+value.
列表上的加号运算符将两个列表连接在一起。 我们没有空间讨论此模块中所有可用的特殊功能,但是您现在可以使用dir和help探索所有这些功能。 官方在线 Python 参考也提供了大量有用的信息。 特别要关注collections模块中讨论的抽象基类。
因此,回到关于何时使用组合与继承的较早点:如果我们需要以某种方式更改类中的任何方法(包括特殊方法),我们肯定需要使用继承。 如果使用组合,则可以编写进行验证或更改的方法,并要求调用方使用这些方法,但是没有什么可以阻止它们直接访问该属性。 他们可能会在我们的列表中插入一个不包含五个字符的项目,这可能会混淆列表中的其他方法。
通常,需要扩展内置数据类型表明我们使用了错误的数据类型。 并非总是如此,但是如果我们要扩展内置函数,则应仔细考虑是否应该使用其他数据结构。
例如,考虑创建一个能记住键插入顺序的字典的过程。 一种方法是保留存储在dict的特殊派生子类中的键的有序列表。 然后,我们可以覆盖方法keys,values,__iter__和items以按顺序返回所有内容。 当然,我们还必须覆盖__setitem__和setdefault,以使列表保持最新。 dir(dict)输出中可能还有其他一些方法需要重写,以使列表和字典保持一致(想到clear和__delitem__,以跟踪何时删除项目),但我们不会 在此示例中,不必为它们担心。
因此,我们将扩展为dict ,并添加一个有序键列表。 琐碎的,但我们在哪里创建实际列表? 我们可以将其包含在__init__方法中,该方法可以正常工作,但我们不能保证任何子类都会调用该初始化程序。 还记得我们在第 2 章和 Python 中的对象中讨论的__new__方法吗? 我说过,它通常仅在非常特殊的情况下有用。 这是那些特殊情况之一。 我们知道__new__只会被调用一次,因此我们可以在新实例上创建一个列表,该列表将始终可供我们的类使用。 考虑到这一点,这是我们整个排序的字典:
from collections import KeysView, ItemsView, ValuesView
class DictSorted(dict):
def __new__(*args, **kwargs):
new_dict = dict.__new__(*args, **kwargs)
new_dict.ordered_keys = []
return new_dict
def __setitem__(self, key, value):
'''self[key] = value syntax'''
if key not in self.ordered_keys:
self.ordered_keys.append(key)
super().__setitem__(key, value)
def setdefault(self, key, value):
if key not in self.ordered_keys:
self.ordered_keys.append(key)
return super().setdefault(key, value)
def keys(self):
return KeysView(self)
def values(self):
return ValuesView(self)
def items(self):
return ItemsView(self)
def __iter__(self):
'''for x in self syntax'''
return self.ordered_keys.__iter__()
__new__方法创建一个新字典,然后在该对象上放置一个空列表。 我们不会覆盖__init__,因为默认实现有效(实际上,只有初始化一个空的DictSorted对象(这是标准行为),这才是正确的。如果我们要支持dict构造函数的其他变体, 其中接受字典或元组列表,我们需要修复__init__才能更新我们的ordered_keys列表)。 设置项目的两种方法非常相似。 它们都将更新键列表,但前提是之前未添加任何项。 我们不希望列表中有重复项,但是我们不能在此处使用集合。 它是无序的!
keys,items和values方法都将视图返回到字典。 集合库在字典上提供了三个只读View对象; 他们使用__iter__方法来遍历键,然后使用__getitem__(我们不需要重写)来检索值。 因此,我们只需要定义我们的自定义__iter__方法即可使这三个视图正常工作。 您可能会认为超类会使用多态性正确创建这些视图,但是如果我们不重写这三种方法,它们将不会返回正确排序的视图。
最后,__iter__方法是真正的特殊方法。 它确保了如果我们遍历字典的键(使用for ... in语法),它将以正确的顺序返回值。 它通过返回ordered_keys列表的__iter__来完成此操作,该列表返回与在列表中使用for ... in时将使用的迭代器对象相同的迭代器对象。 由于ordered_keys是所有可用键的列表(由于我们覆盖其他方法的方式),因此这也是字典的正确迭代器对象。
与普通字典相比,让我们看一下其中的一些方法:
>>> ds = DictSorted()
>>> d = {}
>>> ds['a'] = 1
>>> ds['b'] = 2
>>> ds.setdefault('c', 3)
3
>>> d['a'] = 1
>>> d['b'] = 2
>>> d.setdefault('c', 3)
3
>>> for k,v in ds.items():
... print(k,v)
...
a 1
b 2
c 3
>>> for k,v in d.items():
... print(k,v)
...
a 1
c 3
b 2
啊,我们的字典是按排序的,而普通字典不是。 欢呼!
注意
如果要在生产中使用此类,则必须重写其他几种特殊方法,以确保密钥在所有情况下都是最新的。 但是,您不需要这样做; 使用collections模块中的OrderedDict对象,该类提供的功能已在 Python 中提供。 尝试从collections导入类,然后使用help(OrderedDict)进一步了解它。
队列
队列是特有的数据结构,因为像集一样,它们的功能可以完全使用列表来处理。 但是,尽管列表是用途极为广泛的通用工具,但它们有时并不是容器操作的最有效数据结构。 如果您的程序使用的是小型数据集(在当今的处理器上多达数百甚至数千个元素),则列表可能会涵盖您的所有用例。 但是,如果您需要将数据规模扩展到数百万,则可能需要针对特定用例的更高效容器。 因此,Python 提供了三种类型的队列数据结构,具体取决于您要寻找的访问类型。 这三个都使用相同的 API,但是行为和数据结构都不同。
但是,在开始队列之前,请考虑信任列表数据结构。 对于许多用例,Python 列表是最有利的数据结构:
- 它们支持有效地随机访问列表中的任何元素
- 它们具有严格的元素顺序
- 他们有效地支持附加操作
但是,如果在列表末尾以外的任何地方插入元素,它们往往会使变慢(特别是在列表的开头时)。 正如我们在集合一节中讨论的那样,它们对于检查列表中是否存在某个元素以及通过扩展进行搜索也很慢。 按排序顺序存储数据或对数据重新排序也可能效率不高。
让我们看一下 Python queue模块提供的三种容器。
FIFO 队列
FIFO 代表为先进先出,代表最普遍理解的单词“队列”定义。 想象有一群人在银行或收银机旁排队。 进入队伍的第一个人首先得到服务,队伍中的第二个人获得第二服务,如果有新人想要服务,他们会加入队伍的末端并等待轮到他们。
Python Queue类就是这样。 当一个或多个对象正在生成数据而一个或多个其他对象以某种方式(可能以不同的速率)使用数据时,通常将其用作一种通信介质。 考虑一种正在从网络接收消息,但一次只能向用户显示一条消息的消息传递应用。 可以按照其他消息的接收顺序将它们缓存在队列中。 在此类并发应用中,FIFO 队列被大量利用。 (我们将在第 12 章,“测试面向对象程序”中详细讨论并发性。)
当您不需要访问要使用的下一个对象之外的数据结构中的任何数据时,Queue类是一个不错的选择。 为此使用列表的效率较低,因为在幕后,在列表开头插入数据(或从列表开头删除数据)可能需要移动列表中的所有其他元素。
队列具有非常简单的 API。 Queue可以具有“无限”(直到计算机内存用尽)容量,但通常限制为某个最大大小。 主要方法是put()和get(),它们将元素原样添加到行的末尾,并从前开始按顺序检索它们。 这两种方法都接受可选参数,以控制如果由于队列为空(无法获取)或已满(无法放入)而无法成功完成操作时将发生的情况。 默认行为是阻止或空闲,直到Queue对象具有可用于完成操作的数据或空间。 您可以通过传递block=False参数来使其引发异常。 或者,您可以通过传递timeout参数让它等待指定的时间,然后引发异常。
该类还具有检查Queue是full()还是empty()的方法,并且还有一些其他方法来处理并发访问,我们将在这里不进行讨论。 这是一个交互式会话,展示了这些原理:
>>> from queue import Queue
>>> lineup = Queue(maxsize=3)
>>> lineup.get(block=False)
Traceback (most recent call last):
File "<ipython-input-5-a1c8d8492c59>", line 1, in <module>
lineup.get(block=False)
File "/usr/lib64/python3.3/queue.py", line 164, in get
raise Empty
queue.Empty
>>> lineup.put("one")
>>> lineup.put("two")
>>> lineup.put("three")
>>> lineup.put("four", timeout=1)
Traceback (most recent call last):
File "<ipython-input-9-4b9db399883d>", line 1, in <module>
lineup.put("four", timeout=1)
File "/usr/lib64/python3.3/queue.py", line 144, in put
raise Full
queue.Full
>>> lineup.full()
True
>>> lineup.get()
'one'
>>> lineup.get()
'two'
>>> lineup.get()
'three'
>>> lineup.empty()
True
在幕后,Python 在collections.deque数据结构之上实现了队列。 双端队列是高级数据结构,可以有效访问集合的两端。 它提供的接口比Queue公开的接口更灵活。 如果您想尝试更多 Python 文档,请参考。
LIFO queues
LIFO(后进先出)队列更多,经常被称为堆栈。 考虑一堆纸,您只能访问最上面的纸。 您可以将另一张纸放在纸叠的顶部,使其成为新的最上面的纸,或者可以拿走最上面的纸以露出下面的纸。
传统上,堆栈上的操作称为 push 和 pop,但是 Python queue模块使用与 FIFO 队列完全相同的 API:put()和get()。 但是,在 LIFO 队列中,这些方法在堆栈的“顶部”操作,而不是在行的前面和后面。 这是多态性的一个很好的例子。 如果查看 Python 标准库中的Queue源代码,您实际上会看到一个超类,该超类具有 FIFO 和 LIFO 队列的子类,这些子类实现了一些操作(在堆栈的顶部而不是前面和后面)进行操作。 deque实例的返回)在两者之间存在重大差异。
这是运行中的 LIFO 队列的示例:
>>> from queue import LifoQueue
>>> stack = LifoQueue(maxsize=3)
>>> stack.put("one")
>>> stack.put("two")
>>> stack.put("three")
>>> stack.put("four", block=False)
Traceback (most recent call last):
File "<ipython-input-21-5473b359e5a8>", line 1, in <module>
stack.put("four", block=False)
File "/usr/lib64/python3.3/queue.py", line 133, in put
raise Full
queue.Full
>>> stack.get()
'three'
>>> stack.get()
'two'
>>> stack.get()
'one'
>>> stack.empty()
True
>>> stack.get(timeout=1)
Traceback (most recent call last):
File "<ipython-input-26-28e084a84a10>", line 1, in <module>
stack.get(timeout=1)
File "/usr/lib64/python3.3/queue.py", line 175, in get
raise Empty
queue.Empty
您可能会想知道为什么不能仅在标准列表上使用append()和pop()方法。 坦率地说,这可能就是我要做的。 我很少有机会在生产代码中使用LifoQueue类。 使用列表末尾是一种有效的操作; 实际上,它是如此高效,以至于LifoQueue都在引擎盖下使用了标准清单!
有几个原因可能需要使用LifoQueue而不是列表。 最重要的是LifoQueue支持从多个线程进行干净的并发访问。 如果在并发设置中需要类似堆栈的行为,则应将列表放在家里。 其次,LifoQueue强制执行堆栈接口。 例如,您不能无意间将值插入LifoQueue中的错误位置(尽管作为练习,您可以找出如何完全有意识地做到这一点)。
优先队列
优先级队列强制执行与先前队列实现完全不同的排序方式。 再次,它们遵循完全相同的get()和put() API,但是不是依赖项到达的顺序来确定何时应返回它们,而是返回最“重要”的项。 按照惯例,最重要或优先级最高的项是使用小于运算符对最低项进行排序的项。
通用约定是将元组存储在优先级队列中,其中元组中的第一个元素是该元素的优先级,第二个元素是数据。 如本章前面所述,另一个常见的范例是实现__lt__方法。 队列中有多个具有相同优先级的元素是完全可以接受的,尽管不能保证首先返回一个元素。
例如,搜索引擎可能会使用优先级队列来确保优先级队列在搜寻不太可能被搜索的网站之前刷新最流行网页的内容。 产品推荐工具可能会使用它来显示有关排名最高的产品的信息,同时仍会加载排名较低的数据。
请注意,优先级队列将始终返回队列中当前最重要的元素。 如果队列为空,get()方法将阻止(默认情况下),但是如果队列中已经有东西,则它不会阻止并等待添加更高优先级的元素。 队列对尚未添加的元素(甚至是先前已提取的元素)一无所知,仅根据队列的当前内容做出决定。
此交互式会话显示了一个运行中的优先级队列,使用元组作为权重来确定要处理哪些订单项:
>>> heap.put((3, "three"))
>>> heap.put((4, "four"))
>>> heap.put((1, "one") )
>>> heap.put((2, "two"))
>>> heap.put((5, "five"), block=False)
Traceback (most recent call last):
File "<ipython-input-23-d4209db364ed>", line 1, in <module>
heap.put((5, "five"), block=False)
File "/usr/lib64/python3.3/queue.py", line 133, in put
raise Full
Full
>>> while not heap.empty():
print(heap.get())
(1, 'one')
(2, 'two')
(3, 'three')
(4, 'four')
优先级队列几乎都是使用heap数据结构实现的。 Python 的实现利用heapq模块将堆有效地存储在普通列表中。 我将您引向算法和数据结构的教科书,以获取有关堆的更多信息,更不用说我们这里未介绍的许多其他有趣的结构。 无论数据结构如何,都可以使用面向对象的原理来包装相关算法(行为),例如heapq模块中提供的算法,就像queue一样围绕它们在计算机内存中构造的数据。 模块已代表我们在标准库中完成。
案例研究
为了将结合在一起,我们将编写一个简单的链接收集器,该链接收集器将访问一个网站并收集在该网站上找到的每个页面上的每个链接。 不过,在开始之前,我们需要一些测试数据。 只需编写一些 HTML 文件即可使用,这些文件包含彼此之间的链接以及与 Internet 上其他站点的链接,如下所示:
<html>
<body>
<a href="contact.html">Contact us</a>
<a href="blog.html">Blog</a>
<a href="esme.html">My Dog</a>
<a href="/hobbies.html">Some hobbies</a>
<a href="/contact.html">Contact AGAIN</a>
<a href="http://www.archlinux.org/">Favorite OS</a>
</body>
</html>
命名其中一个文件index.html,以便在提供页面时它首先显示。 确保其他文件存在,并使事情复杂,以便它们之间有很多链接。 如果您不想自己进行设置,则本章的示例包括一个名为case_study_serve的目录(现存最糟糕的个人网站之一!)。
现在,通过输入包含所有这些文件的目录来启动简单的 Web 服务器,然后运行以下命令:
python3 -m http.server
这将启动在端口 8000 上运行的服务器。 您可以在网络浏览器中访问http://localhost:8000/来查看创建的页面。
注意
我怀疑任何人都可以以更少的工作来建立并运行一个网站! 永远不要说“用 Python 不能轻易做到这一点”。
目标是传递给我们的收集器网站的基本 URL(在本例中为http://localhost:8000/),并使其创建一个包含该网站上每个唯一链接的列表。 我们需要考虑三种类型的 URL(到其他站点的链接(以http://开头的外部站点链接,以/字符开头的绝对内部链接和相对链接))。 我们还需要注意,页面可能会循环链接在一起; 我们需要确保不会多次处理同一个页面,否则它可能永远也不会结束。 随着所有这些独特性的进行,听起来我们将需要一些集合。
在开始讨论之前,让我们从基础开始。 我们需要什么代码才能连接到页面并解析该页面上的所有链接?
from urllib.request import urlopen
from urllib.parse import urlparse
import re
import sys
LINK_REGEX = re.compile(
"<a [^>]*href=['\"]([^'\"]+)['\"][^>]*>")
class LinkCollector:
def __init__(self, url):
self.url = "" + urlparse(url).netloc
def collect_links(self, path="/"):
full_url = self.url + path
page = str(urlopen(full_url).read())
links = LINK_REGEX.findall(page)
print(links)
if __name__ == "__main__":
LinkCollector(sys.argv[1]).collect_links()
考虑到它在做什么,这是一个短代码。 它通过命令行传递的参数连接到服务器,下载页面,并提取该页面上的所有链接。 __init__方法使用urlparse函数从 URL 中仅提取主机名; 因此,即使我们传入http://localhost:8000/some/page.html,它仍将在主机http://localhost:8000/的顶层运行。 这是有道理的,因为我们希望收集站点上的所有链接,尽管它假定每个页面都是通过某些链接序列连接到索引的。
collect_links方法连接到服务器并从服务器下载指定的页面,并使用正则表达式查找页面中的所有链接。 正则表达式是一个非常强大的字符串处理工具。 不幸的是,他们的学习曲线陡峭。 如果您以前从未使用过它们,我强烈建议您学习有关该主题的所有书籍或网站。 如果您认为不值得了解它们,请尝试在没有它们的情况下编写前面的代码,您会改变主意。
该示例也停在collect_links方法的中间,以打印链接的值。 这是在编写程序时测试程序的一种常见方法:停止并输出值以确保它是我们期望的值。 这是我们的示例输出的内容:
['contact.html', 'blog.html', 'esme.html', '/hobbies.html',
'/contact.html', 'http://www.archlinux.org/']
现在,我们在第一页中包含了所有链接的集合。 我们该怎么办? 我们不能只是将链接弹出到集合中以删除重复项,因为链接可能是相对的或绝对的。 例如,contact.html和/contact.html指向同一页面。 因此,我们要做的第一件事是规范所有指向其完整 URL 的链接,包括主机名和相对路径。 我们可以通过向我们的对象添加normalize_url方法来做到这一点:
def normalize_url(self, path, link):
if link.startswith("http://"):
return link
elif link.startswith("/"):
return self.url + link
else:
return self.url + path.rpartition(
'/')[0] + '/' + link
此方法将每个 URL 转换为包含协议和主机名的完整地址。 现在,两个联系人页面具有相同的值,我们可以将它们存储在一组中。 我们必须修改__init__来创建集合,并修改collect_links来将所有链接放入其中。
然后,我们必须访问所有非外部链接并收集它们。 等一下 如果这样做,当我们两次遇到相同页面时,如何避免重新访问链接? 看来我们实际上需要两个集合:一组收集的链接和一组已访问的链接。 这表明我们明智的选择一个代表数据的集合。 我们知道,当我们要处理多个集合时,集合是最有用的。 让我们进行设置:
class LinkCollector:
def __init__(self, url):
self.url = "http://+" + urlparse(url).netloc
self.collected_links = set()
self.visited_links = set()
def collect_links(self, path="/"):
full_url = self.url + path
self.visited_links.add(full_url)
page = str(urlopen(full_url).read())
links = LINK_REGEX.findall(page)
links = {self.normalize_url(path, link
) for link in links}
self.collected_links = links.union(
self.collected_links)
unvisited_links = links.difference(
self.visited_links)
print(links, self.visited_links,
self.collected_links, unvisited_links)
创建标准化链接列表的行使用set理解,与列表理解没有区别,不同之处在于结果是一组值。 我们将在下一章详细介绍这些内容。 再次,该方法停止打印当前值,因此我们可以验证我们没有混淆集合,并且difference确实是我们要调用的用于收集unvisited_links的方法。 然后,我们可以添加几行代码来循环所有未访问的链接,并将它们也添加到集合中:
for link in unvisited_links:
if link.startswith(self.url):
self.collect_links(urlparse(link).path)
if语句确保我们仅从一个网站收集链接; 我们不想离开并收集 Internet 上所有页面的所有链接(除非我们是 Google 或 Internet 存档!)。 如果我们修改程序底部的主要代码以输出收集的链接,我们可以看到似乎已经收集了所有链接:
if __name__ == "__main__":
collector = LinkCollector(sys.argv[1])
collector.collect_links()
for link in collector.collected_links:
print(link)
它仅显示一次我们收集的所有链接,即使示例中的许多页面多次链接在一起也是如此:
$ python3 link_collector.py http://localhost:8000
http://localhost:8000/
http://en.wikipedia.org/wiki/Cavalier_King_Charles_Spaniel
http://beluminousyoga.com
http://archlinux.me/dusty/
http://localhost:8000/blog.html
http://ccphillips.net/
http://localhost:8000/contact.html
http://localhost:8000/taichi.html
http://www.archlinux.org/
http://localhost:8000/esme.html
http://localhost:8000/hobbies.html
即使它收集了到外部页面的链接,它也没有从链接到我们链接到的任何外部页面中收集链接。 如果我们要收集站点中的所有链接,这是一个很棒的小程序。 但这并不能为我提供构建站点地图可能需要的所有信息。 它告诉我我有哪些页面,但没有告诉我哪些页面链接到其他页面。 如果要改为执行此操作,则必须进行一些修改。
我们应该做的第一件事是查看我们的数据结构。 收集的链接集不再起作用。 我们想知道哪些链接链接到哪些页面。 然后,我们要做的第一件事就是将所设置的页面变成每个访问页面的页面字典。 字典键将代表集合中当前完全相同的数据。 这些值将是该页面上所有链接的集合。 更改如下:
from urllib.request import urlopen
from urllib.parse import urlparse
import re
import sys
LINK_REGEX = re.compile(
"<a [^>]*href=['\"]([^'\"]+)['\"][^>]*>")
class LinkCollector:
def __init__(self, url):
self.url = "http://%s" % urlparse(url).netloc
self.collected_links = {}
self.visited_links = set()
def collect_links(self, path="/"):
full_url = self.url + path
self.visited_links.add(full_url)
page = str(urlopen(full_url).read())
links = LINK_REGEX.findall(page)
links = {self.normalize_url(path, link
) for link in links}
self.collected_links[full_url] = links
for link in links:
self.collected_links.setdefault(link, set())
unvisited_links = links.difference(
self.visited_links)
for link in unvisited_links:
if link.startswith(self.url):
self.collect_links(urlparse(link).path)
def normalize_url(self, path, link):
if link.startswith("http://"):
return link
elif link.startswith("/"):
return self.url + link
else:
return self.url + path.rpartition('/'
)[0] + '/' + link
if __name__ == "__main__":
collector = LinkCollector(sys.argv[1])
collector.collect_links()
for link, item in collector.collected_links.items():
print("{}: {}".format(link, item))
这是一个令人惊讶的小更改; 最初创建两个集合的并集的行已替换为更新字典的三行。 其中第一个只是告诉字典该页面收集的链接是什么。 第二个方法使用setdefault为字典中尚未添加到字典中的任何项目创建一个空集。 结果是一个字典,其中包含所有链接作为其键,映射到所有内部链接的链接集和外部链接的空集。
最后,我们可以使用队列来存储尚未处理的链接,而不必递归调用collect_links。 此实现不支持此实现,但这将是创建多线程版本的好第一步,该版本可以并行发出多个请求以节省时间。
from urllib.request import urlopen
from urllib.parse import urlparse
import re
import sys
from queue import Queue
LINK_REGEX = re.compile("<a [^>]*href=['\"]([^'\"]+)['\"][^>]*>")
class LinkCollector:
def __init__(self, url):
self.url = "http://%s" % urlparse(url).netloc
self.collected_links = {}
self.visited_links = set()
def collect_links(self):
queue = Queue()
queue.put(self.url)
while not queue.empty():
url = queue.get().rstrip('/')
self.visited_links.add(url)
page = str(urlopen(url).read())
links = LINK_REGEX.findall(page)
links = {
self.normalize_url(urlparse(url).path, link)
for link in links
}
self.collected_links[url] = links
for link in links:
self.collected_links.setdefault(link, set())
unvisited_links = links.difference(self.visited_links)
for link in unvisited_links:
if link.startswith(self.url):
queue.put(link)
def normalize_url(self, path, link):
if link.startswith("http://"):
return link.rstrip('/')
elif link.startswith("/"):
return self.url + link.rstrip('/')
else:
return self.url + path.rpartition('/')[0] + '/' + link.rstrip('/')
if __name__ == "__main__":
collector = LinkCollector(sys.argv[1])
collector.collect_links()
for link, item in collector.collected_links.items():
print("%s: %s" % (link, item))
我必须手动剥离normalize_url方法中的所有尾随正斜杠,以删除此版本代码中的重复项。
由于最终结果是未排序的字典,因此对链接的处理顺序没有限制。因此,在这里我们可以很容易地使用LifoQueue而不是Queue。 优先级队列可能没有多大意义,因为在这种情况下,没有明显的优先级附加到链接。



八、Python 面向对象的快捷方式
Python 的许多方面似乎比面向对象的编程更让人联想到结构或功能编程。 尽管在过去的二十年中,面向对象的编程一直是最明显的范例,但是旧模型却在最近复苏了。 与 Python 的数据结构一样,大多数这些工具都是基于底层面向对象实现的语法糖。 我们可以将它们视为基于(已经抽象的)面向对象范式之上的进一步抽象层。 在本章中,我们将介绍并非严格面向对象的 Python 功能:
- 内置功能可一键处理常见任务
- 文件 I / O 和上下文管理器
- 方法重载的替代方法
- 作为对象
Python 内置函数
Python 中有个函数,它们可以对某些类型的对象执行任务或计算结果,而无需作为基础类的方法。 他们通常抽象适用于多种类型的类的通用计算。 这是最好的鸭子打字方式。 这些函数接受具有某些属性或方法的对象,并能够使用这些方法执行常规操作。 其中许多(但不是全部)是特殊的双下划线方法。 我们已经使用了许多内置函数,但是让我们快速浏览重要的函数,并逐步学习一些巧妙的技巧。
len()函数
最简单的示例是len()函数,该函数计算某种容器对象(例如字典或列表)中的项目数。 您之前看过:
>>> len([1,2,3,4])
4
为什么这些对象不具有 length 属性,而不必在其上调用函数? 从技术上讲,他们做到了。 len()将应用的大多数对象都具有一个称为__len__()的方法,该方法返回相同的值。 因此len(myobj)似乎叫myobj.__len__()。
为什么我们应该使用len()函数而不是__len__方法? 显然__len__是一种特殊的双下划线方法,建议我们不要直接调用它。 对此必须有一个解释。 Python 开发人员不会轻易做出此类设计决策。
主要原因是效率。 当我们在对象上调用__len__时,该对象必须在其命名空间中查找该方法,并且,如果定义了特殊的__getattribute__方法(每次访问对象上的属性或方法时都会调用该方法), 该对象,也必须调用它。 此外,针对该特定方法的__getattribute__可能被编写为令人讨厌,例如拒绝让我们访问诸如__len__之类的特殊方法! len()功能没有遇到任何这些。 它实际上在基础类上调用__len__函数,因此len(myobj)映射到MyObj.__len__(myobj)。
另一个原因是可维护性。 将来,Python 开发人员可能希望更改len(),以便可以计算不具有__len__的对象的长度,例如,通过计算迭代器中返回的项目数。 他们只需要更改一项功能,而不必更改无数种__len__方法。
len()作为外部函数还有另一个极其重要且经常被忽略的原因:向后兼容。 这在文章中经常被引用为“出于历史原因”,这是一个轻微的不屑一顾的词组,作者会用它来表达某些事情,因为很早以前就犯了一个错误,我们一直坚持下去。 严格来说,len()不是错误,它是设计决策,但是该决策是在较少的面向对象的时间内完成的。 它经受了时间的考验并具有一些好处,因此请习惯它。
反转
reversed()函数将任何序列作为输入,并以相反的顺序返回该序列的副本。 当我们要从头到尾循环播放项目时,通常在for循环中使用。
与len相似,reversed对该参数的类调用__reversed__()函数。 如果该方法不存在,则reversed会使用对__len__和__getitem__的调用来构建反向序列本身,这些调用用于定义序列。 如果我们想以某种方式自定义或优化流程,则只需要覆盖__reversed__:
normal_list=[1,2,3,4,5]
class CustomSequence():
def __len__(self):
return 5
def __getitem__(self, index):
return "x{0}".format(index)
class FunkyBackwards():
def __reversed__(self):
return "BACKWARDS!"
for seq in normal_list, CustomSequence(), FunkyBackwards():
print("\n{}: ".format(seq.__class__.__name__), end="")
for item in reversed(seq):
print(item, end=", ")
最后的for循环会打印正常列表的反向版本以及两个自定义序列的实例。 输出显示reversed适用于所有这三个参数,但是当我们自己定义__reversed__时,结果却大不相同:
list: 5, 4, 3, 2, 1,
CustomSequence: x4, x3, x2, x1, x0,
FunkyBackwards: B, A, C, K, W, A, R, D, S, !,
当我们反转CustomSequence时,将为每个项目调用__getitem__方法,该方法只是在索引之前插入x。 对于FunkyBackwards,__reversed__ 方法返回一个字符串,其每个字符在for循环中分别输出。
注意
前面的两个类不是很好的序列,因为它们没有定义__iter__的正确版本,因此对它们的正向for循环将永远不会结束。
上市
有时,当我们在for循环中的容器上循环时,我们想要访问正在处理的当前项目的索引(列表中的当前位置)。 for循环没有为我们提供索引,但是enumerate函数为我们提供了更好的东西:它创建了一个元组序列,其中每个元组中的第一个对象是索引,第二个对象是原始项。
如果我们需要直接使用索引号,这将很有用。 考虑一些简单的代码,该文件输出具有行号的文件中的每一行:
import sys
filename = sys.argv[1]
with open(filename) as file:
for index, line in enumerate(file):
print("{0}: {1}".format(index+1, line), end='')
使用其自己的文件名作为输入文件来运行此代码,将显示其工作方式:
1: import sys
2: filename = sys.argv[1]
3:
4: with open(filename) as file:
5: for index, line in enumerate(file):
6: print("{0}: {1}".format(index+1, line), end='')
enumerate函数返回一个元组序列,我们的for循环将每个元组分成两个值,然后print语句将它们格式化在一起。 因为enumerate与所有序列一样,都是从零开始的,所以它会为每个行号的索引加 1。
我们只涉及了一些更重要的 Python 内置函数。 如您所见,其中许多调用了面向对象的概念,而另一些则订阅了纯粹的功能或过程范例。 标准库中还有许多其他内容。 一些更有趣的包括:
all和any,它们接受一个可迭代的对象并返回True,如果所有或任何一项的计算结果为 true(例如,非空字符串或列表,非零数字,不是None的对象 ]或文字True)。eval,exec和compile,它们在解释器内部将字符串作为代码执行。 小心这些。 它们不安全,因此请不要执行未知用户提供给您的代码(通常,假定所有未知用户都是恶意的,愚蠢的,或两者兼有)。hasattr,getattr,setattr和delattr,它们可以通过对象的字符串名称来操纵对象上的属性。zip,它接受两个或更多序列,并返回一个新的元组序列,其中每个元组都包含来自每个序列的单个值。- 还有很多! 有关
dir(__builtins__)中列出的每个功能,请参见解释器帮助文档。
文件 I / O
到目前为止,我们的示例已完全在文本文件上操作了触摸文件系统,而没有过多考虑幕后情况。 但是,操作系统实际上将文件表示为字节序列,而不是文本。 请注意,从文件读取文本数据是一个相当复杂的过程。 Python,尤其是 Python 3,在幕后为我们完成了大部分工作。 我们不是很幸运吗?
文件的概念早在任何人创造面向对象编程这个术语之前就已经存在。 但是,Python 将操作系统提供的接口包装成了一个甜美的抽象,使我们能够处理文件(或类似文件,相对于鸭子输入)对象。
open()内置函数用于打开文件并返回文件对象。 为了从文件中读取文本,我们只需要将文件名传递给函数。 该文件将打开以供读取,并且字节将使用平台默认编码转换为文本。
当然,我们并不总是要读取文件;而是要读取文件。 通常我们想向他们写数据! 要打开要写入的文件,我们需要传递mode参数作为第二个位置参数,值"w":
contents = "Some file contents"
file = open("filename", "w")
file.write(contents)
file.close()
我们还可以提供"a"值作为模式参数,以附加到文件末尾,而不是完全覆盖现有文件内容。
这些带有用于将字节转换为文本的内置包装器的文件很棒,但是如果我们要打开的文件是图像,可执行文件或其他二进制文件,那将是非常不便的,不是吗?
要打开二进制文件,我们修改模式字符串以附加'b'。 因此,'wb'将打开一个文件来写入字节,而'rb'则允许我们读取它们。 它们的行为类似于文本文件,但没有将文本自动编码为字节的功能。 当我们读取这样的文件时,它将返回bytes对象而不是str,并且在写入该文件时,如果尝试传递文本对象,它将失败。
注意
这些用于控制如何打开文件的模式字符串相当隐秘,既不是 pythonic 也不是面向对象的。 但是,它们与几乎所有其他编程语言都一致。 文件 I / O 是操作系统必须处理的基本工作之一,并且所有编程语言都必须使用相同的系统调用与 OS 进行通信。 很高兴 Python 返回具有有用方法的文件对象,而不是大多数主要操作系统用来标识文件句柄的整数!
打开文件以供阅读后,我们可以调用read,readline或readlines方法来获取文件的内容。 read方法根据模式中是否存在'b',将文件的全部内容作为str或bytes对象返回。 注意不要在没有大文件的情况下使用此方法。 您不想弄清楚如果尝试将那么多数据加载到内存中会发生什么!
也可以从文件中读取固定数量的字节。 我们将一个整数参数传递给read方法,以描述要读取的字节数。 下次调用read将加载下一个字节序列,依此类推。 我们可以在while循环中执行此操作,以可管理的块形式读取整个文件。
readline方法从文件返回一行(其中每一行以换行符结尾,回车符或两者都结束,这取决于在其上创建文件的操作系统)。 我们可以反复调用它以获得更多行。 复数readlines方法返回文件中所有行的列表。 像read方法一样,在非常大的文件上使用也不安全。 这两种方法甚至都可以在bytes模式下打开文件时起作用,但是只有当我们分析在适当位置具有换行符的类似文本的数据时,这才有意义。 例如,图像或音频文件中将没有换行符(除非换行字节恰好代表某个像素或声音),因此应用readline毫无意义。
为了提高可读性,并避免一次将大文件读入内存,通常最好直接在文件对象上使用for循环。 对于文本文件,它将一次读取每一行,我们可以在循环体内对其进行处理。 对于二进制文件,最好使用read()方法读取固定大小的数据块,并传递最大读取字节数的参数。
写入文件同样容易。 文件对象上的write方法将字符串(或字节,对于二进制数据)对象写入文件。 可以反复调用以写入多个字符串,一个接一个。 writelines方法接受字符串序列,并将每个迭代值写入文件。 writelines方法不会而不是在序列中的每个项目之后添加新行。 从根本上来说,方便的函数是一个不好命名的函数,它可以编写字符串序列的内容,而不必使用for循环对其进行显式迭代。
最后,我的意思是,最后,我们来谈谈close方法。 当我们完成文件的读取或写入后,应调用此方法,以确保将所有缓冲的写操作写入磁盘,已正确清理了文件以及与该文件关联的所有资源都释放回了操作系统。 。 从技术上讲,这将在脚本退出时自动发生,但最好是显式并自行清理,尤其是在长时间运行的进程中。
将其放在上下文中
需要在完成文件处理后关闭文件,这会使我们的代码很难看。 由于在文件 I / O 期间随时可能发生异常,因此我们应该将对文件的所有调用包装在try ... finally子句中。 不管 I / O 是否成功,都应在finally子句中关闭该文件。 这不是非常 Pythonic。 当然,还有一种更优雅的方法。
如果在类似文件的对象上运行dir,我们会看到它有两个名为__enter__和__exit__的特殊方法。 这些方法将文件对象转换为所谓的上下文管理器。 基本上,如果我们使用称为with语句的特殊语法,则将在执行嵌套代码之前和之后调用这些方法。 在文件对象上,__exit__方法可确保关闭文件,即使引发异常也是如此。 我们不再需要显式管理文件的关闭。 实际上,这是with语句的样子:
with open('filename') as file:
for line in file:
print(line, end='')
open调用返回一个文件对象,该对象具有__enter__和__exit__方法。 通过as子句将返回的对象分配给名为file的变量。 我们知道,当代码返回到外部缩进级别时,文件将关闭,并且即使引发异常也会发生这种情况。
with语句在标准库中需要执行启动或清除代码的多个位置中使用。 例如,urlopen调用返回一个对象,该对象可以在完成后的with语句中用于清理套接字。 执行语句后,线程模块中的锁可以自动释放锁。
大多数有趣,因为with语句可以应用于具有适当特殊方法的任何对象,因此我们可以在自己的框架中使用它。 例如,记住字符串是不可变的,但是有时您需要从多个部分构建一个字符串。 为了提高效率,通常是通过将组件字符串存储在列表中并在最后将它们连接起来来完成的。 让我们创建一个简单的上下文管理器,它允许我们构造一个字符序列,并在退出时自动将其转换为字符串:
class StringJoiner(list):
def __enter__(self):
return self
def __exit__(self, type, value, tb):
self.result = "".join(self)
此代码将上下文管理器所需的两个特殊方法添加到其继承的list类。 __enter__方法执行任何必需的设置代码(在这种情况下,没有任何设置代码),然后在with语句中的as之后返回将分配给变量的对象。 通常,就像我们在这里所做的那样,这仅仅是上下文管理器对象本身。 __exit__方法接受三个参数。 在正常情况下,这些值都被赋予None的值。 但是,如果with块内发生异常,则将它们设置为与异常的类型,值和追溯相关的值。 这使__exit__方法可以执行可能需要的任何清除代码,即使发生异常也是如此。 在我们的示例中,我们采用不负责任的路径并通过将字符串中的字符连接在一起来创建结果字符串,而不管是否引发了异常。
尽管这是我们可以编写的最简单的上下文管理器之一,并且其用途尚不确定,但它确实可以与with语句一起使用。 看一下它的作用:
import random, string
with StringJoiner() as joiner:
for i in range(15):
joiner.append(random.choice(string.ascii_letters))
print(joiner.result)
此代码构造了一个由 15 个随机字符组成的字符串。 使用从list继承的append方法将它们附加到StringJoiner。 当with语句超出范围(回到外部缩进级别)时,将调用__exit__方法,并且result属性在连接器对象上变为可用。 我们打印该值以查看随机字符串。
方法重载的替代方法
许多面向对象编程语言中的一个突出特征是称为方法重载的工具。 方法重载只是指具有多个名称相同的方法,它们接受不同的参数集。 在静态类型的语言中,如果我们想要一个可以接受整数或字符串的方法,这将非常有用。 在非面向对象的语言中,我们可能需要两个函数add_s和add_i来适应这种情况。 在静态类型的面向对象的语言中,我们需要两种方法,两种方法都称为add,一种方法接受字符串,而另一种方法接受整数。
在 Python 中,我们只需要一个方法即可接受任何类型的对象。 它可能必须对对象类型进行一些测试(例如,如果它是字符串,则将其转换为整数),但是仅需要一种方法。
但是,当我们希望具有相同名称的方法接受不同的数字或参数集时,方法重载也很有用。 例如,电子邮件方法可能有两个版本,其中一个版本接受“发件人”电子邮件地址的参数。 另一种方法可能会查找默认的“发件人”电子邮件地址。 Python 不允许使用多个具有相同名称的方法,但它确实提供了一个不同的,同样灵活的接口。
在前面的示例中,我们已经看到了一些向方法和函数发送参数的可能方法,但是现在我们将涵盖所有细节。 最简单的函数不接受任何参数。 我们可能不需要示例,但是为了完整起见,下面是一个示例:
def no_args():
pass
称呼为:
no_args()
确实接受参数的函数将在逗号分隔的列表中提供这些参数的名称。 只需要提供每个参数的名称。
调用函数时,必须按顺序指定这些位置参数,并且不能遗漏或跳过这些位置参数。 这是在前面的示例中指定参数的最常见方式:
def mandatory_args(x, y, z):
pass
调用它:
mandatory_args("a string", a_variable, 5)
任何类型的对象都可以作为参数传递:对象,容器,基元甚至函数和类。 前面的调用显示了一个硬编码的字符串,一个未知变量和一个传入函数的整数。
默认参数
如果我们希望使参数成为可选参数,而不是创建带有不同参数集的第二种方法,则可以在单个方法中使用等号指定默认值。 如果调用代码不提供此参数,则将为其分配默认值。 但是,调用代码仍然可以选择通过传递其他值来覆盖默认值。 通常,默认值为None或空字符串或列表都是合适的。
这是带有默认参数的函数定义:
def default_arguments(x, y, z, a="Some String", b=False):
pass
前三个参数仍然是必需的,并且必须由调用代码传递。 最后两个参数提供了默认参数。
有几种方法可以调用此函数。 我们可以按顺序提供所有参数,就像所有参数都是位置参数一样:
default_arguments("a string", variable, 8, "", True)
或者,我们可以仅按顺序提供必需参数,而将关键字参数保留为其默认值:
default_arguments("a longer string", some_variable, 14)
在调用函数以不同顺序提供值或跳过我们不感兴趣的默认值时,我们还可以使用等号语法。例如,我们可以跳过第一个关键字参数并提供第二个关键字参数:
default_arguments("a string", variable, 14, b=True)
令人惊讶的是,只要提供了所有位置参数,我们甚至可以使用等号语法来混合位置参数的顺序:
>>> default_arguments(y=1,z=2,x=3,a="hi")
3 1 2 hi False
有这么多的选项,似乎很难选择一个,但是如果您将位置参数视为有序列表,而将关键字参数视为像字典一样,则会发现正确的布局趋于下降 到位。 如果您需要要求调用者指定一个参数,则将其设为必需; 如果您有合理的默认值,则将其设为关键字参数。 选择方法的调用方法通常会自行处理,具体取决于需要提供哪些值,以及哪些可以保留为默认值。
关键字参数要注意的一件事是,我们作为默认参数提供的任何内容都是在首次解释该函数时(而不是在调用它时)进行评估的。 这意味着我们不能动态生成默认值。 例如,下面的代码将无法达到预期的效果:
number = 5
def funky_function(number=number):
print(number)
number=6
funky_function(8)
funky_function()
print(number)
如果我们运行此代码,它将首先输出数字 8,但随后将输出不带参数的调用的数字 5。 正如输出的最后一行所示,我们已将变量设置为数字 6,但是当调用函数时,将打印数字 5;否则,将输出 5。 默认值是在定义函数时计算的,而不是在调用函数时计算的。
使用空容器(例如列表,集合和字典)时,这很棘手。 例如,通常要求调用代码提供我们的函数将要操作的列表,但是该列表是可选的。 我们想将一个空列表作为默认参数。 我们不能这样做; 首次构建代码时,它将仅创建一个列表:
>>> def hello(b=[]):
... b.append('a')
... print(b)
...
>>> hello()
['a']
>>> hello()
['a', 'a']
糟糕,这并不是我们期望的! 解决此问题的常用方法是设置默认值None,然后在方法内部使用惯用语iargument = argument if argument else []。 密切关注!
变量参数列表
仅使用默认值并不能为我们提供方法重载的所有灵活好处。 使 Python 真正精巧的是能够编写无需任意命名位置或关键字参数的方法的能力。 我们还可以将任意列表和字典传递给此类函数。
例如,接受链接或链接列表并下载网页的功能可以使用此类可变参数或 varargs。 我们可以接受任意数量的参数,而每个参数是一个不同的链接,而不是接受一个期望的链接列表的单个值。 我们通过在函数定义中指定*运算符来做到这一点:
def get_pages(*links):
for link in links:
#download the link with urllib
print(link)
*links参数说:“我将接受任意数量的参数,并将它们全部放在名为links的列表中”。 如果我们仅提供一个参数,它将是一个包含一个元素的列表。 如果我们不提供任何参数,它将是一个空列表。 因此,所有这些函数调用均有效:
get_pages()
get_pages('http://www.archlinux.org')
get_pages('http://www.archlinux.org',
'http://ccphillips.net/')
我们还可以接受任意关键字参数。 这些作为字典进入函数。 它们在函数声明中带有两个星号(如**kwargs中所示)。 此工具通常在配置设置中使用。 下列类使我们可以指定一组具有默认值的选项:
class Options:
default_options = {
'port': 21,
'host': 'localhost',
'username': None,
'password': None,
'debug': False,
}
def __init__(self, **kwargs):
self.options = dict(Options.default_options)
self.options.update(kwargs)
def __getitem__(self, key):
return self.options[key]
此类中所有有趣的东西都在__init__方法中发生。 我们在类级别上有一个默认选项和值的字典。 __init__方法要做的第一件事是复制此词典。 我们这样做是为了避免实例化两组单独的选项,而不是直接修改字典。 (请记住,在类的实例之间共享类级别的变量。)然后,__init__使用新字典上的update方法将所有非默认值更改为作为关键字参数提供的值。 __getitem__方法仅允许我们使用索引语法来使用新类。 这是一个演示班级活动的课程:
>>> options = Options(username="dusty", password="drowssap",
debug=True)
>>> options['debug']
True
>>> options['port']
21
>>> options['username']
'dusty'
我们可以使用字典索引语法访问选项实例,并且字典既包含默认值,也包含使用关键字参数设置的值。
关键字参数语法可能很危险,因为它可能违反“显式优于隐式”规则。 在前面的示例中,可以将任意关键字参数传递给Options初始化程序,以表示默认字典中不存在的选项。 根据类的目的,这可能不是一件坏事,但这使使用该类的人很难发现可用的有效选项。 这也使输入混乱的错字(例如,“ Debug”而不是“ debug”)变得容易,该错字在只有一个应有的地方添加了两个选项。
当我们需要接受任意参数以传递给第二个函数时,关键字参数也非常有用,但是我们不知道这些参数将是什么。 当然,我们可以在一个函数调用中结合使用变量参数和变量关键字参数语法,并且也可以使用普通的位置参数和默认参数。 下面的示例有些人为设计,但演示了四种实际的类型:
import shutil
import os.path
def augmented_move(target_folder, *filenames,
verbose=False, **specific):
'''Move all filenames into the target_folder, allowing
specific treatment of certain files.'''
def print_verbose(message, filename):
'''print the message only if verbose is enabled'''
if verbose:
print(message.format(filename))
for filename in filenames:
target_path = os.path.join(target_folder, filename)
if filename in specific:
if specific[filename] == 'ignore':
print_verbose("Ignoring {0}", filename)
elif specific[filename] == 'copy':
print_verbose("Copying {0}", filename)
shutil.copyfile(filename, target_path)
else:
print_verbose("Moving {0}", filename)
shutil.move(filename, target_path)
本示例将处理文件的任意列表。 第一个参数是目标文件夹,默认行为是将所有剩余的非关键字参数文件移动到该文件夹中。 然后有一个仅关键字的参数verbose,它告诉我们是否在每个处理的文件上打印信息。 最后,我们可以提供一个字典,其中包含对特定文件名执行的操作; 默认行为是移动文件,但是如果在关键字参数中指定了有效的字符串操作,则可以忽略或复制它。 注意函数中参数的顺序; 首先指定位置参数,然后指定*filenames列表,然后指定所有仅关键字的参数,最后指定**specific词典来保存其余的关键字参数。
我们创建一个内部帮助程序功能print_verbose,仅在设置了verbose键的情况下才会打印消息。 通过将此功能封装到单个位置,该功能可保持代码可读性。
在常见情况下,假设存在相关文件,此函数可以称为:
>>> augmented_move("move_here", "one", "two")
该命令会将文件one和two移到move_here目录中(假设它们存在)(该函数中没有错误检查或异常处理,因此如果文件或目标目录未包含错误或失败) 不存在)。 由于默认情况下verbose为False,因此该移动将没有任何输出。
如果要查看输出,可以使用以下命令进行调用:
>>> augmented_move("move_here", "three", verbose=True)
Moving three
这将移动一个名为three的文件,并告诉我们它在做什么。 注意,在此示例中,不可能将verbose指定为位置参数。 我们必须传递关键字参数。 否则,Python 会认为它是*filenames列表中的另一个文件名。
如果我们要复制或忽略列表中的某些文件,而不是移动它们,则可以传递其他关键字参数:
>>> augmented_move("move_here", "four", "five", "six",
four="copy", five="ignore")
这将移动第六个文件并复制第四个文件,但由于我们未指定verbose,因此不会显示任何输出。 当然,我们也可以这样做,并且关键字参数可以按任何顺序提供:
>>> augmented_move("move_here", "seven", "eight", "nine",
seven="copy", verbose=True, eight="ignore")
Copying seven
Ignoring eight
Moving nine
解压缩参数
还有个更巧妙的技巧,涉及可变参数和关键字参数。 我们在之前的一些示例中使用了它,但是对于它进行解释永远不会太晚。 给定一个值列表或字典,我们可以将这些值传递给函数,就好像它们是常规的位置或关键字参数一样。 看一下这段代码:
def show_args(arg1, arg2, arg3="THREE"):
print(arg1, arg2, arg3)
some_args = range(3)
more_args = {
"arg1": "ONE",
"arg2": "TWO"}
print("Unpacking a sequence:", end=" ")
show_args(*some_args)
print("Unpacking a dict:", end=" ")
show_args(**more_args)
这是我们运行它时的样子:
Unpacking a sequence: 0 1 2
Unpacking a dict: ONE TWO THREE
该函数接受三个参数,其中一个具有默认值。 但是,当我们具有三个参数的列表时,可以在函数调用内使用*运算符将其解压缩为三个参数。 如果我们有一个参数字典,则可以使用**语法将其解压缩为关键字参数的集合。
当将已从用户输入或从外部源(例如,Internet 页面或文本文件)收集的信息映射到函数或方法调用时,这通常非常有用。
还记得我们之前的示例,该示例在文本文件中使用标题和行来创建包含联系人信息的词典列表吗? 除了将字典添加到列表中,我们还可以使用关键字解包将参数传递给接受相同参数集的特制Contact对象上的__init__方法。 看看您是否可以改编该示例以完成这项工作。
函数也是对象
过分强调面向对象原理的编程语言往往对那些不是方法的函数不满意。 在这种语言中,您应该创建一个对象来包装所涉及的单个方法。 在很多情况下,我们都希望传递一个被称为执行动作的小对象。 这通常在事件驱动的编程中完成,例如图形工具箱或异步服务器。
在 Python 中,我们不需要将此类方法包装在对象中,因为函数已经是对象! 我们可以在函数上设置属性(尽管这不是常见的活动),我们可以传递它们以供日后调用。 它们甚至具有一些可以直接访问的特殊属性。 这是另一个人为的例子:
def my_function():
print("The Function Was Called")
my_function.description = "A silly function"
def second_function():
print("The second was called")
second_function.description = "A sillier function."
def another_function(function):
print("The description:", end=" ")
print(function.description)
print("The name:", end=" ")
print(function.__name__)
print("The class:", end=" ")
print(function.__class__)
print("Now I'll call the function passed in")
function()
another_function(my_function)
another_function(second_function)
如果运行此代码,我们可以看到我们能够将两个不同的函数传递给我们的第三个函数,并且每个函数都得到不同的输出:
The description: A silly function
The name: my_function
The class: <class 'function'>
Now I'll call the function passed in
The Function Was Called
The description: A sillier function.
The name: second_function
The class: <class 'function'>
Now I'll call the function passed in
The second was called
我们在函数上设置了名为description的属性(公认的,它不是很好的描述)。 我们还能够看到该函数的__name__属性,并访问其类,这表明该函数实际上是具有属性的对象。 然后,我们通过使用可调用语法(括号)来调用该函数。
函数是顶级对象的事实通常用于传递它们,以便在以后某个日期执行(例如,当满足特定条件时)。 让我们构建一个事件驱动的计时器来执行以下操作:
import datetime
import time
class TimedEvent:
def __init__(self, endtime, callback):
self.endtime = endtime
self.callback = callback
def ready(self):
return self.endtime <= datetime.datetime.now()
class Timer:
def __init__(self):
self.events = []
def call_after(self, delay, callback):
end_time = datetime.datetime.now() + \
datetime.timedelta(seconds=delay)
self.events.append(TimedEvent(end_time, callback))
def run(self):
while True:
ready_events = (e for e in self.events if e.ready())
for event in ready_events:
event.callback(self)
self.events.remove(event)
time.sleep(0.5)
在生产中,此代码肯定应该有使用 docstrings 的额外文档! call_after方法至少应提及delay参数以秒为单位,并且callback函数应接受一个参数:计时器进行调用。
我们在这里有两个课程。 TimedEvent类并不是真的要被其他类访问; 它所做的只是存储endtime和callback。 我们甚至可以在此处使用tuple或namedtuple,但是由于为对象赋予行为以告知我们事件是否准备就绪的行为很方便,因此我们使用一个类。
Timer类仅存储即将发生的事件的列表。 它具有call_after方法来添加新事件。 此方法接受表示执行回调之前要等待的秒数的delay参数,以及callback函数本身:将在正确的时间执行的函数。 此callback函数应接受一个参数。
run方法非常简单; 它使用生成器表达式过滤掉时间到了的所有事件,并按顺序执行它们。 然后,计时器循环会无限期地继续,因此必须通过键盘中断来中断( Ctrl +C或 Ctrl + Break ) 。 每次迭代后,我们睡半秒钟,以免使系统停顿下来。
这里要注意的重要事项是涉及回调函数的行。 该函数像任何其他对象一样被传递,并且计时器从不知道或不在乎函数的原始名称是什么或定义它的位置。 该调用该函数时,计时器只需将括号语法应用于所存储的变量。
这是一组测试计时器的回调:
from timer import Timer
import datetime
def format_time(message, *args):
now = datetime.datetime.now().strftime("%I:%M:%S")
print(message.format(*args, now=now))
def one(timer):
format_time("{now}: Called One")
def two(timer):
format_time("{now}: Called Two")
def three(timer):
format_time("{now}: Called Three")
class Repeater:
def __init__(self):
self.count = 0
def repeater(self, timer):
format_time("{now}: repeat {0}", self.count)
self.count += 1
timer.call_after(5, self.repeater)
timer = Timer()
timer.call_after(1, one)
timer.call_after(2, one)
timer.call_after(2, two)
timer.call_after(4, two)
timer.call_after(3, three)
timer.call_after(6, three)
repeater = Repeater()
timer.call_after(5, repeater.repeater)
format_time("{now}: Starting")
timer.run()
此示例允许我们查看多个回调如何与计时器交互。 第一个功能是format_time功能。 它使用字符串format方法将当前时间添加到消息中,并说明了正在使用的可变参数。 format_time方法将使用可变参数语法接受任意数量的位置参数,然后将其作为位置参数转发给字符串的format方法。 此后,我们创建了三个简单的回调方法,它们仅输出当前时间和一条短消息,告诉我们已触发了哪个回调。
Repeater类演示方法也可以用作回调,因为它们实际上只是函数。 这也说明了为什么回调函数的timer参数很有用:我们可以从当前正在运行的回调中向计时器添加新的定时事件。 然后,我们创建一个计时器,并向其中添加几个在不同的时间量后调用的事件。 最后,我们启动计时器运行; 输出显示事件按预期顺序运行:
02:53:35: Starting
02:53:36: Called One
02:53:37: Called One
02:53:37: Called Two
02:53:38: Called Three
02:53:39: Called Two
02:53:40: repeat 0
02:53:41: Called Three
02:53:45: repeat 1
02:53:50: repeat 2
02:53:55: repeat 3
02:54:00: repeat 4
Python 3.4 引入了类似于的通用事件循环架构。
使用函数作为属性
函数作为对象的有趣的效果之一是,可以将它们设置为其他对象上的可调用属性。 可以向实例化对象添加或更改函数:
class A:
def print(self):
print("my class is A")
def fake_print():
print("my class is not A")
a = A()
a.print()
a.print = fake_print
a.print()
这段代码使用print方法创建了一个非常简单的类,该类不会告诉我们我们所不知道的任何内容。 然后,我们创建一个新函数,告诉我们一些我们不相信的东西。
当我们在A类的实例上调用print时,它的行为符合预期。 然后,如果我们将print方法设置为指向新函数,它将告诉我们一些不同的东西:
my class is A
my class is not A
也可以替换类而不是对象的方法,尽管在这种情况下,我们必须将self参数添加到参数列表中。 这将更改该对象的所有实例的方法,甚至是已经实例化的实例。 显然,替换这样的方法既危险又难以维护。 读过代码的人会看到已经调用了一个方法,并在原始类上查找了该方法。 但是原始类上的方法不是被调用的方法。 弄清实际发生的情况可能会成为棘手的,令人沮丧的调试会话。
它确实有其用途。 在自动化测试中,通常在运行时替换或添加方法(称为猴子补丁)。 如果要测试客户端服务器应用,我们可能不希望在测试客户端时实际连接到服务器。 这可能会导致资金意外转移或发送给真实人的尴尬测试电子邮件。 相反,我们可以设置测试代码来替换将请求发送到服务器的对象上的某些关键方法,因此它仅记录该方法已被调用。
猴子修补程序也可用于修复错误或在我们正在与之交互的第三方代码中添加功能,并且行为方式与我们所需的不完全相同。 但是,应谨慎使用; 它几乎总是“杂乱无章”。 但是有时候,这是适应现有库以满足我们需求的唯一方法。
可调用对象
正如函数是可以在其上设置属性的对象一样,可以创建一个可以作为函数调用的对象。
通过简单地给它一个接受所需参数的__call__方法,就可以使任何对象成为可调用的。 让我们通过计时器示例使Repeater类变得易于使用,方法是将其设为可调用的:
class Repeater:
def __init__(self):
self.count = 0
def __call__(self, timer):
format_time("{now}: repeat {0}", self.count)
self.count += 1
timer.call_after(5, self)
timer = Timer()
timer.call_after(5, Repeater())
format_time("{now}: Starting")
timer.run()
这个例子与之前的课程没有太大的不同。 我们所做的就是将repeater函数的名称更改为__call__,并将对象本身作为可调用对象传递。 请注意,当我们调用call_after时,将传递参数Repeater()。 这两个括号正在创建该类的新实例; 他们没有显式调用该类。 这稍后会在计时器内部发生。 如果要在新实例化的对象上执行__call__方法,则将使用一种比较奇怪的语法:Repeater()()。 第一组括号构造对象。 第二组执行__call__方法。 如果发现自己这样做,则可能是使用的不是正确的抽象。 如果要将该对象视为函数,则仅在该对象上实现__call__函数。
案例研究
为了将结合到本章中介绍的一些原理中,让我们构建一个邮件列表管理器。 管理员将跟踪分类为命名组的电子邮件地址。 当需要发送邮件时,我们可以选择一个组并将该邮件发送到分配给该组的所有电子邮件地址。
现在,在我们开始这个项目之前,我们应该有一种安全的方法来对其进行测试,而不必向一群真实的人发送电子邮件。 幸运的是,Python 在这里得到了支持。 像测试 HTTP 服务器一样,它具有内置的简单邮件传输协议(SMTP)服务器,我们可以指示捕获实际上没有发送的任何邮件。 我们可以使用以下命令运行服务器:
python -m smtpd -n -c DebuggingServer localhost:1025
在命令提示符处运行此命令将启动在本地计算机上的端口 1025 上运行的 SMTP 服务器。 但是,我们已指示它使用DebuggingServer类(内置 SMTP 模块随附),该类无需将邮件发送给目标收件人,而是在接收到它们时将它们打印在终端屏幕上。 整洁吧?
现在,在编写邮件列表之前,让我们编写一些实际发送邮件的代码。 当然,Python 在标准库中也支持此功能,但是它有点奇怪,因此我们将编写一个新函数来将所有内容包装干净:
import smtplib
from email.mime.text import MIMEText
def send_email(subject, message, from_addr, *to_addrs,
host="localhost", port=1025, **headers):
email = MIMEText(message)
email['Subject'] = subject
email['From'] = from_addr
for header, value in headers.items():
email[header] = value
sender = smtplib.SMTP(host, port)
for addr in to_addrs:
del email['To']
email['To'] = addr
sender.sendmail(from_addr, addr, email.as_string())
sender.quit()
我们不会过于详尽地介绍此方法中的代码。 标准库中的文档可以为您提供有效使用smtplib和email模块所需的所有信息。
我们在函数调用中同时使用了变量参数和关键字参数语法。 可变参数列表允许我们在具有单个to地址的默认情况下提供单个字符串,并允许在需要时提供多个地址。 任何其他关键字参数都将映射到电子邮件标题。 这是可变参数和关键字参数的令人兴奋的用法,但对于调用函数的人来说,这并不是一个很好的接口。 实际上,这使程序员想做的许多事情变得不可能。
传递给函数的标题代表可以附加到方法的辅助标题。 这样的标头可能包括Reply-To,Return-Path或 X 几乎任何东西。 但是,为了在 Python 中成为有效的标识符,名称不能包含-字符。 通常,该字符表示减法。 因此,无法使用Reply-To = my@email.com调用函数。 看来我们太急于使用关键字参数,因为它们是我们在本章刚刚学到的新工具。
我们必须将参数更改为普通字典。 这将起作用,因为任何字符串都可以用作字典中的键。 默认情况下,我们希望此字典为空,但不能将默认参数设为空字典。 因此,我们必须设置默认参数None,然后在方法开始处设置字典:
def send_email(subject, message, from_addr, *to_addrs,
host="localhost", port=1025, headers=None):
headers = {} if headers is None else headers
如果我们在一个终端上运行调试的 SMTP 服务器,则可以在 Python 解释器中测试以下代码:
>>> send_email("A model subject", "The message contents",
"from@example.com", "to1@example.com", "to2@example.com")
然后,如果检查调试 SMTP 服务器的输出,则会得到以下信息:
---------- MESSAGE FOLLOWS ----------
Content-Type: text/plain; charset="us-ascii"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Subject: A model subject
From: from@example.com
To: to1@example.com
X-Peer: 127.0.0.1
The message contents
------------ END MESSAGE ------------
---------- MESSAGE FOLLOWS ----------
Content-Type: text/plain; charset="us-ascii"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Subject: A model subject
From: from@example.com
To: to2@example.com
X-Peer: 127.0.0.1
The message contents
------------ END MESSAGE ------------
太好了,它已经将我们的电子邮件“发送”到了包含主题和消息内容的两个预期地址。 现在我们可以发送消息了,让我们在电子邮件组管理系统上工作。 我们需要一个对象,该对象以某种方式将电子邮件地址与它们所在的组进行匹配。由于这是多对多关系(任何一个电子邮件地址都可以位于多个组中;任何一个组都可以与之关联 多个电子邮件地址),但我们研究的数据结构似乎都不理想。 我们可以尝试使用与关联的电子邮件地址列表匹配的组名字典,但这将重复电子邮件地址。 我们也可以尝试将电子邮件地址的字典与组匹配,从而导致组重复。 两者都不是最佳选择。 让我们尝试使用后一个版本,即使直觉告诉我电子邮件地址组的解决方案会更简单。
由于我们词典中的值将始终是唯一电子邮件地址的集合,因此我们可能应将它们存储在set容器中。 我们可以使用defaultdict来确保每个键始终有一个set容器:
from collections import defaultdict
class MailingList:
'''Manage groups of e-mail addresses for sending e-mails.'''
def __init__(self):
self.email_map = defaultdict(set)
def add_to_group(self, email, group):
self.email_map[email].add(group)
现在,让我们添加一种方法,该方法允许我们收集一个或多个组中的所有电子邮件地址。 这可以通过将组列表转换为集合来完成:
def emails_in_groups(self, *groups):
groups = set(groups)
emails = set()
for e, g in self.email_map.items():
if g & groups:
emails.add(e)
return emails
首先,看看我们要迭代的内容:self.email_map.items()。 当然,此方法为字典中的每个项目返回键-值对的元组。 值是代表组的字符串集。 我们将它们分为两个变量e和g,这是电子邮件和组的缩写。 仅当传入的组与电子邮件地址组相交时,才将电子邮件地址添加到返回值集中。 g & groups语法是g.intersection(groups)的快捷方式; set类通过实现特殊的__and__方法来调用intersection来实现此目的。
现在,借助这些构建块,我们可以轻松地向MailingList类添加一个方法,该方法将消息发送到特定的组:
def send_mailing(self, subject, message, from_addr,
*groups, headers=None):
emails = self.emails_in_groups(*groups)
send_email(subject, message, from_addr,
*emails, headers=headers)
此函数依赖于变量参数列表。 作为输入,它将组列表作为变量参数。 它获取指定组的电子邮件列表,并将这些电子邮件作为变量参数以及传递给此方法的其他参数传递给send_email。
可以通过确保 SMTP 调试服务器在一个命令提示符下运行,然后在第二个提示符下使用以下命令加载代码来测试该程序:
python -i mailing_list.py
创建具有以下内容的MailingList对象:
>>> m = MailingList()
然后按照以下方式创建一些虚假的电子邮件地址和组:
>>> m.add_to_group("friend1@example.com", "friends")
>>> m.add_to_group("friend2@example.com", "friends")
>>> m.add_to_group("family1@example.com", "family")
>>> m.add_to_group("pro1@example.com", "professional")
最后,使用如下命令将电子邮件发送到特定的组:
>>> m.send_mailing("A Party",
"Friends and family only: a party", "me@example.com", "friends",
"family", headers={"Reply-To": "me2@example.com"})
发送到指定组中每个地址的电子邮件应显示在 SMTP 服务器上的控制台中。
邮件列表可以正常工作,但是没有用; 一旦退出程序,我们的信息数据库就会丢失。 让我们对其进行修改,以添加一些方法来加载和保存文件中的电子邮件组列表。
通常,在磁盘上存储结构化数据时,最好考虑一下如何存储数据。 存在大量数据库系统的原因之一是,如果其他人将这种思想纳入了数据存储方式中,则不必这样做。 对于此示例,让我们保持简单,并使用可能可行的第一个解决方案。
我想到的数据格式是存储每个电子邮件地址,后跟一个空格,后跟一个逗号分隔的组列表。 这种格式似乎是合理的,我们将继续使用它,因为数据格式化不是本章的主题。 但是,为了说明为什么您需要认真考虑如何格式化磁盘上的数据,让我们重点介绍一下格式上的一些问题。
首先,在电子邮件地址中,空格字符在技术上是合法的。 大多数电子邮件提供商都禁止这样做(有充分的理由),但是定义电子邮件地址的规范指出,如果电子邮件中带有引号,则可以包含空格。 如果要将空格用作数据格式的标记,从技术上讲,我们应该能够区分该空格和作为电子邮件一部分的空格。 为了简单起见,我们将假装这不是事实,但是现实生活中的数据编码充满了诸如此类的愚蠢问题。 其次,考虑以逗号分隔的组列表。 如果有人决定在组名中添加逗号会怎样? 如果决定使组名中的逗号不合法,则应在add_to_group方法中添加验证以确保这一点。 为了教学上的清晰,我们也将忽略此问题。 最后,我们需要考虑许多安全隐患:有人可以通过在电子邮件地址中添加假逗号来使自己陷入错误的群体吗? 如果解析器遇到无效文件,该怎么办?
讨论的重点是尝试使用经过现场测试的数据存储方法,而不是设计自己的数据序列化协议。 您可能会忽略很多奇怪的边缘情况,最好使用已经遇到并修复这些边缘情况的代码。
但是请记住,让我们编写一些基本代码,使用不健康的一厢情愿来假装这种简单的数据格式是安全的:
email1@mydomain.com group1,group2
email2@mydomain.com group2,group3
执行此操作的代码如下:
def save(self):
with open(self.data_file, 'w') as file:
for email, groups in self.email_map.items():
file.write(
'{} {}\n'.format(email, ','.join(groups))
)
def load(self):
self.email_map = defaultdict(set)
try:
with open(self.data_file) as file:
for line in file:
email, groups = line.strip().split(' ')
groups = set(groups.split(','))
self.email_map[email] = groups
except IOError:
pass
在 save方法中,我们在上下文管理器中打开文件,然后将文件写为格式化字符串。 记住换行符; Python 不会为我们添加它。 load方法首先使用for ... in语法(在文件的每一行上循环)来重置字典(如果字典包含来自先前调用load的数据)。 同样,换行符包含在 line 变量中,因此我们必须调用.strip()将其删除。
在使用这些方法之前,我们需要确保对象具有self.data_file属性,可以通过修改__init__来实现:
def __init__(self, data_file):
self.data_file = data_file
self.email_map = defaultdict(set)
我们可以在解释器中测试这两种方法,如下所示:
>>> m = MailingList('addresses.db')
>>> m.add_to_group('friend1@example.com', 'friends')
>>> m.add_to_group('family1@example.com', 'friends')
>>> m.add_to_group('family1@example.com', 'family')
>>> m.save()
生成的addresses.db文件包含以下几行,如预期的那样:
friend1@example.com friends
family1@example.com friends,family
我们还可以成功地将此数据加载回MailingList对象:
>>> m = MailingList('addresses.db')
>>> m.email_map
defaultdict(<class 'set'>, {})
>>> m.load()
>>> m.email_map
defaultdict(<class 'set'>, {'friend2@example.com': {'friends\n'}, 'family1@example.com': {'family\n'}, 'friend1@example.com': {'friends\n'}})
正如您所看到的,我忘记了执行load命令,也很容易忘记save命令。 为了使想要在自己的代码中使用我们的MailingList API 的用户更轻松一些,让我们提供支持上下文管理器的方法:
def __enter__(self):
self.load()
return self
def __exit__(self, type, value, tb):
self.save()
这些简单的方法只是委派了他们的工作来加载和保存,但是我们现在可以在交互式解释器中编写这样的代码,并且知道以前存储的所有地址都是以我们的名义加载的,并且整个列表将在保存时保存到文件中。 我们完了:
>>> with MailingList('addresses.db') as ml:
... ml.add_to_group('friend2@example.com', 'friends')
... ml.send_mailing("What's up", "hey friends, how's it going", 'me@example.com', 'friends')



九、字符串和序列化
在涉及更高级别的设计模式之前,让我们深入研究 Python 最常见的对象之一:字符串。 我们将看到字符串不仅仅满足您的需求,而且还涵盖了在字符串中搜索模式以及对数据进行序列化以进行存储或传输的内容。
特别是,我们将访问:
- 字符串,字节和字节数组的复杂性
- 字符串格式化的来龙去脉
- 序列化数据的几种方法
- 神秘的正则表达式
字符串
字符串是 Python 中的基本原语; 到目前为止,我们几乎在每个示例中都使用了它们。 他们所做的只是代表一个不变的字符序列。 但是,尽管您以前可能没有考虑过,但是“字符”这个词有点模棱两可; Python 字符串可以表示重音字符序列吗? 中国文字? 那希腊文,西里尔文或波斯文呢?
在 Python 3 中,答案是肯定的。 Python 字符串全部以 Unicode 表示,Unicode 是一种字符定义标准,实际上可以表示地球上任何语言的任何字符(以及某些组合语言和随机字符)。 在大多数情况下,这是无缝完成的。 因此,让我们将 Python 3 字符串视为 Unicode 字符的不可变序列。 那么我们如何处理这个不变的序列呢? 在前面的示例中,我们介绍了许多可操作字符串的方法,但让我们在一个地方快速介绍一下:字符串理论速成班!
字符串操作
如您所知,可以在 Python 中通过用单引号或双引号引起来的字符串序列来创建字符串。 可以使用三个引号字符轻松创建多行字符串,并且可以通过将多个硬编码字符串并排放置在一起来将它们串联在一起。 这里有些例子:
a = "hello"
b = 'world'
c = '''a multiple
line string'''
d = """More
multiple"""
e = ("Three " "Strings "
"Together")
解释器自动将最后一个字符串组成单个字符串。 也可以使用+运算符连接字符串(与"hello " + "world"一样)。 当然,不必对字符串进行硬编码。 它们也可以来自各种外部来源,例如文本文件,用户输入或在网络上编码。
注意
当缺少逗号时,相邻字符串的自动串联会导致一些搞笑的错误。 但是,当需要在函数调用中放置一个长字符串而不超过 Python 样式指南建议的 79 个字符的行长限制时,它非常有用。
像其他序列一样,可以对字符串进行迭代(逐个字符),进行索引,切片或连接。 语法与列表相同。
str类上有许多方法可以简化字符串的操作。 Python 解释器中的dir和help命令可以告诉我们如何使用所有这些命令。 我们将直接考虑一些更常见的问题。
几种布尔便捷方法可帮助我们识别字符串中的字符是否与特定模式匹配。 这是这些方法的摘要。 其中大多数,例如isalpha,isupper / islower和startswith / endswith都有明显的解释。 isspace方法也相当明显,但请记住,考虑了所有空白字符(包括制表符,换行符),而不仅仅是空格字符。
如果每个单词的首字符大写且所有其他字符均为小写,则istitle方法返回True。 请注意,它没有严格执行标题格式的英语语法定义。 例如,即使不是所有单词都大写,Leigh Hunt 的诗《手套和狮子》也应该是有效的标题。 即使最后一个单词的中间有一个大写字母,Robert Service 的“ Sam McGee 的火化”也应该是一个有效的标题。
使用isdigit,isdecimal和isnumeric方法时要小心,因为它们比您期望的要细微得多。 除了我们惯用的十位数字外,许多 Unicode 字符还被视为数字。 更糟糕的是,我们用来从字符串构造浮点数的句点字符不视为十进制字符,因此'45.2'.isdecimal()返回False。 实际的十进制字符由 Unicode 值 0660 表示,如 45.2(或45\u06602)所示。 此外,这些方法不验证字符串是否为有效数字;这些方法不适用。 对于所有三种方法,“ 127.0.0.1”都返回True。 我们可能认为应该为所有数值使用该十进制字符而不是句点,但是将该字符传递到float()或 i nt()构造函数中会将该十进制字符转换为零:
>>> float('45\u06602')
4502.0
对于模式匹配有用的其他方法不返回布尔值。 count方法告诉我们给定子字符串出现在字符串中的次数,而find,index,rfind和rindex告诉我们给定子字符串在原始字符串中的位置。 两种'r'(用于'right'或'reverse')方法从字符串的末尾开始搜索。 如果找不到子字符串,则find方法返回-1,而在这种情况下index会引发ValueError。 看看其中一些有效的方法:
>>> s = "hello world"
>>> s.count('l')
3
>>> s.find('l')
2
>>> s.rindex('m')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: substring not found
其余大多数字符串方法都返回字符串的转换。 upper,lower,capitalize和title方法创建具有给定格式的所有字母字符的新字符串。 translate方法可以使用词典将任意输入字符映射到指定的输出字符。
对于所有这些方法,请注意,输入字符串保持不变。 而是返回一个全新的str实例。 如果需要处理结果字符串,则应将其分配给新变量,如new_value = value.capitalize()所示。 通常,一旦执行了转换,就不再需要旧值,因此常见的习惯用法是将其分配给相同的变量,如value = value.title()中所示。
最后,几个字符串方法返回或对列表进行操作。 split方法接受一个子字符串,并将该字符串拆分为一个字符串列表,无论该子字符串出现在何处。 您可以将数字作为第二个参数来限制结果字符串的数量。 如果您不限制字符串的数量,则rsplit的行为与split相同,但是如果您提供限制,则它会从字符串的末尾开始拆分。 partition和rpartition 方法仅在子字符串的第一次出现或最后一次出现时拆分字符串,并返回三个值的元组:子字符串之前的字符,子字符串本身以及子字符串之后的字符。
作为split的逆函数,join方法接受字符串列表,并通过将原始字符串放在它们之间来返回所有组合在一起的字符串。 replace方法接受两个参数,并返回一个字符串,其中第一个参数的每个实例都已被第二个替换。 以下是一些有效的方法:
>>> s = "hello world, how are you"
>>> s2 = s.split(' ')
>>> s2
['hello', 'world,', 'how', 'are', 'you']
>>> '#'.join(s2)
'hello#world,#how#are#you'
>>> s.replace(' ', '**')
'hello**world,**how**are**you'
>>> s.partition(' ')
('hello', ' ', 'world, how are you')
在那里,对str类上最常见的方法进行了旋风之旅! 现在,让我们看一下 Python 3 的构成字符串和变量以创建新字符串的方法。
字符串格式
Python 3 具有强大的字符串格式化和模板化机制,可让我们构造由硬编码文本和散置变量组成的字符串。 我们在许多先前的示例中都使用过它,但是它比我们使用的简单格式说明符具有更多的用途。
通过调用format()方法,可以将任何字符串转换为格式字符串。 此方法返回一个新字符串,其中输入字符串中的特定字符已替换为作为传递给函数的参数和关键字参数提供的值。 format方法不需要一组固定的参数。 在内部,它使用我们在第 7 章和 Python 面向对象的快捷方式中讨论的*args和**kwargs语法。
在格式化的字符串中替换的特殊字符是大括号字符{和}。 我们可以在字符串中插入这些对,然后将它们按顺序替换为传递给str.format方法的任何位置参数:
template = "Hello {}, you are currently {}."
print(template.format('Dusty', 'writing'))
如果我们运行这些语句,它将用变量替换大括号,顺序是:
Hello Dusty, you are currently writing.
如果我们要重用一个字符串中的变量或决定在其他位置使用它们,则此基本语法并不是非常有用。 我们可以将零索引的整数放在花括号内,以告诉格式化程序哪个位置变量将插入字符串中的给定位置。 让我们重复这个名字:
template = "Hello {0}, you are {1}. Your name is {0}."
print(template.format('Dusty', 'writing'))
如果使用这些整数索引,则必须在所有变量中使用它们。 我们不能将空括号与位置索引混合使用。 例如,此代码失败,并带有适当的ValueError异常:
template = "Hello {}, you are {}. Your name is {0}."
print(template.format('Dusty', 'writing'))
逃脱括号
括号字符除格式化外,在字符串中通常也很有用。 我们需要一种在我们希望它们作为自己显示而不是被替换的情况下逃避它们的方法。 这可以通过将花括号加倍来完成。 例如,我们可以使用 Python 格式化基本的 Java 程序:
template = """
public class {0} {{
public static void main(String[] args) {{
System.out.println("{1}");
}}
}}"""
print(template.format("MyClass", "print('hello world')"));
无论我们在模板中的何处看到{{或}}序列,即包围 Java 类和方法定义的花括号,我们都知道format方法将用单个花括号代替它们,而不是将某些参数传递给 format方法。 这是输出:
public class MyClass {
public static void main(String[] args) {
System.out.println("print('hello world')");
}
}
类的名称和输出内容已替换为两个参数,而双括号已替换为单括号,从而为我们提供了有效的 Java 文件。 原来,这是关于打印最简单的 Java 程序的最简单的 Python 程序,而 Java 程序可以打印最简单的 Python 程序!
关键字参数
如果我们正在格式化复杂的字符串,那么如果我们选择插入新的参数,记住参数的顺序或更新模板可能会变得很乏味。 因此,format方法允许我们在花括号内指定名称而不是数字。 然后将已命名的变量作为关键字参数传递给format方法:
template = """
From: <{from_email}>
To: <{to_email}>
Subject: {subject}
{message}"""
print(template.format(
from_email = "a@example.com",
to_email = "b@example.com",
message = "Here's some mail for you. "
" Hope you enjoy the message!",
subject = "You have mail!"
))
我们还可以混合使用索引参数和关键字参数(与所有 Python 函数调用一样,关键字参数必须位于位置参数之后)。 我们甚至可以将未标记的位置括号与关键字参数混合使用:
print("{} {label} {}".format("x", "y", label="z"))
如预期的那样,此代码输出:
x z y
容器查找
我们不限于将简单的字符串变量传递给format方法。 可以打印任何基元,例如整数或浮点数。 更有趣的是,可以使用复杂的对象,包括列表,元组,字典和任意对象,并且我们可以从format字符串中访问这些对象的索引和变量(但不能访问方法)。
例如,如果由于某种原因,我们的电子邮件将“从”和“到”电子邮件地址分组为一个元组,并将主题和消息放在字典中(也许是因为这是现有send_mail函数所需要的输入 我们想要使用),我们可以像这样格式化它:
emails = ("a@example.com", "b@example.com")
message = {
'subject': "You Have Mail!",
'message': "Here's some mail for you!"
}
template = """
From: <{0[0]}>
To: <{0[1]}>
Subject: {message[subject]}
{message[message]}"""
print(template.format(emails, message=message))
模板字符串中括号内的变量看起来有些奇怪,所以让我们看一下它们在做什么。 我们传递了一个参数作为基于位置的参数,将一个参数作为关键字参数。 这两个电子邮件地址由0[x]查找,其中x为0或1。 与其他基于位置的参数一样,初始零表示传递给format(在这种情况下为emails元组)的第一个位置参数。
内部带有数字的方括号与我们在常规 Python 代码中看到的索引查找类型相同,因此0[0]映射到emails元组中的emails[0]。 索引语法可用于任何可索引的对象,因此,当我们访问message[subject]时,我们会看到类似的行为,只是这次我们正在字典中查找字符串键。 注意,与 Python 代码不同,我们不需要在字典查找中在字符串两边加上引号。
如果我们有嵌套的数据结构,我们甚至可以执行多级查找。 我建议不要经常这样做,因为模板字符串很快变得难以理解。 如果我们有一个包含元组的字典,我们可以这样做:
emails = ("a@example.com", "b@example.com")
message = {
'emails': emails,
'subject': "You Have Mail!",
'message': "Here's some mail for you!"
}
template = """
From: <{0[emails][0]}>
To: <{0[emails][1]}>
Subject: {0[subject]}
{0[message]}"""
print(template.format(message))
对象查找
索引使format查找功能强大,但是我们还没有完成! 我们还可以将任意对象作为参数传递,并使用点符号在这些对象上查找属性。 让我们再次将电子邮件数据更改为一次类:
class EMail:
def __init__(self, from_addr, to_addr, subject, message):
self.from_addr = from_addr
self.to_addr = to_addr
self.subject = subject
self.message = message
email = EMail("a@example.com", "b@example.com",
"You Have Mail!",
"Here's some mail for you!")
template = """
From: <{0.from_addr}>
To: <{0.to_addr}>
Subject: {0.subject}
{0.message}"""
print(template.format(email))
该示例中的模板可能比以前的示例更具可读性,但是创建电子邮件类的开销增加了 Python 代码的复杂性。 为了明确地将对象包括在模板中而创建一个类是愚蠢的。 通常,如果我们要格式化的对象已经存在,我们将使用这种查找。 在所有示例中都是正确的; 如果有元组,列表或字典,则将其直接传递到模板中。 否则,我们将只创建一组简单的位置和关键字参数。
使其看起来正确
能够在模板字符串中包含变量是很好的,但是有时变量需要一点强制性才能使它们在输出中看起来正确。 例如,如果我们使用货币进行计算,则可能会以不希望显示在模板中的长十进制结尾:
subtotal = 12.32
tax = subtotal * 0.07
total = subtotal + tax
print("Sub: ${0} Tax: ${1} Total: ${total}".format(
subtotal, tax, total=total))
如果我们运行以下格式代码,则输出看起来不太像正确的货币:
Sub: $12.32 Tax: $0.8624 Total: $13.182400000000001
注意
从技术上讲,我们永远不要在这样的货币计算中使用浮点数; 我们应该改为构造decimal.Decimal()对象。 浮点数很危险,因为它们的计算固有地超出了特定的精度水平。 但是我们只看字符串而不是浮点数,货币是格式化的一个很好的例子!
要修复前面的format字符串,我们可以在花括号内包含一些其他信息,以调整参数的格式。 我们可以自定义很多东西,但是花括号中的基本语法是相同的。 首先,我们使用较早的布局(位置,关键字,索引,属性访问)中的任何一种都适合于指定要放置在模板字符串中的变量。 我们在此之后加上一个冒号,然后是格式化的特定语法。 这是一个改进的版本:
print("Sub: ${0:0.2f} Tax: ${1:0.2f} "
"Total: ${total:0.2f}".format(
subtotal, tax, total=total))
冒号后面的0.2f格式说明符基本上从左到右说:对于小于 1 的值,请确保小数点的左侧显示零;对于小于 1 的值,请确保零。 小数点后两位; 将输入值格式化为浮点型。
我们还可以通过在精度的句点前放置一个值,来指定每个数字在屏幕上应占据特定数量的字符。 这对于输出表格数据很有用,例如:
orders = [('burger', 2, 5),
('fries', 3.5, 1),
('cola', 1.75, 3)]
print("PRODUCT QUANTITY PRICE SUBTOTAL")
for product, price, quantity in orders:
subtotal = price * quantity
print("{0:10s}{1: ^9d} ${2: <8.2f}${3: >7.2f}".format(
product, quantity, price, subtotal))
好的,这是一个看起来很吓人的格式字符串,因此在将其分解为可理解的部分之前,让我们看看它是如何工作的:
PRODUCT QUANTITY PRICE SUBTOTAL
burger 5 $2.00 $ 10.00
fries 1 $3.50 $ 3.50
cola 3 $1.75 $ 5.25
好漂亮! 那么,这实际上是怎么发生的呢? 在for循环的每一行中,我们有四个正在格式化的变量。 第一个变量是字符串,并使用{0:10s}格式化。 s表示它是一个字符串变量,10表示它应包含十个字符。 默认情况下,对于字符串,如果字符串短于指定的字符数,则会在字符串的右侧添加空格以使其足够长(但是请注意:如果原始字符串太长,则不会 被截断!)。 我们可以更改此行为(以填充其他字符或更改格式字符串中的对齐方式),就像对下一个值quantity一样。
quantity值的格式为{1: ^9d}。 d代表整数值。 9告诉我们该值应占用 9 个字符。 但是默认情况下,使用整数而不是空格,多余的字符为零。 看起来有点奇怪。 因此,我们明确指定一个空格(紧接在冒号之后)作为填充字符。 尖号^告诉我们,数字应在此可用填充的中心对齐; 这使该列看起来更加专业。 说明符的顺序必须正确,尽管它们都是可选的:首先填充,然后对齐,然后是大小,最后是类型。
我们对价格和小计的说明符执行类似的操作。 对于price,我们使用{2: <8.2f};对于subtotal,我们使用{3: >7.2f}。 在这两种情况下,我们都指定一个空格作为填充字符,但是我们分别使用<和>符号来表示数字应在最小 8 个空格内向左或向右对齐。 七个字符。 此外,每个浮点数应格式化为两位小数。
不同类型的“类型”字符也会影响格式输出。 我们已经看到s,d和f类型,用于字符串,整数和浮点数。 其他大多数格式说明符都是这些说明符的替代版本; 例如,o代表八进制格式,X代表十六进制整数。 n类型说明符可用于以当前语言环境的格式格式化整数分隔符。 对于浮点数,%类型将乘以 100,并将浮点数格式化为百分比。
虽然这些标准格式器适用于大多数内置对象,但其他对象也可以定义非标准说明符。 例如,如果将datetime对象传递给format,则可以使用datetime.strftime函数中使用的说明符,如下所示:
import datetime
print("{0:%Y-%m-%d %I:%M%p }".format(
datetime.datetime.now()))
甚至可以为我们自己创建的对象编写自定义格式器,但这超出了本模块的范围。 如果需要在代码中执行此操作,请研究重写__format__特殊方法。 可以在 PEP 3101 中找到最详尽的说明,尽管细节有些枯燥。 您可以使用网络搜索找到更多易消化的教程。
Python 格式化语法非常灵活,但是很难记住。 我每天都在使用它,但仍然偶尔需要在文档中查找被遗忘的概念。 它也不足以满足严重的模板需求,例如生成网页。 如果您需要做的不仅仅是一些字符串的基本格式设置,那么您可以研究几种第三方模板库。
字符串为 Unicode
在本节的开头,我们将字符串定义为不可变 Unicode 字符的集合。 实际上,这有时会使事情变得非常复杂,因为 Unicode 实际上并不是一种存储格式。 例如,如果您从文件或套接字获取一串字节,则它们将不会采用 Unicode。 实际上,它们将是内置类型bytes。 字节是……字节的不可变序列。 字节是计算中最低级别的存储格式。 它们代表 8 位,通常描述为 0 到 255 之间的整数,或 0 到 FF 之间的十六进制等值。 字节不代表任何特定内容; 字节序列可以存储编码字符串的字符或图像中的像素。
如果我们打印一个字节对象,则映射到 ASCII 表示形式的任何字节都将被打印为其原始字符,而非 ASCII 字节(无论是二进制数据还是其他字符)将被打印为\x转义序列转义的十六进制代码。 。 您可能会发现奇怪的是,以整数表示的字节可以映射到 ASCII 字符。 但是 ASCII 实际上只是一种代码,其中每个字母由不同的字节模式表示,因此由不同的整数表示。 字符“ a”由与整数 97 相同的字节表示,整数是十六进制数字 0x61。 具体地说,所有这些都是对二进制模式 01100001 的解释。
即使字节对象引用了文本数据,许多 I / O 操作也只知道如何处理bytes。 因此,至关重要的是要知道如何在bytes和 Unicode 之间进行转换。
问题是有很多方法可以将bytes映射到 Unicode 文本。 字节是机器可读的值,而文本是人类可读的格式。 位于两者之间的是一种编码,它将给定的字节序列映射到给定的文本字符序列。
但是,有多种此类编码(ASCII 只是其中之一)。 当使用不同的编码映射时,相同的字节序列表示完全不同的文本字符! 因此,必须使用与编码相同的字符集对bytes进行解码。 不知道如何解码字节就不可能从字节中获取文本。 如果我们收到不带指定编码的未知字节,那么我们最好的办法就是猜测它们的编码格式,这可能是错误的。
将字节转换为文本
如果从某个位置有一个bytes数组,则可以使用bytes类上的.decode方法将其转换为 Unicode。 此方法接受一个字符串作为字符编码的名称。 这样的名字有很多。 西方语言常用的语言包括 ASCII,UTF-8 和 latin-1。
字节序列(以十六进制表示)63 6c 69 63 68 e9 实际上代表了 latin-1 编码中的单词 cliché的字符。 以下示例将对字节序列进行编码,并使用 latin-1 编码将其转换为 Unicode 字符串:
characters = b'\x63\x6c\x69\x63\x68\xe9'
print(characters)
print(characters.decode("latin-1"))
第一行创建一个bytes对象; 字符串前面的b字符表明我们正在定义bytes对象,而不是普通的 Unicode 字符串。 在字符串中,每个字节使用十六进制数指定(在这种情况下)。 \x字符在字节字符串中转义,并且每个字符都说:“接下来的两个字符使用十六进制数字表示一个字节”。
假设我们使用的是可理解 latin-1 编码的外壳,则两个print调用将输出以下字符串:
b'clich\xe9'
cliché
第一个print语句将 ASCII 字符的字节本身呈现出来。 未知字符(ASCII 未知)就是转义的十六进制格式。 该输出在行首包含b字符,以提醒我们它是bytes表示形式,而不是字符串。
下一个调用使用 latin-1 编码对字符串进行解码。 decode方法返回带有正确字符的普通(Unicode)字符串。 但是,如果我们使用西里尔字母“ iso8859-5”编码对同一字符串进行了解码,那么最终将得到字符串'clichщ'! 这是因为\xe9字节映射到两种编码中的不同字符。
将文本转换为字节
如果我们需要将传入的字节转换为 Unicode,显然我们还会遇到将传出的 Unicode 转换为字节序列的情况。 这是通过str类上的encode方法完成的,该方法与decode方法一样,需要一个字符集。 以下代码创建一个 Unicode 字符串并将其编码为不同的字符集:
characters = "cliché"
print(characters.encode("UTF-8"))
print(characters.encode("latin-1"))
print(characters.encode("CP437"))
print(characters.encode("ascii"))
前三种编码为重音字符创建了一组不同的字节。 第四个甚至不能处理该字节:
b'clich\xc3\xa9'
b'clich\xe9'
b'clich\x82'
Traceback (most recent call last):
File "1261_10_16_decode_unicode.py", line 5, in <module>
print(characters.encode("ascii"))
UnicodeEncodeError: 'ascii' codec can't encode character '\xe9' in position 5: ordinal not in range(128)
您了解现在编码的重要性吗? 对于每种编码,重音字符表示为不同的字节; 如果在将字节解码为文本时使用错误的字符,则会得到错误的字符。
在最后一种情况下,异常并非始终是所需的行为。 在某些情况下,我们希望以不同的方式处理未知字符。 encode方法采用名为errors的可选字符串参数,该参数可以定义如何处理此类字符。 该字符串可以是以下之一:
strictreplaceignorexmlcharrefreplace
strict替换策略是我们刚刚看到的默认策略。 当遇到在请求的编码中没有有效表示形式的字节序列时,将引发异常。 使用replace策略时,该字符将替换为其他字符; 在 ASCII 中,它是一个问号; 其他编码可能使用不同的符号,例如一个空框。 ignore策略只是丢弃不了解的任何字节,而xmlcharrefreplace策略创建代表 Unicode 字符的xml实体。 当转换未知字符串以用于 XML 文档时,此功能很有用。 以下是每种策略如何影响我们的样词:
战略
|
“cliché” .encode(“ ascii”,策略)
|
| --- | --- |
| replace | b'clich?' |
| ignore | b'clich' |
| xmlcharrefreplace | b'cliché' |
可以在不传递编码字符串的情况下调用str.encode和bytes.decode方法。 该编码将被设置为当前平台的默认编码。 这将取决于当前的操作系统和区域设置或区域设置; 您可以使用sys.getdefaultencoding()功能进行查找。 不过,通常通常最好明确指定编码,因为平台的默认编码可能会更改,或者该程序有一天可能会扩展为可处理来自多种来源的文本。
如果您要编码文本,但不知道要使用哪种编码,则最好使用 UTF-8 编码。 UTF-8 可以代表任何 Unicode 字符。 在现代软件中,它是一种事实上的标准编码,可确保以任何语言(甚至多种语言)交换文档。 各种其他可能的编码对于遗留文档或默认情况下仍使用不同字符集的区域很有用。
UTF-8 编码使用 1 个字节表示 ASCII 和其他常见字符,使用 4 个字节表示更复杂的字符。 UTF-8 是特殊的,因为它与 ASCII 向后兼容。 使用 UTF-8 编码的任何 ASCII 文档将与原始 ASCII 文档相同。
注意
我永远都不记得要使用encode还是decode从二进制字节转换为 Unicode。 我一直希望这些方法分别命名为“ to_binary”和“ from_binary”。 如果您有相同的问题,请尝试将“ code”一词替换为“ binary”; “ enbinary”和“ debinary”非常接近“ to_binary”和“ from_binary”。 自从设计此助记符以来,通过不查找方法帮助文件节省了很多时间。
可变字节字符串
像str一样, bytes类型是不可变的。 我们可以在bytes对象上使用索引和切片符号,并搜索特定的字节序列,但不能扩展或修改它们。 在处理 I / O 时,这可能非常不方便,因为通常需要缓冲传入或传出的字节,直到准备好发送它们为止。 例如,如果我们正在从套接字接收数据,则在接收到完整的消息之前可能要进行几次recv调用。
这是内置bytearray的。此类型的行为类似于列表,但只包含字节。 该类的构造函数可以接受bytes对象来对其进行初始化。 extend方法可用于将另一个bytes对象附加到现有阵列(例如,当更多数据来自套接字或其他 I / O 通道时)。
切片符号可以在bytearray上用于内联修改项目。 例如,此代码从bytes对象构造一个bytearray,然后替换两个字节:
b = bytearray(b"abcdefgh")
b[4:6] = b"\x15\xa3"
print(b)
输出如下:
bytearray(b'abcd\x15\xa3gh')
当心; 如果我们要操作bytearray中的单个元素,它将期望我们传递一个介于 0 和 255 之间的整数作为值。 该整数表示特定的bytes模式。 如果我们尝试传递一个字符或bytes对象,它将引发异常。
可以使用ord(序数的缩写)功能将单字节字符转换为整数。 此函数返回单个字符的整数表示形式:
b = bytearray(b'abcdef')
b[3] = ord(b'g')
b[4] = 68
print(b)
输出如下:
bytearray(b'abcgDf')
在构造完数组之后,我们用字节 103 替换了索引3处的字符(第四个字符,因为索引从0开始,与列表一样)。此整数由ord函数返回,并且是 小写字母g的 ASCII 字符。 为了说明,我们还用字节号68替换了下一个字符,该字节号映射为大写D的 ASCII 字符。
bytearray类型的方法可以使其表现得像列表(例如,我们可以在其中附加整数字节),也可以像bytes对象一样; 我们可以像在bytes或str对象上使用相同的方式使用count和find之类的方法。 区别在于bytearray是可变类型,可用于从特定输入源建立复杂的字节序列。
正则表达式
您知道使用面向对象的原理实际上很难做什么? 解析字符串以匹配任意模式,就是这样。 有很多学术论文都使用面向对象设计来设置字符串解析,但是结果总是很冗长且难以阅读,并且在实践中并未得到广泛使用。
在现实世界中,大多数编程语言中的字符串解析都是由正则表达式处理的。 这些不是冗长的,但是,至少在您学习语法之前,它们还是很难阅读的。 即使正则表达式不是面向对象的,Python 正则表达式库也提供了一些类和对象,可用于构造和运行正则表达式。
正则表达式用于解决一个常见问题:给定一个字符串,确定该字符串是否与给定的模式匹配,并有选择地收集包含相关信息的子字符串。 它们可用于回答以下问题:
- 该字符串是有效的网址吗?
- 日志文件中所有警告消息的日期和时间是什么?
/etc/passwd中的哪些用户在给定组中?- 访问者键入的 URL 要求提供什么用户名和文档?
在中有许多类似的情况,其中正则表达式是正确的答案。 许多程序员错误地实现了复杂而脆弱的字符串解析库,因为他们不了解或不会学习正则表达式。 在本节中,我们将获得足够的正则表达式知识,不会犯此类错误!
匹配样式
正则表达式是复杂的迷你语言。 它们依靠特殊字符来匹配未知字符串,但让我们从始终与自身匹配的文字字符开始,例如字母,数字和空格字符。 让我们看一个基本的例子:
import re
search_string = "hello world"
pattern = "hello world"
match = re.match(pattern, search_string)
if match:
print("regex matches")
用于正则表达式的 Python 标准库模块称为re。 我们导入它并设置搜索字符串和模式以进行搜索; 在这种情况下,它们是相同的字符串。 由于搜索字符串与给定的模式匹配,因此条件传递通过并执行print语句。
请记住,match函数将模式匹配到字符串的开头。 因此,如果模式为"ello world",则不会找到匹配项。 由于存在混乱的不对称性,解析器在找到匹配项后立即停止搜索,因此模式"hello wo"成功匹配。 让我们构建一个小的示例程序来演示这些差异并帮助我们学习其他正则表达式语法:
import sys
import re
pattern = sys.argv[1]
search_string = sys.argv[2]
match = re.match(pattern, search_string)
if match:
template = "'{}' matches pattern '{}'"
else:
template = "'{}' does not match pattern '{}'"
print(template.format(search_string, pattern))
这是,只是先前示例的通用版本,它从命令行接受模式和搜索字符串。 我们可以看到模式的开头必须如何匹配,但是在以下命令行交互中找到匹配项后,将立即返回一个值:
$ python regex_generic.py "hello worl" "hello world"
'hello world' matches pattern 'hello worl'
$ python regex_generic.py "ello world" "hello world"
'hello world' does not match pattern 'ello world'
在接下来的几节中,我们将使用此脚本。 尽管始终使用命令行python regex_generic.py "<pattern>" "<string>"调用脚本,但为了节省空间,我们仅在以下示例中显示输出。
如果您需要控制项目是在行的开头还是结尾(或者如果字符串中没有换行,则在字符串的开头和结尾),可以使用^和$字符来控制 分别代表字符串的开头和结尾。 如果您想要一个模式来匹配整个字符串,则最好将这两个都包括在内:
'hello world' matches pattern '^hello world$'
'hello worl' does not match pattern '^hello world$'
匹配所选字符
让我们从匹配任意字符的开始。 在正则表达式模式中使用句点字符时,可以匹配任何单个字符。 在字符串中使用句点意味着您不必关心字符是什么,只需要在那里有一个字符即可。 例如:
'hello world' matches pattern 'hel.o world'
'helpo world' matches pattern 'hel.o world'
'hel o world' matches pattern 'hel.o world'
'helo world' does not match pattern 'hel.o world'
注意最后一个示例如何不匹配,因为模式中句点的位置没有字符。
很好,但是如果我们只想匹配几个特定的字符怎么办? 我们可以将一组字符放在方括号内,以匹配这些字符中的任何一个。 因此,如果我们在正则表达式模式中遇到字符串[abc],我们知道这五个字符(包括两个方括号)将仅与要搜索的字符串中的一个字符匹配,此外,这个字符将是 a,b或c。 看几个例子:
'hello world' matches pattern 'hel[lp]o world'
'helpo world' matches pattern 'hel[lp]o world'
'helPo world' does not match pattern 'hel[lp]o world'
这些方括号集应被命名为字符集,但是它们通常被称为字符类。 通常,我们希望在这些集合中包含大范围的字符,而全部键入它们可能是单调且容易出错的。 幸运的是,正则表达式设计师想到了这一点,并给了我们捷径。 字符集中的破折号将创建一个范围。 如果要按以下方式匹配“所有小写字母”,“所有字母”或“所有数字”,这将特别有用:
'hello world' does not match pattern 'hello [a-z] world'
'hello b world' matches pattern 'hello [a-z] world'
'hello B world' matches pattern 'hello [a-zA-Z] world'
'hello 2 world' matches pattern 'hello [a-zA-Z0-9] world'
还有其他匹配或排除单个字符的方法,但是如果要查找它们是什么,则需要通过网络搜索找到更全面的教程!
转义字符
如果在模式中将的句点字符与任何任意字符匹配,我们如何只匹配字符串中的句点? 一种方法可能是将句点放在方括号内以创建字符类,但更通用的方法是使用反斜杠对其进行转义。 这是一个正则表达式,用于匹配介于 0.00 和 0.99 之间的两位十进制数字:
'0.05' matches pattern '0\.[0-9][0-9]'
'005' does not match pattern '0\.[0-9][0-9]'
'0,05' does not match pattern '0\.[0-9][0-9]'
对于此模式,两个字符\.与单个.字符匹配。 如果句号字符丢失或为其他字符,则不匹配。
反斜杠转义序列用于正则表达式中的各种特殊字符。 您可以使用\[插入方括号而不启动字符类,并且可以使用\(插入括号,稍后我们还将看到这也是一个特殊字符。
更有趣的是,我们还可以使用转义符号后跟一个字符来表示特殊字符,例如换行符(\n)和制表符(\t)。 此外,可以使用转义字符串更简洁地表示某些字符类。 \s代表空格字符,\w代表字母,数字和下划线,\d代表数字:
'(abc]' matches pattern '\(abc\]'
' 1a' matches pattern '\s\d\w'
'\t5n' does not match pattern '\s\d\w'
'5n' matches pattern '\s\d\w'
匹配多个字符
使用此信息,我们可以匹配大多数已知长度的字符串,但是大多数时候我们不知道在一个模式中要匹配多少个字符。 正则表达式也可以解决这一问题。 我们可以通过添加几个难以记住的标点符号之一来匹配多个字符来修改模式。
星号(*)字符表示先前的模式可以匹配零次或多次。 这听起来可能很愚蠢,但这是最有用的重复字符之一。 在探讨原因之前,请考虑一些愚蠢的示例,以确保我们了解它的作用:
'hello' matches pattern 'hel*o'
'heo' matches pattern 'hel*o'
'helllllo' matches pattern 'hel*o'
因此,模式中的*字符表示先前的模式(l字符)是可选的,并且如果存在,则可以重复多次以匹配该模式。 其余字符(h,e和o)必须恰好出现一次。
多次匹配一个字母是很少见的,但是如果我们将星号与匹配多个字符的模式结合使用,就会变得更加有趣。 例如,.*将匹配任何字符串,而[a-z]*则匹配任何小写单词集合,包括空字符串。
例如:
'A string.' matches pattern '[A-Z][a-z]* [a-z]*\.'
'No .' matches pattern '[A-Z][a-z]* [a-z]*\.'
'' matches pattern '[a-z]`.`'
模式中的加号(+)的行为类似于星号。 它指出前一个模式可以重复一次或多次,但是与星号不同,它不是可选的。 问号(?)可确保图案显示正确的次数为零或一,但不能多次。 让我们通过玩数字来探索其中的一些(记住\d与[0-9]匹配相同的字符类:
'0.4' matches pattern '\d+\.\d+'
'1.002' matches pattern '\d+\.\d+'
'1.' does not match pattern '\d+\.\d+'
'1%' matches pattern '\d?\d%'
'99%' matches pattern '\d?\d%'
'999%' does not match pattern '\d?\d%'
将样式分组在一起
到目前为止,我们已经看到了如何重复多次模式,但是我们在可以重复哪些模式方面受到限制。 如果我们想重复单个字符,我们已经介绍了,但是如果我们想重复一个字符序列怎么办? 将任何模式集括在括号中,可以在应用重复操作时将它们视为单个模式。 比较这些模式:
'abccc' matches pattern 'abc{3}'
'abccc' does not match pattern '(abc){3}'
'abcabcabc' matches pattern '(abc){3}'
结合复杂的模式,此分组功能大大扩展了我们的模式匹配功能。 这是一个匹配简单英语句子的正则表达式:
'Eat.' matches pattern '[A-Z][a-z]*( [a-z]+)*\.$'
'Eat more good food.' matches pattern '[A-Z][a-z]*( [a-z]+)*\.$'
'A good meal.' matches pattern '[A-Z][a-z]*( [a-z]+)*\.$'
第一个单词以大写字母开头,然后是零个或多个小写字母。 然后,我们输入一个与单个空格匹配的括号,后跟一个或多个小写字母的单词。 整个括号被重复零次或更多次,并且该模式以句点终止。 句点后不能有任何其他字符,如$与字符串结尾匹配。
我们已经看到了许多最基本的模式,但是正则表达式语言支持更多。 头几年,我每次使用需要执行某些操作时都使用正则表达式来查找语法。 值得为re模块的 Python 文档添加书签,并经常对其进行复审。 正则表达式几乎没有什么不匹配的,它们应该是解析字符串时要使用的第一个工具。
从正则表达式获取信息
现在让我们将集中在 Python 方面。 正则表达式语法是面向对象编程中最详尽的东西。 但是,Python 的re模块提供了一个面向对象的接口,可以进入正则表达式引擎。
我们一直在检查re.match函数是否返回有效对象。 如果模式不匹配,则该函数返回None。 但是,如果匹配,它将返回一个有用的对象,我们可以对其进行内省以获取有关模式的信息。
到目前为止,我们的正则表达式已经回答了诸如“此字符串与该模式匹配吗?”之类的问题。 匹配模式很有用,但是在许多情况下,一个更有趣的问题是:“如果此字符串与该模式匹配,则相关子字符串的值是多少?” 如果使用组来标识稍后要引用的模式部分,则可以使它们脱离匹配返回值,如以下示例所示:
pattern = "^[a-zA-Z.]+@([a-z.]*\.[a-z]+)$"
search_string = "some.user@example.com"
match = re.match(pattern, search_string)
if match:
domain = match.groups()[0]
print(domain)
描述有效电子邮件地址的规范非常复杂,并且精确匹配所有可能性的正则表达式过长。 因此,我们作弊并制作了一个与某些常用电子邮件地址匹配的简单正则表达式; 关键是我们要访问域名(在@符号之后),以便我们可以连接到该地址。 通过将模式的该部分包装在括号中并在 match 返回的对象上调用groups()方法,可以轻松完成此操作。
groups方法返回模式内匹配的所有组的元组,您可以对其进行索引以访问特定值。 这些组从左到右排序。 但是,请记住,组可以嵌套,这意味着您可以在另一个组中包含一个或多个组。 在这种情况下,将按最左括号的顺序返回这些组,因此最外面的组将在其内部匹配的组之前返回。
除了匹配功能之外,re模块还提供了几个其他有用的功能search和findall。 search函数查找匹配模式的第一个实例,从而放宽了模式从字符串的第一个字母开始的限制。 请注意,通过使用 match 并将^.*字符放在模式的前面以匹配字符串开头和要查找的模式之间的任何字符,您可以获得类似的效果。
findall函数的行为与搜索类似,不同之处在于它会找到匹配模式的所有不重叠实例,而不仅仅是第一个实例。 基本上,它找到第一个匹配项,然后将搜索重置到该匹配字符串的末尾并找到下一个匹配项。
它不像您期望的那样返回匹配对象的列表,而是返回匹配字符串的列表。 或元组。 有时是字符串,有时是元组。 根本不是一个很好的 API! 与所有不良 API 一样,您必须记住差异,而不要凭直觉。 返回值的类型取决于正则表达式中带括号的组的数量:
- 如果模式中没有组,则
re.findall将返回一个字符串列表,其中每个值都是源字符串中与模式匹配的完整子字符串 - 如果模式中恰好有一组,
re.findall将返回一个字符串列表,其中每个值都是该组的内容 - 如果模式中有多个组,则
re.findall将返回一个元组列表,其中每个元组都包含一个匹配组中的值,按顺序
注意
在自己的 Python 库中设计函数调用时,请尝试使函数始终返回一致的数据结构。 设计可以接受任意输入并处理它们的函数通常是很好的,但是返回值不应根据输入从单个值切换为列表,或从值列表切换为元组列表。 让re.findall作为一个教训!
在下面的交互式会议中,示例将希望阐明这些差异:
>>> import re
>>> re.findall('a.', 'abacadefagah')
['ab', 'ac', 'ad', 'ag', 'ah']
>>> re.findall('a(.)', 'abacadefagah')
['b', 'c', 'd', 'g', 'h']
>>> re.findall('(a)(.)', 'abacadefagah')
[('a', 'b'), ('a', 'c'), ('a', 'd'), ('a', 'g'), ('a', 'h')]
>>> re.findall('((a)(.))', 'abacadefagah')
[('ab', 'a', 'b'), ('ac', 'a', 'c'), ('ad', 'a', 'd'), ('ag', 'a', 'g'), ('ah', 'a', 'h')]
使重复的正则表达式有效
每当调用正则表达式方法之一时,引擎就必须将模式字符串转换成内部结构,从而使搜索字符串快速。 这种转换耗时很短。 如果正则表达式模式将被多次重用(例如,在for或while循环内),则最好只执行一次此转换步骤。
使用re.compile方法可以做到这一点。 它返回正则表达式的面向对象版本,该正则表达式已被编译并具有我们已经探讨的方法(match,search和findall)。 在案例研究中,我们将看到一些示例。
这绝对是对正则表达式的简要介绍。 在这一点上,我们对基础知识有很好的感觉,并且会认识到何时需要进行进一步的研究。 如果我们遇到字符串模式匹配问题,则正则表达式几乎可以肯定会为我们解决它们。 但是,我们可能需要在该主题的更全面的介绍中查找新的语法。 但是现在我们知道要寻找什么! 让我们继续讨论另一个完全不同的主题:序列化数据以进行存储。
序列化对象
如今,我们具备将数据写入文件并在任意以后的日期取回数据的能力。 如此方便(想象一下如果我们什么也不能存储的计算状态!),我们经常发现自己将存储在一个好的对象或设计模式中的数据转换为某种笨拙的文本或二进制格式进行存储 ,通过网络传输或在远程服务器上进行远程调用。
Python pickle模块是一种面向对象的方法,可以直接以特殊的存储格式存储对象。 它实质上将一个对象(及其作为属性保存的所有对象)转换为可以存储或传输的字节序列,但是我们认为合适。
对于基础工作,pickle模块具有一个非常简单的界面。 它由四个基本功能组成,用于存储和加载数据。 两个用于操纵类似文件的对象,两个用于操纵bytes对象(后者只是类似文件的接口的快捷方式,因此我们不必自己创建BytesIO类似文件的对象)。
dump方法接受要写入的对象和类似文件的对象,以将序列化的字节写入其中。 该对象必须具有write方法(否则它不会像文件一样),并且该方法必须知道如何处理bytes参数(因此,为文本输出打开的文件将无效)。
load方法的作用恰恰相反。 它从类似文件的对象中读取序列化的对象。 该对象必须具有适当的类似文件的read和readline参数,当然每个参数都必须返回bytes。 pickle模块将从这些字节中加载对象,load方法将返回完全重建的对象。 这是一个在列表对象中存储然后加载一些数据的示例:
import pickle
some_data = ["a list", "containing", 5,
"values including another list",
["inner", "list"]]
with open("pickled_list", 'wb') as file:
pickle.dump(some_data, file)
with open("pickled_list", 'rb') as file:
loaded_data = pickle.load(file)
print(loaded_data)
assert loaded_data == some_data
此代码按公布的方式工作:将对象存储在文件中,然后从同一文件加载。 在每种情况下,我们都使用with语句打开文件,以使其自动关闭。 首先打开文件进行写入,然后第二次读取,具体取决于我们是存储还是加载数据。
如果新加载的对象不等于原始对象,则末尾的assert语句将引发错误。 平等并不意味着它们是同一对象。 确实,如果我们打印两个对象的id(),我们会发现它们是不同的。 但是,由于它们都是内容相同的列表,因此这两个列表也被视为相等。
dumps和loads函数的行为与文件类似的对象非常相似,不同之处在于它们返回或接受bytes而不是文件类对象。 dumps函数仅需要一个参数即要存储的对象,并且它返回一个序列化的bytes对象。 loads函数需要一个bytes对象并返回还原的对象。 方法名称中的's'字符是字符串的缩写; 它是 Python 古代版本的旧名称,其中使用str对象代替bytes。
两种dump方法都接受可选的protocol参数。 如果我们要保存和加载仅在 Python 3 程序中使用的腌制对象,则不需要提供此参数。 不幸的是,如果我们存储的对象可能由旧版本的 Python 加载,则必须使用较旧且效率较低的协议。 通常这不是问题。 通常,唯一会加载腌制对象的程序与存储该对象的程序相同。 Pickle 是一种不安全的格式,因此我们不希望将它不安全地通过 Internet 发送给未知的口译员。
提供的参数是整数版本号。 默认版本号为 3,表示 Python 3 酸洗使用的当前高效存储系统。 数字 2 是较旧的版本,它将存储一个对象,该对象可以在所有解释器中加载回 Python 2.3。 由于 2.6 是仍在野外广泛使用的最古老的 Python,因此版本 2 的酸洗通常就足够了。 较旧的解释器支持版本 0 和 1。 0 是 ASCII 格式,而 1 是二进制格式。 还有一个优化的版本 4,有一天可能会成为默认版本。
根据经验,如果您知道要酸洗的对象将仅由 Python 3 程序加载(例如,只有您的程序将被加载),请使用默认的酸洗协议。 如果它们可能是由未知的解释程序加载的,请传递协议值 2,除非您真的相信它们可能需要由 Python 的原始版本加载。
如果确实将协议传递给dump或dumps,则应使用关键字参数来指定它:pickle.dumps(my_object, protocol=2)。 这不是严格必要的,因为该方法仅接受两个参数,但是键入 full 关键字参数会提醒我们代码的读者,数字的用途是什么。 在方法调用中具有随机整数将很难读取。 两个什么? 存储对象的两个副本,也许吗? 请记住,代码应始终可读。 在 Python 中,较少的代码通常比较长的代码更具可读性,但并非总是如此。 要明确。
可以在一个打开的文件上多次调用dump或load。 每次对dump的调用都将存储一个对象(以及它所组成或包含的任何对象),而对load的调用将仅加载并返回一个对象。 因此,对于单个文件,在存储对象时每个对dump的单独调用在以后恢复时都应该具有对load的关联调用。
自定义泡菜
对于大多数通用 Python 对象,腌制“是可行的”。 基本的原语(例如整数,浮点数和字符串)可以被腌制,任何容器对象(例如列表或字典)也可以被腌制,只要这些容器的内容也可以被腌制。 而且,重要的是,任何对象都可以被腌制,只要它的所有属性也是可腌制的。
那么,什么使属性无法拾取呢? 通常,它与对时间敏感的属性有关,将来无法加载。 例如,如果我们将开放的网络套接字,开放的文件,正在运行的线程或数据库连接作为属性存储在对象上,则对这些对象进行腌制是没有意义的。 当我们稍后尝试重新加载它们时,很多操作系统状态都将消失。 我们不能仅仅假装线程或套接字连接存在并使其显示! 不,我们需要以某种方式自定义此类瞬态数据的存储和还原方式。
这是一个每小时都会加载一次网页内容的类,以确保它们保持最新状态。 它使用threading.Timer类安排下一次更新:
from threading import Timer
import datetime
from urllib.request import urlopen
class UpdatedURL:
def __init__(self, url):
self.url = url
self.contents = ''
self.last_updated = None
self.update()
def update(self):
self.contents = urlopen(self.url).read()
self.last_updated = datetime.datetime.now()
self.schedule()
def schedule(self):
self.timer = Timer(3600, self.update)
self.timer.setDaemon(True)
self.timer.start()
url,contents和last_updated都是可腌制的,但是如果我们尝试腌制该类的实例,则self.timer实例会有点生气:
>>> u = UpdatedURL("http://news.yahoo.com/")
>>> import pickle
>>> serialized = pickle.dumps(u)
Traceback (most recent call last):
File "<pyshell#3>", line 1, in <module>
serialized = pickle.dumps(u)
_pickle.PicklingError: Can't pickle <class '_thread.lock'>: attribute lookup lock on _thread failed
这不是一个非常有用的错误,但似乎我们正在尝试使不应该的内容腌制。 那将是Timer实例; 我们在 schedule 方法中存储了对self.timer的引用,并且该属性无法序列化。
当pickle尝试序列化一个对象时,它只是尝试存储该对象的__dict__属性; __dict__是将对象上的所有属性名称映射为其值的字典。 幸运的是,在检查__dict__之前,pickle检查是否存在__getstate__方法。 如果是这样,它将存储该方法的返回值,而不是__dict__。
让我们向UpdatedURL类添加__getstate__方法,该方法仅返回__dict__的副本而无需计时器:
def __getstate__(self):
new_state = self.__dict__.copy()
if 'timer' in new_state:
del new_state['timer']
return new_state
如果我们现在腌制对象,它将不再失败。 我们甚至可以使用loads成功还原该对象。 但是,还原的对象没有 timer 属性,因此它不会像设计的那样刷新内容。 解开对象后,我们需要以某种方式创建新的计时器(以替换缺少的计时器)。
正如我们可能期望的那样,可以实现一种互补的__setstate__方法来自定义解酸。 此方法接受一个参数,即__getstate__返回的对象。 如果我们同时实现这两种方法,则不需要__getstate__返回字典,因为__setstate__将知道如何处理选择__getstate__返回的任何对象。 在我们的例子中,我们只想还原__dict__,然后创建一个新计时器:
def __setstate__(self, data):
self.__dict__ = data
self.schedule()
pickle模块非常灵活,并在需要时提供其他工具来进一步自定义酸洗过程。 但是,这些超出了本模块的范围。 我们介绍的工具足以满足许多基本的酸洗任务。 通常,要腌制的对象是相对简单的数据对象。 例如,我们不会腌制整个正在运行的程序或复杂的设计模式。
序列化 Web 对象
从未知或不受信任的来源加载腌制的对象不是的好主意。 可以向腌制的文件中注入任意代码,以通过腌制恶意攻击计算机。 泡菜的另一个缺点是它们只能由其他 Python 程序加载,而不能轻易与其他语言编写的服务共享。
多年来,已经有许多格式用于此目的。 XML(可扩展标记语言)曾经非常流行,特别是在 Java 开发人员中。 YAML(又一种标记语言)是您偶尔会看到的另一种格式。 表格数据经常以 CSV(逗号分隔值)格式进行交换。 随着时间的流逝,其中许多变得越来越模糊,您还会遇到更多。 Python 对所有这些都有可靠的标准或第三方库。
在对不受信任的数据使用此类库之前,请确保对它们进行安全调查。 例如,XML 和 YAML 都具有晦涩的功能,这些功能被恶意使用,可以允许在主机上执行任意命令。 默认情况下,可能无法关闭这些功能。 做你的研究。
JavaScript 对象符号(JSON)是用于交换原始数据的人类可读格式。 JSON 是一种标准格式,可以由各种各样的异构客户端系统解释。 因此,JSON 对于在完全解耦的系统之间传输数据非常有用。 此外,JSON 不支持可执行代码,只能对数据进行序列化。 因此,向其中注入恶意语句更加困难。
因为 JSON 可以很容易地被 JavaScript 引擎解释,所以它通常用于将数据从 Web 服务器传输到支持 JavaScript 的 Web 浏览器。 如果提供数据的 Web 应用是用 Python 编写的,则需要一种将内部数据转换为 JSON 格式的方法。
有一个模块可以执行此操作,可预测的名称为json。 该模块提供与pickle模块类似的接口,具有dump,load,dumps和loads功能。 对这些函数的默认调用与pickle中的调用几乎相同,因此我们不再赘述。 有一些区别; 显然,这些调用的输出是有效的 JSON 表示法,而不是腌制的对象。 另外,json功能对str对象而不是bytes操作。 因此,在转储到文件或从文件加载时,我们需要创建文本文件,而不是二进制文件。
JSON 序列化程序不如pickle模块强大。 它只能序列化基本类型,例如整数,浮点数和字符串,以及简单的容器,例如字典和列表。 它们中的每一个都直接映射到 JSON 表示形式,但是 JSON 无法表示类,方法或函数。 无法以这种格式传输完整的对象。 因为我们转储为 JSON 格式的对象的接收者通常不是 Python 对象,所以无论如何,它将无法以与 Python 相同的方式理解类或方法。 尽管名称中使用 O for Object,但 JSON 是数据表示法; 您还记得,对象由数据和行为组成。
如果的对象仅要序列化数据,则始终可以序列化对象的__dict__属性。 或者,我们可以通过提供自定义代码从某些类型的对象创建或解析 JSON 可序列化字典来半自动化此任务。
在json模块中,对象存储和加载函数均接受可选参数以自定义行为。 dump和dumps方法接受名称不正确的cls(类的缩写,这是一个保留关键字)关键字参数。 如果通过,则它应该是JSONEncoder类的子类,并且default方法将被覆盖。 此方法接受任意对象,并将其转换为json可以消化的字典。 如果它不知道如何处理该对象,则应调用super()方法,以便它可以照常使用序列化基本类型。
load和loads方法也接受这样的cls参数,它可以是逆类JSONDecoder的子类。 但是,通常使用object_hook关键字参数将函数传递给这些方法就足够了。 该函数接受字典并返回一个对象; 如果它不知道如何处理输入字典,则可以不加修改地将其返回。
让我们来看一个例子。 假设我们有以下简单的联系人类要序列化:
class Contact:
def __init__(self, first, last):
self.first = first
self.last = last
@property
def full_name(self):
return("{} {}".format(self.first, self.last))
我们可以序列化__dict__属性:
>>> c = Contact("John", "Smith")
>>> json.dumps(c.__dict__)
'{"last": "Smith", "first": "John"}'
但是以这种方式访问特殊(双下划线)属性有点粗糙。 另外,如果接收代码(也许在网页上有一些 JavaScript)希望提供full_name属性该怎么办? 当然,我们可以手动构造字典,但是让我们创建一个自定义编码器:
import json
class ContactEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, Contact):
return {'is_contact': True,
'first': obj.first,
'last': obj.last,
'full': obj.full_name}
return super().default(obj)
default方法基本上检查以查看我们要序列化的对象的种类。 如果是联系人,我们会手动将其转换为词典; 否则,我们让父类处理序列化(假设它是json知道如何处理的基本类型)。 请注意,我们传递了一个额外的属性来将该对象标识为联系人,因为在加载时将无法告知。 这只是一个惯例; 对于更通用的序列化机制,将字符串类型存储在字典中甚至是完整的类名(包括包和模块)可能更有意义。 请记住,字典的格式取决于接收端的代码。 必须就如何指定数据达成协议。
通过将类(而不是实例化的对象)传递给dump或dumps函数,我们可以使用此类对联系人进行编码:
>>> c = Contact("John", "Smith")
>>> json.dumps(c, cls=ContactEncoder)
'{"is_contact": true, "last": "Smith", "full": "John Smith",
"first": "John"}'
为了进行解码,我们可以编写一个接受字典并检查is_contact变量是否存在的函数,以确定是否将其转换为联系人:
def decode_contact(dic):
if dic.get('is_contact'):
return Contact(dic['first'], dic['last'])
else:
return dic
我们可以使用object_hook关键字参数将此函数传递给load或loads函数:
>>> data = ('{"is_contact": true, "last": "smith",'
'"full": "john smith", "first": "john"}')
>>> c = json.loads(data, object_hook=decode_contact)
>>> c
<__main__.Contact object at 0xa02918c>
>>> c.full_name
'john smith'
案例研究
让我们用 Python 构建一个基本的以正则表达式为动力的模板引擎。 该引擎将解析文本文件(例如 HTML 页面),并将某些指令替换为从输入到这些指令所计算出的文本。 这是我们要使用正则表达式执行的最复杂的任务。 实际上,对此的完整版本可能会使用适当的语言解析机制。
考虑以下输入文件:
/** include header.html **/
<h1>This is the title of the front page</h1>
/** include menu.html **/
<p>My name is /** variable name **/.
This is the content of my front page. It goes below the menu.</p>
<table>
<tr><th>Favourite Books</th></tr>
/** loopover module_list **/
<tr><td>/** loopvar **/</td></tr>
/** endloop **/
</table>
/** include footer.html **/
Copyright © Today
该文件包含/** <directive> <data> **/形式的“标签”,其中数据是可选的单个单词,伪指令为:
include:在此处复制另一个文件的内容variable:在此处插入变量的内容loopover:对作为列表的变量重复循环的内容endloop:表示循环文本结束loopvar:从要循环的列表中插入单个值
此模板将呈现一个不同的页面,具体取决于将哪些变量传递到其中。 这些变量将从所谓的上下文文件中传入。 这将被编码为json对象,其中的键代表所讨论的变量。 我的上下文文件可能看起来像这样,但是您将派生自己的文件:
{
"name": "Dusty",
"module_list": [
"Thief Of Time",
"The Thief",
"Snow Crash",
"Lathe Of Heaven"
]
}
在进行实际的字符串处理之前,让我们将一些面向对象的样板代码汇总在一起,以处理文件并从命令行获取数据:
import re
import sys
import json
from pathlib import Path
DIRECTIVE_RE = re.compile(
r'/\*\*\s*(include|variable|loopover|endloop|loopvar)'
r'\s*([^ *]*)\s*\*\*/')
class TemplateEngine:
def __init__(self, infilename, outfilename, contextfilename):
self.template = open(infilename).read()
self.working_dir = Path(infilename).absolute().parent
self.pos = 0
self.outfile = open(outfilename, 'w')
with open(contextfilename) as contextfile:
self.context = json.load(contextfile)
def process(self):
print("PROCESSING...")
if __name__ == '__main__':
infilename, outfilename, contextfilename = sys.argv[1:]
engine = TemplateEngine(infilename, outfilename, contextfilename)
engine.process()
这是的全部基本知识,我们创建一个类并使用在命令行中传递的一些变量对其进行初始化。
注意我们如何通过将正则表达式分为两行来使其更具可读性? 我们使用原始字符串(r 前缀),因此我们不必对所有反斜杠进行两次转义。 这在正则表达式中很常见,但仍然一团糟。 (正则表达式总是有用的,但它们通常是值得的。)
pos指示我们正在处理的内容中的当前字符; 我们稍后会看到更多。
现在,剩下的就是实现该处理方法了。 有几种方法可以做到这一点。 让我们以相当明确的方式进行操作。
处理方法必须找到与正则表达式匹配的每个指令,并对其进行适当的处理。 但是,它还必须注意在未修改输出文件的每个指令之前,之后和之间输出普通文本。
正则表达式的编译版本的一个好功能是,可以通过传递pos关键字参数来告诉search方法在特定位置开始搜索。 如果我们暂时将使用指令进行适当的工作定义为“忽略指令并将其从输出文件中删除”,则我们的过程循环看起来非常简单:
def process(self):
match = DIRECTIVE_RE.search(self.template, pos=self.pos)
while match:
self.outfile.write(self.template[self.pos:match.start()])
self.pos = match.end()
match = DIRECTIVE_RE.search(self.template, pos=self.pos)
self.outfile.write(self.template[self.pos:])
用英语来说,此函数在文本中查找与正则表达式匹配的第一个字符串,输出从当前位置到该匹配开始的所有内容,然后将位置前进到上述匹配的末尾。 一旦不匹配,它将输出自上一个位置以来的所有内容。
当然,在模板引擎中忽略该指令几乎是没有用的,因此让我们用该指令替换该位置前进行,该代码根据该指令委托给类上的其他方法:
def process(self):
match = DIRECTIVE_RE.search(self.template, pos=self.pos)
while match:
self.outfile.write(self.template[self.pos:match.start()])
directive, argument = match.groups()
method_name = 'process_{}'.format(directive)
getattr(self, method_name)(match, argument)
match = DIRECTIVE_RE.search(self.template, pos=self.pos)
self.outfile.write(self.template[self.pos:])
因此,我们从正则表达式中获取指令和单个参数。 该伪指令成为方法名称,我们在self对象上动态查找该方法名称(如果模板编写器提供了无效伪指令,此处会进行一些错误处理会更好)。 我们将 match 对象和参数传递给该方法,并假定该方法将适当处理所有事情,包括移动pos指针。
到目前为止,我们已经有了面向对象的体系结构,实现委托给它们的方法实际上非常简单。 include和variable指令非常简单:
def process_include(self, match, argument):
with (self.working_dir / argument).open() as includefile:
self.outfile.write(includefile.read())
self.pos = match.end()
def process_variable(self, match, argument):
self.outfile.write(self.context.get(argument, ''))
self.pos = match.end()
第一个简单地查找包含的文件并插入文件内容,第二个简单地在上下文字典中查找变量名(从__init__方法中的json加载),如果没有,则默认为空字符串 不存在。
三种处理循环的方法更为复杂,因为它们必须在三种方法之间共享状态。 为简单起见(我敢肯定您渴望看到本长长的章节的结尾,我们几乎已经到了!),我们将把它作为类本身的实例变量来处理。 作为练习,您可能想考虑更好的架构方法,尤其是在阅读了下三章之后。
def process_loopover(self, match, argument):
self.loop_index = 0
self.loop_list = self.context.get(argument, [])
self.pos = self.loop_pos = match.end()
def process_loopvar(self, match, argument):
self.outfile.write(self.loop_list[self.loop_index])
self.pos = match.end()
def process_endloop(self, match, argument):
self.loop_index += 1
if self.loop_index >= len(self.loop_list):
self.pos = match.end()
del self.loop_index
del self.loop_list
del self.loop_pos
else:
self.pos = self.loop_pos
当遇到loopover指令时,我们不必输出任何内容,但是必须在三个变量上设置初始状态。 假设loop_list变量是从上下文词典中提取的列表。 loop_index变量指示在循环的此迭代中应在该列表中的哪个位置输出,而loop_pos则被存储,因此我们知道到达循环末尾时要跳转到的位置。
loopvar伪指令输出loop_list变量当前位置的值,并跳至伪指令的末尾。 请注意,它不会增加循环索引,因为loopvar指令可以在循环内多次调用。
endloop指令更加复杂。 它确定loop_list中是否还有更多元素; 如果有的话,它只是跳回到循环的开始,增加索引。 否则,它将重置用于处理循环的所有变量,并跳转到指令的末尾,以便引擎可以继续进行下一个匹配。
请注意,这种特殊的循环机制非常脆弱。 如果模板设计者尝试嵌套循环或忘记了endloop调用,则对他们而言效果不佳。 我们将需要更多错误检查,并且可能想要存储更多循环状态才能使其成为生产平台。 但是我保证本章的结尾不远了,所以让我们在看一下示例模板及其上下文的呈现方式之后再开始练习:
<html>
<body>
<h1>This is the title of the front page</h1>
<a href="link1.html">First Link</a>
<a href="link2.html">Second Link</a>
<p>My name is Dusty.
This is the content of my front page. It goes below the menu.</p>
<table>
<tr><th>Favourite Books</th></tr>
<tr><td>Thief Of Time</td></tr>
<tr><td>The Thief</td></tr>
<tr><td>Snow Crash</td></tr>
<tr><td>Lathe Of Heaven</td></tr>
</table>
</body>
</html>
Copyright © Today
由于我们计划模板的方式,有些奇怪的换行效果,但是它按预期工作。




十、迭代器模式
我们已经讨论了多少 Python 的内置和习惯用法,乍一看似乎是非面向对象的,实际上是提供了对主要对象的访问。 在本章中,我们将讨论看起来如此结构化的for循环实际上是围绕一组面向对象原理的轻量级包装。 我们还将看到对该语法的各种扩展,这些扩展会自动创建更多类型的对象。 我们将介绍:
- 什么是设计模式
- 迭代器协议-最强大的设计模式之一
- 列表,集合和字典理解
- 发电机和协程
设计模式简介
当工程师和建筑师决定建造桥梁,塔楼或建筑物时,他们遵循某些原则以确保结构完整性。 桥梁有多种可能的设计(例如悬架或悬臂),但是如果工程师不使用一种标准设计,并且没有出色的新设计,则他/她设计的桥梁可能会 坍塌。
设计模式旨在将用于正确设计的结构的相同正式定义引入软件工程。 有许多不同的设计模式可以解决不同的一般问题。 创建设计模式的人首先会确定开发人员在各种情况下面临的常见问题。 然后,他们就面向对象设计提出了可能被认为是该问题的理想解决方案的建议。
但是,知道设计模式并选择在我们的软件中使用它并不能保证我们正在创建“正确的”解决方案。 1907 年,魁北克大桥(至今是世界上最长的悬臂桥)在施工完成之前倒塌,因为设计桥梁的工程师严重低估了用于建造它的钢材的重量。 同样,在软件开发中,我们可能会错误地选择或应用设计模式,并创建在正常操作情况下或在超出其原始设计限制的情况下会“崩溃”的软件。
任何一个设计模式都提出了一组以特定方式交互的对象,以解决一般问题。 程序员的职责是识别他们何时面对该问题的特定版本,并在其解决方案中适应常规设计。
在本章中,我们将介绍迭代器设计模式。 这种模式是如此强大和普遍,以至于 Python 开发人员提供了多种语法来访问该模式基础的面向对象原理。 在接下来的两章中,我们将介绍其他设计模式。 其中有些支持语言,有些则没有,但是它们都没有像迭代器模式那样固有地在 Python 编码器的日常生活中发挥作用。
迭代器
在典型的模式设计中,迭代器是使用next()方法和done()方法的对象; 如果序列中没有剩余项目,则后者返回True。 在没有内置支持迭代器的编程语言中,迭代器将像这样循环遍历:
while not iterator.done():
item = iterator.next()
# do something with the item
在 Python 中,迭代是一个特殊功能,因此该方法有一个特殊名称__next__。 可以使用内置的next(iterator)访问此方法。 迭代器协议使用StopIteration而不是done方法来通知循环它已完成。 最后,我们具有更具可读性的for item in iterator语法,可以实际访问迭代器中的项,而不必搞乱while循环。 让我们更详细地看看这些。
迭代器协议
collections.abc模块中的抽象基类Iterator定义了 Python 中的迭代器协议。 如前所述,它必须具有__next__方法,for循环(以及支持迭代的其他功能)可以调用该方法以从序列中获取新元素。 此外,每个迭代器还必须满足Iterable接口。 任何提供__iter__方法的类都是可迭代的。 该方法必须返回一个Iterator实例,该实例将覆盖该类中的所有元素。 由于迭代器已经在元素上循环,因此其__iter__函数通常会返回自身。
这听起来可能有些混乱,所以请看以下示例,但是请注意,这是解决此问题的非常冗长的方法。 它清楚地解释了迭代以及所涉及的两个协议,但是在本章后面,我们将研究几种更易读的方法来获得这种效果:
class CapitalIterable:
def __init__(self, string):
self.string = string
def __iter__(self):
return CapitalIterator(self.string)
class CapitalIterator:
def __init__(self, string):
self.words = [w.capitalize() for w in string.split()]
self.index = 0
def __next__(self):
if self.index == len(self.words):
raise StopIteration()
word = self.words[self.index]
self.index += 1
return word
def __iter__(self):
return self
本示例定义了一个CapitalIterable类,其工作是循环遍历字符串中的每个单词,并以首字母大写输出它们。 可迭代的大部分工作都传递给CapitalIterator实现。 与该迭代器进行交互的规范方法如下:
>>> iterable = CapitalIterable('the quick brown fox jumps over the lazy dog')
>>> iterator = iter(iterable)
>>> while True:
... try:
... print(next(iterator))
... except StopIteration:
... break
...
The
Quick
Brown
Fox
Jumps
Over
The
Lazy
Dog
此示例首先构造一个可迭代的对象,并从中检索一个迭代器。 区别可能需要解释; 可迭代的对象是具有可以循环的元素的对象。 通常,这些元素可以多次循环,甚至可以同时循环或以重叠代码循环。 另一方面,迭代器表示该迭代器中的特定位置。 有些物品已经消耗掉了,有些则没有。 两个不同的迭代器可能在单词列表中的不同位置,但是任何一个迭代器都只能标记一个位置。
每次在迭代器上调用next()时,它都会依次从迭代器中返回另一个令牌。 最终,迭代器将被耗尽(不再有其他要返回的元素),在这种情况下,将引发Stopiteration,我们就跳出了循环。
当然,我们已经知道从可迭代对象构造迭代器的语法要简单得多:
>>> for i in iterable:
... print(i)
...
The
Quick
Brown
Fox
Jumps
Over
The
Lazy
Dog
正如您所看到的,尽管for语句看上去并不十分面向对象,但实际上是某些明显的面向对象设计原则的捷径。 当我们讨论理解时,请记住这一点,因为它们也似乎与面向对象工具相反。 但是,它们使用与for循环完全相同的迭代协议,并且只是另一种捷径。
理解
理解是简单但功能强大的语法,使我们能够以最少的一行代码来转换或过滤可迭代的对象。 结果对象可以是完全正常的列表,集合或字典,也可以是可以一次性有效使用的生成器表达式。
列表理解
列表推导是 Python 中最强大的工具之一,因此人们倾向于将其视为高级工具。 他们不是。 确实,我已经自由地带着理解力乱扔先前的示例,并假设您会理解它们。 确实,高级程序员经常使用理解能力,但这并不是因为他们很先进,而是因为他们很琐碎,并且能够处理软件开发中最常见的一些操作。
让我们看一下其中的一种常见操作; 即,将项目列表转换为相关项目列表。 具体来说,假设我们只是从文件中读取字符串列表,现在我们想将其转换为整数列表。 我们知道列表中的每个项目都是整数,并且我们希望对这些数字进行一些活动(例如,计算平均值)。 这是一种简单的处理方法:
input_strings = ['1', '5', '28', '131', '3']
output_integers = []
for num in input_strings:
output_integers.append(int(num))
可以正常工作,并且只有三行代码。 如果您不习惯于理解,您甚至可能不会认为它看起来很丑! 现在,使用列表理解来查看相同的代码:
input_strings = ['1', '5', '28', '131', '3']output_integers = [int(num) for num in input_strings]
我们将简化为一行,并且为提高性能,重要的是,我们为列表中的每个项目都删除了append方法调用。 总体而言,即使您不习惯理解语法,也很容易知道发生了什么。
与以往一样,方括号表示我们正在创建列表。 该列表内部是一个for循环,该循环遍历输入序列中的每个项目。 唯一令人困惑的是列表的开头大括号与for循环的开始之间发生的情况。 输入列表中的每项都将在此处发生的任何情况应用于。 有问题的项目由循环中的num变量引用。 因此,它将每个单独的元素转换为int数据类型。
这就是基本列表理解的全部内容。 毕竟它们并不那么先进。 理解是高度优化的 C 代码; 遍历大量项目时,列表理解比for循环快得多。 如果仅凭可读性并不是说服人们尽可能多地使用它们的理由,那么应该提高速度。
将列表项转换为一个相关列表并不是我们对列表理解的唯一操作。 我们还可以通过在理解内添加if语句来选择排除某些值。 看一看:
output_ints = [int(n) for n in input_strings if len(n) < 3]
我将变量的名称从num缩短为n,并将结果变量缩短为output_ints,因此它仍然可以放在一行上。 除此之外,此示例与上一个示例的不同之处是if len(n) < 3部分。 此额外的代码排除了两个以上字符的字符串。 if语句在int函数之前应用,因此它正在测试字符串的长度。 由于我们的输入字符串本质上都是整数,因此它排除了 99 以上的任何数字。现在,所有列出的内容都将包含在内! 我们使用它们将输入值映射为输出值,并沿途应用过滤器以包含或排除任何满足特定条件的值。
任何可迭代的都可以作为列表理解的输入; 我们可以包装在for循环中的所有内容也可以放在理解内。 例如,文本文件是可迭代的。 文件的迭代器上对__next__的每次调用都将返回文件的一行。 我们可以使用zip函数将制表符分隔的文件(其中第一行是标题行)加载到字典中:
import sys
filename = sys.argv[1]
with open(filename) as file:
header = file.readline().strip().split('\t')
contacts = [
dict(
zip(header, line.strip().split('\t'))
) for line in file
]
for contact in contacts:
print("email: {email} -- {last}, {first}".format(
**contact))
这次,我添加了一些空格以使其更具可读性(列表理解的不能放在一行上)。 本示例从压缩的标题创建字典列表,并为文件中的每一行分割行。
嗯什么 如果该代码或解释没有意义,请不要担心。 这有点令人困惑。 列表理解在这里需要做大量工作,并且代码很难理解,阅读和维护。 这个例子表明列表理解并不总是最好的解决方案。 大多数程序员都同意for循环比该版本更具可读性。
提示
切记:请勿滥用我们提供的工具! 始终选择适合工作的正确工具,这总是编写可维护的代码。
集合和字典理解
理解力不仅限于列表。 我们可以使用带有大括号的类似语法来创建集合,并像一样创建字典。 让我们从集合开始。 创建集合的一种方法是将列表理解包装在set()构造函数中,该构造函数将其转换为集合。 但是,当我们可以直接创建一个集合时,为什么要在中间列表上浪费内存呢?
这是一个示例,该示例使用命名元组对作者/标题/类型三合会进行建模,然后检索以特定类型撰写的所有作者的集合:
from collections import namedtuple
Book = namedtuple("Book", "author title genre")
books = [
Book("Pratchett", "Nightwatch", "fantasy"),
Book("Pratchett", "Thief Of Time", "fantasy"),
Book("Le Guin", "The Dispossessed", "scifi"),
Book("Le Guin", "A Wizard Of Earthsea", "fantasy"),
Book("Turner", "The Thief", "fantasy"),
Book("Phillips", "Preston Diamond", "western"),
Book("Phillips", "Twice Upon A Time", "scifi"),
]
fantasy_authors = {
b.author for b in books if b.genre == 'fantasy'}
与演示数据设置相比,突出显示的设置理解力肯定很短! 如果我们使用列表推导,那么 Terry Pratchett 当然会被列出两次。按原样,集合的性质将删除重复项,最后得到:
>>> fantasy_authors
{'Turner', 'Pratchett', 'Le Guin'}
我们可以引入冒号来创建字典理解。 这将使用键将值转换为字典:对值。 例如,如果我们知道标题,则快速查找字典中的作者或体裁可能会很有用。 我们可以使用字典理解将标题映射到模块对象:
fantasy_titles = {
b.title: b for b in books if b.genre == 'fantasy'}
现在,我们有了字典,可以使用常规语法按标题查找书籍。
总之,理解不是高级 Python,也不是应避免的“非面向对象”工具。 它们只是从现有序列创建列表,集合或字典的一种更为简洁和优化的语法。
生成器表达式
有时我们想处理一个新序列而不将新列表,集合或字典放入系统内存中。 如果我们一次只遍历一个项目,而实际上并不在乎是否要创建最终的容器对象,那么创建该容器就是浪费内存。 一次处理一项时,我们只需要随时将当前对象存储在内存中。 但是,当我们创建一个容器时,所有对象都必须先存储在该容器中,然后才能开始处理它们。
例如,考虑一个处理日志文件的程序。 一个非常简单的日志可能包含以下格式的信息:
Jan 26, 2015 11:25:25 DEBUG This is a debugging message.
Jan 26, 2015 11:25:36 INFO This is an information method.
Jan 26, 2015 11:25:46 WARNING This is a warning. It could be serious.
Jan 26, 2015 11:25:52 WARNING Another warning sent.
Jan 26, 2015 11:25:59 INFO Here's some information.
Jan 26, 2015 11:26:13 DEBUG Debug messages are only useful if you want to figure something out.
Jan 26, 2015 11:26:32 INFO Information is usually harmless, but helpful.
Jan 26, 2015 11:26:40 WARNING Warnings should be heeded.
Jan 26, 2015 11:26:54 WARNING Watch for warnings.
流行的 Web 服务器,数据库或电子邮件服务器的日志文件可能包含许多 GB 的数据(我最近不得不从行为异常的系统中清除近 2 TB 的日志)。 如果要处理日志中的每一行,则不能使用列表推导。 它将创建一个包含文件中每一行的列表。 这可能不适合 RAM,并且可能会使计算机屈服,具体取决于操作系统。
如果在日志文件上使用for循环,则可以一次处理一行,然后再将下一行读入内存。 如果我们可以使用理解语法来达到相同的效果,那会不好吗?
这就是生成器表达式的用处。它们使用与理解相同的语法,但不会创建最终的容器对象。 要创建生成器表达式,请将理解内容包装在()中,而不是[]或{}中。
以下代码以以前介绍的格式解析日志文件,并输出仅包含WARNING行的新日志文件:
import sys
inname = sys.argv[1]
outname = sys.argv[2]
with open(inname) as infile:
with open(outname, "w") as outfile:
warnings = (l for l in infile if 'WARNING' in l)
for l in warnings:
outfile.write(l)
此程序在命令行上使用两个文件名,使用生成器表达式过滤掉警告(在这种情况下,它使用if语法,并且不修改该行),然后将警告输出到另一个 文件。 如果我们在示例文件中运行它,则输出如下所示:
Jan 26, 2015 11:25:46 WARNING This is a warning. It could be serious.
Jan 26, 2015 11:25:52 WARNING Another warning sent.
Jan 26, 2015 11:26:40 WARNING Warnings should be heeded.
Jan 26, 2015 11:26:54 WARNING Watch for warnings.
当然,使用这么短的输入文件,我们可以安全地使用列表推导,但是如果文件长数百万行,则生成器表达式将对内存和速度产生巨大影响。
生成器表达式通常在函数调用中最有用。 例如,我们可以在生成器表达式而不是列表上调用sum,min或max,因为这些函数一次处理一个对象。 我们只对结果感兴趣,而不对任何中间容器感兴趣。
通常,应尽可能使用生成器表达式。 如果我们实际上不需要列表,集合或字典,而只需要过滤或转换序列中的项目,则生成器表达式将是最有效的。 如果我们需要知道列表的长度,对结果进行排序,删除重复项或创建字典,则必须使用理解语法。
发电机
生成器表达式实际上也是一种理解。 他们将更高级的(这次确实更高级!)生成器语法压缩为一行。 更大的生成器语法看起来甚至比我们所见的面向对象要少,但是我们会再次发现,创建一种对象是一种简单的语法捷径。
让我们进一步介绍日志文件示例。 如果我们要从输出文件中删除WARNING列(由于它是多余的:此文件仅包含警告),我们有几种选项,具有不同的可读性。 我们可以使用生成器表达式来做到这一点:
import sys
inname, outname = sys.argv[1:3]
with open(inname) as infile:
with open(outname, "w") as outfile:
warnings = (l.replace('\tWARNING', '')
for l in infile if 'WARNING' in l)
for l in warnings:
outfile.write(l)
这是完全可读的,尽管我不想使表达式复杂得多。 我们也可以使用普通的for循环来完成此操作:
import sys
inname, outname = sys.argv[1:3]
with open(inname) as infile:
with open(outname, "w") as outfile:
for l in infile:
if 'WARNING' in l:
outfile.write(l.replace('\tWARNING', ''))
这是可以维持的,但是在很少的几行中缩进那么多的等级有点难看。 更令人震惊的是,如果我们想对这些行做一些不同的事情,而不仅仅是打印出来,我们还必须复制循环代码和条件代码。 现在让我们考虑一个没有任何捷径的真正面向对象的解决方案:
import sys
inname, outname = sys.argv[1:3]
class WarningFilter:
def __init__(self, insequence):
self.insequence = insequence
def __iter__(self):
return self
def __next__(self):
l = self.insequence.readline()
while l and 'WARNING' not in l:
l = self.insequence.readline()
if not l:
raise StopIteration
return l.replace('\tWARNING', '')
with open(inname) as infile:
with open(outname, "w") as outfile:
filter = WarningFilter(infile)
for l in filter:
outfile.write(l)
毫无疑问:它是如此丑陋且难以阅读,以至于您甚至无法分辨正在发生的事情。 我们创建了一个将文件对象作为输入的对象,并提供了像任何迭代器一样的__next__方法。
此__next__方法从文件中读取行,如果不是WARNING行,则将其丢弃。 当遇到WARNING行时,将其返回。 然后for循环将再次调用__next__以处理下一个WARNING行。 当我们的行数用完时,我们会举起StopIteration告诉循环我们已经完成了迭代。 与其他示例相比,它很丑陋,但功能也很强大。 现在我们有了一堂课,我们可以用它做任何想要的事情。
有了这些背景知识,我们终于可以看到发电机在起作用。 下一个示例将与上一个完全相同:它使用__next__方法创建一个对象,当该对象不在输入中时,该对象将引发StopIteration:
import sys
inname, outname = sys.argv[1:3]
def warnings_filter(insequence):
for l in insequence:
if 'WARNING' in l:
yield l.replace('\tWARNING', '')
with open(inname) as infile:
with open(outname, "w") as outfile:
filter = warnings_filter(infile)
for l in filter:
outfile.write(l)
好的,这很容易理解,也许……至少很短。 但是这里到底发生了什么,这毫无意义。 什么是yield?
实际上,yield是生成器的密钥。 当 Python 在函数中看到yield时,它将采用该函数并将其包装在一个对象中,这与前面的示例不同。 认为yield语句类似于return语句; 它退出函数并返回一行。 但是,与return不同的是,当再次调用该函数(通过next())时,它将在它停下的位置(在yield语句之后的行上)而不是在函数的开始处开始。 在此示例中,在yield语句之后没有行,因此它跳转到for循环的下一个迭代。 由于yield语句位于if语句内部,因此仅产生包含WARNING的行。
看起来这只是一个循环遍历的函数,它实际上是在创建一种特殊类型的对象,即生成器对象:
>>> print(warnings_filter([]))
<generator object warnings_filter at 0xb728c6bc>
我将一个空列表传递给该函数以充当迭代器。 该函数所做的全部工作就是创建并返回一个生成器对象。 该对象上具有__iter__和__next__方法,就像我们在上一个示例中创建的那样。 每当调用__next__时,生成器都会运行该函数,直到找到yield语句。 然后,它从yield返回值,下次调用__next__时,它将从中断处开始取值。
生成器的这种用法并不先进,但是如果您不知道该函数正在创建对象,那么它看起来就像魔术。 这个例子很简单,但是您可以通过在单个函数中多次调用yield来获得真正强大的效果。 生成器将仅在最近的yield处接收并继续到下一个。
从另一个可迭代项目获得收益
通常,当我们构建一个生成器函数时,我们最终会遇到以下情况:要从另一个可迭代对象中获取数据,可能是我们在生成器内部构造的列表理解或生成器表达式,或者可能是一些传递的外部项 进入功能。 通过循环遍历可迭代项并逐个生成每个项,这始终是可能的。 但是,在 Python 3.3 版中,Python 开发人员引入了新的语法,以使其更加优雅。
让我们稍微修改一下生成器示例,以便它接受文件名而不是接受行序列。 当将对象与特定范例联系在一起时,通常会对此感到不满意。 在可能的情况下,我们应该对迭代器进行操作作为输入。 这样,无论日志行来自文件,内存还是基于 Web 的日志聚合器,都可以使用相同的功能。 因此,出于教学原因,设计了以下示例。
此版本的代码说明,生成器可以在从另一个可迭代项(在这种情况下为生成器表达式)产生信息之前进行一些基本设置:
import sys
inname, outname = sys.argv[1:3]
def warnings_filter(infilename):
with open(infilename) as infile:
yield from (
l.replace('\tWARNING', '')
for l in infile
if 'WARNING' in l
)
filter = warnings_filter(inname)
with open(outname, "w") as outfile:
for l in filter:
outfile.write(l)
此代码将前面示例中的for循环组合到生成器表达式中。 注意,我如何将生成器表达式的三个子句(转换,循环和过滤器)放在单独的行上,以使其更具可读性。 还要注意,这种转换的作用还不够。 前面带有for循环的示例更具可读性。
因此,让我们考虑一个比其他示例更具可读性的示例。 构造一个可以从多个其他生成器产生数据的生成器可能很有用。 例如,itertools.chain函数从可迭代对象开始按顺序生成数据,直到它们全部用尽为止。 使用yield from语法可能太容易实现了,所以让我们考虑一个经典的计算机科学问题:走一棵普通的树。
通用树数据结构的常见实现是计算机的文件系统。 让我们在 Unix 文件系统中为一些文件夹和文件建模,以便我们可以使用yield from有效地遍历它们:
class File:
def __init__(self, name):
self.name = name
class Folder(File):
def __init__(self, name):
super().__init__(name)
self.children = []
root = Folder('')
etc = Folder('etc')
root.children.append(etc)
etc.children.append(File('passwd'))
etc.children.append(File('groups'))
httpd = Folder('httpd')
etc.children.append(httpd)
httpd.children.append(File('http.conf'))
var = Folder('var')
root.children.append(var)
log = Folder('log')
var.children.append(log)
log.children.append(File('messages'))
log.children.append(File('kernel'))
这个设置代码看起来需要很多工作,但是在实际的文件系统中,它将涉及更多的工作。 我们必须从硬盘读取数据并将其结构化为树。 然而,一旦进入内存,输出文件系统中每个文件的代码就会非常优雅:
def walk(file):
if isinstance(file, Folder):
yield file.name + '/'
for f in file.children:
yield from walk(f)
else:
yield file.name
如果此代码遇到目录,它将递归地要求walk()生成从属于其每个子级的所有文件的列表,然后生成所有这些数据以及其自己的文件名。 在遇到普通文件的简单情况下,它只是产生该名称。
顺便说一句,在不使用生成器的情况下解决上述问题非常棘手,以至于这个问题是常见的面试问题。 如果您按如下所示回答,请准备好让您的面试官印象深刻并有些恼怒,以至于您这么容易回答。 他们可能会要求您确切说明正在发生的事情。 当然,有了本章所遵循的原则,您将不会有任何问题。
在编写链接生成器时,yield from语法是的有用快捷方式,但更通常用于其他目的:通过协程传递数据。 我们将在第 13 章,“并发”中看到许多这样的示例,但是现在让我们发现协程是什么。
协程
协程是非常强大的构造,通常与生成器混淆。 许多作者不恰当地将协程描述为“带有一些额外语法的生成器”。 这是一个容易犯的错误,就像在 Python 2.5 中那样,当引入协程时,它们被表示为“我们在生成器语法中添加了send方法”。 当您在 Python 中创建协程时,返回的对象是生成器,这一事实使情况更加复杂。 实际上,差异更加细微了,在您看到一些示例后,这将变得更加有意义。
注意
虽然 Python 中的协程目前与生成器语法紧密相关,但它们只是与我们一直在讨论的迭代器协议表面上相关。 即将发布的 Python 3.5 发行版使协同程序成为一个真正的独立对象,并将提供与之协同工作的新语法。
要记住的另一件事是,协程很难理解。 它们并没有在野外经常使用,您可能会跳过本节而愉快地使用 Python 开发多年,而不会丢失甚至遇到它们。 有几个库广泛使用协程(主要用于并发或异步编程),但是通常编写它们的目的是使您可以在不真正了解协程工作的情况下使用协程! 因此,如果您在本节中迷路,请不要绝望。
但是,通过研究以下示例,您不会迷路。 这是最简单的协程之一。 它使我们能够保持可以通过任意值增加的运行记录:
def tally():
score = 0
while True:
increment = yield score
score += increment
这段代码看起来像黑魔法,可能行不通,因此在逐行描述之前,我们将看到它起作用。 这个简单的对象可以由棒球队的计分应用使用。 每个团队都可以保留单独的得分,并且每半局结束时,他们的得分可以增加累积的跑步次数。 看一下这个互动环节:
>>> white_sox = tally()
>>> blue_jays = tally()
>>> next(white_sox)
0
>>> next(blue_jays)
0
>>> white_sox.send(3)
3
>>> blue_jays.send(2)
2
>>> white_sox.send(2)
5
>>> blue_jays.send(4)
6
首先,我们构造两个tally对象,每个团队一个。 是的,它们看起来像函数,但是和上一节中的生成器对象一样,函数内部有yield语句这一事实告诉 Python 付出了很大的努力才能将简单的函数转换为对象。
然后,我们在每个协程对象上调用next()。 这与在任何生成器上调用 next 相同,也就是说,它将执行每一行代码,直到遇到yield语句,然后返回该点的值,然后暂停直到 下一个next()通话。
到目前为止,没有什么新的。 但是,请回顾一下协程中的yield语句:
increment = yield score
与生成器不同,此 yield 函数看起来应该返回值并将其分配给变量。 实际上,这就是正在发生的事情。 协程仍在yield语句处暂停,并等待再次调用next()再次激活。
或者更确切地说,正如您在交互式会话中看到的一样,是调用名为send()的方法。 除了将生成器前进到下一个yield语句之外,send()方法与next()的作用完全相同。 它还允许您从生成器外部传递值。 此值分配给yield语句的左侧。
对于许多人来说,真正令人困惑的是发生这种情况的顺序:
yield发生并且发生器暂停send()从功能外部出现,发生器唤醒- 发送的值分配给
yield语句的左侧 - 生成器继续处理,直到遇到另一个
yield语句为止
因此,在此特定示例中,在构造协程并通过调用next()将其推进到yield语句之后,每次对send()的后续调用都将一个值传递到协程中,该值将该值添加到其分数中 ,返回到while循环的顶部,并继续进行处理,直到达到yield语句为止。 yield语句返回一个值,该值成为对send的最新调用的返回值。 不要错过:send()方法不仅向生成器提交值,还像next()一样从即将到来的yield语句中返回值。 这就是我们定义生成器和协程之间的区别的方式:生成器仅产生值,而协程也可以消耗它们。
注意
next(i),i.__next__()和i.send(value)的行为和语法非常不直观且令人沮丧。 第一个是普通函数,第二个是特殊方法,最后一个是普通方法。 但是这三个都做同样的事情:使生成器前进,直到产生一个值并暂停。 此外,可以通过调用i.send(None)复制next()函数和相关方法。 在这里拥有两个不同的方法名称是有价值的,因为它可以帮助我们的代码阅读者轻松查看它们是与协程还是生成器进行交互。 我只是发现以下事实:在一种情况下,这是一个函数调用,而在另一种情况下,这是一种普通的方法,有些恼人。
返回日志解析
当然,可以使用几个整数变量并在其上调用x += increment轻松地对前面的示例进行编码。 让我们看一下第二个示例,其中协程实际上为我们节省了一些代码。 这个示例是我在实际工作中必须解决的问题的简化版本(出于教学原因)。 它在逻辑上是从前面有关处理日志文件的讨论中得出的,这完全是偶然的; 这些示例是为该模块的第一版编写的,而四年后出现了这个问题!
Linux 内核日志包含一些看上去有些象但并不完全象样的行,这与下面的行不同:
unrelated log messages
sd 0:0:0:0 Attached Disk Drive
unrelated log messages
sd 0:0:0:0 (SERIAL=ZZ12345)
unrelated log messages
sd 0:0:0:0 [sda] Options
unrelated log messages
XFS ERROR [sda]
unrelated log messages
sd 2:0:0:1 Attached Disk Drive
unrelated log messages
sd 2:0:0:1 (SERIAL=ZZ67890)
unrelated log messages
sd 2:0:0:1 [sdb] Options
unrelated log messages
sd 3:0:1:8 Attached Disk Drive
unrelated log messages
sd 3:0:1:8 (SERIAL=WW11111)
unrelated log messages
sd 3:0:1:8 [sdc] Options
unrelated log messages
XFS ERROR [sdc]
unrelated log messages
有一堆散布的内核日志消息,其中一些消息与硬盘有关。 硬盘消息可能会散布在其他消息中,但是它们以可预测的格式和顺序出现,其中具有已知序列号的特定驱动器与总线标识符(例如0:0:0:0)和块设备标识符相关联 (例如sda)与该总线关联。 最后,如果驱动器的文件系统损坏,它可能会因 XFS 错误而失败。
现在,在给定前面的日志文件的情况下,我们需要解决的问题是如何获取所有带有 XFS 错误的驱动器的序列号。 数据中心技术人员以后可能会使用该序列号来标识和更换驱动器。
我们知道可以使用正则表达式来识别各个行,但是我们在循环遍历这些行时必须更改正则表达式,因为我们将根据之前的发现寻找不同的东西。 另一个困难的一点是,如果我们找到一个错误字符串,则有关哪条总线包含该字符串以及该总线上的驱动器附带的序列号的信息已得到处理。 通过以相反的顺序遍历文件的行可以轻松解决此问题。
在查看此示例之前,请注意—基于协程的解决方案所需的代码量很少:
import re
def match_regex(filename, regex):
with open(filename) as file:
lines = file.readlines()
for line in reversed(lines):
match = re.match(regex, line)
if match:
regex = yield match.groups()[0]
def get_serials(filename):
ERROR_RE = 'XFS ERROR (\[sd[a-z]\])'
matcher = match_regex(filename, ERROR_RE)
device = next(matcher)
while True:
bus = matcher.send(
'(sd \S+) {}.*'.format(re.escape(device)))
serial = matcher.send('{} \(SERIAL=([^)]*)\)'.format(bus))
yield serial
device = matcher.send(ERROR_RE)
for serial_number in get_serials('EXAMPLE_LOG.log'):
print(serial_number)
此代码将作业整齐地分为两个单独的任务。 第一个任务是遍历所有行并吐出与给定正则表达式匹配的所有行。 第二项任务是与第一个任务进行交互,并提供有关在任何给定时间应该搜索哪个正则表达式的指导。
首先查看match_regex协程。 请记住,它在构造时不会执行任何代码; 相反,它只是创建一个协程对象。 一旦构建,协程外部的某个人最终将调用next()来开始运行代码,这时它将存储两个变量filename和regex的状态。 然后,它读取文件中的所有行,并以相反的顺序遍历它们。 将每一行与传入的正则表达式进行比较,直到找到匹配项。 找到匹配项后,协程将从正则表达式中产生第一组并等待。
在将来的某些点,其他代码将发送一个新的正则表达式进行搜索。 注意协程从不关心它要匹配的正则表达式。 它只是遍历行并将它们与正则表达式进行比较。 决定要提供什么正则表达式是其他人的责任。
在这种情况下,其他人是get_serials生成器。 它不在乎文件中的行,实际上它甚至不知道它们。 它要做的第一件事是从match_regex协程构造函数创建一个matcher对象,并为其提供默认的正则表达式进行搜索。 它将协程前进到其第一个yield,并存储它返回的值。 然后进入一个循环,该循环指示匹配器对象根据所存储的设备 ID 搜索总线 ID,然后根据该总线 ID 搜索序列号。
在指示匹配器找到另一个设备 ID 并重复该循环之前,它会闲置地将序列号输出到for外部。
基本上,协程(match_regex,因为它使用regex = yield语法)作业是在文件中搜索下一个重要行,而生成器的[get_serial,它使用yield语法而没有分配)。 决定哪条线很重要。 生成器具有有关此特定问题的信息,例如文件中将显示哪些订单行。 另一方面,协程可以插入任何需要在文件中搜索给定正则表达式的问题。
关闭协程并引发异常
普通生成器通过升高StopIteration发出信号从内部退出。 如果我们将多个生成器链接在一起(例如,通过从另一个生成器内部迭代一个生成器),则StopIteration异常将向外传播。 最终,它将到达for循环,该循环将看到异常并知道该退出循环了。
协程通常不遵循迭代机制。 通常不将数据拉入直到遇到异常,而是通常将数据压入其中(使用send)。 进行推送的实体通常是负责告知协程完成时间的实体。 它通过在相关协程上调用close()方法来实现。
调用时,close()方法会在协程为等待发送值时引发GeneratorExit异常。协程通常将其yield语句包装在try ... finally块,以便可以执行任何清理任务(例如关闭关联的文件或套接字)。
如果需要在协程内部引发异常,则可以类似的方式使用throw()方法。 它接受带有可选value和traceback参数的异常类型。 当我们在一个协程中遇到异常并希望在维持回溯的同时在相邻协程中发生异常时,后者非常有用。
如果您要构建强大的基于协程的库,那么这两个功能都是至关重要的,但是在日常编码工作中我们不太可能遇到它们。
协程,生成器和函数之间的关系
我们已经看到了协程的实际作用,所以现在让我们回到有关它们与生成器的关系的讨论。 在 Python 中(通常如此),区别非常模糊。 实际上,所有协程都是生成器对象,并且作者经常将这两个术语互换使用。 有时,他们将协程描述为生成器的子集(只有从 yield 返回值的生成器才被视为协程)。 正如我们在上一节中所见,在技术上,这在 Python 中是正确的。
但是,在理论计算机科学的更大范围内,协程被认为是更通用的原理,而生成器是协程的一种特定类型。 此外,正常功能是协程的另一个不同子集。
协程是一种例程,可以使数据在一个或多个点处传入并在一个或多个点处将其取出。 在 Python 中,数据传入和传出的点是yield语句。
函数或子例程是协程的最简单类型。 您可以在某一点传递数据,而在函数返回时在另一点获取数据。 一个函数可以有多个return语句,但是对于该函数的任何给定调用,只能调用其中一个。
最后,生成器是一种协程,可以在某一点传递数据,但可以在多个点传递数据。 在 Python 中,数据将在yield语句中传递出去,但是您不能传递回数据。如果调用send,则数据将被静默丢弃。
因此,从理论上讲,生成器是协程的类型,函数是协程的类型,并且有些协程既不是函数也不是生成器。 这很简单,是吗? 那么为什么在 Python 中感觉更复杂?
在 Python 中,生成器和协程都是使用类似于我们正在构造函数的语法构造的。 但是生成的对象根本不是一个函数。 这是完全不同的对象。 当然,功能也是对象。 但是它们具有不同的界面。 函数是可调用的并返回值,生成器使用next()提取数据,协程使用send推送数据。
案例研究
数据科学是 Python 在当今最流行的领域之一。 让我们实现基本的机器学习算法! 机器学习是一个巨大的话题,但是总体思路是通过使用从过去的数据中获得的知识来对未来的数据进行预测或分类。 此类算法的用途十分广泛,数据科学家每天都在寻找新的方法来应用机器学习。 一些重要的机器学习应用包括计算机视觉(例如图像分类或面部识别),产品推荐,识别垃圾邮件和语音识别。 我们来看一个更简单的问题:给定 RGB 颜色定义,人类将这个颜色标识为什么名字?
在标准 RGB 颜色空间中,有超过 1600 万种颜色,而人类只想出了其中一小部分的名字。 尽管有成千上万个名称(有些很荒谬;可以去任何汽车经销商或化妆品店),但让我们建立一个分类器,尝试将 RGB 空间划分为基本颜色:
- 红色的
- 紫色的
- 蓝色
- 绿色的
- 黄色的
- 橘子
- 灰色的
- 白色的
- 粉色的
我们需要的第一件事是训练我们的算法的数据集。 在生产系统中,您可能会刮擦颜色网站列表或调查数千人。 取而代之的是,我创建了一个简单应用,该应用呈现随机颜色并要求用户选择上述九个选项之一来对其进行分类。 该应用包含在kivy_color_classifier目录中的本章示例代码中,但是我们将不讨论此代码的细节,因为它的唯一目的是生成示例数据。
注意
Kivy 有一个设计精良的面向对象的 API,您可能需要自己研究一下。 如果您想开发在许多系统上运行的图形程序,从笔记本电脑到手机,您可能需要查看我的模块在 Kivy 中创建应用,O'Reilly。
就本案例研究而言,关于该应用的重要事项是输出,它是逗号分隔值(CSV)文件,每行包含四个值:红色 ,绿色和蓝色值(表示为介于零和一之间的浮点数),以及用户为该颜色分配的前九个名称之一。 数据集看起来像这样:
0.30928279150905513,0.7536768153744394,0.3244011790604804,Green
0.4991001855115986,0.6394567277907686,0.6340502030888825,Grey
0.21132621004927998,0.3307376167520666,0.704037576789711,Blue
0.7260420945787928,0.4025279573860123,0.49781705131696363,Pink
0.706469868610228,0.28530423638868196,0.7880240251003464,Purple
0.692243900051664,0.7053550777777416,0.1845069151913028,Yellow
0.3628979381122397,0.11079495501215897,0.26924540840045075,Purple
0.611273677646518,0.48798521783547677,0.5346130557761224,Purple
.
.
.
0.4014121109376566,0.42176706818252674,0.9601866228083298,Blue
0.17750449496124632,0.8008214961070862,0.5073944321437429,Green
在感到无聊之前,我做了 200 个数据点(其中有很多是不真实的),并决定是时候对该数据集进行机器学习了。 如果您想使用我的数据,这些数据点将随本章的示例一起提供(没有人告诉我我是色盲的,因此应该是合理的)。
我们将实现一种更简单的机器学习算法,称为 k 最近邻居。 该算法依赖于数据集中各点之间的某种“距离”计算(在我们的示例中,我们可以使用勾股定理的三维版本)。 给定一个新的数据点,它会找到一定数量的数据点(在 k 个最近的邻居中称为 k),该数据点在通过该距离计算进行测量时最接近它。 然后,它以某种方式组合这些数据点(平均值可能适用于线性计算;对于我们的分类问题,我们将使用该模式),并返回结果。
我们不会过多地介绍算法的作用。 相反,我们将重点介绍一些可以将迭代器模式或迭代器协议应用于此问题的方法。
现在让我们编写一个程序,该程序按顺序执行以下步骤:
- 从文件中加载样本数据并从中构建模型。
- 生成 100 种随机颜色。
- 对每种颜色进行分类,然后以与输入相同的格式将其输出到文件中。
有了第二个 CSV 文件后,另一个 Kivy 程序可以加载该文件并渲染每种颜色,要求人类用户确认或否认预测的准确性,从而告知我们算法和初始数据集的准确性如何。 。
第一步是一个相当简单的生成器,用于加载 CSV 数据并将其转换为适合我们需求的格式:
import csv
dataset_filename = 'colors.csv'
def load_colors(filename):
with open(filename) as dataset_file:
lines = csv.reader(dataset_file)
for line in lines:
yield tuple(float(y) for y in line[0:3]), line[3]
我们之前没有见过csv.reader功能。 它在文件中的各行上返回一个迭代器。 迭代器返回的每个值都是一个字符串列表。 在我们的情况下,我们可能会用逗号分开并且很好,但是csv.reader还负责管理引号和逗号分隔的值格式的各种其他细微差别。
然后,我们遍历这些行并将其转换为颜色和名称的元组,其中颜色是三个浮点值整数的元组。 该元组是使用生成器表达式构造的。 可能有更多可读的方法来构造该元组。 您是否认为代码简短和生成器表达式的速度值得混淆? 它没有返回颜色元组列表,而是一次生成一个颜色元组,从而构造了一个生成器对象。
现在,我们需要一百种随机颜色。 有很多方法可以做到这一点:
- 具有嵌套生成器表达式的列表推导:
[tuple(random() for r in range(3)) for r in range(100)] - 基本的生成器功能
- 实现
__iter__和__next__协议的类 - 通过协程管道推送数据
- 即使是基本的
for循环
生成器版本似乎是最易读的,因此让我们将该函数添加到程序中:
from random import random
def generate_colors(count=100):
for i in range(count):
yield (random(), random(), random())
注意我们如何参数化要生成的颜色数量。 现在,我们可以将来将此功能重新用于其他颜色生成任务。
现在,在进行分类步骤之前,我们需要一个函数来计算两种颜色之间的“距离”。 由于可以将颜色视为三维(例如,红色,绿色和蓝色可以映射到x,y和z轴) ,让我们使用一些基本的数学运算:
import math
def color_distance(color1, color2):
channels = zip(color1, color2)
sum_distance_squared = 0
for c1, c2 in channels:
sum_distance_squared += (c1 - c2) ** 2
return math.sqrt(sum_distance_squared)
这是一个非常基本的功能; 它看起来甚至没有使用迭代器协议。 没有yield功能,也没有理解力。 但是,有一个for循环,对zip函数的调用也正在执行一些实际的迭代(请记住zip从每个输入迭代器中生成包含一个元素的元组)。
但是请注意,在我们的 k 近邻算法中,该函数将被调用很多次。 如果我们的代码运行太慢,并且我们能够将该函数识别为瓶颈,则我们可能希望将其替换为可读性较低但更优化的生成器表达式:
def color_distance(color1, color2):
return math.sqrt(sum((x[0] - x[1]) ** 2 for x in zip(
color1, color2)))
但是,强烈建议您在证明可读版本太慢之前不要进行此类优化。
现在我们已经有了一些管道,让我们进行实际的 k 最近邻居实现。 这似乎是使用协程的好地方。 这里带有一些测试代码,以确保产生合理的值:
def nearest_neighbors(model_colors, num_neighbors):
model = list(model_colors)
target = yield
while True:
distances = sorted(
((color_distance(c[0], target), c) for c in model),
)
target = yield [
d[1] for d in distances[0:num_neighbors]
]
model_colors = load_colors(dataset_filename)
target_colors = generate_colors(3)
get_neighbors = nearest_neighbors(model_colors, 5)
next(get_neighbors)
for color in target_colors:
distances = get_neighbors.send(color)
print(color)
for d in distances:
print(color_distance(color, d[0]), d[1])
协程接受两个参数,即用作模型的颜色列表和要查询的邻居数。 它将模型转换为列表,因为它将多次迭代。 在协程的主体中,它使用yield语法接受 RGB 颜色值的元组。 然后,它将对sorted的调用与奇数生成器表达式组合在一起。 看看是否可以弄清楚该生成器表达式在做什么。
它为模型中的每种颜色返回一个元组(distance, color_data)。 请记住,模型本身包含(color, name)的元组,其中color是三个 RGB 值的元组。 因此,生成器将在看起来像这样的怪异数据结构上返回迭代器:
(distance, (r, g, b), color_name)
然后sorted调用按结果的第一个元素(即距离)对结果进行排序。 这是一段复杂的代码,根本不是面向对象的。 您可能需要将其分解为正常的for循环,以确保您了解生成器表达式在做什么。 想像一下如果要将键参数传递给已排序的函数而不是构造元组,则这段代码的外观可能是一个很好的练习。
yield语句稍微复杂一些; 它从前 k 个(distance, color_data)元组的每一个中提取第二个值。 更具体地说,它为具有最小距离的 k 值生成((r, g, b), color_name)元组。 或者,如果您更喜欢抽象术语,它会在给定模型中得出目标的 k 最近邻。
剩下的代码只是测试这种方法的样板。 它构建模型和颜色生成器,启动协程,并在for循环中打印结果。
剩下的两个任务是根据最近的邻居选择一种颜色,并将结果输出到 CSV 文件。 让我们再增加两个协程来完成这些任务。 让我们先进行输出,因为它可以独立测试:
def write_results(filename="output.csv"):
with open(filename, "w") as file:
writer = csv.writer(file)
while True:
color, name = yield
writer.writerow(list(color) + [name])
results = write_results()
next(results)
for i in range(3):
print(i)
results.send(((i, i, i), i * 10))
该协程将打开的文件保持为状态,并在使用send()发送时向其中写入代码行。 测试代码确保协程正常工作,因此现在我们可以将两个协程与第三个协程连接起来。
第二个协程使用了一些奇怪的技巧:
from collections import Counter
def name_colors(get_neighbors):
color = yield
while True:
near = get_neighbors.send(color)
name_guess = Counter(
n[1] for n in near).most_common(1)[0][0]
color = yield name_guess
该协程将现有协程接受作为其参数。 在这种情况下,它是nearest_neighbors的实例。 这段代码基本上代理了通过该nearest_neighbors实例发送给它的所有值。 然后,它将对结果进行一些处理,以从返回的值中获得最普通的颜色。 在这种情况下,修改原始协程以返回名称可能同样有意义,因为它没有被用于其他任何用途。 但是,在许多情况下,传递协程是有用的。 这是我们的做法。
现在我们要做的就是将这些协程和管道连接在一起,并通过一个函数调用启动该过程:
def process_colors(dataset_filename="colors.csv"):
model_colors = load_colors(dataset_filename)
get_neighbors = nearest_neighbors(model_colors, 5)
get_color_name = name_colors(get_neighbors)
output = write_results()
next(output)
next(get_neighbors)
next(get_color_name)
for color in generate_colors():
name = get_color_name.send(color)
output.send((color, name))
process_colors()
因此,与我们定义的几乎所有其他函数不同,该函数是完全正常的函数,没有任何yield语句。 它不会变成协程或生成器对象。 但是,它确实构造了一个生成器和三个协程。 注意get_neighbors协程如何传递到name_colors的构造函数中? 请注意通过调用next将所有三个协程推进到其第一个yield语句。
创建完所有管道后,我们使用for循环将生成的每种颜色发送到get_color_name协程,然后将该协程产生的每个值通过管道传递到输出协程,然后将其写入到 文件。
就是这样! 我创建了第二个 Kivy 应用,该应用加载生成的 CSV 文件并将颜色呈现给用户。 用户可以选择是或否,具体取决于他们是否认为机器学习算法做出的选择与他们会做出的选择相匹配。 这在科学上是不准确的(对于观察偏差已经成熟),但是足够玩了。 用我的眼睛,它成功了大约 84%,这比我 12 年级的平均水平要好。 对于我们的第一个机器学习体验来说还不错,是吗?
您可能想知道,“这与面向对象的编程有什么关系?此代码中甚至没有一个类!”。 在某些方面,您将是对的; 协程和生成器通常都不被认为是面向对象的。 但是,创建它们的函数将返回对象。 实际上,您可以将这些函数视为构造函数。 构造的对象具有适当的send()和__next__()方法。 基本上,协程/生成器语法是特定对象的语法快捷方式,如果没有该对象,创建起来将非常冗长。
此案例研究是自下而上设计的练习。 我们创建了执行特定任务的各种低级对象,并将它们最终挂在一起。 我发现使用协程开发时这是一种常见的做法。 自上而下的替代设计有时会导致更多的整体代码段,而不是唯一的单独代码段。 总的来说,我们希望在太大的方法和太小的方法之间找到一个快乐的中介,很难看到它们如何组合在一起。 当然,无论是否像在此那样使用迭代器协议,都是如此。



十一、Python 设计模式 I
在上一章中,我们对设计模式进行了简要介绍,并介绍了迭代器模式,该模式非常有用,通用,已被抽象为编程语言本身的核心。 在本章中,我们将回顾其他常见模式,以及它们如何在 Python 中实现。 与迭代一样,Python 经常提供替代语法来简化此类问题的处理。 我们将介绍“传统”设计以及这些模式的 Python 版本。 总而言之,我们将看到:
- 多种特定模式
- Python 中每个模式的规范实现
- Python 语法来替换某些模式
装饰器模式
装饰器模式允许我们“包装”一个提供核心功能的对象以及其他更改此功能的对象。 任何使用修饰对象的对象都将以与未修饰对象完全相同的方式与其进行交互(也就是说,修饰对象的接口与核心对象的接口相同)。
装饰器模式有两种主要的用法:
- 增强组件将数据发送到第二个组件时的响应
- 支持多种可选行为
第二种选择通常是替代多继承。 我们可以构造一个核心对象,然后围绕该核心创建一个装饰器。 由于装饰器对象与核心对象具有相同的接口,因此我们甚至可以将新对象包装在其他装饰器中。 这是在 UML 中的外观:

在此,核心 和所有装饰器都实现特定的接口。 装饰器通过合成维护对接口的另一个实例的引用。 调用时,装饰器在调用其包装的接口之前或之后进行一些附加处理。 包装的对象可以是另一个装饰器,也可以是核心功能。 尽管多个装饰器可以互相包裹,但是所有这些装饰器“中心”的对象提供了核心功能。
装饰器示例
让我们来看一个来自网络编程的例子。 我们将使用 TCP 套接字。 socket.send()方法采用一串输入字节,并将它们输出到另一端的接收套接字。 有许多库可以接受套接字并访问此功能以在流上发送数据。 让我们创建一个这样的对象; 它是一个交互式外壳程序,它等待来自客户端的连接,然后提示用户输入字符串响应:
import socket
def respond(client):
response = input("Enter a value: ")
client.send(bytes(response, 'utf8'))
client.close()
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(('localhost',2401))
server.listen(1)
try:
while True:
client, addr = server.accept()
respond(client)
finally:
server.close()
respond函数接受套接字参数,并提示要发送的数据作为答复,然后发送。 为了使用它,我们构造了一个服务器套接字,并告诉它侦听本地计算机上的端口2401(我随机选择了该端口)。 当客户端连接时,它将调用respond函数,该函数以交互方式请求数据并做出适当的响应。 需要注意的重要一点是respond功能仅关心套接字接口的两种方法:send和close。 为了测试这一点,我们可以编写一个非常简单的客户端,该客户端连接到相同的端口并在退出之前输出响应:
import socket
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(('localhost', 2401))
print("Received: {0}".format(client.recv(1024)))
client.close()
要使用这些程序:
- 在一个终端中启动服务器。
- 打开第二个终端窗口并运行客户端。
- 在输入值:在服务器窗口中的提示符下,键入一个值,然后按 Enter。
- 客户端将收到您键入的内容,将其打印到控制台,然后退出。 再次运行客户端; 服务器将提示您输入第二个值。
现在,再次查看我们的服务器代码,我们看到两个部分。 respond函数将数据发送到套接字对象。 剩下的脚本负责创建该套接字对象。 我们将创建一对装饰器,它们可以自定义套接字行为,而不必扩展或修改套接字本身。
让我们从一个“记录”装饰器开始。 该对象将任何发送到服务器控制台的数据输出,然后再发送给客户端:
class LogSocket:
def __init__(self, socket):
self.socket = socket
def send(self, data):
print("Sending {0} to {1}".format(
data, self.socket.getpeername()[0]))
self.socket.send(data)
def close(self):
self.socket.close()
该类装饰一个套接字对象,并向客户端套接字提供send和close接口。 一个更好的装饰器还将实现(并可能自定义)所有其余的套接字方法。 它也应该正确实现send的所有参数(实际上接受一个可选的 flags 参数),但让我们的示例保持简单! 每当在此对象上调用send时,它都会使用原始套接字将输出记录到屏幕,然后再将数据发送到客户端。
我们只需要在原始代码中更改一行即可使用此装饰器。 而不是使用套接字调用respond,而是使用修饰的套接字来调用它:
respond(LogSocket(client))
尽管这很简单,但是我们必须问自己为什么不仅仅扩展套接字类并覆盖send方法。 登录后,我们可以调用super().send进行实际发送。 这种设计也没有错。
面对装饰器和继承之间的选择时,仅当我们需要根据某些条件动态修改对象时,才应使用装饰器。 例如,如果服务器当前处于调试模式,我们可能只想启用日志记录装饰器。 当我们有多个可选行为时,装饰器还会击败多重继承。 例如,我们可以编写第二个装饰器,每当调用send时,该装饰器便会使用gzip压缩来压缩数据:
import gzip
from io import BytesIO
class GzipSocket:
def __init__(self, socket):
self.socket = socket
def send(self, data):
buf = BytesIO()
zipfile = gzip.GzipFile(fileobj=buf, mode="w")
zipfile.write(data)
zipfile.close()
self.socket.send(buf.getvalue())
def close(self):
self.socket.close()
此版本中的send方法在将传入数据发送到客户端之前先对其进行压缩。
现在我们有了这两个装饰器,我们可以编写在响应时在它们之间动态切换的代码。 这个示例并不完整,但是它说明了混合和匹配装饰器可能遵循的逻辑:
client, addr = server.accept()
if log_send:
client = LoggingSocket(client)
if client.getpeername()[0] in compress_hosts:
client = GzipSocket(client)
respond(client)
此代码检查名为log_send的假设配置变量。 如果启用,则将套接字包装在LoggingSocket装饰器中。 同样,它检查已连接的客户端是否在已知接受压缩内容的地址列表中。 如果是这样,它将客户端包装在GzipSocket装饰器中。 请注意,取决于配置和连接客户端,两个装饰器中的一个或两个都不能启用。 尝试使用多重继承编写此代码,看看您有多困惑!
Python 中的装饰器
装饰器模式在 Python 中很有用,但还有其他选择。 例如,我们也许可以使用在第 7 章和 Python 面向对象的快捷方式中讨论过的猴子补丁来获得类似的效果。 单一继承(可以使用一种大方法进行“可选”计算)可以作为一种选择,并且多重继承不应该被注销,因为它不适用于前面提到的特定示例!
在 Python 中,在函数上使用此模式非常普遍。 正如我们在上一章中所看到的,函数也是对象。 实际上,函数修饰是如此普遍,以至于 Python 提供了一种特殊的语法来简化将此类修饰符应用于函数的过程。
例如,我们可以以更一般的方式查看日志记录示例。 除了记录日志外,仅在套接字上发送调用,我们可能会发现将所有调用记录到某些函数或方法中很有帮助。 以下示例实现了一个装饰器,该装饰器可以做到这一点:
import time
def log_calls(func):
def wrapper(*args, **kwargs):
now = time.time()
print("Calling {0} with {1} and {2}".format(
func.__name__, args, kwargs))
return_value = func(*args, **kwargs)
print("Executed {0} in {1}ms".format(
func.__name__, time.time() - now))
return return_value
return wrapper
def test1(a,b,c):
print("\ttest1 called")
def test2(a,b):
print("\ttest2 called")
def test3(a,b):
print("\ttest3 called")
time.sleep(1)
test1 = log_calls(test1)
test2 = log_calls(test2)
test3 = log_calls(test3)
test1(1,2,3)
test2(4,b=5)
test3(6,7)
装饰器功能与我们之前探讨的示例非常相似; 在这种情况下,装饰器将创建一个类似于套接字的对象,并创建一个类似于套接字的对象。 这次,我们的装饰器获取一个函数对象并返回一个新的函数对象。 该代码包含三个单独的任务:
- 一个函数
log_calls,它接受另一个函数 - 此函数(内部)定义了一个名为
wrapper的新函数,该函数在调用原始函数之前会做一些额外的工作 - 返回此新功能
三个示例函数演示了正在使用的装饰器。 第三个包括一个睡眠呼叫以演示时序测试。 我们将每个函数传递给装饰器,该装饰器返回一个新函数。 我们将此新函数分配给原始变量名,从而有效地将原始函数替换为修饰后的函数。
这种语法使我们能够像套接字示例一样动态地构建装饰的函数对象。 如果不替换名称,我们甚至可以在不同情况下保留经过修饰和未经修饰的版本。
通常,这些装饰器是对各种功能永久应用的常规修改。 在这种情况下,Python 支持一种特殊的语法以在定义函数时应用装饰器。 当我们讨论property装饰器时,我们已经看到了这种语法。 现在,让我们了解它是如何工作的。
除了在方法定义之后应用装饰器函数之外,我们可以使用@decorator语法一次完成所有操作:
@log_calls
def test1(a,b,c):
print("\ttest1 called")
此语法的主要好处是,我们可以轻松地看到在定义函数时已经对它进行了修饰。 如果稍后应用装饰器,则读取代码的人可能会完全忘记该功能已被更改。 回答诸如“为什么我的程序日志记录功能调用控制台?”之类的问题? 会变得更加困难! 但是,该语法只能应用于我们定义的函数,因为我们无法访问其他模块的源代码。 如果我们需要装饰别人的第三方库中的功能,则必须使用较早的语法。
装饰器语法比我们在这里看到的更多。 我们这里没有足够的空间来介绍高级主题,因此请查看 Python 参考手册或其他教程以获取更多信息。 装饰器可以创建为可调用对象,而不仅仅是返回函数的函数。 班级也可以装饰。 在这种情况下,装饰器将返回一个新类而不是一个新函数。 最后,装饰器可以接受参数以根据功能自定义它们。
观察者模式
观察者模式对于状态监视和事件处理情况很有用。 这种模式允许给定的对象由未知且动态的“观察者”对象组监视。
只要核心对象上的值发生更改,它就会通过调用update()方法让所有观察者对象知道发生了更改。 每当核心对象发生变化时,每个观察者可能负责不同的任务; 核心对象不知道或不在乎这些任务是什么,观察者通常也不知道或不在乎其他观察者在做什么。
在这里,它在 UML 中:

观察者的例子
观察者模式在冗余备份系统中可能有用。 我们可以编写一个维护某些值的核心对象,然后让一个或多个观察者创建该对象的序列化副本。 例如,这些副本可以存储在数据库中,远程主机上或本地文件中。 让我们使用属性来实现核心对象:
class Inventory:
def __init__(self):
self.observers = []
self._product = None
self._quantity = 0
def attach(self, observer):
self.observers.append(observer)
@property
def product(self):
return self._product
@product.setter
def product(self, value):
self._product = value
self._update_observers()
@property
def quantity(self):
return self._quantity
@quantity.setter
def quantity(self, value):
self._quantity = value
self._update_observers()
def _update_observers(self):
for observer in self.observers:
observer()
该对象具有两个属性,设置这些属性后,它们将自行调用_update_observers方法。 此方法所做的全部工作就是遍历可用的观察者,并让每个人都知道某些更改。 在这种情况下,我们直接调用观察者对象。 该对象将必须实现__call__以处理更新。 在许多面向对象的编程语言中这是不可能的,但这是 Python 中有用的快捷方式,可以帮助使我们的代码更具可读性。
现在,让我们实现一个简单的观察者对象; 这将只是向控制台输出一些状态:
class ConsoleObserver:
def __init__(self, inventory):
self.inventory = inventory
def __call__(self):
print(self.inventory.product)
print(self.inventory.quantity)
这里没有什么令人兴奋的事情。 被观察的对象是在初始化器中设置的,当观察者被调用时,我们做“某事”。 我们可以在交互式控制台中测试观察者:
>>> i = Inventory()
>>> c = ConsoleObserver(i)
>>> i.attach(c)
>>> i.product = "Widget"
Widget
0
>>> i.quantity = 5
Widget
5
将观察者附加到库存对象后,只要我们更改两个观察到的属性之一,便会调用该观察者并调用其动作。 我们甚至可以添加两个不同的观察者实例:
>>> i = Inventory()
>>> c1 = ConsoleObserver(i)
>>> c2 = ConsoleObserver(i)
>>> i.attach(c1)
>>> i.attach(c2)
>>> i.product = "Gadget"
Gadget
0
Gadget
0
这次更改产品时,有两组输出,每个观察者一组。 这里的关键思想是,我们可以轻松地添加完全不同类型的观察者,这些观察者可以同时备份文件,数据库或 Internet 应用中的数据。
观察者模式将正在观察的代码与进行观察的代码分离。 如果我们不使用这种模式,我们将不得不在每个属性中放置代码以处理可能出现的不同情况。 登录到控制台,更新数据库或文件等。 这些任务中的每一个的代码都将与观察到的对象混合在一起。 维护它将是一场噩梦,而日后添加新的监视功能将很痛苦。
策略模式
策略模式是面向对象编程中抽象的常见演示。 该模式对单个问题实施不同的解决方案,每个解决方案都在不同的对象中。 然后,客户端代码可以在运行时动态选择最合适的实现。
通常,不同的算法会有不同的权衡。 一种可能比另一种更快,但是会使用更多的内存,而当存在多个 CPU 或提供分布式系统时,第三种算法可能是最合适的。 这是 UML 中的策略模式:

连接到策略模式的用户代码仅需要知道它正在处理抽象接口。 选择的实际实现执行相同的任务,但是方式不同。 无论哪种方式,界面都是相同的。
一个策略示例
策略模式的典范示例是排序例程。 多年来,发明了许多算法来对一组对象进行分类。 快速排序,合并排序和堆排序都是具有不同功能的快速排序算法,每种算法本身都有用,具体取决于输入的大小和类型,输入的混乱程度以及系统的要求。
如果我们有需要对集合进行排序的客户端代码,则可以使用sort()方法将其传递给对象。 该对象可以是QuickSorter或MergeSorter对象,但是两种情况下的结果都是相同的:排序列表。 用于进行排序的策略是从调用代码中抽象出来的,从而使其具有模块化和可替换性。
当然,在 Python 中,我们通常只调用sorted函数或list.sort方法,并相信它将以接近最佳的方式进行排序。 因此,我们确实需要看一个更好的例子。
让我们考虑一个桌面墙纸管理器。 在桌面背景上显示图像时,可以通过不同方式将其调整为屏幕尺寸。 例如,假设图像小于屏幕,则可以将其平铺在屏幕上,居中放置或缩放以适合屏幕。 也可以使用其他更复杂的策略,例如缩放到最大高度或宽度,将其与纯色,半透明或渐变背景色组合,或进行其他操作。 虽然我们可能以后想添加这些策略,但让我们从基本策略开始。
我们的策略对象需要两个输入。 要显示的图像,以及屏幕的宽度和高度的元组。 它们每个都返回一个屏幕大小的新图像,并根据给定的策略对图像进行调整以适合显示。 您需要在pip3 install pillow中安装pillow模块,此示例才能正常工作:
from PIL import Image
class TiledStrategy:
def make_background(self, img_file, desktop_size):
in_img = Image.open(img_file)
out_img = Image.new('RGB', desktop_size)
num_tiles = [
o // i + 1 for o, i in
zip(out_img.size, in_img.size)
]
for x in range(num_tiles[0]):
for y in range(num_tiles[1]):
out_img.paste(
in_img,
(
in_img.size[0] * x,
in_img.size[1] * y,
in_img.size[0] * (x+1),
in_img.size[1] * (y+1)
)
)
return out_img
class CenteredStrategy:
def make_background(self, img_file, desktop_size):
in_img = Image.open(img_file)
out_img = Image.new('RGB', desktop_size)
left = (out_img.size[0] - in_img.size[0]) // 2
top = (out_img.size[1] - in_img.size[1]) // 2
out_img.paste(
in_img,
(
left,
top,
left+in_img.size[0],
top + in_img.size[1]
)
)
return out_img
class ScaledStrategy:
def make_background(self, img_file, desktop_size):
in_img = Image.open(img_file)
out_img = in_img.resize(desktop_size)
return out_img
在这里,我们有三种策略,每种策略都使用PIL执行其任务。 个别策略具有make_background方法,该方法接受相同的参数集。 选择之后,可以调用适当的策略来创建正确大小的桌面映像版本。 TiledStrategy循环遍历适合图像宽度和高度的输入图像数量,并将其重复复制到每个位置。 CenteredStrategy计算出需要在图像的四个边缘上留出多少空间以使其居中。 ScaledStrategy强制图像达到输出尺寸(忽略宽高比)。
考虑在没有策略模式的情况下如何在这些选项之间进行切换。 我们需要将所有代码放入一个很大的方法中,并使用笨拙的if语句选择所需的代码。 每次我们想添加新策略时,都必须使该方法变得更加笨拙。
Python 中的策略
尽管在大多数面向对象的库中很常见,但是策略模式的之前的规范实现在 Python 编程中很少见。
这些类各自表示只提供单个功能的对象,它们什么也不做。 我们可以轻松地调用该函数__call__并使对象直接可调用。 由于没有其他与该对象相关联的数据,因此我们只需要创建一组顶层函数并将其作为我们的策略传递即可。
因此,反对设计模式哲学的人会说:“因为 Python 具有一流的功能,所以不需要策略模式”。 实际上,Python 的一流功能使我们能够以更直接的方式实现策略模式。 知道存在模式仍然可以帮助我们为程序选择正确的设计,但是可以使用可读性更高的语法来实现它。 当我们需要允许客户端代码或最终用户从同一接口的多个实现中进行选择时,应使用策略模式或其顶层功能实现。
状态模式
状态模式在结构上类似于策略模式,但是其意图和目的有很大不同。 状态模式的目标是表示状态转换系统:即明显可以使对象处于特定状态并且某些活动可能将其驱动到不同状态的系统。
为了使这项工作有效,我们需要提供一个用于切换状态的接口的管理器或上下文类。 在内部,该类包含一个指向当前状态的指针。 每个州都知道允许其进入的其他州,并将根据对其执行的操作转换到那些州。
因此,我们有两种类型的类,上下文类和多个状态类。 上下文类维护当前状态,并将动作转发到状态类。 状态类通常对调用上下文的任何其他对象隐藏; 它就像一个黑匣子,恰好在内部执行状态管理。 这是在 UML 中的外观:

状态示例
为了说明的状态模式,让我们构建一个 XML 解析工具。 上下文类将是解析器本身。 它将以字符串作为输入,并将工具置于初始解析状态。 各种解析状态将吃掉字符,寻找特定的值,找到该值后,将其更改为其他状态。 目标是为每个标签及其内容创建一个节点对象树。 为了使事情易于管理,我们将仅解析 XML 的子集-标签和标签名称。 我们将无法处理标签上的属性。 它将解析标签的文本内容,但是不会尝试解析在文本内部具有标签的“混合”内容。 这是一个我们可以解析的示例“简化的 XML”文件:
<module>
<author>Dusty Phillips</author>
<publisher>Packt Publishing</publisher>
<title>Python 3 Object Oriented Programming</title>
<content>
<chapter>
<number>1</number>
<title>Object Oriented Design</title>
</chapter>
<chapter>
<number>2</number>
<title>Objects In Python</title>
</chapter>
</content>
</module>
在查看状态和解析器之前,让我们考虑一下该程序的输出。 我们知道我们想要Node对象的树,但是Node是什么样的? 好吧,很显然,它需要知道它正在解析的标签的名称,并且由于它是一棵树,因此它可能应该维护一个指向父节点的指针以及该节点的子节点的顺序。 一些节点具有文本值,但不是全部。 让我们首先看一下Node类:
class Node:
def __init__(self, tag_name, parent=None):
self.parent = parent
self.tag_name = tag_name
self.children = []
self.text=""
def __str__(self):
if self.text:
return self.tag_name + ": " + self.text
else:
return self.tag_name
此类在初始化时设置默认属性值。 我们提供__str__方法来帮助我们看完树结构。
现在,在查看示例文档时,我们需要考虑解析器可以处于什么状态。显然,它将以尚未处理任何节点的状态开始。 我们需要一个状态来处理开始标签和结束标签。 而且,当我们在带有文本内容的标记中时,也必须将其作为单独的状态进行处理。
切换状态可能很棘手。 我们如何知道下一个节点是开始标记,结束标记还是文本节点? 我们可以在每个状态中加入一点逻辑来解决这个问题,但是创建一个新状态实际上更有意义,其唯一目的是弄清楚我们将要切换到下一个状态。 如果我们将此过渡状态称为 ChildNode,则最终将具有以下状态:
- FirstTag
- ChildNode
- OpenTag
- CloseTag
- Text
FirstTag 状态将切换到 ChildNode,该子节点负责确定要切换到其他三种状态中的哪一种; 这些状态完成时,它们将切换回 ChildNode。 以下状态转换图显示了可用的状态更改:

各州负责处理“字符串的剩余部分”,处理尽可能多的字符串,然后告诉解析器处理其余部分。 让我们首先构造Parser类:
class Parser:
def __init__(self, parse_string):
self.parse_string = parse_string
self.root = None
self.current_node = None
self.state = FirstTag()
def process(self, remaining_string):
remaining = self.state.process(remaining_string, self)
if remaining:
self.process(remaining)
def start(self):
self.process(self.parse_string)
初始化程序在类中设置了一些变量,各个状态将访问该变量。 parse_string实例变量是我们尝试解析的文本。 root节点是 XML 结构中的“顶部”节点。 current_node实例变量是我们当前要添加子代的变量。
该解析器的重要功能是process方法,该方法接受剩余的字符串,并将其传递到当前状态。 解析器(self参数)也传递到该状态的处理方法中,以便该状态可以对其进行操作。 当状态完成处理时,期望状态返回未解析的字符串的其余部分。 然后,解析器对该剩余的字符串递归调用process方法,以构造树的其余部分。
现在,让我们看一下FirstTag状态:
class FirstTag:
def process(self, remaining_string, parser):
i_start_tag = remaining_string.find('<')
i_end_tag = remaining_string.find('>')
tag_name = remaining_string[i_start_tag+1:i_end_tag]
root = Node(tag_name)
parser.root = parser.current_node = root
parser.state = ChildNode()
return remaining_string[i_end_tag+1:]
此状态在第一个标签上找到打开和关闭尖括号的索引(i_代表索引)。 您可能会认为这种状态是不必要的,因为 XML 要求在开始标记之前不能有文本。 但是,可能需要使用空白。 这就是为什么我们搜索开括号的原因,而不是假设它是文档中的第一个字符。 请注意,此代码假定输入文件有效。 正确的实现将是严格测试无效输入,并尝试恢复或显示极具描述性的错误消息。
该方法提取标记的名称,并将其分配给解析器的根节点。 它还将它分配给current_node,因为这是我们将在下一个添加子项的那个。
接下来是重要的部分:该方法将解析器对象上的当前状态更改为ChildNode状态。 然后,它返回字符串的其余部分(在开始标记之后)以允许对其进行处理。
ChildNode状态看似非常复杂,但实际上只需要一个简单的条件即可:
class ChildNode:
def process(self, remaining_string, parser):
stripped = remaining_string.strip()
if stripped.startswith("</"):
parser.state = CloseTag()
elif stripped.startswith("<"):
parser.state = OpenTag()
else:
parser.state = TextNode()
return stripped
strip()调用从字符串中删除空格。 然后,解析器确定下一个项目是开始标记还是结束标记,还是文本字符串。 根据发生哪种可能性,它将解析器设置为特定状态,然后告诉它解析字符串的其余部分。
OpenTag状态类似于FirstTag状态,不同之处在于它将新创建的节点添加到先前的current_node对象的children并将其设置为新的current_node。 继续执行以下操作之前,它将处理器置于ChildNode状态:
class OpenTag:
def process(self, remaining_string, parser):
i_start_tag = remaining_string.find('<')
i_end_tag = remaining_string.find('>')
tag_name = remaining_string[i_start_tag+1:i_end_tag]
node = Node(tag_name, parser.current_node)
parser.current_node.children.append(node)
parser.current_node = node
parser.state = ChildNode()
return remaining_string[i_end_tag+1:]
CloseTag状态基本上相反。 它将解析器的current_node设置回父节点,以便将外部标记中的任何其他子代添加到它:
class CloseTag:
def process(self, remaining_string, parser):
i_start_tag = remaining_string.find('<')
i_end_tag = remaining_string.find('>')
assert remaining_string[i_start_tag+1] == "/"
tag_name = remaining_string[i_start_tag+2:i_end_tag]
assert tag_name == parser.current_node.tag_name
parser.current_node = parser.current_node.parent
parser.state = ChildNode()
return remaining_string[i_end_tag+1:].strip()
这两个assert语句有助于确保解析字符串一致。 方法末尾的if语句可确保处理器在完成时终止。 如果节点的父节点是None,则意味着我们正在根节点上工作。
最后,TextNode状态非常简单地提取下一个关闭标签之前的文本,并将其设置为当前节点上的值:
class TextNode:
def process(self, remaining_string, parser):
i_start_tag = remaining_string.find('<')
text = remaining_string[:i_start_tag]
parser.current_node.text = text
parser.state = ChildNode()
return remaining_string[i_start_tag:]
现在我们只需在我们创建的解析器对象上设置初始状态。 初始状态为FirstTag对象,因此只需将以下内容添加到__init__方法中:
self.state = FirstTag()
为了测试该类,让我们添加一个主脚本,该脚本从命令行打开一个文件,进行解析并打印节点:
if __name__ == "__main__":
import sys
with open(sys.argv[1]) as file:
contents = file.read()
p = Parser(contents)
p.start()
nodes = [p.root]
while nodes:
node = nodes.pop(0)
print(node)
nodes = node.children + nodes
此代码打开文件,加载内容,然后解析结果。 然后按顺序打印每个节点及其子节点。 我们最初在节点类上添加的__str__方法负责格式化节点以进行打印。 如果我们在前面的示例中运行脚本,则其输出树如下:
module
author: Dusty Phillips
publisher: Packt Publishing
title: Python 3 Object Oriented Programming
content
chapter
number: 1
title: Object Oriented Design
chapter
number: 2
title: Objects In Python
将其与进行比较,原始的简化 XML 文档告诉我们解析器正在工作。
国家与战略
状态模式看起来与策略模式非常相似; 实际上,两者的 UML 图是相同的。 实现也相同。 我们甚至可以将状态写为一等函数,而不是像策略建议那样将它们包装在对象中。
虽然这两种模式具有相同的结构,但它们可以解决完全不同的问题。 策略模式用于在运行时选择算法。 通常,将仅针对特定用例选择这些算法中的一种。 另一方面,状态模式设计为允许随着某些过程的发展动态地在不同状态之间切换。 在代码中,主要区别在于策略模式通常不了解其他策略对象。 在状态模式中,状态或上下文都需要知道可以切换到的其他状态。
作为协程的状态转换
状态模式是状态转换问题的经典面向对象解决方案。 但是,此模式的语法相当冗长。 通过将对象构造为协程,可以获得类似的效果。 还记得我们在第 9 章,“迭代器模式”中内置的正则表达式日志文件解析器吗? 那是变相的国家过渡问题。 该实现与定义状态模式中使用的所有对象(或函数)的实现之间的主要区别在于,协程解决方案允许我们在语言构造中对更多样板进行编码。 有两种实现方式,但是没有一种在本质上优于另一种实现,但是对于给定的“可读”定义,您可能会发现协程更具可读性(首先必须了解协程的语法!)。
单例模式
单例模式是最具争议的模式之一; 许多人指责它是“反模式”,应该避免这种模式,而不应提倡这种模式。 在 Python 中,如果有人使用单例模式,则几乎可以肯定他们做错了事,这可能是因为它们来自限制性更强的编程语言。
那么为什么要讨论它呢? Singleton 是所有设计模式中最著名的之一。 它在面向对象的语言中很有用,并且是传统的面向对象编程的重要组成部分。 更相关的是,即使我们在 Python 中以完全不同的方式实现该想法,单例背后的想法也很有用。
单例模式背后的基本思想是允许某个对象的确切一个实例存在。 通常,该对象是一种管理器类,类似于我们在第 5 章,“何时使用面向对象编程”中所讨论的类。 此类对象通常需要由各种其他对象引用,并且将对管理器对象的引用传递给需要它们的方法和构造函数会使代码难以阅读。
相反,当使用单例时,单独的对象会从类中请求管理器对象的单个实例,因此无需传递对其的引用。 UML 图没有完全描述它,但是这里是为了完整性:

在大多数编程环境中,通过将构造函数设为私有(因此没有人可以创建它的其他实例),然后提供一种静态方法来检索单个实例来实施单例。 此方法在首次调用时创建一个新实例,然后在每次再次调用时返回相同的实例。
单例实现
Python 没有私有的构造函数,但是为此,它有更好的东西。 我们可以使用__new__类方法来确保仅创建一个实例:
class OneOnly:
_singleton = None
def __new__(cls, *args, **kwargs):
if not cls._singleton:
cls._singleton = super(OneOnly, cls
).__new__(cls, *args, **kwargs)
return cls._singleton
调用__new__时,通常会构造该类的新实例。 覆盖它时,我们首先检查是否已创建单例实例; 如果不是,我们使用super调用创建它。 因此,每当我们在OneOnly上调用构造函数时,我们总是得到完全相同的实例:
>>> o1 = OneOnly()
>>> o2 = OneOnly()
>>> o1 == o2
True
>>> o1
<__main__.OneOnly object at 0xb71c008c>
>>> o2
<__main__.OneOnly object at 0xb71c008c>
两个对象相等,并且位于相同的地址。 因此,它们是同一对象。 这个特定的实现不是很透明,因为创建单个对象并不明显。 每当我们调用构造函数时,我们都期望该对象有一个新实例; 在这种情况下,该合同被违反。 也许,如果我们真的认为我们需要一个单例,那么在类上使用良好的文档字符串可以缓解此问题。
但是我们不需要它。 Python 编码人员不愿强迫用户使用特定的思维方式。 我们可能认为只需要一个类的实例,但是其他程序员可能有不同的想法。 例如,单例可能会干扰分布式计算,并行编程和自动化测试。 在所有这些情况下,即使一个“正常”操作可能永远不需要一个实例,拥有一个特定对象的多个或替代实例也可能非常有用。
模块变量可以模仿单例
通常,在 Python 中,可以使用模块级变量充分模仿单例模式。 人们可以随时重新分配这些变量并不像单例的“安全”,但是正如我们在第 2 章,“Python 中的对象”中讨论的私有变量一样,这是 在 Python 中可以接受。 如果有人有正当理由要更改这些变量,为什么我们应该停止它们? 它也不会阻止人们实例化该对象的多个实例,但是,如果他们有正当的理由,那么为什么还要进行干预呢?
理想情况下,我们应该为他们提供一种机制来访问“默认单例”值,同时还允许他们在需要时创建其他实例。 尽管从技术上讲根本不是单例,但它为类单例行为提供了最 Pythonic 的机制。
要使用模块级变量而不是单例,我们在定义类后实例化该类的实例。 我们可以改善状态模式以使用单例。 无需每次更改状态都创建新对象,而是可以创建始终可访问的模块级变量:
class FirstTag:
def process(self, remaining_string, parser):
i_start_tag = remaining_string.find('<')
i_end_tag = remaining_string.find('>')
tag_name = remaining_string[i_start_tag+1:i_end_tag]
root = Node(tag_name)
parser.root = parser.current_node = root
parser.state = child_node
return remaining_string[i_end_tag+1:]
class ChildNode:
def process(self, remaining_string, parser):
stripped = remaining_string.strip()
if stripped.startswith("</"):
parser.state = close_tag
elif stripped.startswith("<"):
parser.state = open_tag
else:
parser.state = text_node
return stripped
class OpenTag:
def process(self, remaining_string, parser):
i_start_tag = remaining_string.find('<')
i_end_tag = remaining_string.find('>')
tag_name = remaining_string[i_start_tag+1:i_end_tag]
node = Node(tag_name, parser.current_node)
parser.current_node.children.append(node)
parser.current_node = node
parser.state = child_node
return remaining_string[i_end_tag+1:]
class TextNode:
def process(self, remaining_string, parser):
i_start_tag = remaining_string.find('<')
text = remaining_string[:i_start_tag]
parser.current_node.text = text
parser.state = child_node
return remaining_string[i_start_tag:]
class CloseTag:
def process(self, remaining_string, parser):
i_start_tag = remaining_string.find('<')
i_end_tag = remaining_string.find('>')
assert remaining_string[i_start_tag+1] == "/"
tag_name = remaining_string[i_start_tag+2:i_end_tag]
assert tag_name == parser.current_node.tag_name
parser.current_node = parser.current_node.parent
parser.state = child_node
return remaining_string[i_end_tag+1:].strip()
first_tag = FirstTag()
child_node = ChildNode()
text_node = TextNode()
open_tag = OpenTag()
close_tag = CloseTag()
我们已经完成的所有工作是创建可重复使用的各种状态类的实例。 请注意,即使在定义变量之前,如何访问类中的这些模块变量? 这是因为直到调用该方法后,类中的代码才会执行,并且到此为止,整个模块都已定义。
此示例的区别在于,我们不是在浪费内存以创建一堆必须进行垃圾回收的新实例,而是为每个状态重用了一个状态对象。 即使同时运行多个解析器,也只需要使用这些状态类。
当我们最初创建基于状态的解析器时,您可能想知道为什么我们没有在每个单独的状态下将解析器对象传递给__init__,而不是像我们那样将其传递给 process 方法。 该状态于是可以被称为self.parser。 这是状态模式的完美有效实现,但不允许利用单例模式。 如果状态对象维护对解析器的引用,则不能同时使用它们来引用其他解析器。
注意
请记住,这是两种不同的模式,具有不同的用途。 Singleton 的目的对于实现状态模式可能有用的事实并不意味着这两个模式是相关的。
模板模式
模板模式对于删除重复代码非常有用。 它是支持不要重复自己原理的一种实现,我们在第 5 章,“何时使用面向对象编程”中讨论了该原理。 它设计用于以下情况:我们要完成几个不同的任务,这些任务有一些但不是全部的相同步骤。 通用步骤在基类中实现,而不同的步骤在子类中被覆盖以提供自定义行为。 在某些方面,这类似于通用策略模式,只是使用基类共享算法的相似部分。 这是 UML 格式:

模板示例
让我们以为例创建一个汽车销售记者。 我们可以将销售记录存储在 SQLite 数据库表中。 SQLite 是一个简单的基于文件的数据库引擎,允许我们使用 SQL 语法存储记录。 Python 3 在其标准库中包括 SQLite,因此不需要额外的模块。
我们需要执行两个常见任务:
- 选择所有新车销售并将其以逗号分隔的格式输出到屏幕
- 输出以逗号分隔的所有销售人员及其销售总额的列表,并将其保存到可以导入电子表格的文件中
这些任务看起来似乎完全不同,但是它们具有一些共同的特征。 在这两种情况下,我们都需要执行以下步骤:
- 连接到数据库。
- 构造查询新车或销售总额。
- 发出查询。
- 将结果格式化为以逗号分隔的字符串。
- 将数据输出到文件或电子邮件。
这两个任务的查询构造和输出步骤不同,但是其余步骤相同。 我们可以使用模板模式将常用步骤放在基类中,将不同步骤放在两个子类中。
在开始之前,让我们创建一个数据库,并使用几行 SQL 将一些示例数据放入其中:
import sqlite3
conn = sqlite3.connect("sales.db")
conn.execute("CREATE TABLE Sales (salesperson text, "
"amt currency, year integer, model text, new boolean)")
conn.execute("INSERT INTO Sales values"
" ('Tim', 16000, 2010, 'Honda Fit', 'true')")
conn.execute("INSERT INTO Sales values"
" ('Tim', 9000, 2006, 'Ford Focus', 'false')")
conn.execute("INSERT INTO Sales values"
" ('Gayle', 8000, 2004, 'Dodge Neon', 'false')")
conn.execute("INSERT INTO Sales values"
" ('Gayle', 28000, 2009, 'Ford Mustang', 'true')")
conn.execute("INSERT INTO Sales values"
" ('Gayle', 50000, 2010, 'Lincoln Navigator', 'true')")
conn.execute("INSERT INTO Sales values"
" ('Don', 20000, 2008, 'Toyota Prius', 'false')")
conn.commit()
conn.close()
希望即使您不了解 SQL,也可以看到这里发生的事情。 我们创建了一个表来保存数据,并使用六个插入语句来添加销售记录。 数据存储在名为sales.db的文件中。 现在我们有了一个样本,可以用来开发模板模式。
由于我们已经概述了模板必须执行的步骤,因此我们可以从定义包含步骤的基类开始。 每个步骤都有其自己的方法(以使其易于选择性地覆盖任何一个步骤),并且我们还有一个管理方法可以依次调用这些步骤。 如果没有任何方法内容,则可能是这样:
class QueryTemplate:
def connect(self):
pass
def construct_query(self):
pass
def do_query(self):
pass
def format_results(self):
pass
def output_results(self):
pass
def process_format(self):
self.connect()
self.construct_query()
self.do_query()
self.format_results()
self.output_results()
process_format方法是外部客户端调用的主要方法。 它确保每个步骤均按顺序执行,但并不关心该步骤是在此类中还是在子类中实现的。 对于我们的示例,我们知道两个类之间的三个方法将完全相同:
import sqlite3
class QueryTemplate:
def connect(self):
self.conn = sqlite3.connect("sales.db")
def construct_query(self):
raise NotImplementedError()
def do_query(self):
results = self.conn.execute(self.query)
self.results = results.fetchall()
def format_results(self):
output = []
for row in self.results:
row =[str(i) for i in row]
output.append(", ".join(row))
self.formatted_results = "\n".join(output)
def output_results(self):
raise NotImplementedError()
为了帮助实现子类,未指定的两个方法将引发NotImplementedError。 当抽象基类看起来过于沉重时,这是在 Python 中指定抽象接口的常用方法。 这些方法可能具有空的实现(使用pass),或者可能尚未完全指定。 然而,提高NotImplementedError可以帮助程序员理解该类是要被子类化的,并且这些方法将被覆盖。 空方法或不存在的方法很难识别为需要实现,如果我们忘记实现它们,则很难调试。
现在,我们有了一个模板类,该模板类负责处理无聊的细节,但具有足够的灵活性以允许执行和格式化各种查询。 最好的部分是,如果我们想将数据库引擎从 SQLite 更改为另一个数据库引擎(例如 py-postgresql),则只需在此模板类中执行此操作,而不必触摸 我们可能已经编写了两个(或两百个)子类。
现在让我们看一下具体的类:
import datetime
class NewVehiclesQuery(QueryTemplate):
def construct_query(self):
self.query = "select * from Sales where new='true'"
def output_results(self):
print(self.formatted_results)
class UserGrossQuery(QueryTemplate):
def construct_query(self):
self.query = ("select salesperson, sum(amt) " +
" from Sales group by salesperson")
def output_results(self):
filename = "gross_sales_{0}".format(
datetime.date.today().strftime("%Y%m%d")
)
with open(filename, 'w') as outfile:
outfile.write(self.formatted_results)
考虑到它们在做什么,实际上这两个类很短:连接到数据库,执行查询,格式化结果并输出。 超类负责重复性工作,但让我们轻松指定在任务之间有所不同的那些步骤。 此外,我们还可以轻松更改基类中提供的步骤。 例如,如果我们要输出除逗号分隔的字符串以外的内容(例如:要上传到网站的 HTML 报告),我们仍然可以覆盖format_results。



十二、Python 设计模式 II
在本章中,我们将介绍更多的设计模式。 再次,我们将介绍规范示例以及 Python 中的所有常见替代实现。 我们将讨论:
- 适配器模式
- 外墙图案
- 延迟初始化和 flyweight 模式
- 命令模式
- 抽象工厂模式
- 构图模式
适配器模式
与中的大多数模式不同,我们在第 8 章,“字符串和序列化”中介绍了该适配器模式,旨在与现有代码进行交互。 我们不会设计实现适配器模式的全新对象集。 适配器用于允许两个预先存在的对象一起工作,即使它们的接口不兼容也是如此。 就像允许 VGA 投影仪插入 HDMI 端口的显示适配器一样,适配器对象位于两个不同的接口之间,可以在它们之间进行即时转换。 适配器对象的唯一目的是执行此转换作业。 适应可能需要执行各种任务,例如将参数转换为不同的格式,重新排列参数的顺序,调用名称不同的方法或提供默认参数。
在结构上,适配器模式类似于简化的装饰器模式。 装饰器通常提供与其替换的相同接口,而适配器在两个不同的接口之间映射。 这是 UML 形式:

在这里,Interface1 是,它希望调用称为 make_action(some,arguments)的方法。 我们已经有了这个完美的 Interface2 类,它可以完成我们想要的所有事情(并且为了避免重复,我们不想重写它!),但是它提供了一种名为 different_action(other ,参数)代替。 适配器类实现 make_action 接口,并将参数映射到现有接口。
这样做的好处是,从一个接口映射到另一个接口的代码都在一个地方。 替代方案确实非常丑陋; 每当我们需要访问此代码时,我们都必须在多个位置执行翻译。
例如,假设我们有以下预先存在的类,该类采用格式为“ YYYY-MM-DD”的字符串日期并计算某天的年龄:
class AgeCalculator:
def __init__(self, birthday):
self.year, self.month, self.day = (
int(x) for x in birthday.split('-'))
def calculate_age(self, date):
year, month, day = (
int(x) for x in date.split('-'))
age = year - self.year
if (month,day) < (self.month,self.day):
age -= 1
return age
这是一个非常简单的类,可以完成预期的工作。 但是我们不得不怀疑程序员在想什么,使用特定格式的字符串而不是使用 Python 极其有用的内置datetime库。 作为尽职尽责的程序员,他们尽可能地重用代码,因此我们编写的大多数程序都将与datetime对象(而不是字符串)进行交互。
我们有个选项来解决这种情况; 我们可以重写该类以接受datetime对象,无论如何这可能会更准确。 但是,如果此类是由第三方提供的,并且我们不知道或无法更改其内部结构,则需要尝试其他方法。 我们可以按原样使用该类,并且每当要计算datetime.date对象的年龄时,都可以调用datetime.date.strftime('%Y-%m-%d')将其转换为正确的格式。 但是这种转换会在很多地方发生,更糟糕的是,如果我们将%m输入错误的名称为%M,它将为我们提供当前分钟而不是输入的月份! 试想一下,如果您在十几个不同的地方写过这篇文章,那么当您意识到自己的错误时,只需回头修改它即可。 它不是可维护的代码,并且破坏了 DRY 原理。
相反,我们可以编写一个适配器,该适配器允许将正常日期插入到正常的AgeCalculator类中:
import datetime
class DateAgeAdapter:
def _str_date(self, date):
return date.strftime("%Y-%m-%d")
def __init__(self, birthday):
birthday = self._str_date(birthday)
self.calculator = AgeCalculator(birthday)
def get_age(self, date):
date = self._str_date(date)
return self.calculator.calculate_age(date)
该适配器将datetime.date和datetime.time(它们与strftime具有相同的接口)转换为我们原始AgeCalculator可以使用的字符串。 现在,我们可以在新界面中使用原始代码。 我将方法签名更改为get_age,以说明调用接口也可能正在寻找其他方法名称,而不仅仅是其他类型的参数。
创建类作为适配器是实现此模式的常用方法,但是与往常一样,在 Python 中还有其他方法可以实现。 继承和多重继承可用于向类添加功能。 例如,我们可以在date类上添加一个适配器,使其与原始AgeCalculator类一起使用:
import datetime
class AgeableDate(datetime.date):
def split(self, char):
return self.year, self.month, self.day
像这样的代码让人怀疑 Python 是否应该合法。 我们在子类中添加了split方法,该方法采用单个参数(我们将其忽略)并返回年,月和日的元组。 这对于原始的AgeCalculator类来说是完美无缺的,因为代码在特殊格式的字符串上调用strip,在这种情况下,strip返回年,月和日的元组。 AgeCalculator代码仅关心strip是否存在并返回可接受的值; 不管我们是否真的传入了一个字符串。 真的行:
>>> bd = AgeableDate(1975, 6, 14)
>>> today = AgeableDate.today()
>>> today
AgeableDate(2015, 8, 4)
>>> a = AgeCalculator(bd)
>>> a.calculate_age(today)
40
它有效,但这是一个愚蠢的想法。 在这种特定情况下,这样的适配器将难以维护。 我们很快就会忘记为什么我们需要向date类添加strip方法。 方法名称不明确。 这可能是适配器的本性,但是显式创建适配器而不是使用继承通常可以阐明其目的。
除了继承之外,有时我们还可以使用猴子修补将方法添加到现有类中。 它不能与datetime对象一起使用,因为它不允许在运行时添加属性,但是在普通类中,我们只能添加一个新方法,该方法提供调用代码所需的已适配接口。 或者,我们可以扩展AgeCalculator本身或对其进行猴子补丁,以更适合我们需求的方式替换calculate_age方法。
最后,通常可以将函数用作适配器。 这显然不适合适配器模式的实际设计,但是如果我们回想起函数本质上是使用__call__方法的对象,则它将成为适配器的明显改编。
外墙图案
外观模式旨在为复杂的组件系统提供简单的界面。 对于复杂的任务,我们可能需要直接与这些对象进行交互,但是对于系统而言,通常不需要使用“典型”的操作,而无需进行这些复杂的交互。 外观模式允许我们定义一个新对象,该对象封装了系统的这种典型用法。 每当我们想要访问通用功能时,我们都可以使用单个对象的简化接口。 如果项目的另一部分需要访问更复杂的功能,则它仍然能够直接与系统进行交互。 外观模式的 UML 图实际上取决于子系统,但是以一种模糊的方式,它看起来像这样:

在许多方面,外墙就像一个适配器。 主要区别在于,外观正试图从复杂的接口中抽象出一个更简单的接口,而适配器仅试图将一个现有接口映射到另一个接口。
让我们为电子邮件应用编写一个简单的外观。 正如在第 7 章和 Python 面向对象的快捷方式中所看到的那样,用于在 Python 中发送电子邮件的低级库非常复杂。 用于接收消息的两个库更糟。
最好有一个简单的类,该类允许我们发送一封电子邮件,并列出 IMAP 或 POP3 连接上收件箱中当前的电子邮件。 为了使我们的示例简短,我们将继续使用 IMAP 和 SMTP:碰巧处理电子邮件的两个完全不同的子系统。 我们的外观仅执行两项任务:向特定地址发送电子邮件,以及检查 IMAP 连接上的收件箱。 它对连接进行了一些常见的假设,例如 SMTP 和 IMAP 的主机位于同一地址,两者的用户名和密码相同,并且它们使用标准端口。 这涵盖了许多电子邮件服务器的情况,但是如果程序员需要更大的灵活性,则他们总是可以绕过立面并直接访问两个子系统。
该类用电子邮件服务器的主机名,用户名和登录密码初始化:
import smtplib
import imaplib
class EmailFacade:
def __init__(self, host, username, password):
self.host = host
self.username = username
self.password = password
send_email方法格式化电子邮件地址和消息,然后使用smtplib发送。 这不是一项复杂的任务,但是需要花很多时间来调整传递给外观的“自然”输入参数的正确格式,以使smtplib能够发送消息:
def send_email(self, to_email, subject, message):
if not "@" in self.username:
from_email = "{0}@{1}".format(
self.username, self.host)
else:
from_email = self.username
message = ("From: {0}\r\n"
"To: {1}\r\n"
"Subject: {2}\r\n\r\n{3}").format(
from_email,
to_email,
subject,
message)
smtp = smtplib.SMTP(self.host)
smtp.login(self.username, self.password)
smtp.sendmail(from_email, [to_email], message)
该方法开头的if语句正在捕获username是整个“发件人”电子邮件地址还是只是@符号左侧的部分; 不同的主机以不同的方式处理登录详细信息。
最后,获取当前收件箱中消息的代码是一片混乱。 IMAP 协议经过了过度的精心设计,imaplib标准库只是该协议的薄层:
def get_inbox(self):
mailbox = imaplib.IMAP4(self.host)
mailbox.login(bytes(self.username, 'utf8'),
bytes(self.password, 'utf8'))
mailbox.select()
x, data = mailbox.search(None, 'ALL')
messages = []
for num in data[0].split():
x, message = mailbox.fetch(num, '(RFC822)')
messages.append(message[0][1])
return messages
现在,如果将全部加在一起,我们将拥有一个简单的 Facade 类,该类可以以相当直接的方式发送和接收消息,这比必须直接与这些复杂的库进行交互要简单得多。
尽管在 Python 社区中很少使用,但是外观模式是 Python 生态系统不可或缺的一部分。 由于 Python 强调语言的可读性,因此语言及其库都倾向于为复杂任务提供易于理解的界面。 例如,for循环,list理解和生成器都是更复杂的迭代器协议的基础。 defaultdict实现是一个立面,可以抽象化字典中不存在键时烦人的角落情况。 第三方请求库是 HTTP 请求的可读性较弱的库的强大基础。
轻量化模式
轻量级模式是内存优化模式。 假定内置垃圾收集器会照顾好它们,那么 Python 新手往往会忽略内存优化。 这通常是完全可以接受的,但是当开发具有许多相关对象的大型应用时,关注内存问题可以带来巨大的回报。
flyweight 模式基本上确保了共享状态的对象可以为该共享状态使用相同的内存。 它通常仅在程序显示出内存问题后才实施。 在某些情况下,从一开始就设计最佳配置可能很有意义,但请记住,过早的优化是创建过于复杂而难以维护的程序的最有效方法。
让我们看一下 flyweight 模式的 UML 图:

每个 Flyweight 都没有没有特定状态; 每当需要在 SpecificState 上执行操作时,该状态就需要通过调用代码传递到 Flyweight 中。 传统上,返回轻量级的工厂是一个单独的对象。 它的目的是为给定密钥返回一个飞行重量,以标识该飞行重量。 它的工作方式类似于我们在第 10 章,“Python 设计模式 I” 中讨论的单例模式; 如果存在飞重,我们将其退还; 否则,我们将创建一个新的。 在许多语言中,工厂不是作为单独的对象而是作为Flyweight类本身的静态方法实现的。
考虑一下汽车销售的库存系统。 每辆汽车都有特定的序列号和特定的颜色。 但是,关于该车的大多数细节对于特定型号的所有车都是相同的。 例如,本田飞度 DX 车型是功能不多的裸车。 LX 型号具有 A / C,倾斜,巡航,电动窗和锁。 Sport 车型配备花式轮毂,USB 充电器和扰流板。 如果没有飞重模式,则每个单独的汽车对象都必须存储一长串它具有和不具有的功能。 考虑到本田一年销售的汽车数量,这将增加大量的内存浪费。 使用 flyweight 模式,我们可以使共享对象成为与模型关联的功能列表,然后简单地为单个车辆引用该模型以及序列号和颜色。 在 Python 中,flyweight 工厂通常使用时髦的__new__构造函数来实现,类似于我们对 singleton 模式所做的事情。 与 Singleton 不同,后者仅需要返回该类的一个实例,而我们需要能够根据键返回不同的实例。 我们可以将项目存储在字典中,然后根据关键字进行查找。 但是,这种解决方案是有问题的,因为只要该项在字典中,它就会一直保留在内存中。 如果我们售罄 LX 型号 Fits,则不再需要 Fit 配重,但仍将在字典中。 当然,每当我们卖出一辆车时,我们都可以清理一下,但这不是垃圾收集器的作用吗?
我们可以利用 Python 的weakref模块来解决此问题。 这个模块提供了一个WeakValueDictionary对象,该对象基本上允许我们将项目存储在字典中,而无需垃圾收集器关心它们。 如果某个值位于弱引用的字典中,并且在应用中的任何地方都没有存储对该对象的其他引用(也就是说,我们售罄了 LX 模型),那么垃圾收集器最终将为我们清理数据。
让我们首先为我们的汽车轻量化建立工厂:
import weakref
class CarModel:
_models = weakref.WeakValueDictionary()
def __new__(cls, model_name, *args, **kwargs):
model = cls._models.get(model_name)
if not model:
model = super().__new__(cls)
cls._models[model_name] = model
return model
基本上,每当我们使用给定名称构造一个新的 flyweight 时,我们首先会在弱引用字典中查找该名称; 如果存在,我们返回该模型; 如果没有,我们创建一个新的。 无论哪种方式,我们都知道每次都会调用 flyweight 上的__init__方法,无论它是新对象还是现有对象。 因此,我们的__init__方法如下所示:
def __init__(self, model_name, air=False, tilt=False,
cruise_control=False, power_locks=False,
alloy_wheels=False, usb_charger=False):
if not hasattr(self, "initted"):
self.model_name = model_name
self.air = air
self.tilt = tilt
self.cruise_control = cruise_control
self.power_locks = power_locks
self.alloy_wheels = alloy_wheels
self.usb_charger = usb_charger
self.initted=True
if语句确保我们仅在首次调用__init__时初始化对象。 这意味着我们稍后可以仅使用模型名称来调用工厂,并返回相同的 flyweight 对象。 但是,由于如果没有外部引用来控制 flyweight,则必须对其进行垃圾回收,因此我们必须注意不要意外创建具有空值的新 flyweight。
让我们在飞行重量中添加一种方法,该方法假设在特定型号的车辆上查找序列号,并确定该序列号是否涉及任何事故。 此方法需要访问汽车的序列号,每个序列号各不相同; 它不能与飞行重量一起存储。 因此,此数据必须通过调用代码传递到方法中:
def check_serial(self, serial_number):
print("Sorry, we are unable to check "
"the serial number {0} on the {1} "
"at this time".format(
serial_number, self.model_name))
我们可以定义一个类,该类存储附加信息以及对 flyweight 的引用:
class Car:
def __init__(self, model, color, serial):
self.model = model
self.color = color
self.serial = serial
def check_serial(self):
return self.model.check_serial(self.serial)
我们还可以跟踪可用模型以及批次中的各个汽车:
>>> dx = CarModel("FIT DX")
>>> lx = CarModel("FIT LX", air=True, cruise_control=True,
... power_locks=True, tilt=True)
>>> car1 = Car(dx, "blue", "12345")
>>> car2 = Car(dx, "black", "12346")
>>> car3 = Car(lx, "red", "12347")
现在,让我们演示工作中的弱引用:
>>> id(lx)
3071620300
>>> del lx
>>> del car3
>>> import gc
>>> gc.collect()
0
>>> lx = CarModel("FIT LX", air=True, cruise_control=True,
... power_locks=True, tilt=True)
>>> id(lx)
3071576140
>>> lx = CarModel("FIT LX")
>>> id(lx)
3071576140
>>> lx.air
True
id函数告诉我们对象的唯一标识符。 当我们第二次调用它时,在删除所有对 LX 模型的引用并强制进行垃圾回收之后,我们看到 ID 已更改。 CarModel __new__工厂字典中的值已删除,并创建了一个新值。 但是,如果我们然后尝试构造第二个CarModel实例,它将返回相同的对象(ID 相同),并且即使我们在第二个调用中未提供任何参数,air变量仍 设置为True。 这意味着对象没有像我们设计的那样第二次初始化。
显然,使用 flyweight 模式可能比仅将特征存储在单个汽车类上更为复杂。 我们什么时候应该选择使用它? 轻量级模式旨在节省内存; 如果我们有成千上万个相似的对象,则将相似的属性组合成一个 flyweight 会对内存消耗产生巨大影响。 优化 CPU,内存或磁盘空间的编程解决方案通常比未优化的同类导致更复杂的代码。 因此,在决定代码的可维护性与优化之间权衡取舍很重要。 在选择优化时,请尝试使用诸如 flyweight 之类的模式,以确保优化引入的复杂性仅限于代码的单个(详细记录)部分。
命令模式
命令模式通常在以后的时间中,在必须执行的动作与调用这些动作的对象之间增加了一个抽象级别。 在命令模式中,客户端代码创建一个Command对象,该对象可以在以后执行。 该对象知道一个接收者对象,当在其上执行命令时,该对象将管理自己的内部状态。 Command对象实现特定的接口(通常具有execute或do_action方法,并跟踪执行该操作所需的任何参数。最后,一个或多个Invoker对象在该对象处执行命令)。 正确的时间。
这是 UML 图:

命令模式的常见示例是图形窗口上的操作。 通常,可以通过菜单栏上的菜单项,键盘快捷键,工具栏图标或上下文菜单来调用动作。 这些都是Invoker对象的示例。 实际发生的动作,例如Exit,Save或Copy是CommandInterface的实现。 可能会显示Receivers的示例包括一个用于接收退出的 GUI 窗口,一个用于接收保存的文档以及一个用于接收复制命令的ClipboardManager。
让我们实现一个简单的命令模式,该模式为Save和Exit操作提供命令。 我们将从一些适度的接收器类开始:
import sys
class Window:
def exit(self):
sys.exit(0)
class Document:
def __init__(self, filename):
self.filename = filename
self.contents = "This file cannot be modified"
def save(self):
with open(self.filename, 'w') as file:
file.write(self.contents)
这些模拟类为在工作环境中可能会做更多工作的对象建模。 该窗口将需要处理鼠标移动和键盘事件,而文档将需要处理字符的插入,删除和选择。 但是对于我们的示例,这两个类将满足我们的需求。
现在让我们定义一些调用程序类。 这些将对可能发生的工具栏,菜单和键盘事件进行建模。 同样,它们实际上并没有连接任何东西,但是我们可以看到如何将它们与命令,接收器和客户端代码分离:
class ToolbarButton:
def __init__(self, name, iconname):
self.name = name
self.iconname = iconname
def click(self):
self.command.execute()
class MenuItem:
def __init__(self, menu_name, menuitem_name):
self.menu = menu_name
self.item = menuitem_name
def click(self):
self.command.execute()
class KeyboardShortcut:
def __init__(self, key, modifier):
self.key = key
self.modifier = modifier
def keypress(self):
self.command.execute()
注意各种操作方法如何分别在各自的命令上调用execute方法吗? 此代码未显示在每个对象上设置的command属性。 可以将它们传递到__init__函数中,但是由于可以更改它们(例如,使用可自定义的键绑定编辑器),因此以后在对象上设置属性更加有意义。
现在,让我们自己连接命令:
class SaveCommand:
def __init__(self, document):
self.document = document
def execute(self):
self.document.save()
class ExitCommand:
def __init__(self, window):
self.window = window
def execute(self):
self.window.exit()
这些命令很简单; 它们演示了基本模式,但是必须注意,必要时我们可以使用命令存储状态和其他信息。 例如,如果我们有一个插入字符的命令,我们可以保持当前插入字符的状态。
现在,我们要做的就是连接一些客户端和测试代码以使命令起作用。 对于基本测试,我们可以在脚本末尾添加以下内容:
window = Window()
document = Document("a_document.txt")
save = SaveCommand(document)
exit = ExitCommand(window)
save_button = ToolbarButton('save', 'save.png')
save_button.command = save
save_keystroke = KeyboardShortcut("s", "ctrl")
save_keystroke.command = save
exit_menu = MenuItem("File", "Exit")
exit_menu.command = exit
首先,我们创建两个接收器和两个命令。 然后,我们创建几个可用的调用程序,并在每个调用程序上设置正确的命令。 为了进行测试,我们可以使用python3 -i filename.py并运行exit_menu.click()这样的代码,该代码将结束程序,或者运行save_keystroke.keystroke()这样的代码,该代码将保存虚假文件。
不幸的是,前面的示例并没有让人感觉很 Python 化。 他们有很多“样板代码”(什么都不做,只为模式提供结构的代码),并且Command类彼此之间都非常相似。 也许我们可以创建一个将函数作为回调的通用命令对象?
其实为什么要打扰? 我们可以仅对每个命令使用函数或方法对象吗? 代替使用execute()方法的对象,我们可以编写一个函数并将其直接用作命令。 这是 Python 中命令模式的常见范例:
import sys
class Window:
def exit(self):
sys.exit(0)
class MenuItem:
def click(self):
self.command()
window = Window()
menu_item = MenuItem()
menu_item.command = window.exit
现在看起来更像 Python。 乍一看,好像我们已经完全删除了命令模式,并且我们已经紧密连接了menu_item和Window类。 但是,如果我们仔细观察,就会发现根本没有紧密的耦合。 像以前一样,可以在MenuItem上将任何可调用对象设置为命令。 并且Window.exit方法可以附加到任何调用程序。 命令模式的大多数灵活性都得到了保留。 为了牺牲可读性,我们已经牺牲了完全的去耦,但是在我看来,与许多 Python 程序员相比,此代码比完全抽象的版本更具可维护性。
当然,由于我们可以向任何对象添加__call__方法,因此我们不仅限于函数。 当被调用的方法不必维护状态时,上一个示例是一个有用的快捷方式,但是在更高级的用法中,我们也可以使用以下代码:
class Document:
def __init__(self, filename):
self.filename = filename
self.contents = "This file cannot be modified"
def save(self):
with open(self.filename, 'w') as file:
file.write(self.contents)
class KeyboardShortcut:
def keypress(self):
self.command()
class SaveCommand:
def __init__(self, document):
self.document = document
def __call__(self):
self.document.save()
document = Document("a_file.txt")
shortcut = KeyboardShortcut()
save_command = SaveCommand(document)
shortcut.command = save_command
在中,我们有一些看起来像第一个命令模式,但有点惯用语。 如您所见,使用 execute 方法使调用者调用可调用对象而不是命令对象,并没有以任何方式限制我们。 实际上,它给了我们更多的灵活性。 我们可以在函数正常工作时直接链接到函数,但是当情况需要时,我们可以构建一个完整的可调用命令对象。
命令模式通常被扩展为支持不可撤销的命令。 例如,文本程序不仅可以使用execute方法将每个插入内容包装在单独的命令中,还可以使用undo方法来删除该插入内容。 图形程序可以在具有undo方法的命令中包装每个绘图动作(矩形,直线,手绘像素等),该方法会将像素重置为其原始状态。 在这种情况下,命令模式的解耦更加有用,因为每个动作必须保持足够的状态才能在以后撤消该动作。
抽象工厂模式
当我们有多个可能依赖于某些配置或平台问题的系统实现时,通常使用抽象工厂模式。 调用代码从抽象工厂请求一个对象,而不确切知道将返回什么类的对象。 返回的基础实现可能取决于多种因素,例如当前的语言环境,操作系统或本地配置。
抽象工厂模式的常见示例包括独立于操作系统的工具包,数据库后端以及特定于国家/地区的格式化程序或计算器的代码。 独立于操作系统的 GUI 工具包可能使用抽象的工厂模式,该模式将返回 Windows 下的 WinForm 小部件,Mac 下的 Cocoa 小部件,Gnome 下的 GTK 小部件以及 KDE 下的 QT 小部件。 Django 提供了一个抽象工厂,该工厂根据当前站点的配置设置返回一组对象关系类,以与特定的数据库后端(MySQL,PostgreSQL,SQLite 等)进行交互。 如果应用需要部署在多个地方,则每个人可以通过仅更改一个配置变量来使用不同的数据库后端。 不同的国家/地区使用不同的系统来计算零售商品的税金,小计和总计; 抽象工厂可以返回特定的税收计算对象。
没有特定的示例,很难理解用于抽象工厂模式的 UML 类图,因此让我们先回过头来创建一个具体的示例。 我们将创建一组格式化程序,这些格式化程序取决于特定的语言环境,并帮助我们格式化日期和货币。 将有一个抽象工厂类来选择特定工厂,以及几个示例混凝土工厂,一个用于法国,一个用于美国。 这些中的每一个都会为日期和时间创建格式化程序对象,可以查询这些对象以格式化特定的值。 如下图:

将该图像与较早的较简单文本进行比较,可以发现一张图片并不总是值一千个字,尤其是考虑到我们甚至在这里甚至都不允许使用工厂选择代码。
当然,在 Python 中,我们无需实现任何接口类,因此可以丢弃DateFormatter,CurrencyFormatter和FormatterFactory。 格式类本身非常简单,如果冗长的话:
class FranceDateFormatter:
def format_date(self, y, m, d):
y, m, d = (str(x) for x in (y,m,d))
y = '20' + y if len(y) == 2 else y
m = '0' + m if len(m) == 1 else m
d = '0' + d if len(d) == 1 else d
return("{0}/{1}/{2}".format(d,m,y))
class USADateFormatter:
def format_date(self, y, m, d):
y, m, d = (str(x) for x in (y,m,d))
y = '20' + y if len(y) == 2 else y
m = '0' + m if len(m) == 1 else m
d = '0' + d if len(d) == 1 else d
return("{0}-{1}-{2}".format(m,d,y))
class FranceCurrencyFormatter:
def format_currency(self, base, cents):
base, cents = (str(x) for x in (base, cents))
if len(cents) == 0:
cents = '00'
elif len(cents) == 1:
cents = '0' + cents
digits = []
for i,c in enumerate(reversed(base)):
if i and not i % 3:
digits.append(' ')
digits.append(c)
base = ''.join(reversed(digits))
return "{0}€{1}".format(base, cents)
class USACurrencyFormatter:
def format_currency(self, base, cents):
base, cents = (str(x) for x in (base, cents))
if len(cents) == 0:
cents = '00'
elif len(cents) == 1:
cents = '0' + cents
digits = []
for i,c in enumerate(reversed(base)):
if i and not i % 3:
digits.append(',')
digits.append(c)
base = ''.join(reversed(digits))
return "${0}.{1}".format(base, cents)
这些类使用一些基本的字符串操作来尝试将各种可能的输入(整数,不同长度的字符串等)转换为以下格式:
| |美国
|
法国
|
| --- | --- | --- |
| 日期 | mm-dd-yyyy | dd / mm / yyyy |
| 货币 | $14,500.50 | 14 500€50 |
可能显然会在此代码中的输入上得到更多验证,但在此示例中,让我们保持其简单明了。
现在我们已经设置了格式化程序,我们只需要创建格式化程序工厂即可:
class USAFormatterFactory:
def create_date_formatter(self):
return USADateFormatter()
def create_currency_formatter(self):
return USACurrencyFormatter()
class FranceFormatterFactory:
def create_date_formatter(self):
return FranceDateFormatter()
def create_currency_formatter(self):
return FranceCurrencyFormatter()
现在,我们设置了选择适当格式器的代码。 由于这种事情只需要设置一次,因此我们可以将其设置为单例-除非单例在 Python 中不是很有用。 让我们仅将当前格式化程序设为模块级变量:
country_code = "US"
factory_map = {
"US": USAFormatterFactory,
"FR": FranceFormatterFactory}
formatter_factory = factory_map.get(country_code)()
在此示例中,我们对当前国家/地区代码进行了硬编码。 实际上,它可能会自省区域设置,操作系统或配置文件以选择代码。 本示例使用字典将国家/地区代码与工厂类别相关联。 然后我们从字典中获取正确的类并实例化它。
当我们想增加对更多国家的支持时,很容易看出需要做什么:创建新的格式化程序类和抽象工厂本身。 请记住,Formatter类可能会被重用; 例如,加拿大的货币格式与美国相同,但其日期格式比其南部邻国更明智。
抽象工厂通常返回一个单例对象,但这不是必需的。 在我们的代码中,每次调用时都会返回每个格式化程序的新实例。 没有理由不能将格式化程序存储为实例变量,并且为每个工厂返回相同的实例。
回顾这些示例,我们再次看到,似乎有很多工厂的样板代码,在 Python 中根本不需要。 通常,通过为每种工厂类型(例如:美国和法国)使用单独的模块,然后确保在工厂模块中访问正确的模块,可以更轻松地满足可能需要抽象工厂的要求。 此类模块的软件包结构可能如下所示:
localize/
__init__.py
backends/
__init__.py
USA.py
France.py
…
诀窍在于,localize软件包中的__init__.py可以包含将所有请求重定向到正确后端的逻辑。 有很多种方法可以做到这一点。
如果我们知道后端永远不会动态更改(即不重新启动),则可以在__init__.py中放置一些if语句来检查当前国家/地区代码,并使用通常不可接受的from .backends.USA import *语法 从适当的后端导入所有变量。 或者,我们可以导入每个后端并设置current_backend变量以指向特定模块:
from .backends import USA, France
if country_code == "US":
current_backend = USA
根据我们选择的解决方案,我们的客户代码必须调用localize.format_date或localize.current_backend.format_date才能以当前国家/地区的语言环境设置日期格式。 最终结果比原始的抽象工厂模式具有更多的 Python 风格,并且在典型用法中同样灵活。
复合图案
复合模式允许从简单的组件构建复杂的树状结构。 这些组件称为复合对象,它们的行为类似于容器,而类似于变量,取决于它们是否具有子组件。 复合对象是容器对象,其中内容实际上可能是另一个复合对象。
传统上,复合对象中的每个组件都必须是叶节点(不能包含其他对象)或复合节点。 关键是复合节点和叶节点都可以具有相同的接口。 UML 图非常简单:

但是,这种简单的模式允许我们创建元素的复杂排列,所有元素都满足组件对象的接口。 这是这种复杂安排的具体实例:

复合模式通常在类似文件/文件夹的树中有用。 无论树中的节点是普通文件还是文件夹,它都仍会受到诸如移动,复制或删除节点之类的操作的影响。 我们可以创建一个支持这些操作的组件接口,然后使用一个复合对象表示文件夹,并使用叶节点表示普通文件。
当然,在 Python 中,我们再次可以利用鸭子类型来隐式提供接口,因此我们只需要编写两个类。 让我们首先定义这些接口:
class Folder:
def __init__(self, name):
self.name = name
self.children = {}
def add_child(self, child):
pass
def move(self, new_path):
pass
def copy(self, new_path):
pass
def delete(self):
pass
class File:
def __init__(self, name, contents):
self.name = name
self.contents = contents
def move(self, new_path):
pass
def copy(self, new_path):
pass
def delete(self):
pass
对于每个文件夹(复合)对象,我们维护一个儿童字典。 通常,列表就足够了,但是在这种情况下,词典对于按名称查找孩子很有用。 我们的路径将被指定为由/字符分隔的节点名称,类似于 Unix shell 中的路径。
考虑到所涉及的方法,我们可以看到移动或删除节点的行为类似,无论它是文件节点还是文件夹节点。 但是,复制必须对文件夹节点进行递归复制,而复制文件节点则是微不足道的操作。
为了利用的类似操作优势,我们可以将一些常用方法提取到父类中。 让我们以废弃的Component接口并将其更改为基类为例:
class Component:
def __init__(self, name):
self.name = name
def move(self, new_path):
new_folder =get_path(new_path)
del self.parent.children[self.name]
new_folder.children[self.name] = self
self.parent = new_folder
def delete(self):
del self.parent.children[self.name]
class Folder(Component):
def __init__(self, name):
super().__init__(name)
self.children = {}
def add_child(self, child):
pass
def copy(self, new_path):
pass
class File(Component):
def __init__(self, name, contents):
super().__init__(name)
self.contents = contents
def copy(self, new_path):
pass
root = Folder('')
def get_path(path):
names = path.split('/')[1:]
node = root
for name in names:
node = node.children[name]
return node
在Component类上创建了,move和delete方法。 他们两个都访问我们尚未设置的神秘parent变量。 move方法使用模块级get_path函数,该函数从给定路径的预定义根节点中查找节点。 所有文件将被添加到该根节点或该节点的子节点。 对于move方法,目标应该是当前存在的文件夹,否则我们会收到错误消息。 与技术书籍中的许多示例一样,严重缺乏错误处理,以帮助重点关注所考虑的原理。
让我们先设置一个神秘的parent变量; 在文件夹的add_child方法中会发生这种情况:
def add_child(self, child):
child.parent = self
self.children[child.name] = child
好吧,这很容易。 让我们看看我们的复合文件层次结构是否正常工作:
$ python3 -i 1261_09_18_add_child.py
>>> folder1 = Folder('folder1')
>>> folder2 = Folder('folder2')
>>> root.add_child(folder1)
>>> root.add_child(folder2)
>>> folder11 = Folder('folder11')
>>> folder1.add_child(folder11)
>>> file111 = File('file111', 'contents')
>>> folder11.add_child(file111)
>>> file21 = File('file21', 'other contents')
>>> folder2.add_child(file21)
>>> folder2.children
{'file21': <__main__.File object at 0xb7220a4c>}
>>> folder2.move('/folder1/folder11')
>>> folder11.children
{'folder2': <__main__.Folder object at 0xb722080c>, 'file111': <__main__.File object at 0xb72209ec>}
>>> file21.move('/folder1')
>>> folder1.children
{'file21': <__main__.File object at 0xb7220a4c>, 'folder11': <__main__.Folder object at 0xb722084c>}
是的,我们可以创建文件夹,将文件夹添加到其他文件夹,将文件添加到文件夹,然后移动它们! 在文件层次结构中,我们还能要求什么?
好吧,我们可以要求复制,但是为了保护树木,让我们将其保留为练习。
复合模式对于多种树状结构非常有用,包括 GUI 小部件层次结构,文件层次结构,树集,图形和 HTML DOM。 如先前的示例所示,当按照传统实现来实现时,这可能是 Python 中有用的模式。 有时,如果仅创建一棵浅树,我们可以摆脱列表列表或词典字典,而无需实现自定义组件,叶和复合类。 在其他时候,我们可以只实现一个复合类,而将叶子和复合对象作为一个类来处理。 另外,只要它们具有正确的接口,Python 的鸭子类型可以轻松地将其他对象添加到复合层次结构中。



十三、测试面向对象的程序
熟练的 Python 程序员同意测试是软件开发最重要的方面之一。 即使本章位于模块的末尾,也不是事后的想法。 到目前为止,我们学习的所有内容都将在编写测试时为我们提供帮助。 我们将研究:
- 单元测试和测试驱动开发的重要性
- 标准
unittest模块 py.test自动化测试套件mock模块- 代码覆盖率
- 使用
tox进行跨平台测试
为什么要测试?
大量的程序员已经知道测试他们的代码有多重要。 如果您在其中,请随意浏览本节。 您会在下一节中看到更多令人眼花。乱的内容-我们实际上在其中看到了如何在 Python 中进行测试。 如果您不相信测试的重要性,那么我保证您的代码已损坏,您只是不知道。 继续阅读!
有人认为测试在 Python 代码中更为重要,因为它具有动态特性。 诸如 Java 和 C ++ 之类的编译语言有时被认为是“安全的”,因为它们在编译时会进行类型检查。 但是,Python 测试很少检查类型。 他们正在检查值。 他们要确保在正确的时间设置正确的属性,或确保序列具有正确的长度,顺序和值。 这些更高层次的内容需要使用任何语言进行测试。 Python 程序员比其他语言的程序员进行更多测试的真正原因是,使用 Python 进行测试是如此容易!
但是为什么要测试? 我们真的需要测试吗? 如果我们不测试怎么办? 要回答这些问题,请从头开始编写井字游戏,而无需进行任何测试。 在完全编写之前不要运行它,请开始完成。 如果您将两个玩家都变成了人类玩家(没有人工智能),则井字游戏的实施相当简单。 您甚至不必尝试计算谁是赢家。 现在运行您的程序。 并修复所有错误。 那里有几个? 我在井字游戏实现中录制了八个,但不确定是否全部抓住了。 你是否?
我们需要测试我们的代码以确保它可以工作。 像我们刚才那样运行程序,并修复错误是测试的一种粗略形式。 Python 程序员能够编写几行代码并运行该程序,以确保这些行符合他们的期望。 但是更改几行代码会影响开发人员未意识到的程序部分,这些部分会受到更改的影响,因此不会对其进行测试。 此外,随着程序的增长,解释器可以通过该代码采用的各种路径也随之增长,并且迅速无法手动测试所有路径。
为了解决这个问题,我们编写了自动化测试。 这些是通过其他程序或程序的一部分自动运行某些输入的程序。 我们可以在几秒钟内运行这些测试程序,并涵盖了比每个程序员每次更改时都认为要测试的输入情况更多的输入情况。
编写测试的主要原因有四个:
- 确保代码按开发人员认为的方式工作
- 为了确保代码在我们进行更改时能够继续工作
- 确保开发人员了解要求
- 为了确保我们正在编写的代码具有可维护的接口
第一点实际上并不能证明编写测试所花的时间。 我们可以直接在交互式解释器中直接测试代码。 但是,当我们必须多次执行相同的测试操作序列时,只需较少的时间就可以自动执行这些步骤,然后在必要时运行它们。 无论何时在初始开发或维护版本中更改代码,最好都运行测试。 当我们拥有一套全面的自动化测试时,我们可以在代码更改后运行它们,并且知道我们没有无意间破坏了所有测试内容。
最后两个点更有趣。 当我们编写代码测试时,它可以帮助我们设计代码所需要的 API,接口或模式。 因此,如果我们误解了需求,编写测试可以帮助突出这种误解。 另一方面,如果我们不确定如何设计一个类,可以编写一个与该类交互的测试,这样我们就可以知道最自然的测试方法。 实际上,在编写要测试的代码之前编写测试通常是有益的。
测试驱动的开发
“首先编写测试”是测试驱动开发的口头禅。 测试驱动的开发将“未经测试的代码就是破坏的代码”的概念进一步向前发展,并建议只应对未经编写的代码进行未经测试的测试。 在为该代码编写测试之前,请勿编写任何代码。 因此,第一步是编写一个测试以证明代码可以工作。 显然,由于尚未编写代码,因此测试将失败。 然后编写确保测试通过的代码。 然后为下一段代码编写另一个测试。
测试驱动的开发很有趣。 它使我们可以解决一些难题。 然后,我们实现代码以解决难题。 然后,我们制作了一个更复杂的难题,并编写了解决新难题而又不解决前一个难题的代码。
测试驱动的方法有两个目标。 首先是要确保测试确实得到编写。 在我们编写代码之后,这样非常容易地说:“嗯,它似乎可以工作。我不必为此编写任何测试。这只是一个很小的变化,没有什么坏的。” 如果在编写代码之前已经编写了测试,那么我们将确切知道它的工作时间(因为测试将通过),并且将来我们会知道它是否被我们或其他人所做的更改破坏了 。
其次,编写测试首先迫使我们仔细考虑代码将如何与之交互。 它告诉我们对象需要具有哪些方法以及如何访问属性。 它帮助我们将最初的问题分解为较小的,可测试的问题,然后将经过测试的解决方案重新组合为也经过测试的较大的解决方案。 因此,编写测试可以成为设计过程的一部分。 通常,如果我们正在为新对象编写测试,则会发现设计中的异常迫使我们考虑软件的新方面。
作为一个具体示例,想象一下编写使用对象关系映射器将对象属性存储在数据库中的代码。 在此类对象中通常使用自动分配的数据库 ID。 我们的代码可能出于各种目的使用此 ID。 如果我们正在编写针对此类代码的测试,则在编写之前,我们可能会意识到我们的设计有问题,因为对象只有在将它们保存到数据库后才具有这些 ID。 如果我们想操作一个对象而不将其保存在测试中,则在基于错误前提编写代码之前,它将突出显示此问题。
测试使软件更好。 在我们发布软件之前编写测试可以使最终用户更好地了解或购买有缺陷的版本(我曾在那些依靠“用户可以测试”这一理念而 thr 壮成长的公司工作。这不是一个健康的商业模型!)。 在我们编写软件之前编写测试可以使它在首次编写时变得更好。
单元测试
让我们从 Python 的内置测试库开始探索。 该库为单元测试提供了通用接口。 单元测试专注于测试任何一种测试中可能的最少代码量。 每个测试仅测试可用代码总量的一个单位。
毫不奇怪,为此,Python 库称为unittest。 它提供了一些用于创建和运行单元测试的工具,其中最重要的是TestCase类。 此类提供了一组方法,使我们可以比较值,设置测试以及在完成后进行清理。
当我们要为特定任务编写一组单元测试时,我们创建TestCase的子类,并编写各个方法来进行实际测试。 这些方法都必须以名称test开头。 遵循此约定后,测试将作为测试过程的一部分自动运行。 通常,测试会在一个对象上设置一些值,然后运行一个方法,并使用内置的比较方法来确保计算出正确的结果。 这是一个非常简单的示例:
import unittest
class CheckNumbers(unittest.TestCase):
def test_int_float(self):
self.assertEqual(1, 1.0)
if __name__ == "__main__":
unittest.main()
此代码只是TestCase类的子类,并添加了一个调用TestCase.assertEqual方法的方法。 此方法将成功还是引发异常,具体取决于两个参数是否相等。 如果运行此代码,则unittest中的main函数将为我们提供以下输出:
.
--------------------------------------------------------------
Ran 1 test in 0.000s
OK
您知道浮点数和整数可以相等吗? 让我们添加一个失败的测试:
def test_str_float(self):
self.assertEqual(1, "1")
此代码的输出更加险恶,因为整数和字符串不视为相等:
.F
============================================================
FAIL: test_str_float (__main__.CheckNumbers)
--------------------------------------------------------------
Traceback (most recent call last):
File "simplest_unittest.py", line 8, in test_str_float
self.assertEqual(1, "1")
AssertionError: 1 != '1'
--------------------------------------------------------------
Ran 2 tests in 0.001s
FAILED (failures=1)
第一行上的点表示第一个测试(我们之前编写的测试)成功通过; 后面的字母F表明第二次测试失败。 然后,最后,它提供了一些信息输出,告诉我们测试失败的方式和地点,以及失败次数的摘要。
我们可以根据需要在一个TestCase类上拥有尽可能多的测试方法。 只要方法名称以test开头,测试运行程序将执行每个测试作为单独的测试。 每个测试应完全独立于其他测试。 先前测试的结果或计算对当前测试没有影响。 编写好的单元测试的关键是使每种测试方法都尽可能短,并在每个测试用例中测试一小部分代码。 如果您的代码似乎没有自然地分解成这样的可测试单元,则可能表明您的设计需要重新考虑。
断言方法
测试用例的总体布局是将某些变量设置为已知值,运行一个或多个函数,方法或过程,然后“证明”使用TestCase返回或计算出正确的预期结果 ]断言方法。
有几种不同的断言方法可用于确认已实现特定结果。 我们刚刚看到assertEqual,如果两个参数未通过相等性检查,将导致测试失败。 如果两个参数确实比较相等,则assertNotEqual相反。 assertTrue和assertFalse方法均接受单个表达式,并且如果该表达式未通过if测试,则会失败。 这些测试不检查布尔值True或False。 相反,它们测试的条件与使用if语句的条件相同:False,None,0或空列表,字典,字符串,集合或元组都会将调用传递给assertFalse方法,而非零数字,其中包含值的容器或值True在调用assertTrue方法时将成功。
assertRaises方法可用于确保特定的函数调用引发特定的异常,或者可以选择将其用作上下文管理器来包装内联代码。 如果with语句中的代码引发适当的异常,则测试通过。 否则,它将失败。 这是两个版本的示例:
import unittest
def average(seq):
return sum(seq) / len(seq)
class TestAverage(unittest.TestCase):
def test_zero(self):
self.assertRaises(ZeroDivisionError,
average,
[])
def test_with_zero(self):
with self.assertRaises(ZeroDivisionError):
average([])
if __name__ == "__main__":
unittest.main()
上下文管理器允许我们以正常编写代码的方式编写代码(通过调用函数或直接执行代码),而不必将函数调用包装在另一个函数调用中。
下表总结了其他几种断言方法:
|方法
|
描述
|
| --- | --- |
| assertGreater``assertGreaterEqual``assertLess``assertLessEqual | 接受两个可比较的对象,并确保指定的不等式成立。 |
| assertIn``assertNotIn | 确保元素是(或不是)容器对象中的元素。 |
| assertIsNone``assertIsNotNone | 确保一个元素是(或不是)确切的值None(但不是另一个假值)。 |
| assertSameElements | 确保两个容器对象具有相同的元素,忽略顺序。 |
| assertSequenceEqualassertDictEqual``assertSetEqual``assertListEqual``assertTupleEqual | 确保两个容器以相同的顺序具有相同的元素。 如果出现故障,请显示代码差异比较两个列表,以查看它们之间的区别。 最后四种方法还可以测试列表的类型。 |
每个断言方法都接受一个名为msg.的可选参数(如果提供),如果断言失败,它将包含在错误消息中。 这对于阐明期望的结果或解释可能在何处发生错误以导致断言失败的位置很有用。
减少样板并清理
在编写了一些小测试之后,我们经常发现我们必须对几个相关测试执行相同的设置代码。 例如,以下list子类具有三种用于统计计算的方法:
from collections import defaultdict
class StatsList(list):
def mean(self):
return sum(self) / len(self)
def median(self):
if len(self) % 2:
return self[int(len(self) / 2)]
else:
idx = int(len(self) / 2)
return (self[idx] + self[idx-1]) / 2
def mode(self):
freqs = defaultdict(int)
for item in self:
freqs[item] += 1
mode_freq = max(freqs.values())
modes = []
for item, value in freqs.items():
if value == mode_freq:
modes.append(item)
return modes
显然,我们将要使用这三种输入非常相似的方法来测试情况。 我们将想看看空列表,包含非数字值的列表或包含普通数据集的列表会发生什么。 我们可以使用TestCase类上的setUp方法对每个测试进行初始化。 此方法不接受任何参数,并允许我们在运行每个测试之前进行任意设置。 例如,我们可以在整数相同的列表上测试所有三种方法,如下所示:
from stats import StatsList
import unittest
class TestValidInputs(unittest.TestCase):
def setUp(self):
self.stats = StatsList([1,2,2,3,3,4])
def test_mean(self):
self.assertEqual(self.stats.mean(), 2.5)
def test_median(self):
self.assertEqual(self.stats.median(), 2.5)
self.stats.append(4)
self.assertEqual(self.stats.median(), 3)
def test_mode(self):
self.assertEqual(self.stats.mode(), [2,3])
self.stats.remove(2)
self.assertEqual(self.stats.mode(), [3])
if __name__ == "__main__":
unittest.main()
如果我们运行,则表明所有测试均通过。 首先请注意,从未在三个test_*方法中显式调用setUp方法。 测试套件代表我们执行此操作。 更重要的是,请注意test_median如何通过向列表添加附加的4来更改列表,但是当调用test_mode时,列表已返回setUp中指定的值(如果没有,则将返回 列表中的两个四分之一,mode方法将返回三个值)。 这表明setUp在每次测试之前被单独调用,以确保测试类以干净的状态开始。 测试可以以任何顺序执行,并且一个测试的结果不应依赖于任何其他测试。
除了setUp方法之外,TestCase还提供了无参数的tearDown方法,该方法可用于在运行该类的每个测试之后清除。 如果清理除了让对象被垃圾回收外还需要其他任何操作,这将很有用。 例如,如果我们正在测试执行文件 I / O 的代码,则我们的测试可能会创建新文件,这是测试的一个副作用。 tearDown方法可以删除这些文件,并确保系统与测试运行之前的状态相同。 测试用例永远都不应有副作用。 通常,我们根据测试方法的共同设置代码将测试方法分组为单独的TestCase子类。 几项要求相同或相似设置的测试将被放在一个类中,而要求不相关设置的测试将被放在另一类中。
组织和运行测试
单元测试的集合不需要很大的时间即可变得非常庞大且笨拙。 一次加载和运行所有测试很快变得很复杂。 这是单元测试的主要目标。 在我们的程序上运行所有测试并迅速回答“我最近的更改是否破坏了任何现有测试?”这个问题的答案很简单。
Python 的discover模块基本上在当前文件夹或子文件夹中查找名称以字符test开头的任何模块。 如果在这些模块的中找到任何TestCase对象,则将执行测试。 这是确保我们不会错过任何测试的无痛方法。 要使用它,请确保您的测试模块名为test_<something>.py,然后运行命令python3 -m unittest discover。
忽略测试失败
有时,已知测试失败,但是我们不希望测试套件报告失败。 这可能是因为已损坏或未完成的功能已编写了测试,但是我们目前并未专注于改进它。 之所以会发生这种情况,是因为某个功能仅在特定平台,Python 版本或特定库的高级版本上可用。 Python 为我们提供了一些装饰器,以将测试标记为预期失败或在已知条件下被跳过。
装饰者是:
expectedFailure()skip(reason)skipIf(condition, reason)skipUnless(condition, reason)
这些是使用 Python 装饰器语法应用的。 第一个不接受任何参数,只是告诉测试运行器在失败时不要将测试记录为失败。 skip方法更进一步,甚至不必费心进行测试。 它期望一个字符串参数描述为什么跳过测试。 其他两个装饰器接受两个参数,一个是指示是否应运行测试的布尔表达式,另一个是类似的描述。 在使用中,这三个装饰器可能像这样应用:
import unittest
import sys
class SkipTests(unittest.TestCase):
@unittest.expectedFailure
def test_fails(self):
self.assertEqual(False, True)
@unittest.skip("Test is useless")
def test_skip(self):
self.assertEqual(False, True)
@unittest.skipIf(sys.version_info.minor == 4,
"broken on 3.4")
def test_skipif(self):
self.assertEqual(False, True)
@unittest.skipUnless(sys.platform.startswith('linux'),
"broken unless on linux")
def test_skipunless(self):
self.assertEqual(False, True)
if __name__ == "__main__":
unittest.main()
第一次测试失败,但报告为预期失败; 第二项测试永远不会运行。 其他两个测试可能会运行,也可能不会运行,具体取决于当前的 Python 版本和操作系统。 在运行 Python 3.4 的 Linux 系统上,输出如下所示:
xssF
=============================================================
FAIL: test_skipunless (__main__.SkipTests)
--------------------------------------------------------------
Traceback (most recent call last):
File "skipping_tests.py", line 21, in test_skipunless
self.assertEqual(False, True)
AssertionError: False != True
--------------------------------------------------------------
Ran 4 tests in 0.001s
FAILED (failures=1, skipped=2, expected failures=1)
第一行的x表示预期失败; 这两个s字符表示已跳过测试,F表示实际失败,因为在我的系统上skipUnless的条件为True。
使用 py.test 进行测试
Python unittest模块需要大量样板代码来设置和初始化测试。 它基于非常流行的 Java JUnit 测试框架。 它甚至使用相同的方法名称(您可能已经注意到它们不符合 PEP-8 命名标准,该标准建议使用下划线而不是 CamelCase 来分隔方法名称中的单词)和测试布局。 尽管这对于用 Java 进行测试是有效的,但不一定是进行 Python 测试的最佳设计。
由于 Python 程序员喜欢其代码简洁明了,因此已经开发了标准库之外的其他测试框架。 最受欢迎的两个是py.test和nose。 前者更强大,并且对 Python 3 的支持时间更长,因此我们将在这里讨论。
由于py.test不属于标准库,因此您需要自己下载并安装; 您可以从 PyTest 主页获取。 该网站提供了适用于各种解释器和平台的全面安装说明,但通常可以使用更通用的 python 软件包安装程序 pip。 只需在命令行中输入pip install pytest,您就可以顺利进行了。
py.test具有与unittest模块完全不同的布局。 它不需要测试用例是类。 相反,它利用了 Python 函数是对象这一事实,并允许任何正确命名的函数表现得像测试一样。 它没有提供一堆用于声明相等性的自定义方法,而是使用assert语句来验证结果。 这使测试更具可读性和可维护性。 当我们运行py.test时,它将在当前文件夹中启动,并在该文件夹或子包中搜索名称以字符test_开头的任何模块。 如果此模块中的任何功能也以test开头,它们将作为单独的测试执行。 此外,如果模块中有任何名称以Test开头的类,则该类上以test_开头的任何方法也将在测试环境中执行。
让我们移植一下我们先前写给py.test的最简单的unittest示例:
def test_int_float():
assert 1 == 1.0
对于完全相同的测试,与第一个unittest示例中的六行相比,我们编写了两行更具可读性的代码。
但是,我们不禁止编写基于类的测试。 类对于将相关测试分组在一起或对需要访问该类上相关属性或方法的测试很有用。 此示例显示了具有通过测试和失败测试的扩展类; 我们将看到错误输出比unittest模块提供的错误输出更全面:
class TestNumbers:
def test_int_float(self):
assert 1 == 1.0
def test_int_str(self):
assert 1 == "1"
请注意,该类无需扩展任何特殊对象即可作为测试(尽管py.test可以正常运行标准unittest TestCases)。 如果运行py.test <filename>,则输出如下所示:
============== test session starts ==============
python: platform linux2 -- Python 3.4.1 -- pytest-2.6.4
test object 1: class_pytest.py
class_pytest.py .F
=================== FAILURES====================
___________ TestNumbers.test_int_str ____________
self = <class_pytest.TestNumbers object at 0x85b4fac>
def test_int_str(self):
> assert 1 == "1"
E assert 1 == '1'
class_pytest.py:7: AssertionError
====== 1 failed, 1 passed in 0.10 seconds =======
输出从有关平台和解释器的一些有用信息开始。 这对于在不同系统之间共享错误很有用。 第三行告诉我们要测试的文件的名称(如果拾取了多个测试模块,它们将全部显示),然后是我们在unittest模块中看到的熟悉的.F; .字符表示通过测试,而字母F表示失败。
运行所有测试后,将显示每个测试的错误输出。 它提供了局部变量的摘要(此示例中只有一个:self参数传递到函数中),发生错误的源代码以及错误消息的摘要。 另外,如果引发了AssertionError以外的异常,py.test将向我们提供完整的追溯,包括源代码引用。
默认情况下,如果测试成功,则py.test抑制print语句的输出。 这对于测试调试很有用; 当测试失败时,我们可以在测试中添加print语句,以在测试运行时检查特定变量和属性的值。 如果测试失败,则输出这些值以帮助诊断。 但是,一旦测试成功,就不会显示print语句输出,并且可以很容易地忽略它们。 我们不必通过删除print语句来“清理”输出。 如果测试由于将来的更改而再次失败,则调试输出将立即可用。
一种进行设置和清除的方法
py.test支持设置和拆卸方法,类似于unittest中使用的方法,但它提供了更大的灵活性。 由于它们很熟悉,因此我们将对其进行简要讨论,但是由于py.test为我们提供了强大的 funcargs 工具,因此它们在unittest模块中的使用并不广泛,我们将在下一节中对其进行讨论。
如果我们正在编写基于类的测试,则可以使用与unittest中调用setUp和tearDown基本上相同的方式使用称为setup_method和teardown_method的两种方法。 在类中的每个测试方法之前和之后调用它们,以执行设置和清除任务。 与unittest方法有一个区别。 两种方法都接受一个参数:表示要调用的方法的函数对象。
此外,py.test提供了其他设置和拆卸功能,使我们可以更好地控制何时执行设置和清除代码。 预计setup_class和teardown_class方法是类方法; 它们接受一个表示所讨论类的参数(没有self参数)。
最后,我们具有setup_module和teardown_module函数,它们在该模块中的所有测试(在函数或类中)之前和之后立即运行。 这些对于“一次性”设置非常有用,例如创建将由模块中的所有测试使用的套接字或数据库连接。 请小心这一点,因为如果要设置的对象存储状态,它可能会意外在测试之间引入依赖关系。
简短的描述并不能很好地解释何时调用这些方法,因此让我们看一个示例,确切说明何时发生这些方法:
def setup_module(module):
print("setting up MODULE {0}".format(
module.__name__))
def teardown_module(module):
print("tearing down MODULE {0}".format(
module.__name__))
def test_a_function():
print("RUNNING TEST FUNCTION")
class BaseTest:
def setup_class(cls):
print("setting up CLASS {0}".format(
cls.__name__))
def teardown_class(cls):
print("tearing down CLASS {0}\n".format(
cls.__name__))
def setup_method(self, method):
print("setting up METHOD {0}".format(
method.__name__))
def teardown_method(self, method):
print("tearing down METHOD {0}".format(
method.__name__))
class TestClass1(BaseTest):
def test_method_1(self):
print("RUNNING METHOD 1-1")
def test_method_2(self):
print("RUNNING METHOD 1-2")
class TestClass2(BaseTest):
def test_method_1(self):
print("RUNNING METHOD 2-1")
def test_method_2(self):
print("RUNNING METHOD 2-2")
BaseTest类的唯一目的是提取在其他方面与测试类相同的四个方法,并使用继承来减少重复代码的数量。 因此,从py.test的角度来看,这两个子类不仅具有两种测试方法,而且还具有两种设置和两种拆卸方法(一种在类级别,一种在方法级别)。
如果我们在禁用print函数输出抑制的情况下(通过传递-s或--capture=no标志)使用py.test运行这些测试,则它们将向我们展示与测试本身相关的各种功能的调用时间:
py.test setup_teardown.py -s
setup_teardown.py
setting up MODULE setup_teardown
RUNNING TEST FUNCTION
.setting up CLASS TestClass1
setting up METHOD test_method_1
RUNNING METHOD 1-1
.tearing down METHOD test_method_1
setting up METHOD test_method_2
RUNNING METHOD 1-2
.tearing down METHOD test_method_2
tearing down CLASS TestClass1
setting up CLASS TestClass2
setting up METHOD test_method_1
RUNNING METHOD 2-1
.tearing down METHOD test_method_1
setting up METHOD test_method_2
RUNNING METHOD 2-2
.tearing down METHOD test_method_2
tearing down CLASS TestClass2
tearing down MODULE setup_teardown
模块的设置和拆卸方法在会话的开始和结束时执行。 然后运行单独的模块级测试功能。 接下来,执行第一类的设置方法,然后执行该类的两个测试。 这些测试分别单独包装在setup_method和teardown_method调用中。 执行完测试后,将调用类拆解方法。 对于第二类,在最终调用teardown_module方法之前,只发生了一次相同的序列。
设置变量的完全不同的方法
各种设置和拆卸功能最常见的用途之一是在运行每种测试方法之前,确保某些类或模块变量具有已知值。
py.test提供了一种完全不同的方法,可以使用称为 funcargs 的函数,这是函数参数的缩写。 Funcargs 基本上是在测试配置文件中预定义的命名变量。 这使我们能够将配置与测试的执行分开,并允许真菌类在多个类和模块中使用。
要使用它们,我们向测试函数添加参数。 参数的名称用于在特殊命名的函数中查找特定的参数。 例如,如果我们要测试演示unittest时使用的StatsList类,我们将再次想要重复测试有效整数列表。 但是我们可以像这样编写测试,而不是使用设置方法:
from stats import StatsList
def pytest_funcarg__valid_stats(request):
return StatsList([1,2,2,3,3,4])
def test_mean(valid_stats):
assert valid_stats.mean() == 2.5
def test_median(valid_stats):
assert valid_stats.median() == 2.5
valid_stats.append(4)
assert valid_stats.median() == 3
def test_mode(valid_stats):
assert valid_stats.mode() == [2,3]
valid_stats.remove(2)
assert valid_stats.mode() == [3]
三种测试方法中的每一种都接受名为valid_stats的参数; 通过调用文件顶部定义的pytest_funcarg__valid_stats函数来创建此参数。 如果多个模块需要使用 funcarg,也可以在名为conftest.py的文件中定义它。 py.test会解析conftest.py文件,以加载任何“全局”测试配置; 它是一种用于定制py.test体验的综合功能。
与其他py.test功能一样,返还 Funcarg 的工厂名称很重要。 funcargs 是名为pytest_funcarg__<identifier>的函数,其中<identifier>是有效的变量名,可以在测试函数中用作参数。 该函数接受一个神秘的request参数,并将要作为参数传递的对象返回到各个测试函数中。 每次调用单个测试函数都会重新创建 funcarg。 例如,这使我们能够在一个测试中更改列表,并知道在下一个测试中它将重置为原始值。
Funcargs 除了返回基本变量外,还可以做更多的事情。 传递给 funcarg 工厂的request对象提供了一些极其有用的方法和属性来修改 funcarg 的行为。 module,cls和function属性使我们可以准确地看到哪个测试正在请求 funcarg。 config属性使我们可以检查命令行参数和其他配置数据。
更有趣的是,请求对象提供的方法使我们可以对真菌进行额外的清除,或在测试之间重用它,否则这些活动将被归类为特定范围的设置和拆卸方法。
request.addfinalizer方法接受一个回调函数,该函数在使用 funcarg 的每个测试函数被调用后执行清除。 这提供了等效的拆卸方法,使我们可以清理文件,关闭连接,清空列表或重置队列。 例如,以下代码通过创建临时目录funcarg来测试os.mkdir功能:
import tempfile
import shutil
import os.path
def pytest_funcarg__temp_dir(request):
dir = tempfile.mkdtemp()
print(dir)
def cleanup():
shutil.rmtree(dir)
request.addfinalizer(cleanup)
return dir
def test_osfiles(temp_dir):
os.mkdir(os.path.join(temp_dir, 'a'))
os.mkdir(os.path.join(temp_dir, 'b'))
dir_contents = os.listdir(temp_dir)
assert len(dir_contents) == 2
assert 'a' in dir_contents
assert 'b' in dir_contents
funcarg 为要在其中创建文件的文件创建了一个新的空临时目录。然后,在测试完成后,它添加了一个终结器调用以删除该目录(使用shutil.rmtree,该递归删除目录和其中的任何内容)。 然后,文件系统保持与启动时相同的状态。
我们可以使用request.cached_setup方法来创建函数参数变量,其持续时间超过一个测试。 这在设置可以由多个测试重用的昂贵操作时非常有用,只要资源重用不会破坏测试的原子或单元性质(这样,一个测试就不会依赖并且不受先前测试的影响) 一)。 例如,如果我们要测试以下回显服务器,则可能只想在单独的进程中运行服务器的一个实例,然后将多个测试连接到该实例:
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(('localhost',1028))
s.listen(1)
while True:
client, address = s.accept()
data = client.recv(1024)
client.send(data)
client.close()
这些代码所做的全部工作就是侦听特定端口并等待来自客户端套接字的输入。 收到输入后,它将回传相同的值。 为了对此进行测试,我们可以在一个单独的进程中启动服务器,并缓存结果以供多个测试使用。 这是测试代码的外观:
import subprocess
import socket
import time
def pytest_funcarg__echoserver(request):
def setup():
p = subprocess.Popen(
['python3', 'echo_server.py'])
time.sleep(1)
return p
def cleanup(p):
p.terminate()
return request.cached_setup(
setup=setup,
teardown=cleanup,
scope="session")
def pytest_funcarg__clientsocket(request):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(('localhost', 1028))
request.addfinalizer(lambda: s.close())
return s
def test_echo(echoserver, clientsocket):
clientsocket.send(b"abc")
assert clientsocket.recv(3) == b'abc'
def test_echo2(echoserver, clientsocket):
clientsocket.send(b"def")
assert clientsocket.recv(3) == b'def'
我们在这里创建了两个 funcarg。 第一个在单独的进程中运行回显服务器,然后返回进程对象。 第二个实例化每个测试的新套接字对象,并在测试完成时使用addfinalizer将其关闭。 第一个功能是我们当前感兴趣的功能。它看起来很像传统的单元测试设置和拆卸。 我们创建一个setup函数,该函数不接受任何参数并返回正确的参数; 在这种情况下,测试实际上忽略了一个过程对象,因为它们仅关心服务器是否正在运行。 然后,我们创建一个cleanup函数(该函数的名称是任意的,因为它只是我们传递给另一个函数的对象),该函数接受一个参数:setup返回的参数。 此清除代码终止该过程。
父函数不是直接返回 funcarg,而是返回对request.cached_setup的调用结果。 它接受setup和teardown函数(我们刚刚创建)的两个参数,以及scope参数。 最后一个参数应该是三个字符串“ function”,“ module”或“ session”之一; 它确定参数将被缓存多长时间。 在此示例中,我们将其设置为“会话”,因此在整个py.test运行期间将其缓存。 在运行所有测试之前,该过程不会终止或重新启动。 当然,“模块”作用域仅将其缓存用于该模块中的测试,而“功能”作用域将对象更像是普通的 funcarg,因为在运行每个测试功能后将其重置。
使用 py.test 跳过测试
与 unittest模块一样,出于各种原因,经常有必要跳过py.test中的测试:尚未编写被测试的代码,该测试仅在某些解释器或操作系统上运行 ,否则测试会很耗时,并且只能在某些情况下运行。
我们可以使用py.test.skip函数在代码的任何位置跳过测试。 它接受一个参数:一个字符串,说明为什么它被跳过了。 可以在任何地方调用此函数。 如果在测试函数中调用它,则将跳过测试。 如果我们在模块级别调用它,则将跳过该模块中的所有测试。 如果我们在 funcarg 函数中调用它,则所有调用该 funcarg 的测试都将被跳过。
当然,在所有这些位置,通常都希望仅在满足或不满足某些条件时才跳过测试。 由于我们可以在 Python 代码中的任何位置执行skip函数,因此我们可以在if语句内执行它。 因此,我们可以编写一个如下所示的测试:
import sys
import py.test
def test_simple_skip():
if sys.platform != "fakeos":
py.test.skip("Test works only on fakeOS")
fakeos.do_something_fake()
assert fakeos.did_not_happen
确实,这是一些非常愚蠢的代码。 没有名为fakeos的 Python 平台,因此此测试将在所有操作系统上跳过。 它显示了我们如何可以有条件地跳过,并且由于if语句可以检查任何有效的条件,因此当跳过测试时,我们具有很大的控制权。 通常,我们检查sys.version_info来检查 Python 解释器版本,检查sys.platform来检查操作系统,或者检查some_library.__version__来检查我们是否具有给定 API 的最新版本。
由于基于某个条件跳过单个测试方法或功能是测试跳过的最常见用途之一,因此py.test提供了一种方便的修饰符,使可以在一行中执行此操作。 装饰器接受单个字符串,该字符串可以包含任何评估为布尔值的可执行 Python 代码。 例如,以下测试将仅在 Python 3 或更高版本上运行:
import py.test
@py.test.mark.skipif("sys.version_info <= (3,0)")
def test_python3():
assert b"hello".decode() == "hello"
py.test.mark.xfail装饰器的行为类似,不同之处在于,它将测试标记为预期失败,类似于unittest.expectedFailure()。 如果测试成功,则将记录为失败;否则,将记录为失败。 如果失败,则将其报告为预期行为。 对于xfail,条件参数是可选的; 如果未提供,则将在所有情况下将测试标记为预期失败。
模仿昂贵的物品
有时,我们想要测试要求提供昂贵或难以构造的对象的代码。 虽然这可能意味着您的 API 需要重新考虑以具有更可测试的接口(通常意味着更可用的接口),但有时我们发现自己编写的测试代码具有大量样板来设置仅与下面的代码相关的对象 测试。
例如,假设我们有一些代码在键值存储区(例如redis或memcache)中跟踪航班状态,从而可以存储时间戳和最新状态。 此类代码的基本版本可能如下所示:
import datetime
import redis
class FlightStatusTracker:
ALLOWED_STATUSES = {'CANCELLED', 'DELAYED', 'ON TIME'}
def __init__(self):
self.redis = redis.StrictRedis()
def change_status(self, flight, status):
status = status.upper()
if status not in self.ALLOWED_STATUSES:
raise ValueError(
"{} is not a valid status".format(status))
key = "flightno:{}".format(flight)
value = "{}|{}".format(
datetime.datetime.now().isoformat(), status)
self.redis.set(key, value)
使用change_status方法,我们需要测试很多。 如果传入了错误的状态,我们应该检查它是否引发了适当的错误。我们需要确保将状态转换为大写。 我们可以看到,在redis对象上调用set()方法时,键和值具有正确的格式。
但是,我们不必在单元测试中检查的一件事是redis对象正确存储了数据。 绝对应该在集成或应用测试中对此进行测试,但是在单元测试级别,我们可以假设 py-redis 开发人员已经测试了他们的代码,并且该方法可以完成我们想要的工作。 通常,单元测试应该是独立的,并且不应依赖外部资源(例如正在运行的 Redis 实例)的存在。
相反,我们只需要测试set()方法被调用了适当的次数并带有适当的参数即可。 我们可以在测试中使用Mock()对象,将麻烦的方法替换为我们可以自省的对象。 以下示例说明了模拟的用法:
from unittest.mock import Mock
import py.test
def pytest_funcarg__tracker():
return FlightStatusTracker()
def test_mock_method(tracker):
tracker.redis.set = Mock()
with py.test.raises(ValueError) as ex:
tracker.change_status("AC101", "lost")
assert ex.value.args[0] == "LOST is not a valid status"
assert tracker.redis.set.call_count == 0
使用py.test语法编写的该测试断言,传入不适当的参数时会引发正确的异常。此外,它会为set方法创建一个模拟对象,并确保从不对其进行调用。 如果是这样,则意味着我们的异常处理代码中存在错误。
在这种情况下,简单地替换该方法就可以了,因为被替换的对象最终被破坏了。 但是,我们通常只想在测试期间替换函数或方法。 例如,如果要在模拟方法中测试时间戳格式,则需要确切知道datetime.datetime.now()将返回什么。 但是,此值随运行而变化。 我们需要某种方法将其固定为特定值,以便可以确定性地对其进行测试。
还记得猴子打补丁吗? 临时将库函数设置为特定值是一种很好的用法。 模拟库提供了一个修补程序上下文管理器,使我们可以使用模拟对象替换现有库上的属性。 当上下文管理器退出时,将自动还原原始属性,以免影响其他测试用例。 这是一个例子:
from unittest.mock import patch
def test_patch(tracker):
tracker.redis.set = Mock()
fake_now = datetime.datetime(2015, 4, 1)
with patch('datetime.datetime') as dt:
dt.now.return_value = fake_now
tracker.change_status("AC102", "on time")
dt.now.assert_called_once_with()
tracker.redis.set.assert_called_once_with(
"flightno:AC102",
"2015-04-01T00:00:00|ON TIME")
在此示例中,我们首先构造一个名为fake_now的值,将其设置为datetime.datetime.now函数的返回值。 在修补datetime.datetime之前,我们必须先构造此对象,因为否则,在构造它之前,我们将调用已修补的now函数!
with语句邀请修补程序用模拟对象替换datetime.datetime模块,该对象作为值dt返回。 关于模拟对象的整洁之处在于,每当您访问该对象的属性或方法时,它都会返回另一个模拟对象。 因此,当我们访问dt.now时,它为我们提供了一个新的模拟对象。 我们将该对象的return_value设置为我们的fake_now对象; 这样,每当调用datetime.datetime.now函数时,它将返回我们的对象而不是新的模拟对象。
然后,在使用已知值调用change_status方法之后,我们使用模拟类的assert_called_once_with函数来确保now函数确实确实被调用了一次而没有任何参数。 然后,我们再次调用它来证明redis.set方法是用我们期望的格式格式化的参数调用的。
前面的示例很好地说明了编写测试如何指导我们的 API 设计。 乍一看,FlightStatusTracker对象看起来很明智; 我们在构造对象时构造一个redis连接,并在需要时调用它。 但是,当我们为该代码编写测试时,我们发现即使在FlightStatusTracker上模拟了self.redis变量,仍然必须构造redis连接。 如果没有 Redis 服务器正在运行,则此调用实际上会失败,并且我们的测试也会失败。
我们可以通过模拟redis.StrictRedis类以在setUp方法中返回模拟来解决此问题。 但是,一个更好的主意可能是重新考虑我们的示例。 与其在__init__内部构造redis实例,不如让我们允许用户传入一个实例,如以下示例所示:
def __init__(self, redis_instance=None):
self.redis = redis_instance if redis_instance else redis.StrictRedis()
这允许我们在测试时传递模拟,因此StrictRedis方法永远不会被构造。 但是,它也允许与FlightStatusTracker对话的任何客户端代码传入其自己的redis实例。 他们可能要这样做有多种原因。 他们可能已经为代码的其他部分构造了一个。 他们可能已经创建了redis API 的优化实现。 也许他们有一个将指标记录到其内部监视系统中。 通过编写单元测试,我们发现了一个用例,该用例从一开始就使我们的 API 更加灵活,而不必等待客户要求我们支持他们的特殊需求。
这是对模拟代码奇迹的简要介绍。 自 Python 3.3 起,模仿是标准unittest库的一部分,但是从这些示例中可以看出,它们也可以与py.test和其他库一起使用。 Mocks 具有其他更高级的功能,随着代码变得越来越复杂,您可能需要利用这些功能。 例如,您可以使用spec参数邀请模拟模仿现有的类,以便在代码尝试访问模仿的类中不存在的属性时引发模拟错误。 您还可以构造一个模拟方法,该方法通过将列表作为side_effect参数传递来在每次调用时返回不同的参数。 side_effect参数用途广泛。 您还可以使用它在调用模拟程序时执行任意函数或引发异常。
总的来说,我们对模拟应该相当谨慎。 如果发现自己在给定的单元测试中模拟了多个元素,则最终可能会测试模拟框架,而不是实际的代码。 这毫无用处。 毕竟,模拟游戏已经过了充分的测试! 如果我们的代码正在执行很多操作,则可能另一个迹象表明我们正在测试的 API 设计不良。 在被测代码和它们所连接的库之间的边界应存在模拟。 如果这没有发生,我们可能需要更改 API,以便在其他位置重绘边界。
多少测试就足够了?
我们已经确定未经测试的代码就是损坏的代码。 但是,我们如何知道我们的代码测试得如何呢? 我们如何知道实际测试了多少代码,有多少被破坏了? 第一个问题是更重要的一个,但很难回答。 即使我们知道已经测试了应用中的每一行代码,我们也不知道我们已经正确测试了它。 例如,如果我们编写一个统计测试仅检查提供整数列表时会发生什么情况,那么如果将其用于浮点数,字符串或自制对象的列表,它可能仍然会失败。 设计完整的测试套件的责任仍然在于程序员。
第二个问题-我们实际测试了多少代码-易于验证。 代码覆盖率实质上是对程序执行的代码行数的估计。 如果我们知道该数字和程序中的行数,则可以估算出实际测试或覆盖的代码百分比。 如果我们还有一个指标可以指示未测试的行,则可以更轻松地编写新的测试以确保这些行的中断较少。
令人难忘的是,用于测试代码覆盖率的最流行的工具被称为coverage.py。 可以像其他大多数第三方库一样使用命令pip install coverage安装。
我们没有足够的空间来覆盖 coverage API 的所有细节,因此我们只看几个典型的例子。 如果我们有一个 Python 脚本可以为我们运行所有单元测试(例如,使用unittest.main,自定义测试运行程序或discover),则可以使用以下命令执行覆盖率分析:
coverage run coverage_unittest.py
该命令将正常退出,但是会创建一个名为.coverage的文件,其中包含运行中的数据。 现在,我们可以使用coverage report命令来分析代码覆盖率:
>>> coverage report
输出如下:
Name Stmts Exec Cover
--------------------------------------------------
coverage_unittest 7 7 100%
stats 19 6 31%
--------------------------------------------------
TOTAL 26 13 50%
这个基本的报告列出了已执行的文件(我们的单元测试和导入的模块)。 还列出了每个文件中的代码行数以及测试执行的代码数。 然后将这两个数字组合起来以估计代码覆盖率。 如果我们将-m选项传递给 report 命令,它将另外添加如下所示的列:
Missing
-----------
8-12, 15-23
此处列出的行范围表示stats模块中在测试运行期间未执行的行。
我们刚刚使用本章前面创建的 stats 模块运行代码覆盖率工具的示例。 但是,它故意使用单个测试,该测试无法测试文件中的许多代码。 这是测试:
from stats import StatsList
import unittest
class TestMean(unittest.TestCase):
def test_mean(self):
self.assertEqual(StatsList([1,2,2,3,3,4]).mean(), 2.5)
if __name__ == "__main__":
unittest.main()
该代码不测试中值或众数函数,它们对应于 coverage 输出告诉我们的行号缺失。
文本报告就足够了,但是如果使用命令coverage html,我们可以获得甚至更漂亮的交互式 HTML 报告,可以在 Web 浏览器中查看。 该网页甚至突出显示了源代码中的哪些行已经过测试,没有经过测试。 外观如下:

我们也可以将与py.test一起使用coverage.py模块。 我们需要使用pip install pytest-coverage安装py.test插件以覆盖代码。 该插件为py.test添加了几个命令行选项,其中最有用的是--cover-report,可以将其设置为html,report或annotate(后者实际上是修改源代码以突出显示任何行) 未涵盖的内容)。
不幸的是,如果我们能以某种方式在本章的这一部分运行一份覆盖率报告,我们会发现我们没有涵盖有关代码覆盖率的大部分知识! 可以使用 coverage API 从我们自己的程序(或测试套件)中管理代码的覆盖范围,并且coverage.py接受许多我们没有涉及的配置选项。 我们也没有讨论语句覆盖率和分支覆盖率之间的区别(后者更为有用,并且是coverage.py的最新版本中的默认设置)或其他样式的代码覆盖率。
请记住,虽然我们所有人都应该争取 100%的代码覆盖率这一崇高目标,但 100%的覆盖率还不够! 仅仅因为对一条语句进行了测试并不意味着就对所有可能的输入都对其进行了正确的测试。
案例研究
让我们通过编写一个经过测试的小型加密应用来逐步进行测试驱动的开发。 不用担心,您将不需要了解复杂的现代加密算法(例如 Threefish 或 RSA)背后的数学原理。 相反,我们将实现一种称为 Vigenère 密码的 16 世纪算法。 给定编码关键字,应用仅需要能够使用此密码对消息进行编码和解码。
首先,我们需要了解如果手动(没有计算机)应用密码,密码是如何工作的。 我们从这样的表开始:
A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
B C D E F G H I J K L M N O P Q R S T U V W X Y Z A
C D E F G H I J K L M N O P Q R S T U V W X Y Z A B
D E F G H I J K L M N O P Q R S T U V W X Y Z A B C
E F G H I J K L M N O P Q R S T U V W X Y Z A B C D
F G H I J K L M N O P Q R S T U V W X Y Z A B C D E
G H I J K L M N O P Q R S T U V W X Y Z A B C D E F
H I J K L M N O P Q R S T U V W X Y Z A B C D E F G
I J K L M N O P Q R S T U V W X Y Z A B C D E F G H
J K L M N O P Q R S T U V W X Y Z A B C D E F G H I
K L M N O P Q R S T U V W X Y Z A B C D E F G H I J
L M N O P Q R S T U V W X Y Z A B C D E F G H I J K
M N O P Q R S T U V W X Y Z A B C D E F G H I J K L
N O P Q R S T U V W X Y Z A B C D E F G H I J K L M
O P Q R S T U V W X Y Z A B C D E F G H I J K L M N
P Q R S T U V W X Y Z A B C D E F G H I J K L M N O
Q R S T U V W X Y Z A B C D E F G H I J K L M N O P
R S T U V W X Y Z A B C D E F G H I J K L M N O P Q
S T U V W X Y Z A B C D E F G H I J K L M N O P Q R
T U V W X Y Z A B C D E F G H I J K L M N O P Q R S
U V W X Y Z A B C D E F G H I J K L M N O P Q R S T
V W X Y Z A B C D E F G H I J K L M N O P Q R S T U
W X Y Z A B C D E F G H I J K L M N O P Q R S T U V
X Y Z A B C D E F G H I J K L M N O P Q R S T U V W
Y Z A B C D E F G H I J K L M N O P Q R S T U V W X
Z A B C D E F G H I J K L M N O P Q R S T U V W X Y
给定关键字 TRAIN,我们可以对消息编码为 PYTHON 进行如下编码:
-
一起重复关键字和消息,以便轻松地将字母从一个字母映射到另一个字母:
E N C O D E D I N P Y T H O N T R A I N T R A I N T R A I N -
对于纯文本中的每个字母,请在表中找到以该字母开头的行。
-
查找包含与所选明文字母的关键字字母关联的字母的列。
-
编码的字符在此行和列的交点处。
例如,以 E 开头的行与以 T 开头的列在字符 X 处相交。因此,密文中的第一个字母为 X。以 N 开头的行与以 R 开头的列在字符 E 处相交,从而导致 密文 XE。 C 在 C 处与 A 相交,O 在 W 处与 I 相交。D 和 N 映射到 Q,而 E 和 T 映射到 X。完整的编码消息是 XECWQXUIVCRKHWA。
解码基本上遵循相反的过程。 首先,找到带有共享关键字字符的行(T 行),然后在该行中找到编码字符(X)所在的位置。 明文字符在该行(E)的列的顶部。
实施
我们的程序将需要一个encode方法,该方法采用关键字和明文并返回密文,而decode方法将接受关键字和密文并返回原始消息。
但是,我们不只是编写这些方法,而是遵循测试驱动的开发策略。 我们将使用py.test进行单元测试。 我们需要encode方法,我们知道它必须做什么; 让我们首先为该方法编写一个测试:
def test_encode():
cipher = VigenereCipher("TRAIN")
encoded = cipher.encode("ENCODEDINPYTHON")
assert encoded == "XECWQXUIVCRKHWA"
自然,该测试失败了,因为我们没有在任何地方导入VigenereCipher类。 让我们创建一个新模块来容纳该类。
让我们从下面的VigenereCipher类开始:
class VigenereCipher:
def __init__(self, keyword):
self.keyword = keyword
def encode(self, plaintext):
return "XECWQXUIVCRKHWA"
如果我们在测试类的顶部添加 from vigenere_cipher import VigenereCipher行并运行py.test,则前面的测试将通过! 我们已经完成了第一个测试驱动的开发周期。
显然,返回一个硬编码的字符串不是密码类的最明智的实现,因此让我们添加另一个测试:
def test_encode_character():
cipher = VigenereCipher("TRAIN")
encoded = cipher.encode("E")
assert encoded == "X"
啊,现在测试失败了。 看来我们将不得不更加努力。 但是我只是想到了什么:如果有人尝试用空格或小写字符编码一个字符串怎么办? 在开始实现编码之前,让我们为这些情况添加一些测试,以免我们忘记它们。 预期的行为是删除空格,并将小写字母转换为大写字母:
def test_encode_spaces():
cipher = VigenereCipher("TRAIN")
encoded = cipher.encode("ENCODED IN PYTHON")
assert encoded == "XECWQXUIVCRKHWA"
def test_encode_lowercase():
cipher = VigenereCipher("TRain")
encoded = cipher.encode("encoded in Python")
assert encoded == "XECWQXUIVCRKHWA"
如果我们运行新的测试套件,则会发现新测试通过了(他们期望使用相同的硬编码字符串)。 但是如果我们忘记考虑这些情况,它们应该在以后失败。
现在我们有了一些测试用例,让我们考虑如何实现我们的编码算法。 可以像以前的手动算法中那样使用表来编写代码,但是考虑到每一行只是一个由一定数量的字符旋转的字母,因此看起来很复杂。 事实证明(我问维基百科),我们可以使用模运算来组合字符,而不是进行表查找。 给定纯文本和关键字字符,如果我们将两个字母转换为它们的数值(A 为 0,Z 为 25),将它们加在一起,然后取余数 mod 26,就得到密文字符! 这是一个简单的计算,但是由于它是逐字符进行的,因此我们可能应该将其放在自己的函数中。 在此之前,我们应该为新功能编写一个测试:
from vigenere_cipher import combine_character
def test_combine_character():
assert combine_character("E", "T") == "X"
assert combine_character("N", "R") == "E"
现在我们可以编写代码以使此功能正常工作。 老实说,我必须多次运行测试才能完全正确地执行此功能。 首先我返回了一个整数,然后我忘记了将字符从从零开始的比例转换回普通的 ASCII 比例。 有了测试,就可以轻松测试和调试这些错误。 这是测试驱动开发的另一个好处。
def combine_character(plain, keyword):
plain = plain.upper()
keyword = keyword.upper()
plain_num = ord(plain) - ord('A')
keyword_num = ord(keyword) - ord('A')
return chr(ord('A') + (plain_num + keyword_num) % 26)
现在combine_characters已经过测试,我想我们已经准备好实现encode功能。 但是,我们要在该函数中首先想到的是关键字字符串的重复版本,该版本与纯文本一样长。 让我们首先实现一个功能。 糟糕,我的意思是让我们先实施测试!
def test_extend_keyword():
cipher = VigenereCipher("TRAIN")
extended = cipher.extend_keyword(16)
assert extended == "TRAINTRAINTRAINT"
在编写此测试之前,我希望将extend_keyword编写为接受关键字和整数的独立函数。 但是,当我开始起草测试时,我意识到将其用作VigenereCipher类的辅助方法更有意义。 这表明测试驱动的开发如何帮助设计更合理的 API。 这是方法的实现:
def extend_keyword(self, number):
repeats = number // len(self.keyword) + 1
return (self.keyword * repeats)[:number]
再次,此进行了几次测试以确保正确。 最后,我添加了第二个版本的测试,一个版本包含 15 个字母,另一个版本包含 16 个字母,以确保整数除法具有偶数时可以正常工作。
现在,我们终于可以编写我们的encode方法了:
def encode(self, plaintext):
cipher = []
keyword = self.extend_keyword(len(plaintext))
for p,k in zip(plaintext, keyword):
cipher.append(combine_character(p,k))
return "".join(cipher)
看起来很正确。 我们的测试套件现在应该通过了,对吧?
实际上,如果运行它,我们会发现两个测试仍然失败。 我们完全忘记了空格和小写字符! 很高兴我们编写了这些测试来提醒我们。 我们必须在方法的开头添加以下行:
plaintext = plaintext.replace(" ", "").upper()
注意
如果在实现某些东西的过程中对某个极端情况有一个想法,我们可以创建一个描述该想法的测试。 我们甚至不必执行测试; 我们可以运行assert False来提醒我们稍后实施。 失败的测试永远不会让我们忘记极端的情况,并且不能像提交任务那样被忽略。 如果花点时间解决该实现问题,我们可以将测试标记为预期的失败。
现在,所有测试均成功通过。 本章篇幅很长,因此我们将压缩示例以进行解码。 这是一些测试:
def test_separate_character():
assert separate_character("X", "T") == "E"
assert separate_character("E", "R") == "N"
def test_decode():
cipher = VigenereCipher("TRAIN")
decoded = cipher.decode("XECWQXUIVCRKHWA")
assert decoded == "ENCODEDINPYTHON"
这是separate_character函数:
def separate_character(cypher, keyword):
cypher = cypher.upper()
keyword = keyword.upper()
cypher_num = ord(cypher) - ord('A')
keyword_num = ord(keyword) - ord('A')
return chr(ord('A') + (cypher_num - keyword_num) % 26)
和 decode方法:
def decode(self, ciphertext):
plain = []
keyword = self.extend_keyword(len(ciphertext))
for p,k in zip(ciphertext, keyword):
plain.append(separate_character(p,k))
return "".join(plain)
这些方法与用于编码的方法有很多相似之处。 编写并通过所有这些测试的最大好处是,我们现在可以返回并修改我们的代码,因为它仍然可以安全地通过测试。 例如,如果我们用这些重构方法替换现有的encode和decode方法,我们的测试仍会通过:
def _code(self, text, combine_func):
text = text.replace(" ", "").upper()
combined = []
keyword = self.extend_keyword(len(text))
for p,k in zip(text, keyword):
combined.append(combine_func(p,k))
return "".join(combined)
def encode(self, plaintext):
return self._code(plaintext, combine_character)
def decode(self, ciphertext):
return self._code(ciphertext, separate_character)
这是测试驱动开发的最终好处,也是最重要的。 编写测试后,我们可以根据需要尽可能地改进代码,并确信我们所做的更改不会破坏我们一直在测试的内容。 此外,我们确切地知道重构何时完成:何时所有测试通过。
当然,我们的测试可能无法全面测试我们需要的所有内容; 维护或代码重构仍会导致无法诊断的错误,这些错误不会在测试中显示。 自动化测试并非万无一失。 但是,如果确实发生错误,仍然可以遵循测试驱动的计划; 第一步是编写一个(或多个)测试来重复或“证明”该问题正在发生。 当然,这将失败。 然后编写代码以使测试停止失败。 如果测试是全面的,则该错误将得到修复,我们将在运行测试套件后立即知道是否再次发生该错误。
最后,我们可以尝试确定我们的测试在此代码上的运行情况。 安装py.test coverage 插件后,py.test –coverage-report=report告诉我们我们的测试套件具有 100%的代码覆盖率。 这是一个很棒的统计数据,但是我们不应该对此过于自大。 在对带有数字的消息进行编码时,我们的代码尚未经过测试,因此未定义此类输入的行为。




十四、并发
并发是使计算机一次完成(或看起来可以完成)多项任务的技术。 从历史上看,这意味着邀请处理器每秒在不同任务之间切换多次。 在现代系统中,这实际上也意味着在单独的处理器内核上同时执行两项或多项操作。
并发本质上不是面向对象的话题,但是 Python 的并发系统是建立在我们在整个模块中介绍的面向对象的构造之上的。 本章将向您介绍以下主题:
- 线程数
- 多处理
- 期货
- 异步 IO
并发很复杂。 基本概念非常简单,但是众所周知,很难发现可能发生的错误。 但是,对于许多项目,并发是获得我们所需性能的唯一方法。 想象一下,在上一个请求完成之前,Web 服务器是否无法响应用户的请求! 我们不会详细讨论它有多难(需要另一个完整的模块)的所有细节,但是我们将看到如何在 Python 中进行基本的并发,以及避免的一些最常见的陷阱。
线程
通常,创建并发是为了在程序等待 I / O 发生时可以继续进行工作。 例如,服务器可以在等待来自先前请求的数据到达时开始处理新的网络请求。 交互式程序可能会在等待用户按下键的同时渲染动画或执行计算。 请记住,尽管一个人每分钟可以键入 500 个以上的字符,但计算机每秒可以执行数十亿条指令。 因此,即使快速键入,也可能在各个按键之间进行大量处理。
从理论上讲,有可能管理程序中活动之间的所有切换,但实际上不可能正确。 相反,我们可以依靠 Python 和操作系统来处理棘手的切换部分,同时创建似乎独立但同时运行的对象。 这些对象称为线程; 在 Python 中,它们具有非常简单的 API。 让我们看一个基本的例子:
from threading import Thread
class InputReader(Thread):
def run(self):
self.line_of_text = input()
print("Enter some text and press enter: ")
thread = InputReader()
thread.start()
count = result = 1
while thread.is_alive():
result = count * count
count += 1
print("calculated squares up to {0} * {0} = {1}".format(
count, result))
print("while you typed '{}'".format(thread.line_of_text))
本示例运行两个线程。 你看得到他们吗? 每个程序都有一个线程,称为主线程。 从头开始执行的代码就发生在该线程中。 更明显地,第二个线程作为InputReader类存在。
要构造线程,我们必须扩展Thread类并实现run方法。 run方法内部的任何代码(或从该方法内部调用的代码)都在单独的线程中执行。
直到我们在对象上调用start()方法后,新线程才开始运行。 在这种情况下,线程立即暂停以等待键盘输入。 同时,原始线程在调用start时继续执行。 它开始计算while循环内的平方。 while循环中的条件检查InputReader线程是否已经退出其run方法; 完成后,它将一些摘要信息输出到屏幕。
如果运行示例并键入字符串“ hello world”,则输出如下所示:
Enter some text and press enter:
hello world
calculated squares up to 1044477 * 1044477 = 1090930114576
while you typed 'hello world'
当然,在键入字符串时,您将计算出更多或更少的平方,因为数字既与我们的相对键入速度有关,又与我们正在运行的计算机的处理器速度有关。
当我们调用start方法时,线程仅在并发模式下开始运行。 如果我们想进行并发调用以查看其比较,可以在最初称为thread.start()的地方调用thread.run()。 输出表明:
Enter some text and press enter:
hello world
calculated squares up to 1 * 1 = 1
while you typed 'hello world'
在这种情况下,线程永远不会存活,并且while循环也不会执行。 在打字时,我们浪费了很多 CPU 闲置时间。
有效使用线程有很多不同的模式。 我们不会涵盖所有内容,但是我们将介绍一种常见的方法,以便我们可以了解join方法。 让我们检查一下加拿大每个省的省会城市的当前温度:
from threading import Thread
import json
from urllib.request import urlopen
import time
CITIES = [
'Edmonton', 'Victoria', 'Winnipeg', 'Fredericton',
"St. John's", 'Halifax', 'Toronto', 'Charlottetown',
'Quebec City', 'Regina'
]
class TempGetter(Thread):
def __init__(self, city):
super().__init__()
self.city = city
def run(self):
url_template = (
'http://api.openweathermap.org/data/2.5/'
'weather?q={},CA&units=metric')
response = urlopen(url_template.format(self.city))
data = json.loads(response.read().decode())
self.temperature = data['main']['temp']
threads = [TempGetter(c) for c in CITIES]
start = time.time()
for thread in threads:
thread.start()
for thread in threads:
thread.join()
for thread in threads:
print(
"it is {0.temperature:.0f}°C in {0.city}".format(thread))
print(
"Got {} temps in {} seconds".format(
len(threads), time.time() - start))
此代码在启动 10 个线程之前会先构建它们。 注意,我们如何重写构造函数以将它们传递给Thread对象,记住要调用super以确保Thread正确初始化。 请注意:新线程尚未运行,因此__init__方法仍在主线程内部执行。 我们在一个线程中构造的数据可从其他正在运行的线程访问。
在启动 10 个线程之后,我们再次遍历它们,在每个线程上调用join()方法。 该方法从本质上说“等待线程完成之后再做任何事情”。 我们称其为十次。 在所有十个线程完成之前,for 循环不会退出。
此时,我们可以打印存储在每个线程对象上的温度。 再次注意,我们可以从主线程访问在线程内构造的数据。 在线程中,默认情况下共享所有状态。
在我的 100 兆位连接上执行此代码大约需要十分之二秒的时间:
it is 5°C in Edmonton
it is 11°C in Victoria
it is 0°C in Winnipeg
it is -10°C in Fredericton
it is -12°C in St. John's
it is -8°C in Halifax
it is -6°C in Toronto
it is -13°C in Charlottetown
it is -12°C in Quebec City
it is 2°C in Regina
Got 10 temps in 0.18970298767089844 seconds
如果我们在单个线程中运行此代码(通过将start()调用更改为run()并注释掉join()调用),则大约需要 2 秒钟的时间,因为每个 0.2 秒的请求都必须先完成 下一个开始。 10 倍的加速表明了并发编程的实用性。
线程的许多问题
线程可能有用,尤其是其他编程语言中的,但是现代 Python 程序员倾向于出于某些原因而避免使用它们。 我们将看到,还有其他进行并发编程的方法正受到 Python 开发人员的更多关注。 在进入更重要的主题之前,让我们讨论其中的一些陷阱。
共享内存
线程的主要问题也是它们的主要优点。 线程可以访问所有内存,从而可以访问程序中的所有变量。 这很容易导致程序状态不一致。 您是否曾经遇到过一个房间,其中一个灯有两个开关,两个不同的人同时打开它们? 每个人(线程)都期望自己的动作打开灯(变量),但是结果值(灯熄灭)与这些期望不一致。 现在,假设这两个线程是在银行帐户之间转移资金还是在车辆中管理巡航控制系统。
在线程编程中,此问题的解决方案是“同步”对读取或写入共享变量的任何代码的访问。 有几种不同的方法可以做到这一点,但是我们这里不再赘述,因此我们可以专注于更多的 Pythonic 结构。 同步解决方案可以工作,但是忘记应用它太容易了。 更糟糕的是,由于线程执行操作的顺序不一致,由于同步使用不当而导致的错误确实很难找到。 我们无法轻易重现该错误。 通常,使用已经适当使用锁的轻量级数据结构来强制线程之间进行通信是最安全的。 Python 提供了queue.Queue类来执行此操作; 它的功能基本上与我们将在下一节中讨论的multiprocessing.Queue相同。
在某些情况下,的这些缺点可能会被允许共享内存的一个优点所抵消:它的速度很快。 如果多个线程需要访问巨大的数据结构,则共享内存可以快速提供该访问。 但是,这种优势通常由于以下事实而无效:在 Python 中,运行在不同 CPU 内核上的两个线程不可能完全同时执行计算。 这将我们带到线程的第二个问题。
全局解释器锁
为了使有效地管理内存,垃圾回收和对库中机器代码的调用,Python 提供了一个名为全局解释器锁或 GIL 的实用程序。 这是不可能关闭的,这意味着线程在 Python 中是无用的,因为它们在其他语言中擅长的一件事是:并行处理。 对于我们而言,GIL 的主要作用是防止两个线程即使在有工作要做的情况下也完全同时工作。 在这种情况下,“完成工作”意味着使用 CPU,因此多个线程访问磁盘或网络是完全可以的。 一旦线程开始等待某些操作,就会释放 GIL。
GIL 被高度贬低,主要是因为那些不了解 GIL 是什么或它给 Python 带来的所有好处的人。 如果我们的语言没有此限制,那肯定会很好,但是 Python 参考开发人员已经确定,至少到目前为止,它带来的价值超过成本。 它使参考实现更易于维护和开发,并且在最初开发 Python 的单核处理器时代,它实际上使解释器更快。 但是,GIL 的最终结果是它在不降低成本的情况下限制了线程带给我们的好处。
注意
尽管 GIL 是大多数人使用的 Python 参考实现中的问题,但已在 IronPython 和 Jython 等某些非标准实现中解决了它。 不幸的是,在发布之时,这些都不支持 Python 3。
线程开销
与异步系统相比,线程的最后一个限制是维护线程的成本。 每个线程占用一定数量的内存(在 Python 进程和操作系统内核中)以记录该线程的状态。 在线程之间切换还占用(少量)CPU 时间。 这项工作可以无缝进行而无需任何额外的编码(我们只需要调用start(),其余的工作就可以了),但是这项工作仍然必须在某个地方进行。
通过结构化我们的工作量,可以稍微减轻这种负担,以便可以重用线程来执行多个作业。 Python 提供了ThreadPool功能来处理此问题。 它作为多处理库的一部分提供,其行为与ProcessPool相同,我们将在稍后进行讨论,因此我们将讨论推迟到下一部分。
多处理
多处理 API 最初旨在模拟线程 API。 但是,它已经发展了,并且在 Python 3 的最新版本中,它更强大地支持更多功能。 当需要大量执行 CPU 密集型工作并可以使用多个内核时,可以设计多处理库(鉴于目前可以以 35 美元的价格购买四核 Raspberry Pi,通常有多个内核)。 当进程将大部分时间都花在等待 I / O(例如,网络,磁盘,数据库或键盘)上时,多处理没有用,但是它们是进行并行计算的方式。
多处理模块启动新的操作系统进程来完成工作。 在 Windows 计算机上,这是一个相对昂贵的操作。 在 Linux 上,进程在内核中的实现方式与线程相同,因此开销受限于在每个进程中运行单独的 Python 解释器的成本。
让我们尝试使用与threading API 提供的结构相似的结构并行化繁重的计算操作:
from multiprocessing import Process, cpu_count
import time
import os
class MuchCPU(Process):
def run(self):
print(os.getpid())
for i in range(200000000):
pass
if __name__ == '__main__':
procs = [MuchCPU() for f in range(cpu_count())]
t = time.time()
for p in procs:
p.start()
for p in procs:
p.join()
print('work took {} seconds'.format(time.time() - t))
这个示例仅占用 CPU 2 亿次迭代。 您可能不认为这是有用的工作,但是今天天气很冷,我非常感谢笔记本电脑在这种负载下产生的热量。
该 API 应该很熟悉; 我们实现Process的子类(而不是Thread)并实现run方法。 在进行一些繁重的工作(如果被误导)之前,此方法会打印出进程 ID(操作系统分配给计算机上每个进程的唯一编号)。
要特别注意模块级别代码周围的if __name__ == '__main__':防护,以防在导入模块时该模块无法运行,而不是作为程序运行。 通常,这是一个好习惯,但是在某些操作系统上使用多处理时,这是必不可少的。 在后台,多处理可能必须在新进程内部导入模块才能执行run()方法。 如果我们允许整个模块在那时执行,它将开始递归地创建新进程,直到操作系统资源耗尽为止。
我们为计算机上的每个处理器核心构建一个进程,然后启动并加入每个进程。 在我的 2014 时代四核笔记本电脑上,输出如下所示:
6987
6988
6989
6990
work took 12.96659541130066 seconds
前四行是在每个MuchCPU实例内部打印的进程 ID。 最后一行显示 2 亿次迭代可以在我的计算机上运行约 13 秒。 在这 13 秒钟内,我的进程监视器表明我所有四个内核都以 100%的速度运行。
如果我们将threading.Thread而不是MuchCPU中的multiprocessing.Process子类化,则输出如下所示:
7235
7235
7235
7235
work took 28.577413082122803 seconds
这次,四个线程在同一个进程中运行,并且运行时间接近前者的三倍。 这就是全局解释器锁定的成本。 在其他语言或 Python 实现中,线程版本的运行速度至少与多处理版本的运行速度相同。我们可能希望它的运行时间是多处理版本的四倍,但请记住,笔记本电脑上还运行着许多其他程序。 在多处理版本中,这些程序还需要共享四个 CPU。 在线程版本中,这些程序可以使用其他三个 CPU 代替。
多处理池
通常,没有理由拥有比计算机上的处理器更多的进程。 这有几个原因:
- 只有
cpu_count()进程可以同时运行 - 每个进程都会消耗 Python 解释器的完整副本的资源
- 进程之间的通信很昂贵
- 创建流程花费的时间不为零
考虑到这些限制,有必要在程序启动时最多创建cpu_count()进程,然后让它们根据需要执行任务。 实现一系列基本的通信过程并不难,但是调试,测试和正确调试可能会很棘手。 当然,Python 是 Python,我们不必做所有这些工作,因为 Python 开发人员已经以多处理池的形式为我们完成了它。
池的主要优点是,它们消除了确定主进程中正在执行什么代码以及子进程中正在运行哪些代码的开销。 与多处理模仿的线程 API 一样,通常很难记住谁在执行什么。 池抽象限制了不同进程中的代码彼此交互的位置的数量,从而更易于跟踪。
- 池还无缝地隐藏了在流程之间传递数据的流程。 使用池看起来很像一个函数调用。 您将数据传递给一个函数,该数据将在另一个进程或多个进程中执行,当工作完成时,将返回一个值。 重要的是要了解,在后台进行了大量工作来支持此工作:对一个过程中的对象进行酸洗并将其传递到管道中。
- 另一个过程从管道检索数据并将其释放。 在子流程中完成工作并产生结果。 将结果腌制并传递到管道中。 最终,原始进程对其进行处理,并将其返回。
所有这些腌制并将数据传递到管道中需要花费时间和内存。 因此,理想的是将传入池中和从池中返回的数据量和大小保持最小,并且只有在必须对相关数据进行大量处理的情况下使用池才是有利的。
有了这些知识,使所有这些机械都能工作的代码非常简单。 让我们看一下计算随机数列表的所有素数的问题。 这是各种密码算法的常见且昂贵的部分(更不用说对这些算法的攻击了!)。 它需要数年的处理能力才能破解用于保护银行帐户的极大数量。 以下实现虽然可读,但根本没有效率,但这没关系,因为我们希望看到它使用大量的 CPU 时间:
import random
from multiprocessing.pool import Pool
def prime_factor(value):
factors = []
for divisor in range(2, value-1):
quotient, remainder = divmod(value, divisor)
if not remainder:
factors.extend(prime_factor(divisor))
factors.extend(prime_factor(quotient))
break
else:
factors = [value]
return factors
if __name__ == '__main__':
pool = Pool()
to_factor = [
random.randint(100000, 50000000) for i in range(20)
]
results = pool.map(prime_factor, to_factor)
for value, factors in zip(to_factor, results):
print("The factors of {} are {}".format(value, factors))
让我们将集中在并行处理方面,因为用于计算因子的蛮力递归算法非常清晰。 我们首先构造一个多处理池实例。 默认情况下,该池为运行它的计算机中的每个 CPU 内核创建一个单独的进程。
map方法接受一个函数和一个可迭代的函数。 池对可迭代项中的每个值进行腌制,并将其传递到可用进程中,该进程在其上执行功能。 当该过程完成工作时,它将对结果列表进行腌制并将其传递回池中。 所有池完成处理工作后(可能需要一些时间),结果列表将传递回原始过程,该过程一直耐心等待所有这些工作完成。
使用类似的map_async方法通常更有用,即使进程仍在工作,该方法也会立即返回。 在那种情况下,结果变量将不是值列表,而是一个承诺,稍后将通过调用results.get()返回值列表。 这个 promise 对象还具有ready()和wait()之类的方法,这些方法使我们可以检查是否所有结果都已经存在。
另外,如果我们不知道要预先获得结果的所有值,则可以使用apply_async方法将单个作业排队。 如果池中有尚未运行的进程,它将立即启动;否则,它将立即启动。 否则,它将一直执行任务,直到有可用的免费进程为止。
池也可以是close d,它拒绝执行任何其他任务,但是处理队列中当前的所有内容;也可以是terminate d,它进一步执行一步,并且拒绝启动仍在队列中的任何作业,尽管有任何作业 当前运行仍允许完成。
队列
如果需要对进程之间的通信进行更多控制,则可以使用Queue。 Queue数据结构可用于将消息从一个进程发送到一个或多个其他进程。 任何可腌制的对象都可以发送到Queue中,但是请记住,腌制可能是一项昂贵的操作,因此请使此类对象保持较小。 为了说明队列,让我们为文本内容构建一个小的搜索引擎,以将所有相关条目存储在内存中。
这不是构建基于文本的搜索引擎的最明智的方法,但是我使用了这种模式来查询数字数据,这些数据需要使用 CPU 密集型流程来构建然后呈现给用户的图表。
这个特定的搜索引擎并行扫描当前目录中的所有文件。 为 CPU 上的每个内核构建一个进程。 这些命令均指示将某些文件加载到内存中。 让我们看一下执行加载和搜索的函数:
def search(paths, query_q, results_q):
lines = []
for path in paths:
lines.extend(l.strip() for l in path.open())
query = query_q.get()
while query:
results_q.put([l for l in lines if query in l])
query = query_q.get()
请记住,此函数在与主线程不同的进程中运行(实际上,在cpucount()不同的进程中运行)。 它传递path.path对象和两个multiprocessing.Queue对象的列表; 一种用于传入查询,另一种用于发送传出结果。 这些队列与我们在第 6 章,“Python 数据结构”中讨论的Queue类具有相似的接口。 但是,他们正在做额外的工作来对队列中的数据进行腌制并将其通过管道传递给子流程。 这两个队列是在主进程中设置的,并通过管道传递到子进程内部的搜索功能中。
无论从效率还是功能上来说,搜索代码都非常笨拙。 它遍历存储在内存中的每一行,并将匹配的行放入列表中。 该列表放在队列中,并传递回主流程。
让我们看一下设置这些队列的主要过程:
if __name__ == '__main__':
from multiprocessing import Process, Queue, cpu_count
from path import path
cpus = cpu_count()
pathnames = [f for f in path('.').listdir() if f.isfile()]
paths = [pathnames[i::cpus] for i in range(cpus)]
query_queues = [Queue() for p in range(cpus)]
results_queue = Queue()
search_procs = [
Process(target=search, args=(p, q, results_queue))
for p, q in zip(paths, query_queues)
]
for proc in search_procs: proc.start()
为了简化的描述,我们假设cpu_count为 4。 注意导入语句如何放置在if保护内部? 这是一个很小的优化,可防止它们在某些操作系统上的每个子进程(不需要它们)中导入。 我们列出当前目录中的所有路径,然后将列表分为四个大致相等的部分。 我们还构造了四个Queue对象的列表,以将数据发送到每个子进程中。 最后,我们构造一个single结果队列; 这将传递到所有四个子流程中。 他们每个人都可以将数据放入队列中,并将在主进程中进行汇总。
现在,让我们看一下实际进行搜索的代码:
for q in query_queues:
q.put("def")
q.put(None) # Signal process termination
for i in range(cpus):
for match in results_queue.get():
print(match)
for proc in search_procs: proc.join()
此代码对"def"执行一次搜索(因为它是充满 Python 文件的目录中的常用短语!)。 在更适合生产的系统中,我们可能会将套接字连接到该搜索代码。 在这种情况下,我们必须更改进程间协议,以使返回队列中返回的消息包含足够的信息,以标识结果附加到许多查询中的哪一个。
队列的使用实际上是可能成为分布式系统的本地版本。 想象一下,是否将搜索发送到多台计算机,然后重新组合。 我们不在这里讨论它,但是多处理模块包括一个管理器类,该类可以从前面的代码中提取很多样板。 甚至还有multiprocessing.Manager版本,可以管理远程系统上的子进程以构建基本的分布式应用。 如果您有兴趣进一步进行此操作,请查看 Python 多处理文档。
多处理的问题
和线程一样,多处理也有问题,我们已经讨论了其中的一些问题。 没有最好的并发方法。 在 Python 中尤其如此。 我们始终需要检查并行问题,以找出许多可用的解决方案中最适合该问题的解决方案。 有时,没有最佳解决方案。
在多处理的情况下,主要缺点是在进程之间共享数据非常昂贵。 正如我们已经讨论的,进程之间的所有通信,无论是通过队列,管道还是更隐式的机制,都需要对对象进行腌制。 过多的酸洗会很快占据加工时间。 当在过程之间传递相对较小的对象并且每个过程需要完成大量工作时,多处理效果最佳。 另一方面,如果不需要进程之间的通信,那么使用该模块可能根本没有意义。 我们可以启动四个独立的 Python 进程并独立使用它们。
多重处理的另一个主要问题是,与线程一样,很难确定正在访问哪个进程或变量的方法。在多重处理中,如果您从另一个进程访问变量,则通常会覆盖当前正在运行的变量 流程,而另一个流程保留了旧值。 维护起来确实很混乱,所以不要这样做。
期货
让我们开始以一种更异步的并发方式研究。 期货根据我们需要的并发类型包装多处理或线程(趋向于 I / O 与趋向于 CPU)。 它们不能完全解决意外更改共享状态的问题,但可以使我们对代码进行结构化,以便在执行代码时更容易进行跟踪。 期货在不同线程或进程之间提供了明确的界限。 与多处理池类似,它们对于“呼叫和应答”类型的交互很有用,在这种交互中,处理可以在另一个线程中进行,然后在将来的某个时刻(毕竟它们被恰当地命名),您可以要求它提供结果 。 它实际上只是多处理池和线程池的包装,但是它提供了更简洁的 API 并鼓励了更好的代码。
将来是一个基本上包装函数调用的对象。 该函数调用在线程或进程的后台运行。 未来对象具有检查未来是否已完成并在完成后获得结果的方法。
让我们做另一个文件搜索示例。 在上一节中,我们实现了unix grep命令的版本。 这次,让我们做一个find命令的简单版本。 示例将在整个文件系统中搜索包含给定字符串的路径:
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from os.path import sep as pathsep
from collections import deque
def find_files(path, query_string):
subdirs = []
for p in path.iterdir():
full_path = str(p.absolute())
if p.is_dir() and not p.is_symlink():
subdirs.append(p)
if query_string in full_path:
print(full_path)
return subdirs
query = '.py'
futures = deque()
basedir = Path(pathsep).absolute()
with ThreadPoolExecutor(max_workers=10) as executor:
futures.append(
executor.submit(find_files, basedir, query))
while futures:
future = futures.popleft()
if future.exception():
continue
elif future.done():
subdirs = future.result()
for subdir in subdirs:
futures.append(executor.submit(
find_files, subdir, query))
else:
futures.append(future)
该代码包含一个名为find_files的函数,该函数在单独的线程(或进程,如果使用ProcessPoolExecutor)中运行。 此函数没有什么特别的地方,但是请注意它不访问任何全局变量。 与外部环境的所有交互都将传递到函数中或从中返回。 这不是技术要求,而是在进行期货编程时将大脑保持在头骨内的最佳方法。
注意
在没有适当同步的情况下访问外部变量会导致出现竞争条件。 例如,假设有两个并发写入尝试递增整数计数器。 它们同时开始,并且都读取值为 5。然后,它们都将值递增,并将结果写回 6。但是,如果两个进程试图递增变量,则预期结果将是变量递增 2。 ,因此结果应为 7。现代人认为,避免这种情况的最简单方法是保持尽可能多的状态为私有,并通过已知安全的构造(例如队列)共享它们。
在开始之前,我们先设置了几个变量。 在此示例中,我们将搜索所有包含字符'.py'的文件。 我们有一排即将要讨论的期货。 basedir变量指向文件系统的根目录; 在 Unix 机器上为'/',在 Windows 上可能为C:\。
首先,让我们学习搜索理论的短期课程。 该算法并行实现广度优先搜索。 它不是使用深度优先搜索来递归搜索每个目录,而是将当前文件夹中的所有子目录添加到队列中,然后将这些文件夹中的每一个的所有子目录添加到队列中,依此类推。
该程序的本质被称为事件循环。 我们可以将ThreadPoolExecutor构造为上下文管理器,以便在完成后自动清除它并关闭其线程。 它需要一个max_workers参数来指示一次运行的线程数。 如果提交的作业超过此数目,它将把其余的作业排入队列,直到工作线程可用。 使用ProcessPoolExecutor时,通常将其限制为计算机上的 CPU 数量,但对于线程而言,它可能更高,具体取决于一次要等待 I / O 的数量。 每个线程都占用一定数量的内存,因此它不应过高。 瓶颈是磁盘速度,而不是并行请求的数量,并不需要那么多线程。
构造执行程序后,我们将使用根目录向其提交作业。 submit()方法立即返回一个Future对象,该对象有望最终为我们提供结果。 未来排在前面。 然后,循环反复从队列中删除第一个 Future,并对其进行检查。 如果它仍在运行,则将其添加回队列末尾。 否则,我们通过调用future.exception()来检查函数是否引发异常。 如果是这样,我们将忽略它(这通常是一个权限错误,尽管真正的应用需要更加小心该异常是什么)。 如果我们不在此处检查此异常,则在调用result()时会引发该异常,并且可以通过常规try ... except机制进行处理。
假设没有发生异常,我们可以调用result()来获取函数调用的返回值。 由于函数返回的不是符号链接的子目录列表(我的防止无限循环的惰性方式),因此result()返回相同的内容。 这些新的子目录被提交给执行者,并且将生成的期货扔到队列中,以便在以后的迭代中搜索其内容。
这就是开发基于将来的 I / O 绑定应用所需要的全部。 在后台,它使用与我们已经讨论过的相同的线程或流程 API,但是它提供了更易理解的界面,并使得查看并发运行的函数之间的边界更加容易(只是不要尝试从内部访问全局变量)。 未来!)。
AsyncIO
AsyncIO 是在 Python 并发编程中的最新技术。 它结合了期货和事件循环的概念以及我们在第 9 章,“迭代器模式”中讨论的协程。 其结果与编写并发代码时获得的结果一样优雅且易于理解,尽管说明并不多!
AsyncIO 可以用于一些不同的并发任务,但是它是专门为网络 I / O 设计的。 大多数网络应用(尤其是在服务器端)花费大量时间等待数据从网络进入。 可以通过在单独的线程中处理每个客户端来解决此问题,但是线程会占用内存和其他资源。 AsyncIO 使用协程而不是线程。
该库还提供了自己的事件循环,从而避免了前面示例中的多行 while 循环。 但是,事件循环是有代价的。 当我们在事件循环上的异步任务中运行代码时,该代码必须立即返回,既不会阻止 I / O,也不会阻止长时间运行的计算。 在编写我们自己的代码时,这是一件小事,但这意味着在 I / O 上阻塞的任何标准库或第三方函数都必须创建非阻塞版本。
AsyncIO 通过创建一组协程来解决此问题,这些协程使用yield from语法立即将控制权返回给事件循环。 事件循环负责检查阻塞调用是否已完成并执行任何后续任务,就像我们在上一节中手动完成的一样。
AsyncIO 运行中
阻塞功能的典型示例是time.sleep调用。 让我们使用此调用的异步版本来说明 AsyncIO 事件循环的基础:
import asyncio
import random
@asyncio.coroutine
def random_sleep(counter):
delay = random.random() * 5
print("{} sleeps for {:.2f} seconds".format(counter, delay))
yield from asyncio.sleep(delay)
print("{} awakens".format(counter))
@asyncio.coroutine
def five_sleepers():
print("Creating five tasks")
tasks = [
asyncio.async(random_sleep(i)) for i in range(5)]
print("Sleeping after starting five tasks")
yield from asyncio.sleep(2)
print("Waking and waiting for five tasks")
yield from asyncio.wait(tasks)
asyncio.get_event_loop().run_until_complete(five_sleepers())
print("Done five tasks")
这是一个相当基本的示例,但是它涵盖了 AsyncIO 编程的一些功能。 从执行的顺序(从上到下或多或少)最容易理解。
最后一行获取事件循环,并指示它运行将来直到完成。 有问题的未来被命名为five_sleepers。 一旦将来完成工作,循环将退出,我们的代码将终止。 作为异步程序员,我们不需要对run_until_complete调用内部发生的事情了解太多,但是要知道发生了很多事情。 这是我们在上一章中编写的 Futures 循环的协程版本,它知道如何处理迭代,异常,函数返回,并行调用等。
现在再仔细看看five_sleepers的未来。 忽略装饰器几段; 我们会回到它。 协程首先构建了random_sleep未来的五个实例。 生成的期货包装在asyncio.async任务中,该任务将它们添加到循环的任务队列中,以便在控制权返回事件循环时可以并行执行。
每当我们调用yield from时,都会返回该控件。 在这种情况下,我们调用yield from asyncio.sleep将协程暂停执行两秒钟。 在此休息期间,事件循环执行它排队的任务; 即五个random_sleep期货。 这些协程分别打印一条开始消息,然后在特定时间段内将控制发送回事件循环。 如果random_sleep内部的任何睡眠调用都短于两秒钟,则事件循环会将控制权传递回相关的 Future,后者在返回之前将其唤醒消息打印出来。 当five_sleepers内部的睡眠调用唤醒时,它将执行直到调用的下一个收益,该收益等待其余的random_sleep任务完成。 当所有睡眠调用均完成执行后,random_sleep任务返回,将其从事件队列中删除。 一旦完成所有这五个操作,就将返回asyncio.wait调用,然后返回five_sleepers方法。 最后,由于事件队列现在为空,因此run_until_complete调用可以终止并且程序结束。
asyncio.coroutine装饰器主要只是证明该协程打算用作事件循环中的未来。 在这种情况下,如果没有装饰器,程序将正常运行。 但是,asyncio.coroutine装饰器也可以用于包装常规函数(一个不产生任何结果的函数),以便可以将其视为将来的函数。 在这种情况下,整个函数将在控制权返回事件循环之前执行。 装饰器只是强制该函数满足协程 API,因此事件循环知道如何处理它。
阅读 AsyncIO 的未来
AsyncIO 协程按顺序执行每一行,直到遇到yield from语句为止,这时它将控制权返回给事件循环。 然后,事件循环将执行所有其他准备运行的任务,包括原始协程正在等待的任务。 每当该子任务完成时,事件循环就会将结果发送回协程,以便它可以继续执行,直到遇到另一个yield from语句或返回为止。
这使我们能够编写同步执行的代码,直到我们明确需要等待某些内容为止。 这消除了线程的不确定行为,因此我们不必过多担心共享状态。
注意
避免从协程内部访问共享状态仍然是一个好主意。 它使您的代码更容易推理。 更重要的是,即使理想世界中所有异步执行都可能发生在协程内部,但实际情况是,某些期货是在线程或进程内部幕后执行的。 坚持“不分享任何东西”的哲学,避免大量困难的错误。
另外,即使我们正在其他地方等待其他工作,AsyncIO 仍使我们可以在一个协程中一起收集代码的逻辑部分。 作为一个特定的实例,即使random_sleep协程中的yield from asyncio.sleep调用允许事件循环内发生大量事情,协程本身看起来也像是按顺序进行了所有操作。 这种读取相关异步代码段而无需担心等待任务完成的机器的能力是 AsyncIO 模块的主要优点。
用于网络的 AsyncIO
AsyncIO 是为与网络套接字一起使用而专门设计的,因此让我们实现一个 DNS 服务器。 更准确地说,让我们实现 DNS 服务器的一项极其基本的功能。
域名系统的基本目的是将域名(例如 www.amazon.com)转换为 IP 地址(例如 72.21.206.6)。 它必须能够执行多种类型的查询,并且在没有所需答案的情况下知道如何与其他 DNS 服务器联系。 我们将不会实施任何此类操作,但是以下示例能够直接响应标准 DNS 查询,以查找我的三个最近雇主的 IP:
import asyncio
from contextlib import suppress
ip_map = {
b'facebook.com.': '173.252.120.6',
b'yougov.com.': '213.52.133.246',
b'wipo.int.': '193.5.93.80'
}
def lookup_dns(data):
domain = b''
pointer, part_length = 13, data[12]
while part_length:
domain += data[pointer:pointer+part_length] + b'.'
pointer += part_length + 1
part_length = data[pointer - 1]
ip = ip_map.get(domain, '127.0.0.1')
return domain, ip
def create_response(data, ip):
ba = bytearray
packet = ba(data[:2]) + ba([129, 128]) + data[4:6] * 2
packet += ba(4) + data[12:]
packet += ba([192, 12, 0, 1, 0, 1, 0, 0, 0, 60, 0, 4])
for x in ip.split('.'): packet.append(int(x))
return packet
class DNSProtocol(asyncio.DatagramProtocol):
def connection_made(self, transport):
self.transport = transport
def datagram_received(self, data, addr):
print("Received request from {}".format(addr[0]))
domain, ip = lookup_dns(data)
print("Sending IP {} for {} to {}".format(
domain.decode(), ip, addr[0]))
self.transport.sendto(
create_response(data, ip), addr)
loop = asyncio.get_event_loop()
transport, protocol = loop.run_until_complete(
loop.create_datagram_endpoint(
DNSProtocol, local_addr=('127.0.0.1', 4343)))
print("DNS Server running")
with suppress(KeyboardInterrupt):
loop.run_forever()
transport.close()
loop.close()
本示例设置了一个字典,该字典将一些域愚蠢地映射到 IPv4 地址。 其后是两个函数,这些函数从二进制 DNS 查询数据包中提取信息并构建响应。 我们不会讨论这些; 如果您想了解有关 DNS 的更多信息,请阅读 RFC(“请求注释”,用于定义大多数 Internet 协议的格式)1034 和 1035。
您可以通过在另一个终端上运行以下命令来测试此服务:
nslookup -port=4343 facebook.com localhost
让我们继续吧。 AsyncIO 网络围绕着传输和协议的紧密联系的概念。 协议是具有特定方法的类,当相关事件发生时会调用该方法。 由于 DNS 在 UDP(用户数据报协议)的顶部运行; 我们将协议类构建为DatagramProtocol的子类。 此类有多种事件可以响应。 我们对发生的初始连接(仅是为了保存传输以供将来使用)和datagram_received事件特别感兴趣。 对于 DNS,必须解析并响应每个收到的数据报,此时交互结束。
因此,当接收到数据报时,我们将使用我们不谈论的功能(它们是家族中的败类)来处理数据包,查找 IP 并构造响应。 然后,我们指示基础传输使用其sendto方法将结果包发送回请求客户端。
传输实质上代表了通信流。 在这种情况下,它将抽象化事件循环上 UDP 套接字上发送和接收数据的所有麻烦。 例如,有类似的传输方式可以与 TCP 套接字和子进程进行交互。
通过调用循环的create_datagram_endpoint协程来构造 UDP 传输。 这将构造适当的 UDP 套接字并开始对其进行侦听。 我们将套接字需要侦听的地址传递给它,更重要的是,传递给我们创建的协议类,以便传输器在接收到数据时知道要调用的内容。
由于初始化套接字的过程会花费很短的时间,并且会阻塞事件循环,因此create_datagram_endpoint函数是协程。 在我们的示例中,等待初始化时我们实际上不需要执行任何操作,因此将调用包装在loop.run_until_complete中。 事件循环负责管理未来,完成后将返回两个值的元组:新初始化的传输和从我们传入的类构造的协议对象。
在后台,传输已在事件循环中设置了一个任务,该任务正在侦听传入的 UDP 连接。 然后,我们要做的就是通过调用loop.run_forever()启动事件循环,以便任务可以处理这些数据包。 数据包到达时,将按照协议进行处理,一切正常。
唯一要注意的主要问题是,传输(以及实际上是事件循环)在完成后应该被关闭。 在这种情况下,无需两次调用close(),代码就可以很好地运行,但是如果我们正在动态地构建传输(或只是进行适当的错误处理!),我们需要更加注意它。 。
您可能很沮丧地看到在设置协议类和基础传输时需要多少样板文件。 AsyncIO 在称为流的这两个关键概念之上提供了抽象。 在下一个示例中,我们将看到 TCP 服务器中的流示例。
使用执行程序包装阻塞代码
AsyncIO 提供自己的期货库版本,以允许我们在没有进行适当的非阻塞调用时在单独的线程或进程中运行代码。 这实质上使我们能够将线程和进程与异步模型结合在一起。 此功能更有用的应用之一是当应用具有大量的 I / O 绑定和 CPU 绑定活动时,两者兼得。 I / O 绑定部分可以在事件循环中发生,而 CPU 密集型工作可以分解为其他进程。 为了说明这一点,让我们使用 AsyncIO 实现“作为服务排序”:
import asyncio
import json
from concurrent.futures import ProcessPoolExecutor
def sort_in_process(data):
nums = json.loads(data.decode())
curr = 1
while curr < len(nums):
if nums[curr] >= nums[curr-1]:
curr += 1
else:
nums[curr], nums[curr-1] = \
nums[curr-1], nums[curr]
if curr > 1:
curr -= 1
return json.dumps(nums).encode()
@asyncio.coroutine
def sort_request(reader, writer):
print("Received connection")
length = yield from reader.read(8)
data = yield from reader.readexactly(
int.from_bytes(length, 'big'))
result = yield from asyncio.get_event_loop().run_in_executor(
None, sort_in_process, data)
print("Sorted list")
writer.write(result)
writer.close()
print("Connection closed")
loop = asyncio.get_event_loop()
loop.set_default_executor(ProcessPoolExecutor())
server = loop.run_until_complete(
asyncio.start_server(sort_request, '127.0.0.1', 2015))
print("Sort Service running")
loop.run_forever()
server.close()
loop.run_until_complete(server.wait_closed())
loop.close()
这是的好代码示例,实现了一些非常愚蠢的想法。 作为服务排序的整个想法是非常荒谬的。 使用我们自己的排序算法而不是调用 Python 的sorted更糟。 我们使用的算法称为 gnome 排序,在某些情况下称为“愚蠢排序”。 这是在纯 Python 中实现的慢速排序算法。 我们定义了自己的协议,而不是使用野外存在的许多非常合适的应用协议之一。 甚至在这里使用多处理来并行处理的想法都可能令人怀疑。 我们仍然最终将所有数据传入和传出子流程。 有时候,重要的是要退出正在编写的程序,并问自己是否正在努力实现正确的目标。
但是,让我们看一下该设计的一些智能功能。 首先,我们将字节传入和传出子进程。 这比在主流程中解码 JSON 聪明得多。 这意味着(相对昂贵的)解码可以在不同的 CPU 上进行。 而且,腌制的 JSON 字符串通常比腌制的列表小,因此在进程之间传递的数据更少。
其次,这两种方法非常线性。 看起来代码正在一行接一行地执行。 当然,在 AsyncIO 中,这只是一种幻想,但是我们不必担心共享内存或并发原语。
流
前面的示例现在应该看起来很熟悉,因为它与其他 AsyncIO 程序具有类似的样板。 但是,有一些差异。 您会注意到我们叫start_server而不是create_server。 此方法挂接到 AsyncIO 的流中,而不使用底层的传输/协议代码。 除了传递协议类之外,我们还可以传递普通协程,该协程接收读取器和写入器参数。 两者都代表可以像文件或套接字一样读取和写入的字节流。 其次,因为这是 TCP 服务器而不是 UDP,所以在程序完成时需要进行一些套接字清理。 此清理是一个阻塞调用,因此我们必须在事件循环上运行wait_closed协程。
流很容易理解。 阅读是一个潜在的阻塞调用,因此我们必须使用yield from进行调用。 写作不会阻塞; 它只是将数据放在队列中,AsyncIO 在后台将其发送出去。
我们在sort_request方法中的代码发出两个读取请求。 首先,它从导线中读取 8 个字节,并使用大端符号将其转换为整数。 该整数表示客户端打算发送的数据字节数。 因此,在下一次调用readexactly时,它将读取那么多字节。 read和readexactly之间的区别在于,前者将读取最多请求的字节数,而后者将缓冲读取直到接收到所有字节,或者直到连接关闭。
执行器
现在让我们看一下执行器代码。 我们导入与上一节中使用的完全相同的ProcessPoolExecutor。 注意,我们不需要特殊的 AsyncIO 版本。 事件循环有一个方便的run_in_executor协程,可用于运行期货。 默认情况下,循环在ThreadPoolExecutor中运行代码,但是如果愿意,我们可以传入其他执行程序。 或者,就像在本示例中所做的那样,可以在通过调用loop.set_default_executor()设置事件循环时设置其他默认值。
您可能在上一节中回忆过,没有多少样板可用于与执行人一起使用期货。 但是,当我们将它们与 AsyncIO 一起使用时,根本没有! 协程程序会在将来自动包装函数调用,并将其提交给执行程序。 我们的代码将阻塞,直到将来完成为止,而事件循环将继续处理其他连接,任务或将来。 将来完成后,协程将唤醒并继续将数据写回到客户端。
您可能想知道,与其在一个事件循环中运行多个进程,不如在一个不同的进程中运行多个事件循环会更好。 答案是:“也许”。 但是,根据确切的问题空间,我们可能最好不要使用单个事件循环来运行程序的独立副本,而是尝试将所有内容与主多处理流程进行协调。
在本节中,我们已经达到 AsyncIO 的大部分要点,并且本章还介绍了许多其他并发原语。 并发是一个很难解决的问题,没有一种解决方案适合所有用例。 设计并发系统的最重要部分是确定哪种可用工具是解决问题的正确工具。 我们已经看到了几种并发系统的优缺点,现在已经有了一些见识,可以看出哪些是针对不同类型需求的更好选择。
案例研究
为了使结束本章以及该模块,让我们构建一个基本的图像压缩工具。 它将拍摄黑白图像(每像素 1 位,打开或关闭),并尝试使用一种非常基本的压缩形式(称为行程编码)对其进行压缩。 您可能会发现黑白图像有些牵强。 如果是这样,您在这个页面上的工作时间不够!
我在本章的示例代码中包含了一些黑白 BMP 图像样本(易于将数据读入,并留有很多机会来改善文件大小)。
我们将使用一种称为游程长度编码的简单技术来压缩图像。 该技术基本上采用一系列位,并用重复的位数替换任何重复的位字符串。 例如,可以将字符串 000011000 替换为 04 12 03,以指示 4 个零之后是 2 个,再是 3 个零。 为了使事情更有趣,我们将每一行分成 127 位的块。
我没有任意选择 127 位。 可以将 127 个不同的值编码为 7 位,这意味着如果一行包含全 1 或全 0,则可以将其存储在单个字节中; 第一位指示是 0 行还是 1 行,其余 7 位指示存在多少位。
将图像分解为的另一个好处是; 我们可以并行处理各个块,而无需彼此依赖。 但是,也有一个主要的缺点。 如果运行中仅有几个或零,则它将占用压缩文件中的more空间。 当我们将长期运行分解为多个块时,我们可能最终会创建更多此类次运行并膨胀文件的大小。
处理文件时,我们必须考虑压缩文件中字节的确切布局。 我们的文件将在文件的开头存储两个字节的 little-endian 整数,代表完整文件的宽度和高度。 然后,它将写入代表每一行的 127 位块的字节。
现在,在开始设计并发系统以构建此类压缩映像之前,我们应该问一个基本问题:该应用受 I / O 约束还是受 CPU 约束?
老实说,我的回答是“我不知道”。 我不确定该应用是否会花费更多时间从磁盘加载数据并将其写回或在内存中进行压缩。 我怀疑它原则上是受 CPU 限制的应用,但是一旦我们开始将图像字符串传递到子进程中,我们可能会失去并行性的任何好处。 解决此问题的最佳方法可能是编写 C 或 Cython 扩展,但让我们看看使用纯 Python 可以获得多远的效果。
我们将使用自下而上的设计来构建此应用。 这样,我们将有一些构建基块,可以将它们组合成不同的并发模式,以了解它们之间的比较。 让我们从使用游程长度编码压缩 127 位块的代码开始:
from bitarray import bitarray
def compress_chunk(chunk):
compressed = bytearray()
count = 1
last = chunk[0]
for bit in chunk[1:]:
if bit != last:
compressed.append(count | (128 * last))
count = 0
last = bit
count += 1
compressed.append(count | (128 * last))
return compressed
该代码使用bitarray类来处理各个零和一。 它作为第三方模块分发,您可以使用命令pip install bitarray进行安装。 传递到compress_chunks的块是此类的一个实例(尽管该示例对于布尔值列表同样适用)。 在这种情况下,位数组的主要好处是在进程之间进行腌制时,它们占据了布尔列表或 1s 和 0s 字节串的空间的八分之一。 因此,它们的腌制速度更快。 与进行大量按位运算相比,它们也更容易使用(双关语意)。
该方法使用行程编码对数据进行压缩,并返回包含打包数据的字节数组。 如果位数组就像一个一和零的列表,那么字节数组就像一个字节对象的列表(每个字节当然包含 8 个一或零)。
执行压缩的算法非常简单(尽管我想指出,我花了两天时间来实现和调试它。简单易懂并不一定意味着易于编写!)。 它首先将last变量设置为当前运行中的位类型(True或False)。 然后,它循环遍历这些位,对每个位进行计数,直到找到不同的位为止。 当这样做时,它会根据last变量所包含的内容,通过将字节的最左位(128 位置)设为零或一来构造一个新的字节。 然后它重置计数器并重复该操作。 循环完成后,它将为最后一次运行创建最后一个字节,并返回结果。
在创建构建块时,让我们创建一个压缩一行图像数据的函数:
def compress_row(row):
compressed = bytearray()
chunks = split_bits(row, 127)
for chunk in chunks:
compressed.extend(compress_chunk(chunk))
return compressed
该函数接受一个名为 row 的位数组。 使用我们将很快定义的函数,它将它分成每个 127 位宽的块。 然后,它使用先前定义的compress_chunk压缩这些块中的每个块,将结果连接到bytearray中,并返回。
我们将split_bits定义为一个简单的生成器:
def split_bits(bits, width):
for i in range(0, len(bits), width):
yield bits[i:i+width]
现在,由于我们尚不确定这是否可以在线程或进程中更有效地运行,因此让我们将这些函数包装在一种方法中,该方法可以在提供的执行程序中运行所有操作:
def compress_in_executor(executor, bits, width):
row_compressors = []
for row in split_bits(bits, width):
compressor = executor.submit(compress_row, row)
row_compressors.append(compressor)
compressed = bytearray()
for compressor in row_compressors:
compressed.extend(compressor.result())
return compressed
这个例子几乎不需要解释; 它使用我们已经定义的相同split_bits函数根据图像的宽度将输入的位分成几行(自下而上的设计!)。
请注意,此代码将压缩任何位序列,尽管它会膨胀,而不是压缩位值频繁变化的二进制数据。 黑白图像绝对是有关压缩算法的良好候选者。 现在,让我们创建一个函数,该函数使用第三方枕头模块加载图像文件,然后将其转换为位并进行压缩。 我们可以使用古老的注释语句轻松地在执行程序之间切换:
from PIL import Image
def compress_image(in_filename, out_filename, executor=None):
executor = executor if executor else ProcessPoolExecutor()
with Image.open(in_filename) as image:
bits = bitarray(image.convert('1').getdata())
width, height = image.size
compressed = compress_in_executor(executor, bits, width)
with open(out_filename, 'wb') as file:
file.write(width.to_bytes(2, 'little'))
file.write(height.to_bytes(2, 'little'))
file.write(compressed)
def single_image_main():
in_filename, out_filename = sys.argv[1:3]
#executor = ThreadPoolExecutor(4)
executor = ProcessPoolExecutor()
compress_image(in_filename, out_filename, executor)
image.convert()调用将图像更改为黑白(一位)模式,而getdata()返回这些值的迭代器。 我们将结果打包到一个位数组中,以便它们更快地通过导线传输。 当输出压缩文件时,我们首先写入图像的宽度和高度,然后写入压缩数据,压缩数据以字节数组的形式出现,可以直接写入二进制文件。
编写完所有这些代码后,我们终于可以测试线程池或进程池是否为我们提供了更好的性能。 我创建了一个大的(7200 x 5600 像素)黑白图像,并将其运行在两个池中。 ProcessPool大约需要 7.5 秒来处理系统上的映像,而ThreadPool始终需要大约 9 秒钟。因此,正如我们所怀疑的那样,在进程之间来回腌制位和字节的成本几乎消耗了所有效率。 通过在多个处理器上运行可以获得好处(尽管看我的 CPU 监视器,它确实充分利用了我计算机上的所有四个内核)。
因此,看起来压缩单个图像是在单独的过程中最有效的方法,但这仅仅是勉强完成的工作,因为我们在父过程和子过程之间来回传递了这么多数据。 当进程之间传递的数据量很少时,多处理会更有效。
因此,让我们扩展应用以并行压缩目录中的所有位图。 我们唯一要传递给子进程的是文件名,因此与使用线程相比,我们应该获得速度上的提高。 另外,要发疯,我们将使用现有代码来压缩单个图像。 这意味着我们将在每个子流程中运行ProcessPoolExecutor以创建更多子流程。 我不建议在现实生活中这样做!
from pathlib import Path
def compress_dir(in_dir, out_dir):
if not out_dir.exists():
out_dir.mkdir()
executor = ProcessPoolExecutor()
for file in (
f for f in in_dir.iterdir() if f.suffix == '.bmp'):
out_file = (out_dir / file.name).with_suffix('.rle')
executor.submit(
compress_image, str(file), str(out_file))
def dir_images_main():
in_dir, out_dir = (Path(p) for p in sys.argv[1:3])
compress_dir(in_dir, out_dir)
这段代码使用了我们先前定义的compress_image函数,但是对每个图像在单独的进程中运行它。 它不会将执行程序传递给该函数,因此,一旦新进程开始运行,compress_image就会创建一个ProcessPoolExecutor。
现在我们正在执行器内部运行执行器,可以使用四种线程和进程池组合来压缩图像。 它们各自具有完全不同的时序配置文件:
| |每个映像的处理池
|
每个映像的线程池
|
| --- | --- | --- |
| 每行的处理池 | 42 秒 | 53 秒 |
| 每行线程池 | 34 秒 | 64 秒 |
如我们所料,对每个图像使用线程,对每行再次使用线程是最慢的,因为 GIL 阻止我们并行执行任何工作。 鉴于我们在使用单个图像时对每行使用单独的进程时速度稍快,如果您在单独的进程中处理每个图像,您可能会惊讶地发现对行使用ThreadPool功能更快 。 花一些时间来了解为什么会这样。
我的机器仅包含四个处理器核心。 每个图像中的每一行都在单独的池中进行处理,这意味着所有这些行都在争夺处理能力。 当只有一张图像时,我们通过并行运行每一行来获得(非常适度的)加速。 但是,当我们一次增加要处理的图像数量时,将所有行数据传入和传出子流程的成本正在积极地浪费其他每个图像的处理时间。 因此,如果我们可以在单独的处理器上处理每个图像,那么只需将几个文件名添加到子进程管道中,就可以使速度得到大幅提升。
因此,我们看到不同的工作负载需要不同的并发范例。 即使我们只是使用期货,我们也必须就使用哪种执行人做出明智的决定。
还要注意,对于通常大小的图像,该程序运行得足够快,以至于我们使用哪种并发结构都没关系。 实际上,即使我们根本不使用任何并发性,我们最终也可能会获得相同的用户体验。
这个问题也可以直接使用线程和/或多处理模块来解决,尽管要编写的样板代码要多得多。 您可能想知道 AsyncIO 在这里是否有用。 答案是:“可能不会”。 大多数操作系统没有很好的方法来进行非阻塞的文件系统读取,因此无论如何,该库最终都会将所有调用包装在期货中。
为了完整起见,这是我用来解压缩 RLE 图像的代码,以确认算法是否正常工作(实际上,直到我修复压缩和解压缩中的错误时,我仍然不确定是否 完美。我应该使用测试驱动的开发!):
from PIL import Image
import sys
def decompress(width, height, bytes):
image = Image.new('1', (width, height))
col = 0
row = 0
for byte in bytes:
color = (byte & 128) >> 7
count = byte & ~128
for i in range(count):
image.putpixel((row, col), color)
row += 1
if not row % width:
col += 1
row = 0
return image
with open(sys.argv[1], 'rb') as file:
width = int.from_bytes(file.read(2), 'little')
height = int.from_bytes(file.read(2), 'little')
image = decompress(width, height, file.read())
image.save(sys.argv[2], 'bmp')
此代码非常简单。 每次运行都编码为一个字节。 它使用一些按位数学运算来提取像素的颜色和行程的长度。 然后,它根据图像中运行的每个像素设置像素,增加下一个像素的行和列,以适当的间隔进行检查。




十五、数据分析和库简介
数据是原始的信息,可以任何形式存在(无论是否可用)。 我们可以轻松地在生活中的任何地方获取数据; 例如,撰写本文之日的黄金价格为每盎司 1.158 美元。 除了描述黄金价格外,这没有任何意义。 这也表明数据基于上下文是有用的。
通过关系数据连接,信息出现并允许我们将知识扩展到我们的感官范围之外。 当我们拥有随时间推移而收集的黄金价格数据时,我们可能拥有的一条信息是,金价在三天内从 1.152 美元连续上涨至 1.158 美元。 跟踪黄金价格的人可能会使用它。
知识可以帮助人们在生活和工作中创造价值。 此值基于组织,合成或汇总的信息以增强理解,认识或理解。 它代表采取行动和决策的状态或潜力。 当金价连续三天上涨时,第二天可能会下跌; 这是有用的知识。
下图说明了从数据到知识的步骤; 我们称这个过程为数据分析过程,我们将在下一节中介绍它:

在本章中,我们将介绍以下主题:
- 数据分析与处理
- 使用不同编程语言进行数据分析的库概述
- 通用 Python 数据分析库
数据分析和处理
数据每天都在变得越来越大和多样化。 因此,分析和处理数据以提高人类知识或创造价值是一个巨大的挑战。 为了解决这些挑战,您将需要领域知识和各种技能,这些知识来自计算机科学,人工智能(AI)和机器学习(ML),统计和数学以及知识领域,如下图所示:

让我们看一下数据分析及其域知识:
- 计算机科学:我们需要此知识来提供有效数据处理的抽象。 需要具备基本的 Python 编程经验才能遵循下一章。 我们将介绍用于数据分析的 Python 库。
- 人工智能和机器学习:如果计算机科学知识帮助我们编程数据分析工具,那么人工智能和机器学习将帮助我们对数据建模并从中学习以构建智能 产品。
- 统计和数学:如果我们不使用统计技术或数学函数,则无法从原始数据中提取有用的信息。
- 知识领域:除了技术和通用技术外,深入了解特定领域也很重要。 数据字段是什么意思? 我们需要收集什么数据? 基于专业知识,我们将逐步应用上述技术来探索和分析原始数据。
数据分析是一个由以下步骤组成的过程:
- 数据需求:我们具有可以根据需求或问题分析定义要收集的数据类型。 例如,如果我们想在阅读 Internet 新闻时检测到用户的行为,则应了解访问的文章链接,日期和时间,文章类别以及用户在不同页面上花费的时间。
- 数据收集:可以从各种来源收集数据:移动,个人计算机,照相机或记录设备。 它也可以通过不同的方式获得:通信,事件以及人与人之间,人与设备之间或设备与设备之间的交互。 数据随时随地出现在世界上。 问题是我们如何找到并收集它来解决我们的问题? 这是此步骤的任务。
- 数据处理:必须对最初获得的数据进行处理或组织以进行分析。 此过程对性能敏感。 我们可以多快地创建,插入,更新或查询数据? 在构建必须处理大数据的真实产品时,我们应该仔细考虑这一步骤。 我们应该使用哪种数据库来存储数据? 哪种数据结构(例如分析,统计或可视化)适合我们的目的?
- 数据清除:在处理和组织了之后,数据可能仍然包含重复项或错误。 因此,我们需要一个清洁步骤,以减少这些情况并在以下步骤中提高结果的质量。 常见任务包括记录匹配,重复数据删除和列分段。 根据数据类型,我们可以应用几种类型的数据清除。 例如,用户访问新闻网站的历史记录可能包含很多重复的行,因为用户可能多次刷新某些页面。 对于我们的特定问题,在探索用户的行为时,这些行可能没有任何意义,因此在将其保存到数据库之前,应将其删除。 我们可能遇到的另一种情况是新闻点击欺诈-某人只想提高其网站排名或破坏网站。 在这种情况下,数据将无助于我们探索用户的行为。 我们可以使用阈值来检查访问页面事件是来自真人还是来自恶意软件。
- 探索性数据分析:现在,我们可以开始通过各种称为探索性数据分析的技术来分析数据。 我们可能会在数据清理中发现其他问题,或者发现对其他数据的请求。 因此,这些步骤可能是迭代的,并且在整个数据分析过程中都会重复。 数据可视化技术还用于检查图形或图表中的数据。 可视化通常有助于理解数据集,尤其是在数据集较大或高维的情况下。
- 建模和算法:大量数学公式和算法可用于从原始数据中检测或预测有用的知识。 例如,我们可以使用相似性度量来对表现出相似新闻阅读行为的用户进行聚类,并在下次推荐感兴趣的文章。 另外,我们可以通过应用分类模型,例如支持向量机(SVM)或线性回归,根据用户的新闻阅读行为来检测他们的性别。 根据问题,我们可能使用不同的算法来获得可接受的结果。 评估算法的准确性并为特定产品选择最佳实施方法可能需要花费大量时间。
- 数据产品:此步骤的目标是构建数据产品,以根据问题要求接收数据输入并生成输出。 我们将应用计算机科学知识来实施我们选择的算法以及管理数据存储。

数据分析中的库概述
有许多数据分析库,可以帮助我们处理和分析数据。 它们使用不同的编程语言,并且在解决各种数据分析问题方面具有不同的优缺点。 现在,我们将介绍一些可能对您有用的通用库。 他们应该给您概述该领域的图书馆。 但是,本模块的其余部分重点介绍基于 Python 的库。
一些使用 Java 语言进行数据分析的库如下:
- Weka:这是我第一次学习数据分析时就熟悉的库。 它具有图形用户界面,可让您在小型数据集上运行实验。 如果您想了解数据处理领域中可能发生的情况,那就太好了。 但是,如果您构建复杂的产品,我认为它不是最佳选择,因为它的性能,粗略的 API 设计,非最佳算法以及很少的文档。
- Mallet:这是另一个 Java 库,用于统计自然语言处理,文档分类,聚类,主题建模,信息提取以及其他文本的机器学习应用。 Mallet 有一个名为 GRMM 的附加程序包,其中包含对一般图形模型推断的支持,以及对具有任意图形结构的条件随机字段(CRF)的训练。 以我的经验,库的性能和算法要比 Weka 好。 但是,它仅关注文本处理问题。 参考页面位于这个页面。
- Mahout:此是 Apache 的基于 Hadoop 的机器学习框架; 它的目标是建立一个可扩展的机器学习库。 它看起来很有前途,但伴随着 Hadoop 的所有负担和开销。 主页位于这个页面。
- Spark:这是一个相对较新的 Apache 项目,据说的速度比 Hadoop 快一百倍。 它也是一个可扩展的库,由常见的机器学习算法和实用程序组成。 开发可以用 Python 以及任何 JVM 语言完成。 参考页位于这个页面。
以下是一些用 C ++ 实现的库:
- Vowpal Wabbit:此库是由 Microsoft Research 和以前的 Yahoo!赞助的快速核心学习系统。 研究。 它已被用来在一小时内学习 1,000 个节点上的 Tera-feature(1012)数据集。 有关更多信息,请参见出版物。
- MultiBoost:此程序包是用 C ++ 实现的多类,多标签和多任务分类增强软件。 如果您使用此软件,则应参阅 2012 年发表在机器学习研究,MultiBoost:多用途 Boosting 软件包,D.Benbouzid,R. Busa-Fekete,N. Casagrande,F.-D。 Collin 和 B.Kégl。
- MLpack:这也是也是 C ++ 机器学习库,由乔治亚州的基础算法和统计工具实验室(FASTLab)开发 科技 它专注于可伸缩性,速度和易用性,并在 NIPS 2011 的 BigLearning 研讨会上进行了介绍。它的主页位于这个页面。
- Caffe:我们要提及的最后一个 C ++ 库是 Caffe。 这是一个考虑了表达,速度和模块化的深度学习框架。 它是由伯克利视觉与学习中心(BVLC)和社区贡献者开发的。 您可以在这个页面上找到有关它的更多信息。
其他用于数据处理和分析的库如下:
- 统计模型:此是一个很棒的用于统计建模的 Python 库,主要用于预测和探索性分析。
- 用于数据处理的模块化工具包(MDP):此是有监督和无监督学习算法的集合,以及可以组合的其他数据处理单元 数据处理序列和更复杂的前馈网络体系结构。
- 橙色:这是开源数据可视化,适合新手和专家进行分析。 它具有用于数据分析的功能,并具有用于生物信息学和文本挖掘的附加组件。 它包含一个自组织映射的实现,这也使其与其他项目区分开来。
- Mirador:此是用于可视化探索复杂数据集的工具,支持 Mac 和 Windows。 它使用户能够发现相关模式并从数据中得出新的假设。
- RapidMiner:此是另一个基于 GUI 的工具,用于数据挖掘,机器学习和预测分析。
- Theano:此弥补了 Python 和较低级语言之间的空白。 Theano 可以显着提高性能,尤其是对于大型矩阵操作,因此是深度学习模型的不错选择。 但是,由于增加了编译层,因此调试起来并不容易。
- 自然语言处理工具包(NLTK):此用 Python 编写,具有非常独特和显着的功能。
在这里,我无法列出所有用于数据分析的库。 但是,我认为上述库足以花很多时间来学习和构建数据分析应用。 我希望您阅读完本模块后会喜欢它们。

数据分析中的 Python 库
Python 是一种多平台通用编程语言,可以在 Windows,Linux / Unix 和 Mac OS X 上运行,并且已经移植到 Java 和.NET 虚拟机。 它具有强大的标准库。 此外,它还有许多用于数据分析的库:Pylearn2,Hebel,Pybrain,Pattern,MontePython 和 MILK。 在本模块中,我们将介绍一些常见的 Python 数据分析库,例如 Numpy,pandas,Matplotlib,PyMongo 和 scikit-learn。 现在,为了帮助您入门,我将为不熟悉科学 Python 堆栈的读者简要介绍每个库的概述。
NumPy
Python 中用于科学计算的基本软件包之一是 Numpy。 除其他外,它包含以下内容:
- 强大的 N 维数组对象
- 复杂的(广播)函数,用于执行数组计算
- 集成 C/C++ 和 Fortran 代码的工具
- 有用的线性代数运算,傅立叶变换和随机数功能
除此之外,它还可以用作通用数据的有效多维容器。 可以定义任意数据类型,并将其与各种数据库集成。
Pandas
pandas 是 Python 软件包,它支持丰富的数据结构和用于分析数据的功能,由 PyData 开发团队开发。 它着重于 Python 数据库的改进。 Pandas 包括以下内容:
- 一组标记数组数据结构; 主要是 Series,DataFrame 和 Panel
- 支持简单轴索引和多级/层次轴索引的索引对象
- 集成引擎按组分组,用于聚合和转换数据集
- 日期范围生成和自定义日期偏移
- 输入/输出工具,可从平面文件或 PyTables / HDF5 格式加载和保存数据
- 标准数据结构的最佳内存版本
- 移动窗口统计信息以及静态和动态窗口线性/面板回归
由于这些功能,对于需要复杂数据结构或高性能时间序列功能的系统(例如财务数据分析应用),pandas 是理想的工具。
Matplotlib
Matplotlib 是用于 2D 图形的最常用的 Python 软件包。 它提供了一种非常快速的方式来可视化来自 Python 的数据以及具有多种格式的出版物质量的图形:折线图,轮廓图,散点图和底图图。 它带有一组默认设置,但允许自定义各种属性。 但是,我们可以使用 Matplotlib 中几乎每个属性的默认值轻松创建图表。
PyMongo
MongoDB 是 NoSQL 数据库的一种。 具有高度的可伸缩性,强大的功能,非常适合与基于 JavaScript 的 Web 应用一起使用,因为我们可以将数据存储为 JSON 文档并使用灵活的模式。
PyMongo 是一个 Python 发行版,其中包含用于 MongoDB 的工具。 还编写了许多工具与 PyMongo 一起使用,以添加更多功能,例如 MongoKit,Humangolus,MongoAlchemy 和 Ming。
scikit 学习库
scikit-learn 是使用 Python 编程语言的开源机器学习库。 它支持与 Python 数值和科学库 NumPy 和 SciPy 互操作的各种机器学习模型,例如分类,回归和聚类算法。




十六、NumPy 数组和向量化计算
NumPy 是基本软件包,受支持,可在 Python 中以高性能显示和计算数据。 它提供了一些有趣的功能,如下所示:
- Python 的扩展包,用于多维数组(
ndarrays),各种派生对象(例如,掩码数组),提供向量化操作的矩阵以及广播功能。 通过利用现代 CPU 中的单指令多数据(SIMD)指令集,向量化可以显着提高阵列计算的性能。 - 对数据数组进行快速便捷的运算,包括数学运算,基本统计运算,排序,选择,线性代数,随机数生成,离散傅立叶变换等。
- 由于集成了 C/C++ / Fortran 代码,因此效率工具更接近硬件。
NumPy 是一个很好的入门软件包,可让您熟悉数据分析中的数组和面向数组的计算。 同样,这也是学习其他更有效的工具(如 Pandas)的基本步骤,我们将在下一章中看到它们。 我们将使用 NumPy 1.9.1 版。
NumPy 数组
数组可用于包含实验或仿真步骤中的数据对象的值,图像的像素或由测量设备记录的信号。 例如,巴黎埃菲尔铁塔的纬度为 48.858598,经度为 2.294495。 它可以在[NumPy]数组对象中显示为p:
>>> import numpy as np
>>> p = np.array([48.858598, 2.294495])
>>> p
Output: array([48.858598, 2.294495])
这是使用np.array功能的阵列的手动构造。 导入 NumPy 的标准约定如下:
>>> import numpy as np
当然,您可以在代码中加入from numpy import *以避免编写np。 但是,由于存在潜在的代码冲突,因此应谨慎使用此习惯(有关代码约定的更多信息,请参见 Python 样式指南,也称为 PEP8。
NumPy 数组有两个要求:创建时固定大小,以及内存中固定大小的统一固定数据类型。 以下功能可帮助您获取有关p矩阵的信息:
>>> p.ndim # getting dimension of array p
1
>>> p.shape # getting size of each array dimension
(2,)
>>> len(p) # getting dimension length of array p
2
>>> p.dtype # getting data type of array p
dtype('float64')
数据类型
有五种基本的数值类型,包括布尔(bool),整数(int),无符号整数(uint),浮点(float)和复数。 它们指示需要多少位来表示内存中数组的元素。 除此之外,NumPy 还具有某些类型,例如intc和intp,这些类型根据平台而具有不同的位大小。
有关 NumPy 支持的数据类型的列表,请参见下表:
|类型
|
类型代码
|
描述
|
价值范围
|
| --- | --- | --- | --- |
| bool | | 布尔值存储为字节 | 真假 |
| intc | | 类似于 C int(int32 或 int 64) | |
| intp | | 用于索引的整数(与 C size_t 相同) | |
| int8,uint8 | i1,u1 | 有符号和无符号 8 位整数类型 | int8:(-128 至 127)uint8:(0 到 255) |
| int16,uint16 | i2,u2 | 有符号和无符号 16 位整数类型 | int16:(-32768 至 32767)uint16:(0 到 65535) |
| int32,uint32 | i4,u4 | 有符号和无符号 32 位整数类型 | int32:(-2147483648 至 2147483647uint32:(0 到 4294967295) |
| int64,uinit64 | i8,u8 | 有符号和无符号 64 位整数类型 | Int64:(-9223372036854775808 至 9223372036854775807)uint64:(0 到 18446744073709551615) |
| float16 | f2 | 半精度浮点数:符号位,5 位指数和 10b 位尾数 | |
| float32 | f4 / f | 单精度浮点数:符号位,8 位指数和 23 位尾数 | |
| float64 | f8 /天 | 双精度浮点数:符号位,11 位指数和 52 位尾数 | |
| complex64,complex128,complex256 | c8,c16,c32 | 复数由两个 32 位,64 位和 128 位浮点数表示 | |
| object | 0 | Python 对象类型 | |
| string_ | 小号 | 定长字符串类型 | 使用S10声明长度为 10 的字符串dtype |
| unicode_ | ü | 定长 Unicode 类型 | 与 string_ 示例类似,我们有'U10' |
我们可以轻松地使用astype方法将转换为或将数组从一个dtype转换为另一个数组:
>>> a = np.array([1, 2, 3, 4])
>>> a.dtype
dtype('int64')
>>> float_b = a.astype(np.float64)
>>> float_b.dtype
dtype('float64')
注意
即使新的dtype与旧的数组相似,astype函数也会使用旧数组中的数据副本创建一个新的数组。

阵列创建
提供了各种函数来创建数组对象。 它们对于我们在不同情况下在多维数组中创建和存储数据非常有用。
现在,在下表中,我们将总结一些 NumPy 的常用功能及其在数组创建示例中的用法:
|功能
|
描述
|
例子
|
| --- | --- | --- |
| empty, empty_like | 创建给定形状和类型的新数组,而无需初始化元素 |
>>> np.empty([3,2], dtype=np.float64)
array([[0., 0.], [0., 0.], [0., 0.]])
>>> a = np.array([[1, 2], [4, 3]])
>>> np.empty_like(a)
array([[0, 0], [0, 0]])
|
| eye,identity | 创建一个 NxN 单位矩阵,对角线为 1,其他位置为零 |
>>> np.eye(2, dtype=np.int)
array([[1, 0], [0, 1]])
|
| ones,ones_like | 创建具有给定形状和类型的新数组,所有元素填充为 1 |
>>> np.ones(5)
array([1., 1., 1., 1., 1.])
>>> np.ones(4, dtype=np.int)
array([1, 1, 1, 1])
>>> x = np.array([[0,1,2], [3,4,5]])
>>> np.ones_like(x)
array([[1, 1, 1],[1, 1, 1]])
|
| zeros,zeros_like | 这类似于ones和ones_like,但是用 0 初始化元素 |
>>> np.zeros(5)
array([0., 0., 0., 0-, 0.])
>>> np.zeros(4, dtype=np.int)
array([0, 0, 0, 0])
>>> x = np.array([[0, 1, 2], [3, 4, 5]])
>>> np.zeros_like(x)
array([[0, 0, 0],[0, 0, 0]])
|
| arange | 在给定间隔中创建具有均等间隔值的数组 |
>>> np.arange(2, 5)
array([2, 3, 4])
>>> np.arange(4, 12, 5)
array([4, 9])
|
| full,full_like | 创建具有给定形状和类型的新数组,并填充选定的值 |
>>> np.full((2,2), 3, dtype=np.int)
array([[3, 3], [3, 3]])
>>> x = np.ones(3)
>>> np.full_like(x, 2)
array([2., 2., 2.])
|
| array | 根据现有数据创建数组 |
>>> np.array([[1.1, 2.2, 3.3], [4.4, 5.5, 6.6]])
array([1.1, 2.2, 3.3], [4.4, 5.5, 6.6]])
|
| asarray | 将输入转换为数组 |
>>> a = [3.14, 2.46]
>>> np.asarray(a)
array([3.14, 2.46])
|
| copy | 返回给定对象的数组副本 |
>>> a = np.array([[1, 2], [3, 4]])
>>> np.copy(a)
array([[1, 2], [3, 4]])
|
| fromstring | 从字符串或文本创建一维数组 |
>>> np.fromstring('3.14 2.17', dtype=np.float, sep=' ')
array([3.14, 2.17])
|
索引和切片
与其他 Python 序列类型(例如列表)一样,访问和分配每个数组元素的值非常容易:
>>> a = np.arange(7)
>>> a
array([0, 1, 2, 3, 4, 5, 6])
>>> a[1], a [4], a[-1]
(1, 4, 6)
注意
在 Python 中,数组索引从 0 开始。这与 Fortran 或 Matlab 的索引从 1 开始形成对比。
再举一个例子,如果我们的数组是多维的,我们需要一个整数元组来索引一个项目:
>>> a = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
>>> a[0, 2] # first row, third column
3
>>> a[0, 2] = 10
>>> a
array([[1, 2, 10], [4, 5, 6], [7, 8, 9]])
>>> b = a[2]
>>> b
array([7, 8, 9])
>>> c = a[:2]
>>> c
array([[1, 2, 10], [4, 5, 6]])
我们将b和c称为数组切片,它们是原始数组的视图。 这意味着数据不会复制到b或c,并且每当我们修改它们的值时,它也将反映在数组a中:
>>> b[-1] = 11
>>> a
array([[1, 2, 10], [4, 5, 6], [7, 8, 11]])
注意
当我们省略索引号时,我们使用冒号(:)表示整个轴。
花式索引
除了使用切片索引之外,NumPy 还支持使用布尔或整数数组(掩码)进行索引。 该方法称为花式索引。 它创建副本,而不是视图。
首先,我们来看一个使用布尔型掩码数组建立索引的示例:
>>> a = np.array([3, 5, 1, 10])
>>> b = (a % 5 == 0)
>>> b
array([False, True, False, True], dtype=bool)
>>> c = np.array([[0, 1], [2, 3], [4, 5], [6, 7]])
>>> c[b]
array([[2, 3], [6, 7]])
第二个示例说明了在数组上使用整数掩码:
>>> a = np.array([[1, 2, 3, 4],
[5, 6, 7, 8],
[9, 10, 11, 12],
[13, 14, 15, 16]])
>>> a[[2, 1]]
array([[9, 10, 11, 12], [5, 6, 7, 8]])
>>> a[[-2, -1]] # select rows from the end
array([[ 9, 10, 11, 12], [13, 14, 15, 16]])
>>> a[[2, 3], [0, 1]] # take elements at (2, 0) and (3, 1)
array([9, 14])
注意
遮罩数组必须与其索引的轴具有相同的长度。
对数组的数值运算
我们正在熟悉创建和访问ndarrays的过程。 现在,我们继续下一步,对数组数据进行一些数学运算,而无需编写任何 for 循环,当然,它们具有更高的性能。
标量操作会将值传播到数组的每个元素:
>>> a = np.ones(4)
>>> a * 2
array([2., 2., 2., 2.])
>>> a + 3
array([4., 4., 4., 4.])
数组之间的所有算术运算都将运算元素明智地应用:
>>> a = np.ones([2, 4])
>>> a * a
array([[1., 1., 1., 1.], [1., 1., 1., 1.]])
>>> a + a
array([[2., 2., 2., 2.], [2., 2., 2., 2.]])
另外,这是一些比较和逻辑运算的示例:
>>> a = np.array([1, 2, 3, 4])
>>> b = np.array([1, 1, 5, 3])
>>> a == b
array([True, False, False, False], dtype=bool)
>>> np.array_equal(a, b) # array-wise comparison
False
>>> c = np.array([1, 0])
>>> d = np.array([1, 1])
>>> np.logical_and(c, d) # logical operations
array([True, False])

数组功能
NumPy 支持许多有用的数组函数来分析数据。 我们将列出其中一些常用的部分。 首先,转置函数是另一种重塑形式,它可以在不复制任何内容的情况下返回原始数据数组的视图:
>>> a = np.array([[0, 5, 10], [20, 25, 30]])
>>> a.reshape(3, 2)
array([[0, 5], [10, 20], [25, 30]])
>>> a.T
array([[0, 20], [5, 25], [10, 30]])
通常,我们有swapaxes方法,该方法采用一对轴号并返回数据视图,而不进行复制:
>>> a = np.array([[[0, 1, 2], [3, 4, 5]],
[[6, 7, 8], [9, 10, 11]]])
>>> a.swapaxes(1, 2)
array([[[0, 3],
[1, 4],
[2, 5]],
[[6, 9],
[7, 10],
[8, 11]]])
转置函数用于进行矩阵计算。 例如,使用np.dot计算内部矩阵乘积XT.X:
>>> a = np.array([[1, 2, 3],[4,5,6]])
>>> np.dot(a.T, a)
array([[17, 22, 27],
[22, 29, 36],
[27, 36, 45]])
在数组中对数据进行排序也是处理数据的重要要求。 让我们看一下一些排序函数及其用法:
>>> a = np.array ([[6, 34, 1, 6], [0, 5, 2, -1]])
>>> np.sort(a) # sort along the last axis
array([[1, 6, 6, 34], [-1, 0, 2, 5]])
>>> np.sort(a, axis=0) # sort along the first axis
array([[0, 5, 1, -1], [6, 34, 2, 6]])
>>> b = np.argsort(a) # fancy indexing of sorted array
>>> b
array([[2, 0, 3, 1], [3, 0, 2, 1]])
>>> a[0][b[0]]
array([1, 6, 6, 34])
>>> np.argmax(a) # get index of maximum element
1
请参阅下表以获取数组函数的列表:
|功能
|
描述
|
例子
|
| --- | --- | --- |
| sin,cos,tan,cosh,sinh,tanh,arcos,arctan,deg2rad | 三角函数和双曲函数 |
>>> a = np.array([0.,30., 45.])
>>> np.sin(a * np.pi / 180)
array([0., 0.5, 0.7071678])
|
| around,round,rint,fix,floor,ceil,trunc | 将数组的元素四舍五入到给定或最接近的数字 |
>>> a = np.array([0.34, 1.65])
>>> np.round(a)
array([0., 2.])
|
| sqrt,square,exp,expm1,exp2,log,log10,log1p,logaddexp | 计算数组的指数和对数 |
>>> np.exp(np.array([2.25, 3.16]))
array([9.4877, 23.5705])
|
| add,negative,multiply,devide,power,substract,mod,modf,remainder | 数组上的算术函数集 |
>>> a = np.arange(6)
>>> x1 = a.reshape(2,3)
>>> x2 = np.arange(3)
>>> np.multiply(x1, x2)
array([[0,1,4],[0,4,10]])
|
| greater,greater_equal,less,less_equal,equal,not_equal | 执行元素比较:>,> =, |
>>> np.greater(x1, x2)
array([[False, False, False], [True, True, True]], dtype = bool)
|

使用数组的数据处理
使用 NumPy 软件包,我们可以轻松解决多种数据处理任务,而无需编写复杂的循环。 对我们来说,控制我们的代码以及程序的性能非常有帮助。 在这一部分中,我们要介绍一些数学和统计函数。
有关数学和统计函数的列表,请参见下表:
|功能
|
描述
|
例子
|
| --- | --- | --- |
| sum | 计算数组中或沿轴上所有元素的总和 |
>>> a = np.array([[2,4], [3,5]])
>>> np.sum(a, axis=0)
array([5, 9])
|
| prod | 计算给定轴上数组元素的乘积 |
>>> np.prod(a, axis=1)
array([8, 15])
|
| diff | 计算沿给定轴的离散差 |
>>> np.diff(a, axis=0)
array([[1,1]])
|
| gradient | 返回数组的梯度 |
>>> np.gradient(a)
[array([[1., 1.], [1., 1.]]), array([[2., 2.], [2., 2.]])]
|
| cross | 返回两个数组的叉积 |
>>> b = np.array([[1,2], [3,4]])
>>> np.cross(a,b)
array([0, -3])
|
| std,var | 返回数组的标准偏差和方差 |
>>> np.std(a)
1.1180339
>>> np.var(a)
1.25
|
| mean | 计算数组的算术平均值 |
>>> np.mean(a)
3.5
|
| where | 从 x 或 y 返回满足条件的元素 |
>>> np.where([[True, True], [False, True]], [[1,2],[3,4]], [[5,6],[7,8]])
array([[1,2], [7, 4]])
|
| unique | 返回数组中排序的唯一值 |
>>> id = np.array(['a', 'b', 'c', 'c', 'd'])
>>> np.unique(id)
array(['a', 'b', 'c', 'd'], dtype='|S1')
|
| intersect1d | 计算两个数组中的排序元素和公共元素 |
>>> a = np.array(['a', 'b', 'a', 'c', 'd', 'c'])
>>> b = np.array(['a', 'xyz', 'klm', 'd'])
>>> np.intersect1d(a,b)
array(['a', 'd'], dtype='|S3')
|
加载和保存数据
通过使用 NumPy 包中支持的不同功能,我们还可以以文本或二进制格式将数据保存到磁盘或从磁盘加载数据。
保存数组
默认情况下,数组以未压缩的原始二进制格式保存,np.save函数使用文件扩展名.npy:
>>> a = np.array([[0, 1, 2], [3, 4, 5]])
>>> np.save('test1.npy', a)
注意
如果我们省略.npy扩展名,该库将自动为其分配。
如果我们想以未压缩的.npz格式将多个数组存储到单个文件中,则可以使用np.savez函数,如以下示例所示:
>>> a = np.arange(4)
>>> b = np.arange(7)
>>> np.savez('test2.npz', arr0=a, arr1=b)
.npz文件是文件的压缩存档,其文件名为包含的变量。 当我们加载.npz文件时,我们会得到一个类似字典的对象,可以查询其数组列表:
>>> dic = np.load('test2.npz')
>>> dic['arr0']
array([0, 1, 2, 3])
将数组数据保存到文件中的另一种方法是使用np.savetxt函数,该函数允许我们在输出文件中设置格式属性:
>>> x = np.arange(4)
>>> # e.g., set comma as separator between elements
>>> np.savetxt('test3.out', x, delimiter=',')

加载数组
我们有两个通用函数,例如 np.load和np.loadtxt,它们与保存函数相对应,用于加载数组:
>>> np.load('test1.npy')
array([[0, 1, 2], [3, 4, 5]])
>>> np.loadtxt('test3.out', delimiter=',')
array([0., 1., 2., 3.])
与和np.savetxt函数相似,np.loadtxt函数也具有许多从文本文件加载数组的选项。
带有 NumPy 的线性代数
线性代数是与向量空间以及这些空间之间的映射有关的数学分支。 NumPy 有一个名为 linalg 的软件包,它支持强大的线性代数功能。 我们可以使用这些函数来查找特征值和特征向量或执行奇异值分解:
>>> A = np.array([[1, 4, 6],
[5, 2, 2],
[-1, 6, 8]])
>>> w, v = np.linalg.eig(A)
>>> w # eigenvalues
array([-0.111 + 1.5756j, -0.111 – 1.5756j, 11.222+0.j])
>>> v # eigenvector
array([[-0.0981 + 0.2726j, -0.0981 – 0.2726j, 0.5764+0.j],
[0.7683+0.j, 0.7683-0.j, 0.4591+0.j],
[-0.5656 – 0.0762j, -0.5656 + 0.00763j, 0.6759+0.j]])
该函数是使用 geev Lapack 例程实现的,该例程计算通用平方矩阵的特征值和特征向量。
另一个常见的问题是求解线性系统,例如以A为矩阵,以x和b为向量的Ax = b。 使用numpy.linalg.solve功能可以轻松解决该问题:
>>> A = np.array([[1, 4, 6], [5, 2, 2], [-1, 6, 8]])
>>> b = np.array([[1], [2], [3]])
>>> x = np.linalg.solve(A, b)
>>> x
array([[-1.77635e-16], [2.5], [-1.5]])
下表总结了numpy.linalg软件包中的一些常用功能:
功能
|
描述
|
例子
|
| --- | --- | --- |
| dot | 计算两个数组的点积 |
>>> a = np.array([[1, 0],[0, 1]])
>>> b = np.array( [[4, 1],[2, 2]])
>>> np.dot(a,b)
array([[4, 1],[2, 2]])
|
| inner,outer | 计算两个数组的内积和外积 |
>>> a = np.array([1, 1, 1])
>>> b = np.array([3, 5, 1])
>>> np.inner(a,b)
9
|
| linalg.norm | 找出矩阵或向量范数 |
>>> a = np.arange(3)
>>> np.linalg.norm(a)
2.23606
|
| linalg.det | 计算数组的行列式 |
>>> a = np.array([[1,2],[3,4]])
>>> np.linalg.det(a)
-2.0
|
| linalg.inv | 计算矩阵的逆 |
>>> a = np.array([[1,2],[3,4]])
>>> np.linalg.inv(a)
array([[-2., 1.],[1.5, -0.5]])
|
| linalg.qr | 计算 QR 分解 |
>>> a = np.array([[1,2],[3,4]])
>>> np.linalg.qr(a)
(array([[0.316, 0.948], [0.948, 0.316]]), array([[ 3.162, 4.427], [ 0., 0.632]]))
|
| linalg.cond | 计算矩阵的条件数 |
>>> a = np.array([[1,3],[2,4]])
>>> np.linalg.cond(a)
14.933034
|
| trace | 计算对角线元素的总和 |
>>> np.trace(np.arange(6)).
reshape(2,3))
4
|
NumPy 随机数
任何模拟的重要部分是产生随机数的能力。 为此,NumPy 在子模块random中提供了各种例程。 它使用一种称为 Mersenne Twister 的特殊算法来生成伪随机数。
首先,我们需要定义一个使随机数可预测的种子。 重置该值时,每次都会出现相同的数字。 如果我们不分配种子,则 NumPy 会根据系统的随机数生成器设备或时钟自动选择一个随机种子值:
>>> np.random.seed(20)
可以按以下方式生成[0.0, 1.0]间隔中的随机数数组:
>>> np.random.rand(5)
array([0.5881308, 0.89771373, 0.89153073, 0.81583748,
0.03588959])
>>> np.random.rand(5)
array([0.69175758, 0.37868094, 0.51851095, 0.65795147,
0.19385022])
>>> np.random.seed(20) # reset seed number
>>> np.random.rand(5)
array([0.5881308, 0.89771373, 0.89153073, 0.81583748,
0.03588959])
如果要在半开间隔[min, max]中生成随机整数,则可以使用randint(min,max和length)函数:
>>> np.random.randint(10, 20, 5)
array([17, 12, 10, 16, 18])
NumPy 还提供的许多其他分布,包括Beta,bionomial,chi-square,Dirichlet,exponential,F,Gamma,geometric或 Gumbel。
下表将列出一些分布函数,并提供生成随机数的示例:
|功能
|
描述
|
例子
|
| --- | --- | --- |
| binomial | 从二项分布中抽取样本(n:试验次数,p:概率) |
>>> n, p = 100, 0.2
>>> np.random.binomial(n, p, 3)
array([17, 14, 23])
|
| dirichlet | 使用 Dirichlet 分布绘制样本 |
>>> np.random.dirichlet(alpha=(2,3), size=3)
array([[0.519, 0.480], [0.639, 0.36],
[0.838, 0.161]])
|
| poisson | 从泊松分布中抽取样本 |
>>> np.random.poisson(lam=2, size= 2)
array([4,1])
|
| normal | 使用正态高斯分布绘制样本 |
>>> np.random.normal
(loc=2.5, scale=0.3, size=3)
array([2.4436, 2.849, 2.741)
|
| uniform | 使用均匀分布绘制样本 |
>>> np.random.uniform(
low=0.5, high=2.5, size=3)
array([1.38, 1.04, 2.19[)
|
我们还可以使用随机数生成来随机排列列表中的项目。 有时候当我们想以随机顺序对列表进行排序时,这很有用:
>>> a = np.arange(10)
>>> np.random.shuffle(a)
>>> a
array([7, 6, 3, 1, 4, 2, 5, 0, 9, 8])
下图并排显示了两个分布binomial和poisson和各种参数(可视化由matplotlib创建,将在第 4 章,“数据可视化”):




十七、Pandas 数据分析
在本章中,我们将探索另一个称为 pandas 的数据分析库。 本章的目的是为您提供一些入门 Pandas 的基本知识和具体示例。
Pandas 包概述
pandas 是一个 Python 软件包,它支持快速,灵活和富于表现力的数据结构,以及用于数据分析的计算功能。 以下是 Pandas 支持的一些突出功能:
- 带有标记轴的数据结构。 这样可以使程序整洁清晰,并避免由于数据未对齐而引起的常见错误。
- 灵活处理丢失的数据。
- 基于智能标签的切片,花式索引和大型数据集的子集创建。
- 通过轴标签在自定义轴上进行强大的算术运算和统计计算。
- 强大的输入和输出支持,用于从文件,数据库或 HDF5 格式加载或保存数据。
与 Pandas 安装有关,我们建议一种简单的方法,即将其安装为 Anaconda 的一部分,Anaconda 是用于数据分析和科学计算的跨平台发行版。 您可以参考这个页面上的参考资料,以下载并安装该库。
安装后,我们可以像其他 Python 软件包一样使用它。 首先,我们必须在程序开始时导入以下软件包:
>>> import pandas as pd
>>> import numpy as np
Pandas 数据结构
首先让了解 Pandas 的两个主要数据结构:系列和数据帧。 他们可以处理金融,统计,社会科学以及许多工程领域中的大多数用例。
系列
系列是一维对象,类似于表中的数组,列表或列。 系列中的每个项目都分配给索引中的一个条目:
>>> s1 = pd.Series(np.random.rand(4),
index=['a', 'b', 'c', 'd'])
>>> s1
a 0.6122
b 0.98096
c 0.3350
d 0.7221
dtype: float64
默认情况下,如果未传递任何索引,则将其创建为具有从0到N-1的值,其中N是 Series 的长度:
>>> s2 = pd.Series(np.random.rand(4))
>>> s2
0 0.6913
1 0.8487
2 0.8627
3 0.7286
dtype: float64
我们可以通过使用索引访问 Series 的值:
>>> s1['c']
0.3350
>>>s1['c'] = 3.14
>>> s1['c', 'a', 'b']
c 3.14
a 0.6122
b 0.98096
此访问方法类似于 Python 字典。 因此,pandas 还允许我们直接从 Python 字典初始化 Series 对象:
>>> s3 = pd.Series({'001': 'Nam', '002': 'Mary',
'003': 'Peter'})
>>> s3
001 Nam
002 Mary
003 Peter
dtype: object
有时,我们想要过滤或重命名从 Python 字典创建的系列的索引。 在这种情况下,我们可以将所选索引列表直接传递给初始函数,类似于前面示例中的过程。 只有索引列表中存在的元素才会在 Series 对象中。 相反,字典中缺少的索引被 Pandas 初始化为默认的NaN值:
>>> s4 = pd.Series({'001': 'Nam', '002': 'Mary',
'003': 'Peter'}, index=[
'002', '001', '024', '065'])
>>> s4
002 Mary
001 Nam
024 NaN
065 NaN
dtype: object
ect
该库还支持检测丢失数据的功能:
>>> pd.isnull(s4)
002 False
001 False
024 True
065 True
dtype: bool
同样,我们也可以从标量值初始化 Series:
>>> s5 = pd.Series(2.71, index=['x', 'y'])
>>> s5
x 2.71
y 2.71
dtype: float64
Series 对象也可以使用 NumPy 对象初始化,例如ndarray。 此外,Pandas 可以在算术运算中自动以不同的方式对齐索引的数据:
>>> s6 = pd.Series(np.array([2.71, 3.14]), index=['z', 'y'])
>>> s6
z 2.71
y 3.14
dtype: float64
>>> s5 + s6
x NaN
y 5.85
z NaN
dtype: float64
数据框
DataFrame 是一个表格数据结构,包含一组有序的列和行。 可以将其视为共享索引(列名)的一组 Series 对象。 有很多方法可以初始化 DataFrame 对象。 首先,让我们看一下从列表字典创建 DataFrame 的常见示例:
>>> data = {'Year': [2000, 2005, 2010, 2014],
'Median_Age': [24.2, 26.4, 28.5, 30.3],
'Density': [244, 256, 268, 279]}
>>> df1 = pd.DataFrame(data)
>>> df1
Density Median_Age Year
0 244 24.2 2000
1 256 26.4 2005
2 268 28.5 2010
3 279 30.3 2014
默认情况下,DataFrame 构造函数将按字母顺序对列进行排序。 我们可以通过将列的属性传递给初始化函数来编辑默认顺序:
>>> df2 = pd.DataFrame(data, columns=['Year', 'Density',
'Median_Age'])
>>> df2
Year Density Median_Age
0 2000 244 24.2
1 2005 256 26.4
2 2010 268 28.5
3 2014 279 30.3
>>> df2.index
Int64Index([0, 1, 2, 3], dtype='int64')
我们可以提供类似于 Series 的 DataFrame 的索引标签:
>>> df3 = pd.DataFrame(data, columns=['Year', 'Density',
'Median_Age'], index=['a', 'b', 'c', 'd'])
>>> df3.index
Index([u'a', u'b', u'c', u'd'], dtype='object')
我们也可以从嵌套列表中构造一个 DataFrame:
>>> df4 = pd.DataFrame([
['Peter', 16, 'pupil', 'TN', 'M', None],
['Mary', 21, 'student', 'SG', 'F', None],
['Nam', 22, 'student', 'HN', 'M', None],
['Mai', 31, 'nurse', 'SG', 'F', None],
['John', 28, 'laywer', 'SG', 'M', None]],
columns=['name', 'age', 'career', 'province', 'sex', 'award'])
如果列名是语法上有效的属性名,则可以按列名的方式访问列,也可以按类的字典符号或属性的方式访问列:
>>> df4.name # or df4['name']
0 Peter
1 Mary
2 Nam
3 Mai
4 John
Name: name, dtype: object
要将新列修改或追加到创建的 DataFrame 中,我们指定列名称和我们要分配的值:
>>> df4['award'] = None
>>> df4
name age career province sex award
0 Peter 16 pupil TN M None
1 Mary 21 student SG F None
2 Nam 22 student HN M None
3 Mai 31 nurse SG F None
4 John 28 lawer SG M None
使用两种方法,可以按位置或名称检索行:
>>> df4.ix[1]
name Mary
age 21
career student
province SG
sex F
award None
Name: 1, dtype: object
还可以从不同的数据结构(例如字典列表,系列字典或记录数组)创建 DataFrame 对象。 初始化 DataFrame 对象的方法与前面的示例相似。
另一种常见情况是为 DataFrame 提供来自诸如文本文件等位置的数据。 在这种情况下,我们使用read_csv函数,默认情况下期望列分隔符为逗号。 但是,我们可以使用sep参数来更改它:
# person.csv file
name,age,career,province,sex
Peter,16,pupil,TN,M
Mary,21,student,SG,F
Nam,22,student,HN,M
Mai,31,nurse,SG,F
John,28,lawer,SG,M
# loading person.cvs into a DataFrame
>>> df4 = pd.read_csv('person.csv')
>>> df4
name age career province sex
0 Peter 16 pupil TN M
1 Mary 21 student SG F
2 Nam 22 student HN M
3 Mai 31 nurse SG F
4 John 28 laywer SG M
在读取数据文件时,有时我们想跳过一行或无效值。 至于 Pandas0.16.2, read_csv支持 50 多个参数来控制加载过程。 一些常用的有用参数如下:
sep:这是列之间的分隔符。 默认值为逗号符号。dtype:这是数据或列的数据类型。header:设置行号用作列名。skiprows:这会跳过行号以在文件的开头跳过。error_bad_lines:这显示无效行(太多字段),默认情况下会导致异常,因此不会返回任何 DataFrame。 如果我们将此参数的值设置为false,则会跳过错误的行。
此外,Pandas 还支持直接从数据库读取数据帧或向数据库读取数据帧,例如 Pandas 模块中的read_frame或write_frame函数。 我们将在本章后面再回到这些方法。

基本的基本功能
Pandas 支持许多对操纵 Pandas 数据结构有用的基本功能。 在本模块中,我们将重点介绍与探索和分析有关的最重要功能。
重新索引和更改标签
重新索引是 Pandas 数据结构中的关键方法。 它确认新数据或修改后的数据是否满足沿 Pandas 对象特定轴的一组给定标签。
首先,让我们来看一个关于 Series 对象的reindex示例:
>>> s2.reindex([0, 2, 'b', 3])
0 0.6913
2 0.8627
b NaN
3 0.7286
dtype: float64
当数据对象中不存在reindexed标签时,将自动为该位置分配默认值NaN。 对于 DataFrame 情况也是如此:
>>> df1.reindex(index=[0, 2, 'b', 3],
columns=['Density', 'Year', 'Median_Age','C'])
Density Year Median_Age C
0 244 2000 24.2 NaN
2 268 2010 28.5 NaN
b NaN NaN NaN NaN
3 279 2014 30.3 NaN
通过设置fill_value参数,我们可以在缺少索引的情况下将NaN值更改为自定义值。 让我们看一下reindex函数支持的参数,如下表所示:
争论
|
描述
|
| --- | --- |
| index | 这是要遵守的新标签/索引。 |
| method | 这是用于填充reindexed对象中的孔的方法。 默认设置为填空。pad/ffill:向前填充值backfill / bfill:向后填充值nearest:使用最接近的值填补空白 |
| copy | 这将返回一个新对象。 默认设置为true。 |
| level | 在传递的多个索引级别上匹配索引值。 |
| fill_value | 这是用于缺失值的值。 默认设置为NaN。 |
| limit | 这是填补forward或backward方法的最大尺寸差距。 |
头和尾
在常见的数据分析情况下,我们的数据结构对象包含许多列和大量行。 因此,我们无法查看或加载对象的所有信息。 Pandas 支持的功能使我们可以检查少量样品。 默认情况下,这些函数返回五个元素,但是我们也可以设置一个自定义数字。 以下示例显示了如何显示较长系列的前五行和后三行:
>>> s7 = pd.Series(np.random.rand(10000))
>>> s7.head()
0 0.631059
1 0.766085
2 0.066891
3 0.867591
4 0.339678
dtype: float64
>>> s7.tail(3)
9997 0.412178
9998 0.800711
9999 0.438344
dtype: float64
我们也可以以相同的方式将这些函数用于 DataFrame 对象。
二进制运算
首先,我们将考虑对象之间的算术运算。 在不同索引对象的情况下,预期结果将是索引对的并集。 我们不会再对此进行解释,因为在上一节(s5 + s6)中有一个示例。 这次,我们将展示另一个带有 DataFrame 的示例:
>>> df5 = pd.DataFrame(np.arange(9).reshape(3,3),0
columns=['a','b','c'])
>>> df5
a b c
0 0 1 2
1 3 4 5
2 6 7 8
>>> df6 = pd.DataFrame(np.arange(8).reshape(2,4),
columns=['a','b','c','d'])
>>> df6
a b c d
0 0 1 2 3
1 4 5 6 7
>>> df5 + df6
a b c d
0 0 2 4 NaN
1 7 9 11 NaN
2 NaN NaN NaN NaN
用于在两种数据结构之间返回结果的机制相似。 我们需要考虑的问题是对象之间的数据丢失。 在这种情况下,如果要填充固定值(例如0),则可以使用算术函数(例如add,sub,div和mul)以及该函数支持的参数 例如fill_value:
>>> df7 = df5.add(df6, fill_value=0)
>>> df7
a b c d
0 0 2 4 3
1 7 9 11 7
2 6 7 8 NaN
接下来,我们将讨论数据对象之间的比较操作。 支持某些 函数,例如等于(eq),不等于(ne [),大于(gt),小于(lt),小于([ 或等于(ge)。 这里是一个示例:
>>> df5.eq(df6)
a b c d
0 True True True False
1 False False False False
2 False False False False

功能统计
库支持的统计方法在数据分析中确实很重要。 要进入大数据对象,我们需要了解一些汇总信息,例如均值,总和或分位数。 Pandas 支持多种计算方法。 让我们考虑一个简单的示例,该示例计算作为数据帧对象的df5的sum信息:
>>> df5.sum()
a 9
b 12
c 15
dtype: int64
当我们未指定要计算 sum信息的轴时,默认情况下,该函数将在索引轴(即0轴)上进行计算:
- 系列:我们不需要指定轴。
- DataFrame:列(
axis = 1)或索引(axis = 0)。 默认设置为axis 0。
我们还具有skipna参数,该参数使我们可以决定是否排除丢失的数据。 默认情况下,它设置为true:
>>> df7.sum(skipna=False)
a 13
b 18
c 23
d NaN
dtype: float64
我们要考虑的另一个功能是describe()。 对于我们来说,汇总数据结构的大多数统计信息(例如 Series 和 DataFrame)也非常方便:
>>> df5.describe()
a b c
count 3.0 3.0 3.0
mean 3.0 4.0 5.0
std 3.0 3.0 3.0
min 0.0 1.0 2.0
25% 1.5 2.5 3.5
50% 3.0 4.0 5.0
75% 4.5 5.5 6.5
max 6.0 7.0 8.0
我们可以使用percentiles参数指定要包含在输出中或排除在输出中的百分位数; 例如,考虑以下因素:
>>> df5.describe(percentiles=[0.5, 0.8])
a b c
count 3.0 3.0 3.0
mean 3.0 4.0 5.0
std 3.0 3.0 3.0
min 0.0 1.0 2.0
50% 3.0 4.0 5.0
80% 4.8 5.8 6.8
max 6.0 7.0 8.0
在这里,我们有一个汇总表,列出了 Pandas 中常见的受支持的统计信息:
|功能
|
描述
|
| --- | --- |
| idxmin(axis),idxmax(axis) | 这将使用最小或最大对应值来计算索引标签。 |
| value_counts() | 这将计算唯一值的频率。 |
| count() | 这将返回数据对象中非空值的数量。 |
| mean(),median(),min(),max() | 此返回值是数据对象中轴的平均值,中位数,最小值和最大值。 |
| std(),var(),sem() | 这些返回标准偏差,方差和平均值的标准误差。 |
| abs() | 这将获取数据对象的绝对值。 |
功能应用
pandas 支持函数应用,它使我们可以将其他软件包(如 NumPy)中支持的某些函数应用于数据结构对象上。 在这里,我们举例说明这些情况的两个示例,首先,使用apply执行std()函数,该函数是 NumPy 包的标准差计算函数:
>>> df5.apply(np.std, axis=1) # default: axis=0
0 0.816497
1 0.816497
2 0.816497
dtype: float64
其次,如果我们想将公式应用于数据对象,则还可以通过执行以下步骤来使用 apply 函数:
-
定义要应用于数据对象的函数或公式。
-
通过
apply调用定义的函数或公式。 在这一步中,我们还需要弄清楚要将计算应用于的轴:>>> f = lambda x: x.max() – x.min() # step 1 >>> df5.apply(f, axis=1) # step 2 0 2 1 2 2 2 dtype: int64 >>> def sigmoid(x): return 1/(1 + np.exp(x)) >>> df5.apply(sigmoid) a b c 0 0.500000 0.268941 0.119203 1 0.047426 0.017986 0.006693 2 0.002473 0.000911 0.000335
排序
有两种我们感兴趣的排序方法:按行或列索引排序和按数据值排序。
首先,我们将考虑按行和列索引排序的方法。 在这种情况下,我们具有sort_index()功能。 我们还具有axis参数来设置函数应按行还是按列排序。 带有true或false值的ascending选项将允许我们按升序或降序对数据进行排序。 此选项的默认设置为true:
>>> df7 = pd.DataFrame(np.arange(12).reshape(3,4),
columns=['b', 'd', 'a', 'c'],
index=['x', 'y', 'z'])
>>> df7
b d a c
x 0 1 2 3
y 4 5 6 7
z 8 9 10 11
>>> df7.sort_index(axis=1)
a b c d
x 2 0 3 1
y 6 4 7 5
z 10 8 11 9
系列具有按值排序的方法顺序。 对于对象中的NaN值,我们还可以通过na_position选项进行特殊处理:
>>> s4.order(na_position='first')
024 NaN
065 NaN
002 Mary
001 Nam
dtype: object
>>> s4
002 Mary
001 Nam
024 NaN
065 NaN
dtype: object
除此之外,系列还具有sort()函数,该函数按值对数据进行排序。 但是,该函数不会返回已排序数据的副本:
>>> s4.sort(na_position='first')
>>> s4
024 NaN
065 NaN
002 Mary
001 Nam
dtype: object
如果要对 DataFrame 对象应用排序功能,则需要弄清楚将对哪些列或行进行排序:
>>> df7.sort(['b', 'd'], ascending=False)
b d a c
z 8 9 10 11
y 4 5 6 7
x 0 1 2 3
如果我们不想自动将排序结果保存到当前数据对象,则可以将inplace参数的设置更改为False。
索引和选择数据
在本节中,我们将关注于如何获取,设置或切片 Pandas 数据结构对象的子集。 正如我们在前面的部分中了解到的那样,Series 或 DataFrame 对象具有轴标签信息。 此信息可用于标识我们要选择或在对象中分配新值的项目:
>>> s4[['024', '002']] # selecting data of Series object
024 NaN
002 Mary
dtype: object
>>> s4[['024', '002']] = 'unknown' # assigning data
>>> s4
024 unknown
065 NaN
002 unknown
001 Nam
dtype: object
如果数据对象是 DataFrame 结构,我们也可以按照类似的方式进行:
>>> df5[['b', 'c']]
b c
0 1 2
1 4 5
2 7 8
对于在 DataFrame 的行上建立标签的索引,我们使用ix函数,该函数使我们能够选择对象中的一组行和列。 我们需要指定两个参数:我们要获取的row和column标签。 默认情况下,如果我们不指定选定的列名,则该函数将返回选定的行以及对象中的所有列:
>>> df5.ix[0]
a 0
b 1
c 2
Name: 0, dtype: int64
>>> df5.ix[0, 1:3]
b 1
c 2
Name: 0, dtype: int64
此外,我们有很多方法可以选择和编辑 Pandas 对象中包含的数据。 我们在下表中总结了这些功能:
|方法
|
描述
|
| --- | --- |
| icol,irow | 这将按整数位置选择单个行或列。 |
| get_value,set_value | 这将按行或列标签选择或设置数据对象的单个值。 |
| xs | 这将选择单个列或行作为“按标签序列”。 |
注意
Pandas 数据对象可能包含重复索引。 在这种情况下,当我们通过索引标签获取或设置数据值时,它将影响具有相同选定索引名称的所有行或列。

计算工具
让我们从两个数据对象之间的相关性和协方差计算开始。 Series 和 DataFrame 都具有cov方法。 在 DataFrame 对象上,此方法将计算对象内部 Series 之间的协方差:
>>> s1 = pd.Series(np.random.rand(3))
>>> s1
0 0.460324
1 0.993279
2 0.032957
dtype: float64
>>> s2 = pd.Series(np.random.rand(3))
>>> s2
0 0.777509
1 0.573716
2 0.664212
dtype: float64
>>> s1.cov(s2)
-0.024516360159045424
>>> df8 = pd.DataFrame(np.random.rand(12).reshape(4,3),
columns=['a','b','c'])
>>> df8
a b c
0 0.200049 0.070034 0.978615
1 0.293063 0.609812 0.788773
2 0.853431 0.243656 0.978057
0.985584 0.500765 0.481180
>>> df8.cov()
a b c
a 0.155307 0.021273 -0.048449
b 0.021273 0.059925 -0.040029
c -0.048449 -0.040029 0.055067
相关方法的用法类似于协方差方法。 如果数据对象是 DataFrame,它会计算数据对象内部 Series 之间的相关性。 但是,我们需要指定将使用哪种方法来计算相关性。 可用的方法是pearson,kendall和spearman。 默认情况下,该函数应用spearman方法:
>>> df8.corr(method = 'spearman')
a b c
a 1.0 0.4 -0.8
b 0.4 1.0 -0.8
c -0.8 -0.8 1.0
我们还具有corrwith函数,该函数支持计算在不同 DataFrame 对象中包含相同标签的 Series 之间的相关性:
>>> df9 = pd.DataFrame(np.arange(8).reshape(4,2),
columns=['a', 'b'])
>>> df9
a b
0 0 1
1 2 3
2 4 5
3 6 7
>>> df8.corrwith(df9)
a 0.955567
b 0.488370
c NaN
dtype: float64
处理丢失的数据
在本节中,我们将讨论 Pandas 数据结构中的NaN或null值缺失。 在对象中丢失数据是很常见的情况。 导致丢失数据的一种情况是重新索引:
>>> df8 = pd.DataFrame(np.arange(12).reshape(4,3),
columns=['a', 'b', 'c'])
a b c
0 0 1 2
1 3 4 5
2 6 7 8
3 9 10 11
>>> df9 = df8.reindex(columns = ['a', 'b', 'c', 'd'])
a b c d
0 0 1 2 NaN
1 3 4 5 NaN
2 6 7 8 NaN
4 9 10 11 NaN
>>> df10 = df8.reindex([3, 2, 'a', 0])
a b c
3 9 10 11
2 6 7 8
a NaN NaN NaN
0 0 1 2
为了处理缺失值,我们可以使用isnull()或notnull()函数来检测 Series 对象以及 DataFrame 对象中的缺失值:
>>> df10.isnull()
a b c
3 False False False
2 False False False
a True True True
0 False False False
在系列中,我们可以使用dropna函数删除所有null数据和索引值:
>>> s4 = pd.Series({'001': 'Nam', '002': 'Mary',
'003': 'Peter'},
index=['002', '001', '024', '065'])
>>> s4
002 Mary
001 Nam
024 NaN
065 NaN
dtype: object
>>> s4.dropna() # dropping all null value of Series object
002 Mary
001 Nam
dtype: object
对于 DataFrame 对象,它比 Series 复杂一些。 我们可以判断要删除的行或列,以及是否所有条目都必须为null或单个null值就足够了。 默认情况下,该函数将删除包含缺失值的任何行:
>>> df9.dropna() # all rows will be dropped
Empty DataFrame
Columns: [a, b, c, d]
Index: []
>>> df9.dropna(axis=1)
a b c
0 0 1 2
1 3 4 5
2 6 7 8
3 9 10 11
控制缺失值的另一种方法是使用我们在上一节中介绍的功能的受支持参数。 它们对于解决此问题也非常有用。 根据我们的经验,当我们创建数据对象时,应该在丢失情况下分配一个固定值。 此将在后续处理步骤中使我们的对象更清洁。 例如,考虑以下内容:
>>> df11 = df8.reindex([3, 2, 'a', 0], fill_value = 0)
>>> df11
a b c
3 9 10 11
2 6 7 8
a 0 0 0
0 0 1 2
我们还可以使用fillna函数在缺失值中填充自定义值:
>>> df9.fillna(-1)
a b c d
0 0 1 2 -1
1 3 4 5 -1
2 6 7 8 -1
3 9 10 11 -1

Pandas 用于数据分析的高级用途
在本节中,我们将考虑一些高级 Pandas 用例。
分层索引
分层索引通过在轴上将数据对象构造为多个索引级别,为我们提供了一种在较低维度上处理较高维度数据的方法:
>>> s8 = pd.Series(np.random.rand(8), index=[['a','a','b','b','c','c', 'd','d'], [0, 1, 0, 1, 0,1, 0, 1, ]])
>>> s8
a 0 0.721652
1 0.297784
b 0 0.271995
1 0.125342
c 0 0.444074
1 0.948363
d 0 0.197565
1 0.883776
dtype: float64
在前面的示例中,我们有一个具有两个索引级别的 Series 对象。 可以使用unstack功能将重新布置到 DataFrame 中。 在相反的情况下,可以使用stack功能:
>>> s8.unstack()
0 1
a 0.549211 0.420874
b 0.051516 0.715021
c 0.503072 0.720772
d 0.373037 0.207026
我们还可以创建一个 DataFrame 在两个轴上都具有层次结构索引:
>>> df = pd.DataFrame(np.random.rand(12).reshape(4,3),
index=[['a', 'a', 'b', 'b'],
[0, 1, 0, 1]],
columns=[['x', 'x', 'y'], [0, 1, 0]])
>>> df
x y
0 1 0
a 0 0.636893 0.729521 0.747230
1 0.749002 0.323388 0.259496
b 0 0.214046 0.926961 0.679686
0.013258 0.416101 0.626927
>>> df.index
MultiIndex(levels=[['a', 'b'], [0, 1]],
labels=[[0, 0, 1, 1], [0, 1, 0, 1]])
>>> df.columns
MultiIndex(levels=[['x', 'y'], [0, 1]],
labels=[[0, 0, 1], [0, 1, 0]])
获取或设置具有多个索引级别的数据对象的值或子集的方法与非分层情况类似:
>>> df['x']
0 1
a 0 0.636893 0.729521
1 0.749002 0.323388
b 0 0.214046 0.926961
0.013258 0.416101
>>> df[[0]]
x
0
a 0 0.636893
1 0.749002
b 0 0.214046
0.013258
>>> df.ix['a', 'x']
0 1
0 0.636893 0.729521
0.749002 0.323388
>>> df.ix['a','x'].ix[1]
0 0.749002
1 0.323388
Name: 1, dtype: float64
将数据分组为多个索引级别后,我们还可以使用大多数具有级别选项的描述和统计功能,这些功能可用于指定我们要处理的级别:
>>> df.std(level=1)
x y
0 1 0
0 0.298998 0.139611 0.047761
0.520250 0.065558 0.259813
>>> df.std(level=0)
x y
0 1 0
a 0.079273 0.287180 0.344880
b 0.141979 0.361232 0.037306

面板数据
面板是另一个用于 Pandas 中三维数据的数据结构。 但是,它比 Series 或 DataFrame 的使用频率更低。 您可以将 Panel 视为 DataFrame 对象的表。 我们可以从 3D ndarray或 DataFrame 对象的字典创建 Panel 对象:
# create a Panel from 3D ndarray
>>> panel = pd.Panel(np.random.rand(2, 4, 5),
items = ['item1', 'item2'])
>>> panel
<class 'pandas.core.panel.Panel'>
Dimensions: 2 (items) x 4 (major_axis) x 5 (minor_axis)
Items axis: item1 to item2
Major_axis axis: 0 to 3
Minor_axis axis: 0 to 4
>>> df1 = pd.DataFrame(np.arange(12).reshape(4, 3),
columns=['a','b','c'])
>>> df1
a b c
0 0 1 2
1 3 4 5
2 6 7 8
9 10 11
>>> df2 = pd.DataFrame(np.arange(9).reshape(3, 3),
columns=['a','b','c'])
>>> df2
a b c
0 0 1 2
1 3 4 5
6 7 8
# create another Panel from a dict of DataFrame objects
>>> panel2 = pd.Panel({'item1': df1, 'item2': df2})
>>> panel2
<class 'pandas.core.panel.Panel'>
Dimensions: 2 (items) x 4 (major_axis) x 3 (minor_axis)
Items axis: item1 to item2
Major_axis axis: 0 to 3
Minor_axis axis: a to c
面板中的每个项目都是一个 DataFrame。 我们可以通过项目名称选择一个项目:
>>> panel2['item1']
a b c
0 0 1 2
1 3 4 5
2 6 7 8
3 9 10 11
另外,如果我们要通过轴或数据位置选择数据,则可以使用ix方法,例如在 Series 或 DataFrame 上:
>>> panel2.ix[:, 1:3, ['b', 'c']]
<class 'pandas.core.panel.Panel'>
Dimensions: 2 (items) x 3 (major_axis) x 2 (minor_axis)
Items axis: item1 to item2
Major_axis axis: 1 to 3
Minor_axis axis: b to c
>>> panel2.ix[:, 2, :]
item1 item2
a 6 6
b 7 7
c 8 8



十八、数据可视化
数据可视化与图形或图形形式的数据表示有关。 这是数据分析中最重要的任务之一,因为它使我们能够查看分析结果,检测异常值并为模型构建做出决策。 有许多用于可视化的 Python 库,其中 matplotlib,seaborn,bokeh 和 ggplot 最受欢迎。 但是,在本章中,我们主要关注 matplotlib 库,该库在许多不同的上下文中被许多人使用。
Matplotlib 生成各种格式的出版物质量的数字,以及跨 Python 平台的交互式环境。 另一个优点是,pandas 附带了一些围绕 matplotlib 绘制例程的有用包装器,可以快速方便地绘制 Series 和 DataFrame 对象。
IPython 包起初是对标准交互式 Python Shell 的替代,但从那时起,它已发展成为用于数据探索,可视化和快速原型制作的必不可少的工具。 可以通过各种选项来使用 IPython 中 matplotlib 提供的图形功能,其中最简单的方法是pylab标志:
$ ipython --pylab
该标志将预加载matplotlib和numpy与默认的 matplotlib 后端进行交互使用。 IPython 可以在各种环境中运行:在终端中作为Qt应用运行,或者在浏览器内部运行。 这些选项值得探索,因为 IPython 已在许多用例中得到广泛采用,例如原型设计,用于使会议演讲或讲座更具吸引力的交互式幻灯片,以及作为共享研究的工具。
matplotlib API 入门
使用 matplotlib 进行绘图的最简单的方法通常是使用软件包支持的 MATLAB API:
>>> import matplotlib.pyplot as plt
>>> from numpy import *
>>> x = linspace(0, 3, 6)
>>> x
array([0., 0.6, 1.2, 1.8, 2.4, 3.])
>>> y = power(x,2)
>>> y
array([0., 0.36, 1.44, 3.24, 5.76, 9.])
>>> figure()
>>> plot(x, y, 'r')
>>> xlabel('x')
>>> ylabel('y')
>>> title('Data visualization in MATLAB-like API')
>>> plt.show()
先前命令的输出如下:

但是,除非有充分的理由,否则不应使用星级导入。 对于 matplotlib,我们可以使用规范导入:
>>> import matplotlib.pyplot as plt
然后,可以将前面的示例编写如下:
>>> plt.plot(x, y)
>>> plt.xlabel('x')
>>> plt.ylabel('y')
>>> plt.title('Data visualization using Pyplot of Matplotlib')
>>> plt.show()
先前命令的输出如下:

如果我们仅向 plot 函数提供单个参数,它将自动将其用作y值并生成从0到N-1的x值,其中N等于值的数量 :
>>> plt.plot(y)
>>> plt.xlabel('x')
>>> plt.ylabel('y')
>>> plt.title('Plot y value without given x values')
>>> plt.show()
先前命令的输出如下:

默认情况下,轴的范围受输入x和y数据的范围限制。 如果要指定轴的viewport,则可以使用axis()方法设置自定义范围。 例如,在先前的可视化中,我们可以通过编写以下命令来将x轴的范围从[0, 5]增大到[0, 6],并将y轴的范围从[0, 9]增大到[0, 10]。 :
>>> plt.axis([0, 6, 0, 12])
线属性
当我们在 matplotlib 中绘制数据时,默认线格式是蓝色实线,缩写为b-。 要更改此设置,我们只需要向plot功能添加符号代码,其中包括字母作为颜色字符串和符号作为线条样式字符串。 让我们考虑一下具有不同格式样式的多条线的图:
>>> plt.plot(x*2, 'g^', x*3, 'rs', x**x, 'y-')
>>> plt.axis([0, 6, 0, 30])
>>> plt.show()
上一条命令的输出如下:

我们可以选择许多线型和属性,例如颜色,线宽和破折号,以控制图形的外观。 以下示例说明了设置线属性的几种方法:
>>> line = plt.plot(y, color='red', linewidth=2.0)
>>> line.set_linestyle('--')
>>> plt.setp(line, marker='o')
>>> plt.show()
上一条命令的输出如下:

下表列出了line2d绘图的一些常见属性:
财产
|
值类型
|
描述
|
| --- | --- | --- |
| color或c | 任何 matplotlib 颜色 | 设置图中线条的颜色 |
| dashes | 开关 | 这样可以设置墨点的顺序 |
| data | np。 array xdata,np.array ydata | 设置用于可视化的数据 |
| linestyle或ls | ['-' | '—' | '-.' | ':' | ...] | 这将在图中设置线条样式 |
| linewidth或lw | 浮点价值 | 这将设置图中的线宽 |
| marker | 任何符号 | 这将在图中的数据点设置样式 |
图和子图
默认情况下,所有绘图命令均适用于当前图形和轴。 在某些情况下,我们希望可视化多个图形和轴上的数据,以比较不同的图或更有效地使用页面上的空间。 在绘制数据之前,需要完成两个步骤。 首先,我们必须定义要绘制的图形。 其次,我们需要弄清楚子图在图中的位置:
>>> plt.figure('a') # define a figure, named 'a'
>>> plt.subplot(221) # the first position of 4 subplots in 2x2 figure
>>> plt.plot(y+y, 'r--')
>>> plt.subplot(222) # the second position of 4 subplots
>>> plt.plot(y*3, 'ko')
>>> plt.subplot(223) # the third position of 4 subplots
>>> plt.plot(y*y, 'b^')
>>> plt.subplot(224)
>>> plt.show()
上一条命令的输出如下:

在这种情况下,我们当前具有图a。 如果要修改图形a中的任何子图,我们首先调用命令以选择图形和子图,然后执行该函数以修改子图。 例如,在这里,我们更改了四图图形的第二个图形的标题:
>>> plt.figure('a')
>>> plt.subplot(222)
>>> plt.title('visualization of y*3')
>>> plt.show()
前面的命令的输出如下:

注意
如果我们不使用逗号分隔索引,则整数子图规范必须为三位数。 因此,plt.subplot(221)等于plt.subplot(2,2,1)命令。
有便捷方法plt.subplots()来创建包含给定数量的子图的图形。 与前面的示例一样,我们可以使用plt.subplots(2,2)命令创建由四个子图组成的2x2图形。
我们还可以使用plt.axes([left, bottom, width, height])命令手动创建轴,而不是矩形网格,其中所有输入参数都在小数[0, 1]坐标中:
>>> plt.figure('b') # create another figure, named 'b'
>>> ax1 = plt.axes([0.05, 0.1, 0.4, 0.32])
>>> ax2 = plt.axes([0.52, 0.1, 0.4, 0.32])
>>> ax3 = plt.axes([0.05, 0.53, 0.87, 0.44])
>>> plt.show()
先前命令的输出如下:

但是,当您手动创建轴时,需要花费更多时间来平衡子图之间的坐标和大小,才能获得比例合理的图形。

探索地块类型
到目前为止,我们已经研究了如何创建简单的线图。 matplotlib 库支持更多可用于数据可视化的图类型。 但是,我们的目标是提供基本知识,以帮助您理解和使用该库在最常见的情况下可视化数据。 因此,我们仅关注四种图类型:散点图,条形图,等高线图和直方图。
散点图
散点图用于可视化同一数据集中测得的变量之间的关系。 使用plt.scatter()函数可以很容易地绘制一个简单的散点图,该散点图需要x和y轴都使用数字列:

让我们看一下前面输出的命令:
>>> X = np.random.normal(0, 1, 1000)
>>> Y = np.random.normal(0, 1, 1000)
>>> plt.scatter(X, Y, c = ['b', 'g', 'k', 'r', 'c'])
>>> plt.show()
条形图
条形图用于以矩形条形显示分组的数据,矩形条可以是垂直或水平的,且条形的长度对应于其值。 我们使用plt.bar()命令显示垂直条,使用plt.barh()命令显示其他条形:

上一个输出的命令如下:
>>> X = np.arange(5)
>>> Y = 3.14 + 2.71 * np.random.rand(5)
>>> plt.subplots(2)
>>> # the first subplot
>>> plt.subplot(211)
>>> plt.bar(X, Y, align='center', alpha=0.4, color='y')
>>> plt.xlabel('x')
>>> plt.ylabel('y')
>>> plt.title('bar plot in vertical')
>>> # the second subplot
>>> plt.subplot(212)
>>> plt.barh(X, Y, align='center', alpha=0.4, color='c')
>>> plt.xlabel('x')
>>> plt.ylabel('y')
>>> plt.title('bar plot in horizontal')
>>> plt.show()
等高线图
我们使用等高线图来表示二维的三个数值变量之间的关系。 沿x和y轴绘制了两个变量,第三个变量z用于以不同颜色绘制为曲线的轮廓级别:
>>> x = np.linspace(-1, 1, 255)
>>> y = np.linspace(-2, 2, 300)
>>> z = np.sin(y[:, np.newaxis]) * np.cos(x)
>>> plt.contour(x, y, z, 255, linewidth=2)
>>> plt.show()
让我们看一下下图中的轮廓图:

注意
如果要绘制轮廓线和填充轮廓,可以使用plt.contourf()方法代替plt.contour()。 与 MATLAB 相比,matplotlib 的contourf()不会绘制多边形的边缘。
直方图
直方图以图形方式表示数值数据的分布。 通常,值范围被划分为大小相等的 bin,每个 bin 的高度对应于该 bin 中值的频率:

先前输出的命令如下:
>>> mu, sigma = 100, 25
>>> fig, (ax0, ax1) = plt.subplots(ncols=2)
>>> x = mu + sigma * np.random.randn(1000)
>>> ax0.hist(x,20, normed=1, histtype='stepfilled',
facecolor='g', alpha=0.75)
>>> ax0.set_title('Stepfilled histogram')
>>> ax1.hist(x, bins=[100,150, 165, 170, 195] normed=1,
histtype='bar', rwidth=0.8)
>>> ax1.set_title('uniquel bins histogram')
>>> # automatically adjust subplot parameters to give specified padding
>>> plt.tight_layout()
>>> plt.show()

图例和注释
图例是一个重要元素,用于标识图中的plot元素。 在图中显示图例的最简单方法是使用plot函数的label参数,并通过调用plt.legend()方法显示标签:
>>> x = np.linspace(0, 1, 20)
>>> y1 = np.sin(x)
>>> y2 = np.cos(x)
>>> y3 = np.tan(x)
>>> plt.plot(x, y1, 'c', label='y=sin(x)')
>>> plt.plot(x, y2, 'y', label='y=cos(x)')
>>> plt.plot(x, y3, 'r', label='y=tan(x)')
>>> plt.lengend(loc='upper left')
>>> plt.show()
前面命令的输出如下:

图例命令中的loc自变量用于确定标签框的位置。 有几个有效的位置选项:lower left,right,upper left,lower center,upper right,center,lower right,upper right,center right,best,upper center 和center left。 默认位置设置为upper right。 但是,当我们设置了上面列表中不存在的无效位置选项时,该功能将自动退回到最佳选项。
如果要将图例分成多个图框,可以手动设置绘图线的预期标签,如下图所示:

上一条命令的输出如下:
>>> p1 = plt.plot(x, y1, 'c', label='y=sin(x)')
>>> p2 = plt.plot(x, y2, 'y', label='y=cos(x)')
>>> p3 = plt.plot(x, y3, 'r', label='y=tan(x)')
>>> lsin = plt.legend(handles=p1, loc='lower right')
>>> lcos = plt.legend(handles=p2, loc='upper left')
>>> ltan = plt.legend(handles=p3, loc='upper right')
>>> # with above code, only 'y=tan(x)' legend appears in the figure
>>> # fix: add lsin, lcos as separate artists to the axes
>>> plt.gca().add_artist(lsin)
>>> plt.gca().add_artist(lcos)
>>> # automatically adjust subplot parameters to specified padding
>>> plt.tight_layout()
>>> plt.show()
我们要介绍的图形中的另一个元素是注释,可以由文本,箭头或其他形状组成,以详细说明图形的各个部分或强调一些特殊的数据点。 有多种显示注释的方法,例如text,arrow和 annotation。
text方法在绘图上的给定坐标(x, y)处绘制文本; (可选)具有自定义属性。 该函数中有一些常见的参数:x,y,标签文本和可以通过fontdict传递的字体相关属性,例如family,fontsize和style。annotate方法可以绘制适当排列的文本和箭头。 该函数的参数是s(标签文本),xy(要注释的元素的位置),xytext(标签s的位置),xycoords(指示什么类型的 坐标xy是)和arrowprops(连接注释的箭头的线属性字典)。
这是一个简单的示例来说明annotate和text功能:
>>> x = np.linspace(-2.4, 0.4, 20)
>>> y = x*x + 2*x + 1
>>> plt.plot(x, y, 'c', linewidth=2.0)
>>> plt.text(-1.5, 1.8, 'y=x^2 + 2*x + 1',
fontsize=14, style='italic')
>>> plt.annotate('minima point', xy=(-1, 0),
xytext=(-1, 0.3),
horizontalalignment='center',
verticalalignment='top',
arrowprops=dict(arrowstyle='->',
connectionstyle='arc3'))
>>> plt.show()
上一条命令的输出如下:

用 Pandas 绘图功能
我们已使用 matplotlib 覆盖了图中的大多数重要组件。 在本节中,我们将介绍另一种强大的绘图方法,该方法可从通常用于处理数据的 Pandas 数据对象直接创建标准可视化效果。
对于 Pandas 中的 Series 或 DataFrame 对象,支持大多数绘图类型,例如折线图,条形图,框形图,直方图和散点图以及饼图。 要选择绘图类型,我们使用plot函数的kind参数。 没有指定任何类型的绘图,默认情况下plot函数将生成线型可视化效果,如以下示例所示:
>>> s = pd.Series(np.random.normal(10, 8, 20))
>>> s.plot(style='ko—', alpha=0.4, label='Series plotting')
>>> plt.legend()
>>> plt.show()
上一条命令的输出如下:

另一个示例将可视化包含多列的 DataFrame 对象的数据:
>>> data = {'Median_Age': [24.2, 26.4, 28.5, 30.3],
'Density': [244, 256, 268, 279]}
>>> index_label = ['2000', '2005', '2010', '2014'];
>>> df1 = pd.DataFrame(data, index=index_label)
>>> df1.plot(kind='bar', subplots=True, sharex=True)
>>> plt.tight_layout();
>>> plt.show()
上一条命令的输出如下:

DataFrame 的绘图方法具有许多选项,使我们能够处理列的绘图。 例如,在前面的 DataFrame 可视化中,我们选择将列绘制在单独的子图中。 下表列出了更多选项:
|争论
|
价值
|
描述
|
| --- | --- | --- |
| subplots | True / False | 该图在单独的子图中绘制每个数据列 |
| logy | True / False | 获取对数刻度y轴 |
| secondary_y | True / False | 在辅助y轴上绘制数据 |
| sharex,sharey | True / False | 共享相同的x或y轴,链接杆和限制 |

其他 Python 数据可视化工具
除了 matplotlib 之外,还有其他基于 Python 的强大数据可视化工具包。 尽管我们无法更深入地研究这些库,但我们希望至少在本节中简要介绍它们。
散景
Bokeh 是,由 Peter Wang,Hugo Shi 和其他人在 Continuum Analytics 中进行。 它旨在以D3.js的样式提供优雅且引人入胜的可视化。 该库可以快速轻松地创建交互式绘图,仪表板和数据应用。 这是 matplotlib 和 Bokeh 之间的一些差异:
- Bokeh 通过 IPython 的浏览器内客户端渲染新模型实现了跨平台的普遍性
- Bokeh 使用 R 和 ggplot 用户熟悉的语法,而 matplotlib 对 Matlab 用户更熟悉
- Bokeh 具有一致的构想,以构建受 ggplot 启发的浏览器内交互式可视化工具,而 Matplotlib 具有一致的构想,即专注于 2D 跨平台图形。
使用散景创建图的基本步骤如下:
- 准备列表,系列和数据框中的一些数据
- 告诉 Bokeh 您想在哪里生成输出
- 调用
figure()创建带有一些总体选项的绘图,类似于前面讨论的 matplotlib 选项 - 使用颜色,图例和宽度等视觉自定义添加数据的渲染器
- 要求 Bokeh
show()或save()结果
MayaVi
MayaVi 是一个用于交互式科学数据可视化和 3D 绘图的库,建立在屡获殊荣的可视化工具包(VTK)的基础上, 开源可视化库的基于特征的包装器。 它提供以下内容:
- 通过对话框与可视化中的数据和对象进行交互的可能性。
- Python 中的接口,用于编写脚本。 MayaVi 可以与 Numpy 和 scipy 一起使用以进行现成的 3D 绘制,并且可以在 IPython 笔记本中使用,类似于 matplotlib。
- VTK 的抽象,提供了更简单的编程模型。
让我们查看基于 VTK 示例及其提供的数据完全使用 MayaVi 制作的插图:





十九、时间序列
时间序列通常由一系列数据点组成,这些数据点来自随时间推移而进行的测量。 这种数据非常普遍,并且发生在多个字段中。
业务主管对股票价格,商品和服务价格或每月销售数字感兴趣。 气象学家每天要进行几次温度测量,并保留降水,湿度,风向和力的记录。 神经科医生可以使用脑电图来测量沿头皮的大脑电活动。 社会学家可以使用竞选捐款数据来了解政党及其支持者,并将这些见解用作论证的辅助手段。 几乎可以无休止地列举时间序列数据的更多示例。
时间序列入门
通常,时间序列用于有两个目的。 首先,它们帮助我们了解生成数据的基本过程。 另一方面,我们希望能够使用现有数据来预测相同或相关系列的未来价值。 当我们测量温度,降水或风时,我们想了解更多有关更复杂事物的信息,例如天气或某个地区的气候以及各种因素如何相互作用。 同时,我们可能对天气预报感兴趣。
在本章中,我们将探讨 Pandas 的时间序列功能。 除了强大的核心数据结构(系列和 DataFrame)之外,pandas 还具有用于处理与时间相关的数据的辅助功能。 凭借其广泛的内置优化功能,pandas 能够轻松处理具有数百万个数据点的大型时间序列。
我们将从日期和时间对象的基本构造块开始逐步处理时间序列。
处理日期和时间对象
Python 支持标准库中datetime和时间模块中的日期和时间处理:
>>> import datetime
>>> datetime.datetime(2000, 1, 1)
datetime.datetime(2000, 1, 1, 0, 0)
有时,日期是以字符串形式给出或预期的,因此必须从字符串到字符串的转换,这是通过两个函数分别实现的:strptime和strftime:
>>> datetime.datetime.strptime("2000/1/1", "%Y/%m/%d")
datetime.datetime(2000, 1, 1, 0, 0)
>>> datetime.datetime(2000, 1, 1, 0, 0).strftime("%Y%m%d")
'20000101'
现实世界中的数据通常以各种形式出现,如果我们不需要记住为解析指定的确切日期格式,那就太好了。 值得庆幸的是,当处理代表日期或时间的字符串时,Pandas 消除了很多摩擦。 这些辅助功能之一是to_datetime:
>>> import pandas as pd
>>> import numpy as np
>>> pd.to_datetime("4th of July")
Timestamp('2015-07-04
>>> pd.to_datetime("13.01.2000")
Timestamp('2000-01-13 00:00:00')
>>> pd.to_datetime("7/8/2000")
Timestamp('2000-07-08 00:00:00')
根据地区,最后一个可以指 8 月 7 日或 7 月 8 日。 为了消除这种情况的歧义,可以向to_datetime传递关键字参数dayfirst:
>>> pd.to_datetime("7/8/2000", dayfirst=True)
Timestamp('2000-08-07 00:00:00')
时间戳记对象可以看作是datetime对象的 Pandas 版本,实际上Timestamp类是datetime的子类:
>>> issubclass(pd.Timestamp, datetime.datetime)
True
这意味着它们可以在许多情况下互换使用:
>>> ts = pd.to_datetime(946684800000000000)
>>> ts.year, ts.month, ts.day, ts.weekday()
(2000, 1, 1, 5)
时间戳记对象是 Pandas 时间序列功能的重要组成部分,因为时间戳记是DateTimeIndex对象的构建块:
>>> index = [pd.Timestamp("2000-01-01"),
pd.Timestamp("2000-01-02"),
pd.Timestamp("2000-01-03")]
>>> ts = pd.Series(np.random.randn(len(index)), index=index)
>>> ts
2000-01-01 0.731897
2000-01-02 0.761540
2000-01-03 -1.316866
dtype: float64
>>> ts.indexDatetime
Index(['2000-01-01', '2000-01-02', '2000-01-03'],
dtype='datetime64[ns]', freq=None, tz=None)
这里需要注意一些事项:我们创建一个时间戳对象列表,并将其作为索引传递给序列构造函数。 此时间戳列表将即时转换为DatetimeIndex。 如果只传递了日期字符串,则不会获得DatetimeIndex,而只会得到index:
>>> ts = pd.Series(np.random.randn(len(index)), index=[
"2000-01-01", "2000-01-02", "2000-01-03"])
>>> ts.index
Index([u'2000-01-01', u'2000-01-02', u'2000-01-03'], dtype='object')
但是,to_datetime函数足够灵活,可以帮助您,如果我们仅有的是日期字符串列表:
>>> index = pd.to_datetime(["2000-01-01", "2000-01-02", "2000-01-03"])
>>> ts = pd.Series(np.random.randn(len(index)), index=index)
>>> ts.index
DatetimeIndex(['2000-01-01', '2000-01-02', '2000-01-03'], dtype='datetime64[ns]', freq=None, tz=None))
要注意的另一件事是,尽管我们具有DatetimeIndex,但freq和tz属性都是None。 在本章后面,我们将学习这两个属性的效用。
使用to_datetime,我们可以将各种字符串甚至字符串列表转换为时间戳或DatetimeIndex对象。 有时,我们没有明确获得有关一个序列的所有信息,我们必须自己生成固定间隔的时间戳序列。
Pandas 为此任务提供了另一个强大的实用功能:date_range。
date_range功能有助于在开始日期和结束日期之间生成固定频率的datetime索引。 也可以指定开始或结束日期以及要生成的时间戳数。
频率可以通过freq参数指定,该参数支持多个偏移量。 您可以使用典型的时间间隔,例如小时,分钟和秒:
>>> pd.date_range(start="2000-01-01", periods=3, freq='H')
DatetimeIndex(['2000-01-01 00:00:00', '2000-01-01 01:00:00','2000-01-01 02:00:00'], dtype='datetime64[ns]', freq='H', tz=None)
>>> pd.date_range(start="2000-01-01", periods=3, freq='T')
DatetimeIndex(['2000-01-01 00:00:00', '2000-01-01 00:01:00','2000-01-01 00:02:00'], dtype='datetime64[ns]', freq='T', tz=None)
>>> pd.date_range(start="2000-01-01", periods=3, freq='S')
DatetimeIndex(['2000-01-01 00:00:00', '2000-01-01 00:00:01','2000-01-01 00:00:02'], dtype='datetime64[ns]', freq='S', tz=None)
freq属性允许我们指定多个选项。 Pandas 已经在金融和经济学中成功使用,尤其是因为它也很容易处理营业日期。 例如,要获取包含千年的前三个工作日的索引,可以使用B偏移别名:
>>> pd.date_range(start="2000-01-01", periods=3, freq='B')
DatetimeIndex(['2000-01-03', '2000-01-04', '2000-01-05'], dtype='datetime64[ns]', freq='B', tz=None)
下表显示了可用的偏移别名,也可以在时间序列的 pandas 文档查找:
|别名
|
描述
|
| --- | --- |
| 乙 | 工作日频率 |
| C | 自定义工作日频率 |
| d | 日历日频率 |
| 在 | 每周频率 |
| 中号 | 月末频率 |
| BM | 营业月结束频率 |
| 煤层气 | 自定义营业月结束频率 |
| 小姐 | 月开始频率 |
| BMS | 营业月份开始频率 |
| 中央管理系统 | 自定义营业月开始频率 |
| 问 | 四分之一结束频率 |
| BQ | 业务季度频率 |
| 质量体系 | 季度开始频率 |
| BQS | 业务季度开始频率 |
| 一种 | 年末频率 |
| BA | 营业年度结束频率 |
| 作为 | 年开始频率 |
| 低的 | 营业年度开始频率 |
| BH | 营业时间频率 |
| H | 每小时频率 |
| Ť | 分频 |
| 小号 | 其次频率 |
| 大号 | 毫秒 |
| ü | 微秒 |
| ñ | 纳秒 |
此外,偏移别名也可以组合使用。 在这里,我们正在生成一个datetime索引,该索引包含五个元素,分别每天,一小时,一分钟和一秒间隔开:
>>> pd.date_range(start="2000-01-01", periods=5, freq='1D1h1min10s')
DatetimeIndex(['2000-01-01 00:00:00', '2000-01-02 01:01:10','2000-01-03 02:02:20', '2000-01-04 03:03:30','2000-01-05 04:04:40'],dtype='datetime64[ns]', freq='90070S', tz=None)
如果我们想在工作时间的每 12 小时(默认情况下从 9 AM 开始到 5 PM 结束)对数据建立索引,则只需为BH别名加上前缀:
>>> pd.date_range(start="2000-01-01", periods=5, freq='12BH')
DatetimeIndex(['2000-01-03 09:00:00', '2000-01-04 13:00:00','2000-01-06 09:00:00', '2000-01-07 13:00:00','2000-01-11 09:00:00'],dtype='datetime64[ns]', freq='12BH', tz=None)
还可以对工作时间的含义进行自定义:
>>> ts.index
DatetimeIndex(['2000-01-01', '2000-01-02', '2000-01-03'], dtype='datetime64[ns]', freq=None, tz=None)
我们也可以使用此自定义工作时间来建立索引:
>>> pd.date_range(start="2000-01-01", periods=5, freq=12 * bh)
DatetimeIndex(['2000-01-03 07:00:00', '2000-01-03 19:00:00','2000-01-04 07:00:00', '2000-01-04 19:00:00','2000-01-05 07:00:00', '2000-01-05 19:00:00','2000-01-06 07:00:00'],dtype='datetime64[ns]', freq='12BH', tz=None)
一些频率允许我们指定锚定后缀,这使我们可以表达间隔,例如每月的每个星期五或第二个星期二:
>>> pd.date_range(start="2000-01-01", periods=5, freq='W-FRI')
DatetimeIndex(['2000-01-07', '2000-01-14', '2000-01-21', '2000-01-28', '2000-02-04'], dtype='datetime64[ns]', freq='W-FRI', tz=None)
>>> pd.date_range(start="2000-01-01", periods=5, freq='WOM-2TUE')DatetimeIndex(['2000-01-11', '2000-02-08', '2000-03-14', '2000-04-11', '2000-05-09'], dtype='datetime64[ns]', freq='WOM-2TUE', tz=None)
最后,我们可以合并不同频率的各种索引。 可能性是无止境。 我们仅显示一个示例,其中我们结合了两个索引(每个索引十年),一个指向一年中的每个工作日,另一个指向二月的最后一天:
>>> s = pd.date_range(start="2000-01-01", periods=10, freq='BAS-JAN')
>>> t = pd.date_range(start="2000-01-01", periods=10, freq='A-FEB')
>>> s.union(t)
DatetimeIndex(['2000-01-03', '2000-02-29', '2001-01-01', '2001-02-28','2002-01-01', '2002-02-28', '2003-01-01', '2003-02-28','2004-01-01', '2004-02-29', '2005-01-03', '2005-02-28','2006-01-02', '2006-02-28', '2007-01-01', '2007-02-28','2008-01-01', '2008-02-29', '2009-01-01', '2009-02-28'],dtype='datetime64[ns]', freq=None, tz=None)
我们看到 2000 年和 2005 年不是在工作日开始的,而 2000 年,2004 年和 2008 年是 the 年。
到目前为止,我们已经看到了两个强大的功能to_datetime和date_range。 现在,我们想通过首先展示如何仅用几行就可以创建和绘制时间序列数据来深入研究时间序列。 在本节的其余部分,我们将展示访问和切片时间序列数据的各种方法。
开始使用 Pandas 中的时间序列数据很容易。 可以创建随机游走并将其绘制成几行:
>>> index = pd.date_range(start='2000-01-01', periods=200, freq='B')
>>> ts = pd.Series(np.random.randn(len(index)), index=index)
>>> walk = ts.cumsum()
>>> walk.plot()
下图显示了该图的可能输出:

就像通常的系列对象一样,您可以选择零件并切片索引:
>>> ts.head()
2000-01-03 1.464142
2000-01-04 0.103077
2000-01-05 0.762656
2000-01-06 1.157041
2000-01-07 -0.427284
Freq: B, dtype: float64
>>> ts[0]
1.4641415817112928>>> ts[1:3]
2000-01-04 0.103077
2000-01-05 0.762656
即使我们的系列具有DatetimeIndex,我们也可以将日期字符串用作键:
>>> ts['2000-01-03']
1.4641415817112928
即使DatetimeIndex由时间戳对象组成,我们也可以将datetime对象用作键:
>>> ts[datetime.datetime(2000, 1, 3)]
1.4641415817112928
访问类似于字典或列表中的查找,但功能更强大。 例如,我们可以用字符串甚至是混合对象切片:
>>> ts['2000-01-03':'2000-01-05']
2000-01-03 1.464142
2000-01-04 0.103077
2000-01-05 0.762656
Freq: B, dtype: float64
>>> ts['2000-01-03':datetime.datetime(2000, 1, 5)]
2000-01-03 1.464142
2000-01-04 0.103077
2000-01-05 0.762656
Freq: B, dtype: float64
>>> ts['2000-01-03':datetime.date(2000, 1, 5)]
2000-01-03 -0.807669
2000-01-04 0.029802
2000-01-05 -0.434855
Freq: B, dtype: float64
甚至可以使用部分字符串来选择条目组。 如果只对二月份感兴趣,我们可以简单地写:
>>> ts['2000-02']
2000-02-01 0.277544
2000-02-02 -0.844352
2000-02-03 -1.900688
2000-02-04 -0.120010
2000-02-07 -0.465916
2000-02-08 -0.575722
2000-02-09 0.426153
2000-02-10 0.720124
2000-02-11 0.213050
2000-02-14 -0.604096
2000-02-15 -1.275345
2000-02-16 -0.708486
2000-02-17 -0.262574
2000-02-18 1.898234
2000-02-21 0.772746
2000-02-22 1.142317
2000-02-23 -1.461767
2000-02-24 -2.746059
2000-02-25 -0.608201
2000-02-28 0.513832
2000-02-29 -0.132000
要查看从三月到五月的所有条目,包括:
>>> ts['2000-03':'2000-05']
2000-03-01 0.528070
2000-03-02 0.200661
...
2000-05-30 1.206963
2000-05-31 0.230351
Freq: B, dtype: float64
时间序列可以在时间上向前或向后移动。 索引保持不变,值移动:
>>> small_ts = ts['2000-02-01':'2000-02-05']
>>> small_ts
2000-02-01 0.277544
2000-02-02 -0.844352
2000-02-03 -1.900688
2000-02-04 -0.120010
Freq: B, dtype: float64
>>> small_ts.shift(2)
2000-02-01 NaN
2000-02-02 NaN
2000-02-03 0.277544
2000-02-04 -0.844352
Freq: B, dtype: float64
要向后移动时间,我们只需使用负值即可:
>>> small_ts.shift(-2)
2000-02-01 -1.900688
2000-02-02 -0.120010
2000-02-03 NaN
2000-02-04 NaN
Freq: B, dtype: float64

重采样时间序列
重采样描述了按时间序列数据进行频率转换的过程。 在各种情况下,它都是一种有用的技术,因为它可以通过将数据分组和汇总来增进理解。 可以从显示每周或每月平均温度的每日温度数据创建新的时间序列。 另一方面,现实世界中的数据可能无法以统一的间隔获取,因此需要将观测值映射到统一的间隔或填充某些时间点的缺失值。 这是重采样的两个主要使用方向:合并和聚合,以及填充丢失的数据。 下采样和上采样也发生在其他领域,例如数字信号处理。 在那里,下采样的过程通常被称为抽取,并降低了采样率。 逆过程称为内插,其中采样率增加。 我们将从数据分析的角度看两个方向。
下采样时间序列数据
下采样减少了数据中的采样数。 在减少过程中,我们能够对数据点应用聚合。 让我们想象一个繁忙的机场,每小时都有成千上万的人经过。 机场管理局在主要区域安装了一个访客柜台,以给人确切的印象是他们的机场有多忙。
他们每分钟都会从计数器设备接收数据。 以下是一天的假设度量,从 08:00 开始,到 600 分钟后的 18:00 结束:
>>> rng = pd.date_range('4/29/2015 8:00', periods=600, freq='T')
>>> ts = pd.Series(np.random.randint(0, 100, len(rng)), index=rng)
>>> ts.head()
2015-04-29 08:00:00 9
2015-04-29 08:01:00 60
2015-04-29 08:02:00 65
2015-04-29 08:03:00 25
2015-04-29 08:04:00 19
为了更好地了解当天情况,我们可以将此时间序列下采样到更大的间隔,例如 10 分钟。 我们也可以选择一个聚合函数。 默认聚合是获取所有值并计算均值:
>>> ts.resample('10min').head()
2015-04-29 08:00:00 49.1
2015-04-29 08:10:00 56.0
2015-04-29 08:20:00 42.0
2015-04-29 08:30:00 51.9
2015-04-29 08:40:00 59.0
Freq: 10T, dtype: float64
在我们的机场示例中,我们还对值的总和(即,给定时间范围内的访客总数)感兴趣。 我们可以通过将函数或函数名称传递给how参数来选择聚合函数:
>>> ts.resample('10min', how='sum').head()
2015-04-29 08:00:00 442
2015-04-29 08:10:00 409
2015-04-29 08:20:00 532
2015-04-29 08:30:00 433
2015-04-29 08:40:00 470
Freq: 10T, dtype: int64
或者我们可以通过重新采样到每小时间隔来进一步减少采样间隔:
>>> ts.resample('1h', how='sum').head()
2015-04-29 08:00:00 2745
2015-04-29 09:00:00 2897
2015-04-29 10:00:00 3088
2015-04-29 11:00:00 2616
2015-04-29 12:00:00 2691
Freq: H, dtype: int64
我们也可以要求其他。 例如,一小时内通过我们机场的最大人数是多少:
>>> ts.resample('1h', how='max').head()
2015-04-29 08:00:00 97
2015-04-29 09:00:00 98
2015-04-29 10:00:00 99
2015-04-29 11:00:00 98
2015-04-29 12:00:00 99
Freq: H, dtype: int64
或者,如果我们对更多非常规指标感兴趣,则可以定义一个自定义函数。 例如,我们可能会对每小时选择一个随机样本感兴趣:
>>> import random
>>> ts.resample('1h', how=lambda m: random.choice(m)).head()2015-04-29 08:00:00 28
2015-04-29 09:00:00 14
2015-04-29 10:00:00 68
2015-04-29 11:00:00 31
2015-04-29 12:00:00 5
如果您通过字符串指定函数,pandas 将使用高度优化的版本。
可以用作how参数的内置函数是:sum,mean,std, sem,max,min,median,first,last,[ ohlc。 ohlc指标在金融领域很流行。 它代表开-高-低-闭。 OHLC 图表是说明金融工具价格随时间变化的一种典型方法。
尽管在我们的机场中该指标可能没有那么有价值,但是我们仍然可以计算出它:
>>> ts.resample('1h', how='ohlc').head()
open high low close
2015-04-29 08:00:00 9 97 0 14
2015-04-29 09:00:00 68 98 3 12
2015-04-29 10:00:00 71 99 1 1
2015-04-29 11:00:00 59 98 0 4
2015-04-29 12:00:00 56 99 3 55
上采样时间序列数据
在上采样中,的时间序列的频率增加。 结果,我们有比数据点更多的采样点。 主要问题之一是如何计算我们无法衡量的系列中的条目。
让我们从一天的每小时数据开始:
>>> rng = pd.date_range('4/29/2015 8:00', periods=10, freq='H')
>>> ts = pd.Series(np.random.randint(0, 100, len(rng)), index=rng)
>>> ts.head()
2015-04-29 08:00:00 30
2015-04-29 09:00:00 27
2015-04-29 10:00:00 54
2015-04-29 11:00:00 9
2015-04-29 12:00:00 48
Freq: H, dtype: int64
如果我们将上采样到每 15 分钟获取的数据点,则我们的时间序列将扩展为NaN值:
>>> ts.resample('15min')
>>> ts.head()
2015-04-29 08:00:00 30
2015-04-29 08:15:00 NaN
2015-04-29 08:30:00 NaN
2015-04-29 08:45:00 NaN
2015-04-29 09:00:00 27
处理缺失值的方法有多种,可以通过fill_method关键字参数进行控制以进行重采样。 值可以向前或向后填充:
>>> ts.resample('15min', fill_method='ffill').head()
2015-04-29 08:00:00 30
2015-04-29 08:15:00 30
2015-04-29 08:30:00 30
2015-04-29 08:45:00 30
2015-04-29 09:00:00 27
Freq: 15T, dtype: int64
>>> ts.resample('15min', fill_method='bfill').head()
2015-04-29 08:00:00 30
2015-04-29 08:15:00 27
2015-04-29 08:30:00 27
2015-04-29 08:45:00 27
2015-04-29 09:00:00 27
使用limit参数,可以控制要填充的缺失值的数量:
>>> ts.resample('15min', fill_method='ffill', limit=2).head()
2015-04-29 08:00:00 30
2015-04-29 08:15:00 30
2015-04-29 08:30:00 30
2015-04-29 08:45:00 NaN
2015-04-29 09:00:00 27
Freq: 15T, dtype: float64
如果要在重新采样期间调整标签,则可以使用loffset关键字参数:
>>> ts.resample('15min', fill_method='ffill', limit=2, loffset='5min').head()
2015-04-29 08:05:00 30
2015-04-29 08:20:00 30
2015-04-29 08:35:00 30
2015-04-29 08:50:00 NaN
2015-04-29 09:05:00 27
Freq: 15T, dtype: float64
还有另一种方式来填写缺失值。 对于某种定义,我们可以采用一种算法来构建某种程度上适合现有点的新数据点。 此过程称为插值。
我们可以要求 Pandas 为我们内插时间序列:
>>> tsx = ts.resample('15min')
>>> tsx.interpolate().head()
2015-04-29 08:00:00 30.00
2015-04-29 08:15:00 29.25
2015-04-29 08:30:00 28.50
2015-04-29 08:45:00 27.75
2015-04-29 09:00:00 27.00
Freq: 15T, dtype: float64
我们看到了默认的interpolate方法-线性插值-正在起作用。 Pandas 假设两个现有点之间存在线性关系。
Pandas 支持十多种interpolation功能,其中一些功能需要安装scipy库。 我们不会在本章中介绍interpolation方法,但是我们建议您自己探索各种方法。 正确的interpolation方法将取决于您的应用要求。

尽管默认情况下,Pandas 对象不了解时区,但许多实际应用都会使用时区。 与通常使用时间一样,时区也不是一件容易的事:您知道哪个国家有夏时制吗?您知道这些国家的时区何时切换吗? 值得庆幸的是,pandas 建立在两个流行且经过验证的实用程序库的时区功能上,用于处理时间和日期:pytz和dateutil:
>>> t = pd.Timestamp('2000-01-01')
>>> t.tz is None
True
要提供时区信息,可以使用tz关键字参数:
>>> t = pd.Timestamp('2000-01-01', tz='Europe/Berlin')
>>> t.tz
<DstTzInfo 'Europe/Berlin' CET+1:00:00 STD>
这也适用于ranges:
>>> rng = pd.date_range('1/1/2000 00:00', periods=10, freq='D', tz='Europe/London')
>>> rng
DatetimeIndex(['2000-01-01', '2000-01-02', '2000-01-03', '2000-01-04','2000-01-05', '2000-01-06', '2000-01-07', '2000-01-08','2000-01-09', '2000-01-10'], dtype='datetime64[ns]', freq='D', tz='Europe/London')
时区对象也可以预先构造:
>>> import pytz
>>> tz = pytz.timezone('Europe/London')
>>> rng = pd.date_range('1/1/2000 00:00', periods=10, freq='D', tz=tz)
>>> rng
DatetimeIndex(['2000-01-01', '2000-01-02', '2000-01-03', '2000-01-04','2000-01-05', '2000-01-06', '2000-01-07', '2000-01-08','2000-01-09', '2000-01-10'], dtype='datetime64[ns]', freq='D', tz='Europe/London')
有时,您将已经有了一个不知道时区的时间序列对象,而您想使该对象知道时区。 tz_localize功能有助于在时区感知对象和时区未知对象之间切换:
>>> rng = pd.date_range('1/1/2000 00:00', periods=10, freq='D')
>>> ts = pd.Series(np.random.randn(len(rng)), rng)
>>> ts.index.tz is None
True
>>> ts_utc = ts.tz_localize('UTC')
>>> ts_utc.index.tz
<UTC>
要将时区感知对象移动到其他时区,可以使用tz_convert方法:
>>> ts_utc.tz_convert('Europe/Berlin').index.tz
<DstTzInfo 'Europe/Berlin' LMT+0:53:00 STD>
最后,要从对象分离任何时区信息,可以将None传递给tz_convert或tz_localize:
>>> ts_utc.tz_convert(None).index.tz is None
True
>>> ts_utc.tz_localize(None).index.tz is None
True

Timedeltas
除了功能强大的时间戳对象(用作DatetimeIndex的构建块)外,还有另一个有用的数据结构(已在 Pandas 0.15 中引入)– Timedelta。 Timedelta 也可以用作索引的基础,在这种情况下为TimedeltaIndex。
时间增量是时间差异,以差异单位表示。 Pandas 中的Timedelta类是 Python 标准库中datetime.timedelta的子类。 与其他 Pandas 数据结构一样,Timedelta 可以由多种输入构成:
>>> pd.Timedelta('1 days')
Timedelta('1 days 00:00:00')
>>> pd.Timedelta('-1 days 2 min 10s 3us')
Timedelta('-2 days +23:57:49.999997')
>>> pd.Timedelta(days=1,seconds=1)
Timedelta('1 days 00:00:01')
如您所料,Timedeltas允许进行基本算术运算:
>>> pd.Timedelta(days=1) + pd.Timedelta(seconds=1)
Timedelta('1 days 00:00:01')
与to_datetime相似,有一个to_timedelta函数可以将字符串或字符串列表解析为 Timedelta 结构或TimedeltaIndices:
>>> pd.to_timedelta('20.1s')
Timedelta('0 days 00:00:20.100000')
除了绝对日期,我们可以创建timedeltas的索引。 例如,想象一下从火山进行的测量。 我们可能要进行测量,但要从给定日期(例如上一次喷发的日期)开始对其进行索引。 我们可以创建一个timedelta索引,该索引具有最近 7 天的条目:
>>> pd.to_timedelta(np.arange(7), unit='D')
TimedeltaIndex(['0 days', '1 days', '2 days', '3 days', '4 days', '5 days', '6 days'], dtype='timedelta64[ns]', freq=None)
然后,我们可以处理从上一次喷发开始索引的时间序列数据。 如果我们对许多火山爆发(可能来自多个火山)进行了测量,那么我们将拥有一个索引,可以使对这些数据的比较和分析更加容易。 例如,我们可以询问在喷发后的第三天到第五天之间是否存在典型的模式。 用DatetimeIndex不可能回答这个问题,但是TimedeltaIndex使这种探索更加方便。

时间序列图
pandas 对绘图提供了强大的支持,时间序列数据也是如此。
作为第一个示例,让我们获取一些每月数据并将其绘制成图:
>>> rng = pd.date_range(start='2000', periods=120, freq='MS')
>>> ts = pd.Series(np.random.randint(-10, 10, size=len(rng)), rng).cumsum()
>>> ts.head()
2000-01-01 -4
2000-02-01 -6
2000-03-01 -16
2000-04-01 -26
2000-05-01 -24
Freq: MS, dtype: int64
由于 matplotlib 是在后台使用的,因此我们可以传递一个熟悉的参数来绘图,例如 c 代表颜色,或者 title 代表图表标题:
>>> ts.plot(c='k', title='Example time series')
>>> plt.show()
下图显示了时间序列图示例:

我们可以覆盖 2 年和 5 年的总积:
>>> ts.resample('2A').plot(c='0.75', ls='--')
>>> ts.resample('5A').plot(c='0.25', ls='-.')
下图显示了重新采样的 2 年图:

下图显示了重采样的 5 年图:

我们也可以将这种图表传递给plot方法。 plot方法的返回值为AxesSubplot,它使我们可以自定义绘图的许多方面。 在这里,我们将X轴上的标签值设置为时间序列中的年份值:
>>> plt.clf()
>>> tsx = ts.resample('1A')
>>> ax = tsx.plot(kind='bar', color='k')
>>> ax.set_xticklabels(tsx.index.year)

想象一下,我们想同时绘制四个时间序列。 我们生成一个 1000×4 随机值的矩阵,并将每列视为一个单独的时间序列:
>>> plt.clf()
>>> ts = pd.Series(np.random.randn(1000), index=pd.date_range('1/1/2000', periods=1000))
>>> df = pd.DataFrame(np.random.randn(1000, 4), index=ts.index, columns=['A', 'B', 'C', 'D'])
>>> df = df.cumsum()>>> df.plot(color=['k', '0.75', '0.5', '0.25'], ls='--')




二十、与数据库交互
数据分析始于数据。 因此,使用易于设置,操作且数据访问本身不会成为问题的数据存储系统是有益的。 简而言之,我们希望拥有易于嵌入到我们的数据分析过程和工作流中的数据库系统。 在本模块中,我们主要集中在数据库交互的 Python 方面,并且我们将学习如何将数据导入和导出 Pandas 数据结构。
有多种存储数据的方法。 在本章中,我们将学习与三个主要类别进行交互:文本格式,二进制格式和数据库。 我们将专注于两种存储解决方案,MongoDB 和 Redis。 MongoDB 是一个面向文档的数据库,易于入门,因为我们可以存储 JSON 文档,而无需预先定义架构。 Redis 是一种流行的内存数据结构存储,可以在其上构建许多应用。 可以将 Redis 用作快速键值存储,但是 Redis 也支持开箱即用的列表,集合,哈希,位数组甚至高级数据结构(例如 HyperLogLog)。
与文本格式的数据进行交互
文本是很好的媒介,它是一种交换信息的简单方法。 以下语句来自 Doug McIlroy 的引号:编写处理文本流的程序,因为这是通用接口。
在本节中,我们将开始在文本文件中读写数据。
从文本格式读取数据
通常,系统的原始数据日志存储在多个文本文件中,随着时间的推移,这些文本文件会累积大量信息。 幸运的是,在 Python 中与这些文件进行交互很简单。
pandas 支持多种功能,可将数据从文本文件读取到 DataFrame 对象中。 最简单的是read_csv()功能。 让我们从一个小的示例文件开始:
$ cat example_data/ex_06-01.txt
Name,age,major_id,sex,hometown
Nam,7,1,male,hcm
Mai,11,1,female,hcm
Lan,25,3,female,hn
Hung,42,3,male,tn
Nghia,26,3,male,dn
Vinh,39,3,male,vl
Hong,28,4,female,dn
注意
cat是 Unix shell 命令,可用于将文件内容打印到屏幕上。
在前面的示例文件中,每一列用逗号分隔,第一行是标题行,其中包含列名。 要将数据文件读入 DataFrame 对象,我们键入以下命令:
>>> df_ex1 = pd.read_csv('example_data/ex_06-01.txt')
>>> df_ex1
Name age major_id sex hometown
0 Nam 7 1 male hcm
1 Mai 11 1 female hcm
2 Lan 25 3 female hn
3 Hung 42 3 male tn
4 Nghia 26 3 male dn
5 Vinh 39 3 male vl
6 Hong 28 4 female dn
我们看到read_csv函数使用逗号作为文本文件中各列之间的默认分隔符,并且第一行自动用作各列的标题。 如果要更改此设置,可以在示例文件没有标题行的情况下使用sep参数更改分隔的符号并设置header=None。
请参见以下示例:
$ cat example_data/ex_06-02.txt
Nam 7 1 male hcm
Mai 11 1 female hcm
Lan 25 3 female hn
Hung 42 3 male tn
Nghia 26 3 male dn
Vinh 39 3 male vl
Hong 28 4 female dn
>>> df_ex2 = pd.read_csv('example_data/ex_06-02.txt',
sep = '\t', header=None)
>>> df_ex2
0 1 2 3 4
0 Nam 7 1 male hcm
1 Mai 11 1 female hcm
2 Lan 25 3 female hn
3 Hung 42 3 male tn
4 Nghia 26 3 male dn
5 Vinh 39 3 male vl
6 Hong 28 4 female dn
我们也可以使用等于选定行索引的header将设置为字幕行。 类似地,当我们想使用数据文件中的任何列作为 DataFrame 的列索引时,我们将index_col设置为该列的名称或索引。 我们再次使用第二个数据文件example_data/ex_06-02.txt进行说明:
>>> df_ex3 = pd.read_csv('example_data/ex_06-02.txt',
sep = '\t', header=None,
index_col=0)
>>> df_ex3
1 2 3 4
0
Nam 7 1 male hcm
Mai 11 1 female hcm
Lan 25 3 female hn
Hung 42 3 male tn
Nghia 26 3 male dn
Vinh 39 3 male vl
Hong 28 4 female dn
除了这些参数,我们还有许多有用的参数,可以帮助我们更有效地将数据文件加载到 pandas 对象中。 下表显示了一些常用参数:
|范围
|
价值
|
描述
|
| --- | --- | --- |
| dtype | 类型名称或列类型的字典 | 设置数据或列的数据类型。 默认情况下,它将尝试推断最合适的数据类型。 |
| skiprows | 类列表或整数 | 要跳过的行数(从 0 开始)。 |
| na_values | 类列表或字典,默认为无 | 识别为NA / NaN的值。 如果通过了 dict,则可以在每个列的基础上进行设置。 |
| true_values | 列表 | 也要转换为布尔 True 的值列表。 |
| false_values | 列表 | 也将要转换为布尔 False 的值列表。 |
| keep_default_na | Bool,default True | 如果存在na_values参数,并且keep_default_na为False,则默认 NaN 值将被忽略,否则会将它们附加到 |
| thousands | Str,default None | 千位分隔符 |
| nrows | Int,default None | 限制要从文件读取的行数。 |
| error_bad_lines | Boolean,default True | 如果设置为 True,则即使解析期间发生错误,也将返回 DataFrame。 |
除了read_csv()功能外,我们在 Pandas 中还具有其他一些解析功能:
功能
|
描述
|
| --- | --- |
| read_table | 将常规定界文件读入 DataFrame |
| read_fwf | 将固定宽度格式的行表读入 DataFrame |
| read_clipboard | 从剪贴板中读取文本,然后传递到read_table。 这对于从网页转换表很有用 |
在某些情况下,我们无法使用这些功能自动分析磁盘中的数据文件。 在这种情况下,我们也可以打开文件并通过阅读器进行迭代,这由标准库中的 CSV 模块支持:
$ cat example_data/ex_06-03.txt
Nam 7 1 male hcm
Mai 11 1 female hcm
Lan 25 3 female hn
Hung 42 3 male tn single
Nghia 26 3 male dn single
Vinh 39 3 male vl
Hong 28 4 female dn
>>> import csv
>>> f = open('data/ex_06-03.txt')
>>> r = csv.reader(f, delimiter='\t')
>>> for line in r:
>>> print(line)
['Nam', '7', '1', 'male', 'hcm']
['Mai', '11', '1', 'female', 'hcm']
['Lan', '25', '3', 'female', 'hn']
['Hung', '42', '3', 'male', 'tn', 'single']
['Nghia', '26', '3', 'male', 'dn', 'single']
['Vinh', '39', '3', 'male', 'vl']
['Hong', '28', '4', 'female', 'dn']
将数据写入文本格式
我们看到了如何将文本文件中的数据加载到 Pandas 数据结构中。 现在,我们将学习如何将数据从程序的数据对象导出到文本文件。 对应于read_csv()功能,我们还具有 Pandas 支持的to_csv()功能。 让我们看下面的例子:
>>> df_ex3.to_csv('example_data/ex_06-02.out', sep = ';')
结果将如下所示:
$ cat example_data/ex_06-02.out
0;1;2;3;4
Nam;7;1;male;hcm
Mai;11;1;female;hcm
Lan;25;3;female;hn
Hung;42;3;male;tn
Nghia;26;3;male;dn
Vinh;39;3;male;vl
Hong;28;4;female;dn
如果要在将数据写到磁盘文件中时跳过标题行或索引列,可以为标题和索引参数设置False值:
>>> import sys
>>> df_ex3.to_csv(sys.stdout, sep='\t',
header=False, index=False)
7 1 male hcm
11 1 female hcm
25 3 female hn
42 3 male tn
26 3 male dn
39 3 male vl
28 4 female dn
我们还可以通过在columns参数中指定它们来将 DataFrame 列的子集写入文件:
>>> df_ex3.to_csv(sys.stdout, columns=[3,1,4],
header=False, sep='\t')
Nam male 7 hcm
Mai female 11 hcm
Lan female 25 hn
Hung male 42 tn
Nghia male 26 dn
Vinh male 39 vl
Hong female 28 dn
对于系列对象,我们可以使用相同的函数将数据写入文本文件,并且参数与之前的参数基本相同。

与二进制格式的数据进行交互
我们可以使用 pickle 模块读写 Python 对象的二进制序列化,该序列可以在标准库中找到。 如果您使用创建时间较长的对象(例如某些机器学习模型),则对象序列化会很有用。 通过腌制此类对象,可以更快地访问此模型。 它还允许您以标准化方式分发 Python 对象。
Pandas 支持开箱即用的腌制。 相关方法是read_pickle()和to_pickle()功能,可以轻松地在文件中读取和写入数据。 这些方法将以 pickle 格式将数据写入磁盘,这是一种方便的短期存储格式:
>>> df_ex3.to_pickle('example_data/ex_06-03.out')
>>> pd.read_pickle('example_data/ex_06-03.out')
1 2 3 4
0
Nam 7 1 male hcm
Mai 11 1 female hcm
Lan 25 3 female hn
Hung 42 3 male tn
Nghia 26 3 male dn
Vinh 39 3 male vl
Hong 28 4 female dn
HDF5
HDF5 不是,而是数据库,而是数据模型和文件格式。 它适合于写入一,读取多的数据集。 HDF5 文件包括两种对象:数据集,它们是类似于数组的数据集合;组是类似于文件夹的容器,用于容纳数据集和其他组。 在 Python 中,存在一些用于与 HDF5 格式进行交互的接口,例如h5py,它使用了熟悉的 NumPy 和 Python 构造,例如字典和 NumPy 数组语法。 使用h5py,我们具有 HDF5 API 的高级接口,可帮助我们入门。 但是,在本模块中,我们将为这种格式引入另一种库,称为 PyTables,该库可与 pandas 对象配合使用:
>>> store = pd.HDFStore('hdf5_store.h5')
>>> store
<class 'pandas.io.pytables.HDFStore'>
File path: hdf5_store.h5
Empty
我们创建了一个空的 HDF5 文件,名为hdf5_store.h5。 现在,我们可以将数据写入文件,就像将键值对添加到dict一样:
>>> store['ex3'] = df_ex3
>>> store['name'] = df_ex2[0]
>>> store['hometown'] = df_ex3[4]
>>> store
<class 'pandas.io.pytables.HDFStore'>
File path: hdf5_store.h5
/ex3 frame (shape->[7,4])
/hometown series (shape->[1])
/name series (shape->[1])
可以通过指定对象密钥来检索存储在 HDF5 文件中的对象:
>>> store['name']
0 Nam
1 Mai
2 Lan
3 Hung
4 Nghia
5 Vinh
6 Hong
Name: 0, dtype: object
与 HDF5 文件完成交互后,我们将其关闭以释放文件句柄:
>>> store.close()
>>> store
<class 'pandas.io.pytables.HDFStore'>
File path: hdf5_store.h5
File is CLOSED
还有其他受支持的功能对于使用 HDF5 格式很有用。 如果您需要处理大量数据,则应更详细地研究两个库-pytables和h5py。
与 MongoDB 中的数据进行交互
与文本文件相比,许多应用需要更强大的存储系统,这就是为什么许多应用使用数据库来存储数据的原因。 数据库的种类很多,但有两大类:关系数据库,它支持称为 SQL 的标准声明性语言;以及所谓的 NoSQL 数据库,它们通常能够在没有预定义模式的情况下工作,并且数据实例更合适 描述为文档,而不是一行。
MongoDB 是一种 NoSQL 数据库,用于将数据存储为文档,这些文档在集合中分组在一起。 文档表示为 JSON 对象。 它可以快速,可扩展地存储数据,也可以灵活地查询数据。 要在 Python 中使用 MongoDB,我们需要导入pymongo包并通过传递主机名和端口来打开与数据库的连接。 我们假设我们有一个 MongoDB 实例,它在默认主机(localhost)和端口(27017)上运行:
>>> import pymongo
>>> conn = pymongo.MongoClient(host='localhost', port=27017)
如果我们没有在pymongo.MongoClient()函数中添加任何参数,它将自动使用默认的主机和端口。
在下一步中,我们将与 MongoDB 实例内部的数据库进行交互。 我们可以列出实例中可用的所有数据库:
>>> conn.database_names()
['local']
>>> lc = conn.local
>>> lc
Database(MongoClient('localhost', 27017), 'local')
前面的片段说我们的 MongoDB 实例只有一个数据库,名为“本地”。 如果我们指向的数据库和集合不存在,MongoDB 将根据需要创建它们:
>>> db = conn.db
>>> db
Database(MongoClient('localhost', 27017), 'db')
每个数据库包含称为集合的文档组。 我们可以将它们理解为关系数据库中的表。 要列出数据库中所有现有的集合,我们使用collection_names()函数:
>>> lc.collection_names()
['startup_log', 'system.indexes']
>>> db.collection_names()
[]
我们的db数据库尚无任何集合。 让我们创建一个名为person的集合,并将数据从 DataFrame 对象插入其中:
>>> collection = db.person
>>> collection
Collection(Database(MongoClient('localhost', 27017), 'db'), 'person')
>>> # insert df_ex2 DataFrame into created collection
>>> import json
>>> records = json.load(df_ex2.T.to_json()).values()
>>> records
dict_values([{'2': 3, '3': 'male', '1': 39, '4': 'vl', '0': 'Vinh'}, {'2': 3, '3': 'male', '1': 26, '4': 'dn', '0': 'Nghia'}, {'2': 4, '3': 'female', '1': 28, '4': 'dn', '0': 'Hong'}, {'2': 3, '3': 'female', '1': 25, '4': 'hn', '0': 'Lan'}, {'2': 3, '3': 'male', '1': 42, '4': 'tn', '0': 'Hung'}, {'2': 1, '3':'male', '1': 7, '4': 'hcm', '0': 'Nam'}, {'2': 1, '3': 'female', '1': 11, '4': 'hcm', '0': 'Mai'}])
>>> collection.insert(records)
[ObjectId('557da218f21c761d7c176a40'),
ObjectId('557da218f21c761d7c176a41'),
ObjectId('557da218f21c761d7c176a42'),
ObjectId('557da218f21c761d7c176a43'),
ObjectId('557da218f21c761d7c176a44'),
ObjectId('557da218f21c761d7c176a45'),
ObjectId('557da218f21c761d7c176a46')]
在将df_ex2移入字典之前,会对进行转置并将其转换为 JSON 字符串。 insert()函数从df_ex2接收我们创建的字典并将其保存到集合中。
如果要列出集合中的所有数据,可以执行以下命令:
>>> for cur in collection.find():
>>> print(cur)
{'4': 'vl', '2': 3, '3': 'male', '1': 39, '_id': ObjectId('557da218f21c761d7c176
a40'), '0': 'Vinh'}
{'4': 'dn', '2': 3, '3': 'male', '1': 26, '_id': ObjectId('557da218f21c761d7c176
a41'), '0': 'Nghia'}
{'4': 'dn', '2': 4, '3': 'female', '1': 28, '_id': ObjectId('557da218f21c761d7c1
76a42'), '0': 'Hong'}
{'4': 'hn', '2': 3, '3': 'female', '1': 25, '_id': ObjectId('557da218f21c761d7c1
76a43'), '0': 'Lan'}
{'4': 'tn', '2': 3, '3': 'male', '1': 42, '_id': ObjectId('557da218f21c761d7c176
a44'), '0': 'Hung'}
{'4': 'hcm', '2': 1, '3': 'male', '1': 7, '_id': ObjectId('557da218f21c761d7c176
a45'), '0': 'Nam'}
{'4': 'hcm', '2': 1, '3': 'female', '1': 11, '_id': ObjectId('557da218f21c761d7c
176a46'), '0': 'Mai'}
如果要在某些条件下从创建的集合中查询数据,则可以使用find()函数并传入描述我们要检索的文档的字典。 返回的结果是cursor类型,它支持迭代器协议:
>>> cur = collection.find({'3' : 'male'})
>>> type(cur)
pymongo.cursor.Cursor
>>> result = pd.DataFrame(list(cur))
>>> result
0 1 2 3 4 _id
0 Vinh 39 3 male vl 557da218f21c761d7c176a40
1 Nghia 26 3 male dn 557da218f21c761d7c176a41
2 Hung 42 3 male tn 557da218f21c761d7c176a44
3 Nam 7 1 male hcm 557da218f21c761d7c176a45
有时,我们希望删除 MongdoDB 中的数据。 我们需要做的就是将查询传递给集合上的remove()方法:
>>> # before removing data
>>> pd.DataFrame(list(collection.find()))
0 1 2 3 4 _id
0 Vinh 39 3 male vl 557da218f21c761d7c176a40
1 Nghia 26 3 male dn 557da218f21c761d7c176a41
2 Hong 28 4 female dn 557da218f21c761d7c176a42
3 Lan 25 3 female hn 557da218f21c761d7c176a43
4 Hung 42 3 male tn 557da218f21c761d7c176a44
5 Nam 7 1 male hcm 557da218f21c761d7c176a45
6 Mai 11 1 female hcm 557da218f21c761d7c176a46
>>> # after removing records which have '2' column as 1 and '3' column as 'male'
>>> collection.remove({'2': 1, '3': 'male'})
{'n': 1, 'ok': 1}
>>> cur_all = collection.find();
>>> pd.DataFrame(list(cur_all))
0 1 2 3 4 _id
0 Vinh 39 3 male vl 557da218f21c761d7c176a40
1 Nghia 26 3 male dn 557da218f21c761d7c176a41
2 Hong 28 4 female dn 557da218f21c761d7c176a42
3 Lan 25 3 female hn 557da218f21c761d7c176a43
4 Hung 42 3 male tn 557da218f21c761d7c176a44
5 Mai 11 1 female hcm 557da218f21c761d7c176a46
我们逐步学习了如何在集合中插入,查询和删除数据。 现在,我们将展示如何在 MongoDB 中更新集合中的现有数据:
>>> doc = collection.find_one({'1' : 42})
>>> doc['4'] = 'hcm'
>>> collection.save(doc)
ObjectId('557da218f21c761d7c176a44')
>>> pd.DataFrame(list(collection.find()))
0 1 2 3 4 _id
0 Vinh 39 3 male vl 557da218f21c761d7c176a40
1 Nghia 26 3 male dn 557da218f21c761d7c176a41
2 Hong 28 4 female dn 557da218f21c761d7c176a42
3 Lan 25 3 female hn 557da218f21c761d7c176a43
4 Hung 42 3 male hcm 557da218f21c761d7c176a44
5 Mai 11 1 female hcm 557da218f21c761d7c176a46
下表显示了为操作 MongoDB 中的文档提供快捷方式的方法:
|更新方式
|
描述
|
| --- | --- |
| inc() | 递增数字字段 |
| set() | 将某些字段设置为新值 |
| unset() | 从文档中删除字段 |
| push() | 将值附加到文档中的数组 |
| pushAll() | 将多个值附加到文档中的数组 |
| addToSet() | 仅在数组中不存在的情况下添加一个值 |
| pop() | 删除数组的最后一个值 |
| pull() | 从数组中删除所有出现的值 |
| pullAll() | 从数组中删除所有出现的任何一组值 |
| rename() | 重命名字段 |
| bit() | 通过按位运算更新值 |
与 Redis 中的数据进行交互
Redis 是一种高级键值存储,其中的值可以具有不同的类型:字符串,列表,集合,排序集合或哈希。 Redis 像 memcached 一样将数据存储在内存中,但是与 memcached 不同,Redis 没有这样的选择,它可以保留在磁盘上。 Redis 支持每秒约 100,000 次 set 或 get 操作的快速读写。
要与 Redis 交互,我们需要将Redis-py模块安装到 Python,该模块在pypi上可用,并且可以与pip一起安装:
$ pip install redis
现在,我们可以通过数据库服务器的主机和端口连接到 Redis。 我们假设我们已经安装了 Redis 服务器,该服务器正在使用默认主机(localhost)和端口(6379)参数运行:
>>> import redis
>>> r = redis.StrictRedis(host='127.0.0.1', port=6379)
>>> r
StrictRedis<ConnectionPool<Connection<host=localhost,port=6379,db=0>>>
作为在 Redis 中存储数据的第一步,我们需要定义哪种数据结构适合我们的需求。 在本节中,我们将介绍 Redis 中的四种常用数据结构:简单值,列表,集合和有序集合。 尽管数据以许多不同的数据结构存储到 Redis 中,但是每个值都必须与一个键相关联。
简单值
这是 Redis 中最基本的一种价值。 对于 Redis 中的每个键,我们还有一个可以具有数据类型的值,例如字符串,整数或双精度型。 让我们以一个示例为例,该示例用于在 Redis 中设置数据和从 Redis 中获取数据:
>>> r.set('gender:An', 'male')
True
>>> r.get('gender:An')
b'male'
在此示例中,我们要将名为An的人的性别信息存储到 Redis 中。 我们的密钥是gender:An,我们的值是male。 它们都是字符串类型。
set()功能接收两个参数:键和值。 第一个参数是键,第二个参数是值。 如果要更新此键的值,则只需再次调用该函数并更改第二个参数的值即可。 Redis 会自动更新它。
get()函数将检索我们的键的值,该值作为参数传递。 在这种情况下,我们想要获取密钥gender:An的性别信息。
在第二个示例中,我们向您显示另一种值类型,即整数:
>>> r.set('visited_time:An', 12)
True
>>> r.get('visited_time:An')
b'12'
>>> r.incr('visited_time:An', 1)
13
>>> r.get('visited_time:An')
b'13'
我们看到了一个新函数incr(),该函数用于将 key 的值增加给定的数量。 如果我们的密钥不存在,则 RedisDB 将使用给定的增量作为值来创建密钥。

列表
我们有几种方法与 Redis 中的列表值进行交互。 下面的示例使用rpush()和lrange()函数将列表数据放置到 DB 中以及从 DB 中获取列表数据:
>>> r.rpush('name_list', 'Tom')
1L
>>> r.rpush('name_list', 'John')
2L
>>> r.rpush('name_list', 'Mary')
3L
>>> r.rpush('name_list', 'Jan')
4L
>>> r.lrange('name_list', 0, -1)
[b'Tom', b'John', b'Mary', b'Jan']
>>> r.llen('name_list')
4
>>> r.lindex('name_list', 1)
b'John'
除了在示例中使用的rpush()和lrange()函数之外,我们还想介绍另外两个函数。 首先,llen()函数用于获取给定密钥在 Redis 中列表的长度。 lindex()功能是检索列表项的另一种方法。 我们需要将两个参数传递给函数:列表中的键和项的索引。 下表列出了使用 Redis 处理列表数据结构时的其他一些强大功能:
功能
|
描述
|
| --- | --- |
| rpushx(name, value) | 如果名称存在,则将值推到列表名称的末尾 |
| rpop(name) | 删除并返回列表名称的最后一项 |
| lset(name, index, value) | 将列表名称的索引位置处的项目设置为输入值 |
| lpushx(name,value) | 如果名称存在,则将值推入列表名称的开头 |
| lpop(name) | 删除并返回列表名称的第一项 |
设置
此数据结构也类似于列表类型。 但是,与列表相反,我们不能在集合中存储重复值:
>>> r.sadd('country', 'USA')
1
>>> r.sadd('country', 'Italy')
1
>>> r.sadd('country', 'Singapore')
1
>>> r.sadd('country', 'Singapore')
0
>>> r.smembers('country')
{b'Italy', b'Singapore', b'USA'}
>>> r.srem('country', 'Singapore')
1
>>> r.smembers('country')
{b'Italy', b'USA'}
与与列表数据结构相对应,我们还具有许多功能来获取,设置,更新或删除集合中的项目。 下表列出了支持的用于数据结构的函数:
|功能
|
描述
|
| --- | --- |
| sadd(name, values) | 使用键名将值添加到集合中 |
| scard(name) | 用键名返回集合中的元素数 |
| smembers(name) | 返回键名称为 set 的所有成员 |
| srem(name, values) | 从键名称集中删除值 |
有序集
当我们将数据添加到称为得分的集合中时,有序集合数据结构具有额外的属性。 有序集合将使用分数来确定集合中元素的顺序:
>>> r.zadd('person:A', 10, 'sub:Math')
1
>>> r.zadd('person:A', 7, 'sub:Bio')
1
>>> r.zadd('person:A', 8, 'sub:Chem')
1
>>> r.zrange('person:A', 0, -1)
[b'sub:Bio', b'sub:Chem', b'sub:Math']
>>> r.zrange('person:A', 0, -1, withscores=True)
[(b'sub:Bio', 7.0), (b'sub:Chem', 8.0), (b'sub:Math', 10.0)]
通过使用zrange(name, start, end)函数,我们可以从开始和结束得分之间的排序集中获得一系列值(默认情况下,这些得分以升序排序)。 如果要更改way的排序方法,可以将desc参数设置为True。 如果要获取分数和返回值,请使用withscore参数。 返回类型是(值,得分)对的列表,如您在前面的示例中看到的那样。
请参阅下表以了解有序集合上可用的更多功能:
|功能
|
描述
|
| --- | --- |
| zcard(name) | 返回具有键名称的排序集中的元素数 |
| zincrby(name, value, amount=1) | 将键名与排序集中的值的分数按数量递增 |
| zrangebyscore(name, min, max, withscores=False, start=None, num=None) | 从具有键名的排序集中返回一个范围在最小和最大之间的值。如果withscores为true,则返回分数和值。如果给定 start 和num,则返回范围的一部分 |
| zrank(name, value) | 返回从 0 开始的值,该值指示键名在排序集中的值的排名 |
| zrem(name, values) | 从具有键名的排序集中删除成员值 |




二十一、数据分析应用示例
在本章中,我们希望使您熟悉典型的数据准备任务和分析技术,因为流利地准备,分组和重塑数据是成功进行数据分析的重要基础。
尽管准备数据似乎是一项平凡的任务,而且通常是这样,但这是我们不能跳过的一步,尽管我们可以通过使用诸如 Pandas 之类的工具来简化数据。
为什么根本没有必要做准备? 因为最有用的数据将来自现实世界,并且会存在缺陷,包含错误或零散的信息。
数据准备有用的原因还有很多:它使您与原材料保持紧密联系。 了解您的输入有助于您尽早发现潜在错误并建立对结果的信心。
以下是一些数据准备方案:
- 客户将三个文件交给您,每个文件包含有关单个地质现象的时间序列数据,但观察到的数据记录在不同的时间间隔上,并使用不同的分隔符
- 机器学习算法只能处理数字数据,但您的输入仅包含文本标签
- 您将获得即将启动的服务的 Web 服务器的原始日志,您的任务是根据现有访问者的行为为增长策略提出建议
数据处理
用于数据处理的工具库庞大,尽管我们将重点介绍 Python,但我们也想提及一些有用的工具。 如果它们在您的系统上可用,并且您希望处理大量数据,则值得学习。
一组工具属于 Unix 传统,它强调文本处理,因此,在过去的 40 年中,开发了许多高性能和经过考验的用于处理文本的工具。 一些常见的工具是:sed,grep,awk,sort,uniq,tr,cut,tail和head。 它们执行非常基本的操作,例如从文件中过滤出行(grep或列(cut),替换文本(sed,tr)或仅显示文件的一部分(head,tail)。
我们仅想通过一个示例来演示这些工具的功能。
想象一下,您已经收到了 Web 服务器的日志文件,并且对 IP 地址的分配感兴趣。
日志文件的每一行均包含通用日志服务器格式的条目(您可以从这个页面下载此数据集) ):
$ cat epa-html.txt
wpbfl2-45.gate.net [29:23:56:12] "GET /Access/ HTTP/1.0" 200 2376ebaca.icsi.net [30:00:22:20] "GET /Info.html HTTP/1.0" 200 884
例如,我们想知道某些用户访问我们网站的频率。
我们仅对第一列感兴趣,因为可以在其中找到 IP 地址或主机名。 之后,我们需要计算每个主机的出现次数,最后以友好的方式显示结果。
sort | uniq -c节是我们的主力军:它将首先对数据进行排序,uniq -c将保存出现次数和值。 sort -nr | head -15 是我们的格式化部分; 我们按数字(-n)和反向(-r)进行排序,并且仅保留前 15 个条目。
将所有内容与管道放在一起:
$ cut -d ' ' -f 1 epa-http.txt | sort | uniq -c | sort -nr | head -15
294 sandy.rtptok1.epa.gov
292 e659229.boeing.com
266 wicdgserv.wic.epa.gov
263 keyhole.es.dupont.com
248 dwilson.pr.mcs.net
176 oea4.r8stw56.epa.gov
174 macip26.nacion.co.cr
172 dcimsd23.dcimsd.epa.gov
167 www-b1.proxy.aol.com
158 piweba3y.prodigy.com
152 wictrn13.dcwictrn.epa.gov
151 nntp1.reach.com
151 inetg1.arco.com
149 canto04.nmsu.edu
146 weisman.metrokc.gov
使用一个命令,我们可以将顺序服务器日志转换为访问我们站点的最常见主机的有序列表。 我们还看到,我们的最高用户之间的访问次数似乎没有太大差异。
有用的工具很少,以下只是其中的很小一部分:
csvkit:这是用于处理 CSV(表格文件格式之王)的实用程序套件jq:这是一个轻巧灵活的命令行 JSON 处理器xmlstarlet:这是一个工具,它支持 XPath 等 XML 查询q:此操作在文本文件上运行 SQL
Unix 命令行结束处,轻量级语言接管了。 您可能只能从文本中获得印象,但是您的同事可能会更喜欢由 matplotlib 生成的可视化表示形式,例如图表或漂亮的图形。
Python 及其数据工具生态系统比命令行更具通用性,但是对于首次探索和简单操作而言,命令行的有效性通常是无与伦比的。
清洁数据
大多数真实世界的数据都会存在一些缺陷,因此需要首先进行清洁步骤。 我们从一个小文件开始。 尽管此文件仅包含四行,但它将使我们能够演示经过清理的数据集的过程:
$ cat small.csv
22,6.1
41,5.7
18,5.3*
29,NA
请注意,此文件存在一些问题。 包含值的行均以逗号分隔,但缺少(NA)值,可能还有不干净的(5.3 *)值。 尽管如此,我们可以将该文件加载到数据帧中:
>>> import pandas as pd
>>> df = pd.read_csv("small.csv")
>>> df
22 6.1
0 41 5.7
1 18 5.3*
2 29 NaN
Pandas 将第一行用作header,但这不是我们想要的:
>>> df = pd.read_csv("small.csv", header=None)
>>> df
0 1
0 22 6.1
1 41 5.7
2 18 5.3*
3 29 NaN
这样做更好,但是我们希望提供自己的列名称,而不是数字值:
>>> df = pd.read_csv("small.csv", names=["age", "height"])
>>> df
age height
0 22 6.1
1 41 5.7
2 18 5.3*
3 29 NaN
age列看起来不错,因为 Pandas 已经推断出了预期的类型,但是height尚不能解析为数值:
>>> df.age.dtype
dtype('int64')
>>> df.height.dtype
dtype('O')
如果我们尝试将height列强制转换为浮点值,则 Pandas 将报告异常:
>>> df.height.astype('float')
ValueError: invalid literal for float(): 5.3*
我们可以将任何可解析的值用作浮点数,并使用convert_objects方法丢弃其余值:
>>> df.height.convert_objects(convert_numeric=True)
0 6.1
1 5.7
2 NaN
3 NaN
Name: height, dtype: float64
如果我们事先知道数据集中的不良字符,则可以使用自定义转换器函数来扩展read_csv方法:
>>> remove_stars = lambda s: s.replace("*", "")
>>> df = pd.read_csv("small.csv", names=["age", "height"],
converters={"height": remove_stars})
>>> df
age height
0 22 6.1
1 41 5.7
2 18 5.3
3 29 NA
现在,我们终于可以使 height 列更有用了。 我们可以为其分配更新的版本,该版本具有首选类型:
>>> df.height = df.height.convert_objects(convert_numeric=True)
>>> df
age height
0 22 6.1
1 41 5.7
2 18 5.3
3 29 NaN
如果我们只想保留完整的条目,则可以删除任何包含未定义值的行:
>>> df.dropna()
age height
0 22 6.1
1 41 5.7
2 18 5.3
我们可以使用默认的高度,也许是一个固定值:
>>> df.fillna(5.0)
age height
0 22 6.1
1 41 5.7
2 18 5.3
3 29 5.0
另一方面,我们也可以使用现有值的平均值:
>>> df.fillna(df.height.mean())
age height
0 22 6.1
1 41 5.7
2 18 5.3
3 29 5.7
最后三个数据帧是完整且正确的,具体取决于您在处理缺失值时对正确的定义。 特别是,这些列具有所需的类型,并准备进行进一步分析。 哪个数据帧最合适取决于当前的任务。
过滤
即使我们具有干净的和可能正确的数据,我们也可能只希望使用其中的一部分,或者可能要检查异常值。 离群点是由于可变性或测量误差而与其他观测值相距较远的观测点。 在这两种情况下,我们都希望减少数据集中的元素数量,以使其与进一步处理更加相关。
在此示例中,我们将尝试查找潜在的异常值。 我们将使用美国能源信息署记录的欧洲布伦特原油现货价格。 原始 Excel 数据可从这个页面获得(可以在第二个工作表中找到)。 我们对数据进行了轻微的清理(清理过程是本章末尾练习的一部分),并将使用以下数据框架,其中包含 7160 个条目,范围从 1987 年到 2015 年:
>>> df.head()
date price
0 1987-05-20 18.63
1 1987-05-21 18.45
2 1987-05-22 18.55
3 1987-05-25 18.60
4 1987-05-26 18.63
>>> df.tail()
date price
7155 2015-08-04 49.08
7156 2015-08-05 49.04
7157 2015-08-06 47.80
7158 2015-08-07 47.54
7159 2015-08-10 48.30
尽管很多人都知道油价(无论是从新闻还是从加油站来的),但让我们暂时忘记我们对它的任何了解。 我们首先可以要求极端:
>>> df[df.price==df.price.min()]
date price
2937 1998-12-10 9.1
>>> df[df.price==df.price.max()]
date price
5373 2008-07-03 143.95
查找潜在离群值的另一种方法是要求提供与均值最不相符的值。 我们可以使用np.abs函数首先计算与均值的偏差:
>>> np.abs(df.price - df.price.mean())
0 26.17137 1 26.35137 7157 2.99863
7158 2.73863 7159 3.49863
现在,我们可以将这个偏差与标准偏差的倍数(我们选择 2.5)进行比较:
>>> import numpy as np
>>> df[np.abs(df.price - df.price.mean()) > 2.5 * df.price.std()]
date price5354 2008-06-06 132.81
5355 2008-06-09 134.43
5356 2008-06-10 135.24
5357 2008-06-11 134.52
5358 2008-06-12 132.11
5359 2008-06-13 134.29
5360 2008-06-16 133.90
5361 2008-06-17 131.27
5363 2008-06-19 131.84
5364 2008-06-20 134.28
5365 2008-06-23 134.54
5366 2008-06-24 135.37
5367 2008-06-25 131.59
5368 2008-06-26 136.82
5369 2008-06-27 139.38
5370 2008-06-30 138.40
5371 2008-07-01 140.67
5372 2008-07-02 141.24
5373 2008-07-03 143.95
5374 2008-07-07 139.62
5375 2008-07-08 134.15
5376 2008-07-09 133.91
5377 2008-07-10 135.81
5378 2008-07-11 143.68
5379 2008-07-14 142.43
5380 2008-07-15 136.02
5381 2008-07-16 133.31
5382 2008-07-17 134.16
我们看到,2008 年夏季的那几天一定很特别。 确实,找到标题为和 2007-08 年石油危机的原因和后果之类的文章和文章并不难。 我们仅通过查看数据就发现了这些事件的踪迹。
我们可以每十年分别问前面的问题。 我们首先使数据框看起来更像一个时间序列:
>>> df.index = df.date
>>> del df["date"]
>>> df.head()
pricedate
1987-05-20 18.63 1987-05-21 18.45
1987-05-22 18.55 1987-05-25 18.60
1987-05-26 18.63
我们可以过滤出八十年代:
>>> decade = df["1980":"1989"]
>>> decade[np.abs(decade.price - decade.price.mean()) > 2.5 * decade.price.std()]
price
date
1988-10-03 11.60 1988-10-04 11.65 1988-10-05 11.20 1988-10-06 11.30 1988-10-07 11.35
我们观察到,在可得的数据(1987-1989 年)中,1988 年秋季的油价略有上涨。 同样,在 90 年代,我们看到 1990 年秋我们的偏差更大:
>>> decade = df["1990":"1999"]
>>> decade[np.abs(decade.price - decade.price.mean()) > 5 * decade.price.std()]
pricedate
1990-09-24 40.75 1990-09-26 40.85 1990-09-27 41.45 1990-09-28 41.00 1990-10-09 40.90 1990-10-10 40.20 1990-10-11 41.15
还有更多的用例来过滤数据。 空间和时间是典型的单位:您可能希望按州或城市过滤普查数据,或者按季度过滤经济数据。 可能性是无止境的,将由您的项目来驱动。
合并数据
情况很常见:您有多个数据源,但是为了对内容进行陈述,您宁愿将它们组合在一起。 幸运的是,当合并,联接或对齐数据时,Pandas 的串联和合并功能消除了大部分麻烦。 它也以高度优化的方式进行。
在两个数据帧具有相似形状的情况下,将一个接一个添加到另一个可能很有用。 也许A和B是产品,并且一个数据框包含商店中每种产品售出的商品数量:
>>> df1 = pd.DataFrame({'A': [1, 2, 3], 'B': [4, 5, 6]})
>>> df1
A B
0 1 4
1 2 5
2 3 6
>>> df2 = pd.DataFrame({'A': [4, 5, 6], 'B': [7, 8, 9]})
>>> df2
A B
0 4 7
1 5 8
2 6 9
>>> df1.append(df2)
A B
0 1 4
1 2 5
2 3 6
0 4 7
1 5 8
2 6 9
有时,我们不会关心原始数据帧的索引:
>>> df1.append(df2, ignore_index=True)
A B
0 1 4
1 2 5
2 3 6
3 4 7
4 5 8
5 6 9
pd.concat函数提供了一种更灵活的组合对象的方法,该函数将任意数量的序列,数据帧或面板作为输入。 默认行为类似于附加:
>>> pd.concat([df1, df2])
A B
0 1 4
1 2 5
2 3 6
0 4 7
1 5 8
2 6 9
默认的concat操作沿行或索引附加两个帧,它们对应于轴 0。要沿列连接,我们可以传入 axis 关键字参数:
>>> pd.concat([df1, df2], axis=1)
A B A B
0 1 4 4 7
1 2 5 5 8
2 3 6 6 9
我们可以添加键来创建层次结构索引。
>>> pd.concat([df1, df2], keys=['UK', 'DE'])
A B
UK 0 1 4
1 2 5
2 3 6
DE 0 4 7
1 5 8
2 6 9
如果您想稍后再参考数据框架的某些部分,这将很有用。 我们使用ix索引器:
>>> df3 = pd.concat([df1, df2], keys=['UK', 'DE'])
>>> df3.ix["UK"]
A B
0 1 4
1 2 5
2 3 6
数据帧类似于数据库表。 因此,Pandas 对它们执行类似 SQL 的联接操作就不足为奇了。 令人惊讶的是,这些操作是高度优化且极其快速的:
>>> import numpy as np
>>> df1 = pd.DataFrame({'key': ['A', 'B', 'C', 'D'],
'value': range(4)})
>>> df1
key value
0 A 0
1 B 1
2 C 2
3 D 3
>>> df2 = pd.DataFrame({'key': ['B', 'D', 'D', 'E'],'value': range(10, 14)})
>>> df2
key value
0 B 10
1 D 11
2 D 12
3 E 13
如果我们在key上合并,则会得到一个内部联接。 这通过基于连接谓词组合原始数据帧的列值来创建新数据帧,此处使用key属性:
>>> df1.merge(df2, on='key')
key value_x value_y
0 B 1 10
1 D 3 11
2 D 3 12
左,右和完全联接可以通过how参数指定:
>>> df1.merge(df2, on='key', how='left')
key value_x value_y
0 A 0 NaN
1 B 1 10
2 C 2 NaN
3 D 3 11
4 D 3 12
>>> df1.merge(df2, on='key', how='right')
key value_x value_y
0 B 1 10
1 D 3 11
2 D 3 12
3 E NaN 13
>>> df1.merge(df2, on='key', how='outer')
key value_x value_y
0 A 0 NaN
1 B 1 10
2 C 2 NaN
3 D 3 11
4 D 3 12
5 E NaN 13
合并方法可以通过 how 参数指定。 下表显示了与 SQL 相比的方法:
|合并方法
|
SQL 连接名称
|
描述
|
| --- | --- | --- |
| left | 左外连接 | 仅使用左框中的键。 |
| right | 右外连接 | 仅使用右框架中的键。 |
| outer | 全外连接 | 使用两个框架中的键并集。 |
| inner | 内部联接 | 使用两个框架中关键点的交集。 |

重塑数据
我们看到了如何组合数据帧,但是有时我们在单个数据结构中拥有所有正确的数据,但是对于某些任务而言,这种格式是不切实际的。 我们从一些人工天气数据开始:
>>> df
date city value
0 2000-01-03 London 6
1 2000-01-04 London 3
2 2000-01-05 London 4
3 2000-01-03 Mexico 3
4 2000-01-04 Mexico 9
5 2000-01-05 Mexico 8
6 2000-01-03 Mumbai 12
7 2000-01-04 Mumbai 9
8 2000-01-05 Mumbai 8
9 2000-01-03 Tokyo 5
10 2000-01-04 Tokyo 5
11 2000-01-05 Tokyo 6
如果要计算每个城市的最高温度,我们可以按城市对数据进行分组,然后使用max函数:
>>> df.groupby('city').max()
date value
city
London 2000-01-05 6
Mexico 2000-01-05 9
Mumbai 2000-01-05 12
Tokyo 2000-01-05 6
但是,如果我们每次都要将数据整理成表格,则可以通过首先创建一个经过重塑的数据框(将日期作为索引,将城市作为列)来提高效率。
我们可以使用pivot功能创建这样的数据帧。 参数是索引(我们使用日期),列(我们使用城市)和值(存储在原始数据框的 value 列中):
>>> pv = df.pivot("date", "city", "value")
>>> pv
city London Mexico Mumbai Tokyodate
2000-01-03 6 3 12 5
2000-01-04 3 9 9 5
2000-01-05 4 8 8 6
我们可以在此新数据帧上直接使用max函数:
>>> pv.max()
city
London 6
Mexico 9
Mumbai 12
Tokyo 6
dtype: int64
具有更合适的形状,其他操作也变得更加容易。 例如,要查找每天的最高温度,我们可以简单地提供一个附加的轴参数:
>>> pv.max(axis=1)
date
2000-01-03 12
2000-01-04 9
2000-01-05 8
dtype: int64
数据聚合
作为最后的主题,我们将研究通过聚合获得精简数据视图的方法。 pandas 内置了许多聚合功能。 我们已经在第 3 章,“使用 Pandas”进行数据分析中看到了describe函数。 这也适用于部分数据。 我们再次从一些人工数据开始,其中包含有关每个城市和日期的日照小时数的度量:
>>> df.head()
country city date hours
0 Germany Hamburg 2015-06-01 8
1 Germany Hamburg 2015-06-02 10
2 Germany Hamburg 2015-06-03 9
3 Germany Hamburg 2015-06-04 7
4 Germany Hamburg 2015-06-05 3
要查看每个city的摘要,我们对分组的数据集使用describe函数:
>>> df.groupby("city").describe()
hours
city
Berlin count 10.000000
mean 6.000000
std 3.741657
min 0.000000
25% 4.000000
50% 6.000000
75% 9.750000
max 10.000000
Birmingham count 10.000000
mean 5.100000
std 2.078995
min 2.000000
25% 4.000000
50% 5.500000
75% 6.750000
max 8.000000
在某些数据集上,按多个属性分组可能很有用。 通过传递两个列名称,我们可以大致了解每个国家和日期的晴天:
>>> df.groupby(["country", "date"]).describe()
hourscountry date
France 2015-06-01 count 5.000000
mean 6.200000
std 1.095445
min 5.000000
25% 5.000000
50% 7.000000
75% 7.000000
max 7.000000
2015-06-02 count 5.000000
mean 3.600000
std 3.577709
min 0.000000
25% 0.000000
50% 4.000000
75% 6.000000
max 8.000000
UK 2015-06-07 std 3.872983
min 0.000000
25% 2.000000
50% 6.000000
75% 8.000000
max 9.000000
我们也可以计算单个统计信息:
>>> df.groupby("city").mean()
hours
cityBerlin 6.0
Birmingham 5.1
Bordeax 4.7
Edinburgh 7.5
Frankfurt 5.8
Glasgow 4.8
Hamburg 5.5
Leipzig 5.0
London 4.8
Lyon 5.0
Manchester 5.2
Marseille 6.2
Munich 6.6
Nice 3.9
Paris 6.3
最后,我们可以使用agg方法定义要应用于组的任何函数。 前面的代码可能是用agg编写的,如下所示:
>>> df.groupby("city").agg(np.mean)
hours
city
Berlin 6.0
Birmingham 5.1
Bordeax 4.7
Edinburgh 7.5
Frankfurt 5.8
Glasgow 4.8
...
但是任意功能都是可能的。 在最后一个示例中,我们定义一个custom函数,该函数获取一系列对象的输入并计算最小元素和最大元素之间的差:
>>> df.groupby("city").agg(lambda s: abs(min(s) - max(s)))
hours
city
Berlin 10
Birmingham 6
Bordeax 10
Edinburgh 8
Frankfurt 9
Glasgow 10
Hamburg 10
Leipzig 9
London 10
Lyon 8
Manchester 10
Marseille 10
Munich 9
Nice 10
Paris 9
分组数据
数据探索期间的一种典型工作流程如下:
-
您找到要用于分组数据的条件。 也许您拥有该大陆以及每个国家/地区的 GDP 数据,并且想问有关这些大陆的问题。 这些问题通常会带来一些功能应用-您可能需要计算每个大陆的平均 GDP。 最后,您想要存储此数据以在新的数据结构中进行进一步处理。
-
我们在这里使用一个更简单的示例。 想象一下有关每天和城市晴天小时数的一些虚构的天气数据:
>>> df date city value 0 2000-01-03 London 6 1 2000-01-04 London 3 2 2000-01-05 London 4 3 2000-01-03 Mexico 3 4 2000-01-04 Mexico 9 5 2000-01-05 Mexico 8 6 2000-01-03 Mumbai 12 7 2000-01-04 Mumbai 9 8 2000-01-05 Mumbai 8 9 2000-01-03 Tokyo 5 10 2000-01-04 Tokyo 5 11 2000-01-05 Tokyo 6groups属性返回一个字典,其中包含唯一组和相应的值作为轴标签:>>> df.groupby("city").groups {'London': [0, 1, 2], 'Mexico': [3, 4, 5], 'Mumbai': [6, 7, 8], 'Tokyo': [9, 10, 11]} -
尽管的结果是 GroupBy 对象,而不是 DataFrame,但我们可以使用常规的索引符号来引用列:
>>> grouped = df.groupby(["city", "value"]) >>> grouped["value"].max() city London 6 Mexico 9 Mumbai 12 Tokyo 6 Name: value, dtype: int64 >>> grouped["value"].sum() city London 13 Mexico 20 Mumbai 29 Tokyo 16 Name: value, dtype: int64 -
根据我们的数据集,我们看到孟买似乎是一个阳光明媚的城市。 实现上述代码的另一种方法(更详细)是:
>>> df['value'].groupby(df['city']).sum() city London 13 Mexico 20 Mumbai 29 Tokyo 16 Name: value, dtype: int64




二十二、数据挖掘入门
我们正在以人类历史上从未见过的规模收集信息,并越来越重视在日常生活中使用这些信息。 我们希望我们的计算机将网页翻译成其他语言,预测天气,建议我们想要的书并诊断我们的健康问题。 这些期望将会增加,无论是应用数量还是预期的功效。 数据挖掘是一种方法,可以用来训练计算机使用数据进行决策,并且构成了当今许多高科技系统的骨干。
有充分的理由,Python 语言正在迅速普及。 它给程序员很大的灵活性。 它具有大量执行不同任务的模块; Python 代码通常比其他任何语言都更具可读性和简洁性。 有大量活跃的研究人员,从业人员和初学者使用 Python 进行数据挖掘。
在本章中,我们将介绍使用 Python 进行数据挖掘。 我们将涵盖以下主题:
- 什么是数据挖掘,可在哪里使用?
- 设置基于 Python 的环境以执行数据挖掘
- 亲和力分析示例,根据购买习惯推荐产品
- (经典)分类问题的一个示例,根据其度量预测植物种类
引入数据挖掘
数据挖掘为计算机提供了一种学习如何对数据进行决策的方法。 这个决定可能是预测明天的天气,阻止垃圾邮件进入您的收件箱,检测网站的语言或在约会网站上找到新的恋情。 数据挖掘有许多不同的应用,新的应用一直在被发现。
数据挖掘是算法,统计,工程,优化和计算机科学的一部分。 我们还使用其他领域的概念和知识,例如语言学,神经科学或城市规划。 有效地应用它通常需要将此特定于领域的知识与算法集成在一起。
大多数数据挖掘应用都使用相同的高级视图,尽管细节通常会发生相当大的变化。 我们通过创建描述真实世界的一个方面的数据集来开始数据挖掘过程。 数据集包含两个方面的:
- 是现实世界中的对象的样本。 这可以是书,照片,动物,人或任何其他物体。
- 的功能是对数据集中样本的描述。 特征可以是给定单词的长度,频率,支路数量,创建日期等。
下一步是调整数据挖掘算法。 每个数据挖掘算法都有参数,这些参数可以在算法内,也可以由用户提供。 通过这种调整,算法可以学习如何制定有关数据的决策。
作为一个简单的示例,我们希望计算机能够将人们分类为“矮”或“高”。 我们从收集数据集开始,该数据集包括不同人的身高以及他们的矮矮或高矮:
|人
|
高度
|
矮还是高?
|
| --- | --- | --- |
| 1 | 155 厘米 | 短的 |
| 2 | 165 厘米 | 短的 |
| 3 | 175 厘米 | 高的 |
| 4 | 185 厘米 | 高的 |
下一步涉及调整我们的算法。 作为一种简单的算法; 如果身高大于x,则该人个子高,否则个子矮。 然后,我们的训练算法将查看数据并确定x的合适值。 对于前面的数据集,合理的值为 170 cm。 该算法认为身高高于 170 厘米的任何人。 其他人被认为是矮子。
在前面的数据集中,我们有一个明显的要素类型。 我们想知道人是矮还是高,所以我们收集了他们的身高。 此工程功能是数据挖掘中的重要问题。 在后面的章节中,我们将讨论选择好的特征以收集到数据集中的方法。 最终,此步骤通常需要一些专业知识,或者至少需要一些反复试验。
注意
在本模块中,我们将介绍通过 Python 进行数据挖掘。 在某些情况下,我们选择代码和工作流程的清晰度,而不是最优化的方式来做到这一点。 该有时涉及跳过一些细节,这些细节可以提高算法的速度或有效性。
一个简单的亲和力分析示例
在本节中,我们跳入第一个示例。 数据挖掘的一个常见用例是通过询问正在购买产品的客户是否也想要其他类似产品来提高销售。 这可以通过亲和力分析来完成,这是对事物何时存在的研究。
什么是亲和力分析?
相似性分析是一种数据挖掘类型,可以使样本(对象)之间具有相似性。 这可能是以下两者之间的相似之处:
- 网站上的用户,以提供各种服务或针对性的广告
- 出售给这些用户的商品,以便提供推荐的电影或产品
- 人类基因,以便找到拥有相同祖先的人
我们可以通过多种方式测量亲和力。 例如,我们可以记录两次购买产品的频率。 我们还可以记录一个人购买对象 1 以及购买对象 2 时语句的准确性。其他衡量亲和力的方法包括计算样本之间的相似度,我们将在后面的章节中介绍。
产品推荐
将传统业务(例如商务)在线移动的问题之一是,过去人类必须完成的任务需要自动化,以便在线业务得以扩展。 一个例子就是向上销售,或向已经购买的客户出售额外的物品。 通过数据挖掘进行自动产品推荐是电子商务革命的推动力之一,电子商务革命每年将数十亿美元转化为收入。
在此示例中,我们将专注于基本的产品推荐服务。 我们根据以下想法进行设计:历史上两个物品一起购买时,将来很有可能一起购买。 这种想法是在线和离线企业中许多产品推荐服务的背后。
对于这种类型的产品推荐算法,一种非常简单的算法是简单地找到用户带来商品的任何历史案例,并推荐历史用户带来的其他商品。 在实践中,像这样的简单算法效果很好,至少比选择要推荐的随机项目更好。 但是,可以极大地改进它们,这就是数据挖掘的用武之地。
为了简化编码,我们一次只考虑两项。 例如,人们可以在超市同时购买面包和牛奶。 在这个早期的示例中,我们希望找到以下形式的简单规则:
如果某人购买产品 X,那么他们很可能会购买产品 Y
涉及多个项目的更复杂的规则将无法涵盖,例如购买香肠和汉堡的人更有可能购买番茄酱。
使用 NumPy 加载数据集
可以从课程随附的代码包中下载数据集。 下载此文件并将其保存在计算机上,并注意数据集的路径。 对于本示例,建议您在计算机上创建一个新文件夹以放入数据集和代码。从此处打开 IPython Notebook,导航至该文件夹并创建一个新笔记本。
我们将在此示例中使用的数据集是 NumPy 二维数组,该数组是格式,是该模块其余部分中大多数示例的基础。 该数组看起来像一个表格,其中行代表不同的样本,列代表不同的特征。
单元格代表特定样本的特定特征的值。 为了说明这一点,我们可以使用以下代码加载数据集:
import numpy as np
dataset_filename = "affinity_dataset.txt"
X = np.loadtxt(dataset_filename)
对于此示例,运行 IPython Notebook 并创建 IPython Notebook。 在笔记本的第一个单元格中输入上述代码。 然后,您可以通过按 Shift + 输入来运行代码(这还将为下一批代码添加新的单元格)。 运行代码后,第一个单元格左侧的方括号将被分配一个递增数字,让您知道此单元格已完成。 第一个单元格应如下所示:

对于以后将花费更多时间运行的代码,此处将使用星号表示此代码正在运行或已计划运行。 代码运行完毕后,该星号将替换为数字。
您将需要将数据集保存到与 IPython Notebook 相同的目录中。 如果选择将其存储在其他位置,则需要将dataset_filename值更改为新位置。
接下来,我们可以显示数据集的某些行,以了解数据集的外观。 在下一个单元格中输入以下代码行并运行它,以便打印数据集的前五行:
print(X[:5])
结果将显示在前五个交易中购买了哪些物品:

一次查看每行(水平线)即可读取数据集。 第一行(0, 0, 1, 1, 1)显示在第一笔交易中购买的物品。 每一列(垂直行)代表每个项目。 它们分别是面包,牛奶,奶酪,苹果和香蕉。 因此,在第一笔交易中,该人购买了奶酪,苹果和香蕉,但没有买面包或牛奶。
这些功能中的每一个都包含二进制值,仅说明是否购买了商品,而不说明购买了多少商品。 1 表示“至少购买了 1 个”此类商品,而 0 表示绝对未购买任何此类商品。
实施规则的简单排名
我们希望找到类型为的规则。如果某人购买产品 X,那么他们很可能会购买产品 Y。 通过简单地查找同时购买两种产品的所有情况,我们可以很容易地在数据集中创建所有规则的列表。 但是,我们然后需要一种从坏规则中确定好的规则的方法。 这将使我们能够选择要推荐的特定产品。
可以用很多方法来衡量这种类型的规则,其中我们将把重点放在两个方面:支持和置信度。
支持是规则在数据集中出现的次数,该次数是通过简单地计算该规则对其有效的样本数来计算的。 有时可以将其除以该规则的前提有效的总次数进行归一化,但是我们只计算该实现的总次数。
在支持测量规则存在的频率时,置信度测量规则在使用时的准确性。 可以通过确定前提适用时规则适用的次数百分比来计算。 我们首先计算一个规则在我们的数据集中适用的次数,然后将其除以前提(if语句)出现的样本数。
例如,如果一个人购买苹果,他们也购买香蕉,我们将计算规则的支持度和置信度。
如下例所示,我们可以通过检查sample[3]的值来判断是否有人在交易中购买了苹果,在该示例中,将样本分配给矩阵的一行:

同样,我们可以通过查看sample[4]的值是否等于 1(依此类推)来检查交易中是否购买了香蕉。 现在,我们可以计算规则在数据集中的存在次数,并由此计算出置信度和支持度。
现在,我们需要为数据库中的所有规则计算这些统计信息。 为此,我们将为有效规则和无效规则创建字典。 该词典的关键是元组(前提和结论)。 我们将存储索引,而不是实际的特征名称。 因此,我们将存储(3 和 4)以表示先前的规则。如果某人购买苹果,他们还将购买香蕉。 如果给出前提和结论,则该规则被视为有效。 尽管给出了前提但没有得出结论,但该规则被认为对该样本无效。
为了计算所有可能规则的置信度和支持度,我们首先设置一些字典来存储结果。 我们将为此使用defaultdict,如果访问的键尚不存在,它将设置默认值。 我们记录有效规则,无效规则的数量以及每个前提的出现:
from collections import defaultdict
valid_rules = defaultdict(int)
invalid_rules = defaultdict(int)
num_occurances = defaultdict(int)
接下来,我们在一个大循环中计算这些值。 我们遍历数据集中的每个样本和特征。 第一个功能构成规则的前提-如果某人购买产品前提:
for sample in X:
for premise in range(4):
我们检查该样本是否存在前提。 如果不是这样,我们就不再需要对此样本/前提组合进行任何处理,然后移至循环的下一个迭代:
if sample[premise] == 0: continue
如果前提对于此样本有效(其值为 1),则我们记录此情况并检查规则的每个结论。 我们跳过与前提相同的任何结论,这将给我们一些规则,例如,如果某人购买苹果,那么他们购买苹果,这显然对我们没有多大帮助;
num_occurances[premise] += 1
for conclusion in range(n_features):
if premise == conclusion: continue
如果存在该样本的结论,则我们将增加此规则的有效计数。 如果没有,我们将为该规则增加无效计数:
if sample[conclusion] == 1:valid_rules[(premise, conclusion)] += 1else:
invalid_rules[(premise, conclusion)] += 1
现在,我们已经完成了必要统计信息的计算,现在可以为每个规则计算支持和置信度。 和以前一样,支持只是我们的valid_rules值:
support = valid_rules
置信度的计算方法相同,但是我们必须遍历每个规则才能计算出此置信度:
confidence = defaultdict(float)
for premise, conclusion in valid_rules.keys():
rule = (premise, conclusion)
confidence[rule] = valid_rules[rule] / num_occurances[premise]
现在,我们有了一本字典,其中包含对每个规则的支持和信心。 我们可以创建一个函数,以一种可读的格式打印出规则。 规则的签名包含前提和结论索引,我们刚刚计算的支持和置信度字典以及可以告诉我们特征含义的特征数组:
def print_rule(premise, conclusion,
support, confidence, features):
我们获取前提和结论的特征名称,并以可读格式打印规则:
premise_name = features[premise]conclusion_name = features[conclusion]print("Rule: If a person buys {0} they will also buy {1}".format(premise_name, conclusion_name))
然后我们打印出此规则的Support和Confidence:
print(" - Support: {0}".format(support[(premise,
conclusion)]))
print(" - Confidence: {0:.3f}".format(confidence[(premise,
conclusion)]))
我们可以通过以下方式调用来测试代码-随意尝试不同的前提和结论:

排名以找到最佳规则
现在我们可以计算所有规则的支持度和置信度,我们希望能够找到最佳规则。 为此,我们执行排名并打印具有最高值的排名。 对于支持值和置信度值,我们都可以这样做。
为了找到支持最高的规则,我们首先对支持字典进行排序。 默认情况下,字典不支持排序。 items()函数为我们提供了一个包含字典中数据的列表。 我们可以使用itemgetter类作为键对列表进行排序,从而可以对嵌套列表(例如此列表)进行排序。 使用itemgetter(1)可以使我们根据值进行排序。 设置reverse=True首先为我们提供最高值:
from operator import itemgetter
sorted_support = sorted(support.items(), key=itemgetter(1), reverse=True)
然后,我们可以打印出最重要的五个规则:
for index in range(5):
print("Rule #{0}".format(index + 1))
premise, conclusion = sorted_support[index][0]
print_rule(premise, conclusion, support, confidence, features)
结果将如下所示:

同样,我们可以根据置信度打印最重要的规则。 首先,计算排序后的置信度列表:
sorted_confidence = sorted(confidence.items(), key=itemgetter(1), reverse=True)
接下来,使用与以前相同的方法将它们打印出来。 注意第三行对sorted_confidence的更改;
for index in range(5):
print("Rule #{0}".format(index + 1))
premise, conclusion = sorted_confidence[index][0]
print_rule(premise, conclusion, support, confidence, features)

两个列表的顶部附近有两个规则。 第一个是如果一个人购买苹果,他们还将购买奶酪,第二个是如果一个人购买奶酪,他们还将购买香蕉。 商店经理可以使用此类规则来组织商店。 例如,如果本周销售苹果,则在附近摆放奶酪。 同样,将两种香蕉与奶酪同时出售也没有意义,因为将近 66%的购买奶酪的人仍然会购买香蕉-我们的销售不会增加香蕉的购买量。
在这样的示例中,数据挖掘具有强大的探索能力。 一个人可以使用数据挖掘技术来探索其数据集中的关系以找到新的见解。 在下一节中,我们将数据挖掘用于其他目的:预测。

一个简单的分类示例
在相似性分析示例中,我们在数据集中寻找了不同变量之间的相关性。 在分类中,我们只具有一个我们感兴趣的变量,我们将其称为类(也称为目标)。 如果在上一个示例中,我们对如何使人们购买更多苹果感兴趣,则可以将该变量设置为作为类别,并寻找实现该目标的分类规则。 然后,我们将仅寻找与该目标相关的规则。
什么是分类?
无论是在实际应用还是在研究中,分类都是数据挖掘的最大用途之一。 和以前一样,我们有一组样本来表示我们感兴趣的分类对象或事物。 我们还有一个新的数组,类值。 这些类别值为我们提供了样本的分类。 一些示例如下:
- 通过查看植物的尺寸来确定植物的种类。 这里的分类值是这是哪个物种?。
- 确定图像中是否包含狗。 这个班级是此图片上有只狗吗?。
- 根据测试结果确定患者是否患有癌症。 类别为该患者患有癌症吗?。
尽管上面的许多示例都是二元(是/否)问题,但不一定非要如此,就像本节中植物种类的分类一样。
分类应用的目标是在一组具有已知类别的样本上训练模型,然后将该模型应用于具有未知类别的新的未见样本。 例如,我们要在过去的电子邮件上训练垃圾邮件分类器,我将其标记为垃圾邮件或非垃圾邮件。 然后,我想使用该分类器来确定我的下一封电子邮件是否为垃圾邮件,而无需自己进行分类。
加载和准备数据集
我们将在此示例中使用的数据集是著名的植物分类 Iris 数据库。 在此数据集中,我们有 150 个植物样品,每个样品有四个测量值:萼片长度,萼片宽度和花瓣长度 和花瓣宽度(均以厘米为单位)。 这个经典数据集(最早在 1936 年使用!)是数据挖掘经典数据集中的一个。 分为三类:鸢尾鸢尾,鸢尾花和鸢尾花。 目的是通过检查其测量值来确定样品属于哪种植物。
scikit-learn库包含此内置数据集,使数据集的加载变得简单:
from sklearn.datasets import load_iris
dataset = load_iris()
X = dataset.data
y = dataset.target
您也可以打印(dataset.DESCR)以查看数据集的轮廓,包括有关要素的一些详细信息。
此数据集中的特征是连续值,这意味着它们可以采用任何范围的值。 测量是此类功能的一个很好的示例,其中测量可以取值为 1、1.2 或 1.25,依此类推。 关于连续特征的另一个方面是彼此接近的特征值指示相似性。 萼片长度为 1.2 厘米的植物就像萼片宽度为 1.25 厘米的植物一样。
相反,是分类特征。 这些功能通常用数字表示,但不能以相同的方式进行比较。 在虹膜数据集中,类值是分类特征的示例。 类 0 代表鸢尾鸢尾花,类 1 代表鸢尾花,类 2 代表鸢尾花。 这并不意味着 Iris Setosa 与 Iris Versicolour 比与 Iris Virginica 更相似,尽管其类值更相似。 此处的数字代表类别。 我们只能说类别是相同还是不同。
也有其他类型的功能,其中一些功能将在后面的章节中介绍。
尽管此数据集中的特征是连续的,但我们在本示例中将使用的算法需要分类特征。 将连续特征转换为分类特征的过程称为离散化。
一种简单的离散化算法是选择一些阈值,并且该阈值以下的任何值都给定值 0。与此同时,任何高于此阈值的值都给定值 1。对于我们的阈值,我们将计算该特征的均值(平均值) 。 首先,我们计算每个特征的均值:
attribute_means = X.mean(axis=0)
这将给我们一个长度为 4 的数组,这是我们拥有的特征数。 第一个值是第一个特征的值的平均值,依此类推。 接下来,我们使用它来将数据集从具有连续特征的数据集转换为具有离散分类特征的数据集:
X_d = np.array(X >= attribute_means, dtype='int')
我们将使用这个新的X_d数据集(用于 X 离散化)进行训练和测试,而不是原始数据集(X)。

实现 OneR 算法
OneR 是一种简单的算法,它通过查找特征值最频繁的类别来简单地预测样本的类别。 OneR 是的缩写,是一个规则,表示通过选择性能最佳的功能,我们仅对该分类使用一条规则。 尽管某些后来的算法明显更复杂,但该简单算法已在许多实际数据集中表现出良好的性能。
该算法从迭代每个功能的每个值开始。 对于该值,计算每个类别具有该特征值的样本数。 记录最频繁出现的特征值类别以及该预测的错误。
例如,如果某个要素具有两个值0和1,则我们首先检查所有值为0的样本。 对于该值,我们可能在A类中有 20 个,在B类中有 60 个,在C类中又有 20 个。 此值最常见的类别是B,并且有 40 个实例具有差异类别。 该特征值的预测为B,误差为 40,因为有 40 个样本的类别与预测不同。 然后,针对此功能的1值,然后对所有其他功能值组合执行相同的过程。
一旦计算了所有这些组合,我们就可以通过汇总该功能的所有值的误差来计算每个功能的误差。 总误差最低的特征被选为一个规则,然后用于对其他实例进行分类。
在代码中,我们将首先创建一个函数,该函数计算特定特征值的类预测和错误。 我们在先前的代码中使用了两个必要的导入defaultdict和itemgetter:
from collections import defaultdict
from operator import itemgetter
接下来,我们创建函数定义,它需要数据集,类,我们感兴趣的特征的索引以及我们正在计算的值:
def train_feature_value(X, y_true, feature_index, value):
然后,我们遍历数据集中的所有样本,计算具有该特征值的每个样本的实际类别:
class_counts = defaultdict(int)
for sample, y in zip(X, y_true):
if sample[feature_index] == value:
class_counts[y] += 1
然后,我们通过对class_counts字典进行排序并找到最大值来找到分配最频繁的类:
sorted_class_counts = sorted(class_counts.items(),key=itemgetter(1), reverse=True)most_frequent_class = sorted_class_counts[0][0]
最后,我们计算该规则的误差。 在 OneR 算法中,具有此特征值的任何样本都将被预测为最频繁的类别。 因此,我们通过对其他类别(不是最常见)的计数求和来计算误差。 这些代表该规则不适用的训练样本:
incorrect_predictions = [class_count for class_value, class_count in class_counts.items()if class_value != most_frequent_class]
error = sum(incorrect_predictions)
最后,我们既返回该特征值的预测类别,又返回该规则的分类错误的训练样本数,错误:
return most_frequent_class, error
使用此功能,我们现在可以通过遍历该功能的所有值,对错误求和并记录每个值的预测类来计算整个功能的误差。
函数头需要我们感兴趣的数据集,类和特征索引:
def train_on_feature(X, y_true, feature_index):
接下来,我们找到给定功能采用的所有唯一值。 下一行的索引将查看给定功能的整列,并将其作为数组返回。 然后,我们使用 set 函数仅查找唯一值:
values = set(X[:,feature_index])
接下来,我们创建将存储预测变量的字典。 该词典将具有要素值作为键,并将分类作为值。 键值为 1.5 且值为 2 的条目表示,当要素的值设置为 1.5 时,将其归为 2 类。我们还创建了一个列表,用于存储每个要素值的错误:
predictors = {}
errors = []
作为此功能的主要部分,我们迭代此功能的所有唯一值,并使用先前定义的函数train_feature_value()查找给定功能值的最常见类别和错误。 我们存储上述结果:
for current_value in values:
most_frequent_class, error = train_feature_value(X, y_true, feature_index, current_value)
predictors[current_value] = most_frequent_class
errors.append(error)
最后,我们计算此规则的总误差,并返回预测值和该值:
total_error = sum(errors)
return predictors, total_error
测试算法
当我们评估最后一部分的亲和力分析算法时,我们的目的是探索当前数据集。 通过这种分类,我们的问题就不同了。 我们想要建立一个模型,通过将它们与我们对问题的了解进行比较,从而对我们之前看不见的样本进行分类。
因此,我们将机器学习工作流程分为两个阶段:训练和测试。 在训练中,我们获取一部分数据集并创建模型。 在测试中,我们应用该模型并评估其在数据集上的有效性。 由于我们的目标是创建一个能够对以前看不见的样本进行分类的模型,因此我们不能使用测试数据来训练模型。 如果这样做,我们将面临过度拟合的风险。
过度拟合是创建模型的问题,该模型可以很好地对训练数据集进行分类,但在新样本上的表现不佳。 解决方案非常简单:永远不要使用训练数据来测试算法。 这个简单的规则有一些复杂的变体,我们将在后面的章节中介绍。 但是,目前,我们可以通过简单地将数据集分为两个小数据集来评估我们的 OneR 实现:训练数据集和测试数据集。 此工作流在本节中给出。
scikit-learn库包含一个将数据分为训练和测试组件的功能:
from sklearn.cross_validation import train_test_split
此功能将根据给定的比率(默认情况下使用数据集的 25%进行测试)将数据集分为两个子数据集。 它是随机执行的,从而提高了对该算法进行适当测试的信心:
Xd_train, Xd_test, y_train, y_test = train_test_split(X_d, y, random_state=14)
现在,我们有两个较小的数据集:Xd_train包含我们的训练数据,Xd_test包含我们的测试数据。 y_train和y_test给出了这些数据集的相应类别值。
我们还指定了一个特定的random_state。 设置随机状态将在每次输入相同的值时进行相同的分割。 看起来是随机的,但是所使用的算法是确定性的,输出将保持一致。 对于此模块,我建议将随机状态设置为与我相同的值,因为它将为您提供与我相同的结果,从而使您可以验证结果。 要获得每次运行都会改变的真正随机结果,请将random_state设置为none。
接下来,我们为数据集的所有特征计算预测变量。 请记住,仅在此过程中使用训练数据。 我们遍历数据集中的所有特征,并使用我们先前定义的函数来训练预测变量并计算误差:
all_predictors = {}
errors = {}
for feature_index in range(Xd_train.shape[1]):
predictors, total_error = train_on_feature(Xd_train, y_train, feature_index)
all_predictors[feature_index] = predictors
errors[feature_index] = total_error
接下来,我们通过找到错误最少的功能来找到用作“一个规则”的最佳功能:
best_feature, best_error = sorted(errors.items(), key=itemgetter(1))[0]
然后,我们通过存储最佳功能的预测变量来创建model:
model = {'feature': best_feature,
'predictor': all_predictors[best_feature][0]}
我们的模型是一个字典,它告诉我们一个规则使用哪个功能以及根据其具有的值进行的预测。 在这种模型的情况下,我们可以通过查找特定特征的值并使用适当的预测变量来预测以前未见过的样本的类别。 以下代码针对给定的示例执行此操作:
variable = model['variable']
predictor = model['predictor']
prediction = predictor[int(sample[variable])]
通常,我们想一次预测许多新样本,我们可以使用以下函数进行预测: 我们使用上面的代码,但是迭代数据集中的所有样本,以获得每个样本的预测:
def predict(X_test, model):
variable = model['variable']
predictor = model['predictor']
y_predicted = np.array([predictor[int(sample[variable])] for sample in X_test])
return y_predicted
对于我们的testing数据集,我们通过调用以下函数来获得预测:
y_predicted = predict(X_test, model)
然后,我们可以通过将其与已知类进行比较来计算其准确性:
accuracy = np.mean(y_predicted == y_test) * 100
print("The test accuracy is {:.1f}%".format(accuracy))
这给出了 68%的准确度,对于单个规则来说还不错!




二十三、将 scikit-learn 估计器用于分类
scikit-learn库是数据挖掘算法的集合,这些数据挖掘算法是用 Python 编写的,并使用通用编程接口。 这使用户可以轻松尝试不同的算法,并利用标准工具进行有效的测试和参数搜索。 scikit-learn 中有大量算法和实用程序。
在本章中,我们着重于为运行数据挖掘过程建立一个良好的框架。 这将在后面的章节中使用,所有这些章节都将重点放在那些情况下的应用和技术上。
本章介绍的关键概念如下:
- 估算器:此用于执行分类,聚类和回归
- 提升器:此用于执行预处理和数据更改
- 管道:这是,可将您的工作流程整合为可复制的格式
scikit 学习估算器
估计器是scikit-learn's抽象,允许大量分类算法的标准化实现。 估计器用于分类。 估算器具有以下两个主要功能:
fit():此进行算法训练并设置内部参数。 它需要两个输入,训练样本数据集和这些样本的相应类。predict():此预测作为输入给出的测试样本的类别。 此函数返回一个数组,其中包含每个输入测试样本的预测。
大多数scikit-learn估算器都将NumPy数组或相关格式用于输入和输出。
scikit-learn 中有大量估计量。 这些包括支持向量机(SVM),随机 森林和神经网络。 这些算法中的许多算法将在的后续章节中使用。 在本章中,我们将使用与scikit-learn不同的估算器:最近邻居。
注意
对于本章,您将需要安装一个名为matplotlib的新库。 最简单的安装方法是使用pip3,就像在第 1 章,“数据挖掘入门”中所做的那样,安装scikit-learn:
$pip3 install matplotlib
如果您在安装matplotlib时遇到任何困难,请在这个页面中查找官方安装说明。
最近的邻居
最近的邻居是,可能是标准数据挖掘算法集中最直观的算法之一。 为了预测新样本的类别,我们在训练数据集中浏览了与新样本最相似的样本。 我们采用最相似的样本,并预测大多数样本所具有的类别。
例如,我们希望根据三角形的类更相似来预测它的类(此处通过将相似的对象靠得更近来表示)。 我们寻找三个最近的邻居,即两个菱形和一个正方形。 菱形多于圆形,因此三角形的预测类别为菱形:

最近邻居几乎可用于任何数据集,但是,计算所有样本对之间的距离在计算上非常昂贵。 例如,如果数据集中有 10 个样本,则有 45 个唯一距离要计算。 但是,如果有 1000 个样本,则将近 500,000! 存在多种方法可以大大提高该速度。 其中一些内容将在本模块的后续章节中介绍。
它在基于分类的数据集中的表现也很差,应该使用其他算法代替。

距离指标
数据挖掘中的一个关键基础概念是距离。 如果我们有两个样本,则需要知道它们彼此之间有多近。 此外,我们需要回答一些问题,例如这两个样本是否比其他两个样本更相似? 回答这样的问题对于案件的结果很重要。
人们知道的最常见的距离度量是欧几里德距离,它是现实世界中的距离。 如果要在图形上绘制这些点并使用直尺测量距离,则结果将是欧几里得距离。 更正式地说,它是每个要素的平方距离之和的平方根。
欧几里得距离很直观,但是如果某些要素的值大于其他要素,则精度会较差。 当许多特征的值为 0(称为稀疏矩阵)时,结果也会很差。 还有其他距离度量标准正在使用中。 常用的两个是曼哈顿距离和余弦距离。
曼哈顿距离是每个要素的绝对差之和(不使用平方距离)。 直观上,可以将中的白嘴鸦棋子(或城堡)在点之间移动所需要的移动次数考虑在内,前提是一次只能移动一个正方形。 如果某些要素具有比其他要素更大的值,则曼哈顿距离确实会受到影响,但效果却不如欧几里得。
余弦距离更适合,适用于某些特征大于其他特征且数据集中存在大量零的情况。 直观地,我们从原点到每个样本绘制一条直线,并测量这些直线之间的角度。 在下图中可以看到:

在此示例中,每个灰色圆圈与白色圆圈的距离相同。 在(a)中,距离是欧几里得距离,因此,类似的距离适用于一个圆。 可以使用标尺测量该距离。 在(b)中,距离为曼哈顿,也称为城市街区。 我们通过在行和列之间移动来计算距离,类似于国际象棋中的白嘴鸦(城堡)如何移动。 最后,在(c)中,我们具有余弦距离,该余弦距离是通过计算从样本绘制到向量的直线之间的角度而测得的,而忽略了直线的实际长度。
选择的距离指标可能会对最终效果产生很大影响。 例如,如果您有许多功能,则随机样本之间的欧几里德距离接近相同的值。 由于距离相同,因此很难比较样本! 在某些情况下,曼哈顿距离可能会更稳定,但是如果某些要素的值很大,则可能会推翻在其他要素中的许多相似性。 最后,余弦距离是比较具有大量特征的项目的一个很好的指标,但是它会丢弃一些有关向量长度的信息,这在某些情况下很有用。
在本章中,我们将在后面的章节中使用其他度量来保持欧几里得距离。

加载数据集
我们将要使用的数据集被称为电离层,其中是许多高频天线的记录。 天线的目的是确定电离层中是否存在结构以及高层大气中是否存在区域。 具有结构的那些被认为是好的,而没有结构的那些被认为是坏的。 此应用的目的是建立一个数据挖掘分类器,该分类器可以确定图像的好坏。

(图片来源:https://www.flickr.com/photos/geckzilla/16149273389/)
可以从 UCL 机器学习数据存储库下载,其中包含用于不同数据挖掘应用的大量数据集。 转到这个页面并单击数据文件夹。 将,ionosphere.data和ionosphere.names文件下载到计算机上的文件夹中。 对于此示例,我假设您已将数据集放在主文件夹中名为 Data 的目录中。
注意
主文件夹的位置取决于您的操作系统。 对于 Windows,通常为C:\Documents and Settings\username。 对于 Mac 或 Linux 机器,通常为/home/username。 您可以通过运行以下 python 代码获取主文件夹:
import os
print(os.path.expanduser("~"))
对于数据集中的每一行,有 35 个值。 前 34 个是从 17 根天线(每个天线有两个值)获取的测量值。 最后一个是“ g”或“ b”; 分别代表好与坏。
启动 IPython Notebook 服务器,并为本章创建一个名为电离层最近邻居的新笔记本。
首先,我们加载代码所需的NumPy和csv库:
import numpy as np
import csv
要加载数据集,我们首先获取数据集的文件名。 首先,从数据文件夹中获取存储数据集的文件夹:
data_filename = os.path.join(data_folder, "Ionosphere", "ionosphere.data")
然后,我们创建X和y NumPy 数组来存储数据集。这些数组的大小可以从数据集中得知。 如果您不知道将来的数据集的大小,请不要担心-我们将使用其他方法在以后的章节中加载数据集,并且您无需事先知道此大小:
X = np.zeros((351, 34), dtype='float')
y = np.zeros((351,), dtype='bool')
数据集采用逗号分隔值(CSV)格式,这是数据集的常用格式。 我们将使用csv模块加载该文件。 导入它并设置一个csv阅读器对象:
with open(data_filename, 'r') as input_file:
reader = csv.reader(input_file)
接下来,我们遍历文件中的各行。 每行代表一组新的度量,这是此数据集中的一个样本。 我们也使用 enumerate 函数来获取行的索引,因此我们可以更新数据集中的适当样本(X):
for i, row in enumerate(reader):
我们从该样本中获取前 34 个值,将每个值转换为浮点数,然后将其保存到我们的数据集中:
data = [float(datum) for datum in row[:-1]]
X[i] = data
最后,我们获取行的最后一个值并设置类。 如果它是一个好的样本,我们将其设置为 1(或True),否则将其设置为 0:
y[i] = row[-1] == 'g'
现在,我们在X,中具有示例和特征的数据集,在y中具有相应的类,就像在第 1 章的分类示例中所做的一样。

迈向标准工作流程
scikit-learn中的估计器具有两个主要功能:fit()和predict()。 我们使用fit方法和我们的训练集来训练算法。 我们在测试集上使用predict方法对其进行评估。
首先,我们需要创建这些训练和测试集。 和以前一样,导入并运行train_test_split函数:
from sklearn.cross_validation import train_test_split
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=14)
然后,我们导入最近的邻居类并为其创建一个实例。 现在,我们将参数保留为默认值,本章稍后将选择合适的参数。 默认情况下,该算法将选择五个最近的邻居来预测测试样本的类别:
from sklearn.neighbors import KNeighborsClassifierestimator = KNeighborsClassifier()
创建估算器后,我们必须将其拟合到训练数据集中。 对于最近的邻居类,这记录了我们的数据集,从而允许我们通过将新点与训练数据集进行比较来找到新数据点的最近邻居:
estimator.fit(X_train, y_train)
然后,我们使用测试集训练算法,并使用测试集进行评估:
y_predicted = estimator.predict(X_test)
accuracy = np.mean(y_test == y_predicted) * 100
print("The accuracy is {0:.1f}%".format(accuracy))
准确率达到 86.4%,对于默认算法和仅几行代码而言,这是令人印象深刻的! 显式选择了大多数scikit-learn默认参数,以与一系列数据集配合使用。 但是,您始终应该基于应用实验的知识来选择参数。
运行算法
在我们的早期实验中,我们将数据集的一部分留作测试集,其余作为训练集。 我们在训练集上训练算法,并根据测试集评估算法的有效性。 但是,如果我们幸运并选择一个简单的测试集会发生什么? 或者,如果它特别麻烦怎么办? 由于数据的这种“不幸”分裂而导致的不良结果,我们可以丢弃一个好的模型。
交叉折叠验证框架是解决数据挖掘中选择测试集和标准方法的问题的一种方法。 该过程通过对不同的训练和测试分组进行大量实验来工作,但是每个样本仅在测试集中使用一次。 步骤如下:
-
将整个数据集拆分为多个称为折叠的部分。
-
对于数据集中的每个折叠,执行以下步骤:
- 将该折叠设置为当前测试集
- 在其余折叠上训练算法
- 评估 当前测试集
-
报告所有评估分数,包括平均分数。
-
在此过程中,每个样本仅在测试集中使用一次。 这样可以减少(但不能完全消除)选择幸运测试集的可能性。
注意
在整个模块中,代码示例在一个章中相互构建。 除非另有说明,否则各章的代码应输入到同一
IPython笔记本中。
scikit-learn库包含许多交叉折叠验证方法。 给出了执行前面过程的helper功能。 我们现在可以将其导入 IPython Notebook:
from sklearn.cross_validation import cross_val_score
注意
默认为,cross_val_score使用一种称为分层 K 折的特定方法将数据集拆分为折叠。 这样创建的折页在每个折页中的类别比例几乎相同,再次降低了选择不良折页的可能性。 这是一个很好的默认设置,因此我们现在不会对其进行处理。
接下来,我们使用此函数,传递原始(完整)数据集和类:
scores = cross_val_score(estimator, X, y, scoring='accuracy')average_accuracy = np.mean(scores) * 100print("The average accuracy is {0:.1f}%".format(average_accuracy))
这给出了 82.3%的适度结果,但是考虑到我们还没有尝试设置更好的参数,它还是相当不错的。 在下一节中,我们将看到如何更改参数以获得更好的结果。
设置参数
几乎所有数据挖掘算法都具有用户可以设置的参数。 这通常是使算法通用化的原因,以使其可用于多种情况。 设置这些参数可能非常困难,因为选择良好的参数值通常高度依赖于数据集的特征。
最近邻居算法具有多个参数,但是最重要的一个参数是预测未知属性类别时要使用的最近邻居数量。 在 scikit-learn中,此参数称为n_neighbors。 在下图中,我们显示了当此数字太低时,随机标记的样本可能会导致错误。 相反,当它太高时,实际最近的邻居对结果的影响较小:

在图(a)的左侧,我们通常希望测试样品(三角形)被分类为圆形。 但是,如果n_neighbors为 1,则该区域中的单个红色菱形(可能是有噪声的样本)会导致该样本被预测为菱形,而它似乎位于红色区域中。 在图(b)中,我们通常希望测试样品被归类为钻石。 但是,如果n_neighbors为 7,则三个最接近的邻居(都是菱形)将被大量的圆形样本覆盖。
如果我们要测试n_neighbors参数的多个值,例如,每个值从 1 到 20,则可以通过设置n_neighbors并观察结果来多次重新运行实验:
avg_scores = []
all_scores = []
parameter_values = list(range(1, 21)) # Include 20
for n_neighbors in parameter_values:
estimator = KNeighborsClassifier(n_neighbors=n_neighbors)
scores = cross_val_score(estimator, X, y, scoring='accuracy')
计算平均值并将其存储在我们的分数列表中。 我们还将存储完整的分数集,以供以后分析:
avg_scores.append(np.mean(scores))
all_scores.append(scores)
然后我们可以绘制n_neighbors的值和精度之间的关系。 首先,我们告诉 IPython 笔记本我们要在笔记本本身中内联显示图:
%matplotlib inline
然后,我们从matplotlib库中导入 pyplot并将参数值与平均分数一起绘制:
from matplotlib import pyplot as plt plt.plot(parameter_values, avg_scores, '-o')

尽管有很多方差,但该图显示出随着邻居数量增加而减少的趋势。

使用管道进行预处理
当进行现实世界物体的测量时,我们通常可以获得范围非常不同的特征。 例如,如果我们正在测量动物的品质,则可能具有以下几个特征:
- 腿数:对于大多数动物,这是介于 0-8 之间的范围,而有些动物的更多!
- 重量:这仅在几微克的范围内,一直到重量为 190,000 公斤的蓝鲸!
- 心数:如果是。,则可以在零到五之间。
对于基于数学的算法来比较每个特征,规模,范围和单位的差异可能难以解释。 如果我们在许多算法中使用上述功能,则权重可能是最有影响力的功能,因为仅是数量较大,而与功能的实际有效性无关。
解决此问题的方法之一是使用称为标准化功能的预处理过程,以使它们具有相同的范围,或被归类为小等类别,中型和大。 突然之间,特征类型的巨大差异对算法的影响较小,并且可能导致准确性的大幅提高。
预处理还可以用于仅选择更有效的功能,创建新功能等。 scikit-learn中的预处理是通过Transformer对象完成的,这些对象以一种形式获取数据集,并在对数据进行某种转换后返回更改后的数据集。 这些不必一定是数字,因为变形金刚也用于提取特征,但是在本节中,我们将坚持预处理。
一个例子
我们可以通过破坏 Ionosphere数据集来显示问题的示例。 尽管这只是一个示例,但许多现实世界的数据集都存在这种形式的问题。 首先,我们创建数组的副本,以便不更改原始数据集:
X_broken = np.array(X)
接下来,我们通过将第二个特征除以10来打破数据集:
X_broken[:,::2] /= 10
从理论上讲,这不会对结果产生太大影响。 毕竟,这些功能的值仍然相对相同。 主要问题是比例尺已更改,奇数特征现在比偶数特征大。 我们可以通过计算精度来看到此效果:
estimator = KNeighborsClassifier()
original_scores = cross_val_score(estimator, X, y,scoring='accuracy')
print("The original average accuracy for is {0:.1f}%".format(np.mean(original_scores) * 100))
broken_scores = cross_val_score(estimator, X_broken, y,scoring='accuracy')
print("The 'broken' average accuracy for is {0:.1f}%".format(np.mean(broken_scores) * 100))
原始数据集的得分为 82.3%,在损坏的数据集上下降为 71.5%。 我们可以通过将所有功能缩放到0至1范围来解决此问题。
标准预处理
我们将为执行的预处理通过MinMaxScaler类称为基于特征的归一化。 继续本章其余部分的 IPython 笔记本,首先,我们导入此类:
from sklearn.preprocessing import MinMaxScaler
此类采用每个功能并将其缩放到0至1范围。 最小值替换为0,最大值替换为1,而其他值介于两者之间。
要应用预处理器,我们在其上运行 transform 函数。 尽管MinMaxScaler没有,但是某些提升器需要首先以与分类器相同的方式进行训练。 我们可以通过运行fit_transform函数来组合这些步骤:
X_transformed = MinMaxScaler().fit_transform(X)
在此,X_transformed将具有与X相同的形状。 但是,每列的最大值为 1,最小值为 0。
以这种方式进行标准化的其他各种形式,对其他应用和要素类型也有效:
- 使用
sklearn.preprocessing.Normalizer确保每个样本的值总和等于 1 - 使用
sklearn.preprocessing.StandardScaler强制每个特征均值为零且方差为 1,这是标准化的常用起点 - 使用
sklearn.preprocessing.Binarizer将数字特征转换为二进制特征,其中高于阈值的任何值均为 1,低于阈值的任何值为 0
在后面的章节中,我们将结合使用这些预处理器以及其他类型的Transformers对象。
全部放在一起
现在,我们可以通过使用先前计算的分解数据集来组合上一部分中的代码来创建工作流程:
X_transformed = MinMaxScaler().fit_transform(X_broken)
estimator = KNeighborsClassifier()
transformed_scores = cross_val_score(estimator, X_transformed, y, scoring='accuracy')
print("The average accuracy for is {0:.1f}%".format(np.mean(transformed_scores) * 100))
这使我们获得了 82.3%的准确率。 MinMaxScaler产生的特征具有相同的比例,这意味着没有任何特征会因为仅仅是更大的值而超过其他特征。 尽管可以将“最近邻居”算法与较大的功能混淆,但某些算法可以更好地处理比例差异。 相反,有些情况更糟!
管道
随着实验的发展,操作的复杂性也在增加。 我们可能会拆分数据集,对特征进行二值化,执行基于特征的缩放,执行基于样本的缩放以及更多其他操作。
跟踪所有这些操作可能会造成混乱,并可能导致无法复制结果。 问题包括忘记一个步骤,错误地应用转换或添加不需要的转换。
另一个问题是代码的顺序。 在上一节中,我们创建了X_transformed数据集,然后创建了用于交叉验证的新估算器。 如果我们有多个步骤,则需要在代码中跟踪所有对数据集的更改。
管道是解决这些问题(以及其他问题的结构,我们将在下一章中看到)。 管道将步骤存储在数据挖掘工作流中。 他们可以接收您的原始数据,执行所有必要的转换,然后创建预测。 这使我们可以在cross_val_score之类的函数中使用流水线,它们期望估计器。 首先,导入Pipeline对象:
from sklearn.pipeline import Pipeline
管道将步骤列表作为输入,代表数据挖掘应用的链。 最后一步需要是Estimator,而所有先前的步骤都是Transformers。 每个Transformer都会更改输入数据集,其中一步的输出是下一步的输入。 最后,样本由最后一步的估算器分类。 在我们的管道中,我们有两个步骤:
- 使用
MinMaxScaler将特征值从 0 缩放到 1 - 使用
KNeighborsClassifier作为分类算法
然后,每个步骤由元组('name', step)表示。 然后,我们可以创建管道:
scaling_pipeline = Pipeline([('scale', MinMaxScaler()),
('predict', KNeighborsClassifier())])
这里的关键是元组列表。 第一个元组是我们的缩放步骤,第二个元组是预测步骤。 我们为每个步骤指定一个名称:第一个我们称为scale,第二个我们称为predict,但是您可以选择自己的名称。 元组的第二部分是实际的 Transformer 或 estimator 对象。
现在,使用之前的交叉验证代码,运行此管道非常容易:
scores = cross_val_score(scaling_pipeline, X_broken, y, scoring='accuracy')
print("The pipeline scored an average accuracy for is {0:.1f}%".format(np.mean(transformed_scores) * 100))
由于我们正在有效地执行相同的步骤,因此我们得到的分数与以前相同(82.3%)。
在后面的章节中,我们将使用更高级的测试方法,并且设置管道是确保代码复杂度不会难以管理的好方法。




二十四、使用决策树预测体育获胜者
在本章中,我们将研究使用不同类型的分类算法:决策树来预测体育比赛的获胜者。 与其他算法相比,这些算法具有许多优势。 主要优点之一是它们可以被人类读取。 这样,决策树可用于学习过程,然后可以将其提供给人工执行(如果需要)。 另一个优点是它们可以使用多种功能,我们将在本章中看到这些功能。
我们将在本章介绍以下主题:
- 使用 pandas 库加载和处理数据
- 决策树
- 随机森林
- 在数据挖掘中使用真实数据集
- 创建新功能并在强大的框架中对其进行测试
加载数据集
在本章中,我们将预测国家篮球协会(NBA)比赛的获胜者。 NBA 中的比赛通常很接近,可以在最后一刻决定,这很难预测获胜者。 许多运动都有此特征,因此预期冠军可能会在适当的时候被另一支球队击败。
有关预测获胜者的各种研究表明,运动结果预测的准确性可能存在上限,视运动而定,其准确性介于 70%到 80%之间。 通常通过数据挖掘或基于统计的方法,对运动预测进行了大量研究。
收集数据
我们将使用的数据是 2013-2014 赛季 NBA 的比赛历史数据,包含从 NBA 和其他联赛收集的大量资源和统计信息。 要下载数据集,请执行以下步骤:
- 在您的网络浏览器中导航到这个页面。
- 单击常规季节标题旁边的导出按钮。
- 将文件下载到您的数据文件夹并记下路径。
这将下载 CSV(逗号分隔值的缩写)文件,其中包含 NBA 常规赛季 1,230 场比赛的结果。
CSV 文件是简单的文本文件,其中每行包含一个新行,并且每个值都用逗号分隔(因此而得名)。 只需输入文本编辑器并以.csv扩展名保存,即可手动创建 CSV 文件。 它们也可以在任何可以读取文本文件的程序中打开,但是也可以在 Excel 中作为电子表格打开。
我们将使用 pandas(Python 数据分析的缩写)库加载文件,该库对于处理数据非常有用。 Python 还包含一个名为csv的内置库,该库支持读写 CSV 文件。 但是,我们将使用 pandas,它提供了更强大的功能,我们将在本章的后面部分使用它们来创建新功能。
注意
对于本章,您将需要安装 Pandas。 最简单的安装方法是使用pip3,就像在第 1 章 “数据挖掘入门”中所做的那样,安装scikit-learn:
$pip3 install pandas
如果您在安装[Pandas]时遇到困难,请访问Pandas 网站 并阅读系统的安装说明。
使用 Pandas 加载数据集
pandas 库是用于加载,管理和处理数据的库。 它处理后台数据结构并支持分析方法,例如计算均值。
在进行多个数据挖掘实验时,您会发现您一次又一次地编写了许多相同的功能,例如读取文件和提取功能。 每次重新实现时,您都有引入错误的风险。 使用诸如 pandas 之类的高级库可以显着减少执行这些功能所需的工作量,还可以使您对使用经过良好测试的代码更有信心。
在整个模块中,我们将大量使用 Pandas,并在此过程中介绍用例。
我们可以使用read_csv函数加载数据集:
import pandas as pd
dataset = pd.read_csv(data_filename)
结果是 Pandas数据帧,它具有一些有用的功能,稍后我们将使用它们。 查看结果数据集,我们可以看到一些问题。 键入以下内容并运行代码以查看数据集的前五行:
dataset.ix[:5]
这是输出:

这实际上是一个可用的数据集,但是它包含一些问题,我们将尽快解决。
清理数据集
在查看输出后,我们可以看到许多问题:
- 日期只是一个字符串,而不是日期对象
- 第一行是空白
- 通过目视检查结果,标题不完整或不正确
这些问题来自数据,我们可以通过更改数据本身来解决此问题。 但是,这样做时,我们可能会忘记采取的步骤或错误地使用了这些步骤; 也就是说,我们无法复制结果。 与上一节中使用管道跟踪对数据集所做的转换一样,我们将使用 pandas 将转换应用于原始数据本身。
pandas.read_csv函数具有修复每个问题的参数,我们可以在加载文件时指定这些参数。 加载文件后,我们还可以更改标题,如以下代码所示:
dataset = pd.read_csv(data_filename, parse_dates=["Date"], skiprows=[0,])
dataset.columns = ["Date", "Score Type", "Visitor Team", "VisitorPts", "Home Team", "HomePts", "OT?", "Notes"]
结果有了显着改善,因为我们可以看到是否打印出了结果数据帧:
dataset.ix[:5]
输出如下:

即使在诸如此类的经过良好编译的数据源中,您也需要进行一些调整。 不同的系统具有不同的细微差别,导致数据文件彼此之间不太兼容。
现在我们有了数据集,我们可以计算基线了。 基线是一种准确度,表示获得良好准确度的简便方法。 任何数据挖掘解决方案都应该胜过这一点。
在每场比赛中,我们都有两支球队:主队和客队。 一个明显的基线(称为机会率)为 50%。 (随时间推移)随机选择将导致 50%的准确性。
提取新功能
现在,我们可以通过合并和比较现有数据从此数据集中提取特征。 首先,我们需要指定我们的类值,这将为我们的分类算法提供一些参考,以比较其预测是否正确。 这可以用多种方式进行编码。 但是,对于此应用,如果主队获胜,我们将类别指定为 1,如果访问者队获胜,则将类别指定为 0。 在篮球比赛中,得分最高的球队获胜。 因此,尽管数据集未指定谁获胜,但我们可以轻松地进行计算。
我们可以通过以下方式指定数据集:
dataset["HomeWin"] = dataset["VisitorPts"] < dataset["HomePts"]
然后,我们将这些值复制到NumPy数组中,以供以后用于scikit-learn分类器。 Pandas 和 scikit-learn 之间目前尚无干净的集成,但是它们可以通过使用NumPy数组很好地协同工作。 虽然我们将使用 Pandas 提取特征,但我们需要提取值以将其与 scikit-learn 结合使用:
y_true = dataset["HomeWin"].values
现在,前面的数组以 scikit-learn 可以读取的格式保存我们的类值。
我们还可以开始创建一些可用于数据挖掘的功能。 尽管有时我们只是将原始数据扔进分类器中,但我们经常需要推导连续的数值或分类特征。
我们要创建的前两个功能可以帮助我们预测哪支球队会获胜,这就是这两支球队中的哪支球队是否赢得了最后一场比赛。 这大致可以估算出哪支球队表现出色。
我们将通过依次遍历各行并记录获胜团队来计算此功能。 当我们进入新的一行时,我们将检查团队是否在上次看到他们时赢了。
我们首先创建一个(默认)字典来存储团队的最后结果:
from collections import defaultdict
won_last = defaultdict(int)
这本词典的关键是团队,价值在于他们是否赢得了上一场比赛。 然后,我们可以遍历所有行,并使用团队的最后结果更新当前行:
for index, row in dataset.iterrows():
home_team = row["Home Team"]
visitor_team = row["Visitor Team"]
row["HomeLastWin"] = won_last[home_team]
row["VisitorLastWin"] = won_last[visitor_team]
dataset.ix[index] = row
请注意,前面的代码依赖于我们按时间顺序排列的数据集。 我们的数据集是有序的; 但是,如果使用的数据集不按顺序排列,则需要将dataset.iterrows()替换为dataset.sort("Date").iterrows()。
然后,在下次看到这些团队时,使用每个团队的结果(在此行中)设置字典。 代码如下:
won_last[home_team] = row["HomeWin"]
won_last[visitor_team] = not row["HomeWin"]
在前面的代码运行之后,我们将具有两个新功能:HomeLastWin和VisitorLastWin。 我们可以看一下数据集。 不过,看一下前五款游戏并没有多大意义。 由于我们的代码运行方式,当时我们没有用于它们的数据。 因此,直到一支球队第二个赛季,我们才知道他们目前的状态。 我们可以改为查看列表中的其他位置。 以下代码将显示本赛季第 20 到 25 场比赛:
dataset.ix[20:25]
这是输出:

您可以更改这些索引以查看数据的其他部分,因为我们的数据集中有 1000 多个游戏!
当前,这给所有团队(包括前一年的冠军!)初次见到的时候都是虚假的价值。 我们可以使用上一年的数据来改进此功能,但本章将不做。
决策树
决策树是一类监督学习算法,例如由一系列节点组成的流程图,其中样本值用于对要进入的下一个节点进行决策。

与大多数分类算法一样,有两个组件:
- 第一个是训练阶段,其中使用训练数据构建一棵树。 虽然上一章中最接近的邻居算法没有训练阶段,但决策树需要它。 这样,最近邻居算法是一个懒惰的学习者,仅在需要进行预测时才做任何工作。 相反,决策树像大多数分类方法一样,都是渴望学习的人,在训练阶段就开始工作。
- 第二个是预测阶段,其中训练有素的树用于预测新样本的分类。 使用前面的示例树,数据点
["is raining", "very windy"]将被分类为"bad weather"。
创建决策树的算法很多。 这些算法很多都是迭代的。 它们从基础节点开始,并确定要用于第一个决策的最佳功能,然后转到每个节点并选择下一个最佳功能,依此类推。 当确定无法进一步扩展树时,该过程将在某个点停止。
scikit-learn程序包将 CART(分类和回归树)算法实现为其默认决策树类,该算法可以同时使用分类和连续功能。
决策树中的参数
决策树的最重要的特征之一就是停止标准。 当构建一棵树时,最后的几个决策通常在某种程度上是任意的,并且仅依靠少量样本做出决策。 使用这样的特定节点可能会导致树大大超出训练数据。 相反,可以使用停止标准来确保决策树未达到此准确性。
除了使用停止条件外,还可以完整地创建树,然后进行修剪。 此修整过程将删除不会为整个过程提供太多信息的节点。 这被称为修剪。
scikit-learn 中的决策树实现提供了一种使用以下选项停止构建树的方法:
min_samples_split:这指定需要多少样本才能在决策树中创建一个新节点min_samples_leaf:这指定从节点留下的样本必须为多少
第一个指示是否将创建决策节点,而第二个指示是否将保留决策节点。
决策树的另一个参数是创建决策的标准。 基尼杂质和信息增益是两个流行的:
- 基尼杂质:这是衡量决策节点错误地预测样本类别的频率的方法
- 信息增益:这使用基于信息论的熵来指示决策节点获得了多少额外信息
使用决策树
我们可以导入DecisionTreeClassifier类,并使用 scikit-learn 创建决策树:
from sklearn.tree import DecisionTreeClassifierclf = DecisionTreeClassifier(random_state=14)
提示
我们再次将 14 用于random_state,并将在大多数模块中使用。 使用相同的随机种子可以复制实验。 但是,在进行实验时,应混合使用随机状态,以确保算法的性能不受特定值的限制。
现在,我们需要从我们的 Pandas 数据框中提取数据集,以便将其与scikit-learn分类器一起使用。 为此,我们指定希望使用的列,并使用数据框视图的values参数。 以下代码使用我们的主队和客队的最后获胜值创建一个数据集:
X_previouswins = dataset[["HomeLastWin", "VisitorLastWin"]].values
决策树是估计器,如在第 2 章,“使用 scikit-learn 估计器进行分类”中,因此具有fit和predict方法。 我们还可以使用cross_val_score方法来获得平均分数(就像我们之前所做的那样):
scores = cross_val_score(clf, X_previouswins, y_true, scoring='accuracy')
print("Accuracy: {0:.1f}%".format(np.mean(scores) * 100))
得分 56.1%:我们比随机选择更好! 我们应该能够做得更好。 特征工程是数据挖掘中最困难的任务之一,选择好的特征是获得好的结果的关键-比选择正确的算法还重要!

运动成绩预测
通过尝试其他功能,可能会做得更好。 我们有一种方法可以测试模型的准确性。 cross_val_score方法允许我们尝试新功能。
我们可以使用许多可能的功能,但是我们将尝试以下问题:
- 一般认为哪个小组更好?
- 哪支球队赢得了最后一场比赛?
我们还将尝试将原始团队放入算法中,以检查算法是否可以学习一个模型,该模型可以检查不同团队之间的对抗方式。
全部放在一起
对于第一个功能,我们将创建一个功能来告诉我们主队通常是否比访问者更好。 为此,我们将在上个赛季从 NBA 获得排名(在某些运动中也称为阶梯)。 如果一个团队在 2013 年排名高于其他团队,就会被认为会更好。
要获取排名数据,请执行以下步骤:
- 在您的网络浏览器中导航到这个页面。
- 选择扩展排名以获取整个联赛的一个列表。
- 单击导出链接。
- 将下载的文件保存在您的数据文件夹中。
返回您的 IPython Notebook,在新单元格中输入以下行。 您需要确保文件已保存到data_folder变量指向的位置。 代码如下:
standings_filename = os.path.join(data_folder, "leagues_NBA_2013_standings_expanded-standings.csv")
standings = pd.read_csv(standings_filename, skiprows=[0,1])
您可以通过在新单元格中键入standings并运行代码来查看梯形图:
Standings
输出如下:

接下来,我们使用与先前功能相似的模式创建一个新功能。 我们遍历行,查找主队和客队的排名。 代码如下:
dataset["HomeTeamRanksHigher"] = 0
for index, row in dataset.iterrows():
home_team = row["Home Team"]
visitor_team = row["Visitor Team"]
为了对数据进行重要调整,在 2013 和 2014 赛季之间改名了一支球队(但仍然是同一支球队)。 这是尝试集成数据时可能发生的许多不同事情之一的示例! 我们将需要调整团队查找,以确保获得正确的团队排名:
if home_team == "New Orleans Pelicans":
home_team = "New Orleans Hornets"
elif visitor_team == "New Orleans Pelicans":
visitor_team = "New Orleans Hornets"
现在我们可以获取每个团队的排名。 然后,我们将它们进行比较并更新该行中的功能:
home_rank = standings[standings["Team"] == home_team]["Rk"].values[0]
visitor_rank = standings[standings["Team"] == visitor_team]["Rk"].values[0]
row["HomeTeamRanksHigher"] = int(home_rank > visitor_rank)
dataset.ix[index] = row
接下来,我们使用cross_val_score功能测试结果。 首先,我们提取数据集:
X_homehigher = dataset[["HomeLastWin", "VisitorLastWin", "HomeTeamRanksHigher"]].values
然后,我们创建一个新的DecisionTreeClassifier并运行评估:
clf = DecisionTreeClassifier(random_state=14)
scores = cross_val_score(clf, X_homehigher, y_true, scoring='accuracy')
print("Accuracy: {0:.1f}%".format(np.mean(scores) * 100))
现在,该分数为 60.3%,甚至比我们之前的结果还要好。 我们可以做得更好吗?
接下来,让我们测试两支球队中的哪支赢得了最后一场比赛。 虽然排名可以暗示谁是赢家(排名较高的球队更有可能获胜),但有时球队与其他球队的比赛表现更好。 造成这种情况的原因很多,例如,某些团队可能制定了与其他团队非常有效的策略。 按照我们以前的模式,我们创建了一个字典来存储过去游戏的获胜者,并在数据框中创建一个新功能。 代码如下:
last_match_winner = defaultdict(int)
dataset["HomeTeamWonLast"] = 0
然后,我们遍历每一行并获得主队和访客队:
for index, row in dataset.iterrows():
home_team = row["Home Team"]
visitor_team = row["Visitor Team"]
我们想看看谁赢得了这两支球队之间的最后一场比赛,而与哪支球队在家中比赛无关。 因此,我们按字母顺序对团队名称进行排序,从而为这两个团队提供一致的密钥:
teams = tuple(sorted([home_team, visitor_team]))
我们在字典中查询,看谁赢得了两支队伍之间的最后一场比赛。 然后,我们更新数据集数据框中的行:
row["HomeTeamWonLast"] = 1 if last_match_winner[teams] == row["Home Team"] else 0
dataset.ix[index] = row
最后,我们用该游戏的赢家更新了我们的词典,以便计算这两个团队下次见面时的功能:
winner = row["Home Team"] if row["HomeWin"] else row["Visitor Team"]
last_match_winner[teams] = winner
接下来,我们将仅创建具有两个功能的数据集。 您可以尝试不同的功能组合,以查看它们是否获得不同的结果。 代码如下:
X_lastwinner = dataset[["HomeTeamRanksHigher", "HomeTeamWonLast"]].values
clf = DecisionTreeClassifier(random_state=14)
scores = cross_val_score(clf, X_lastwinner, y_true, scoring='accuracy')
print("Accuracy: {0:.1f}%".format(np.mean(scores) * 100))
这占 60.6%。 我们的结果越来越好。
最后,我们将检查如果在决策树上投入大量数据会发生什么,并查看它是否仍然可以学习有效的模型。 我们将使团队进入树状结构,并检查决策树是否可以学习合并这些信息。
尽管决策树能够从分类特征中学习,但是 scikit-learn 中的实现要求首先对那些特征进行编码。 我们可以使用LabelEncoder转换器在基于字符串的团队名称之间转换为整数。 代码如下:
from sklearn.preprocessing import LabelEncoder
encoding = LabelEncoder()
我们将把此转换器安装到主队中,以便为每个队学习一个整数表示形式:
encoding.fit(dataset["Home Team"].values)
我们提取主队和访客队的所有标签,然后将它们加入(在NumPy中称为堆叠),以创建一个矩阵,对每个主队和访客队进行编码 游戏。 代码如下:
home_teams = encoding.transform(dataset["Home Team"].values)
visitor_teams = encoding.transform(dataset["Visitor Team"].values)
X_teams = np.vstack([home_teams, visitor_teams]).T
这些整数可以输入到决策树中,但是DecisionTreeClassifier仍会将它们解释为连续特征。 例如,可以为团队分配 0 到 16 的整数。算法将使团队 1 和 2 相似,而团队 4 和 10 则不同-但这对所有人都没有意义。 所有团队都互不相同-两个团队要么相同,要么不一样!
要解决此不一致的问题,我们使用OneHotEncoder转换器将这些整数编码为许多二进制特征。 每个二进制特征将是该特征的单个值。 例如,如果LabelEncoder将 NBA 芝加哥公牛队分配为整数 7,则如果该团队是芝加哥公牛队,则OneHotEncoder返回的第七个特征将为 1,而所有球队均为 0 其他团队。 针对每个可能的值执行此操作,从而导致更大的数据集。 代码如下:
from sklearn.preprocessing import OneHotEncoder
onehot = OneHotEncoder()
我们对同一个数据集进行拟合和变换,保存结果:
X_teams_expanded = onehot.fit_transform(X_teams).todense()
接下来,我们像以前一样在新数据集上运行决策树:
clf = DecisionTreeClassifier(random_state=14)
scores = cross_val_score(clf, X_teams_expanded, y_true, scoring='accuracy')
print("Accuracy: {0:.1f}%".format(np.mean(scores) * 100))
得分准确性为 60%。 分数比基线好,但不及以前。 决策树可能无法正确处理大量特征。 因此,我们将尝试更改算法,看看是否有帮助。 数据挖掘可以是尝试新算法和功能的迭代过程。
随机森林
单个决策树可以学习非常复杂的功能。 但是,在许多方面,它很容易过拟合-仅适用于训练集的学习规则。 我们可以对此进行调整的方法之一是限制它学习的规则数量。 例如,我们可以将树的深度限制为仅三层。 这样的树将学习在全局范围内分割数据集的最佳规则,但不会学习将数据集分为高度精确的组的高度特定的规则。 这种权衡导致树可能具有良好的泛化性,但总体性能稍差。
为了弥补这一点,我们可以创建许多决策树,然后要求每个决策树预测类值。 我们可以进行多数表决,并将该答案用作我们的总体预测。 随机森林按照这一原则开展工作。
上述过程存在两个问题。 第一个问题是,构建决策树在很大程度上是确定性的-使用相同的输入将每次产生相同的输出。 我们只有一个训练数据集,这意味着如果我们尝试构建多棵树,我们的输入(以及输出)将是相同的。 我们可以通过选择数据集的随机子样本来解决此问题,从而有效地创建new训练集。 该处理称为套袋。
的第二个问题是,用于树中前几个决策节点的功能将非常好。 即使我们选择训练数据的随机子样本,建立的决策树在很大程度上仍将是相同的。 为了弥补这一点,我们还选择了特征的随机子集来执行数据拆分。
然后,我们使用(几乎)随机选择的特征,使用随机选择的样本随机构建了树。 这是一个随机森林,也许是而不是,该算法在许多数据集上非常有效。
集成如何工作?
随机森林中固有的随机性可能使我们似乎将算法的结果留给了机会。 但是,我们将平均的好处应用于几乎随机建立的决策树,从而产生了一种可以减少结果差异的算法。
方差是算法中训练数据集变化引起的误差。 训练数据集的变化会极大地影响方差较大的算法(例如决策树)。 这导致模型存在过度拟合的问题。
注意
与相反,偏差是算法中的假设所引入的误差,而不是与数据集有关的误差,也就是说,如果我们有一个算法假定所有特征都是正态分布的,那么我们 如果功能不具备,则算法可能会有很高的错误。 通过分析数据以查看分类器的数据模型是否与实际数据相匹配,可以减少偏差带来的负面影响。
通过平均大量决策树,可以大大减少这种差异。 这导致模型具有更高的整体精度。
总的来说,合奏是基于这样的假设,即预测中的误差实际上是随机的,并且各个分类器之间的误差完全不同。 通过对许多模型的结果求平均值,可以消除这些随机误差,从而留下真实的预测结果。 在本书的其余部分中,我们将看到更多的合奏。
随机森林中的参数
scikit-learn 中的随机森林实现称为RandomForestClassifier,它具有许多参数。 由于随机森林使用的许多实例,因此它们共享许多相同的参数,例如标准(基尼杂质或熵/信息增益),max_features和min_samples_split。
此外,合奏过程中使用了一些新参数:
n_estimators:这指示应构建多少个决策树。 较高的值将花费更长的时间运行,但(可能)会导致较高的精度。oob_score:如果为 true,则使用不在用于训练决策树的随机子样本中的样本对方法进行测试。n_jobs:此指定在并行训练决策树时要使用的核心数。
scikit-learn程序包使用名为Joblib的库进行内置并行化。 此参数决定要使用多少个内核。 默认情况下,仅使用单个核心-如果您具有更多核心,则可以增加该核心,或将其设置为-1 以使用所有核心。
应用随机森林
scikit-learn 中的随机森林使用 estimator 接口,使我们可以使用几乎与之前完全相同的代码来进行交叉折叠验证:
from sklearn.ensemble import RandomForestClassifier
clf = RandomForestClassifier(random_state=14)
scores = cross_val_score(clf, X_teams, y_true, scoring='accuracy')
print("Accuracy: {0:.1f}%".format(np.mean(scores) * 100))
通过交换分类器,可以立即带来 60.6%的收益,提高了 0.6 个百分点。
使用特征子集的随机森林应该能够比正常决策树更有效地学习更多特征。 我们可以通过在算法上添加更多功能并查看其运行方式来进行测试:
X_all = np.hstack([X_home_higher, X_teams])
clf = RandomForestClassifier(random_state=14)
scores = cross_val_score(clf, X_all, y_true, scoring='accuracy')
print("Accuracy: {0:.1f}%".format(np.mean(scores) * 100))
结果是 61.1%,甚至更好! 我们还可以使用类尝试其他一些参数,如我们在第 2 章,“使用 scikit-learn 估计器进行分类”中介绍的那样:
parameter_space = {
"max_features": [2, 10, 'auto'],
"n_estimators": [100,],
"criterion": ["gini", "entropy"],
"min_samples_leaf": [2, 4, 6],
}
clf = RandomForestClassifier(random_state=14)
grid = GridSearchCV(clf, parameter_space)
grid.fit(X_all, y_true)
print("Accuracy: {0:.1f}%".format(grid.best_score_ * 100))
准确率高达 64.2%!
如果我们想查看所使用的参数,我们可以打印出在网格搜索中找到的最佳模型。 代码如下:
print(grid.best_estimator_)
结果显示了最佳评分模型中使用的参数:
RandomForestClassifier(bootstrap=True, compute_importances=None,criterion='entropy', max_depth=None, max_features=2,max_leaf_nodes=None, min_density=None, min_samples_leaf=6,min_samples_split=2, n_estimators=100, n_jobs=1,oob_score=False, random_state=14, verbose=0)
工程新功能
在的前面几个示例中,我们看到更改功能可能会对算法的性能产生很大影响。 通过少量的测试,我们仅在功能上就有超过 10%的差异。
您可以通过执行以下操作来创建来自 Pandas 中的简单功能的功能:
dataset["New Feature"] = feature_creator()
feature_creator函数必须返回数据集中每个样本的特征值列表。 一种常见的模式是将数据集用作参数:
dataset["New Feature"] = feature_creator(dataset)
您可以通过将所有值设置为单个“默认”值来更直接地创建这些功能,例如下一行中的 0:
dataset["My New Feature"] = 0
然后,您可以遍历数据集,随时进行特征计算。 在本章中,我们使用了这种格式来创建许多功能:
for index, row in dataset.iterrows():
home_team = row["Home Team"]
visitor_team = row["Visitor Team"]
# Some calculation here to alter row dataset.ix[index] = row
请记住,这种模式不是很有效。 如果要执行此操作,请立即尝试所有功能。 常见的“最佳实践”是尽可能少地触摸每个样本,最好只接触一次。
您可以尝试实现的一些示例功能如下:
- 自每支球队上一场比赛以来已有多少天? 如果团队在短时间内玩太多游戏,可能会感到疲倦。
- 每支球队在最近五场比赛中赢了几场? 这将为我们较早提取的
HomeLastWin和VisitorLastWin特征提供更稳定的形式(并且可以非常相似的方式提取)。 - 拜访某些其他团队时,团队是否有良好记录? 例如,即使是访客,一支球队也可能在特定的体育场打得很好。
如果在提取这些类型的功能时遇到麻烦,请查看这个页面上的 Pandas 文档。 或者,您可以尝试使用在线论坛(例如 Stack Overflow)寻求帮助。
更极端的例子可能是使用球员数据来估算每支球队的实力来预测谁获胜。 赌徒和体育博彩公司每天都使用这些类型的复杂功能,通过预测体育比赛的结果来尝试获利。




二十五、使用亲和力分析推荐电影
在本章中,我们将研究确定对象何时频繁出现的亲和力分析。 在确定何时一起购买物品的用例之一之后,通俗地称为“市场篮子分析”。
在第 3 章,“用决策树”预测运动优胜者中,我们将一个对象视为焦点,并使用特征来描述该对象。 在本章中,数据具有不同的形式。 我们有一些交易,其中以某种方式在这些交易中使用了感兴趣的对象(本章中的电影)。 目的是发现对象同时发生的时间。 在此示例中,我们希望确定何时由同一位审阅者推荐两部电影。
本章的关键概念如下:
- 亲和力分析
- 使用 Apriori 算法进行特征关联挖掘
- 电影推荐
- 稀疏数据格式
亲和力分析
亲和力分析是确定何时以类似方式使用对象的任务。 在上一章中,我们集中于对象本身是否相似。 用于亲和力分析的数据通常以事务的形式描述。 直观地讲,这来自商店的交易-确定何时一起购买物品。
但是,它可以应用于许多过程:
- 欺诈识别
- 客户细分
- 软件优化
- 产品推荐
亲和力分析通常比分类更具探索性。 我们常常没有许多分类任务所需的完整数据集。 例如,在电影推荐中,我们对不同电影的评论来自不同的人。 但是,不太可能让每个审阅者审阅数据集中的所有电影。 这在亲和力分析中留下了一个重要而困难的问题。 如果评论者没有评论过电影,是否表明他们对电影不感兴趣(因此不推荐),或者仅仅是他们还没有评论过?
我们不会在本章中回答该问题,但是考虑数据集中的空白可能会导致类似的问题。 反过来,这可能会导致可能有助于提高方法效率的答案。
亲和力分析算法
我们在第 1 章,“数据挖掘入门”中引入了一种用于亲和力分析的基本方法,该方法测试了所有可能的规则组合。 我们计算了每个规则的置信度和支持度,从而使我们能够对它们进行排名,以找到最佳规则。
但是,这种方法效率不高。 我们在第 1 章,“数据挖掘入门”中的数据集仅售出 5 件。 我们可以预期,即使是一家小商店,也会有数百种商品待售,而许多在线商店将拥有成千上万(甚至数百万!)。 通过天真的规则创建(例如我们之前的算法),计算这些规则所需的时间增长呈指数增长。 随着我们添加更多项目,计算所有规则所需的时间显着增加。 具体而言,规则的总数为 2n-1。 对于我们的五项数据集,有 31 条可能的规则。 对于 10 个项目,它是 1023。对于仅 100 个项目,该数字具有 30 位数字。 甚至计算能力的急剧增加也无法跟上在线存储物品数量的增加。 因此,我们需要更智能的算法,而不是更辛苦的计算机。
用于亲和力分析的经典算法称为 Apriori 算法。 它解决了创建在数据库中频繁出现的项目集(称为频繁项目集)的指数问题。 一旦发现了这些频繁的项目集,就很容易创建关联规则。
Apriori 的直觉既简单又聪明。 首先,我们确保规则在数据集中具有足够的支持。 定义最低支持级别是 Apriori 的关键参数。 若要建立一个频繁的项目集,要使一个项目集(A,B)的支持至少达到 30,A 和 B 在数据库中都必须发生至少 30 次。 此属性也扩展到更大的集合。 对于被认为是频繁的项目集(A,B,C,D),集合(A,B,C)也必须是频繁的(与 D 一样)。
可以构建这些频繁项目集,并且不会测试不频繁(可能有很多)的可能项目集。 这节省了测试新规则的大量时间。
用于亲和力分析的其他示例算法包括 Eclat 和 FP-growth 算法。 数据挖掘文献中对这些算法进行了许多改进,进一步提高了该方法的效率。 在本章中,我们将重点介绍基本的 Apriori 算法。
选择参数
为了执行关联规则挖掘以进行亲和力分析,我们首先使用 Apriori 生成频繁项集。 接下来,我们通过测试前提条件和结论在这些频繁项目集中的组合,来创建关联规则(例如,如果某人推荐电影X,他们也会推荐电影Y)。
对于第一阶段,Apriori 算法需要一个值,该值用于将项目集视为频繁的最小支持。 任何支持较少的项目集将不予考虑。 将此最低支持设置得太低将导致 Apriori 测试大量项目集,从而减慢算法速度。 设置得太高将导致较少的项目集被认为是频繁的。
在第二阶段,在发现频繁项集之后,将根据其置信度对关联规则进行测试。 我们可以选择最低置信度,可以选择返回多个规则,也可以简单地返回所有规则,然后让用户决定如何处理它们。
在本章中,我们将仅返回给定置信度以上的规则。 因此,我们需要设置最低置信度。 将此值设置得太低将导致规则具有较高的支持度,但并不十分准确。 将此值设置得更高将导致仅返回更准确的规则,但发现的规则却更少。
电影推荐问题
产品推荐是一项大生意。 在线商店通过推荐其他客户可以购买的产品,将其用于向客户进行向上销售。 提出更好的建议可以带来更好的销售。 当在线购物每年销售给数百万个客户时,向这些客户销售更多商品会产生很多潜在的收益。
产品推荐已经研究了很多年。 但是,当 Netflix 在 2007 年至 2009 年间获得 Netflix 奖时,该领域获得了巨大的推动。该竞赛旨在确定是否有人能够预测用户对电影的评级要好于 Netflix 当前的表现。 奖杯比目前的解决方案高出 10%以上。 尽管这似乎不是很大的改进,但这种改进可以通过更好的电影推荐为 Netflix 带来数百万美元的收入。
获取数据集
自从成立 Netflix 奖以来,明尼苏达大学的研究小组 Grouplens 已发布了多个数据集,这些数据集通常用于测试该领域的算法。 他们发布了电影分级数据集的多个版本,大小各异。 有一个具有 100,000 条评论的版本,一个具有 100 万条评论,一个具有 1000 万条评论。
数据集可从这个页面获得,我们将在本章中使用的数据集是 MovieLens 100 万数据集。 下载此数据集并将其解压缩到您的数据文件夹中。 启动一个新的 IPython Notebook 并输入以下代码:
import os
import pandas as pd
data_folder = os.path.join(os.path.expanduser("~"), "Data", "ml-100k")
ratings_filename = os.path.join(data_folder, "u.data")
确保ratings_filename指向解压缩文件夹中的u.data文件。
加载 Pandas
MovieLens 数据集状态良好; 但是,我们需要对pandas.read_csv中的默认选项进行一些更改。 首先,数据由制表符(而不是逗号)分隔。 接下来,没有标题行。 这意味着文件中的第一行实际上是数据,我们需要手动设置列名。
加载文件时,我们将 delimiter 参数设置为制表符,告诉 Pandas 不要读取第一行作为标题(带有header=None),并设置列名。 让我们看下面的代码:
all_ratings = pd.read_csv(ratings_filename, delimiter="\t",header=None, names = ["UserID", "MovieID", "Rating", "Datetime"])
尽管我们将在本章中不使用它,但是您可以使用以下行正确地解析日期时间戳:
all_ratings["Datetime"] = pd.to_datetime(all_ratings['Datetime'],unit='s')
您可以通过在新单元格中运行以下命令来查看前几条记录:
all_ratings[:5]
结果将看起来像这样:
| |用户身份
|
电影 ID
|
评分
|
约会时间
|
| --- | --- | --- | --- | --- |
|0| 196 | 242 | 3 | 1997-12-04 15:55:49 |
|1| 186 | 302 | 3 | 1998-04-04 19:22:22 |
|2| 22 | 377 | 1 | 1997-11-07 07:18:36 |
|3| 244 | 51 | 2 | 1997-11-27 05:02:03 |
|4| 166 | 346 | 1 | 1998-02-02 05:33:16 |
稀疏数据格式
该数据集为稀疏格式。 可以将每一行视为上一章中所使用类型的大型特征矩阵中的单元,其中行是用户,列是单独的电影。 第一列将是每个用户对第一部电影的评论,第二列将是每个用户对第二部电影的评论,依此类推。
此数据集中有 1,000 个用户和 1,700 部电影,这意味着完整的矩阵将非常大。 我们可能会遇到将整个矩阵存储在内存中并在其上进行计算的麻烦。 但是,此矩阵具有大多数单元为空的属性,也就是说,大多数用户对大多数电影都没有评论。 但是,对于用户#213,没有针对电影#675的评论,对于用户和电影的大多数其他组合,则没有评论。
此处给出的格式代表完整的矩阵,但格式更紧凑。 第一行表示用户#196观看了电影#242,在 1997 年 12 月 4 日,该电影的排名为 3(五分之三)。
假定该数据库中没有用户和电影的任何组合,则不存在。 与在内存中存储一堆零相反,此节省了大量空间。 这种格式称为稀疏矩阵格式。 根据经验,如果您希望约 60%或更多的数据集为空或为零,则稀疏格式将占用较少的存储空间。
在稀疏矩阵上进行计算时,通常不将重点放在我们没有的数据上,而是比较所有零。 我们通常关注于我们拥有的数据并进行比较。

Apriori 实现
本章的目标旨在产生以下格式的规则:如果有人推荐这些电影,他们也会推荐该电影。 我们还将讨论如果有人推荐一组电影可能会推荐另一部特定电影的扩展。
为此,我们首先需要确定一个人是否推荐电影。 为此,我们可以创建一个新功能Favorable,如果该人对电影给予了好评,则该功能为True:
all_ratings["Favorable"] = all_ratings["Rating"] > 3
我们可以通过查看数据集来查看新功能:
all_ratings[10:15]
| |
用户身份
|
电影 ID
|
评分
|
约会时间
|
有利
|
| --- | --- | --- | --- | --- | --- |
|10| 62 | 257 | 2 | 1997-11-12 22:07:14 | 错误的 |
|11| 286 | 1014 | 5 | 1997-11-17 15:38:45 | 真的 |
|12| 200 | 222 | 5 | 1997-10-05 09:05:40 | 真的 |
|13| 210 | 40 | 3 | 1998-03-27 21:59:54 | 错误的 |
|14| 224 | 29 | 3 | 1998-02-21 23:40:57 | 错误的 |
我们将采样我们的数据集以形成训练数据集。 这也有助于减小将要搜索的数据集的大小,从而使 Apriori 算法的运行速度更快。 我们从前 200 个用户中获得所有评论:
ratings = all_ratings[all_ratings['UserID'].isin(range(200))]
接下来,我们可以创建样本中仅包含好评的数据集:
favorable_ratings = ratings[ratings["Favorable"]]
我们将搜索用户对我们的商品集的好评。 因此,接下来我们需要的是每个用户都喜欢的电影。 我们可以通过按用户 ID 对数据集进行分组并遍历每个组中的电影来进行计算:
favorable_reviews_by_users = dict((k, frozenset(v.values))
for k, v in favorable_ratings
groupby("UserID")["MovieID"])
在前面的代码中,我们将值存储为frozenset,从而使我们能够快速检查电影是否已被用户评级。 对于这种类型的操作,集合比列表快得多,我们将在以后的代码中使用它们。
最后,我们可以创建一个 DataFrame 来告诉我们每部电影获得好评的频率:
num_favorable_by_movie = ratings[["MovieID", "Favorable"]]. groupby("MovieID").sum()
通过运行以下代码,我们可以查看前五部电影:
num_favorable_by_movie.sort("Favorable", ascending=False)[:5]
让我们看一下前五部电影列表:
|电影 ID
|
有利
|
| --- | --- |
| 50 | 100 |
| 100 | 89 |
| 258 | 83 |
| 181 | 79 |
| 174 | 74 |
Apriori 算法
Apriori 算法是我们的亲和力分析的一部分,专门用于查找数据中的频繁项集。 Apriori 的基本过程是从以前发现的频繁项目集构建新的候选项目集。 对这些候选项进行测试以查看它们是否频繁出现,然后算法按以下说明进行迭代:
- 通过将每个项目放置在其自己的项目集中来创建初始频繁项目集。 在此步骤中,仅使用支持最少的项目。
- 通过查找现有频繁项目集的超集,从最近发现的频繁项目集创建新的候选项目集。
- 测试所有候选项目集以查看它们是否频繁。 如果候选人不频繁,则将其丢弃。 如果此步骤中没有新的频繁项目集,请转到最后一步。
- 存储新发现的频繁项目集,然后转到第二步。
- 返回所有发现的频繁项目集。
以下工作流概述了此过程:

实施
在 Apriori 的第一次迭代中,新发现的项目集的长度为 2,因为它们是第一步中创建的初始项目集的超集。 在第二次迭代中(应用第四步之后),新发现的项目集的长度为 3。这使我们能够根据第二步的需要快速识别新发现的项目集。
我们可以将发现的频繁项集存储在字典中,其中的关键是项集的长度。 这使我们能够在以下代码的帮助下快速访问给定长度的项目集,从而可以访问最近发现的频繁项目集:
frequent_itemsets = {}
我们还需要定义一个项目集被视为频繁的最低支持。 该值是根据数据集选择的,但是可以尝试使用其他值。 不过,我建议一次只将其更改 10%,因为算法运行所需的时间会大大不同! 让我们应用最低限度的支持:
min_support = 50
为了实现 Apriori 算法的第一步,我们分别为每个电影创建一个项目集,并测试该项目集是否频繁。 我们使用frozenset,因为它们允许我们稍后执行设置操作,它们也可以用作计数字典中的键(常规设置不能)。 让我们看下面的代码:
frequent_itemsets[1] = dict((frozenset((movie_id,)),
row["Favorable"])
for movie_id, row in num_favorable_by_movie.iterrows()
if row["Favorable"] > min_support)
为了实现效率,我们共同创建了第二和第三步,方法是创建一个函数,该函数采用新发现的频繁项集,创建超集,然后测试它们是否频繁。 首先,我们设置函数和计数字典:
from collections import defaultdict
def find_frequent_itemsets(favorable_reviews_by_users, k_1_itemsets, min_support):
counts = defaultdict(int)
为了尽可能少地读取数据,我们每次调用此函数都会对数据集进行一次迭代。 尽管在此实现中这无关紧要(我们的数据集相对较小),但是对于大型应用来说,这是一个好习惯。 我们遍历所有用户及其评论:
for user, reviews in favorable_reviews_by_users.items():
接下来,我们遍历每个先前发现的项目集,并查看它是否是当前评论集的子集。 如果是,则表示用户已查看项目集中的每个电影。 让我们看一下代码:
for itemset in k_1_itemsets:
if itemset.issubset(reviews):
然后,我们可以浏览用户查看过的每个未在项目集中的单个电影,从中创建一个超集,并在计数字典中记录我们看到的该特定项目集。 让我们看一下代码:
for other_reviewed_movie in reviews - itemset:
current_superset = itemset | frozenset((other_reviewed_movie,))
counts[current_superset] += 1
我们通过测试哪些候选项目集有足够的支持被认为是频繁的来结束我们的功能,并仅返回那些:
return dict([(itemset, frequency) for itemset, frequency in counts.items() if frequency >= min_support])
为了运行我们的代码,我们现在创建一个循环,循环遍历 Apriori 算法的各个步骤,并在进行过程中存储新的项目集。 在此循环中,k表示即将被发现的频繁项集的长度,从而使我们可以通过使用键 k 在frequent_itemsets字典中查找来访问先前发现最多的项- 1。 我们创建常用项目集,并将它们按其长度存储在我们的字典中。 让我们看一下代码:
for k in range(2, 20):
cur_frequent_itemsets = find_frequent_itemsets(favorable_reviews_by_users, frequent_itemsets[k-1],
min_support)
frequent_itemsets[k] = cur_frequent_itemsets
如果我们没有找到任何新的频繁项目集,我们想打破前面的循环(并打印一条消息,让我们知道发生了什么事情):
if len(cur_frequent_itemsets) == 0:
print("Did not find any frequent itemsets of length {}".format(k))
sys.stdout.flush()
break
注意
我们使用sys.stdout.flush()来确保在代码仍在运行时进行打印输出。 有时,在特定单元格中的大型循环中,直到代码完成后才进行打印输出。 以这种方式刷新输出可确保在需要时进行打印输出。 不过,不要做太多—刷新操作会带来计算成本(与打印一样),这会降低程序速度。
如果我们确实找到了频繁的项目集,我们将打印一条消息,让我们知道循环将再次运行。 该算法可能需要一段时间才能运行,因此在等待代码完成时知道代码仍在运行非常有帮助! 让我们看一下代码:
else:
print("I found {} frequent itemsets of length {}".format(len(cur_frequent_itemsets), k))
sys.stdout.flush()
最终,在循环结束之后,我们不再对第一组项目集感兴趣-这些项目集的长度为 1,这对我们创建关联规则没有帮助-我们至少需要两个项目来创建关联规则。 让我们删除它们:
del frequent_itemsets[1]
您现在可以运行此代码。 这可能需要几分钟,如果您使用的是较旧的硬件,则可能需要更多时间。 如果发现在运行任何代码示例时遇到问题,请查看使用在线云提供商以提高速度。 有关使用云进行工作的详细信息,请参见第 13 章,“后续步骤”。
前面的代码返回了 1,718 个不同长度的频繁项集。 您会注意到,项目集的数量在长度减小之前就随着长度的增加而增加。 由于可能的规则越来越多,所以它不断增长。 一段时间后,大量的组合不再具有被认为是频繁出现的必要支持。 这导致数量减少。 这种缩小是 Apriori 算法的好处。 如果我们搜索所有可能的项目集(而不仅仅是频繁项目集的超集),那么我们将搜索成千上万次的项目集,以查看它们是否频繁。
提取关联规则
在 Apriori 算法完成后,我们将获得一个频繁项集列表。 这些并不完全是关联规则,但与之相似。 频繁项集是一组具有最小支持的项,而关联规则具有前提和结论。
我们可以通过将项目集中的一部电影作为结论来从频繁项目集中制定关联规则。 项目集中的其他电影将作为前提。 这将形成以下形式的规则:如果审阅者推荐前提条件中的所有电影,他们还将推荐结论。
对于每个项目集,我们可以通过将每个电影设置为结论并将其余电影设置为前提来生成许多关联规则。
在代码中,我们首先遍历每个发现的每个长度的频繁项集,从每个频繁项集生成所有规则的列表:
candidate_rules = []
for itemset_length, itemset_counts in frequent_itemsets.items():
for itemset in itemset_counts.keys():
然后,我们迭代此项目集中的每个电影,并以此作为结论。 项目集中的其余电影是前提。 我们将前提和结论保存为我们的候选规则:
for conclusion in itemset:
premise = itemset - set((conclusion,))
candidate_rules.append((premise, conclusion))
这将返回大量候选规则。 通过打印出列表中的前几个规则,我们可以看到一些内容:
print(candidate_rules[:5])
结果输出显示获得的规则:
[(frozenset({79}), 258), (frozenset({258}), 79), (frozenset({50}), 64), (frozenset({64}), 50), (frozenset({127}), 181)]
在这些规则中,第一部分(frozenset)是前提中的电影列表,而后面的数字是结论。 在第一种情况下,如果审阅者推荐电影 79,则他们也可能会推荐电影 258。
接下来,我们计算每个规则的置信度。 此操作非常类似于第 1 章,“数据挖掘入门”,唯一的更改是使用新数据格式进行计算所需的更改。
该过程从创建字典开始,以存储我们看到得出结论的前提的次数(规则的正确示例)和不存在的次数(错误) 例子)。 让我们看一下代码:
correct_counts = defaultdict(int)
incorrect_counts = defaultdict(int)
我们遍历所有用户,他们的好评以及每条候选关联规则:
for user, reviews in favorable_reviews_by_users.items():
for candidate_rule in candidate_rules:
premise, conclusion = candidate_rule
然后,我们测试一下前提是否适用于该用户。 换句话说,用户是否在前提条件下浏览了所有电影? 让我们看一下代码:
if premise.issubset(reviews):
如果前提适用,我们将看看结论电影是否也获得了好评。 如果是这样,则该规则在这种情况下是正确的。 如果不是,则不正确。 让我们看一下代码:
if premise.issubset(reviews):
if conclusion in reviews:
correct_counts[candidate_rule] += 1
else:
incorrect_counts[candidate_rule] += 1
然后,我们通过将正确的计数除以看到规则的总次数来计算每个规则的置信度:
rule_confidence = {candidate_rule: correct_counts[candidate_rule] / float(correct_counts[candidate_rule] + incorrect_counts[candidate_rule])
for candidate_rule in candidate_rules}
现在,我们可以通过排序此置信词典并打印结果来打印前五个规则:
from operator import itemgetter
sorted_confidence = sorted(rule_confidence.items(), key=itemgetter(1), reverse=True)
for index in range(5):
print("Rule #{0}".format(index + 1))
(premise, conclusion) = sorted_confidence[index][0]
print("Rule: If a person recommends {0} they will also recommend {1}".format(premise, conclusion))
print(" - Confidence: {0:.3f}".format(rule_confidence[(premise, conclusion)]))
print("")
结果如下:
Rule #1
Rule: If a person recommends frozenset({64, 56, 98, 50, 7}) they will also recommend 174
- Confidence: 1.000
Rule #2
Rule: If a person recommends frozenset({98, 100, 172, 79, 50, 56}) they will also recommend 7
- Confidence: 1.000
Rule #3
Rule: If a person recommends frozenset({98, 172, 181, 174, 7}) they will also recommend 50
- Confidence: 1.000
Rule #4
Rule: If a person recommends frozenset({64, 98, 100, 7, 172, 50}) they will also recommend 174
- Confidence: 1.000
Rule #5
Rule: If a person recommends frozenset({64, 1, 7, 172, 79, 50}) they will also recommend 181
- Confidence: 1.000
产生的打印输出仅显示电影 ID,如果没有电影名称也没有太大帮助。 数据集附带一个名为u.items的文件,该文件存储电影名称及其对应的MovieID(以及其他信息,例如流派)。
我们可以使用 pandas 从该文件中加载标题。 数据集随附的自述文件中提供了有关文件和类别的其他信息。 文件中的数据为 CSV 格式,但数据之间用|符号分隔; 它没有标题,并且编码很重要。 在README文件中找到了列名。
movie_name_filename = os.path.join(data_folder, "u.item")
movie_name_data = pd.read_csv(movie_name_filename, delimiter="|", header=None, encoding = "mac-roman")
movie_name_data.columns = ["MovieID", "Title", "Release Date", "Video Release", "IMDB", "<UNK>", "Action", "Adventure",
"Animation", "Children's", "Comedy", "Crime", "Documentary", "Drama", "Fantasy", "Film-Noir",
"Horror", "Musical", "Mystery", "Romance", "Sci-Fi", "Thriller","War", "Western"]
获取电影标题很重要,因此我们将创建一个函数,该函数将从MovieID中返回电影的标题,从而避免了每次查找电影的麻烦。 让我们看一下代码:
def get_movie_name(movie_id):
我们为给定的MovieID查找movie_name_data DataFrame,仅返回标题列:
title_object = movie_name_data[movie_name_data["MovieID"] == movie_id]["Title"]
我们使用 values 参数获取实际值(而不是当前存储在title_object中的 PandasSeries对象)。 我们只对第一个值感兴趣-无论如何,给定的MovieID应该只有一个标题!
title = title_object.values[0]
我们根据需要返回标题来结束该函数。 让我们看一下代码:
return title
在新的 IPython Notebook 单元中,我们调整了先前的代码以打印出最高规则,同时还包括标题:
for index in range(5):
print("Rule #{0}".format(index + 1))
(premise, conclusion) = sorted_confidence[index][0]
premise_names = ", ".join(get_movie_name(idx) for idx in premise)
conclusion_name = get_movie_name(conclusion)
print("Rule: If a person recommends {0} they will also recommend {1}".format(premise_names, conclusion_name))
print(" - Confidence: {0:.3f}".format(confidence[(premise, conclusion)]))
print("")
结果更具可读性(仍然存在一些问题,但是我们暂时可以忽略它们):
Rule #1
Rule: If a person recommends Shawshank Redemption, The (1994), Pulp Fiction (1994), Silence of the Lambs, The (1991), Star Wars (1977), Twelve Monkeys (1995) they will also recommend Raiders of the Lost Ark (1981)
- Confidence: 1.000
Rule #2
Rule: If a person recommends Silence of the Lambs, The (1991), Fargo (1996), Empire Strikes Back, The (1980), Fugitive, The (1993), Star Wars (1977), Pulp Fiction (1994) they will also recommend Twelve Monkeys (1995)
- Confidence: 1.000
Rule #3
Rule: If a person recommends Silence of the Lambs, The (1991), Empire Strikes Back, The (1980), Return of the Jedi (1983), Raiders of the Lost Ark (1981), Twelve Monkeys (1995) they will also recommend Star Wars (1977)
- Confidence: 1.000
Rule #4
Rule: If a person recommends Shawshank Redemption, The (1994), Silence of the Lambs, The (1991), Fargo (1996), Twelve Monkeys (1995), Empire Strikes Back, The (1980), Star Wars (1977) they will also recommend Raiders of the Lost Ark (1981)
- Confidence: 1.000
Rule #5
Rule: If a person recommends Shawshank Redemption, The (1994), Toy Story (1995), Twelve Monkeys (1995), Empire Strikes Back, The (1980), Fugitive, The (1993), Star Wars (1977) they will also recommend Return of the Jedi (1983)
- Confidence: 1.000
评估
在广义上,我们可以使用与分类相同的概念评估关联规则。 我们使用未用于训练的数据测试集,并根据它们在该测试集中的性能评估发现的规则。
为此,我们将计算测试集的置信度,即每个规则对测试集的置信度。
在这种情况下,我们不会应用正式的评估指标; 我们只研究规则并寻找好的例子。
首先,我们提取测试数据集,这是我们在训练集中未使用的所有记录。 我们将前 200 个用户(按 ID 值)用于训练集,并将其余所有用户用于测试数据集。 与训练集一样,我们还将获得该数据集中每个用户的好评。 让我们看一下代码:
test_dataset = all_ratings[~all_ratings['UserID'].isin(range(200))]
test_favorable = test_dataset[test_dataset["Favorable"]]
test_favorable_by_users = dict((k, frozenset(v.values)) for k, v in test_favorable.groupby("UserID")["MovieID"])
然后,我们以与之前相同的方式计算前提得出结论的正确实例。 唯一的变化是使用测试数据而不是训练数据。 让我们看一下代码:
correct_counts = defaultdict(int)
incorrect_counts = defaultdict(int)
for user, reviews in test_favorable_by_users.items():
for candidate_rule in candidate_rules:
premise, conclusion = candidate_rule
if premise.issubset(reviews):
if conclusion in reviews:
correct_counts[candidate_rule] += 1
else:
incorrect_counts[candidate_rule] += 1
接下来,我们从正确的计数中计算每个规则的置信度。 让我们看一下代码:
test_confidence = {candidate_rule: correct_counts[candidate_rule]/ float(correct_counts[candidate_rule] + incorrect_counts[candidate_rule])
for candidate_rule in rule_confidence}
最后,我们打印出具有标题而不是影片 ID 的最佳关联规则。
for index in range(5):
print("Rule #{0}".format(index + 1))
(premise, conclusion) = sorted_confidence[index][0]
premise_names = ", ".join(get_movie_name(idx) for idx in premise)
conclusion_name = get_movie_name(conclusion)
print("Rule: If a person recommends {0} they will also recommend {1}".format(premise_names, conclusion_name))
print(" - Train Confidence: {0:.3f}".format(rule_confidence.get((premise, conclusion), -1)))
print(" - Test Confidence: {0:.3f}".format(test_confidence.get((premise, conclusion), -1)))
print("")
现在,我们可以看到哪些规则最适用于新的看不见的数据:
Rule #1
Rule: If a person recommends Shawshank Redemption, The (1994), Pulp Fiction (1994), Silence of the Lambs, The (1991), Star Wars (1977), Twelve Monkeys (1995) they will also recommend Raiders of the Lost Ark (1981)
- Train Confidence: 1.000
- Test Confidence: 0.909
Rule #2
Rule: If a person recommends Silence of the Lambs, The (1991), Fargo (1996), Empire Strikes Back, The (1980), Fugitive, The (1993), Star Wars (1977), Pulp Fiction (1994) they will also recommend Twelve Monkeys (1995)
- Train Confidence: 1.000
- Test Confidence: 0.609
Rule #3
Rule: If a person recommends Silence of the Lambs, The (1991), Empire Strikes Back, The (1980), Return of the Jedi (1983), Raiders of the Lost Ark (1981), Twelve Monkeys (1995) they will also recommend Star Wars (1977)
- Train Confidence: 1.000
- Test Confidence: 0.946
Rule #4
Rule: If a person recommends Shawshank Redemption, The (1994), Silence of the Lambs, The (1991), Fargo (1996), Twelve Monkeys (1995), Empire Strikes Back, The (1980), Star Wars (1977) they will also recommend Raiders of the Lost Ark (1981)
- Train Confidence: 1.000
- Test Confidence: 0.971
Rule #5
Rule: If a person recommends Shawshank Redemption, The (1994), Toy Story (1995), Twelve Monkeys (1995), Empire Strikes Back, The (1980), Fugitive, The (1993), Star Wars (1977) they will also recommend Return of the Jedi (1983)
- Train Confidence: 1.000
- Test Confidence: 0.900
例如,第二条规则对训练数据具有完全的信心,但仅在 60%的情况下对测试数据准确。 不过,前 10 名中的许多其他规则对测试数据都抱有很高的信心,这使它们成为提出建议的良好规则。
注意
如果您正在浏览其余规则,则某些规则的测试置信度为-1。 置信度值始终在 0 到 1 之间。该值表示在测试数据集中根本找不到该特定规则。



二十六、使用提升器提取特征
到目前为止,我们已经使用的数据集已按功能进行了描述。 在上一章中,我们使用了以事务为中心的数据集。 但是,最终这只是表示基于特征的数据的另一种格式。
还有许多其他类型的数据集,包括文本,图像,声音,电影,甚至是真实对象。 但是,大多数数据挖掘算法都依赖于具有数字或分类特征。 这意味着我们需要一种表示这些类型的方法,然后再将它们输入数据挖掘算法。
在本章中,我们将讨论如何提取数字和分类特征,并在拥有特征时选择最佳特征。 我们将讨论一些常见的特征提取模式和技术。
本章介绍的关键概念包括:
- 从数据集中提取特征
- 创建新功能
- 选择好的功能
- 为自定义数据集创建自己的转换器
特征提取
提取功能是数据挖掘中最关键的任务之一,并且与选择数据挖掘算法相比,它通常对最终结果的影响更大。 不幸的是,对于选择可导致高性能数据挖掘的功能没有严格的规定。 在许多方面,这就是数据挖掘科学成为一门艺术的地方。 创建好的功能依赖于直觉,领域专业知识,数据挖掘经验,反复试验,有时还需要运气。
代表模型中的现实
并非所有数据集都以特征表示。 有时,数据集仅由给定作者撰写的所有书籍组成。 有时,它是 1979 年发行的每部电影的电影。有时,它是有趣的历史文物的图书馆集合。
从这些数据集中,我们可能想要执行数据挖掘任务。 对于书籍,我们可能想知道作者撰写的不同类别。 在电影中,我们不妨看看如何描绘女性。 在历史文物中,我们可能想知道它们是否来自一个国家或另一个国家。 不能仅将这些原始数据集传递到决策树中并查看结果是什么。
为了在这里为我们提供帮助的数据挖掘算法,我们需要将其表示为特征。 特征是一种创建模型的方式,而模型以数据挖掘算法可以理解的方式提供了逼真的近似。 因此,模型只是现实世界中某些方面的简化版本。 例如,国际象棋是历史战争的简化模型。
选择功能还有另一个优点:将特征的复杂性降低到更易于管理的模型中。 想象一下,要正确,准确和完整地向不具备该项目背景知识的人描述真实世界的对象需要多少信息。 您需要描述尺寸,重量,质地,成分,年龄,缺陷,目的,来源等。
对于当前算法而言,真实对象的复杂性太大,因此我们改用这些更简单的模型。
这种简化还将我们的意图集中在数据挖掘应用中。 在后面的章节中,我们将研究集群及其在哪些方面至关重要。 如果放入随机特征,则会得到随机结果。
但是,这样做有一个弊端,因为这种简化会减少细节,或者可能会删除我们希望对其进行数据挖掘的事物的良好指示。
应该始终考虑如何以模型的形式表示现实。 您不仅需要使用过去使用过的方法,还需要考虑数据挖掘工作的目标。 您想达到什么目的? 在第 3 章,“用决策树”预测运动优胜者中,我们通过考虑目标(预测优胜者)来创建功能,并使用了一点点领域知识来提出新功能的想法。
注意
并非所有功能都必须是数字或分类的。 已经开发了可以直接在文本,图形和其他数据结构上运行的算法。 不幸的是,这些算法不在本模块的范围之内。 在本模块中,我们主要使用数字或分类特征。
Adult数据集是采用复杂现实并尝试使用功能对其建模的一个很好的例子。 在此数据集中,目标是估计某人的年收入是否超过 50,000 美元。 要下载数据集,请导航至这个页面,然后单击数据文件夹链接。 将adult.data和adult.names下载到数据文件夹中名为Adult的目录中。
该数据集承担一项复杂的任务,并在功能中对其进行了描述。 这些功能描述了人,他们的环境,他们的背景和他们的生活状况。
在本章中打开一个新的 IPython Notebook,并设置数据的文件名并导入 pandas 以加载文件:
import os
import pandas as pd
data_folder = os.path.join(os.path.expanduser("~"), "Data", "Adult")
adult_filename = os.path.join(data_folder, "adult.data")
Using pandas as before, we load the file with read_csv:
adult = pd.read_csv(adult_filename, header=None,
names=["Age", "Work-Class", "fnlwgt",
"Education", "Education-Num",
"Marital-Status", "Occupation",
"Relationship", "Race", "Sex",
"Capital-gain", "Capital-loss",
"Hours-per-week", "Native-Country",
"Earnings-Raw"])
大多数代码与前面的章节相同。
adult文件本身在文件末尾包含两个空行。 默认情况下,Pandas 会将倒数第二行解释为空(但有效)行。 要删除此行,我们删除任何行号无效的行(使用inplace只是确保同一数据框受到影响,而不是创建一个新的行):
adult.dropna(how='all', inplace=True)
看一下数据集,我们可以从adult.columns中看到各种功能:
adult.columns
结果显示了存储在pandas中Index对象中的每个功能名称:
Index(['Age', 'Work-Class', 'fnlwgt', 'Education', 'Education-Num', 'Marital-Status', 'Occupation', 'Relationship', 'Race', 'Sex', 'Capital-gain', 'Capital-loss', 'Hours-per-week', 'Native-Country', 'Earnings-Raw'], dtype='object')
共同特征模式
尽管有数百万种创建特征的方法,但跨不同学科采用的某些常见模式。 但是,选择合适的功能很棘手,值得考虑一下功能如何与最终结果相关。 就像谚语所说的那样,不要凭封面来判断一本书-如果您对其中包含的信息感兴趣,那么就不值得考虑一本书的大小。
一些常用功能着眼于正在研究的现实世界对象的物理属性,例如:
- 空间属性,例如对象的长度,宽度和高度
- 物体的重量和/或密度
- 对象或其组件的年龄
- 对象的类型
- 物体的质量
其他功能可能取决于对象的用法或历史记录:
- 对象的生产者,发行者或创建者
- 制造年份
- 使用对象
其他功能根据其组成部分描述数据集:
- 给定子组件的频率,例如书中的单词
- 子组件数和/或不同子组件数
- 子组件的平均大小,例如平均句子长度
序数功能使我们能够对相似值进行排名,排序和分组。 正如我们在前几章中看到的那样,特征可以是数字的或分类的。 数字特征通常被描述为序数。 例如,三个人爱丽丝,鲍勃和查理可能身高分别为 1.5 m,1.6 m 和 1.7 m。 我们可以说爱丽丝和鲍勃的身高比爱丽丝和查理高。
我们在上一节中加载的 Adult 数据集包含连续,序数特征的示例。 例如,Hours-per-week功能可跟踪人们每周工作多少小时。 在这样的功能上,某些操作是有意义的。 它们包括计算平均值,标准偏差,最小值和最大值。 Pandas 中有一个函数可以提供这种类型的一些基本摘要统计信息:
adult["Hours-per-week"].describe()
结果告诉我们有关此功能的一些信息。
count 32561.000000
mean 40.437456
std 12.347429
min 1.000000
25% 40.000000
50% 40.000000
75% 45.000000
max 99.000000
dtype: float64
这些操作中的某些对其他功能没有意义。 例如,计算教育状况的总和是没有意义的。
也有一些特征不是数字的,但仍然是序数。 Adult 数据集中的Education功能就是一个示例。 例如,学士学位比未完成高中的学历更高,而未完成高中的学历更高。 计算这些值的平均值并没有多大意义,但是我们可以通过取中值来创建一个近似值。 该数据集提供了一个有用的功能Education-Num,该功能分配的数字基本上等于完成的教育年限。 这使我们可以快速计算中位数:
adult["Education-Num"].median()
结果是 10,或高中毕业一年。 如果没有,我们可以通过对教育值进行排序来计算中位数。
功能也可以是分类的。 例如,球可以是网球,板球,足球或任何其他类型的球。 分类特征也称为标称特征。 对于名义特征,值可以相同或不同。 尽管我们可以按大小或重量对球进行排名,但仅凭类别还不足以比较事物。 网球不是板球,也不是足球。 我们可以争辩说,网球与板球(在尺寸上)更相似,但仅凭类别并不能与之区分开来-它们是相同的,或者不是。
如第 3 章,“用决策树预测体育获胜者”中所述,我们可以使用一键编码将类别特征转换为数字特征。 对于上述类别的球,我们可以创建三个新的二进制特征:is a tennis ball,is a cricket ball和is a football。 对于网球,向量将为[1、0、0]。 板球的值为[0,1,0],而足球的值为[0,0,1]。 这些特征是二进制的,但是许多算法都可以将它们用作连续特征。 这样做的一个关键原因是它很容易实现直接的数值比较(例如计算样本之间的距离)。
Adult 数据集包含几个分类特征,例如Work-Class。 虽然我们可以争辩说某些价值观比其他价值观具有更高的地位(例如,有工作的人可能比没有工作的人收入更高),但这并非对所有价值观都有意义。 例如,在州政府工作的人比在私营部门工作的人收入的可能性不大或少。
我们可以使用unique()函数在数据集中查看此功能的唯一值:
adult["Work-Class"].unique()
结果在此列中显示唯一值:
array([' State-gov', ' Self-emp-not-inc', ' Private', ' Federal-gov',
' Local-gov', ' ?', ' Self-emp-inc', ' Without-pay',
' Never-worked', nan], dtype=object)
前面的数据集中有一些缺失值,但是在此示例中它们不会影响我们的计算。
类似地,我们可以通过称为离散化的过程将数字特征转换为分类特征,正如我们在第 4 章,“使用亲和力分析”推荐电影中所看到的那样。 我们可以称呼任何身高高于 1.7 m 的人,以及任何身高小于 1.7 m 的人。 这给了我们一种分类特征(尽管仍然是序数特征)。 我们在这里确实丢失了一些数据。 例如,两个人,一个身高 1.69 m,一个身高 1.71 m,将处于两个不同的类别,并且认为彼此完全不同。 相反,身高 1.2 m 的人将被认为与身高 1.69 m 的人“大致相同的身高”! 细节上的损失是离散化的副作用,这是我们在创建模型时要处理的问题。
在成人数据集中,我们可以创建LongHours功能,该功能可以告诉我们一个人每周工作时间是否超过 40 小时。 这将我们的连续功能(Hours-per-week)变成了一个绝对的功能:
adult["LongHours"] = adult["Hours-per-week"] > 40
创建良好的功能
建模以及简化所导致的信息丢失是我们没有可以仅应用于任何数据集的数据挖掘方法的原因。 优秀的数据挖掘从业人员将在其应用数据挖掘的领域中拥有或获得领域知识。 他们将研究问题,可用的数据,并提出一个代表他们试图实现的目标的模型。
例如,身高特征可能描述一个人的一个组成部分,但可能无法很好地描述他们的学习成绩。 如果我们试图预测一个人的等级,我们可能不会费心测量每个人的身高。
在这里,数据挖掘变得比艺术更具艺术性。 提取好的特征是困难的,并且是正在进行的重要研究的主题。 选择更好的分类算法可以提高数据挖掘应用的性能,但是选择更好的功能通常是更好的选择。
在所有数据挖掘应用中,您应先概述要查找的内容,然后再开始设计可以找到它的方法。 这将决定您要针对的功能类型,可以使用的算法类型以及对最终结果的期望。

功能选择
通常会有很多功能可供选择,但是我们只希望选择一小部分。 有许多可能的原因:
- 降低复杂度:随着数据数量的增加,许多数据挖掘算法需要更多的时间和资源。 减少功能部件的数量是一种使算法运行更快或资源更少的好方法。
- 降低噪音:添加额外的功能并不总是可以带来更好的性能。 额外的功能可能会使算法感到困惑,从而发现没有意义的相关性和模式(这在较小的数据集中很常见)。 仅选择适当的特征是减少没有实际意义的随机相关性的好方法。
- 创建可读模型:尽管许多数据挖掘算法将为具有数千个特征的模型愉快地计算答案,但结果可能难以为人类解释。 在这些情况下,值得使用较少的功能并创建人类可以理解的模型。
一些分类算法可以处理诸如此类的数据。 获得正确的数据并获得有效描述正在建模的数据集的功能仍可以帮助算法。
我们可以执行一些基本测试,例如确保功能至少有所不同。 如果要素的值都相同,则无法为我们提供额外的信息来执行数据挖掘。
例如,scikit-learn 中的VarianceThreshold转换器将删除至少没有最小方差值的任何功能。 为了展示它是如何工作的,我们首先使用 NumPy 创建一个简单的矩阵:
import numpy as np
X = np.arange(30).reshape((10, 3))
结果是三列 10 行中的数字 0 到 29。 这代表具有 10 个样本和三个特征的综合数据集:
array([[ 0, 1, 2],
[ 3, 4, 5],
[ 6, 7, 8],
[ 9, 10, 11],
[12, 13, 14],
[15, 16, 17],
[18, 19, 20],
[21, 22, 23],
[24, 25, 26],
[27, 28, 29]])
然后,我们将整个第二列/功能设置为值 1:
X[:,1] = 1
结果在第一行和第三行中有很多方差,但是在第二行中没有方差:
array([[ 0, 1, 2],
[ 3, 1, 5],
[ 6, 1, 8],
[ 9, 1, 11],
[12, 1, 14],
[15, 1, 17],
[18, 1, 20],
[21, 1, 23],
[24, 1, 26],
[27, 1, 29]])
现在,我们可以创建一个VarianceThreshold转换器并将其应用于我们的数据集:
from sklearn.feature_selection import VarianceThreshold
vt = VarianceThreshold()
Xt = vt.fit_transform(X)
现在,结果Xt没有第二列:
array([[ 0, 2],
[ 3, 5],
[ 6, 8],
[ 9, 11],
[12, 14],
[15, 17],
[18, 20],
[21, 23],
[24, 26],
[27, 29]])
我们可以通过打印vt.variances_属性来观察每一列的方差:
print(vt.variances_)
结果表明,尽管第一列和第三列至少包含一些信息,但第二列没有差异:
array([ 74.25, 0\. , 74.25])
像这样的简单明了的测试总是很适合在初次查看数据时运行。 没有差异的要素不会为数据挖掘应用增加任何价值; 但是,它们会降低算法的性能。
选择最佳的个人功能
如果我们具有许多功能,那么找到最佳子集的问题将是一项艰巨的任务。 它涉及多次解决数据挖掘问题本身。 正如我们在第 4 章,“使用亲和力分析推荐电影”一样,基于子集的任务随着特征数量的增加而呈指数增长。 寻找所需的最佳特征子集所需的时间呈指数增长。
解决此问题的方法不是寻找一个可以协同工作的子集,而不仅仅是寻找最佳的个别功能。 这个单变量特征选择会根据一个特征本身的执行情况给我们一个分数。 这通常是针对分类任务完成的,并且我们通常会测量变量和目标类之间的某种类型的相关性。
scikit-learn程序包具有许多用于执行单变量特征选择的转换器。 它们包括SelectKBest和SelectPercentile,它们返回k最佳性能特征,而SelectPercentile则返回最高 r%特征。 在这两种情况下,都有许多计算要素质量的方法。
有许多不同的方法可以计算单个要素与类值的关联效率。 常用的方法是卡方检验(χ2)。 其他方法包括互信息和熵。
我们可以使用Adult数据集观察单个功能的测试。 首先,我们从 PandasDataFrame中提取数据集和类值。 我们可以选择以下功能:
X = adult[["Age", "Education-Num", "Capital-gain", "Capital-loss", "Hours-per-week"]].values
我们还将通过测试Earnings-Raw值是否超过$ 50,000 来创建目标类数组。 如果是,则类别为True。 否则为False。 让我们看一下代码:
y = (adult["Earnings-Raw"] == ' >50K').values
接下来,我们使用chi2函数和SelectKBest提升器创建提升器:
from sklearn.feature_selection import SelectKBest
from sklearn.feature_selection import chi2
transformer = SelectKBest(score_func=chi2, k=3)
运行fit_transform将调用 fit,然后使用相同的数据集进行转换。 结果将创建一个新的数据集,仅选择最佳的三个特征。 让我们看一下代码:
Xt_chi2 = transformer.fit_transform(X, y)
现在,生成的矩阵仅包含三个特征。 我们还可以获取每列的分数,从而找出所使用的功能。 让我们看一下代码:
print(transformer.scores_)
列印结果给我们这些分数:
[ 8.60061182e+03 2.40142178e+03 8.21924671e+07 1.37214589e+06
6.47640900e+03]
第一,第三和第四列的最大值与Age,Capital-Gain和Capital-Loss功能相关。 基于单变量特征选择,这些是最佳选择。
注意
如果您想了解有关成人数据集中特征的更多信息,请查看数据集随附的adult.names文件及其引用的学术论文。
我们还可以实现其他相关性,例如 Pearson 相关性系数。 这在 SciPy 中实现,SciPy 是用于科学计算的库(scikit-learn 将其用作基础)。
注意
如果您的计算机上正在运行 scikit-learn,则 SciPy 也是如此。 您无需进一步安装任何组件即可使此示例正常工作。
首先,我们从 SciPy 导入pearsonr函数:
from scipy.stats import pearsonr
前面的功能几乎适合 scikit-learn 的单变量转换器所需的接口。 该函数需要接受两个数组(在我们的示例中为x和y)作为参数,并返回两个数组,即每个要素的得分和相应的 p 值。 我们先前使用的chi2函数仅使用所需的接口,这使我们可以将其直接传递给 SelectKBest。
SciPy 中的pearsonr函数接受两个数组。 但是,它接受的 X 数组只是一个维度。 我们将编写一个包装函数,使我们可以将其用于多变量数组,就像我们拥有的那样。 让我们看一下代码:
def multivariate_pearsonr(X, y):
我们创建scores和pvalues数组,然后遍历数据集的每一列:
scores, pvalues = [], []
for column in range(X.shape[1]):
我们仅计算此列的 Pearson 相关性,并记录得分和 p 值。
cur_score, cur_p = pearsonr(X[:,column], y)
scores.append(abs(cur_score))
pvalues.append(cur_p)
注意
皮尔逊值可以在-1和1之间。 1的值表示两个变量之间的完美相关,而值-1 表示一个完美的负相关,即,一个变量的高值给出另一个变量的低相关值,反之亦然。 拥有这些功能确实很有用,但是将被丢弃。 因此,我们将绝对值而不是原始有符号值存储在scores数组中。
最后,我们在一个元组中返回分数和p-values:
return (np.array(scores), np.array(pvalues))
现在,我们可以像以前一样使用变形器类使用 Pearson 相关系数对特征进行排名:
transformer = SelectKBest(score_func=multivariate_pearsonr, k=3)
Xt_pearson = transformer.fit_transform(X, y)
print(transformer.scores_)
这将返回一组不同的功能! 以这种方式选择的功能是第一,第二和第五列:Age,Education和Hours-per-week有效。 这表明最好的功能是什么并没有确切的答案,这取决于度量标准。
通过分类器运行它们,我们可以看到哪个更好。 请记住,结果仅表明哪个子集更适合特定的分类器和/或特征组合-在数据挖掘中很少有一种情况在所有情况下均严格优于另一种方法! 让我们看一下代码:
from sklearn.tree import DecisionTreeClassifier
from sklearn.cross_validation import cross_val_score
clf = DecisionTreeClassifier(random_state=14)
scores_chi2 = cross_val_score(clf, Xt_chi2, y, scoring='accuracy')
scores_pearson = cross_val_score(clf, Xt_pearson, y, scoing='accuracy')
这里的chi2平均值为 0.83,而 Pearson 得分较低,为 0.77。 对于此组合,chi2返回更好的结果!
值得记住此数据挖掘活动的目标:预测财富。 结合良好的功能和功能选择,仅使用一个人的三个功能就可以达到 83%的准确性!

功能创建
有时,仅从现有功能中选择功能是不够的。 我们可以用不同于已有的功能来创建功能。 我们之前看到的单热编码方法就是一个例子。 代替使用带有A,B和C选项的类别特征,我们将创建三个新特征是 A 吗?,是 B 吗? 和是 C 吗?。
创建新功能似乎没有必要,也没有明显的好处-毕竟,信息已经在数据集中,我们只需要使用它即可。 但是,某些算法在要素之间具有显着相关性或存在冗余要素时会遇到困难。 如果有多余的功能,它们也可能会遇到困难。
因此,有多种方法可以从现有功能中创建新功能。
我们将加载一个新的数据集,因此现在是启动一个新的 IPython Notebook 的好时机。 从这个页面下载广告数据集,并将其保存到Data文件夹中。
接下来,我们需要用 Pandas 加载数据集。 首先,我们像往常一样设置数据的文件名:
import os
import numpy as np
import pandas as pd
data_folder = os.path.join(os.path.expanduser("~"), "Data")
data_filename = os.path.join(data_folder, "Ads", "ad.data")
此数据集存在两个问题,使我们无法轻松加载它。 首先,前几个特征是数字,但是 Pandas 会将它们作为字符串加载。 为了解决这个问题,我们需要编写一个转换函数,该函数将字符串转换为数字(如果可能)。 否则,我们将得到 NaN(这是的缩写,不是数字),这是一个特殊的值,指示该值不能解释为数字。 与其他编程语言中的无或 null 相似。
该数据集的另一个问题是缺少某些值。 这些在数据集中使用字符串?表示。 幸运的是,问号不会转换为浮点数,因此我们可以使用相同的概念将其转换为 NaN。 在后续的章节中,我们将探讨处理此类缺失值的其他方法。
我们将创建一个函数来为我们执行此转换:
def convert_number(x):
首先,我们要将字符串转换为数字,然后查看是否失败。 然后,我们将转换包含在try/except块中,并捕获ValueError异常(如果无法通过这种方式将字符串转换为数字,则会抛出该异常):
try:
return float(x)
except ValueError:
最后,如果转换失败,我们将从先前导入的 NumPy 库中获得一个 NaN:
return np.nan
现在,我们为转换创建字典。 我们希望将所有功能转换为浮点数:
converters = defaultdict(convert_number
另外,我们想将最后一列(列索引#1558)设置为二进制功能。 在成人数据集中,我们为此创建了一个新功能。 在数据集中,我们将在加载特征时对其进行转换。
converters[1558] = lambda x: 1 if x.strip() == "ad." else 0
现在我们可以使用read_csv加载数据集。 我们使用 converters 参数将自定义转换传递给 Pandas:
ads = pd.read_csv(data_filename, header=None, converters=converters)
结果数据集非常大,具有 1,559 个要素和 2,000 多个行。 以下是通过将ads[:5]插入新单元格来打印的前五个特征值:

数据集描述了网站上的图像,目的是确定给定图像是否为广告。
该数据集中的特征未按其标题很好地描述。 ad.data文件随附两个文件,它们具有更多信息:ad.DOCUMENTATION和ad.names。 前三个功能是图像尺寸的高度,宽度和比率。 如果是广告,则最终功能为 1,否则为 0。
其他功能是 1,表示 URL 中的某些单词,替代文本或图像的标题。 这些单词(例如单词赞助商)用于确定图片是否可能是广告。 许多功能重叠很多,因为它们是其他功能的组合。 因此,该数据集具有很多冗余信息。
将数据集加载到 Pandas 中后,我们现在将提取x和y数据用于分类算法。 x矩阵将是我们数据框中的所有列,但最后一列除外。 相反,y 数组将仅是最后一列(特征#1558)。 让我们看一下代码:
X = ads.drop(1558, axis=1).values
y = ads[1558]
创建自己的转换器
随着复杂性和数据集类型的变化,您可能会发现找不到适合您需求的现有特征提取转换器。 我们将在第 7 章,“使用图形挖掘”发现要遵循的帐户中看到一个示例,在此我们从图形中创建新功能。
提升器类似于转换功能。 它以一种形式的数据作为输入,并以另一种形式的数据作为输出。 可以使用一些训练数据集来训练提升器,并且可以使用这些训练后的参数来转换测试数据。
转换器 API 非常简单。 它以特定格式的数据作为输入,并返回另一种格式(与输入相同或不同)的数据作为输出。 程序员不需要太多其他内容。

提升器 API
提升器具有两个关键功能:
fit():此以训练数据集作为输入并设置内部参数transform():此本身执行转换。 这可以采用训练数据集,也可以采用相同格式的新数据集
fit()和transform()功能应采用与输入相同的数据类型,但是transform()可以返回不同类型的数据。
我们将创建一个琐碎的转换器来展示该 API 的作用。 转换器将 NumPy 数组作为输入,并根据均值将其离散化。 高于(训练数据的)平均值的任何值将被赋予值 1,而低于或等于平均值的任何值将被赋予值 0。
我们使用 Pandas 对 Adult 数据集进行了相似的转换:如果值大于每周 40 小时,则采用Hours-per-week功能并创建了LongHours功能。 该提升器不同有两个原因。 首先,代码将符合 scikit-learn API,使我们可以在管道中使用它。 第二,代码将学习平均值,而不是将其作为固定值(例如LongHours示例中为 40)。
实施细节
首先,打开我们用于成人数据集的 IPython Notebook 的。 然后,单击单元菜单项,然后选择 Run All。 这将重新运行所有单元,并确保笔记本计算机是最新的。
首先,我们导入TransformerMixin,这将为我们设置 API。 尽管 Python 没有严格的接口(与 Java 之类的语言相对),但使用mixin这样的 scikit-learn 可以确定该类实际上是一个转换器。 我们还需要导入一个函数来检查输入的有效类型。 我们将尽快使用它。
让我们看一下代码:
from sklearn.base import TransformerMixin
from sklearn.utils import as_float_array
现在,创建一个新类,作为我们mixin的子类:
class MeanDiscrete(TransformerMixin):
我们需要定义 fit 和 transform 函数以符合 API。 在fit函数中,我们找到数据集的平均值,并设置一个内部变量以记住该值。 让我们看一下代码:
def fit(self, X):
首先,我们使用as_float_array函数确保X是我们可以使用的数据集(如果可以,例如X是浮点列表,它也会转换X):
X = as_float_array(X)
接下来,我们计算数组的平均值并设置一个内部参数以记住该值。 当X是一个多元数组时,self.mean将是一个包含每个特征均值的数组:
self.mean = X.mean(axis=0)
fit函数还需要返回类本身。 此要求确保我们可以在转换器中执行功能链(例如调用transformer.fit(X).transform(X))。 让我们看一下代码:
return self
接下来,我们定义转换函数,它采用与拟合函数相同类型的数据集,因此我们需要检查是否输入正确:
def transform(self, X):
X = as_float_array(X)
我们也应该在这里执行另一项检查。 尽管我们需要输入为 NumPy 数组(或等效数据结构),但形状也必须保持一致。 该数组中的要素数量必须与该类在其上进行训练的要素数量相同。
assert X.shape[1] == self.mean.shape[0]
现在,我们仅通过测试X中的值是否大于存储的平均值来执行实际的转换。
return X > self.mean
然后,我们可以创建此类的实例,并使用它来转换我们的X数组:
mean_discrete = MeanDiscrete()
X_mean = mean_discrete.fit_transform(X)
单元测试
当创建自己的函数和类时,进行单元测试总是一个好主意。 单元测试旨在测试代码的单个单元。 在这种情况下,我们要测试我们的提升器是否按需进行。
好的测试应该是独立可验证的。 确认测试合法性的一个好方法是使用另一种计算机语言或方法来执行计算。 在这种情况下,我使用 Excel 创建数据集,然后计算每个单元的平均值。 然后将这些值转移到此处。
单元测试也应该很小并且可以快速运行。 因此,所使用的任何数据都应具有较小的大小。 我用于创建测试的数据集存储在先前的Xt变量中,我们将在测试中重新创建它。 这两个特征的平均值分别为 13.5 和 15.5。
为了创建我们的单元测试,我们从 NumPy 的测试中导入assert_array_equal函数,该函数检查两个数组是否相等:
from numpy.testing import assert_array_equal
接下来,我们创建函数。 测试名称以test_开头很重要,因为此名称是用于自动查找和运行测试的工具。 我们还设置了测试数据:
def test_meandiscrete():
X_test = np.array([[ 0, 2],
[ 3, 5],
[ 6, 8],
[ 9, 11],
[12, 14],
[15, 17],
[18, 20],
[21, 23],
[24, 26],
[27, 29]])
然后,我们创建提升器实例并使用以下测试数据进行拟合:
mean_discrete = MeanDiscrete()
mean_discrete.fit(X_test)
接下来,我们将内部均值参数与我们独立验证的结果进行比较,以检查内部均值参数是否设置正确:
assert_array_equal(mean_discrete.mean, np.array([13.5, 15.5]))
然后,我们运行转换以创建转换后的数据集。 我们还使用输出的期望值创建一个(独立计算的)数组:
X_transformed = mean_discrete.transform(X_test)
X_expected = np.array([[ 0, 0],
[ 0, 0],
[ 0, 0],
[ 0, 0],
[ 0, 0],
[ 1, 1],
[ 1, 1],
[ 1, 1],
[ 1, 1],
[ 1, 1]])
最后,我们测试返回的结果确实是我们期望的结果:
assert_array_equal(X_transformed, X_expected)
我们可以通过简单地运行函数本身来运行测试:
test_meandiscrete()
如果没有错误,则测试运行没有问题! 您可以通过将某些测试更改为故意不正确的值并查看测试失败来验证这一点。 记住将它们改回来,以便测试通过。
如果我们有多个测试,那么值得使用一个名为nose的测试框架来运行我们的测试。
全部放在一起
现在我们已经测试了提升器,是时候将其付诸实践了。 使用到目前为止所学的知识,我们创建Pipeline,第一步设置为MeanDiscrete转换器,第二步设置为决策树分类器。 然后,我们进行交叉验证并打印出结果。 让我们看一下代码:
from sklearn.pipeline import Pipeline
pipeline = Pipeline([('mean_discrete', MeanDiscrete()),
('classifier', DecisionTreeClassifier(random_state=14))])
scores_mean_discrete = cross_val_score(pipeline, X, y, scoring='accuracy')
print("Mean Discrete performance:
{0:.3f}".format(scores_mean_discrete.mean()))
结果为 0.803,不如以前好,但对于简单的二进制功能也不错。



二十七、将朴素贝叶斯用于社交媒体洞察
基于文本的数据集包含许多信息,无论它们是书籍,历史文档,社交媒体,电子邮件还是我们通过书面形式进行交流的任何其他方式。 从基于文本的数据集中提取特征并将其用于分类是一个难题。 但是,有一些常见的文本挖掘模式。
我们使用朴素贝叶斯算法(Naive Bayes algorithm)来查看社交媒体中的歧义术语,该算法是一种功能强大且令人惊讶的简单算法。 朴素贝叶斯采取了一些捷径来正确计算分类的概率,因此名称中的术语朴素。 它也可以很容易地扩展到其他类型的数据集,而不依赖于数字特征。 本章中的模型是文本挖掘研究的基线,因为该过程对于各种数据集都可以很好地运行。
我们将在本章介绍以下主题:
- 从社交网络 API 下载数据
- 文字变形金刚
- 朴素贝叶斯分类器
- 使用 JSON 保存和加载数据集
- NLTK 库,用于从文本中提取特征
- 评估的 F 量度
消除歧义
文本通常为,称为非结构化格式。 那里有很多信息,但那里只是; 没有标题,没有要求的格式,宽松的语法以及等其他问题,使得无法轻松地从文本中提取信息。 数据之间也是高度关联的,有很多提及和交叉引用-只是格式不便于我们提取!
我们可以将书籍中存储的信息与大型数据库中存储的信息进行比较,以了解两者之间的区别。 书中有人物,主题,地点和许多信息。 但是,需要阅读本书,更重要的是,要对其进行解释才能获得此信息。 数据库位于您的服务器上,具有列名和数据类型。 所有信息都在那里,并且所需的解释水平很低。 有关数据的信息(例如其类型或含义)称为元数据,而文本则缺少该信息。 一本书还包含一些表格形式的内容和索引元数据,但程度明显低于数据库。
问题之一是术语消除歧义。 当一个人使用银行这个词时,这是金融消息还是环境消息(例如河岸)? 在许多情况下,这种类型的歧义消除对于人类来说是很容易的(尽管仍然存在麻烦),但是对于计算机而言,消除歧义却要困难得多。
在本章中,我们将消除在 Twitter 流中使用 Python 一词的歧义。 Twitter 上的消息称为推文,并且限制为 140 个字符。 这意味着没有上下文的空间。 尽管通常使用井号来表示推文的主题,但可用的元数据很少。
当人们谈论 Python 时,他们可能在谈论以下事情:
- 编程语言 Python
- 经典喜剧团 Monty Python
- 蛇蟒
- 一双叫做 Python 的鞋子
可能还有许多其他东西叫做 Python。 我们实验的目的是仅通过一条推文的内容来提及 Python,并确定它是否在谈论编程语言。
从社交网络下载数据
我们将要从 Twitter 下载数据集,并使用它从有用的内容中清除垃圾邮件。 Twitter 提供了一个健壮的 API,用于从其服务器收集信息,该 API 对于小规模使用是免费的。 但是,如果您开始在商业环境中使用 Twitter 的数据,则需要注意一些条件。
首先,您需要注册一个 Twitter 帐户(免费)。 如果您还没有帐户,请访问这个页面并注册一个帐户。
接下来,您需要确保每分钟仅发出一定数量的请求。 目前,该限制为每小时 180 个请求。 确保不违反此限制可能很棘手,因此强烈建议您使用库与 Twitter 的 API 进行通信。
您将需要一个密钥来访问 Twitter 的数据。 转到这个页面并登录到您的帐户。
当您登录后,转到这个页面并单击创建新应用。
为您的应用创建的名称和说明以及网站地址。 如果您没有要使用的网站,请插入一个占位符。 将此应用的回调 URL 字段保留为空白-我们将不需要它。 同意使用条款(如果需要),然后单击创建您的 Twitter 应用。
保持结果网站保持打开状态–您需要此页面上的访问密钥。 接下来,我们需要一个图书馆与 Twitter 对话。 有很多选择。 我喜欢的一个简称为twitter,是官方 Twitter Python 库。
注意
如果要使用pip至安装软件包,则可以使用pip3 install twitter安装twitter。 如果您使用的是其他系统,请查看这个页面上的文档。
创建一个新的 IPython Notebook 以下载数据。 我们将在本章中出于不同的目的创建多个笔记本,因此最好创建一个文件夹来跟踪它们。 第一个笔记本ch6_get_twitter专用于下载新的 Twitter 数据。
首先,我们导入twitter库并设置我们的授权令牌。 消费者密钥,消费者秘密将在 Twitter 应用页面的密钥和访问令牌选项卡上可用。 要获取访问令牌,您需要单击同一页面上的创建我的访问令牌按钮。 在以下代码中将密钥输入适当的位置:
import twitter
consumer_key = "<Your Consumer Key Here>"
consumer_secret = "<Your Consumer Secret Here>"
access_token = "<Your Access Token Here>"
access_token_secret = "<Your Access Token Secret Here>"
authorization = twitter.OAuth(access_token, access_token_secret, consumer_key, consumer_secret)
我们将从 Twitter 的search函数获得推文。 我们将创建一个使用我们的授权连接到twitter的阅读器,然后使用该阅读器执行搜索。 在笔记本中,我们设置将存储推文的文件名:
import os
output_filename = os.path.join(os.path.expanduser("~"), "Data", "twitter", "python_tweets.json")
我们还需要json库来保存我们的推文:
import json
接下来,创建一个可以从 Twitter 读取的对象。 我们使用之前设置的授权对象创建该对象:
t = twitter.Twitter(auth=authorization)
然后,我们打开输出文件进行写入。 我们打开它进行追加,这使我们可以重新运行脚本以获得更多推文。 然后,我们使用 Twitter 连接对 Python 进行搜索。 我们只想要为数据集返回的状态。 该代码使用了 tweet,使用json库通过dumps函数创建字符串表示形式,然后将其写入文件中。 然后,它在 tweet 下创建一个空白行,以便我们可以轻松地区分一条 tweet 在文件中的开始和结束位置:
with open(output_filename, 'a') as output_file:
search_results = t.search.tweets(q="python", count=100)['statuses']
for tweet in search_results:
if 'text' in tweet:
output_file.write(json.dumps(tweet))
output_file.write("\n\n")
在前面的循环中,我们还执行检查以查看推文中是否有文本。 并非 twitter 返回的所有对象都是实际的 tweet(有些是删除 tweet 的动作,另一些是删除 tweet 的动作)。 关键的区别在于将文本作为密钥,我们对其进行了测试。
运行此命令几分钟将导致 100 条推文添加到输出文件中。
注意
您可以继续运行此脚本以向数据集中添加更多推文,请记住,如果重新运行速度太快(即在 Twitter 获得新的推文返回之前),您可能会在输出文件中获得一些重复项。
加载和分类数据集
在收集了一组推文(我们的数据集)之后,需要标签进行分类。 我们将通过在 IPython Notebook 中设置表单来标记数据集,以允许我们输入标签。
我们以 JSON 格式存储的数据集接近。 JSON 是一种数据格式,它不要求太多结构,并且可以在 JavaScript 中直接读取(因此,其名称为 JavaScript Object Notation)。 JSON 定义了基本对象,例如数字,字符串,列表和字典,如果它们包含非数字数据,则使其成为存储数据集的一种很好的格式。 如果数据集是完全数值的,则可以使用基于矩阵的格式(如 NumPy)来节省空间和时间。
我们的数据集与实际 JSON 之间的关键区别在于,我们在 tweet 之间添加了新行。 这样做的原因是允许我们轻松添加新的 tweet(实际的 JSON 格式不允许这样做)。 我们的格式是 tweet 的 JSON 表示形式,其后是换行符,然后是下一个 tweet,依此类推。
为了解析它,我们可以使用json库,但是我们必须首先用换行符分割文件,以获取实际的 tweet 对象本身。
设置一个新的 IPython Notebook(我叫我的ch6_label_twitter)并输入数据集的文件名。 这与上一部分中保存数据的文件名相同。 我们还定义了用于保存标签的文件名。 代码如下:
import os
input_filename = os.path.join(os.path.expanduser("~"), "Data", "twitter", "python_tweets.json")
labels_filename = os.path.join(os.path.expanduser("~"), "Data", "twitter", "python_classes.json")
如前所述,我们将使用json库,因此也要导入该库:
import json
我们创建一个列表,该列表将存储从文件中收到的推文:
tweets = []
然后,我们遍历文件中的每一行。 我们对没有信息的行不感兴趣(它们为我们分开了 tweet),因此请检查行的长度(减去任何空白字符)是否为零。 如果是,请忽略它并移至下一行。 否则,请使用json.loads(从字符串中加载 JSON 对象)加载推文,并将其添加到我们的推文列表中。 代码如下:
with open(input_filename) as inf:
for line in inf:
if len(line.strip()) == 0:
continue
tweets.append(json.loads(line))
现在,我们对分类某项是否与我们相关感兴趣(在这种情况下,相关意味着指的是编程语言 Python )。 我们将使用 IPython Notebook 的功能来嵌入 HTML,并在 JavaScript 和 Python 之间进行对话,以创建推文查看器,从而使我们能够轻松,快速地将推文归为垃圾邮件。
该代码将向用户(您)显示一条新推文,并要求提供标签:是否相关? 然后它将存储输入并显示下一个要标记的推文。
首先,我们创建一个存储标签的列表。 无论给定的推文是否引用编程语言 Python,这些标签都将被存储,这将使我们的分类器能够学习如何区分含义。
我们还将检查是否已经有任何标签并加载它们。 如果您需要在贴标签的中途关闭笔记本计算机,这将很有帮助。 此代码将从停止的地方加载标签。 通常,最好考虑如何在中点保存此类任务。 没有什么比损失一个小时的工作更令人痛心的了,因为在保存标签之前计算机崩溃了! 代码如下:
labels = []
if os.path.exists(labels_filename):
with open(labels_filename) as inf:
labels = json.load(inf)
接下来,我们创建一个简单的函数,该函数将返回下一个需要标记的推文。 我们可以通过找到尚未标记的第一条推文来算出下一条推文。 代码如下:
def get_next_tweet():
return tweet_sample[len(labels)]['text']
注意
我们实验的下一步是从用户(您!)收集信息,其中哪些推文是指 Python(编程语言),哪些不是。 到目前为止,在 IPython Notebook 中还没有一种简单明了的纯 Python 交互式反馈方式。 因此,我们将使用一些 JavaScript 和 HTML 从用户那里获得输入。
接下来,我们在 IPython Notebook 中创建一些 JavaScript 以运行我们的输入。 笔记本允许我们使用魔术功能将 HTML 和 JavaScript(以及其他功能)直接嵌入到笔记本本身中。 从顶部的以下行开始新的单元格:
%%javascript
此处的代码将使用 JavaScript,因此出现了花括号。 不用担心,我们将很快返回 Python。 请记住,以下代码必须与%%javascript魔术函数位于同一单元格中。
我们将在 JavaScript 中定义的第一个函数显示了从 IPython Notebooks 中的 JavaScript 与您的 Python 代码进行对话有多么容易。 如果调用此函数,它将在labels数组中添加标签(在python代码中)。 为此,我们将 IPython 内核加载为 JavaScript 对象,并为其提供 Python 命令来执行。 代码如下:
function set_label(label){
var kernel = IPython.notebook.kernel;
kernel.execute("labels.append(" + label + ")");
load_next_tweet();
}
在该函数的结尾,我们调用load_next_tweet函数。 此功能加载要标记的下一条推文。 它以相同的原理运行; 我们加载 IPython 内核并为其执行命令(调用我们先前定义的get_next_tweet函数)。
但是,在中,我们想要得到结果。 这有点困难。 我们需要定义一个callback,这是一个在返回数据时调用的函数。 定义callback的格式超出了该模块的范围。 如果您对更高级的 JavaScript / Python 集成感兴趣,请查阅 IPython 文档。
代码如下:
function load_next_tweet(){
var code_input = "get_next_tweet()";
var kernel = IPython.notebook.kernel;
var callbacks = { 'iopub' : {'output' : handle_output}};
kernel.execute(code_input, callbacks, {silent:false});
}
回调函数称为handle_output,我们现在将对其进行定义。 当kernel.execute调用的 Python 函数返回值时,将调用此函数。 和以前一样,其完整格式不在本模块的范围之内。 但是,出于我们的目的,结果将作为 text / plain 类型的数据返回,我们将其提取并显示在要在下一个单元格中创建的表单的#tweet_text div中。 代码如下:
function handle_output(out){
var res = out.content.data["text/plain"];
$("div#tweet_text").html(res);
}
我们的表单将带有一个div,显示要标记的下一条推文,我们将为其提供ID #tweet_text。 我们还创建了一个文本框,使我们能够捕获按键(否则,Notebook 将捕获按键,而 JavaScript 不会执行任何操作)。 这使我们能够使用键盘设置1或0的标签,这比使用鼠标单击按钮要快-因为我们至少需要标记 100 条推文。
运行上一个单元格,将一些 JavaScript 嵌入到页面中,尽管结果部分中不会显示任何内容。
现在,我们将使用另一个魔术函数%%html。 毫不奇怪,此魔术功能使我们可以将 HTML 直接嵌入到 Notebook 中。 在新单元格中,从以下这一行开始:
%%html
对于此单元格,我们将使用 HTML 和一些 JavaScript 进行编码。 首先,定义div元素以存储要标记的当前 tweet。 我还添加了一些使用此表单的说明。 然后,创建#tweet_text div,该 div 将存储要标记的下一条推文的文本。 如前所述,我们需要创建一个文本框以捕获按键。 代码如下:
<div name="tweetbox">
Instructions: Click in textbox. Enter a 1 if the tweet is relevant, enter 0 otherwise.<br>
Tweet: <div id="tweet_text" value="text"></div><br>
<input type=text id="capture"></input><br>
</div>
暂时不要运行单元!
我们创建用于捕获按键的 JavaScript。 在创建表单后,需要定义,因为在上述代码运行之前#tweet_text div 不存在。 我们使用 JQuery 库(IPython 已经在使用,因此我们不需要包含 JavaScript 文件)来添加一个在#capture上按下键时调用的函数。 我们定义的文本框。 但是,请记住,这是%%html单元而不是 JavaScript 单元,因此我们需要将此 JavaScript 封装在[HTG3]标签中。
仅当用户按下0或1时,我们才对按键感兴趣,在这种情况下,将添加相关标签。 我们可以确定存储在e.which中的 ASCII 值按下了哪个键。 如果用户按下 0 或 1,我们将附加标签并清除文本框。 代码如下:
<script>
$("input#capture").keypress(function(e) {
if(e.which == 48) {
set_label(0);
$("input#capture").val("");
}else if (e.which == 49){
set_label(1);
$("input#capture").val("");
}
});
所有其他按键均被忽略。
作为本章最后的 JavaScript(我保证),我们称为load_next_tweet()函数。 这将设置要标记的第一个 tweet,然后关闭 JavaScript。 代码如下:
load_next_tweet();
</script>
运行此单元格后,您将获得一个 HTML 文本框以及第一个 tweet 的文本。 单击文本框,如果与我们的目标相关,则输入1(在这种情况下,这意味着是与编程语言 Python 相关的推文),如果与我们的目标无关,则输入 0。 完成此操作后,将加载下一条推文。 输入标签,下一个将加载。 这一直持续到推文用完为止。
完成所有这些操作后,只需将标签保存到我们先前为类值定义的输出文件名即可:
with open(labels_filename, 'w') as outf:
json.dump(labels, outf)
即使您尚未完成操作,也可以调用前面的代码。 到此为止,您所做的所有标签都将被保存。 再次运行此笔记本将在您离开的地方继续,您可以继续为自己的推文贴上标签。
为此可能需要一段时间! 如果数据集中有很多推文,则需要对所有这些推文进行分类。 如果时间有限,则可以下载我使用的相同数据集,其中包含分类。
从 Twitter 创建可复制的数据集
在数据挖掘中,有很多变量。 这些不仅仅出现在数据挖掘算法中,它们还出现在数据收集,环境和许多其他因素中。 能够复制结果非常重要,因为它使您可以验证或改进结果。
注意
使用算法X在一个数据集上获得 80%的准确性,使用算法Y在另一数据集上获得 90%的准确性并不意味着 Y 会更好。 我们需要能够在相同条件下对相同数据集进行测试,以进行正确比较。
在运行上述代码时,您将获得与我创建和使用的数据集不同的数据集。 主要原因是,Twitter 将根据您执行搜索的时间为您返回与我不同的搜索结果。 即使在那之后,您的推文标签可能与我的标签有所不同。 尽管有一些明显的示例,其中给定的推文与 python 编程语言相关,但总会有灰色区域的标签不明显。 我遇到的一个困难的灰色区域是我看不懂的非英语推文。 在这种特定情况下,Twitter 的 API 中提供了用于设置语言的选项,但是即使这些选项也不是完美的。
由于这些因素,很难在从社交媒体提取的数据库上复制实验,Twitter 也不例外。 Twitter 明确禁止直接共享数据集。
一种解决方案是仅共享鸣叫 ID,您可以自由共享它们。 在本节中,我们将首先创建一个我们可以自由共享的 tweet ID 数据集。 然后,我们将看到如何从该文件下载原始推文以重新创建原始数据集。
首先,我们保存 tweet ID 的可复制数据集。 创建另一个新的 IPython Notebook,首先设置文件名。 这样做的方式与标记相同,但是有一个新文件名可以存储可复制的数据集。 代码如下:
import os
input_filename = os.path.join(os.path.expanduser("~"), "Data", "twitter", "python_tweets.json")
labels_filename = os.path.join(os.path.expanduser("~"), "Data", "twitter", "python_classes.json")
replicable_dataset = os.path.join(os.path.expanduser("~"), "Data", "twitter", "replicable_dataset.json")
我们像上一个笔记本一样加载推文和标签:
import json
tweets = []
with open(input_filename) as inf:
for line in inf:
if len(line.strip()) == 0:
continue
tweets.append(json.loads(line))
if os.path.exists(labels_filename):
with open(classes_filename) as inf:
labels = json.load(inf)
现在,我们通过同时遍历推文和标签并将它们保存在列表中来创建数据集:
dataset = [(tweet['id'], label) for tweet, label in zip(tweets, labels)]
最后,我们将结果保存在文件中:
with open(replicable_dataset, 'w') as outf:
json.dump(dataset, outf)
现在我们已经保存了推特 ID 和标签,我们可以重新创建原始数据集。 如果要重新创建本章使用的数据集,可以在本课程随附的代码包中找到它。
加载之前的数据集并不困难,但可能需要一些时间。 启动一个新的 IPython Notebook,并像以前一样设置数据集,标签和 tweet ID 文件名。 我在这里调整了文件名,以确保您不会覆盖以前收集的数据集,但可以根据需要随时更改它们。 代码如下:
import os
tweet_filename = os.path.join(os.path.expanduser("~"), "Data", "twitter", "replicable_python_tweets.json")
labels_filename = os.path.join(os.path.expanduser("~"), "Data", "twitter", "replicable_python_classes.json")
replicable_dataset = os.path.join(os.path.expanduser("~"), "Data", "twitter", "replicable_dataset.json")
然后使用 JSON 从文件中加载 tweet ID:
import json
with open(replicable_dataset) as inf:
tweet_ids = json.load(inf)
保存标签非常容易。 我们只是遍历此数据集并提取 ID。 我们只需两行代码(打开文件并保存推文)就可以轻松完成此操作。 但是,我们不能保证会得到所有的 tweet(例如,自收集数据集以来,某些 tweet 可能已更改为 private),因此标签将不正确地针对数据建立索引。
举例来说,我试图在收集数据后的一天之内重新创建数据集,并且已经丢失了两条推文(它们可能被用户删除或设为私有)。 因此,仅打印出所需标签很重要。 为此,我们首先创建一个空的actual labels列表来存储实际从twitter恢复的推文的标签,然后创建将推文 ID 映射到标签的字典。
代码如下:
actual_labels = []
label_mapping = dict(tweet_ids)
接下来,我们将创建一个twitter服务器来收集所有这些推文。 这将花费更长的时间。 导入我们之前使用的twitter库,创建一个授权令牌,并使用该令牌创建twitter对象:
import twitter
consumer_key = "<Your Consumer Key Here>"
consumer_secret = "<Your Consumer Secret Here>"
access_token = "<Your Access Token Here>"
access_token_secret = "<Your Access Token Secret Here>"
authorization = twitter.OAuth(access_token, access_token_secret, consumer_key, consumer_secret)
t = twitter.Twitter(auth=authorization)
通过使用以下命令将 ID 提取到列表中来迭代每个 Twitter ID:
all_ids = [tweet_id for tweet_id, label in tweet_ids]
然后,我们打开输出文件以保存推文:
with open(tweets_filename, 'a') as output_file:
Twitter API 允许我们一次获得 100 条推文。 因此,我们遍历每 100 条 tweet:
for start_index in range(0, len(tweet_ids), 100):
要按 ID 进行搜索,我们首先创建一个将所有 ID(在此批次中)连接在一起的字符串:
id_string = ",".join(str(i) for i in all_ids[start_index:start_index+100])
接下来,我们执行状态/查找 API 调用,该调用由 Twitter 定义。 我们将 ID 列表(我们将其转换为字符串)传递到 API 调用中,以便将这些 tweet 返回给我们:
search_results = t.statuses.lookup(_id=id_string)
然后,对于搜索结果中的每个推文,我们将其保存到文件中的方式与最初收集数据集时的方式相同:
for tweet in search_results:
if 'text' in tweet:
output_file.write(json.dumps(tweet))
output_file.write("\n\n")
作为此处的最后一步(仍在前面的if块下),我们想存储此推文的标签。 我们可以使用之前创建的label_mapping字典来执行此操作,查找推特 ID。 代码如下:
actual_labels.append(label_mapping[tweet['id']])
运行上一个单元格,代码将为您收集所有推文。 如果您创建了一个非常大的数据集,则可能需要一段时间-Twitter 会进行限速请求。 作为此处的最后一步,请将actual_labels保存到我们的classes文件中:
with open(labels_filename, 'w') as outf:
json.dump(actual_labels, outf)

文字转换器
现在我们有了数据集,我们将如何对其进行数据挖掘?
基于文本的数据集包括书籍,论文,网站,手稿,编程代码和其他形式的书面表达。 到目前为止,我们已经看到的所有算法都处理数字或分类特征,那么如何将文本转换为该算法可以处理的格式?
有个可以进行的测量。 例如,平均单词和平均句子长度用于预测文档的可读性。 但是,我们现在将研究许多特征类型,例如单词出现。
口碑
最简单但高效的模型中的一种是简单地计算数据集中的每个单词。 我们创建一个矩阵,其中每一行代表数据集中的一个文档,每一列代表一个单词。 单元格的值是该单词在文档中的出现频率。
这是指环王和 J.R.R.的摘录 托尔金:
| | 天上的精灵王三环,七个在石头大厅里的矮人,九个人命中注定要死,为黑暗之王登上黑暗宝座的人在暗影存在的魔多国。一个环来统治所有人,一个环来找到他们,一枚戒指将它们全部带走,在黑暗中将它们绑起来。**在暗影存在的魔多国。 | |
| | - J.R.R. 托尔金在《指环王》中的题词 |
出现在单词中,而出现在中,显示为,至显示为,显示为 ]分别出现四次。 单词环出现了 3 次,的单词也出现了 3 次。
我们可以由此创建一个数据集,选择一个单词子集并计算频率:
| **字** | 这 | 一 | 戒指 | 到 | | **频率** | 9 | 4 | 3 | 4 |我们可以使用counter类对给定的字符串进行简单计数。 在计算单词时,通常将所有字母都转换为小写,这在创建字符串时会执行。 代码如下:
s = """Three Rings for the Elven-kings under the sky,
Seven for the Dwarf-lords in halls of stone,
Nine for Mortal Men, doomed to die,
One for the Dark Lord on his dark throne
In the Land of Mordor where the Shadows lie.
One Ring to rule them all, One Ring to find them,
One Ring to bring them all and in the darkness bind them.
In the Land of Mordor where the Shadows lie. """.lower()
words = s.split()
from collections import Counter
c = Counter(words)
打印c.most_common(5)给出了最频繁出现的前五个单词的列表。 领带处理不佳,仅给出了五个,并且大量单词都并列第五。
词袋模型有三种主要类型。 首先是使用原始频率,如前面的示例所示。 当文档的大小从较少的单词变为很多单词时,这确实有一个缺点,因为的整体值将有很大的不同。 第二种模型是使用归一化频率,其中每个文档的总和等于 1。这是更好的解决方案,因为文档的长度无关紧要。 第三种是简单地使用二进制功能-如果单词完全出现,则值为 1;否则,则返回 0。 我们将在本章中使用二进制表示。
另一种执行标准化的流行方法(可能更流行)称为词频-反向文档频率或 tf-idf。 在这种加权方案中,术语计数首先被归一化为频率,然后除以它出现在语料库中的文档数。 我们将在第 10 章,“聚类新闻文章”中使用 tf-idf。
有许多库可用于在 Python 中处理文本数据。 我们将使用一个主要的工具,称为自然语言工具包(NLTK)。 scikit-learn库还具有执行类似操作的 CountVectorizer类,建议您看一下(我们将在第 9 章,作者归因中使用它)。 但是,NLTK 版本具有更多用于单词标记化的选项。 如果您使用 python 进行自然语言处理,则 NLTK 是一个很好的库。
N-grams
从单个单词袋特征开始的步骤是 n-gram 的特征。 n-gram 是n个连续标记的子序列。 在这种情况下,单词 n-gram 是连续出现的n个单词的集合。
他们的计数方法相同,n 元语法词构成词,并放入袋中。 此数据集中的单元格值是特定 n-gram 在给定文档中出现的频率。
注意
n的值是一个参数。 对于英语,将其设置为 2 到 5 之间是一个好的开始,尽管某些应用要求使用更高的值。
例如,对于 n = 3,我们提取以下引号中的前几个 n-gram:
始终朝着生活的光明面看。
第一个 n-gram(大小为 3)是始终在上看,第二个是在上看,第三个是在明亮的上。 如您所见,n 元语法重叠并覆盖了三个单词。
单词 n-gram 优于使用单个单词。 这个简单的概念通过考虑词的本地环境为词的使用引入了一些上下文,而无需花大量的时间来理解该语言。 使用 n-gram 的一个缺点是矩阵变得更加稀疏-不太可能出现单词 n-gram 两次(特别是在推文和其他简短文档中!)。
专门用于社交媒体和其他短文档的单词 n-gram 不太可能出现在太多不同的推文中,除非它是转推。 但是,在较大的文档中,单词 n-gram 对于许多应用都非常有效。
文本文档的另一种形式的 n-gram 是字符 n-gram。 而不是使用单词集,我们只使用字符集(尽管字符 n-gram 对于它们的计算方式有很多选择!)。 这种类型的数据集可以选择拼写错误的单词,并提供其他好处。 我们将在本章中测试字符 n-gram,然后在第 9 章和“作者身份”中再次看到它们。
其他功能
还有其他可以提取的功能。 这些包括句法功能,例如句子中特定单词的使用。 词性标签在需要理解文本含义的数据挖掘应用中也很流行。 此类功能类型将不在本模块中介绍。 如果您有兴趣了解更多信息,建议使用 Python 3 文本处理和 NLTK 3 食谱,Jacob Perkins,Packt Publishing。
朴素贝叶斯
朴素贝叶斯是概率模型,毫无疑问地基于朴素的贝叶斯统计解释。 尽管幼稚的方面,该方法在大量上下文中仍然表现良好。 它可以用于对许多不同的特征类型和格式进行分类,但是在本章中我们将重点介绍一种:词袋模型中的二进制特征。
贝叶斯定理
对于我们大多数来说,当我们学习统计学时,我们是从一种常识性方法开始的。 在这种方法中,我们假设数据来自某个分布,并且我们旨在确定该分布的参数。 但是,这些参数(可能不正确)被假定为固定的。 我们使用模型来描述数据,甚至进行测试以确保数据适合我们的模型。
贝叶斯统计代替了人们(非统计学家)实际推理的模型。 我们有一些数据,并使用这些数据来更新模型,以了解发生某件事的可能性。 在贝叶斯统计中,我们使用数据来描述模型,而不是使用模型并通过数据进行确认(按照常客方法)。
贝叶斯定理计算 P(A | B)的值,也就是说,知道B发生了,A的概率是多少。 在大多数情况下,B是观察到的事件,例如,昨天下雨的,A是今天下雨的预报。 对于数据挖掘,B通常是,我们观察到此样本,A是,它属于此类。 下一节将介绍如何使用贝叶斯定理进行数据挖掘。
贝叶斯定理的方程给出如下:

例如,我们要确定包含药物一词的电子邮件是垃圾邮件的可能性(因为我们认为这样的推文可能是制药垃圾邮件)。
在此上下文中,是该推文是垃圾邮件的概率。 我们可以通过计算数据集中的垃圾邮件百分比来直接从训练数据集中计算 P(A),称为先验信念。 如果我们的数据集中每 100 封电子邮件包含 30 条垃圾邮件,则 P(A)为 30/100 或0.3。
在此上下文中,B是,此推文包含单词“ drugs”。 同样,我们可以通过计算包含药物一词的数据集中的推文百分比来计算 P(B)。 如果我们的训练数据集中每 100 封电子邮件中有 10 封邮件包含药物字词,则 P(B)为 10/100 或 0.1。 请注意,计算此值时,我们不在乎电子邮件是否为垃圾邮件。
P(B|A)是电子邮件中包含毒品单词的可能性,如果它是垃圾邮件。 从我们的训练数据集进行计算也很容易。 我们仔细查看垃圾邮件的训练集,并计算出包含毒品一词的邮件所占的百分比。 在我们的 30 封垃圾邮件中,如果 6 封包含药物一词,则 P(B | A)计算为 6/30 或 0.2。
从这里开始,我们使用贝叶斯定理来计算 P(A | B),这是包含药物的推文成为垃圾邮件的可能性。 使用前面的公式,我们看到结果是 0.6。 这表明,如果电子邮件中包含毒品一词,则有 60%的可能性是垃圾邮件。
注意前面示例的经验性质-我们直接从训练数据集中使用证据,而不是从某些先入为主的分布中使用证据。 相反,对此的常识性看法将依靠我们在推文中创建单词概率的分布来计算相似的方程式。
朴素贝叶斯算法
回顾的贝叶斯定理方程,我们可以用它来计算给定样本属于给定类别的概率。 这允许将该方程用作分类算法。
在数据集中以C为给定类,以D为样本,我们创建贝叶斯定理和随后的朴素贝叶斯所需的元素。 朴素贝叶斯是一种分类算法,利用贝叶斯定理来计算新数据样本属于特定类别的概率。
P(C)是类别的概率,它是从训练数据集本身计算出来的(就像我们对垃圾邮件示例所做的那样)。 我们只需计算训练数据集中属于给定类别的样本的百分比即可。
P(D)是给定数据样本的概率。 由于样本是不同功能之间的复杂交互,因此可能很难计算出来,但是幸运的是,它在所有类中都是一个常数。 因此,我们根本不需要计算它。 稍后我们将看到如何解决此问题。
P(D | C)是数据点属于类别的概率。 由于功能不同,这也可能难以计算。 但是,这是我们介绍朴素贝叶斯算法的朴素部分的地方。 我们天真地假设每个功能都是彼此独立的。 而不是计算 P(D | C)的全部概率,我们计算每个特征 D1,D2,D3 等的概率。 然后,我们将它们相乘:
P(D|C) = P(D1|C) x P(D2|C).... x P(Dn|C)
这些值中的每一个都相对易于使用二进制功能进行计算。 我们只计算样本数据集中相等时间的百分比。
相反,如果要执行本部分的非朴素贝叶斯版本,则需要为每个类计算不同特征之间的相关性。 这种计算充其量是不可行的,如果没有大量的数据或适当的语言分析模型,几乎是不可能的。
从开始,该算法非常简单。 我们为每个可能的类别计算 P(C | D),而忽略了 P(D)项。 然后,我们选择概率最高的类别。 由于 P(D)术语在每个类别中都是一致的,因此忽略它不会对最终预测产生影响。
工作原理
作为的示例,假设我们从数据集中的样本中获得以下(二进制)特征值:[0, 0, 0, 1]。
我们的训练数据集包含两个类别,其中 75%的样本属于0类别,而 25%的样本属于1类别。 每个类的特征值的可能性如下:
对于课程 0:[0.3, 0.4, 0.4, 0.7]
对于类别 1:[0.7, 0.3, 0.4, 0.9]
这些值应解释为:对于特征 1 ,对于类别 0,在 30%的情况下为 1。
现在我们可以计算出该样本属于0类的概率。 P(C = 0)= 0.75,这是类别为0的概率。
朴素贝叶斯算法不需要 P(D)。 让我们看一下计算:
P(D|C=0) = P(D1|C=0) x P(D2|C=0) x P(D3|C=0) x P(D4|C=0)
= 0.3 x 0.6 x 0.6 x 0.7
= 0.0756
注意
第二个和第三个值为 0.6,因为样本中该特征的值为0。 列出的概率是每个功能的1值。 因此,0的概率是其倒数:P(0)= 1-P(1)。
现在,我们可以计算出属于此类的数据点的概率。 需要注意的重要一点是,我们尚未计算出 P(D),因此这不是真实的概率。 但是,将相同的值与类别 1 的概率进行比较就足够了。让我们看一下计算:
P(C=0|D) = P(C=0) P(D|C=0)
= 0.75 * 0.0756
= 0.0567
现在,我们为类 1 计算相同的值:
P(C=1) = 0.25
天真贝叶斯不需要P(D)。 让我们看一下计算:
P(D|C=1) = P(D1|C=1) x P(D2|C=1) x P(D3|C=1) x P(D4|C=1)
= 0.7 x 0.7 x 0.6 x 0.9
= 0.2646
P(C=1|D) = P(C=1)P(D|C=1)
= 0.25 * 0.2646
= 0.06615
注意
通常,P(C = 0 | D)+ P(C = 1 | D)应该等于 1。毕竟,这是仅有的两个可能的选择! 但是,由于我们没有在此处的方程式中包含 P(D)的计算,因此概率不是 1。
数据点应分类为属于类别1。 无论如何,您可能已经在通过方程式进行猜测了; 但是,您可能对最终决定如此之近感到有些惊讶。 毕竟,对于类别1,计算 P(D | C)的概率要高得多。 这是因为我们引入了一个先验的信念,即大多数样本通常都属于0类。
如果类的大小相等,则得出的概率将有很大不同。 通过将 P(C = 0)和 P(C = 1)都更改为 0.5,以实现相同的班级大小,然后再次计算结果,自己尝试一下。
二十八、使用图挖掘发现要关注的帐户
许多事物可以表示为图形。 在大数据,在线社交网络和物联网的今天,尤其如此。 尤其是,在线社交网络是一项大生意,Facebook 等网站拥有超过 5 亿活跃用户(其中50%每天登录)。 这些网站通常通过有针对性的广告获利。 但是,要使用户与网站互动,他们通常需要关注有趣的人或页面。
在本章中,我们将研究相似性的概念以及如何基于相似性创建图。 我们还将看到如何使用连接的组件将此图分成有意义的子图。 这个简单的算法引入了聚类分析的概念-根据相似度将数据集分为子集。 我们将在第 10 章,“聚类新闻文章”中更深入地研究聚类分析。
本章涵盖的主题包括:
- 从社交网络创建图
- 加载和保存内置分类器
- NetworkX 软件包
- 将图转换为矩阵
- 距离和相似度
- 基于评分功能优化参数
- 损失函数和计分函数
加载数据集
在本章的中,我们的任务是基于共享连接向在线社交网络上的用户推荐。 我们的逻辑是,如果两个用户有相同的朋友,则他们非常相似,值得彼此推荐。
我们将使用上一章介绍的 API 从 Twitter 创建一个小的社交图。 我们正在寻找的数据是对类似主题感兴趣的用户的子集(再次是 Python 编程语言)以及所有朋友的列表(他们关注的人)。 使用此数据,我们将基于检查两个用户共有多少个朋友,他们的相似程度如何。
注意
除 Twitter 外,还有许多其他在线社交网络。 我们选择 Twitter 进行此实验的原因是,他们的 API 使得获取此类信息非常容易。 该信息也可以从其他站点获得,例如 Facebook,LinkedIn 和 Instagram。 但是,获取此信息更加困难。
要开始收集数据,请像上一章一样,设置一个新的 IPython Notebook 和一个twitter连接的实例。 您可以重用上一章中的应用信息,也可以创建一个新的信息:
import twitter
consumer_key = "<Your Consumer Key Here>"
consumer_secret = "<Your Consumer Secret Here>"
access_token = "<Your Access Token Here>"
access_token_secret = "<Your Access Token Secret Here>"
authorization = twitter.OAuth(access_token, access_token_secret, consumer_key, consumer_secret)
t = twitter.Twitter(auth=authorization, retry=True)
另外,创建输出文件名:
import os
data_folder = os.path.join(os.path.expanduser("~"), "Data", "twitter")
output_filename = os.path.join(data_folder, "python_tweets.json")
我们还需要json库来保存数据:
import json
接下来,我们将需要一个用户列表。 和上一章一样,我们将搜索鸣叫,并寻找那些提及单词 python 的人。 首先,创建两个列表,用于存储推文的文本和相应的用户。 稍后我们将需要用户 ID,因此我们现在创建一个字典映射。 代码如下:
original_users = []
tweets = []
user_ids = {}
我们将像上一章一样搜索 python 一词,并遍历搜索结果:
search_results = t.search.tweets(q="python", count=100)['statuses']
for tweet in search_results:
我们只对推文感兴趣,对 Twitter 可以传递的其他消息不感兴趣。 因此,我们检查结果中是否有文字:
if 'text' in tweet:
如果是这样,我们将记录用户的屏幕名称,鸣叫文本以及屏幕名称到用户 ID 的映射。 代码如下:
original_users.append(tweet['user']['screen_name'])
user_ids[tweet['user']['screen_name']] = tweet['user']['id']
tweets.append(tweet['text'])
运行此代码将获得约 100 条推文,在某些情况下可能会少一些。 但是,并非所有这些都与编程语言有关。
使用现有模型进行分类
正如我们在上一章中所了解的那样,并非所有提及单词 python 的推文都将与编程语言相关。 为此,我们将使用上一章中使用的分类器来基于编程语言获取推文。 我们的分类器并不完美,但是与仅进行搜索相比,它会带来更好的专业化。
在这种情况下,我们只对发推特有关 Python(一种编程语言)的用户感兴趣。 我们将使用上一章中的分类器来确定哪些推文与编程语言相关。 从那里,我们将只选择那些在推特上发布有关编程语言的用户。
为此,我们首先需要保存模型。 打开我们在上一章中创建的 IPython Notebook,我们在其中构建了分类器。 如果您关闭了它,那么 IPython Notebook 将不会记住您所做的事情,因此您将需要再次运行这些单元。 为此,请在笔记本电脑的单元格菜单上单击,然后选择全部运行。
计算完所有单元格后,选择最终的空白单元格。 如果您的笔记本电脑末尾没有空白单元格,请选择最后一个单元格,选择插入菜单,然后选择在下面插入单元格。
我们将使用joblib库保存并加载模型。
注意
scikit-learn软件包中包含joblib。
首先,导入库并为我们的模型创建输出文件名(确保目录存在,否则将不会创建目录)。 我已经将该模型存储在 Models 目录中,但是您可以选择将它们存储在其他位置。 代码如下:
from sklearn.externals import joblib
output_filename = os.path.join(os.path.expanduser("~"), "Models", "twitter", "python_context.pkl")
接下来,我们使用joblib中的dump函数,其功能类似于json库中的函数。 我们传递模型本身(如果忘记了,则简称为model),并传递输出文件名:
joblib.dump(model, output_filename)
运行此代码会将模型保存到给定的文件名。 接下来,返回到在上一小节中创建的新 IPython Notebook 并加载此模型。
您将需要通过复制以下代码在此笔记本中再次设置模型的文件名:
model_filename = os.path.join(os.path.expanduser("~"), "Models", "twitter", "python_context.pkl")
确保文件名是保存模型之前使用的文件名。 接下来,我们需要重新创建我们的NLTKBOW类,因为它是一个定制类,不能由joblib直接加载。 在后面的章节中,我们将解决此问题的一些更好方法。 现在,只需从上一章的代码中复制整个NLTKBOW类,包括其依赖项即可:
from sklearn.base import TransformerMixin
from nltk import word_tokenize
class NLTKBOW(TransformerMixin):
def fit(self, X, y=None):
return self
def transform(self, X):
return [{word: True for word in word_tokenize(document)}
for document in X]
现在加载模型只需要调用joblib的load函数:
from sklearn.externals import joblib
context_classifier = joblib.load(model_filename)
我们的context_classifier的工作原理与我们在第 6 章,“使用朴素贝叶斯”的社交媒体见解中看到的笔记本的model对象相同,它是Pipeline的一个实例, 具有与之前相同的三个步骤(NLTKBOW,DictVectorizer和BernoulliNB分类器)。
在此模型上调用predict函数可以预测我们的推文是否与编程语言相关。 代码如下:
y_pred = context_classifier.predict(tweets)
如果(预测为)与编程语言相关的ith推文,则y_pred中的ith项将为 1,否则它将为 0。从这里,我们可以仅获得与之相关的推文。 他们的相关用户:
relevant_tweets = [tweets[i] for i in range(len(tweets)) if y_pred[i] == 1]
relevant_users = [original_users[i] for i in range(len(tweets)) if y_pred[i] == 1]
使用我的数据,这涉及到 46 个相关用户。 比以前的 100 条推文/用户略低,但现在我们有了构建社交网络的基础。
从 Twitter 获取关注者信息
接下来,我们需要获得的个朋友每个用户。 朋友是用户关注的人。 用于此的 API 称为friends/ids,它有优点也有缺点。 好消息是,它在一个 API 调用中最多返回 5,000 个朋友 ID。 坏消息是,您每 15 分钟只能拨打 15 个电话,这意味着每位用户至少要花 1 分钟才能吸引所有关注者-如果他们的朋友数超过 5,000,则关注者会更多(发生的次数比您想象的要多) 。
但是,代码相对容易。 我们将其打包为一个函数,因为在接下来的两节中将使用此代码。 首先,我们将创建带有我们的 Twitter 连接和用户 ID 的功能签名。 该函数将返回该用户的所有关注者,因此我们还将创建一个列表来存储这些关注者。我们还将需要时间模块,因此也需要导入它。 我们将首先介绍函数的组成,但是接下来,我将为您完整介绍整个函数。 代码如下:
import time
def get_friends(t, user_id):
friends = []
尽管这可能令人惊讶,但许多 Twitter 用户拥有超过 5,000 个朋友。 因此,我们将需要使用 Twitter 的分页。 Twitter 通过使用游标管理多个页面数据。 当要求 Twitter 提供信息时,它会提供该信息以及光标,这是 Twitter 用户跟踪您的请求的整数。 如果没有更多信息,则此光标为 0;否则为 0。 否则,您可以使用提供的光标来获取下一页结果。 首先,将光标设置为-1,指示结果的开始:
cursor = -1
接下来,我们继续循环,而此游标不等于 0(因为当它是游标时,就没有更多数据可收集了)。 然后,我们请求用户的关注者并将其添加到我们的列表中。 我们在try块中执行此操作,因为我们可以处理可能发生的错误。 追随者的 ID 存储在results词典的ids键中。 获取该信息后,我们更新游标。 它将在循环的下一次迭代中使用。 最后,我们检查是否有超过 10,000 个朋友。 如果是这样,我们就跳出了循环。 代码如下:
while cursor != 0:
try:
results = t.friends.ids(user_id= user_id, cursor=cursor, count=5000)
friends.extend([friend for friend in results['ids']])
cursor = results['next_cursor']
if len(friends) >= 10000:
break
注意
值得在此处插入警告。 我们正在处理来自 Internet 的数据,这意味着奇怪的事情可能而且确实会定期发生。 我在开发此代码时遇到的一个问题是,一些用户有很多很多朋友。 为解决此问题,我们将在此处放置一个故障保护,如果我们达到 10,000 个以上的用户,则退出。 如果要收集完整的数据集,则可以删除这些行,但是请注意,它可能会长时间卡在特定用户上。
现在,我们处理可能发生的错误。 如果我们不小心达到了 API 限制,则会发生最有可能发生的错误(虽然我们有sleep来阻止它,但是,如果在sleep完成之前停止并运行代码,则可能会发生)。 在这种情况下,results is None和我们的代码将失败,并带有TypeError。 在这种情况下,我们等待 5 分钟,然后重试,希望我们到达下一个 15 分钟的窗口。 此时可能会出现另一种TypeError。 如果其中之一确实存在,我们将提出并需要单独处理。 代码如下:
except TypeError as e:
if results is None:
print("You probably reached your API limit, waiting for 5 minutes")
sys.stdout.flush()
time.sleep(5*60) # 5 minute wait
else:
raise e
可能发生的第二个错误发生在 Twitter 的末端,例如要求一个不存在的用户或其他一些基于数据的错误。 在这种情况下,请勿再尝试使用该用户,而只返回我们确实获得的所有关注者(在这种情况下,该关注者可能为 0)。 代码如下:
except twitter.TwitterHTTPError as e:
break
现在,我们将处理我们的 API 限制。 Twitter 仅让我们每 15 分钟询问 15 次关注者信息,因此我们将等待 1 分钟后再继续。 我们在finally块中执行此操作,以便即使发生错误也可以发生:
finally:
time.sleep(60)
通过返回我们收集的朋友来完成我们的功能:
return friends
完整功能如下:
import time
def get_friends(t, user_id):
friends = []
cursor = -1
while cursor != 0:
try:
results = t.friends.ids(user_id= user_id, cursor=cursor, count=5000)
friends.extend([friend for friend in results['ids']])
cursor = results['next_cursor']
if len(friends) >= 10000:
break
except TypeError as e:
if results is None:
print("You probably reached your API limit, waiting for 5 minutes")
sys.stdout.flush()
time.sleep(5*60) # 5 minute wait
else:
raise e
except twitter.TwitterHTTPError as e:
break
finally:
time.sleep(60)
return friends
建立网络
现在我们将建立我们的网络。 从我们的原始用户开始,我们将获得他们的每个朋友并将其存储在字典中(从user_id字典中获得用户的 ID 后):
friends = {}
for screen_name in relevant_users:
user_id = user_ids[screen_name]
friends[user_id] = get_friends(t, user_id)
接下来,我们将删除没有任何朋友的所有用户。 对于这些用户,我们真的不能以这种方式提出建议。 相反,我们可能必须查看他们的内容或关注他们的人。 但是,我们将其排除在本章的范围之外,因此我们仅删除这些用户。 代码如下:
friends = {user_id:friends[user_id] for user_id in friends
if len(friends[user_id]) > 0}
现在,我们有 30 至 50 个用户,具体取决于您的初始搜索结果。 现在,我们将其数量增加到 150 个。以下代码将花费很长时间才能运行-鉴于 API 的限制,我们每分钟只能为用户获得一次好友。 简单的数学将告诉我们,150 个用户将花费 150 分钟,即 2.5 个小时。 考虑到我们将花费时间来获取这些数据,确保我们仅获得个良好的用户是值得的。
但是,什么才是好的用户呢? 鉴于我们将寻求基于共享连接的建议,我们将基于共享连接搜索用户。 我们将从与我们现有用户之间有更好联系的那些用户开始,吸引现有用户的朋友。 为此,我们将统计用户在friends列表之一中的所有访问次数。 在考虑采样策略时,值得考虑应用的目标。 为此,获得大量相似用户可以使建议更加定期地适用。
为此,我们只需遍历我们拥有的所有friends列表,然后在每次出现朋友时计数。
from collections import defaultdict
def count_friends(friends):
friend_count = defaultdict(int)
for friend_list in friends.values():
for friend in friend_list:
friend_count[friend] += 1
return friend_count
计算我们当前的个朋友计数,然后我们可以从样本中获得最多个联系的人(即,现有列表中的大多数朋友)。 代码如下:
friend_count
reverse=True) = count_friends(friends)
from operator import itemgetter
best_friends = sorted(friend_count.items(), key=itemgetter(1),
从这里开始,我们建立了一个循环,一直持续到拥有 150 个用户的朋友为止。 然后,我们遍历所有最好的朋友(这按照将他们作为朋友的人数的顺序进行),直到找到一个我们尚未认识其朋友的用户。 然后,我们得到该用户的朋友并更新friends计数。 最后,我们算出谁是我们名单中尚未联系最多的用户:
while len(friends) < 150:
for user_id, count in best_friends:
if user_id not in friends:
break
friends[user_id] = get_friends(t, user_id)
for friend in friends[user_id]:
friend_count[friend] += 1
best_friends = sorted(friend_count.items(),
key=itemgetter(1), reverse=True)
然后,这些代码将循环播放并继续,直到达到 150 个用户为止。
注意
您可能希望将这些值设置得较低,例如 40 个或 50 个用户(甚至只是暂时跳过此代码位)。 然后,完成本章的代码并了解结果的工作方式。 之后,将该循环中的用户数重置为 150,使代码运行几个小时,然后返回并重新运行以后的代码。
考虑到收集数据可能要花费 2 个小时以上的时间,因此保存它是个好主意,以防万一我们必须关闭计算机。 使用json库,我们可以轻松地将好友字典保存到文件中:
import json
friends_filename = os.path.join(data_folder, "python_friends.json")
with open(friends_filename, 'w') as outf:
json.dump(friends, outf)
如果需要加载文件,请使用json.load函数:
with open(friends_filename) as inf:
friends = json.load(inf)
创建图表
现在,我们有一个用户及其朋友的列表,其中许多用户是从其他用户的朋友那里获取的。 这为我们提供了一个图表,其中一些用户是其他用户的朋友(尽管不一定相反)。
图是一组节点和边。 节点通常是对象,在这种情况下,它们是我们的用户。 该初始图中的边缘指示用户 A 是用户 B 的朋友。 我们将其称为有向图,因为节点的顺序很重要。 仅仅因为用户 A 是用户 B 的朋友,并不意味着用户 B 是用户 A 的朋友。我们可以使用 NetworkX 包来可视化该图。
注意
再次单击,即可使用pip安装 NetworkX:pip3 install networkx。
首先,我们使用 NetworkX 创建一个有向图。 按照惯例,在导入 NetworkX 时,我们使用缩写nx(尽管这不是必需的)。 代码如下:
import networkx as nx
G = nx.DiGraph()
我们只会可视化关键用户,而不是所有朋友(因为有成千上万的用户,很难想象)。 我们得到了主要用户,然后将它们作为节点添加到我们的图形中。 代码如下:
main_users = friends.keys()
G.add_nodes_from(main_users)
接下来,我们设置边缘。 如果第二个用户是第一个用户的朋友,则创建从一个用户到另一个用户的边缘。 为此,我们遍历所有朋友:
for user_id in friends:
for friend in friends[user_id]:
我们确保朋友是我们的主要用户之一(因为我们目前对其他用户不感兴趣),如果有,请添加优势。 代码如下:
if friend in main_users:
G.add_edge(user_id, friend)
现在,我们可以使用 NetworkX 的draw函数(使用matplotlib)来可视化网络。 为了在笔记本中获取图像,我们使用matplotlib上的inline函数,然后调用draw函数。 代码如下:
%matplotlib inline
nx.draw(G)
结果有点难以理解。 他们显示有些节点的连接很少,但许多节点的连接却很多:

通过使用pyplot处理图形的创建,我们可以使图稍微大一些。 为此,我们导入pyplot,创建一个大图形,然后调用 NetworkX 的draw函数(NetworkX 使用pyplot绘制其图形):
from matplotlib import pyplot as plt
plt.figure(3,figsize=(20,20))
nx.draw(G, alpha=0.1, edge_color='b')
结果对于此处的页面来说太大了,但是通过放大图形,现在可以看到图形的外观轮廓。 在我的图表中,有一个主要的用户组,它们彼此之间高度连接,而大多数其他用户根本没有多少连接。 在这里,我仅放大了网络的中心,并在前面的代码中将边缘颜色设置为蓝色,且alpha较低。
如您所见,它在中心位置连接非常好!

实际上是我们选择新用户的方法的一个属性-我们选择那些在图中已经很好地联系在一起的用户,因此他们很可能会使这个组更大。 对于社交网络,通常,用户具有的连接数遵循幂定律。 一小部分用户拥有许多连接,而其他用户只有少数。 通常将图形的形状描述为具有长尾巴。 我们的数据集不遵循这种模式,因为我们是通过结交已有用户的朋友来收集数据的。
创建相似图
通过共享的朋友推荐本章中的任务。 如前所述,我们的逻辑是,如果两个用户有相同的朋友,则他们是非常相似的。 我们可以在此基础上向另一个推荐一个用户。
因此,我们将采用我们现有的图(具有与友谊有关的边缘)并创建一个新图。 节点仍然是用户,但是边缘将成为加权边缘。 加权边仅是具有weight属性的边。 逻辑是,较高的权重表示两个节点之间的相似性高于较低的权重。 这是取决于上下文的。 如果权重代表距离,则权重越低表示相似度越高。
对于我们的应用,权重将是通过该边缘连接的两个用户的相似性(基于他们共享的朋友数)。 此图还具有不定向的特性。 这是由于我们进行了相似度计算,其中用户 A 与用户 B 的相似度与用户 B 与用户 A 的相似度相同。
这样有很多方法可以计算两个列表之间的相似度。 例如,我们可以计算两者共有的朋友数。 但是,对于有更多朋友的人来说,此指标总是更高。 取而代之的是,我们可以将归一化,以除以两者拥有的不同好友总数。 这称为 Jaccard 相似度。
Jaccard 相似度始终在 0 和 1 之间,表示两者的百分比重叠。 正如我们在第 2 章,“使用 scikit-learn 估计器进行分类”中一样,规范化是数据挖掘练习的重要组成部分,通常是一件好事(除非您有特定的原因而不是 到)。
为了计算此 Jaccard 相似度,我们将两组跟随者的交集除以两者的并集。 这些是set操作,我们有lists,因此我们需要首先将friends列表转换为集合。 代码如下:
friends = {user: set(friends[user]) for user in friends}
然后,我们创建一个函数来计算两组friends列表的相似度。 代码如下:
def compute_similarity(friends1, friends2):
return len(friends1 & friends2) / len(friends1 | friends2)
从这里,我们可以创建用户之间相似度的加权图。 在本章的其余部分中,我们将大量使用它,因此我们将创建一个函数来执行此操作。 让我们看一下 threshold 参数:
def create_graph(followers, threshold=0):
G = nx.Graph()
我们遍历所有用户组合,而忽略了将用户与其自身进行比较的实例:
for user1 in friends.keys():
for user2 in friends.keys():
if user1 == user2:
continue
我们计算两个用户之间边缘的权重:
weight = compute_similarity(friends[user1], friends[user2])
接下来,我们仅在超过特定阈值时添加边缘。 这样可以阻止我们添加不需要的边缘,例如权重为 0 的边缘。默认情况下,我们的阈值为 0,因此现在将包括所有边缘。 但是,我们将在本章后面使用此参数。 代码如下:
if weight >= threshold:
如果权重高于阈值,则将两个用户添加到图中(如果两个用户已经在图中,则不会将它们添加为重复项):
G.add_node(user1)
G.add_node(user2)
然后,我们在它们之间添加边,将权重设置为计算出的相似度:
G.add_edge(user1, user2, weight=weight)
循环完成后,我们将获得一个完整的图形,并从函数中将其返回:
return G
现在,我们可以通过调用此函数来创建图形。 我们从没有阈值开始,这意味着将创建所有链接。 代码如下:
G = create_graph(friends)
结果是一个非常紧密相连的图-所有节点都具有边,尽管其中许多结点的权重为 0。通过绘制线宽相对于边权重的图,我们将看到边的权重 -粗线表示较高的权重。
由于节点数,将数字增大以更清楚地了解连接是有意义的:
plt.figure(figsize=(10,10))
我们将使用权重绘制边缘,因此我们需要先绘制节点。 NetworkX 根据某些条件使用布局来确定将节点和边放置在何处。 网络可视化是一个非常困难的问题,尤其是随着节点数量的增长。 存在多种用于可视化网络的技术,但是它们的工作程度在很大程度上取决于您的数据集,个人喜好和可视化的目标。 我发现spring_layout效果很好,但是其他选项,例如circular_layout(如果没有其他效果,这是一个很好的默认设置),random_layout,shell_layout和spectral_layout。
注意
请访问这个页面了解有关 NetworkX 中布局的更多详细信息。 尽管draw_graphviz选项增加了一些复杂性,但其效果很好,值得进行更好的可视化研究。 在实际使用中非常值得考虑。
让我们使用spring_layout进行可视化:
pos = nx.spring_layout(G)
使用我们的pos布局,然后可以定位节点:
nx.draw_networkx_nodes(G, pos)
接下来,我们绘制边缘。 为了获得权重,我们遍历图形的边缘(以特定顺序)并收集权重:
edgewidth = [ d['weight'] for (u,v,d) in G.edges(data=True)]
然后,我们绘制边缘:
nx.draw_networkx_edges(G, pos, width=edgewidth)
结果将取决于您的数据,但通常会显示一个图形,其中有大量节点牢固连接,而有几个节点与网络其余部分的连接不良。

与上一张图相比,此图的不同之处在于,边基于我们的相似性度量标准而不是根据一个人是否是另一个的朋友来确定节点之间的相似性(尽管两者之间存在相似性! )。 现在,我们可以开始从该图中提取信息以提出建议。

应用
现在,我们将创建一个仅包含一条 tweet 的管道,并仅基于该 tweet 的内容确定其是否相关。
为了执行单词提取,我们将使用 NLTK,这是一个包含大量用于对自然语言进行分析的工具的库。 我们还将在以后的章节中使用 NLTK。
注意
要在计算机上获取 NLTK,请使用pip安装软件包:pip3 install nltk
如果不起作用,请参见这个页面上的 NLTK 安装说明。
我们将创建一个管道以提取单词特征并使用朴素贝叶斯对推文进行分类。 我们的管道具有以下步骤:
- 使用 NLTK 的
word_tokenize函数将原始文本文档转换为计数字典。 - 使用
scikit-learn中的DictVectorizer转换器将这些词典转换为向量矩阵。 这对于使 Naive Bayes 分类器能够读取第一步中提取的特征值是必需的。 - 如前几章所述,训练朴素贝叶斯分类器。
- 我们将需要创建另一个名为
ch6_classify_twitter的笔记本(本章的最后一个!)来执行分类。

提取字数
我们将使用 NLTK 提取我们的字数统计。 我们仍然想在管道中使用它,但是 NLTK 不符合我们的转换器接口。 因此,我们将需要创建一个基本的转换器来执行此操作,以获得fit和transform方法,从而使我们能够在管道中使用它。
首先,将设置为transformer类。 我们不需要在该类中放入任何内容,因为此转换器仅提取文档中的单词。 因此,我们的 fit 是一个空函数,除了它返回self(对于提升器对象而言是必需的)。
我们的转换要复杂一些。 我们想从每个文档中提取每个单词并记录True(如果已发现)。 我们仅在此处使用二进制功能-如果在文档中使用True,否则使用False。 如果我们想使用频率,就可以像过去几章一样设置计数字典。
让我们看一下代码:
from sklearn.base import TransformerMixin
class NLTKBOW(TransformerMixin):
def fit(self, X, y=None):
return self
def transform(self, X):
return [{word: True for word in word_tokenize(document)}
for document in X]
结果是字典列表,其中第一个字典是第一条推文中的单词列表,依此类推。 每个词典都有一个单词作为关键字,值true表示已发现此单词。 词典中未包含的任何单词都将被视为未出现在推文中。 明确指出单词的出现是False也可以,但是会占用不必要的空间来存储。
将字典转换为矩阵
此步骤将根据上一步构建的字典转换为可与分类器一起使用的矩阵。 通过DictVectorizer提升器,此步骤变得非常简单。
DictVectorizer类仅获取字典列表并将其转换为矩阵。 此矩阵中的特征是每个词典中的键,并且值对应于每个样本中这些特征的出现。 字典很容易用代码创建,但是许多数据算法实现更喜欢矩阵。 这使DictVectorizer成为非常有用的类。
在我们的数据集中,每个字典都将单词作为关键字,并且仅在单词实际出现在推文中时才会出现。 因此,如果单词出现在推文中,我们的矩阵会将每个单词作为特征,并在单元格中使用True的值。
要使用DictVectorizer,只需使用以下命令将其导入:
from sklearn.feature_extraction import DictVectorizer
训练朴素贝叶斯分类器
最后,我们需要设置一个分类器,本章将使用朴素贝叶斯。 由于我们的数据集仅包含二进制特征,因此我们使用专为二进制特征设计的BernoulliNB分类器。 作为分类器,它非常易于使用。 与DictVectorizer一样,我们只需将其导入并将其添加到管道中即可:
from sklearn.naive_bayes import BernoulliNB
全部放在一起
现在到了将所有这些片段放在一起的时刻。 在我们的 IPython Notebook 中,像以前一样设置文件名并加载数据集和类。 设置推文本身(而不是 ID!)和我们分配给它们的标签的文件名。 代码如下:
import os
input_filename = os.path.join(os.path.expanduser("~"), "Data", "twitter", "python_tweets.json")
labels_filename = os.path.join(os.path.expanduser("~"), "Data", "twitter", "python_classes.json")
自己加载推文。 我们只对 tweet 的内容感兴趣,因此我们提取text值并仅存储该值。 代码如下:
tweets = []
with open(input_filename) as inf:
for line in inf:
if len(line.strip()) == 0:
continue
tweets.append(json.loads(line)['text'])
加载每个推文的标签:
with open(classes_filename) as inf:
labels = json.load(inf)
现在,创建一个管道,将以前的组件放在一起。 我们的管道包括三个部分:
- 我们创建的
NLTKBOW提升器 DictVectorizer提升器BernoulliNB分类器
代码如下:
from sklearn.pipeline import Pipeline
pipeline = Pipeline([('bag-of-words', NLTKBOW()),
('vectorizer', DictVectorizer()),
('naive-bayes', BernoulliNB())
])
我们现在可以几乎运行我们的管道,就像我们之前做过很多次一样,我们将使用cross_val_score来完成。 在之前,我们将引入比以前使用的精度指标更好的评估指标。 正如我们将看到的,当每个类别中的样本数量不同时,对数据集使用准确性是不够的。
使用 F1 评分进行评估
当选择评估指标时,考虑该评估指标无用的情况总是很重要的。 在许多情况下,准确性是一个很好的评估指标,因为它易于理解且易于计算。 但是,它很容易伪造。 换句话说,在许多情况下,由于实用性差,您可以创建具有高精度的算法。
虽然我们的推文数据集(通常,您的结果可能有所不同)包含大约 50%的与编程相关的信息和 50%的非编程相关信息,但许多数据集并不像平衡那样。
作为示例,电子邮件垃圾邮件筛选器可能希望看到超过 80%的传入电子邮件都是垃圾邮件。 仅将所有内容标记为垃圾邮件的垃圾邮件过滤器完全没有用; 但是,它将获得 80%的精度!
为了解决这个问题,我们可以使用其他评估指标。 最常用的一种称为 f1 分数(也称为 f 分数,f 度量或该术语的许多其他变体之一)。
f1 分数是基于每类定义的,基于两个概念:精度和调用。 精度是预测为属于特定类别的实际上来自该类别的所有样本的百分比。 召回是数据集中属于某个类别并实际标记为属于该类别的样本的百分比。
在我们的应用中,我们可以计算两个类的值(相关和不相关)。 但是,我们对垃圾邮件确实很感兴趣。 因此,我们的精度计算成为一个问题:在所有被预测为相关的推文中,实际相关的百分比是多少? 同样,召回也成为一个问题:在数据集中所有相关推文中,有多少被预测为相关?
在中计算精度和查全率后,f1 分数是精度和查全率的谐波均值:

要在scikit-learn方法中使用 f1-score,只需将评分参数设置为 f1。 默认情况下,此将返回带有标签 1 的类的 f1 分数。在我们的数据集上运行代码,我们只需使用以下代码行:
scores = cross_val_score(pipeline, tweets, labels, scoring='f1')
然后,我们打印出平均分数:
import numpy as np
print("Score: {:.3f}".format(np.mean(scores)))
结果为 0.798,这意味着我们可以准确地确定使用 Python 的推文是否有 80%的时间与编程语言相关。 这使用的数据集中只有 200 条推文。 返回并收集更多数据,您会发现结果有所增加!
注意
通常,更多数据意味着更好的准确性,但不能保证!
从模型中获得有用的功能
您可能会问的问题是,用于确定推文是否相关的最佳功能是什么? 根据朴素贝叶斯的说法,我们可以从朴素贝叶斯模型中提取这些信息,并找出哪些功能最适合个人。
首先,我们适合一个新模型。 尽管cross_val_score为我们提供了经过交叉验证的测试数据的不同折分,但它并不容易为我们提供经过训练的模型。 为此,我们只需将推文与流水线配合,以创建新模型。 代码如下:
model = pipeline.fit(tweets, labels)
注意
请注意,我们此处并没有真正评估模型,因此我们不需要在训练/测试拆分方面格外小心。 但是,在将这些功能付诸实践之前,应该对单独的测试拆分进行评估。 为了清楚起见,我们在这里跳过该内容。
管道使您可以通过named_steps属性和步骤名称访问各个步骤(在创建管道对象本身时,我们自己定义了这些名称)。 例如,我们可以获得朴素贝叶斯模型:
nb = model.named_steps['naive-bayes']
从这个模型中,我们可以提取每个单词的概率。 这些作为对数概率存储,即 log(P(A | f)),其中f是给定特征。
之所以将它们存储为对数概率,是因为实际值非常低。 例如,第一个值是-3.486,它与低于 0.03%的概率相关。 对数概率用于涉及这样的小概率的计算中,因为它们会阻止下溢错误,在这种情况下,很小的值会四舍五入为零。 假定所有概率都相乘,那么单个值 0 将导致整个答案始终为 0! 无论如何,值之间的关系仍然相同。 值越高,该功能越有用。
通过对对数概率数组进行排序,我们可以获得最有用的功能。 我们想要降序,因此我们只需要先取反值即可。 代码如下:
top_features = np.argsort(-feature_probabilities[1])[:50]
前面的代码只会给我们提供索引,而不是实际的特征值。 这不是很有用,因此我们将要素的索引映射到实际值。 关键是管道的DictVectorizer步骤,它为我们创建了矩阵。 幸运的是,它还记录了映射,使我们能够查找与不同列相关的要素名称。 我们可以从管道的那一部分中提取特征:
dv = model.named_steps['vectorizer']
在这里,我们可以通过在DictVectorizer的feature_names_属性中查找顶级功能部件的名称来打印它们。 在新单元格中输入以下行,然后运行以打印出一些主要功能:
for i, feature_index in enumerate(top_features):
print(i, dv.feature_names_[feature_index], np.exp(feature_probabilities[1][feature_index]))
前几个功能包括:,http,#和@。 根据我们收集的数据,这很可能是噪音(尽管在编程之外,冒号的使用并不常见)。 收集更多数据对于解决这些问题至关重要。 通过列表,我们可以看到许多更明显的编程功能:
7 for 0.188679245283
11 with 0.141509433962
28 installing 0.0660377358491
29 Top 0.0660377358491
34 Developer 0.0566037735849
35 library 0.0566037735849
36 ] 0.0566037735849
37 [ 0.0566037735849
41 version 0.0471698113208
43 error 0.0471698113208
还有在工作环境中也提到 Python,因此可能是指编程语言(尽管自由蛇处理程序也可能使用类似的术语,但它们在 Twitter 上并不常见):
22 jobs 0.0660377358491
30 looking 0.0566037735849
31 Job 0.0566037735849
34 Developer 0.0566037735849
38 Freelancer 0.0471698113208
40 projects 0.0471698113208
47 We're 0.0471698113208
最后一个通常采用以下格式:我们正在寻找该职位的候选人。
浏览这些功能会给我们带来很多好处。 我们可以训练人们认识这些推文,寻找共同点(可以深入了解某个主题),甚至摆脱毫无意义的功能。 例如,单词 RT 在此列表中显得很高。 但是,这是推特上常见的 Twitter 短语(即在其他人的推文上转发)。 专家可以决定从列表中删除该单词,从而使分类器因数据集较小而较不容易受到噪声的影响。




查找子图
从我们的相似度函数中,我们可以简单地为每个用户对结果进行排名,并以最相似的用户作为推荐来返回-就像我们对产品推荐一样。 相反,我们可能想要查找彼此相似的用户集群。 我们可以建议这些用户成立一个小组,针对该细分受众群创建广告,甚至只是使用这些集群自己进行推荐。
查找相似用户的这些集群是一项称为集群分析的任务。 这是一项艰巨的任务,具有分类任务通常没有的复杂性。 例如,评估分类结果相对容易-我们将结果与基本事实进行比较(来自我们的训练集),然后查看正确的百分比。 但是,对于聚类分析,通常没有基本事实。 基于一些先入为主的概念,我们通常会评估集群是否有意义,这取决于集群的外观。 聚类分析的另一个复杂之处在于,无法针对预期的学习结果训练模型-它必须基于聚类的数学模型使用一些近似值,而不是用户希望从分析中获得的结果。
连接的组件
最简单的聚类方法之一是在图中找到连接的组件。 连接的组件是图中通过边连接的一组节点。 并非所有节点都需要彼此连接才能成为连接的组件。 但是,要使两个节点位于同一连接的组件中,就需要一种从该连接的组件中的一个节点移动到另一个节点的方法。
注意
连接的组件在计算时不考虑边缘权重; 他们只检查边缘的存在。 因此,后面的代码将删除重量较轻的任何边缘。
NetworkX 具有用于计算连接的组件的功能,可以在图形上调用它。 首先,我们使用create_graph函数创建一个新图形,但是这次我们将的阈值设置为 0.1,以仅获得权重至少为 0.1 的那些边。
G = create_graph(friends, 0.1)
然后,我们使用 NetworkX 在图中找到连接的组件:
sub_graphs = nx.connected_component_subgraphs(G)
为了使了解图的大小,我们可以遍历各组并打印出一些基本信息:
for i, sub_graph in enumerate(sub_graphs):
n_nodes = len(sub_graph.nodes())
print("Subgraph {0} has {1} nodes".format(i, n_nodes))
结果将告诉您每个连接的组件有多大。 我的结果有一个大子图,其中包含 62 个用户,而很多小子中,则有 12 个或更少的用户。
我们可以更改阈值以更改连接的组件。 这是因为较高的阈值具有较少的连接节点的边缘,因此将具有较小的连接组件,并且连接的组件更多。 我们可以通过以更高的阈值运行前面的代码来看到这一点:
G = create_graph(friends, 0.25)
sub_graphs = nx.connected_component_subgraphs(G)
for i, sub_graph in enumerate(sub_graphs):
n_nodes = len(sub_graph.nodes())
print("Subgraph {0} has {1} nodes".format(i, n_nodes))
前面的代码为我们提供了更小的节点和更多的节点。 我最大的群集至少分为三个部分,并且这些群集中的任何一个都没有超过 10 个用户。 下图显示了一个示例集群,并且还显示了该集群内的连接。 请注意,由于它是一个连接的组件,因此从该组件中的节点到图形中的其他节点之间没有任何边(至少将阈值设置为 0.25):

我们也可以用绘制整个集合的图形,以不同的颜色显示每个连接的组件。 由于这些连接的组件未相互连接,因此将它们绘制在单个图形上实际上没有任何意义。 这是因为节点和组件的位置是任意的,并且可能使可视化混乱。 相反,我们可以将它们分别绘制在单独的子图上。
在新单元格中,获取连接的组件以及连接的组件的数量:
sub_graphs = nx.connected_component_subgraphs(G)
n_subgraphs = nx.number_connected_components(G)
注意
sub_graphs是一个生成器,而不是已连接组件的列表。 因此,请使用nx.number_connected_components找出有多少个已连接的组件。 不要使用len,因为 NetworkX 存储此信息的方式不起作用。 这就是为什么我们需要在此处重新计算连接的组件的原因。
创建一个新的图形,并为提供足够的空间来显示我们所有连接的组件。 因此,我们允许图形随着连接的组件数的增加而增大:
fig = plt.figure(figsize=(20, (n_subgraphs * 3)))
接下来,遍历每个连接的组件并为每个组件添加一个子图。 add_subplot的参数是我们感兴趣的子图的行数,列数和子图的索引。我的可视化使用三列,但是您可以尝试其他值而不是三列(记住要更改 两个值):
for i, sub_graph in enumerate(sub_graphs):
ax = fig.add_subplot(int(n_subgraphs / 3), 3, i)
默认情况下,pyplot显示带有轴标签的图,在这种情况下是没有意义的。 因此,我们关闭标签:
ax.get_xaxis().set_visible(False)
ax.get_yaxis().set_visible(False)
然后,我们绘制节点和边(使用ax参数绘制到正确的子图)。 为此,我们还需要首先设置一个布局:
pos = nx.spring_layout(G)
nx.draw_networkx_nodes(G, pos, sub_graph.nodes(), ax=ax, node_size=500)
nx.draw_networkx_edges(G, pos, sub_graph.edges(), ax=ax)
结果直观地显示了每个连接的组件,使我们对每个组件中的节点数量以及它们之间的连接方式有了一定的了解。

优化标准
我们用于查找这些连接组件的算法依赖于阈值参数,该阈值参数指示是否将边缘添加到图形中。 反过来,这直接决定了我们发现多少个相连的组件以及它们的大小。 从这里开始,我们可能想要确定使用最佳阈值的一些概念。 这是一个非常主观的问题,没有明确的答案。 这是任何集群分析任务的主要问题。
但是,我们可以确定我们认为好的解决方案应该是什么样的,并根据该想法定义一个指标。 通常,我们通常需要以下解决方案:
- 同一簇(连接的组件)中的样本彼此之间高度相似
** 不同簇中的样本彼此之间与高度不同*
*Silhouette Coefficient是量化这些点的度量。 给定一个样本,我们将轮廓系数定义如下:

其中a是集群内距离或到样本集群中与其他样本的平均距离,b是集群间距离 或下一个簇中与其他样本的平均距离。
为了计算总体轮廓系数,我们取每个样本的轮廓平均值。 提供接近最大 1 的轮廓系数的聚类,其聚类具有彼此相似的样本,并且这些聚类非常分散。 接近 0 的值表示聚类全部重叠,并且聚类之间几乎没有区别。 接近最小值-1 的值表示样本可能位于错误的群集中,也就是说,在其他群集中效果最好。
使用此度量,我们希望找到一种解决方案(即阈值),该解决方案通过更改阈值参数来最大化轮廓系数。 为此,我们创建一个将阈值作为参数并计算轮廓系数的函数。
然后,将其传递到 SciPy 的optimize模块中,该模块包含minimize函数,该函数用于通过更改参数之一来查找函数的最小值。 尽管我们对最大化轮廓系数感兴趣,但是 SciPy 没有最大化功能。 取而代之的是,我们最小化 Silhouette 的逆(基本上是同一件事)。
scikit-learn 库具有用于计算轮廓系数sklearn.metrics.silhouette_score的功能; 但是,它不能解决 SciPy minimize函数所需的函数格式。 最小化函数要求变量参数为第一个(在我们的示例中为阈值),而任何参数都在其后。 在我们的例子中,我们需要将friends字典作为参数传递,以计算图形。 代码如下:
def compute_silhouette(threshold, friends):
然后,我们使用 threshold 参数创建图,并检查它是否至少包含一些节点:
G = create_graph(friends, threshold=threshold)
if len(G.nodes()) < 2:
除非至少有两个节点(以便完全计算距离),否则不定义轮廓系数。 在这种情况下,我们将问题范围定义为无效。 有几种方法可以解决此问题,但最简单的方法是返回非常差的分数。 在我们的示例中,轮廓系数可以采用的最小值是-1,我们将返回-99 表示无效问题。 任何有效的解决方案都将获得更高的分数。 代码如下:
return -99
然后,我们提取连接的组件:
sub_graphs = nx.connected_component_subgraphs(G)
仅当我们具有至少两个连接的组件(以计算群集间距离)并且这些连接的组件中的至少一个具有两个成员(以计算群集内距离)时,才定义 Silhouette。 我们对这些情况进行测试,如果不合适,则返回无效的问题分数。 代码如下:
if not (2 <= nx.number_connected_components() < len(G.nodes()) - 1):
return -99
接下来,我们需要获取指示每个样本放置在哪个连接组件上的标签。我们在所有连接的组件上进行迭代,并在字典中注明哪个用户属于哪个连接的组件。 代码如下:
label_dict = {}
for i, sub_graph in enumerate(sub_graphs):
for node in sub_graph.nodes():
label_dict[node] = i
然后,我们遍历图中的节点以按顺序获取每个节点的标签。 我们需要执行此两步过程,因为节点在图中没有明确排序,但是只要不对图进行任何更改,它们就可以维持其顺序。 这意味着在更改图表之前,我们可以在图表上调用.nodes()以获得相同的顺序。 代码如下:
labels = np.array([label_dict[node] for node in G.nodes()])
接下来,轮廓系数函数采用距离矩阵,而不是图。 解决这个问题是另外两个步骤。 首先,NetworkX 提供了一个方便的函数to_scipy_sparse_matrix,该函数以可以使用的矩阵格式返回图形:
X = nx.to_scipy_sparse_matrix(G).todense()
在撰写本文时,scikit-learn 中的 Silhouette Coefficient 实现不支持稀疏矩阵。 因此,我们需要调用todense()函数。 通常,这是个坏主意-通常使用稀疏矩阵,因为数据通常不应采用密集格式。 在这种情况下,会很好,因为我们的数据集相对较小; 但是,不要对较大的数据集尝试这样做。
注意
为了评估稀疏数据集,我建议您研究 V 度量或调整后的相互信息。 这些都是在 scikit-learn 中实现的,但是它们具有非常不同的参数来执行评估。
但是,这些值基于我们的权重,这是相似的,而不是距离。 对于距离,值越高表示差异越大。 我们可以从最大可能值中减去该值,从而将相似度转换为距离,这对于我们的权重为 1:
X = 1 - X
现在我们有了距离矩阵和标签,因此我们有了计算轮廓系数所需的所有信息。 我们将度量传递为precomputed; 否则,矩阵 X 将被视为特征矩阵,而不是距离矩阵(默认情况下,在 scikit-learn 中几乎所有地方都使用特征矩阵)。 代码如下:
return silhouette_score(X, labels, metric='precomputed')
注意
我们在这里发生两种形式的反转。 首先是采用相似度的逆函数来计算距离函数。 这是必需的,因为轮廓系数仅接受距离。 第二个是轮廓系数得分的倒数,因此我们可以使用 SciPy 的optimize模块最小化。
但是,我们有一个小问题。 此函数返回轮廓系数,该系数是一个得分,其中较高的值被认为更好。 Scipy 的optimize模块仅定义了minimize函数,该函数在分数较低的loss函数上起作用。 我们可以通过反转值来解决此问题,该值采用score函数并返回loss函数。
def inverted_silhouette(threshold, friends):
return -compute_silhouette(threshold, friends)
该功能从原始功能创建了一个新功能。 调用新函数时,所有相同的参数和关键字都传递到原始函数上,并返回返回值,但在返回之前取反该返回值。
现在我们可以进行实际的优化了。 我们在定义的反向compute_silhouette函数上调用minimize函数:
result = minimize(inverted_silhouette, 0.1, args=(friends,))
参数如下:
invert(compute_silhouette):这是我们要最小化的功能(记住,我们将其反转以使其变为损失功能)0.1:这是在某个阈值处的初始猜测,该阈值将使功能最小化options={'maxiter':10}:这表明仅要执行 10 次迭代(增加迭代次数可能会得到更好的结果,但运行时间会更长)method='nelder-mead':用于选择 Nelder-Mead 优化路由(SciPy 支持很多不同的选项)args=(friends,):这会将friends词典传递给正在最小化的函数
注意
此功能将需要一段时间才能运行。 我们的图形创建功能不是那么快,计算轮廓系数的功能也不是那么快。 减小maxiter值将导致执行的迭代次数减少,但是我们冒着找到次优解决方案的风险。
运行此函数,我得到的阈值为 0.135,该阈值返回 10 个组件。 最小化函数返回的分数为-0.192。 但是,我们必须记住,我们否定了这个价值。 这意味着我们的分数实际上是 0.192。 值是正数,表示群集比没有群集的分离程度更高(这是一件好事)。 我们可以运行其他模型,并检查其结果是否更好,这意味着群集可以更好地分离。
我们可以使用结果来推荐用户-如果一个用户在连接的组件中,那么我们可以推荐该组件中的其他用户。 在此建议之前,我们使用了“杰卡德相似性”来找到用户之间的良好连接,使用了连接的组件将它们分成多个集群,并使用了优化技术来找到此设置中的最佳模型。
但是,可能根本没有连接大量用户,因此我们将使用其他算法为他们找到集群。



*
二十九、使用神经网络击败验证码
长期以来,解释图像中包含的信息一直是数据挖掘中的难题,但这是一个真正开始得到解决的问题。 最新的研究正在提供算法,以检测和理解图像,直到主要供应商在现实世界中使用自动化商业监视系统。 这些系统能够理解和识别录像中的物体和人物。
从图像中提取信息是困难的。 图像中有许多原始数据,而对图像进行编码的标准方法(像素)本身并不能提供足够的信息。 图像(尤其是照片)可能会模糊,太靠近目标,太暗,太亮,缩放,裁剪,偏斜或其他各种问题,这些问题会给试图提取有用信息的计算机系统造成破坏。
在本章中,我们着眼于使用神经网络预测每个字母从图像中提取文本。 我们正在尝试解决的问题是自动理解 CAPTCHA 消息。 根据首字母缩写词:CAPTCHA 是旨在使人类易于解决而计算机难以解决的图像:全自动公共图灵测试,用于区分计算机和人类。 许多网站使用它们进行注册和评论系统,以阻止自动化程序用假帐户和垃圾评论充斥其网站。
本章涵盖的主题包括:
- 神经网络
- 创建我们自己的验证码和字母数据集
- scikit-image 库,用于处理图像数据
- 神经网络的 PyBrain 库
- 从图像中提取基本特征
- 使用神经网络进行大规模分类任务
- 使用后处理提高性能
人工神经网络
神经网络是一类算法,最初是根据人脑的工作方式设计的。 但是,现代技术通常基于数学而非生物学见解。 神经网络是连接在一起的神经元的集合。 每个神经元都是其输入的简单函数,它生成一个输出:

定义神经元处理的函数可以是任何标准函数,例如输入的线性组合,称为激活函数。 为了使常用的学习算法正常工作,我们需要激活函数是可导出的且平滑的。 常用的激活函数是逻辑函数,由以下等式定义(k通常简单为 1,x是神经元的输入,L [ 通常为 1,即该函数的最大值):

该图的值从-6 到+6,如下所示:

红线表示x为零时的值为 0.5。
每个单独的神经元接收其输入,然后根据这些值计算输出。 神经网络只是将这些神经元连接在一起的网络,它们对于数据挖掘应用可能非常强大。 这些神经元的组合,它们如何组合在一起以及如何组合以学习模型是机器学习中最强大的概念之一。
神经网络介绍
对于数据挖掘应用,神经元的排列通常位于层中。 第一层是输入层,它从数据集中获取输入。 计算每个神经元的输出,然后将其传递到下一层中的神经元。 这称为前馈 神经网络。 在本章中,我们将这些简称为神经网络。 还有其他类型的神经网络可用于不同的应用。 我们将在第 11 章,“使用深度学习”对图像中的对象进行分类中看到另一种类型的网络。
一层的输出变为下一层的输入,一直持续到到达最后一层:输出层为止。 这些输出将神经网络的预测表示为分类。 输入层和输出层之间的任何神经元层都被称为隐藏层,因为他们学习了人类无法直观理解的数据表示。 大多数神经网络至少具有三层,尽管大多数现代应用使用的网络要多得多。

通常,我们考虑完全连接的层。 一层中每个神经元的输出将到达下一层中的所有神经元。 虽然我们确实定义了一个完全连接的网络,但在训练过程中许多权重将被设置为零,从而有效地删除了这些链接。 完全连接的神经网络也比其他连接模式更简单,更有效地编程。
由于神经元的功能通常是逻辑功能,并且神经元完全连接到下一层,因此用于构建和训练神经网络的参数必须是其他因素。 神经网络的第一个因素是在构建阶段:神经网络的大小。 这包括神经网络有多少层以及每个隐藏层中有多少神经元(输入和输出层的大小通常由数据集决定)。
在训练阶段确定神经网络的第二个参数:神经元之间连接的权重。 当一个神经元连接到另一个神经元时,该连接具有关联的权重,该权重乘以信号(第一个神经元的输出)。 如果连接的权重为 0.8,则神经元被激活,并输出值 1,下一个神经元的输入结果为 0.8。 如果第一个神经元未激活且值为 0,则保持为 0。
适当大小的网络和训练有素的权重的结合确定了进行分类时神经网络的准确性。 “适当地”一词也不一定意味着更大,因为太大的神经网络需要花费很长时间进行训练,并且更容易过拟合训练数据。
注意
通常从一开始就随机设置权重,然后在训练阶段进行权重更新。
现在,我们有了一个分类器,该分类器具有要设置的初始参数(网络的大小)和要从数据集中训练的参数。 然后,分类器可用于基于输入来预测数据样本的目标,这与我们在前几章中使用的分类算法非常相似。 但是首先,我们需要一个数据集进行训练和测试。
创建数据集
在本章中,我们将扮演坏蛋的角色。 我们要创建一个可以击败验证码的程序,从而使我们的垃圾评论程序能够在某人的网站上做广告。 应当指出,我们的验证码将比今天在网络上使用的验证码要容易一些,并且垃圾邮件并不是一件好事。
我们的验证码将仅是四个字母的单个英文单词,如下图所示:

我们的目标将是创建一个程序,可以从这样的图像中恢复单词。 为此,我们将使用四个步骤:
- 将图像分成单个字母。
- 对每个单独的字母进行分类。
- 重新组合字母以形成单词。
- 使用字典对单词进行排名,以尝试纠正错误。
我们的验证码消除算法将做出以下假设。 首先,该单词将是一个完整且有效的四字符英文单词(实际上,我们使用相同的词典来创建和删除验证码)。 其次,单词将仅包含大写字母。 不会使用符号,数字或空格。 我们将使问题更加棘手:我们将执行剪切转换为文本,以及不同的剪切速率。
绘制基本的验证码
接下来,我们开发用于创建验证码的功能。 我们的目标是绘制带有单词的图像以及剪切变换。 我们将使用PIL库绘制我们的验证码,并使用scikit-image库执行剪切变换。 scikit-image库可以读取PIL可以导出到的 NumPy 数组格式的图像,从而允许我们使用这两个库。
注意
PIL和scikit-image均可通过pip安装:
pip install PIL
pip install scikit-image
首先,我们导入必要的库和模块。 我们导入 NumPy 和Image绘图函数,如下所示:
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from skimage import transform as tf
然后,我们创建我们的基本函数以生成验证码。 此函数需要一个单词和一个剪切值(通常在 0 到 0.5 之间)以 NumPy 数组格式返回图像。 我们允许用户设置结果图像的大小,因为我们还将将此功能用于单字母训练样本。 代码如下:
def create_captcha(text, shear=0, size=(100, 24)):
我们使用 L 作为格式创建一个新图像,即仅表示黑白像素,然后创建ImageDraw类的实例。 这使我们可以使用PIL绘制此图像。 代码如下:
im = Image.new("L", size, "black")
draw = ImageDraw.Draw(im)
接下来,我们设置将使用的验证码的字体。 您将需要一个字体文件,并且以下代码(Coval.otf)中的文件名应指向该文件(我只是将文件放置在Notebook's目录中。
font = ImageFont.truetype(r"Coval.otf", 22)
draw.text((2, 2), text, fill=1, font=font)
注意
您可以从这个页面的开放字体库中获得我使用的 Coval 字体。
我们将 PIL 图像转换为 NumPy 数组,这使我们可以使用scikit-image对它进行剪切。 scikit-image库在其大部分计算中倾向于使用 NumPy 数组。 代码如下:
image = np.array(im)
然后,我们应用剪切变换并返回图像:
affine_tf = tf.AffineTransform(shear=shear)
image = tf.warp(image, affine_tf)
return image / image.max()
在最后一行,我们通过除以最大值进行归一化,确保特征值在 0 到 1 的范围内。此归一化可以发生在数据预处理阶段,分类阶段或其他地方。
从这里,我们现在可以很容易地生成图像并使用pyplot来显示它们。 首先,我们将在线显示用于matplotlib图并导入pyplot。 代码如下:
%matplotlib inline
from matplotlib import pyplot as plt
然后,我们创建我们的第一个 CAPTCHA 并显示它:
image = create_captcha("GENE", shear=0.5)
plt.imshow(image, cmap='Greys')
结果是本节开头显示的图像:我们的 CAPTCHA。
将图像分割成单个字母
我们的验证码是单词。 与其构建可以识别成千上万个可能单词的分类器,不如将问题分解为一个较小的问题:预测字母。
击败这些验证码的算法的下一步涉及对单词进行分段以发现其中的每个字母。 为此,我们将创建一个函数,以查找图像上黑色像素的连续部分并将其提取为子图像。 这些是(或至少应该是)我们的来信。
首先,我们导入label和regionprops函数,我们将在此函数中使用它们:
from skimage.measure import label, regionprops
我们的函数将拍摄一张图像,并返回一个子图像列表,其中每个子图像都是图像中原始单词的字母:
def segment_image(image):
我们需要做的第一件事是检测每个字母在哪里。 为此,我们将使用scikit-image中的标签功能,该功能可找到具有相同值的相连像素集。 这类似于我们在第 7 章,“使用图形挖掘”中发现要遵循的帐户中的连接组件发现。
label函数拍摄图像并返回与原始形状相同的数组。 但是,每个连接区域在阵列中具有不同的编号,不在连接区域中的像素的值为 0。代码如下:
labeled_image = label(image > 0)
我们将提取每个子图像并将其放入列表中:
subimages = []
scikit-image库还包含用于提取有关以下区域的信息的功能:regionprops。 我们可以遍历这些区域并分别处理每个区域:
for region in regionprops(labeled_image):
从这里,我们可以查询region对象以获取有关当前区域的信息。 对于我们的算法,我们需要获取当前区域的开始和结束坐标:
start_x, start_y, end_x, end_y = region.bbox
然后,我们可以通过使用子图像的开始位置和结束位置对图像进行索引(请记住将其表示为简单的 NumPy 数组,以便我们可以轻松对其进行索引)来提取子图像,并将所选子图像添加到 我们的清单。 代码如下:
subimages.append(image[start_x:end_x,start_y:end_y])
最后(在循环外部),我们返回发现的子图像,每个子图像(希望是)都包含图像中带有单个字母的部分。 但是,如果未找到任何子图像,则仅将原始图像作为唯一的子图像返回。 代码如下:
if len(subimages) == 0:
return [image,]
return subimages
然后,我们可以使用以下函数从示例验证码中获取子图像:
subimages = segment_image(image)
我们还可以查看以下每个子图像:
f, axes = plt.subplots(1, len(subimages), figsize=(10, 3))
for i in range(len(subimages)):
axes[i].imshow(subimages[i], cmap="gray")
结果将如下所示:

正如您所看到的,我们的图像分割做得很合理,但是结果仍然很混乱,显示了一些先前的字母。
创建训练数据集
使用此功能,我们现在可以创建字母的数据集,每个字母具有不同的剪切值。 由此,我们将训练一个神经网络来识别图像中的每个字母。
首先,我们设置随机状态和一个数组,其中包含我们将从中随机选择的字母和剪切值的选项。 这里没有什么奇怪的,但是如果您以前没有使用过 NumPy 的arange函数,则它类似于 Python 的range函数-区别在于该函数可与 NumPy 数组一起使用并使步骤成为浮点数。 代码如下:
from sklearn.utils import check_random_state
random_state = check_random_state(14)
letters = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
shear_values = np.arange(0, 0.5, 0.05)
然后,我们创建一个函数(用于在训练数据集中生成单个样本),该函数从可用选项中随机选择一个字母和一个剪切值。 代码如下:
def generate_sample(random_state=None):
random_state = check_random_state(random_state)
letter = random_state.choice(letters)
shear = random_state.choice(shear_values)
然后,我们返回字母的图像以及代表图像中字母的目标值。 我们的班级对于 A 来说是 0,对于 B 来说是 1,对于 C 来说是 2,依此类推。 代码如下:
return create_captcha(letter, shear=shear, size=(20, 20)), letters.index(letter)
在功能块之外,我们现在可以调用此代码以生成新的示例,然后使用pyplot进行显示:
image, target = generate_sample(random_state)
plt.imshow(image, cmap="Greys")
print("The target for this image is: {0}".format(target))
现在,我们可以通过调用数千次来生成所有数据集。 然后,我们将数据放入 NumPy 数组中,因为它们比列表更易于使用。 代码如下:
dataset, targets = zip(*(generate_sample(random_state) for i in range(3000)))
dataset = np.array(dataset, dtype='float')
targets = np.array(targets)
我们的目标是 0 到 26 之间的整数值,每个值代表一个字母。 神经网络通常不支持单个神经元的多个值,而是更喜欢具有多个输出,每个输出的值为 0 或 1。因此,我们对目标执行一次热编码,从而为我们提供了一个目标数组,每个样本具有 26 个输出 ,如果该字母很可能使用接近 1 的值,否则使用接近 0 的值。 代码如下:
from sklearn.preprocessing import OneHotEncoder
onehot = OneHotEncoder()
y = onehot.fit_transform(targets.reshape(targets.shape[0],1))
我们将要使用的库不支持稀疏数组,因此我们需要将稀疏矩阵转换为密集的 NumPy 数组。 代码如下:
y = y.todense()
根据我们的方法调整训练数据集
我们的训练数据集与我们的最终方法有很大不同。 我们的数据集是精心创建的单个字母,适合 20 像素乘 20 像素的图像。 该方法包括从单词中提取字母,这可能会挤压它们,将其移离中心或产生其他问题。
理想情况下,您训练分类器所使用的数据应模拟将要使用的环境。在实践中,我们做出了让步,但目的是尽可能地减少差异。
对于此实验,理想情况下,我们将从实际的验证码中提取字母并将其标记。 为了稍微加快该过程,我们将在训练数据集上运行分段功能,然后返回这些字母。
我们将需要scikit-image中的resize函数,因为我们的子图像并不总是 20 像素乘 20 像素。 代码如下:
from skimage.transform import resize
从这里开始,我们可以对每个样本运行segment_image函数,然后将它们的大小调整为 20 x 20 像素。 代码如下:
dataset = np.array([resize(segment_image(sample)[0], (20, 20)) for sample in dataset])
最后,我们将创建我们的数据集。 此dataset数组是三维的,因为它是二维图像的数组。 我们的分类器将需要一个二维数组,因此我们只需将最后两个维度展平:
X = dataset.reshape((dataset.shape[0], dataset.shape[1] * dataset.shape[2]))
最后,使用 scikit-learn 的train_test_split函数,我们创建了一组训练数据和一个测试数据。 代码如下:
from sklearn.cross_validation import train_test_split
X_train, X_test, y_train, y_test = \
train_test_split(X, y, train_size=0.9)

训练和分类
现在,我们将构建一个神经网络,该神经网络将图像作为输入并尝试预测图像中的哪个(单个)字母。
我们将使用我们之前创建的单个字母的训练集。 数据集本身非常简单。 我们有一个 20 x 20 像素的图像,每个像素 1(黑色)或 0(白色)。 这些代表我们将用作神经网络输入的 400 个功能。 输出将是 0 到 1 之间的 26 个值,其中较高的值表示关联字母(第一个神经元是 A,第二个神经元是 B,依此类推)是输入图像表示的字母的可能性更高。
我们将对神经网络使用 PyBrain 库。
注意
与到目前为止我们所看到的所有库一样,可以从pip: pip install pybrain安装 PyBrain。
PyBrain库使用其自己的数据集格式,但幸运的是,使用此格式创建训练和测试数据集并不难。 代码如下:
from pybrain.datasets import SupervisedDataSet
首先,我们在训练数据集上迭代,并将每个作为样本添加到新的SupervisedDataSet实例中。 代码如下:
training = SupervisedDataSet(X.shape[1], y.shape[1])
for i in range(X_train.shape[0]):
training.addSample(X_train[i], y_train[i])
然后,我们遍历测试数据集,并将每个样本作为样本添加到新的SupervisedDataSet实例中进行测试。 代码如下:
testing = SupervisedDataSet(X.shape[1], y.shape[1])
for i in range(X_test.shape[0]):
testing.addSample(X_test[i], y_test[i])
现在我们可以建立一个神经网络。 我们将创建一个基本的三层网络,该网络由输入层,输出层和它们之间的单个隐藏层组成。 输入层和输出层中神经元的数量是固定的。 数据集中的 400 个特征指示第一层需要 400 个神经元,而 26 个可能的目标指示我们需要 26 个输出神经元。
确定隐藏层中神经元的数量可能非常困难。 太多的结果会导致网络稀疏,这意味着很难训练足够的神经元来正确表示数据。 这通常会导致训练数据过拟合。 如果神经元中尝试进行太多分类的结果太少而又没有正确训练,则数据不足会成为问题。 我发现创建漏斗形状(中间层介于输入的大小和输出的大小之间)是一个很好的起点。 在本章中,我们将在隐藏层中使用 100 个神经元,但是使用此值可能会产生更好的结果。
我们导入buildNetwork函数,并告诉它根据我们的必要尺寸来构建网络。 第一个值X.shape[1]是输入层中神经元的数量,并设置为要素数量(这是X中的列数)。 第二个特征是我们确定的隐藏层中 100 个神经元的值。 第三个值是输出数量,它基于目标阵列y的形状。 最后,我们将网络设置为对每一层(输出层除外)使用偏向神经元,即有效地始终激活(但仍具有受过训练的权重的连接)的神经元。 代码如下:
from pybrain.tools.shortcuts import buildNetwork
net = buildNetwork(X.shape[1], 100, y.shape[1], bias=True)
现在,从,我们可以训练网络并为权重确定好的值。 但是我们如何训练神经网络呢?
反向传播
反向传播(反向传播)算法是一种将错误归因于错误预测的每个神经元的方法。 从输出层开始,我们计算哪些神经元的预测不正确,并以少量调整这些神经元的权重以尝试修正错误的预测。
这些神经元犯了错误,是因为神经元向它们提供了输入,但更具体地说,是由于神经元与其输入之间的连接权重。 然后,我们通过少量更改它们来更改这些权重。 变化量基于两个方面:神经元单个权重的误差函数的偏导数和学习率,这是算法的参数(通常设置为非常低的值)。 我们计算函数误差的梯度,将其乘以学习率,然后从权重中减去该梯度。 在下面的示例中显示。 取决于误差,该梯度将为正或负,并且减去权重将始终尝试将权重朝校正为正确的预测。 不过,在某些情况下,校正会朝着称为局部最优值的方向发展,该最优值优于类似的权重,但不是最佳的权重集。
此过程从输出层开始,然后返回每一层,直到我们到达输入层。 此时,所有连接上的权重已更新。
PyBrain 包含 backprop 算法的实现,该算法通过trainer类在神经网络上调用。 代码如下:
from pybrain.supervised.trainers import BackpropTrainer
trainer = BackpropTrainer(net, training, learningrate=0.01, weightdecay=0.01)
使用训练数据集反复运行反向传播算法,每次调整权重时都会进行一次。 当错误减少非常少时,我们可以停止运行 backprop,这表明该算法不能进一步改善错误,并且不值得继续进行训练。 在理论中,我们将运行算法,直到误差完全不改变为止。 这称为收敛,但是实际上这需要很长时间才能获得很少的收益。
或者,更简单地说,我们可以将算法运行固定的次数,称为时期。 纪元数越多,算法将花费的时间越长,结果越好(每个纪元的改进程度都在下降)。 我们将为此代码训练 20 个纪元,但尝试使用更大的值将提高性能(如果只是略微提高)。 代码如下:
trainer.trainEpochs(epochs=20)
在运行了之前的代码(可能需要花费几分钟的时间,具体取决于硬件)之后,我们可以对测试数据集中的样本进行预测。 PyBrain 包含一个为此功能,并在trainer实例上调用它:
predictions = trainer.testOnClassData(dataset=testing)
根据这些预测,我们可以使用scikit-learn计算 F1 分数:
from sklearn.metrics import f1_score
print("F-score: {0:.2f}".format(f1_score(predictions,
y_test.argmax(axis=1) )))
此处的分数是 0.97,对于这样一个相对简单的模型,这是一个不错的结果。 回想一下,我们的功能仅是简单的像素值; 神经网络找出了如何使用它们。
既然我们有了一个字母预测准确度很高的分类器,就可以开始为我们的验证码组合单词。
预测字
我们希望从这些段中的每个段中预测每个字母,并将这些预测放在一起以形成来自给定验证码的预测单词。
我们的函数将接受 CAPTCHA 和经过训练的神经网络,并将返回预测的单词:
def predict_captcha(captcha_image, neural_network):
我们首先使用先前创建的segment_image函数提取子图像:
subimages = segment_image(captcha_image)
我们将从每个字母中建立我们的单词。 子图像是根据其位置排序的,因此通常这将以正确的顺序放置字母:
predicted_word = ""
接下来,我们遍历子图像:
for subimage in subimages:
每个子图像不太可能恰好是 20 像素乘 20 像素,因此我们将需要调整其大小,以便为神经网络拥有正确的尺寸。
subimage = resize(subimage, (20, 20))
我们将通过将子图像数据发送到输入层来激活神经网络。 这会通过我们的神经网络传播并返回给定的输出。 所有这些都发生在我们之前对神经网络的测试中,但是我们不必显式调用它。 代码如下:
outputs = net.activate(subimage.flatten())
神经网络的输出是 26 个数字,每个数字都与给定索引处的字母为预测字母的可能性有关。 为了获得实际的预测,我们获取这些输出的最大值的索引,并从之前的字母列表中查找实际字母。 例如,如果第五个输出的值最高,则预测字母将为E。 代码如下:
prediction = np.argmax(outputs)
然后,我们将预测字母附加到要构建的预测单词上:
predicted_word += letters[prediction]
循环完成后,我们遍历了每个字母并形成了我们预测的单词:
return predicted_word
现在,我们可以使用以下代码对一个单词进行测试。 尝试使用不同的单词,看看会遇到什么样的错误,但是请记住,我们的神经网络只知道大写字母。
word = "GENE"
captcha = create_captcha(word, shear=0.2)
print(predict_captcha(captcha, net))
我们可以将其编码为一个函数,从而使我们可以更轻松地执行预测。 我们还利用了这样的假设,即单词只有四个字符,使预测更容易。 尝试不使用prediction = prediction[:4]行的情况,然后查看您得到的错误类型。 代码如下:
def test_prediction(word, net, shear=0.2):
captcha = create_captcha(word, shear=shear)
prediction = predict_captcha(captcha, net)
prediction = prediction[:4]
return word == prediction, word, prediction
返回的结果指定预测是否正确,原始单词和预测单词。
该代码可以正确预测单词 GENE,但会出错。 它有多精确? 为了进行测试,我们将创建一个数据集,其中包含来自 NLTK 的一串四个四个字母的英语单词。 代码如下:
from nltk.corpus import words
这里的words实例实际上是一个语料库对象,因此我们需要对其调用words()以从该语料库中提取单个单词。 我们还过滤以从此列表中仅获取四个字母的单词。 代码如下:
valid_words = [word.upper() for word in words.words() if len(word) == 4]
然后,我们可以遍历所有单词,只需简单地计算正确和不正确的预测就可以看到有多少正确的单词:
num_correct = 0
num_incorrect = 0
for word in valid_words:
correct, word, prediction = test_prediction(word, net,
shear=0.2)
if correct:
num_correct += 1
else:
num_incorrect += 1
print("Number correct is {0}".format(num_correct))
print("Number incorrect is {0}".format(num_incorrect))
我们得到的结果是 2,832 正确和 2,681 不正确,准确度超过 51%。 从我们最初的每个字母 97%的准确性来看,这是一个很大的下降。 发生了什么?
影响的第一个因素是我们的准确性。 在所有其他条件相同的情况下,如果我们有四个字母,并且每个字母的准确性为 97%,那么我们可以期望大约 88%的成功率(在所有其他条件相同的情况下)连续四个字母(0.88≈0.974)。 单个字母的预测中的单个错误会导致预测错误的单词。
第二个影响是剪切值。 我们的数据集在 0 到 0.5 的剪切值之间随机选择。 先前的测试使用的剪切力为 0.2。 值为 0 时,我的准确度为 75%; 对于 0.5 的剪切,结果差得多,为 2.5%。 剪切力越高,性能越低。
接下来的影响是我们的字母是为数据集随机选择的。 实际上,这根本不是真的。 字母(例如 E)比其他字母(例如 Q)出现的频率要高得多。合理地普遍出现但经常被误认的字母也会导致这种错误。
我们可以使用混淆矩阵(二维数组)来确定哪些字母经常被误认为彼此。 它的行和列分别代表一个单独的类。
每个单元代表样品实际来自一类(由行表示)并被预测为处于第二类(由列表示)的次数。 例如,如果单元格(4,2)的值为 6,则意味着在六种情况下,带有字母 D 的样本被预测为字母 B。
from sklearn.metrics import confusion_matrix
cm = confusion_matrix(np.argmax(y_test, axis=1), predictions)
理想情况下,混淆矩阵应仅沿对角线具有值。 单元格(i, i)具有值,但其他单元格的值为零。 这表明预测的类别与实际的类别完全相同。 不在对角线上的值表示分类中的错误。
我们还可以使用pyplot对此进行绘制,以图形方式显示哪些字母相互混淆。 代码如下:
plt.figure(figsize=(10, 10))
plt.imshow(cm)
我们设置轴和刻度线以轻松引用每个索引对应的字母:
tick_marks = np.arange(len(letters))
plt.xticks(tick_marks, letters)
plt.yticks(tick_marks, letters)
plt.ylabel('Actual')
plt.xlabel('Predicted')
plt.show()
下图显示结果。 可以很清楚地看出,错误的主要根源是几乎每次都将 U 误认为 H!

在列表中 17%的单词中显示字母 U。 对于出现 U 的每个单词,我们可以预期这是错误的。 实际上,U 的出现频率比 H(大约占单词的 11%)多,这表明我们可以通过将任何 H 预测更改为 U 来廉价(尽管可能不是很健壮)地提高准确性。
在的下一部分中,我们将做一些更聪明的操作,并实际使用词典搜索相似的单词。
使用字典提高准确性
除了而不只是返回给定的预测,我们可以检查单词是否确实存在于我们的字典中。 如果是的话,那就是我们的预测。 如果字典中没有它,我们可以尝试找到一个与它相似的词,然后进行预测。 请注意,此策略基于我们的假设,即所有 CAPTCHA 单词都是有效的英语单词,因此该策略不适用于随机字符序列。 这就是某些验证码不使用单词的原因之一。
这里有一个问题-我们如何确定最接近的词? 有很多方法可以做到这一点。 例如,我们可以比较单词的长度。 具有相似长度的两个单词可以被认为更相似。 但是,如果单词在相同位置具有相同字母,我们通常认为它们是相似的。 这是编辑距离的来源。
单词排名机制
Levenshtein 编辑距离是比较两个短字符串的相似度的常用方法。 它不是可扩展性很高的,因此它不常用于很长的字符串。 编辑距离计算从一个单词到到另一个单词所需的步数。 这些步骤可以是以下三个操作之一:
- 在单词的任意位置插入一个新字母。
- 删除单词中的任何字母。
- 用一封信代替另一封信。
将第一个单词转换为第二个单词所需的最少操作数作为距离给出。 较高的值表示单词不太相似。
该距离在 NLTK 中以nltk.metrics.edit_distance的形式提供。 我们可以使用两个字符串来调用它,并返回编辑距离:
from nltk.metrics import edit_distance
steps = edit_distance("STEP", "STOP")
print("The number of steps needed is: {0}".format(steps))
当与不同的单词一起使用时,编辑距离非常接近许多人在直觉上会感觉到的相似单词。 编辑距离非常适合测试拼写错误,听写错误和名称匹配(您可以在其中轻松混合 Marc 和 Mark 拼写)。
但是,它不是很好。 我们并不真正期望字母会四处移动,只是将单个字母进行比较是错误的。 因此,我们将创建一个不同的距离度量标准,该距离度量标准只是在相同位置上不正确的字母数。 代码如下:
def compute_distance(prediction, word):
return len(prediction) - sum(prediction[i] == word[i] for i in range(len(prediction)))
我们从预测字的长度(为四)中减去该值,以使其成为距离度量,其中较低的值表示这些字之间的相似度更高。
全部放在一起
现在,我们可以使用与以前相似的代码来测试改进的预测功能。 首先,我们定义一个预测,该预测也使用我们的有效单词列表:
from operator import itemgetter
def improved_prediction(word, net, dictionary, shear=0.2):
captcha = create_captcha(word, shear=shear)
prediction = predict_captcha(captcha, net)
prediction = prediction[:4]
至此,代码与以前一样。 我们进行预测并将其限制为前四个字符。 但是,我们现在检查单词是否在字典中。 如果是,我们将其作为我们的预测。 如果不是,我们找到下一个最接近的词。 代码如下:
if prediction not in dictionary:
我们计算字典中预测单词与其他单词之间的距离,并按距离排序(最低的优先)。 代码如下:
distances = sorted([(word, compute_distance(prediction, word))
for word in dictionary], key=itemgetter(1))
然后,我们得到匹配度最高的单词(即距离最小的单词)并预测该单词:
best_word = distances[0]
prediction = best_word[0]
然后,我们像以前一样返回正确性,单词和预测:
return word == prediction, word, prediction
以下代码突出显示了我们测试代码中的更改:
num_correct = 0
num_incorrect = 0
for word in valid_words:
correct, word, prediction = improved_prediction(word, net, valid_words, shear=0.2)
if correct:
num_correct += 1
else:
num_incorrect += 1
print("Number correct is {0}".format(num_correct))
print("Number incorrect is {0}".format(num_incorrect))
前面的代码需要一段时间才能运行(计算所有距离都需要一些时间),但最终结果是正确的 3,037 个样本和错误的 2,476 个样本。 这是 55%的准确度,可提高 4 个百分点。 该改进之所以如此之低,是因为多个单词都具有相同的相似性,并且算法是在这组最相似的单词之间随机选择最佳。 例如,列表中的第一个单词 AANI(我刚刚选择了列表中的第一个单词,这是埃及神话中的狗头猿),具有 44 个候选单词,它们与该单词的距离相同。 从列表中选择正确单词的机会只有 1/44。
如果我们作弊并将预测视为正确,如果实际单词是最佳候选者中的任何一个,则我们会将 78%的预测评为正确(要查看此代码,请查看捆绑包中的代码)。
为了进一步改善结果,我们可以研究距离度量,也许使用来自混淆矩阵的信息来查找常见混淆字母或对此进行一些其他改进。 这种迭代式改进是许多数据挖掘方法的一个功能,它模仿了科学方法-有一个主意,对其进行测试,分析结果,然后使用该主意来改进下一个主意。




三十、作者归属
作者身份分析主要是一项文本挖掘任务,旨在仅基于作者的内容来确定有关作者的某些方面。 这可能包括年龄,性别或背景等特征。 在特定的作者归属任务中,我们旨在确定一组特定文档中谁是谁撰写的。 这是分类任务的典型案例。 在许多方面,作者权限分析任务是使用标准数据挖掘方法执行的,例如交叉折叠验证,特征提取和分类算法。
在本章中,我们将使用作者归属问题来拼凑我们在前几章中开发的数据挖掘方法的各个部分。 我们确定问题并讨论问题的背景和知识。 这使我们可以选择要提取的功能,从而为实现建立一个管道。 我们将测试两种不同类型的功能:功能词和字符 n-gram。 最后,我们将对结果进行深入分析。 我们将使用书籍数据集,然后使用非常混乱的真实电子邮件语料库。
我们将在本章中介绍的主题如下:
- 功能工程以及功能根据应用的不同
- 带着特定目标重新审视词袋模型
- 特征类型和字符 n-gram 模型
- 支持向量机
- 清理凌乱的数据集以进行数据挖掘
将文件归给作者
作者身份分析在笔法中具有背景,是对作者写作风格的研究。 这个概念的基础是每个人学习语言的方式略有不同,并且衡量人们写作中的细微差别将使我们能够仅使用他们的写作内容来区分他们。
该问题历来是使用手动分析和统计数据执行的,这很好地表明了可以通过数据挖掘将其自动化。 现代作者分析研究几乎完全基于数据挖掘,尽管仍然有相当多的工作需要使用语言样式进行更多手动驱动的分析。
作者分析有个子问题,主要子问题如下:
- 作者身份分析:这根据撰写内容确定作者的年龄,性别或其他特征。 例如,我们可以通过寻找说英语的特定方式来检测该人说英语的母语。
- 作者身份验证:这检查该文档的作者是否也写了另一文档。 这个问题就是您在法律法庭上通常会想到的。 例如,将分析犯罪嫌疑人的写作风格(从内容上看),看其是否与赎金记录相符。
- 作者身份聚类:这是作者身份验证的扩展,在中,我们使用聚类分析将来自大集合的文档分组为聚类,每个聚类由同一位作者编写。
但是,作者身份分析研究的最常见形式是作者身份归属,这是一种分类任务,我们试图预测一组作者中的哪位撰写了给定文档。
应用和用例
作者分析有许多用例。 的许多使用案例都涉及诸如验证作者身份,证明共享作者身份/出处或将社交媒体资料与实际用户链接等问题。
从历史的角度来看,我们可以使用作者身份分析来验证某些文档是否确实由其假定的作者撰写。 有争议的作者主张包括莎士比亚的一些戏剧,美国建国时期的联邦主义者论文以及其他历史著作。
单独的作者研究不能证明作者,但是可以提供支持或反对给定理论的证据。 例如,在测试给定十四行诗是否确实源于莎士比亚之前,我们可以分析莎士比亚的戏剧以确定他的写作风格。
一个更现代的用例是关联社交网络帐户的用例。 例如,恶意的在线用户可能在多个在线社交网络上设置帐户。 能够链接它们允许当局跟踪给定帐户的用户-例如,如果它正在骚扰其他在线用户。
过去使用的另一个示例是成为法庭上提供专家证词以确定给定人员是否撰写文档的支柱。 例如,犯罪嫌疑人可能被指控撰写骚扰他人的电子邮件。 作者分析的使用可以确定该人是否确实确实在写文件。 另一种基于法院的用途是解决版权被盗的主张。 例如,两名作者可能声称写过一本书,而作者身份分析可以提供证据证明可能的作者。
作者分析并不是万无一失的。 最近的一项研究发现,仅要求未受过训练的人隐藏其写作风格,就很难将文档赋予作者。 这项研究还研究了一种框架练习,要求人们以另一种人的风格写作。 事实证明,这种对他人的取证非常可靠,伪造的文件通常归因于被陷害的人。
尽管存在这些问题,作者身份分析仍在越来越多的领域中被证明是有用的,并且是一个有趣的数据挖掘问题,需要研究。
署名作者
作者身份归因是一项分类任务,通过该任务,我们具有一组候选作者,每个作者的一组文档(训练集)和一组未知作者的文档(测试集) 。 如果作者身份不明的文档肯定是属于其中一个候选者,我们将其称为封闭问题。

如果我们不能确定,则将其称为未解决的问题。 但是,这种区别不仅仅针对作者归属-在实际训练中可能没有实际班级的任何数据挖掘应用都被认为是一个开放性问题,任务是查找候选作者或不选择任何作者。

在作者身份归属中,我们通常对任务有两个限制。 首先,我们仅使用文档中的内容信息,而不使用有关书写时间,交付时间,笔迹样式等的元数据。 有多种方法可以将这些不同类型的信息中的模型进行组合,但这通常不被认为是作者身份归属,它更是一种数据融合应用。
第二个限制是我们不关注文档主题。 相反,我们寻找更显着的功能,例如单词使用,标点符号和其他基于文本的功能。 这里的理由是,一个人可以写许多不同的主题,因此担心他们的写作主题不会模拟他们的实际作者风格。 查看主题词也可能导致训练数据过拟合-我们的模型可能会训练来自同一作者的文档,也可能针对同一主题。 例如,如果您要通过查看此模块来建模我的作者风格,则您可能会得出结论,数据挖掘表示我的风格,而实际上我也在其他主题上写作。
从这里开始,用于执行作者身份归因的管道看起来很像我们在第 6 章,“使用朴素贝叶斯”的社交媒体洞察中开发的管道。 首先,我们从文本中提取特征。 然后,我们对这些功能执行一些功能选择。 最后,我们训练分类算法以适合模型,然后将其用于预测文档的类(在这种情况下为作者)。
我们将在本章中介绍一些差异,主要与所使用的功能有关。 但是首先,我们将定义问题的范围。
获取数据
我们将在本章中使用的数据是 Gutenberg 项目在这个页面上的一组书籍,该书籍是公共领域文献作品的存储库。 我用于这些实验的书籍来自不同的作者:
- 摊位 Tarkington(22 个标题)
- 查尔斯·狄更斯(44 题)
- 伊迪丝·内斯比特(10 个标题)
- 亚瑟·柯南·道尔(51 题)
- 马克·吐温(29 个标题)
- 理查德·弗朗西斯·伯顿爵士(11 个冠军)
- 埃米尔·加博里奥(10 标题)
总体而言,有 7 位作者撰写的 177 篇文档,为您提供了大量可参考的文字。 代码包中提供了标题的完整列表,以及下载链接和自动获取它们的脚本。
要下载这些书,我们使用请求库将文件下载到我们的数据目录中。 首先,设置数据目录并确保以下代码链接到该目录:
import os
import sys
data_folder = os.path.join(os.path.expanduser("~"), "Data", "books")
接下来,从代码包中运行脚本以从 Gutenberg 项目下载每本书。 这会将它们放置在此数据文件夹的相应子文件夹中。
要运行该脚本,请从代码包的Chapter 9文件夹中下载getdata.py脚本。 将其保存到notebooks文件夹,然后在新单元格中输入以下内容:
!load getdata.py
然后,从 IPython Notebook 的中,按 Shift + 输入以运行单元格。 这会将脚本加载到单元格中。 然后再次单击代码,然后按 Shift + 输入以运行脚本本身。 这将花费一些时间,但是它将打印一条消息以通知您它已完成。
在查看了这些文件之后,您会发现其中的许多文件都是非常混乱的-至少从数据分析的角度来看。 文件开始处有一个大型项目 Gutenberg 免责声明。 在进行分析之前,需要将其删除。
我们可以更改磁盘上的单个文件以删除这些内容。 但是,如果我们丢失数据怎么办? 我们将丢失所做的更改,并可能无法复制研究。 因此,我们将在加载文件时执行预处理-这使我们可以确保结果是可复制的(只要数据源保持不变)。 代码如下:
def clean_book(document):
我们首先将文档分成几行,因为我们可以通过开始和结束行来标识免责声明的开始和结束:
lines = document.split("\n")
我们将遍历每一行。 我们寻找指示书的开始的线和指示书的结束的线。 然后,我们将介于两者之间的文本作为本书本身。 代码如下:
start = 0
end = len(lines)
for i in range(len(lines)):
line = lines[i]
if line.startswith("*** START OF THIS PROJECT GUTENBERG"):
start = i + 1
elif line.startswith("*** END OF THIS PROJECT GUTENBERG"):
end = i - 1
最后,我们将这些行与换行符连接在一起,以重新创建不带免责声明的书:
return "\n".join(lines[start:end])
现在,从这里,我们可以创建一个函数,该函数加载所有书籍,执行预处理,并将其与每个作者的班级号一起返回。 代码如下:
import numpy as np
默认情况下,我们的函数签名使用包含每个包含实际书籍的子文件夹的父文件夹。 代码如下:
def load_books_data(folder=data_folder):
我们创建用于存储文档本身和作者类的列表:
documents = []
authors = []
然后,我们直接在父级中创建每个子文件夹的列表,因为脚本为每个作者创建了一个子文件夹。 代码如下:
subfolders = [subfolder for subfolder in os.listdir(folder)
if os.path.isdir(os.path.join(folder, subfolder))]
接下来,我们遍历这些子文件夹,并使用enumerate为每个子文件夹分配一个数字:
for author_number, subfolder in enumerate(subfolders):
然后,我们创建完整的子文件夹路径,并在该子文件夹中查找所有文档:
full_subfolder_path = os.path.join(folder, subfolder)
for document_name in os.listdir(full_subfolder_path):
对于每个文件,我们都将其打开,阅读其中的内容,对其进行预处理并将其附加到我们的文档列表中。 代码如下:
with open(os.path.join(full_subfolder_path, document_name)) as inf:
documents.append(clean_book(inf.read()))
我们还将分配给该作者的编号附加到我们的作者列表中,这将构成我们的课程:
authors.append(author_number)
然后,我们返回文档和类(稍后将其转换为每个索引的 NumPy 数组):
return documents, np.array(authors, dtype='int')
现在,我们可以使用以下函数调用获取我们的文档和类:
documents, classes = load_books_data(data_folder)
注意
该数据集很容易装入内存,因此我们可以一次加载所有文本。 如果整个数据集都不适合,则更好的解决方案是一次(或分批)从每个文档中提取特征,并将结果值保存到文件或内存矩阵中。
功能词
最早的一种功能,对于作者权分析仍然非常有效,一种功能是在词袋模型中使用功能词。 功能词是本身没有什么意义的词,但是创建(英语)句子是必需的。 例如,单词,和(其中)实际上是仅由它们在句子中的作用而不是其含义定义的词。 将此内容与诸如 Tiger 之类的内容词进行对比,该词具有明确的含义,当在句子中使用时会调用大型猫的图像。
并非总是清楚地阐明功能词。 一个好的经验法则是选择使用频率最高的单词(在所有可能的文档中,而不仅仅是同一作者的文档)。 通常,单词使用得越频繁,对作者身份分析的效果就越好。 相反,单词使用的频率越少,基于内容的文本挖掘就越有用,例如在下一章中,我们将讨论不同文档的主题。

功能词的使用较少地由文档的内容来定义,而更多地由作者的决定来定义。 这使它们成为区分不同用户之间的作者特征的理想人选。 例如,虽然许多美国人特别关注和在句子中所用的与之间的区别,但是来自其他国家(如澳大利亚)的人们对此不太关注。 这意味着一些澳大利亚人将倾向于只使用一个单词或另一个单词,而另一些澳大利亚人可能会使用,而更多。 这种差异加上成千上万的其他细微差异构成了作者身份的模型。**
计数功能字
我们可以使用第 6 章,“使用朴素贝叶斯”的社交媒体洞察力来使用CountVectorizer类对功能词进行计数。 此类可以通过词汇表传递,这是它将要查找的一组单词。 如果未传递词汇表(在Chapter 6的代码中我们未传递词汇表),则它将从数据集中学习该词汇表。 所有单词都在训练文档集中(取决于课程的其他参数)。
首先,我们建立功能词的词汇表,它只是包含每个功能词的列表。 确切地说,哪些词是功能词,哪些不该争论。 从公开发表的研究中,我发现这个列表相当不错:
function_words = ["a", "able", "aboard", "about", "above", "absent",
"according" , "accordingly", "across", "after", "against",
"ahead", "albeit", "all", "along", "alongside", "although",
"am", "amid", "amidst", "among", "amongst", "amount", "an",
"and", "another", "anti", "any", "anybody", "anyone",
"anything", "are", "around", "as", "aside", "astraddle",
"astride", "at", "away", "bar", "barring", "be", "because",
"been", "before", "behind", "being", "below", "beneath",
"beside", "besides", "better", "between", "beyond", "bit",
"both", "but", "by", "can", "certain", "circa", "close",
"concerning", "consequently", "considering", "could",
"couple", "dare", "deal", "despite", "down", "due", "during",
"each", "eight", "eighth", "either", "enough", "every",
"everybody", "everyone", "everything", "except", "excepting",
"excluding", "failing", "few", "fewer", "fifth", "first",
"five", "following", "for", "four", "fourth", "from", "front",
"given", "good", "great", "had", "half", "have", "he",
"heaps", "hence", "her", "hers", "herself", "him", "himself",
"his", "however", "i", "if", "in", "including", "inside",
"instead", "into", "is", "it", "its", "itself", "keeping",
"lack", "less", "like", "little", "loads", "lots", "majority",
"many", "masses", "may", "me", "might", "mine", "minority",
"minus", "more", "most", "much", "must", "my", "myself",
"near", "need", "neither", "nevertheless", "next", "nine",
"ninth", "no", "nobody", "none", "nor", "nothing",
"notwithstanding", "number", "numbers", "of", "off", "on",
"once", "one", "onto", "opposite", "or", "other", "ought",
"our", "ours", "ourselves", "out", "outside", "over", "part",
"past", "pending", "per", "pertaining", "place", "plenty",
"plethora", "plus", "quantities", "quantity", "quarter",
"regarding", "remainder", "respecting", "rest", "round",
"save", "saving", "second", "seven", "seventh", "several",
"shall", "she", "should", "similar", "since", "six", "sixth",
"so", "some", "somebody", "someone", "something", "spite",
"such", "ten", "tenth", "than", "thanks", "that", "the",
"their", "theirs", "them", "themselves", "then", "thence",
"therefore", "these", "they", "third", "this", "those",
"though", "three", "through", "throughout", "thru", "thus",
"till", "time", "to", "tons", "top", "toward", "towards",
"two", "under", "underneath", "unless", "unlike", "until",
"unto", "up", "upon", "us", "used", "various", "versus",
"via", "view", "wanting", "was", "we", "were", "what",
"whatever", "when", "whenever", "where", "whereas",
"wherever", "whether", "which", "whichever", "while",
"whilst", "who", "whoever", "whole", "whom", "whomever",
"whose", "will", "with", "within", "without", "would", "yet",
"you", "your", "yours", "yourself", "yourselves"]
现在,我们可以设置一个提取器来获取这些功能字的计数。 稍后我们将使用管道进行调整:
from sklearn.feature_extraction.text import CountVectorizer
extractor = CountVectorizer(vocabulary=function_words)
用功能词分类
接下来,我们导入我们的类。 这里唯一的新事物是支持向量机,我们将在下一节中介绍(目前,仅将其视为标准分类算法)。 我们导入 SVC 类,用于分类的 SVM 以及我们之前看到的其他标准工作流程工具:
from sklearn.svm import SVC
from sklearn.cross_validation import cross_val_score
from sklearn.pipeline import Pipeline
from sklearn import grid_search
支持向量机采用许多参数。 就像我说的,在下一节中详细介绍之前,我们将在这里盲目使用。 然后,我们使用字典来设置要搜索的参数。 对于kernel参数,我们将尝试linear和rbf。 对于C,我们将尝试使用值 1 和 10(这些参数的说明将在下一部分中介绍)。 然后,我们创建一个网格搜索以搜索以下参数以获得最佳选择:
parameters = {'kernel':('linear', 'rbf'), 'C':[1, 10]}
svr = SVC()
grid = grid_search.GridSearchCV(svr, parameters)
注意
高斯核(例如rbf)仅适用于大小合理的数据集,例如要素数量少于 10,000 个时。
接下来,我们建立一个管道,该管道使用CountVectorizer(仅使用功能词)以及使用 SVM 的网格搜索进行特征提取步骤。 代码如下:
pipeline1 = Pipeline([('feature_extraction', extractor),
('clf', grid)
])
接下来,我们应用cross_val_score来获得该管道的交叉验证分数。 结果为 0.811,这意味着大约有 80%的预测正确。 对于 7 位作者来说,这是一个很好的结果!
支持向量机
支持向量机(SVM)是基于简单直观的思想的分类算法。 它仅在两个类之间执行分类(尽管我们可以将其扩展到更多类)。 假设我们的两个类可以用一条线分开,使得该线之上的任何点都属于一个类,而该线之下的任何点都属于另一类。 支持向量机找到这条线并将其用于预测,与线性回归的工作方式大致相同。 但是,SVM 找到用于分离数据集的最佳行。
在下图中,我们用三行分隔数据集:蓝色,黑色和绿色。 您会说哪个是最好的选择?

凭直觉,人通常会选择蓝线作为最佳选项,因为这会最大程度地分离数据。 也就是说,它与每个类别中任何点的最大距离。
找到这条线是一个优化问题,其基础是找到边缘之间的最大距离的线。
注意
这些方程式的推导超出了本模块的范围,但我建议感兴趣的读者详细阅读这个页面的推导。 另外,您可以访问这个页面。
使用 SVM 分类
在训练模型后,我们有一条最大边距的线。 然后,新样本的分类只是问一个问题:是落在该线之上还是之下? 如果它落在该线的上方,则被预测为一类。 如果在该线以下,则将其预测为另一类。
对于多个类,我们创建了多个 SVM-每个二进制分类器。 然后,我们使用多种策略中的任何一种将它们连接起来。 一种基本策略是为每个类别创建一个对所有分类器,我们在其中使用两个类别(给定的类别和所有其他样本)进行训练。 我们为每个类别执行此操作,并在一个新样本上运行每个分类器,并从每个类别中选择最佳匹配项。 在大多数 SVM 实现中,都会自动执行此过程。
我们在之前的代码中看到了两个参数:C和kernel。 我们将在下一节介绍kernel参数,但是C参数是安装 SVM 的重要参数。 C参数涉及分类器在过度拟合的风险下应旨在正确预测所有训练样本的程度。 选择较高的C值将找到一条边距较小的分隔线,目的是对所有训练样本进行正确分类。 选择较低的C值将导致分离线具有较大的余量-即使这意味着某些训练样本未正确分类。 在这种情况下,较低的C值表示过拟合的机会较小,存在选择通常较差的分离线的风险。
SVM(以其基本形式)的局限性在于它们仅分离线性可分离的数据。 如果没有数据该怎么办? 对于这个问题,我们使用内核。
内核
当数据无法线性分离时,诀窍是将其嵌入到高维空间中。 这意味着要花很多时间在细节上,这是要添加伪特征,直到数据可线性分离为止(如果添加了足够多的正确种类的特征,则总是会发生这种情况)。
诀窍在于,当找到最佳数据线以分离数据集时,我们通常会计算样本的内部生成量。 给定使用点积的功能,我们可以有效地制造新功能,而不必实际定义这些新功能。 这很方便,因为我们仍然不知道这些功能将是什么。 现在,我们将kernel定义为一个函数,该函数本身就是数据集中两个样本的函数的点积,而不是基于样本(及其组成特征)本身。
现在我们可以计算出点积是什么(或近似值),然后使用它。
有个常用内核。 linear内核是最简单的内核,它只是两个样本特征向量,权重特征和偏差值的点积。 还有一个多项式内核,它将点积提高到给定的度数(例如 2)。 其他函数包括高斯函数(rbf)和 Sigmoidal 函数。 在我们先前的代码示例中,我们在linear内核和rbf内核之间进行了测试。
所有这些推导的最终结果是,这些内核有效地定义了两个样本之间的距离,该距离用于支持向量机中新样本的分类。 从理论上讲,可以使用任何距离,尽管它可能不具有能够轻松优化 SVM 训练的相同特征。
在 scikit-learn 的 SVM 的实现中,我们可以定义kernel参数来更改在计算中使用哪个内核函数,就像我们在前面的代码示例中看到的那样。

字符 n-gram
我们看到了功能词如何用作预测文档作者的特征。 另一种特征类型是字符 n-gram。 n-gram 是n对象的序列,其中n是一个值(对于文本,通常在 2 到 6 之间)。 单词 n-gram 已用于许多研究中,通常与文档的主题有关。 但是,字符 n-gram 已被证明具有高质量的作者身份。
通过将文档表示为字符序列,可以在文本文档中找到字符 n-gram。 然后从该序列中提取这些 n-gram,并训练模型。 有很多不同的模型,但是一个标准模型与我们之前使用的词袋模型非常相似。
对于训练语料库中的每个不同的 n-gram,我们为其创建一个特征。 n-gram 的示例是<e t>,它是字母e,空格,然后是字母t(尖括号用于表示开始和结束 而不是其中的一部分)。 然后,我们使用训练文档中每个 n-gram 的频率训练模型,并使用创建的特征矩阵训练分类器。
注意
字符 n 元语法有多种定义方式。 例如,某些应用仅选择单词内字符,而忽略空格和标点符号。 有些人使用此信息(例如本章中的实现)。
关于字符 n-gram 为何起作用的一个普遍理论是,人们通常会写出他们可以轻松说出的单词,而字符 n-gram(至少当 n 在 2 到 6 之间时)是音素的一个很好的近似值。 说的话。 从这个意义上讲,使用字符 n-gram 可以近似单词的声音,也可以近似您的写作风格。 这是创建新功能时的常见模式。 首先,我们有一个理论来研究哪些概念会影响最终结果(作者风格),然后创建特征来近似或度量这些概念。
字符 n 元语法矩阵的主要特征是它稀疏并且稀疏度随 n 值较高而迅速增加。 对于n-值为 2,大约 75%的特征矩阵为零。 对于n值为 5,超过 93%的是零。 但是,这通常比同类型的单词 n-gram 矩阵稀疏,并且使用基于单词的分类的分类器不会引起很多问题。
提取字符 n-gram
将使用我们的CountVectorizer类提取字符 n-gram。 为此,我们设置analyzer参数并为n指定一个值以提取 n-gram。
scikit-learn 中的实现使用 n-gram 范围,使您可以同时提取多个大小的 n-gram。 在本实验中,我们不会研究不同的n-值,因此我们将这些值设置为相同。 要提取大小为 3 的 n-gram,您需要指定(3,3)作为 n-gram 范围的值。
我们可以重用先前代码中的网格搜索。 我们需要做的就是在新管道中指定新功能提取器:
pipeline = Pipeline([('feature_extraction', CountVectorizer(analyzer='char', ngram_range=(3, 3))),
('classifier', grid)
])
scores = cross_val_score(pipeline, documents, classes, scoring='f1')
print("Score: {:.3f}".format(np.mean(scores)))
注意
功能词和字符 n 元语法词之间存在很多隐式重叠,因为功能词中的字符序列更可能出现。 但是,实际特征非常不同,字符 n-gram 捕获标点符号,而功能词则没有。 例如,一个字符 n-gram 在句子的末尾包括句号,而基于功能词的方法将仅使用前一个词本身。

使用 Enron 数据集
在 1990 年代后期,安然(HTG0)是世界上最大的能源公司之一,报告收入超过 1000 亿美元。 它拥有 20,000 多名员工,并且-到 2000 年-似乎没有迹象表明有什么不对劲。
在 2001 年,发生了安然丑闻,发现该安然正在采取系统的,欺诈性的会计惯例。 这种欺诈行为是故意的,遍及整个公司,涉及金额可观。 在公开发现此消息后,其股价从 2000 年的 90 多美元跌至 2001 年的 1 美元以下。安然不久便申请了破产,一团糟,最终需要 5 年以上的时间才能解决。
作为对安然公司的调查的一部分,美国联邦能源管理委员会公开发布了 60 万封电子邮件。 从那时起,此数据集已用于从社交网络分析到欺诈分析的所有内容。 它也是进行作者身份分析的绝佳数据集,因为我们能够从单个用户的已发送文件夹中提取电子邮件。 这使我们可以创建一个比许多以前的数据集大得多的数据集。
访问 Enron 数据集
可在这个页面上获取完整的 Enron 电子邮件的集。
注意
完整的数据集为 423 MB,压缩格式为gzip。 如果您没有基于 Linux 的计算机来解压缩(解压缩)该文件,请获取其他程序,例如如 7-zip。
下载完整的语料库并将其解压缩到您的数据文件夹中。 默认情况下,它将解压缩到名为enron_mail_20110402的文件夹中。
在寻找作者身份信息时,我们只希望可以将电子邮件归因于特定作者。 因此,我们将查看每个用户的已发送文件夹,即他们已发送的电子邮件。
在笔记本中,设置 Enron 数据集的数据文件夹:
enron_data_folder = os.path.join(os.path.expanduser("~"), "Data", "enron_mail_20110402", "maildir")
创建数据集加载器
现在,我们可以创建一个函数,该函数将随机选择几个作者,并在其发送的文件夹中返回每个电子邮件。 具体来说,我们正在寻找有效载荷,即内容而不是电子邮件本身。 为此,我们将需要一个电子邮件解析器。 代码如下:
from email.parser import Parser
p = Parser()
稍后我们将使用它从数据文件夹中的电子邮件文件中提取有效负载。
我们将随机选择作者,因此我们将使用随机状态,该状态允许我们在需要时复制结果:
from sklearn.utils import check_random_state
通过我们的数据加载功能,我们将有很多选择。 这些大多数确保我们的数据集相对平衡。 有些作者的发送邮件中将有数千封电子邮件,而其他作者则只有几十封。 我们将搜索限制为仅使用min_docs_author接收至少 10 封电子邮件的作者,并使用max_docs_author参数从每位作者接收最多 100 封电子邮件。 我们还指定了我们希望获得多少作者-默认情况下,使用num_authors参数为 10 位作者。 代码如下:
def get_enron_corpus(num_authors=10, data_folder=data_folder,
min_docs_author=10, max_docs_author=100,
random_state=None):
random_state = check_random_state(random_state)
接下来,我们列出 data 文件夹中的所有文件夹,它们是 Enron 员工的单独电子邮件地址。 当我们随机地对它们进行洗牌时,我们可以在每次运行代码时选择一个新的集合。 请记住,设置随机状态将使我们能够复制以下结果:
email_addresses = sorted(os.listdir(data_folder))
random_state.shuffle(email_addresses)
注意
我们对电子邮件地址进行排序,只是将它们乱码,似乎有些奇怪。 os.listdir函数并不总是返回相同的结果,因此我们首先对其进行排序以获得一定的稳定性。 然后,我们使用随机状态进行混洗,这意味着我们的混洗可以根据需要重现过去的结果。
然后,我们设置了我们的文档和类列表。 我们还创建了一个author_num,它将告诉我们每个新作者要使用哪个类。 我们不会使用我们之前使用的enumerate技巧,因为我们可能不会选择某些作者。 例如,如果作者没有发送 10 封电子邮件,我们将不使用它。 代码如下:
documents = []
classes = []
author_num = 0
我们还将记录使用的作者以及分配给他们的班级编号。 这不是用于数据挖掘,而是将在可视化中使用,以便我们可以更轻松地识别作者。 该词典将简单地将电子邮件用户名映射到类值。 代码如下:
authors = {}
接下来,我们遍历每个电子邮件地址,并查找名称中带有“已发送”的所有子文件夹,以指示已发送的邮箱。 代码如下:
for user in email_addresses:
users_email_folder = os.path.join(data_folder, user)
mail_folders = [os.path.join(users_email_folder, subfolder) for subfolder in os.listdir(users_email_folder)
if "sent" in subfolder]
然后,我们获取此文件夹中的每封电子邮件。 我把这个调用放在了 try-except 块中,因为有些作者的发送邮件中有子目录。 我们可以使用一些更详细的代码来获取所有这些电子邮件,但是现在我们将继续并忽略这些用户。 代码如下:
try:
authored_emails = [open(os.path.join(mail_folder, email_filename), encoding='cp1252').read()
for mail_folder in mail_folders
for email_filename in os.listdir(mail_folder)]
except IsADirectoryError:
continue
接下来,我们检查至少有 10 封电子邮件(或设置为min_docs_author的任何内容):
if len(authored_emails) < min_docs_author:
continue
作为的下一步,如果我们收到来自该作者的过多电子邮件,则仅接收前 100 条电子邮件(来自max_docs_author):
if len(authored_emails) > max_docs_author:
authored_emails = authored_emails[:max_docs_author]
接下来,我们解析电子邮件以提取内容。 我们对标题不感兴趣-作者对此处的内容几乎没有控制权,因此它不能为作者身份分析提供良好的数据。 然后,我们将这些电子邮件有效负载添加到我们的数据集中:
contents = [p.parsestr(email)._payload for email in authored_emails]
documents.extend(contents)
然后,对于添加到数据集中的每封电子邮件,我们都为该作者添加一个类值:
classes.extend([author_num] * len(authored_emails))
然后,我们记录用于该作者的类编号,然后按,然后按递增:
authors[user] = author_num
author_num += 1
然后,我们检查我们是否有足够的作者,如果有,我们跳出循环返回数据集。 代码如下:
if author_num >= num_authors or author_num >= len(email_addresses):
break
然后,我们返回数据集的文档和类,以及我们的作者映射。 代码如下:
return documents, np.array(classes), authors
在此函数之外,我们现在可以通过执行以下函数调用来获取数据集。 我们将在这里使用 14 的随机状态(与本模块一样),但是您可以尝试其他值或将其设置为 none,以在每次调用函数时获得随机设置:
documents, classes, authors = get_enron_corpus(data_folder=enron_data_folder, random_state=14)
如果您看一下数据集,我们还需要进行进一步的预处理。 我们的电子邮件非常混乱,但是(从数据分析的角度来看)最糟糕的一点是,这些电子邮件包含其他作者的来信,形式为。 以以下电子邮件为例:documents[100]:
我对时机感到失望,但我理解。 谢谢。 标记
-----原始消息-----
来自:马克·格林伯格
发送:2001 年 9 月 28 日,星期五,下午 4:19
致:海迪克(Mark E.)
主题:网站
标记-
仅供参考-我在下面附上了该网站拟议的新外观的屏幕截图。 我们需要进行一些调整,但是我认为这比我们现在的外观干净得多。
本文档包含另一封作为回复的电子邮件,作为一种常见的电子邮件格式,附加在底部。 电子邮件的第一部分来自 Mark Haedicke,而第二部分是 Mark Greenberg 写给 Mark Haedicke 的先前电子邮件。 只有前面的文本(-----原始消息的第一个实例-----)可以归因于作者,这是我们真正担心的唯一一环。
通常,提取此信息并不容易。 电子邮件是一种众所周知的不好使用的格式。 不同的电子邮件客户端添加其自己的标头,以不同的方式定义答复,并根据需要进行操作。 电子邮件在当前环境中可以正常工作真是令人惊讶。
我们可以寻找一些常用的模式。 quotequail程序包将查找这些,并且可以找到电子邮件的新部分,丢弃回复和其他信息。
提示
您可以使用 pip pip3 install quotequail安装quotequail。
我们将编写一个简单的函数来包装quotequail功能,使我们能够轻松地在所有文档上调用它。 首先,我们导入quotequail并设置函数定义:
import quotequail
def remove_replies(email_contents):
接下来,我们使用quotequail展开电子邮件,这将返回包含电子邮件不同部分的字典。 代码如下:
r = quotequail.unwrap(email_contents)
在某些情况下,r可以为无。 如果无法解析电子邮件,则会发生这种情况。 在这种情况下,我们只返回完整的电子邮件内容。 在处理实际数据集时,通常需要这种凌乱的解决方案。 代码如下:
if r is None:
return email_contents
我们感兴趣的电子邮件的实际部分称为text_top(由quotequail表示)。 如果存在,我们将其作为电子邮件中有趣的部分返回。 代码如下:
if 'text_top' in r:
return r['text_top']
如果不存在,则quotequail找不到。 可能在电子邮件中找到其他文本。 如果存在,我们仅返回该文本。 代码如下:
elif 'text' in r:
return r['text']
最后,如果无法获得结果,我们只返回电子邮件内容,希望它们为我们的数据分析提供一些好处:
return email_contents
现在,我们可以通过在每个文档上运行此功能来预处理所有文档:
documents = [remove_replies(document) for document in documents]
我们之前的电子邮件示例现在已经得到了很大的澄清,并且仅包含 Mark Greenberg 编写的电子邮件:
我对时机感到失望,但我理解。 谢谢。 标记
全部放在一起
我们可以使用先前实验的中现有的参数空间和分类器-我们要做的就是将其重新调整为新数据。 默认情况下,对 scikit-learn 的训练是从头开始的,随后对fit()的调用将丢弃任何先前的信息。
注意
有一种称为在线学习的算法,可以用新样本更新训练,而不必每次都重新开始训练。 我们将在本模块的稍后部分看到在线学习的实际应用,包括下一章,第 10 章,“聚类新闻文章”。
和以前一样,我们可以使用cross_val_score计算分数并打印结果。 代码如下:
scores = cross_val_score(pipeline, documents, classes, scoring='f1')
print("Score: {:.3f}".format(np.mean(scores)))
结果是 0.523,对于这样一个混乱的数据集,这是一个合理的结果。 添加更多数据(例如增加数据集加载中的max_docs_author)可以改善这些结果。
评估
通常,永远不要将评估基于单个数字。 在 f 得分的情况下,它通常比技巧更强健,尽管它们没有用,但仍能给出良好的分数。 一个例子就是准确性。 正如我们在上一章中所述,垃圾邮件分类器可以将所有内容预测为垃圾邮件,并且可以达到 80%以上的准确性,尽管该解决方案根本没有用。 因此,通常值得对结果进行更深入的研究。
首先,我们将研究混淆矩阵,就像在第 8 章,“使用神经网络击败 CAPTCHA”一样。 在我们这样做之前,我们需要预测一个测试集。 先前的代码使用cross_val_score,实际上并没有给我们提供可以使用的经过训练的模型。 因此,我们将需要改装一个。 为此,我们需要训练和测试子集:
from sklearn.cross_validation import train_test_split
training_documents, testing_documents, y_train, y_test = train_test_split(documents, classes, random_state=14)
接下来,我们将管道调整到训练文档中,并为测试集创建预测:
pipeline.fit(training_documents, y_train)
y_pred = pipeline.predict(testing_documents)
此时,您可能想知道参数的最佳组合实际上是什么。 我们可以很容易地从网格搜索对象中提取它(这是管道的classifier步骤):
print(pipeline.named_steps['classifier'].best_params_)
结果为您提供了分类器的所有参数。 但是,大多数参数是我们没有碰过的默认值。 我们搜索的是C和kernel,它们分别设置为1和linear。
现在我们可以创建一个混淆矩阵:
from sklearn.metrics import confusion_matrix
cm = confusion_matrix(y_pred, y_test)
cm = cm / cm.astype(np.float).sum(axis=1)
接下来,我们得到我们的作者,以便我们可以正确地标记轴。 为此,我们使用 Enron 数据集加载的authors字典。 代码如下:
sorted_authors = sorted(authors.keys(), key=lambda x:authors[x])
最后,我们使用matplotlib显示混淆矩阵。 与上一章相比,唯一的变化如下。 只需用本章实验中的作者替换字母标签:
%matplotlib inline
from matplotlib import pyplot as plt
plt.figure(figsize=(10,10))
plt.imshow(cm, cmap='Blues')
tick_marks = np.arange(len(sorted_authors))
plt.xticks(tick_marks, sorted_authors)
plt.yticks(tick_marks, sorted_authors)
plt.ylabel('Actual')
plt.xlabel('Predicted')
plt.show()
结果如下图所示:

我们可以看到,在大多数情况下,作者的预测是正确的-存在一条清晰的对角线,其值很高。 但是,错误的来源很多(较暗的值更大):例如,通常预测来自用户baughman-d的电子邮件来自reitmeyer-j。



三十一、新闻文章聚类
在前面的大多数章节中,我们都是在了解数据的基础上进行数据挖掘的。 通过使用目标类,我们可以了解我们的变量如何在训练阶段对这些目标进行建模。 我们有目标要针对的这种学习类型称为监督学习。 在本章中,我们考虑了没有这些目标的情况。 这是无监督学习,更多的是探索性任务。 无需使用我们的模型进行分类,无监督学习的目标更多是关于探索数据以寻找见解。
在本章中,我们着眼于对新闻文章进行聚类以发现数据中的趋势和模式。 我们研究如何使用链接聚合网站显示不同的新闻故事,从不同的网站提取数据。
本章涵盖的关键概念包括:
- 从任意网站获取文本
- 使用 Reddit API 收集有趣的新闻故事
- 用于无监督数据挖掘的聚类分析
- 从文档中提取主题
- 在线学习以更新模型而无需重新训练
- 集群整合以结合不同的模型
获得新闻文章
在本章中,我们将构建一个系统,该系统获取新闻文章的实时供稿并将其分组,其中各组的主题相似。 您可以在数周(或更长时间)内运行系统,以查看在这段时间内趋势如何变化。
我们的系统将从流行的链接聚合网站 reddit 开始,该网站存储指向其他网站的链接列表以及用于讨论的评论部分。 reddit 上的链接分为几类链接,称为 subreddits。 有一些专门针对特定电视节目,有趣图像和许多其他事物的版本。 我们感兴趣的是新闻的分类。 我们将在本章中使用/r/worldnews子目录,但该代码应与任何其他子目录一起使用。
在本章中,我们的目标是下载受欢迎的故事,然后将它们聚类以查看出现的任何主要主题或概念。 这将使我们能够深入了解流行的焦点,而无需手动分析数百个单个故事。
使用 Web API 获取数据
在前面的几章中,我们使用基于 Web 的 API 提取数据。 例如,在第 7 章,“使用图形挖掘”发现要遵循的帐户中,我们使用了 Twitter 的 API 提取数据。 收集数据是数据挖掘管道中的关键部分,基于 Web 的 API 是一种收集各种主题数据的绝妙方法。
使用基于 Web 的 API 收集数据时,需要考虑三件事:授权方法,速率限制和 API 端点。
授权方法允许数据提供者知道谁在收集数据,以便确保它们受到适当的速率限制,并且可以跟踪数据访问。 对于大多数网站,个人帐户通常足以开始收集数据,但是某些网站会要求您创建一个正式的开发人员帐户来获得此访问权限。
速率限制适用于数据收集,特别是免费服务。 使用 API时要注意规则,这一点很重要,因为它们会并且确实会在网站之间发生变化。 Twitter 的 API 限制为每 15 分钟 180 个请求(取决于特定的 API 调用)。 稍后我们将看到 Reddit 允许每分钟 30 个请求。 其他网站设置了每日限制,而其他网站则是按秒限制。 即使在网站内部,不同的 API 调用也存在巨大差异。 例如,Google Maps 的限制较小,每个资源的 API 限制不同,每小时请求数量的配额也不同。
注意
如果您发现自己正在创建需要更多请求和更快响应的应用或正在运行的实验,则大多数 API 提供商都制定了允许进行更多调用的商业计划。
API 端点是用于提取信息的实际 URL。 这些内容因网站而异网站。 通常,基于 Web 的 API 将遵循 RESTful 接口(表示状态传输的缩写)。 RESTful 接口通常使用与 HTTP 相同的操作:GET,POST 和 DELETE 是最常见的操作。 例如,要检索有关资源的信息,我们可以使用以下 API 端点: www.dataprovider.com/api/resource_type/resource_id/ 。
为了使获得和信息,我们仅向该 URL 发送一个 HTTP GET 请求。 这将返回具有给定类型和 ID 的资源信息。 尽管实现上存在一些差异,但大多数 API 都遵循此结构。 大多数具有 API 的网站都会对其进行适当记录,从而为您提供可检索的所有 API 的详细信息。
首先,我们设置参数以连接到服务。 为此,您需要使用开发人员密钥进行 Reddit。 为了获得此密钥,请登录这个页面网站并转到这个页面。 从此处单击,您是开发人员吗? 创建一个应用…并填写表格,将类型设置为脚本。 您将获得客户端 ID 和一个秘密,可以将其添加到新的 IPython Notebook 中:
CLIENT_ID = "<Enter your Client ID here>"
CLIENT_SECRET = "<Enter your Client Secret here>"
Reddit 还要求您在使用 API时将用户代理设置为包含用户名的唯一字符串。 创建一个唯一标识您的应用的用户代理字符串。 我使用书名chapter 10和版本号 0.1 来创建我的用户代理,但是它可以是您喜欢的任何字符串。 请注意,不这样做会导致您的连接受到严重的速率限制:
USER_AGENT = "python:<your unique user agent> (by /u/<your reddit username>)"
此外,您将需要使用用户名和密码登录 reddit。 如果您还没有,请注册一个新的帐户(这是免费的,您也不需要使用个人信息进行验证)。
注意
您将需要密码来完成下一步,因此在与他人共享代码以将其删除之前,请务必小心。 如果您不输入密码,请将其设置为none,系统将提示您输入密码。 但是,由于 IPython Notebook 的工作方式,您需要将其输入到启动 IPython 服务器的命令行终端中,而不是输入 Notebook 本身。 如果无法执行此操作,则需要在脚本中进行设置。 IPython Notebook 的开发人员正在开发一个插件来解决此问题,但在编写本文时尚不可用。
现在让我们创建用户名和密码:
USERNAME = "<your reddit username>"
PASSWORD = "<your reddit password>"
接下来,我们将创建一个函数来记录此信息。 reddit 登录 API 将返回一个可用于进一步连接的令牌,这是该功能的结果。 代码如下:
def login(username, password):
首先,如果您不想将密码添加到脚本中,可以将其设置为None,系统将提示您,如前所述。 代码如下:
if password is None:
password = getpass.getpass("Enter reddit password for user {}: ".format(username))
将用户代理设置为唯一值非常重要,否则连接可能会受到严格限制。 代码如下:
headers = {"User-Agent": USER_AGENT}
接下来,我们设置一个 HTTP 授权对象以允许我们在 reddit 的服务器上登录:
client_auth = requests.auth.HTTPBasicAuth(CLIENT_ID, CLIENT_SECRET)
要登录,我们向access_token端点发出 POST 请求。 我们发送的数据是我们的用户名和密码,以及在此示例中设置为password的授予类型:
post_data = {"grant_type": "password", "username": username, "password": password}
最后,我们使用requests库发出登录请求(通过 HTTP POST 请求完成)并返回结果,该结果是值的字典。 这些值之一是我们将来需要的令牌。 代码如下:
response = requests.post("https://www.reddit.com/api/v1/access_token", auth=client_auth, data=post_data, headers=headers)
return response.json()
我们现在可以调用我们的函数来获取令牌:
token = login(USERNAME, PASSWORD)
token对象只是一个字典,但它包含access_token字符串,我们将在以后的请求中将其传递给。 它还包含其他信息,例如令牌的范围(将是所有内容)以及令牌的到期时间,例如:
{'access_token': '<semi-random string>', 'expires_in': 3600, 'scope': '*', 'token_type': 'bearer'}
Reddit 作为数据源
Reddit( www.reddit.com )是一个链接聚合的网站,全球数百万人使用,尽管英文版本是以美国为中心。 任何用户都可以向他们认为有趣的网站提供链接,以及该链接的标题。 然后,其他用户可以赞成,表明他们喜欢该链接,或者赞成,表明他们不喜欢该链接。 投票最高的链接将移至页面顶部,而投票较低的链接则不会显示。 较旧的链接会随着时间的流逝而被删除(取决于它拥有多少票)。 拥有推荐的故事的用户获得称为业力的积分,从而鼓励他们仅提交好故事。
Reddit 还允许非链接内容,称为自发布。 这些包含标题和提交者输入的一些文本。 这些用于提问和开始讨论,但不计入一个人的业力。 在本章中,我们将仅考虑基于链接的帖子,而不考虑基于评论的帖子。
帖子分为网站的不同部分,称为 subreddits。 subreddit 是相关帖子的集合。 当用户提交指向 reddit 的链接时,他们可以选择要加入的子 reddit。 Subreddit 有其自己的管理员,并且对于该 Subreddit 的有效内容有自己的规则。
默认情况下,帖子按热门排序,这取决于帖子的年龄,支持的数量和已收到的支持的数量。 还有新,它只为您提供最近发布的故事(因此包含很多垃圾邮件和不良帖子),以及热门,是给定投票率最高的故事 时间段。 在本章中,我们将使用热门,它将为我们提供最近的高质量故事(新中确实有许多劣质链接)。
使用我们先前创建的令牌,我们现在可以从 subreddit 获取链接集。 为此,我们将使用/r/<subredditname> API 端点,该端点默认情况下返回热门故事。 我们将使用/r/worldnews subreddit:
subreddit = "worldnews"
上一个端点的 URL 使我们可以创建完整的 URL,可以使用字符串格式设置该 URL:
url = "https://oauth.reddit.com/r/{}".format(subreddit)
接下来,我们需要设置标题。 这样做有两个原因:允许我们使用之前收到的授权令牌,并设置用户代理以阻止我们的请求受到严格限制。 代码如下:
headers = {"Authorization": "bearer {}".format(token['access_token']),
"User-Agent": USER_AGENT}
然后,像之前的一样,我们使用requests库进行调用,确保设置了标头:
response = requests.get(url, headers=headers)
对此调用json()将产生一个 Python 字典,其中包含 Reddit 返回的信息。 它将包含来自给定 subreddit 的前 25 个结果。 我们可以通过遍历此响应中的故事来获得标题。 故事本身存储在词典的data键下。 代码如下:
for story in result['data']['children']:
print(story['data']['title'])
获取数据
我们的数据集将包含/r/worldnews subreddit 的热门列表中的帖子。 我们在上一节中看到了如何连接到 reddit 以及如何下载链接。 综上所述,我们将创建一个函数,该函数将提取给定 subreddit 中每个项目的标题,链接和得分。
我们将遍历 subreddit,一次最多获取 100 个故事。 我们也可以进行分页以获得更多结果。 在 reddit 阻止我们之前,我们可以阅读大量页面,但我们会将其限制为 5 页。
由于我们的代码将反复调用 API,因此请务必对调用进行速率限制。 为此,我们需要sleep函数:
from time import sleep
我们的函数将接受一个 subreddit 名称和一个授权令牌。 尽管我们将默认设置为 5:
def get_links(subreddit, token, n_pages=5):
然后,我们创建一个列表以将故事存储在:
stories = []
我们在第 7 章中发现了要使用图形挖掘进行跟踪的帐户,即分页如何适用于 Twitter API。 我们将光标与返回的结果一起发送,并随请求发送。 然后,Twitter 将使用此光标来获取结果的下一页。 除了调用参数after之外,reddit API 的功能几乎完全相同。 我们第一页不需要它,因此我们最初将其设置为none。 我们将在结果的第一页之后将其设置为有意义的值。 代码如下:
after = None
然后,我们迭代要返回的页面数:
for page_number in range(n_pages):
在循环内部,我们像以前一样初始化 URL 结构:
headers = {"Authorization": "bearer {}".format(token['access_token']),
"User-Agent": USER_AGENT}
url = "https://oauth.reddit.com/r/{}?limit=100".format(subreddit)
从第二个循环开始,我们需要设置after参数(否则,我们将只获得同一页结果的多个副本)。 该值将在循环的上一个迭代中设置-第一个循环为第二个循环设置 after 参数,依此类推。 如果存在,则将其附加到 URL 的末尾,告诉 reddit 获取下一页数据。 代码如下:
if after:
url += "&after={}".format(after)
然后,像以前一样,我们使用请求库进行调用,然后使用json()将结果转换为 Python 字典:
response = requests.get(url, headers=headers)
result = response.json()
此结果将为我们在下一次循环迭代时提供after参数,我们现在可以将其设置如下:
after = result['data']['after']
然后,我们睡眠 2 秒钟以避免超出 API 限制:
sleep(2)
作为循环中的最后一个动作,我们从返回的结果中获取每个故事,并将它们添加到我们的stories列表中。 我们不需要所有数据,仅获得标题,URL 和分数。 代码如下:
stories.extend([(story['data']['title'], story['data']['url'], story['data']['score'])
for story in result['data']['children']])
最后(在循环之外),我们返回找到的所有故事:
return stories
调用stories函数是传递授权令牌和 subreddit 名称的简单情况:
stories = get_links("worldnews", token)
返回的结果应包含标题,URL 和 500 个故事,我们现在将使用它们从所得网站中提取实际文本。

从任意网站提取文本
我们从 reddit 获得的链接转到由许多不同组织运营的任意网站。 更难的是,这些页面被设计为人类而非计算机程序读取。 尝试获取这些结果的实际内容/故事时,这可能会引起问题,因为现代网站在后台运行了很多工作。 调用 JavaScript 库,应用样式表,使用 AJAX 加载广告,在侧边栏中添加额外的内容,并进行其他各种操作以使现代网页成为复杂的文档。 这些功能使现代 Web 成为现实,但是很难自动从中获取良好的信息!
在任意网站中查找故事
首先,我们将从每个链接下载完整的网页,并将其存储在raw子文件夹下的数据文件夹中。 稍后我们将处理这些信息以提取有用的信息。 这种结果缓存确保我们在工作时不必连续下载网站。 首先,我们设置数据文件夹路径:
import os
data_folder = os.path.join(os.path.expanduser("~"), "Data", "websites", "raw")
我们将使用 MD5 哈希为文章创建唯一的文件名,因此我们将导入hashlib来执行此操作。 hash函数是将某些输入(在我们的示例中为包含标题的字符串)转换为看似随机的字符串的函数。 相同的输入将始终返回相同的输出,但是略有不同的输入将返回截然不同的输出。 从哈希值到原始值也是不可能的,这使其成为单向函数。 代码如下:
import hashlib
我们将简单地跳过所有失败的网站下载。 为了确保这样做不会丢失太多信息,我们对发生的错误数进行了简单的计数。 我们将抑制发生的任何错误,这可能导致系统性的问题,禁止下载。 如果此错误计数器过高,我们可以查看这些错误是什么并尝试修复它们。 例如,如果计算机无法访问 Internet,则所有 500 次下载都将失败,您应该在继续之前解决此问题!
如果下载没有错误,则输出应为零:
number_errors = 0
接下来,我们遍历每个故事:
for title, url, score in stories:
然后,我们通过散列标题为文章创建唯一的输出文件名。 reddit 中的标题不必唯一,这意味着两个故事可能具有相同的标题,因此在我们的数据集中会发生冲突。 为了获得唯一的文件名,我们只需使用 MD5 算法对文章的 URL 进行哈希处理即可。 尽管已知 MD5 存在一些问题,但在我们的场景中不太可能发生问题(冲突),即使它确实发生了,我们也不必担心太多,也不必担心太多 如果确实发生碰撞。
output_filename = hashlib.md5(url.encode()).hexdigest()
fullpath = os.path.join(data_folder, output_filename + ".txt")
接下来,我们下载实际页面并将其保存到我们的output文件夹中:
try:
response = requests.get(url)
data = response.text
with open(fullpath, 'w') as outf:
outf.write(data)
如果在获取网站时出错,我们只需跳过此网站并继续前进。 该代码将在 95%的网站上运行,这对于我们的应用已经足够了,因为我们正在寻找总体趋势而非精确性。 请注意,有时您确实希望获得 100%的响应,并且应该调整代码以容纳更多的错误。 获得最终 5%到 10%网站的代码将变得更加复杂。 然后,我们捕获可能发生的任何错误(这是 Internet,许多事情可能出错),增加错误计数,然后继续。
except Exception as e:
number_errors += 1
print(e)
如果发现发生太多错误,请将print(e)行更改为仅键入raise。 这将导致调用异常,从而使您可以调试问题。
现在,子文件夹中有很多网站。 看完这些页面(在文本编辑器中打开创建的文件)之后,您可以看到其中存在内容,但是有 HTML,JavaScript,CSS 代码以及其他内容。 由于我们仅对故事本身感兴趣,因此我们现在需要一种从这些不同的网站中提取此信息的方法。
全部放在一起
在获得原始数据之后,我们需要在每个数据中找到故事。 有一些在线来源使用数据挖掘来实现此目的。 您可以在第 13 章中找到它们。 尽管使用它们可以得到更好的准确性,但是很少需要使用这样的复杂算法。 这是数据挖掘的一部分-知道何时使用它,何时不使用。
首先,我们在raw子文件夹中获得每个文件名的列表:
filenames = [os.path.join(data_folder, filename)
for filename in os.listdir(data_folder)]
接下来,我们为要提取的纯文本版本创建一个output文件夹:
text_output_folder = os.path.join(os.path.expanduser("~"), "Data",
"websites", "textonly")
接下来,我们开发代码以从文件中提取文本。 我们将使用lxml库来解析 HTML 文件,因为该库具有处理某些格式错误的表达式的良好 HTML 解析器。 代码如下:
from lxml import etree
提取文本的实际代码基于三个步骤。 首先,我们遍历 HTML 文件中的每个节点,并提取其中的文本。 其次,我们跳过任何包含 JavaScript,样式或注释的节点,因为这不太可能包含我们感兴趣的信息。 第三,我们确保内容至少包含 100 个字符。 这是一个很好的基准,但是可以进行改进以获得更准确的结果。
如前所述,我们对脚本,样式或注释不感兴趣。 因此,我们创建了一个列表来忽略这些类型的节点。 此列表中具有类型的任何节点都不会被视为包含故事。 代码如下:
skip_node_types = ["script", "head", "style", etree.Comment]
现在,我们将创建一个将 HTML 文件解析为lxml etree的函数,然后我们将创建另一个解析该树以查找文本的函数。 第一个功能非常简单; 只需使用lxml库的 HTML 文件解析功能打开文件并创建树。 代码如下:
def get_text_from_file(filename):
with open(filename) as inf:
html_tree = lxml.html.parse(inf)
return get_text_from_node(html_tree.getroot())
在该函数的最后一行,我们调用getroot()函数来获取树的根节点,而不是完整的etree。 这使我们可以编写文本提取函数以接受任何节点,因此可以编写递归函数。
此函数将在任何子节点上调用自身以从中提取文本,然后返回任何子节点文本的串联。
如果传递此函数的节点没有任何子节点,则仅从中返回文本。 如果没有任何文本,我们只返回一个空字符串。 请注意,我们还在此处检查第三个条件-文本至少 100 个字符长。 代码如下:
def get_text_from_node(node):
if len(node) == 0:
# No children, just return text from this item
if node.text and len(node.text) > 100:
return node.text
else:
return ""
至此,我们知道该节点具有子节点,因此我们在每个子节点上递归调用此函数,然后在它们返回时加入结果。 代码如下:
results = (get_text_from_node(child) for child in node
if child.tag not in skip_node_types)
return "\n".join(r for r in results if len(r) > 1)
返回结果的最终条件是停止返回空行(例如,当节点没有子节点也没有文本时)。
现在,我们可以通过遍历原始 HTML 页面,在每个原始 HTML 页面上调用文本提取函数并将结果保存到纯文本子文件夹来在所有原始 HTML 页面上运行此代码:
for filename in os.listdir(data_folder):
text = get_text_from_file(os.path.join(data_folder, filename))
with open(os.path.join(text_output_folder, filename), 'w') as outf:
outf.write(text)
您可以通过打开“纯文本”子文件夹中的每个文件并检查其内容来手动评估结果。 如果发现太多结果包含非故事内容,请尝试增加最小 100 个字符的限制。 如果仍然无法获得满意的结果,或者需要更好的结果,请尝试使用第 13 章中列出的更复杂的方法。
分组新闻文章
本章的目的是通过将新闻文章进行聚类或分组来发现新闻文章中的趋势。 为此,我们将使用 k-means 算法,这是一种最初于 1957 年开发的经典机器学习算法。
聚类是一种无监督的学习技术,我们使用聚类算法来探索数据。 我们的数据集包含大约 500 个故事,要逐一检查每个故事将非常艰巨。 即使我们使用汇总统计信息,仍然是很多数据。 通过使用聚类,我们可以将相似的故事分组在一起,并且我们可以独立地探索每个聚类中的主题。
当我们没有清晰的数据目标类集时,我们将使用聚类技术。 从这个意义上讲,聚类算法的学习方向不大。 他们根据某种功能学习,而与数据的基本含义无关。 因此,选择良好的功能至关重要。 在监督学习中,如果选择较差的功能,则学习算法可以选择不使用那些功能。 例如,支持向量机将对那些在分类中没有用的特征给予很小的重视。 但是,通过聚类,所有功能都将用于最终结果中,即使这些功能无法为我们提供所需的答案。
在对真实数据进行聚类分析时,最好先了解哪种功能将适用于您的方案。 在本章中,我们将使用词袋模型。 我们正在寻找基于主题的组,因此我们将使用基于主题的功能为文档建模。 我们知道这些功能有效是因为其他人在问题的监督版本中所做的工作。 相反,如果要执行基于作者身份的聚类,则将使用诸如 第 9 章,“作者身份归因”实验中发现的功能。
k-均值算法
k 均值聚类算法使用迭代过程找到最能代表数据的质心。 该算法从一组预定义的质心开始,这些质心通常是从训练数据中获取的数据点。 k 均值中的k是要查找的质心数以及该算法将找到多少个簇。 例如,将k设置为3将在数据集中找到三个聚类。
k 均值分为两个阶段:分配和更新。
在分配步骤中,我们为数据集中的每个样本设置了标签,将其链接到最近的质心。 对于每个最接近质心 1 的样本,我们为其分配标签 1。对于每个最接近质心 2 的样本,我们为每个k重心分配一个标签 2,依此类推。 这些标签形成聚类,因此我们说每个带有标签 1 的数据点都在聚类 1 中(仅此时,因为分配可以随着算法的运行而变化)。
在更新步骤中,我们采用每个聚类并计算质心,质心是该聚类中所有样本的平均值。
然后,算法在分配步骤和更新步骤之间进行迭代; 每次执行更新步骤时,每个质心都会移动少量。 这将导致分配稍有变化,从而导致质心在下一次迭代中移动少量。 重复此过程,直到达到某个停止标准为止。 通常在经过一定数量的迭代后或质心的总运动非常低时停止。 该算法在某些情况下也可以完成,这意味着群集是稳定的,分配不会更改,质心也不会更改。
在下图中,对随机创建的数据集执行了 k 均值,但是数据中包含三个簇。 星形代表质心的起始位置,这些质心是通过从数据集中选取随机样本随机选择的。 在 k 均值算法的 5 次迭代中,质心移至三角形表示的位置。

k 均值算法因其数学特性和历史意义而着迷。 它是一种(大约)只有一个参数的算法,在发现后的 50 多年中,它是相当有效且经常使用的算法。
scikit-learn 中有一个 k-means 算法,我们从cluster子包中导入该算法:
from sklearn.cluster import KMeans
我们还导入了CountVectorizer类的近亲表弟TfidfVectorizer。 此向量化器将根据每个术语的出现次数对每个术语的计数进行加权。许多文档中出现的术语的权重较低(通过将值除以出现在其中的文档数的对数)。 对于许多文本挖掘应用,使用这种类型的加权方案可以相当可靠地提高性能。 代码如下:
from sklearn.feature_extraction.text import TfidfVectorizer
然后,我们建立了用于分析的管道。 这有两个步骤。 首先是应用向量化器,其次是应用 k-means 算法。 代码如下:
from sklearn.pipeline import Pipeline
n_clusters = 10
pipeline = Pipeline([('feature_extraction', TfidfVectorizer(max_df=0.4)),
('clusterer', KMeans(n_clusters=n_clusters))
])
max_df参数设置为 0.4 的较低值,表示忽略 40%以上的文档中出现的任何单词。 此参数对于删除功能词本身无价值的意义非常大。
注意
删除 40%以上的文档中出现的任何单词都会删除功能单词,这对于我们在第 9 章和“作者身份”中看到的工作来说,这种类型的预处理非常无用。
然后,我们拟合并预测该管道。 到目前为止,在该模块中,我们已经针对分类任务执行了多次此过程,但是这里有所不同-我们没有将数据集的目标类提供给 fit 函数。 这就是使它成为无监督学习任务的原因! 代码如下:
pipeline.fit(documents)
labels = pipeline.predict(documents)
labels变量现在包含每个样本的簇号。 带有相同标签的样品被称为属于同一类。 应当注意,集群标签本身是没有意义的:集群 1 和 2 与集群 1 和 3 不再相似。
我们可以使用Counter类查看每个簇中放置了多少样本:
from collections import Counter
c = Counter(labels)
for cluster_number in range(n_clusters):
print("Cluster {} contains {} samples".format(cluster_number, c[cluster_number]))
结果中的许多(请记住,您的数据集将与我的完全不同)由一个包含大多数实例的大型群集,几个中等群集以及一些仅包含一个或两个实例的群集组成。 在许多群集应用中,这种不平衡是很正常的。
评估结果
聚类主要是的探索性分析,因此难以有效评估聚类算法的结果。 一种直接的方法是根据算法尝试学习的标准来评估算法。
注意
如果有测试集,则可以根据它评估聚类。 有关更多详细信息,请访问这个页面。
对于 k 均值算法,开发质心时使用的标准是最小化每个样本到其最近质心的距离。 这称为算法的惯性,可以从对其进行调用的任何KMeans实例中检索出来:
pipeline.named_steps['clusterer'].inertia_
我的数据集上的结果是 343.94。 不幸的是,这个值本身是毫无意义的,但是我们可以使用它来确定应该使用多少个群集。 在前面的示例中,我们将n_clusters设置为 10,但这是最佳值吗? 下面的代码运行 10 次 k-means 算法,其中n_clusters的每个值从 2 到 20。每次运行时,它记录结果的惯性。
我们仅将n_clusters的每个值拟合X矩阵一次,以(大幅)提高此代码的速度:
inertia_scores = []
n_cluster_values = list(range(2, 20))
for n_clusters in n_cluster_values:
cur_inertia_scores = []
X = TfidfVectorizer(max_df=0.4).fit_transform(documents)
for i in range(10):
km = KMeans(n_clusters=n_clusters).fit(X)
cur_inertia_scores.append(km.inertia_)
inertia_scores.append(cur_inertia_scores)
inertia_scores变量现在包含 2 到 20 之间的每个n_clusters值的惯性分数列表。我们可以对其进行绘图,以了解该值如何与n_clusters相互作用:

总体而言,随着簇数的增加,惯性的值应随着改进的减少而减小,这可以从这些结果中大致看到。 值 6 到 7 之间的增加仅是由于选择质心的随机性,这直接影响最终结果的质量。 尽管如此,总的趋势是(在这些结果中;您的结果可能会有所不同),大约是 6 个簇是最后一次惯性发生重大改善。
此后,尽管很难对诸如此类的模糊标准做出具体规定,但对惯性仅作了些微改进。 寻找这种类型的图案称为弯头规则,因为我们正在图中寻找弯头式弯头。 一些数据集的弯头更明显,但是不能保证甚至出现此功能(某些图形可能很平滑!)。
基于此分析,我们将n_clusters设置为 6,然后重新运行该算法:
n_clusters = 6
pipeline = Pipeline([('feature_extraction', TfidfVectorizer(max_df=0.4)),
('clusterer', KMeans(n_clusters=n_clusters))
])
pipeline.fit(documents)
labels = pipeline.predict(documents)
从集群中提取主题信息
现在我们将目光聚焦在集群上,以尝试发现每个集群中的主题。 我们首先从特征提取步骤中提取term列表:
terms = pipeline.named_steps['feature_extraction'].get_feature_names()
我们还设置了另一个计数器来计算每个类的大小:
c = Counter(labels)
遍历每个集群,我们像以前一样打印集群的大小。 在评估结果时,请记住群集的大小,这一点很重要-一些群集仅具有一个样本,因此并不表示总体趋势。 代码如下:
for cluster_number in range(n_clusters):
print("Cluster {} contains {} samples".format(cluster_number, c[cluster_number]))
接下来(仍然在循环中),我们遍历此集群的最重要术语。 为此,我们从质心中选取五个最大的值,方法是找到质心本身中具有最高值的要素。 代码如下:
print(" Most important terms")
centroid = pipeline.named_steps['clusterer'].cluster_centers_[cluster_number]
most_important = centroid.argsort()
然后,我们打印出最重要的五个术语:
for i in range(5):
我们在这一行中使用i的取反,因为我们的most_important数组首先以最低值排序:
term_index = most_important[-(i+1)]
然后,我们为该值打印等级,术语和得分:
print(" {0}) {1} (score: {2:.4f})".format(i+1, terms[term_index], centroid[term_index]))
结果可以很好地指示当前趋势。 在我的结果(2015 年 3 月)中,这些分类对应于健康问题,中东紧张局势,朝鲜紧张局势和俄罗斯事务。 这些是这段时间新闻频繁出现的主要主题-尽管多年来一直没有改变!
使用聚类算法作为转换器
作为的补充说明,关于 k 均值算法(和任何聚类算法)的一个有趣特性是可以将其用于特征约简。 有许多方法可以减少要素的数量(或创建新的要素以将数据集嵌入到要素上),例如主成分分析,潜在语义索引等。 这些算法中的许多问题之一是它们通常需要大量的计算能力。
在前面的示例中,术语列表中有 14,000 多个条目-这是一个很大的数据集。 我们的 k 均值算法将其转换为六个簇。 然后,我们可以通过将每个质心的距离作为特征来创建特征数量少得多的数据集。 代码如下:
为此,我们在 KMeans 实例上调用 transform 函数。 我们的管道适合于此目的,因为它的末尾有一个 k-means 实例:
X = pipeline.transform(documents)
这将在流水线的最后一步调用转换方法,这是 k 均值的一个实例。 这将导致具有六个特征的矩阵,并且样本数与文档的长度相同。
然后,您可以对结果执行自己的第二级聚类,如果有目标值,则可以将其用于分类。 为此,可能的工作流程是使用监督数据执行某些特征选择,使用聚类将特征数量减少到更易于管理的数量,然后在分类算法(例如 SVM)中使用结果。

聚类合奏
在第 3 章,“用决策树”预测运动优胜者中,我们研究了使用随机森林算法的分类集成,该集成是许多低质量, 基于分类器。 也可以使用聚类算法进行组装。 这样做的关键原因之一是平滑算法多次运行的结果。 正如我们之前看到的,根据初始质心的选择,运行 k 均值的结果会有所不同。 可以通过多次运行算法然后组合结果来减少差异。
集成还可以减少选择参数对最终结果的影响。 大多数聚类算法对为该算法选择的参数值非常敏感。 选择稍有不同的参数将导致不同的群集。
证据积累
作为基本合奏,我们可以首先对数据进行多次聚类并记录每次运行的标签。 然后,我们记录每对样本在一个新矩阵中聚集在一起的次数。 这是证据累积聚类(EAC)算法的本质。
EAC 有两个主要步骤。 第一步是使用低级聚类算法(例如 k 均值)对数据进行多次聚类,并在每次迭代中记录样本在同一聚类中的频率。 该存储在coassociation矩阵中。 第二步是对生成的coassociation矩阵执行聚类分析,该分析使用另一种称为层次聚类的聚类算法执行。 该具有有趣的属性,因为它在数学上与找到将所有节点链接在一起并删除弱链接的树相同。
通过遍历每个标签并记录两个样本具有相同标签的地方,我们可以从标签数组中创建一个coassociation矩阵。 我们使用 SciPy 的csr_matrix,它是一种稀疏矩阵:
from scipy.sparse import csr_matrix
我们的函数定义带有一组标签:
def create_coassociation_matrix(labels):
然后,我们记录每个匹配项的行和列。 我们在列表中进行这些操作。 稀疏矩阵通常只是记录非零值位置的列表的集合,csr_matrix是这种稀疏矩阵的示例:
rows = []
cols = []
然后,我们遍历每个单独的标签:
unique_labels = set(labels)
for label in unique_labels:
我们寻找具有该标签的所有样品:
indices = np.where(labels == label)[0]
对于带有前面标签的每对样本,我们将两个样本的位置记录在列表中。 代码如下:
for index1 in indices:
for index2 in indices:
rows.append(index1)
cols.append(index2)
在所有循环之外,我们然后创建数据,每次将两个样本一起列出时,该数据的值就是 1。 通过注意总共在我们的标签集中有多少个匹配项,可以得出 1 的数量。 代码如下:
data = np.ones((len(rows),))
return csr_matrix((data, (rows, cols)), dtype='float')
要从标签中获取关联矩阵,我们只需调用此函数:
C = create_coassociation_matrix(labels)
从这里,我们可以将这些矩阵的多个实例加在一起。 这使我们可以合并多次 k 均值的结果。 打印出 C(只需将 C 输入一个新的单元格并运行它)将告诉您其中有多少个单元格具有非零值。 在我的情况下,大约一半的单元格中都有值,因为我的聚类结果具有较大的聚类(聚类越多,非零值的数量越少)。
下一步涉及协同关联矩阵的层次聚类。 我们将通过在此矩阵上找到最小的生成树并删除权重低于给定阈值的边来做到这一点。
在图理论中,生成树是图上将所有节点连接在一起的一组边。 最小生成树(MST)只是具有最低总权重的生成树。 对于我们的应用,图中的节点是数据集中的样本,边缘权重是这两个样本聚在一起的次数,即,来自我们的关联矩阵的值。
在下图中,显示了六个节点的图形上的 MST。 图中的节点可以在 MST 中多次使用。 生成树的唯一标准是所有节点都应连接在一起。

为了计算 MST,我们使用 SciPy 的minimum_spanning_tree函数,该函数在sparse程序包中找到:
from scipy.sparse.csgraph import minimum_spanning_tree
可以直接在我们的关联函数返回的稀疏矩阵上调用mst函数:
mst = minimum_spanning_tree(C)
但是,在我们的关联矩阵C中,较高的值表示样本聚集在一起的频率更高,即相似度值。 相反,minimum_spanning_tree将输入视为距离,但得分较高。 由于这个原因,我们改为根据协关联矩阵的求反来计算最小生成树:
mst = minimum_spanning_tree(-C)
上一个函数的结果是一个矩阵的大小与协关联矩阵的大小相同(行和列的数量与数据集中的样本数相同),仅保留了 MST 中的边,所有其他边均被删除。
然后,我们删除任何权重小于预定义阈值的节点。 为此,我们在 MST 矩阵的边缘上进行迭代,删除所有小于特定值的边缘。 我们不能仅凭一个在关联矩阵中的迭代来测试这一点(值将为 1 或 0,因此没有太多需要处理的)。 因此,我们将首先创建额外的标签,创建协关联矩阵,然后将两个矩阵加在一起。 代码如下:
pipeline.fit(documents)
labels2 = pipeline.predict(documents)
C2 = create_coassociation_matrix(labels2)
C_sum = (C + C2) / 2
然后,我们计算 MST 并删除这两个标签中未出现的任何边:
mst = minimum_spanning_tree(-C_sum)
mst.data[mst.data > -1] = 0
我们想要切掉的阈值是两个聚类中都不存在的任何边缘,即值为 1。但是,当我们否定协关联矩阵时,我们也必须否定该阈值。
最后,我们找到所有连接的分量,这是在去除低权重的边缘之后找到仍通过边缘连接的所有样本的一种简单方法。 返回的第一个值是连接的组件数(即簇数),第二个是每个样本的标签。 代码如下:
from scipy.sparse.csgraph import connected_components
number_of_clusters, labels = connected_components(mst)
在我的数据集中,我获得了八个聚类,这些聚类与以前大致相同。 鉴于我们仅使用了两次 k 均值迭代,这不足为奇。 使用更多的 k 均值迭代(如我们在下一节中所做的那样)将导致更多的方差。
工作原理
在 k 均值算法中,使用每个特征时均不考虑其权重。 从本质上讲,所有功能均假定为相同比例。 我们在第 2 章,“用 scikit-learn 估计器分类”中看到了不具有缩放功能的问题。 结果是 k-means 正在寻找圆形簇,如以下屏幕截图所示:

正如我们在前面的屏幕截图中所看到的,并不是所有的集群都具有这种形状。 蓝色簇是圆形的,并且是 k 均值非常擅长的类型。 红色簇是椭圆形。 k 均值算法可以通过某种特征缩放来拾取这种形状的聚类。 第三个簇甚至不是凸的-k 均值将很难发现,这是一个奇怪的形状。
EAC 算法通过将特征重新映射到新的空间来工作,实质上是使用与上一节中使用 k 均值进行特征约简的原理相同的原理将 k 均值算法的每次运行转换为变形器。 但是,在这种情况下,我们仅使用实际标签,而不使用到每个质心的距离。 这是在关联矩阵中记录的数据。
结果是,EAC 现在只在乎事物之间的距离,而不必关心它们在原始特征空间中的放置方式。 围绕未缩放的功能仍然存在问题。 特征缩放很重要,并且无论如何都应该进行(我们在本章中使用tf-idf进行了缩放,这导致特征值具有相同的缩放比例)。
通过在 SVM 中使用内核,我们在第 9 章,“作者身份”中看到了类似的转换形式。 这些转换非常强大,对于复杂的数据集应牢记。
实施
综合所有这些,我们现在可以创建一个适合 scikit-learn 接口的简单聚类算法,该接口执行 EAC 中的所有步骤。 首先,我们使用 scikit-learn 的ClusterMixin创建该类的基本结构:
from sklearn.base import BaseEstimator, ClusterMixin
class EAC(BaseEstimator, ClusterMixin):
我们的参数是第一步(创建协关联矩阵)要执行的 k 均值聚类数,要在其中截止的阈值以及在每个 k 均值聚类中要找到的聚类数。 我们设置n_clusters的范围是为了在我们的 k 均值迭代中获得很多方差。 通常,从整体上讲,差异是一件好事; 没有它,解决方案将不会比单个聚类更好(也就是说,高方差并不表示整体会更好)。 代码如下:
def __init__(self, n_clusterings=10, cut_threshold=0.5, n_clusters_range=(3, 10)):
self.n_clusterings = n_clusterings
self.cut_threshold = cut_threshold
self.n_clusters_range = n_clusters_range
接下来是我们的EAC类的fit函数:
def fit(self, X, y=None):
然后,我们使用 k 均值执行低级聚类,并对每次迭代所得的关联矩阵求和。 我们在生成器中执行此操作以节省内存,仅在需要它们时才创建协关联矩阵。 在此生成器的每次迭代中,我们使用数据集创建一个新的单 k 均值,然后为其创建协关联矩阵。 我们使用sum将它们加在一起。 代码如下:
C = sum((create_coassociation_matrix(self._single_clustering(X))
for i in range(self.n_clusterings)))
和以前一样,我们创建 MST,删除小于给定阈值的任何边(如前所述,适当取负值),然后找到连接的组件。 与 scikit-learn 中的任何fit函数一样,我们需要返回 self 以使类有效地在管道中工作。 代码如下:
mst = minimum_spanning_tree(-C)
mst.data[mst.data > -self.cut_threshold] = 0
self.n_components, self.labels_ = connected_components(mst)
return self
然后,我们编写函数以对单个迭代进行聚类。 为此,我们使用 NumPy 的randint函数和我们的n_clusters_range参数随机选择了多个聚类,以设置可能值的范围。 然后,我们使用 k 均值对数据集进行聚类和预测。 这里的返回值将是来自 k 均值的标签。 代码如下:
def _single_clustering(self, X):
n_clusters = np.random.randint(*self.n_clusters_range)
km = KMeans(n_clusters=n_clusters)
return km.fit_predict(X)
现在,我们可以像以前一样通过建立管道并使用 EAC(以前使用KMeans实例作为管道的最后阶段)来在以前的代码上运行此代码。 代码如下:
pipeline = Pipeline([('feature_extraction', TfidfVectorizer(max_df=0.4)),
('clusterer', EAC())
])
在线学习
在某些情况下,在我们开始学习之前,我们没有训练所需的所有数据。 有时,我们在等待新数据到达,也许我们拥有的数据太大而无法容纳到内存中,或者在做出预测后我们收到了额外的数据。 在这种情况下,在线学习是随时间推移训练模型的一种选择。
在线学习简介
在线学习是随着新数据的到来而对模型进行的增量更新。 支持在线学习的算法可以一次训练一个或几个样本,并在新样本到达时进行更新。 相反,非在线算法要求立即访问所有数据。 标准 k 均值算法就是这样,到目前为止我们在本模块中看到的大多数算法都是这样。
在线版本的算法仅用几个样本就可以部分更新其模型。 神经网络是一种以在线方式工作的算法的标准示例。 当将新样本提供给神经网络时,网络中的权重会根据学习率进行更新,该学习率通常是一个非常小的值,例如 0.01。 这意味着任何单个实例都只会对模型进行较小(但希望有所改善)的更改。
神经网络也可以以批处理模式进行训练,其中一次给出一组样本,并且训练是一步完成的。 在批处理模式下,算法更快,但使用更多的内存。
同样地,我们可以在单个或少量样本后稍微更新 k 均值质心。 为此,我们在 k 均值算法的更新步骤中将学习率应用于质心运动。 假设样本是从总体中随机选择的,则质心应趋向于它们在标准,离线和 k 均值算法中的位置。
在线学习与基于流的学习有关; 但是,有一些重要的区别。 在线学习能够在模型中使用较旧的样本后对其进行审查,而基于流的机器学习算法通常仅一次通过,也就是说,只有一次机会查看每个样本。
实施
scikit-learn 软件包包含MiniBatchKMeans算法,该算法允许在线学习。 该类实现partial_fit函数,该函数获取一组样本并更新模型。 相反,调用fit()将删除以前的所有训练,仅在新数据上重新拟合模型。
MiniBatchKMeans遵循与 scikit-learn 中其他算法相同的聚类格式,因此创建和使用它与其他算法非常相似。
因此,我们可以通过使用TfIDFVectorizer从数据集中提取特征来创建矩阵X,然后从中进行采样以逐步更新模型。 代码如下:
vec = TfidfVectorizer(max_df=0.4)
X = vec.fit_transform(documents)
然后,我们导入MiniBatchKMeans并创建一个实例:
from sklearn.cluster import MiniBatchKMeans
mbkm = MiniBatchKMeans(random_state=14, n_clusters=3)
接下来,我们将从X矩阵中随机采样以模拟来自外部源的数据。 每次输入一些数据时,我们都会更新模型:
batch_size = 10
for iteration in range(int(X.shape[0] / batch_size)):
start = batch_size * iteration
end = batch_size * (iteration + 1)
mbkm.partial_fit(X[start:end])
然后,我们可以通过要求实例进行预测来获取原始数据集的标签:
labels = mbkm.predict(X)
不过,在此阶段,我们无法在管道中执行此操作,因为TfIDFVectorizer不是在线算法。 为了解决这个问题,我们使用HashingVectorizer。 HashingVectorizer类巧妙地使用了散列算法,从而大大减少了计算词袋模型的内存。 我们只记录那些名称的哈希,而不是记录特征名称(如文档中的单词)。 这使我们甚至在查看数据集之前,就可以知道我们的功能,因为它是所有可能散列的集合。 这是一个非常大的数字,通常约为 2 ^ 18。 使用稀疏矩阵,我们甚至可以很容易地存储和计算这种大小的矩阵,因为很大一部分矩阵的值为 0。
当前,Pipeline类不允许在在线学习中使用它。 在不同的应用中存在一些细微差别,这意味着没有一种明显的“一刀切”的方法可以实施。 相反,我们可以创建自己的Pipeline子类,使我们可以将其用于在线学习。 我们首先从Pipeline派生我们的类,因为我们只需要实现一个函数:
class PartialFitPipeline(Pipeline):
我们创建一个类函数partial_fit,它接受输入矩阵和一个可选的类集(尽管本实验中不需要这些类):
def partial_fit(self, X, y=None):
我们之前介绍过的管道是一组转换,其中一步的输入是上一步的输出。 为此,我们将第一个输入设置为X矩阵,然后遍历每个转换器以转换此数据:
Xt = X
for name, transform in self.steps[:-1]:
然后,我们转换当前数据集并继续进行迭代,直到完成最后一步(在本例中为聚类算法):
Xt = transform.transform(Xt)
然后,我们在最后一步调用partial_fit函数并返回结果:
return self.steps[-1][1].partial_fit(Xt, y=y)
现在,我们可以与HashingVectorizer一起创建一个管道,以在在线学习中使用MiniBatchKMeans。 除了使用我们的新类PartialFitPipeline和HashingVectorizer外,此过程与本章其余部分所用的过程相同,只是一次只能容纳几个文档。 代码如下:
pipeline = PartialFitPipeline([('feature_extraction', HashingVectorizer()),
('clusterer', MiniBatchKMeans(random_state=14, n_clusters=3))
])
batch_size = 10
for iteration in range(int(len(documents) / batch_size)):
start = batch_size * iteration
end = batch_size * (iteration + 1)
pipeline.partial_fit(documents[start:end])
labels = pipeline.predict(documents)
不过,仍存在一些缺点。 首先,我们无法轻松地找出每个单词中最重要的单词。 我们可以通过拟合另一个CountVectorizer并获取每个单词的哈希值来解决此问题。 然后,我们通过哈希而不是单词来查找值。 这有点麻烦,并且会破坏使用HashingVectorizer的内存。 此外,我们无法使用之前使用的max_df参数,因为它要求我们知道功能的含义并随时间对其进行计数。
在线进行训练时,我们也无法使用tf-idf加权。 可以对此进行近似并应用这样的加权,但这又是一个麻烦的方法。 HashingVectorizer仍然是一种非常有用的算法,并且大量使用了哈希算法。




三十二、使用深度学习分类图像中的对象
我们在第 8 章,“和神经网络”打败验证码中使用了基本的神经网络。 该地区最近的大量研究已使该基础设计取得了许多重大进展。 如今,神经网络的研究正在许多领域中创建一些最先进,最准确的分类算法。
这些进步来自计算能力的提高,这使我们能够训练更大,更复杂的网络。 但是,这些进步远不只是简单地为该问题投入更多的计算能力。 新的算法和层类型大大提高了性能,超出了计算能力。
在本章中,我们将研究确定图像中表示的对象。 像素值将用作输入,然后神经网络将自动找到有用的像素组合以形成更高级别的特征。 然后将这些用于实际分类。 总体而言,在本章中,我们将研究以下内容:
- 分类图像中的对象
- 不同类型的深度神经网络
- Theano,Lasagne 和 nolearn; 库来构建和训练神经网络
- 使用 GPU 来提高算法速度
对象分类
计算机视觉正成为未来技术的重要组成部分。 例如,我们将在未来五年内使用自动驾驶汽车(如果相信一些传闻,则可能会更快)。 为了实现这一目标,汽车的计算机必须能够看到周围的物体:障碍物,其他交通状况和天气状况。
尽管我们可以很容易地检测到是否存在障碍物(例如使用雷达),但知道该物体是什么也很重要。 如果是动物,它可能会移开。 如果它是建筑物,它将完全不会移动,我们需要绕开它。
应用场景和目标
在本章中,我们将构建一个系统,该系统将图像作为输入并预测其中的对象是什么。 我们将扮演汽车视觉系统的角色,环视道路或道路两侧的任何障碍物。 图像具有以下形式:

该数据集来自一个流行的数据集 CIFAR-10。 它包含 60,000 张图像,这些图像的宽度为 32 像素,高度为 32 像素,每个像素都有一个红绿蓝(RGB)值。 数据集已经分为训练和测试,尽管直到完成训练后我们才使用测试数据集。
注意
CIFAR-10 数据集可从以下位置下载。 下载 python 版本,该版本已转换为 NumPy 数组。
打开一个新的 IPython Notebook,我们可以看到数据的样子。 首先,我们设置数据文件名。 我们只担心第一个批次开始,并在最后增加到完整的数据集大小。
import os
data_folder = os.path.join(os.path.expanduser("~"), "Data", "cifar-10-batches-py")
batch1_filename = os.path.join(data_folder, "data_batch_1")
接下来,我们创建一个可以读取批处理中存储的数据的函数。 批处理已使用 pickle 保存,pickle 是用于保存对象的 python 库。 通常,我们只需在文件上调用pickle.load即可获取对象。 但是,此数据有一个小问题:它已保存在 Python 2 中,但是我们需要在 Python 3 中打开它。为了解决此问题,我们将编码设置为拉丁语(即使我们 正在以字节模式打开它):
import pickle
# Bigfix thanks to: http://stackoverflow.com/questions/11305790/pickle-incompatability-of-numpy-arrays-between-python-2-and-3
def unpickle(filename):
with open(filename, 'rb') as fo:
return pickle.load(fo, encoding='latin1')
使用此功能,我们现在可以加载批处理数据集:
batch1 = unpickle(batch1_filename)
该批处理是一个字典,其中包含 NumPy 数组中的实际数据,相应的标签和文件名,最后是一个注释,说明该批处理是什么(例如,这是训练 5 的第 1 批)。
我们可以通过使用批次数据键中的索引来提取图像:
image_index = 100
image = batch1['data'][image_index]
图像数组是一个 NumPy 数组,具有 3,072 个条目(从 0 到 255)。每个值都是图像中特定位置的红色,绿色或蓝色强度。
图像的格式与 matplotlib 通常使用的格式不同(用于显示图像),因此,要显示图像,我们首先需要对阵列进行整形并旋转矩阵。 训练我们的神经网络并不重要(我们将以适合数据的方式定义网络),但是为了 matplotlib 的缘故,我们确实需要对其进行转换:
image = image.reshape((32,32, 3), order='F')
import numpy as np
image = np.rot90(image, -1)
现在我们可以使用 matplotlib 显示图像:
%matplotlib inline
from matplotlib import pyplot as plt
plt.imshow(image)
显示结果图像,一条船:

该图像的分辨率非常差-仅 32 像素宽和 32 像素高。 尽管如此,大多数人还是会看着图像并看到一条船。 我们可以让计算机做同样的事情吗?
您可以更改图像索引以显示不同的图像,以了解数据集的属性。
在本章中,我们项目的目的是建立一个分类系统,该系统可以拍摄这样的图像并预测其中的对象。
用例
在许多情况下都使用计算机视觉。
在线地图网站(例如 Google Maps)出于多种原因使用计算机视觉。 原因之一是自动模糊他们发现的任何面孔,以便为作为街景功能一部分的被拍照人员提供一定的隐私保护。
人脸检测还用于许多行业。 现代相机会自动检测人脸,以提高所拍摄照片的质量(用户最常希望将焦点对准可见的脸)。 人脸检测也可以用于识别。 例如,Facebook 自动识别照片中的人物,从而轻松标记朋友。
正如我们之前所述,自动驾驶汽车高度依赖计算机视觉来识别其路径并避免障碍。 计算机视觉是不仅在自动驾驶汽车研究中要解决的关键问题之一,不仅是供消费者使用,还包括采矿和其他行业。
其他行业也在使用计算机视觉,包括仓库自动检查货物是否有缺陷。
航天工业也在使用计算机视觉,以帮助自动化数据收集。 这对于有效利用航天器至关重要,因为从地球向火星上的漫游者发送信号可能会花费很长时间,并且在某些时候是不可能的(例如,当两个行星彼此不面对时)。 随着我们越来越频繁地从更远的距离开始处理天基飞行器,绝对有必要提高这些航天器的自主性。
以下屏幕截图显示了由 NASA 设计和使用的火星探测器; 它充分利用了计算机视觉:

深度神经网络
我们在第 8 章和“用神经网络”击败 CAPTCHAs 中使用的神经网络具有一些出色的理论特性。 例如,只需要一个隐藏层即可学习任何映射(尽管中间层的大小可能需要非常大)。 神经网络在 1970 年代和 1980 年代是非常活跃的研究领域,因此不再使用这些网络,特别是与其他分类算法(例如支持向量机)相比。 主要问题之一是运行许多神经网络所需的计算能力比其他算法还要多,而且比许多人可以访问的能力还要多。
另一个问题是训练网络。 虽然反向传播算法已经有一段时间了,但是它在较大的网络中存在问题,需要在权重确定之前进行大量的训练。
这些问题中的每一个都已在近期得到解决,从而导致了神经网络的流行。 现在比 30 年前更容易获得计算能力,并且训练算法的进步意味着我们现在可以随时使用该能力。
直觉
在第 8 章,“和神经网络”中,将深度神经网络与更基本的神经网络区分开来。 当神经网络具有两个或多个隐藏层时,它被认为是。 在实践中,无论是在每层的节点数还是在层数上,深度神经网络通常都大得多。 虽然 2000 年代中期的一些研究集中在非常大量的层上,但是更智能的算法正在减少所需的实际层数。
神经网络基本上将非常基本的特征作为输入-在计算机视觉的情况下,它是简单的像素值。 然后,随着数据的组合和通过网络的推送,这些基本功能将合并为更复杂的功能。 有时,这些功能对人类意义不大,但它们代表了计算机进行分类所需的样本方面。
实施
由于它们的规模,实现这些深度神经网络可能非常具有挑战性。 一个不好的实现比一个好的实现要花费更长的时间,并且由于内存的使用甚至可能根本无法运行。
神经网络的基本实现可能首先创建一个节点类,然后将它们的集合收集到一个层类中。 然后,使用 Edge 类的实例将每个节点连接到下一层中的节点。 这种实现是基于类的实现,很好地展示了网络是如何工作的,但对于大型网络而言效率太低。
神经网络的核心只是简单的矩阵数学表达式。 一个网络与下一个网络之间的连接权重可以表示为值矩阵,其中行表示第一层中的节点,列表示第二层中的节点(有时也使用此矩阵的转置) 。 该值是一层与下一层之间的边缘的权重。 然后可以将网络定义为这些权重矩阵的集合。 除了节点之外,我们还向每层添加一个偏差项,该偏差项基本上是一个始终位于并连接到下一层中每个神经元的节点。
这种见解使我们能够使用数学运算来构建,训练和使用神经网络,而不是创建基于类的实现。 这些数学运算非常棒,因为已经编写了许多很棒的高度优化的代码库,我们可以使用它们尽可能高效地执行这些计算。
我们在第 8 章,“用神经网络”击败 CAPTCHAs 中使用的PyBrain库确实包含用于神经网络的简单卷积层。 但是,它没有为我们提供此应用所需的某些功能。 但是,对于更大,更自定义的网络,我们需要一个库,该库可以为我们提供更多功能。 因此,将使用Lasagne和nolearn库。 该库在Theano库上运行,该库是用于数学表达式的有用工具。
在本章中,我们将从使用Lasagne实现基本的神经网络开始,以介绍概念。 然后,我们将使用nolearn在第 8 章,“和神经网络”预测图像中的哪个字母上复制实验。 最后,我们将使用复杂得多的卷积神经网络对 CIFAR 数据集进行图像分类,这还将包括在 GPU 而不是 CPU 上运行该算法以提高性能。
Theano 简介
Theano 是一个库,可让您构建数学表达式并运行它们。 尽管该似乎与我们通常编写程序的并没有什么不同,但在 Theano 中,我们定义了要执行的功能,而不是其计算方式。 这使 Theano 可以优化表达式的评估并执行延迟计算-仅在需要时才实际计算表达式,而在定义它们时才进行计算。
许多程序员每天都不使用这种类型的编程,但是大多数程序员都与相关的系统交互。 关系数据库,特别是基于 SQL 的数据库,使用了称为声明式范式的概念。 尽管程序员可能使用 WHERE 子句在数据库上定义了 SELECT 查询,但数据库会对此进行解释并根据多种因素(例如是否 ] WHERE 子句位于主键上,数据存储的格式以及其他因素上。 程序员定义他们想要的东西,然后系统确定如何做。
注意
您可以使用 pip pip3 install Theano安装 Theano。
使用 Theano,我们可以定义许多用于标量,数组和矩阵的函数,以及其他数学表达式。 例如,我们可以创建一个函数来计算直角三角形的斜边的长度:
import theano
from theano import tensor as T
首先,我们定义两个输入 a 和 b。 这些是简单的数值,因此我们将它们定义为标量:
a = T.dscalar()
b = T.dscalar()
然后,我们定义输出c。 这是一个基于a和b值的表达式:
c = T.sqrt(a ** 2 + b ** 2)
请注意,此处c不是函数或值,它只是给定a和b的表达式。 还要注意a和b没有实际值-这是一个代数表达式,而不是绝对值。 为了对此进行计算,我们定义一个函数:
f = theano.function([a,b], c)
该基本上告诉 Theano 创建一个函数,该函数将a和b的值作为输入,并根据给定的值计算返回c作为输出。 例如,f(3, 4)返回5。
尽管这个简单的示例似乎没有比 Python 强大的功能,但我们现在可以在其他代码部分和其余映射中使用函数或数学表达式c。 另外,虽然我们在定义函数之前就定义了c,但是直到调用函数之前,才进行实际的计算。
千层面的介绍
Theano 并不是构建神经网络的库。 以类似的方式,NumPy 并不是执行机器学习的库; 它只是执行繁重的任务,通常在其他库中使用。 Lasagne 是一个这样的库,它是专门为构建神经网络而设计的,使用 Theano 进行计算。
Lasagne 实现了许多现代类型的神经网络层,以及用于构建它们的构建块。
其中包括:
- 网络中的网络层:这些是小型的神经网络,比传统的神经网络层更易于解释。
- 脱落层:这些在训练过程中随机脱落的单元,可防止过度拟合,这是神经网络中的主要问题。
- 噪声层:这些层将噪声引入神经元。 再次,解决过度拟合的问题。
在本章中,我们将使用convolution layers(用来模拟人类视觉工作方式的图层)。 他们使用连接神经元的小集合,这些神经元仅分析输入值的一部分(在这种情况下为图像)。 这允许网络处理标准更改,例如处理图像的翻译。 在基于视觉的实验中,卷积层处理的变化示例是平移图像。
相比之下,传统的神经网络通常连接紧密,一层中的所有神经元都连接到下一层中的所有神经元。
卷积网络是在lasagne.layers.Conv1DLayer和lasagne.layers.Conv2DLayer类中实现的。
注意
在撰写本文时,Lasagne 尚未正式发布,也没有发布在pip上。 您可以从github安装它。 在新文件夹中,使用以下命令下载源代码存储库:
git clone https://github.com/Lasagne/Lasagne.git
在创建的 Lasagne 文件夹中,然后可以使用以下命令安装该库:
sudo python3 setup.py install
请参阅这个页面了解安装说明。
神经网络使用卷积层(通常仅使用卷积神经网络)以及pooling层,它们在特定区域内获得最大输出。 这样可以减少由图像的微小变化引起的噪声,并减少(或下采样)信息量。 这具有减少后续层中需要完成的工作量的额外好处。
Lasagne 还实现了这些池化层,例如在lasagne.layers.MaxPool2DLayer类中。 与卷积层一起,我们拥有构建卷积神经网络所需的所有工具。
在 Lasagne 中构建神经网络比仅使用 Theano 构建神经网络容易。 为了展示这些原理,我们将实现一个基于 Iris 数据集的基本网络,我们在第 1 章,“数据挖掘入门”中看到了该数据集。 Iris 数据集非常适合测试新算法,甚至包括深度神经网络等复杂算法。
首先,打开一个新的 IPython Notebook。 在本章的后面,我们将返回加载了 CIFAR 数据集的 Notebook。
首先,我们加载数据集:
from sklearn.datasets import load_iris
iris = load_iris()
X = iris.data.astype(np.float32)
y_true = iris.target.astype(np.int32)
由于 Lasagne 的工作方式,我们需要更加明确地说明数据类型。 这就是为什么我们将类转换为int32(它们在原始数据集中存储为int64)的原因。
然后,我们分为训练和测试数据集:
from sklearn.cross_validation import train_test_split
X_train, X_test, y_train, y_test = train_test_split(X, y_true, random_state=14)
接下来,我们通过创建不同的层来构建我们的网络。 我们的数据集包含四个输入变量和三个输出类。 这给了我们第一层和最后一层的大小,但没有中间的层。 玩弄这个数字会得出不同的结果,值得追踪不同的值以查看会发生什么。
我们从创建一个输入层开始,该输入层具有与数据集相同数量的节点。 我们可以指定一个批处理大小(该值为 10),这使 Lasagne 可以在训练中进行一些优化:
import lasagne
input_layer = lasagne.layers.InputLayer(shape=(10, X.shape[1]))
接下来,我们创建我们的隐藏层。 该层的输入来自我们的输入层(指定为第一个参数),该输入层具有 12 个节点,并使用 S 型非线性,这在第 8 章,“通过神经网络击败验证码”中;
hidden_layer = lasagne.layers.DenseLayer(input_layer, num_units=12, nonlinearity=lasagne.nonlinearities.sigmoid)
接下来,我们有一个输出层,它从具有三个节点(与类数相同)的隐藏层获取输入,并使用 softmax 非线性。 Softmax 是,通常用于神经网络的最后一层:
output_layer = lasagne.layers.DenseLayer(hidden_layer, num_units=3,
nonlinearity=lasagne.nonlinearities.softmax)
在千层面的用法中,此输出层是我们的network。 当我们在其中输入样本时,它将查看该输出层并获得输入到其中的层(第一个参数)。 这将以递归方式持续进行,直到到达输入层为止,该输入层将样本应用于自身,因为它没有输入层。 输入层中神经元的激活然后被馈送到其调用层(在我们的情况下为hidden_layer),然后一直传播到输出层。
为了训练我们的网络,我们现在需要定义一些训练功能,它们是基于 Theano 的功能。 为此,我们需要定义 Theano 表达式和用于训练的函数。 我们首先为输入样本,网络给定的输出和实际输出创建变量:
import theano.tensor as T
net_input = T.matrix('net_input')
net_output = output_layer.get_output(net_input)
true_output = T.ivector('true_output')
现在,我们可以定义损失函数,该函数告诉训练函数如何改善网络-它尝试根据此函数训练网络以最大程度地减少损失。 我们将使用的损失是分类交叉熵,它是对诸如我们这样的分类数据的度量。 这是网络给定的输出和我们期望的实际输出的函数:
loss = T.mean(T.nnet.categorical_crossentropy(net_output, true_output))
接下来,我们定义将改变网络权重的函数。 为此,我们从网络中获取所有参数,并创建一个函数(使用 Lasagne 提供的辅助函数),该函数可以调整权重以最大程度地减少损失。
all_params = lasagne.layers.get_all_params(output_layer)
updates = lasagne.updates.sgd(loss, all_params, learning_rate=0.1)
最后,我们创建基于 Theano 的功能来执行此训练,并获得网络的输出以进行测试:
import theano
train = theano.function([net_input, true_output], loss, updates=updates)
get_output = theano.function([net_input], net_output)
然后,我们可以在训练数据上调用训练函数,以对网络进行一次迭代训练。 这涉及获取每个样本,计算其预测类别,将这些预测与预期类别进行比较,并更新权重以最小化损失函数。 然后,我们执行这 1000 次,在这些迭代中逐步训练我们的网络:
for n in range(1000):
train(X_train, y_train)
接下来,我们可以通过计算输出的 F 分数进行评估。 首先,我们获得以下输出:
y_output = get_output(X_test)
注意
请注意,get_output是我们从神经网络获得的 Theano 函数,这就是为什么我们不需要将网络作为参数添加到此代码行的原因。
此结果y_output是最终输出层中每个神经元的激活。 通过发现哪个神经元具有最高的激活来创建实际的预测:
import numpy as np
y_pred = np.argmax(y_output, axis=1)
现在,y_pred是类预测的数组,就像我们在分类任务中所习惯的那样。 现在,我们可以使用以下预测来计算 F 分数:
from sklearn.metrics import f1_score
print(f1_score(y_test, y_pred))
结果是令人印象深刻的完美-1.0! 这意味着所有分类在测试数据中都是正确的:很好的结果(尽管这是一个更简单的数据集)。
如我们所见,虽然仅使用 Lasagne 即可开发和训练网络,但可能会有些尴尬。 为了解决这个问题,我们将使用nolearn,这是一个软件包,可以进一步将该过程包装在可通过 scikit-learn API 方便转换的代码中。
使用 nolearn 实现神经网络
nolearn软件包提供了千层面的包装。 我们无法通过在 Lasagne 中手动构建神经网络来进行的微调,但是该代码更具可读性且易于管理。
nolearn包实现了您可能想要构建的普通类型的复杂神经网络。 如果您想获得比nolearn更大的控制权,可以恢复使用 Lasagne,但要付出更多的训练和建设过程的代价。
要开始使用nolearn,我们将重新实现在第 8 章,“使用神经网络打败 CAPTCHA”的示例,以预测图像中代表了哪个字母。 我们将重新创建在第 8 章,“使用神经网络击败 CAPTCHA”中的密集神经网络。 首先,我们需要在笔记本中再次输入数据集构建代码。 有关此代码的功能的描述,请参见第 8 章和“用神经网络”击败验证码:
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from skimage.transform import resize
from skimage import transform as tf
from skimage.measure import label, regionprops
from sklearn.utils import check_random_state
from sklearn.preprocessing import OneHotEncoder
from sklearn.cross_validation import train_test_split
def create_captcha(text, shear=0, size=(100, 24)):
im = Image.new("L", size, "black")
draw = ImageDraw.Draw(im)
font = ImageFont.truetype(r"Coval.otf", 22)
draw.text((2, 2), text, fill=1, font=font)
image = np.array(im)
affine_tf = tf.AffineTransform(shear=shear)
image = tf.warp(image, affine_tf)
return image / image.max()
def segment_image(image):
labeled_image = label(image > 0)
subimages = []
for region in regionprops(labeled_image):
start_x, start_y, end_x, end_y = region.bbox
subimages.append(image[start_x:end_x,start_y:end_y])
if len(subimages) == 0:
return [image,]
return subimages
random_state = check_random_state(14)
letters = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
shear_values = np.arange(0, 0.5, 0.05)
def generate_sample(random_state=None):
random_state = check_random_state(random_state)
letter = random_state.choice(letters)
shear = random_state.choice(shear_values)
return create_captcha(letter, shear=shear, size=(20, 20)), letters.index(letter)
dataset, targets = zip(*(generate_sample(random_state) for i in range(3000)))
dataset = np.array(dataset, dtype='float')
targets = np.array(targets)
onehot = OneHotEncoder()
y = onehot.fit_transform(targets.reshape(targets.shape[0],1))
y = y.todense().astype(np.float32)
dataset = np.array([resize(segment_image(sample)[0], (20, 20)) for sample in dataset])
X = dataset.reshape((dataset.shape[0], dataset.shape[1] * dataset.shape[2]))
X = X / X.max()
X = X.astype(np.float32)
X_train, X_test, y_train, y_test = \
train_test_split(X, y, train_size=0.9, random_state=14)
神经网络是层的集合。 在nolearn中实现一个层是组织这些层的外观的情况,就像在 PyBrain 中一样。 我们在第 8 章,“使用神经网络击败 CAPTCHAs”中的神经网络使用了完全连接的密集层。 这些是在 nolearn中实现的,这意味着我们可以在此处复制我们的基本网络结构。 首先,我们创建由输入层,密集隐藏层和密集输出层组成的层:
from lasagne import layers
layers=[
('input', layers.InputLayer),
('hidden', layers.DenseLayer),
('output', layers.DenseLayer),
]
然后,我们导入一些需求,我们将在使用它们时进行解释:
from lasagne import updates
from nolearn.lasagne import NeuralNet
from lasagne.nonlinearities import sigmoid, softmax
接下来,我们定义神经网络,将其表示为 scikit-learn-compatible 估计器:
net1 = NeuralNet(layers=layers,
请注意,我们还没有关闭括号,这是有意的。 此时,我们从每层的大小开始输入神经网络的参数:
input_shape=X.shape,
hidden_num_units=100,
output_num_units=26,
此处的参数与图层匹配。 换句话说,input_shape参数首先在我们的层中找到已输入名称的层,其工作方式与在管道中设置参数的方式几乎相同。
接下来,我们定义非线性。 同样,我们将sigmoid用于隐藏层,并将softmax用于输出层:
hidden_nonlinearity=sigmoid,
output_nonlinearity=softmax,
接下来,我们将使用偏置节点,这些节点是始终在隐藏层中打开的节点。 偏置节点对于训练网络很重要,因为它们允许神经元的激活来更具体地训练他们的问题。 举一个简化的例子,如果我们的预测始终偏离 4,则可以添加-4 的偏差以消除该偏差。 我们的偏差节点允许这样做,权重的训练决定了所使用的偏差量。
偏差是作为一组权重给出的,这意味着它的大小必须与该偏差附加到的图层的大小相同:
hidden_b=np.zeros((100,), dtype=np.float32),
接下来,我们定义网络将如何训练。 nolearn软件包没有与第 8 章和“用神经网络”击败 CAPTCHA 所使用的训练机制完全相同,因为它没有衰减权重的方法 。 但是,它确实具有动量,我们将使用它,以及高学习率和低动量值:
update=updates.momentum,
update_learning_rate=0.9,
update_momentum=0.1,
接下来,我们将该问题定义为回归问题。 在执行分类任务时,这可能看起来很奇怪。 但是,输出是实值,优化它们是一个回归问题,在训练中比尝试对分类进行优化要好得多:
regression=True,
最后,我们将训练的最大纪元数设置为 1,000,这非常适合良好的训练,而又不需要花费很长时间进行训练(对于此数据集;其他数据集可能需要或多或少的训练):
max_epochs=1000,
现在,我们可以关闭神经网络构造函数的括号;
)
接下来,我们在训练数据集上训练网络:
net1.fit(X_train, y_train)
现在我们可以评估经过训练的网络。 为此,我们获得网络的输出,并且与 Iris 示例一样,我们需要执行argmax来通过选择最高激活值来获得实际分类:
y_pred = net1.predict(X_test)
y_pred = y_pred.argmax(axis=1)
assert len(y_pred) == len(X_test)
if len(y_test.shape) > 1:
y_test = y_test.argmax(axis=1)
print(f1_score(y_test, y_pred))
结果同样令人印象深刻,这在我的机器上又是一个完美的成绩。 但是,由于nolearn程序包具有某些无法在此阶段直接控制的随机性,因此的结果可能会有所不同。

GPU 优化
神经网络的规模会很大。 这对内存使用有一些影响。 但是,有效的结构(例如稀疏矩阵)意味着我们通常不会遇到将神经网络拟合到内存中的问题。
当神经网络变大时,主要的问题是它们需要很长时间才能计算。 此外,一些数据集和神经网络将需要进行许多训练,才能很好地适应该数据集。 我们将在本章中训练的神经网络在功能强大的计算机上每个纪元要花费 8 分钟以上的时间,并且我们预计将运行数十个纪元,甚至可能数百个纪元。 一些较大的网络可能需要几个小时才能训练一个时期。 为了获得最佳性能,您可能正在考虑数千个训练周期。
数学显然不能在这里给出很好的结果。
一个积极的方面是,神经网络的核心是充满浮点运算。 由于神经网络训练主要由矩阵运算组成,因此还有许多运算可以并行执行。 这些因素意味着在 GPU 上进行计算是加快训练速度的有吸引力的选择。
何时使用 GPU 进行计算
GPU 最初旨在渲染图形以供显示。 这些图形使用矩阵和这些矩阵上的数学方程式表示,然后将其转换为我们在屏幕上看到的像素。 此过程涉及大量并行计算。 尽管现代 CPU 可能具有许多核心(您的计算机可能具有 2、4 甚至 16 个甚至更多!),但 GPU 具有数千个专门为图形设计的小型核心。
因此,CPU 更适合用于顺序任务,因为内核往往更快一些,并且诸如访问计算机内存等任务效率更高。 老实说,让 CPU 做繁重的工作也更容易。 几乎每个机器学习库都默认使用 CPU,并且在使用 GPU 进行计算之前还涉及其他工作。 但是,好处可能非常明显。
因此,GPU 更适合用于其中许多可以同时执行的数字小操作的任务。 许多机器学习任务就是这样,通过使用 GPU 来提高效率。
使您的代码在 GPU 上运行可能会令人沮丧。 这在很大程度上取决于您拥有的 GPU 类型,如何配置,您的操作系统以及您是否准备对计算机进行一些低级更改。
有以下三种主要途径:
- 首先是查看您的计算机,为您的 GPU 和操作系统搜索工具和驱动程序,浏览其中的许多教程,然后找到适合您情况的教程。 是否有效取决于您的系统。 就是说,这种情况比几年前容易得多,有了更好的工具和驱动程序可以执行支持 GPU 的计算。
- 第二种方法是选择一个系统,找到有关设置的良好文档,然后购买一个匹配的系统。 这将更好地工作,但可能会相当昂贵-在大多数现代计算机中,GPU 是最昂贵的部件之一。 如果您想从系统中获得出色的性能,那就尤其如此-您将需要一个非常好的 GPU,这可能会非常昂贵。
- 第三种途径是使用已为此目的配置的虚拟机。 例如,马库斯·贝辛格(Markus Beissinger)已经创建了一个在亚马逊网络服务上运行的系统。 该系统将花费您运行的钱,但价格却比新计算机便宜得多。 根据您所在的位置,所获得的确切系统以及使用量的多少,您每小时的收入可能会少于 1 美元,而且往往要少得多。 如果您在 Amazon Web Services 中使用竞价型实例,则可以以每小时几美分的价格运行它们(尽管您将需要开发代码以分别在竞价型实例上运行)。
如果您负担不起虚拟机的运行成本,建议您使用当前系统来研究第一个途径。 您还可以从家人或不断更新计算机的朋友那里获得良好的二手 GPU(游戏玩家朋友对此非常有用!)。
在 GPU 上运行我们的代码
将采用本章的第三种方法,并基于 Markus Beissinger 的基本系统创建一个虚拟机。 这将在亚马逊的 EC2 服务上运行。 还有许多其他的 Web 服务要使用,每个过程都将略有不同。 在本节中,我将概述 Amazon 的过程。
如果要使用自己的计算机并将其配置为运行启用 GPU 的计算,请随时跳过此部分。
注意
您可以在这个页面。
首先,在以下位置转到到 AWS 控制台:
https://console.aws.amazon.com/console/home?region=us-east-1
使用您的 Amazon 帐户登录。 如果您没有,则将提示您创建一个,然后继续进行操作。
单击启动实例,然后在右上角的下拉菜单中选择 N. California 作为您的位置。
单击社区 AMI,然后搜索ami-b141a2f5,这是 Markus Beissinger 创建的机器。 然后,单击选择。 在下一个屏幕上,选择 g2.2xlarge 作为机器类型,然后单击 Review and Launch。 在下一个屏幕上,单击启动。
此时,您将需要付费,因此请记住在完成处理后关闭计算机。 您可以转到 EC2 服务,选择机器,然后将其停止。 您无需为未运行的计算机付费。
系统将提示您有关如何连接到实例的一些信息。 如果您以前从未使用过 AWS,则可能需要创建一个新的密钥对以安全地连接到您的实例。 在这种情况下,给您的密钥对命名,下载pem文件,并将其存储在安全的地方-如果丢失,您将无法再次连接到您的实例!
单击 Connect,以获取有关使用pem文件连接到您的实例的信息。 最可能的情况是,您将通过以下命令使用ssh:
ssh -i <certificante_name>.pem ubuntu@<server_ip_address>
设置环境
将连接到实例后,可以安装更新的Lasagne和nolearn软件包。
首先,为本章前面概述的Lasagne克隆git存储库:
git clone https://github.com/Lasagne/Lasagne.git
为了在此机器上构建该库,我们需要 Python 3 的setuptools,我们可以通过apt-get进行安装,这是 Ubuntu 安装应用和库的方法。 我们还需要 NumPy 的开发库。 在虚拟机的命令行中运行以下命令:
sudo apt-get install python3-pip python3-numpy-dev
接下来,我们安装千层面。 首先,我们转到源代码目录,然后运行setup.py来构建和安装它:
cd Lasagne
sudo python3 setup.py install
注意
为简便起见,我们已经安装了Lasagne,并将以系统级软件包的形式安装nolearn。 对于那些想要更便携的解决方案的人,我建议使用virtualenv安装这些软件包。 它将允许您在同一台计算机上使用不同的 python 和库版本,并使将代码移动到新计算机变得更加容易。 有关更多信息,请参见这个页面。
千层面建好后,我们现在可以安装nolearn。 转到主目录,并遵循相同的步骤,但nolearn软件包除外:
cd ~/
git clone https://github.com/dnouri/nolearn.git
cd nolearn
sudo python3 setup.py install
我们的系统即将建立。 我们需要安装 scikit-learn 和 scikit-image 以及 matplotlib。 我们可以使用pip3完成所有这些操作。 作为对此的依赖,我们还需要scipy和matplotlib软件包,它们目前尚未安装在本机上。 我建议使用apt-get而不是pip3的 scipy 和 matplotlib,因为在某些情况下使用pip3安装它可能会很痛苦:
sudo apt-get install python3-scipy python3-matplotlib
sudo pip3 install scikit-learn scikit-image
接下来,我们需要将代码添加到计算机上。 有很多方法可以将该文件保存到计算机上,但是最简单的方法之一就是复制并粘贴内容。
首先,打开我们之前使用的 IPython Notebook(在您的计算机上,而不是在 Amazon 虚拟机上)。 在笔记本电脑上本身是一个菜单。 单击文件,然后单击下载为。 选择 Python 并将其保存到您的计算机。 此过程将 IPython Notebook 中的代码下载为可从命令行运行的 python 脚本。
打开此文件(在某些系统上,您可能需要右键单击并使用文本编辑器打开)。 选择所有内容并将其复制到剪贴板。
在 Amazon 虚拟机上,移至主目录并使用新文件名打开nano:
cd ~/
nano chapter11script.py
将打开nano程序,这是一个命令行文本编辑器。
打开此程序,将剪贴板的内容粘贴到此文件中。 在某些系统上,可能需要使用 ssh 程序的文件选项,而不是按 Ctrl +V进行粘贴。
在nano中,按 Ctrl +O将文件保存在磁盘上,然后按 Ctrl +X退出程序。
您还需要字体文件。 最简单的方法是从原始位置重新下载。 为此,请输入以下内容:
wget http://openfontlibrary.org/img/downloads/bretan/680bc56bbeeca95353ede363a3744fdf/bretan.zip
sudo apt-get install unzip
unzip -p bretan.zip Coval.otf > Coval.otf
这只会解压缩一个Coval.otf文件(此 zip 文件夹中有很多我们不需要的文件)。
仍在虚拟机中时,可以使用以下命令运行该程序:
python3 chapter11script.py
该程序将像在 IPython Notebook 中一样运行,并且结果将打印到命令行。
结果应该与以前相同,但是神经网络的实际训练和测试将更快。 请注意,在程序的其他方面并没有那么快—我们没有编写使用 GPU 的 CAPTCHA 数据集创建过程,因此我们无法在那里获得加速。
注意
您可能希望关闭 Amazon 虚拟机以节省一些钱。 我们将在本章结尾使用它来运行我们的主要实验,但是将首先在您的主计算机上开发代码。

应用
现在回到您的主机,打开我们在本章中创建的第一个 IPython Notebook-我们用来加载 CIFAR 数据集的笔记本。 在这个主要实验中,我们将获取 CIFAR 数据集,创建一个深度卷积神经网络,然后在基于 GPU 的虚拟机上运行它。
获取数据
首先,我们将获取 CIFAR 图像并使用它们创建数据集。 与以前不同,我们将保留像素结构,即。 在行和列中。 首先,将所有批次加载到列表中:
import numpy as np
batches = []
for i in range(1, 6):
batch_filename = os.path.join(data_folder, "data_batch_{}".format(i))
batches.append(unpickle(batch1_filename))
break
最后一行,即中断,是测试代码—这将大大减少训练示例的数量,使您可以快速查看代码是否正常运行。 在测试代码正常工作后,我会提示您删除此行。
接下来,通过将这些批次彼此堆叠来创建数据集。 我们使用 NumPy 的vstack,可以将其可视化为在行末添加行:
X = np.vstack([batch['data'] for batch in batches])
然后,我们将数据集规范化为 0 到 1 的范围,然后将类型强制为 32 位浮点型(这是启用 GPU 的虚拟机可以运行的唯一数据类型):
X = np.array(X) / X.max()
X = X.astype(np.float32)
然后,我们对这些类进行相同的操作,除了执行hstack,这类似于将列添加到数组的末尾。 然后,我们使用OneHotEncoder将其转换为单热阵列:
from sklearn.preprocessing import OneHotEncoder
y = np.hstack(batch['labels'] for batch in batches).flatten()
y = OneHotEncoder().fit_transform(y.reshape(y.shape[0],1)).todense()
y = y.astype(np.float32)
接下来,我们将数据集分为训练和测试集:
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
接下来,我们调整数组的形状以保留原始数据结构。 原始数据为 32 x 32 像素的图像,每个像素具有 3 个值(红色,绿色和蓝色值);
X_train = X_train.reshape(-1, 3, 32, 32)
X_test = X_test.reshape(-1, 3, 32, 32)
现在,我们有了一个熟悉的训练和测试数据集,以及每个目标类。 现在,我们可以构建分类器。
创建神经网络
我们将使用nolearn包来构建神经网络,因此将遵循类似于第 8 章和“用神经网络”复制击败 CAPTCHA 的复制实验的模式。 。
首先,我们创建神经网络的各层:
from lasagne import layers
layers=[
('input', layers.InputLayer),
('conv1', layers.Conv2DLayer),
('pool1', layers.MaxPool2DLayer),
('conv2', layers.Conv2DLayer),
('pool2', layers.MaxPool2DLayer),
('conv3', layers.Conv2DLayer),
('pool3', layers.MaxPool2DLayer),
('hidden4', layers.DenseLayer),
('hidden5', layers.DenseLayer),
('output', layers.DenseLayer),
]
最后三层使用密集层,但在此之前,我们将卷积层与池化层结合使用。 我们有三套。 另外,我们(必须)从输入层开始。 这总共给了我们 10 层。 和以前一样,可以很容易地从数据集中计算出第一层和最后一层的大小,尽管我们的输入大小将具有与数据集相同的形状,而不仅仅是相同数量的节点/输入。
开始构建我们的神经网络(请记住不要关闭括号):
from nolearn.lasagne import NeuralNet
nnet = NeuralNet(layers=layers,
添加输入形状。 此处的形状类似于数据集的形状(每个像素三个值和一个 32 x 32 像素图像)。 第一个值,无,是nolearn使用的默认批处理大小-它将立即训练此数量的样本,从而减少了算法的运行时间。 将其设置为 None 将删除此硬编码值,从而使我们在运行算法时更具灵活性:
input_shape=(None, 3, 32, 32),
注意
要更改批处理大小,您将需要创建BatchIterator实例。 对此参数感兴趣的人可以在这个页面上查看文件的源,跟踪batch_iterator_train和batch_iterator_test 参数,以及如何在此文件的NeuralNet类中设置它们。
接下来,我们设置卷积层的大小。 这里没有严格的规则,但是我发现以下值是一个很好的起点。
conv1_num_filters=32,
conv1_filter_size=(3, 3),
conv2_num_filters=64,
conv2_filter_size=(2, 2),
conv3_num_filters=128,
conv3_filter_size=(2, 2),
filter_size参数决定卷积层所查看图像的窗口大小。 另外,我们设置池化层的大小:
pool1_ds=(2,2),
pool2_ds=(2,2),
pool3_ds=(2,2),
然后,我们设置两个隐藏的密集层(倒数第三层和倒数第二层)的大小,以及输出层的大小,即我们数据集中的类数;
hidden4_num_units=500,
hidden5_num_units=500,
output_num_units=10,
我们也使用softmax为最后一层设置非线性。
output_nonlinearity=softmax,
我们还设置了学习速度和动力。 根据经验,随着样本数量的增加,学习率应降低:
update_learning_rate=0.01,
update_momentum=0.9,
像以前一样,我们将回归设置为True,并且将训练时期的数量设置为较低,因为此网络需要很长时间才能运行。 成功运行之后,增加时期数将得到更好的模型,但是您可能需要等待一两天(或更长时间!)才能进行训练:
regression=True,
max_epochs=3,
最后,我们将详细程度设置为等于 1,这将使我们打印出每个时期的结果。 这使我们能够知道模型的进度以及模型仍在运行。 另一个功能是,它告诉我们每个纪元运行所花费的时间。 这是非常一致的,因此您可以通过将该值乘以剩余历元数来计算出训练剩余时间,从而可以很好地估算出等待训练完成所需的时间:
verbose=1)
全部放在一起
现在我们有了网络,我们可以使用我们的训练数据集对其进行训练:
nnet.fit(X_train, y_train)
即使减小了数据集的大小并减少了纪元的数量,该仍需要花费相当长的时间才能运行。 代码完成后,您可以像以前一样对其进行测试:
from sklearn.metrics import f1_score
y_pred = nnet.predict(X_test)
print(f1_score(y_test.argmax(axis=1), y_pred.argmax(axis=1)))
结果将是可怕的,应该如此! 我们对网络的训练不是很多,仅进行了几次迭代,仅处理了五分之一的数据。
首先,返回并删除在创建数据集时放入的折线(位于批处理循环中)。 这将使代码可以训练所有样本,而不仅仅是其中一些样本。
接下来,将神经网络定义中的时期数更改为 100。
现在,我们将脚本上传到我们的虚拟机。 与以前一样,单击文件 | 以Python格式下载,并将脚本保存在计算机上的某个位置。 启动并连接到虚拟机,然后像您之前所做的那样上传脚本(我叫我的脚本chapter11cifar.py-如果您的命名不同,则只需更新以下代码)。
接下来,我们需要将数据集放在虚拟机上。 最简单的方法是转到虚拟机并键入:
wget http://www.cs.toronto.edu/~kriz/cifar-10-python.tar.gz
这将下载数据集。 下载完成后,您可以通过首先创建该文件夹,然后将数据解压缩到其中,将数据提取到Data文件夹中:
mkdir Data
tar -zxf cifar-10-python.tar.gz -C Data
最后,我们可以使用以下示例运行示例:
python3 chapter11cifar.py
您会注意到的第一件事是急剧的加速。 在我的家用计算机上,每个纪元花费了 100 秒钟以上才能运行。 在启用 GPU 的虚拟机上,每个纪元仅需 16 秒! 如果我们尝试在计算机上运行 100 个纪元,则将花费近 3 个小时,而在虚拟机上仅需 26 分钟。
这种巨大的加速使追踪不同模型的速度更快。 通常在试用机器学习算法的情况下,单个算法的计算复杂性并不太重要。 算法可能需要几秒钟,几分钟或几小时才能运行。 如果仅运行一个模型,则此训练时间不太可能太大,尤其是大多数机器学习算法的预测速度很快时,这就是大多数使用机器学习模型的地方。
但是,当您有多个参数要运行时,您突然需要训练成千上万个参数略有不同的模型-突然地,这些速度的增加就变得越来越重要。
经过 100 个历时的训练,历时 26 分钟,您将获得最终结果的打印输出:
0.8497
还不错! 我们可以增加训练的次数来进一步改善这种情况,或者我们可以尝试更改参数。 也许更多的隐藏节点,更多的卷积层或其他密集层。 在千层面中也可以尝试其他类型的图层; 尽管通常来说,卷积层更适合视觉。



三十三、使用大数据
数据量正以指数速度增长。 当今的系统正在生成和记录有关客户行为,分布式系统,网络分析,传感器以及许多其他来源的信息。 当前移动数据的大趋势正在推动当前的增长,而下一个大事物– 物联网(IoT)–将进一步提高增长率。
这对数据挖掘意味着什么是一种新的思维方式。 需要改进或放弃运行时间较长的复杂算法,而能够处理更多样本的简单算法越来越受欢迎。 例如,虽然支持向量机是很好的分类器,但是某些变体很难在非常大的数据集上使用。 相反,在这些情况下,更简单的算法(例如逻辑回归)可以更轻松地进行管理。
在本章中,我们将研究以下内容:
- 大数据挑战与应用
- MapReduce 范例
- Hadoop MapReduce
- mrjob,一个可在亚马逊基础设施上运行 MapReduce 程序的 python 库
大数据
什么使大数据与众不同? 大多数大数据支持者谈论大数据的四个 V:
- 量:我们生成和存储的数据量正在以越来越高的速度增长,对未来的预测通常仅暗示进一步的增长。 今天的数千兆字节大小的硬盘驱动器将在几年内转变为 EB 级硬盘驱动器,并且网络吞吐量流量也将不断增加。 信噪比可能非常困难,因为重要数据丢失在大量不重要的数据中。
- 速度:尽管与体积有关,但数据的速度也在增加。 现代汽车有数百个传感器,这些传感器将数据流传输到计算机中,并且需要在亚秒级的水平上分析来自这些传感器的信息,以操作汽车。 这不仅仅是在数据量中寻找答案的情况; 这些答案通常需要迅速得出。
- 品种:具有清晰定义列的漂亮数据集只是我们如今拥有的数据集的一小部分。 考虑一个社交媒体帖子,该帖子可能包含文本,照片,用户提及,喜欢,评论,视频,地理信息和其他字段。 仅仅忽略这些数据中不适合您模型的部分将导致信息丢失,但是整合信息本身可能非常困难。
- 准确性:随着数据量的增加,可能很难确定是否已正确收集数据-是过时,嘈杂,包含异常值,还是通常是否有用 完全没有 当人们无法可靠地验证数据本身时,很难信任数据。 外部数据集也越来越多地被合并为内部数据集,从而带来了更多与数据准确性相关的麻烦。
这四个主要 V(其他人提出了其他 V)概述了大数据为何与大量数据不同的原因。 在这些规模上,处理数据的工程问题通常更加困难,更不用说分析了。 尽管有很多蛇油销售人员高估了使用大数据的能力,但很难否认工程挑战和大数据分析的潜力。
我们使用的算法是先将数据集加载到内存中,然后再处理内存中的版本。 这在计算速度方面带来了很大的好处,因为在内存中数据上进行计算要比必须在使用之前加载样本快得多。 此外,内存中的数据使我们可以对数据进行多次迭代,从而改进我们的模型。
在大数据中,我们无法将数据加载到内存中,从很多方面来说,这是一个确定问题是否是大数据的好定义-如果数据可以容纳在计算机的内存中,那么您就不会在处理 大数据问题。
应用场景和目标
在公共和私营部门中都有许多大数据用例。
人们使用基于大数据的系统的最常见体验是在 Internet 搜索中,例如 Google。 要运行这些系统,需要在一秒钟之内对数十亿个网站进行搜索。 进行基于文本的基本搜索将不足以解决此类问题。 仅存储所有这些网站的文本是一个大问题。 为了处理查询,需要为此应用专门创建和实现新的数据结构和数据挖掘方法。
大数据还用于许多其他科学实验中,例如大型强子对撞机(下图所示),它的长度超过 17 公里,包含 1.5 亿个传感器,每秒监视数亿个粒子碰撞。 来自该实验的数据非常庞大,经过过滤过程之后,每天创建 25 PB(如果不使用过滤,则每年将有 1.5 亿 PB)。 对如此庞大的数据进行分析已经获得了有关我们宇宙的惊人见解,但这一直是工程和分析领域的重大挑战。

各国政府也越来越多地使用大数据来跟踪人口,企业以及有关其国家的其他方面。 跟踪数百万人和数十亿的互动(例如业务交易或医疗保健支出)导致许多政府组织需要大数据分析。
交通管理是世界上许多国家/地区的特别关注的国家,它们正在使用数百万个传感器来跟踪交通,以确定最拥挤的道路,并预测新道路对交通水平的影响。
大型零售组织正在使用大数据来改善客户体验并降低成本。 这涉及到预测客户需求以拥有正确的库存水平,用他们可能想要购买的产品向客户推销,以及跟踪交易以寻找趋势,模式和潜在的欺诈行为。
其他大型企业也正在利用大数据来自动化其业务并改善其产品。 这包括利用分析来预测其行业的未来趋势并跟踪外部竞争对手。 大型企业还使用分析来管理自己的员工,从而跟踪员工以寻找员工可能离开公司的迹象,以便在他们离开之前进行干预。
信息安全部门还通过监视网络流量,利用大数据来查找大型网络中的恶意软件感染。 这可能包括寻找异常的流量模式,恶意软件传播的证据以及其他异常情况。 高级持久威胁(APT)是另一个问题,有意识的攻击者会将他们的代码隐藏在大型网络中,从而在很长一段时间内窃取信息或造成破坏。 查找 APT 通常是对许多计算机进行取证检查的情况,对于人类而言,完成这项任务只需要很长时间才能有效地执行自己的任务。 Analytics(分析)可帮助自动化和分析这些取证图像以发现感染。
大数据正在越来越多的部门和应用中使用,并且这种趋势可能只会持续下去。

MapReduce
有许多概念可以对大数据执行数据挖掘和常规计算。 最受欢迎的模型之一是 MapReduce 模型,该模型可用于任意大型数据集的常规计算。
MapReduce 起源于 Google,当时该公司在开发时就考虑了分布式计算。 它还引入了容错能力和可伸缩性改进。 MapReduce 的“原始”研究报告于 2004 年发表,自那时以来,已有成千上万的项目,实现和应用在使用它。
尽管该概念与许多先前的概念相似,但 MapReduce 已成为大数据分析的主要内容。
直觉
MapReduce 有两个主要步骤:Map 步骤和 Reduce 步骤。 这些建立在将函数映射到列表并减少结果的函数编程概念上。 为了解释这个概念,我们将开发代码,该代码将遍历列表列表并产生这些列表中所有数字的总和。
MapReduce 范式中还包含混洗和合并步骤,我们将在后面看到。
首先,“映射”步骤采用一个函数并将其应用于列表中的每个元素。 返回的结果是一个大小相同的列表,该函数的结果应用于每个元素。
要打开一个新的 IPython Notebook,首先创建一个列表列表,每个子列表中都有数字:
a = [[1,2,1], [3,2], [4,9,1,0,2]]
接下来,我们可以使用 sum 函数执行映射。 此步骤将求和函数应用于a的每个元素:
sums = map(sum, a)
尽管 sums 是一个生成器(实际值直到我们要求时才计算),但上述步骤大约等于以下代码:
sums = []
for sublist in a:
results = sum(sublist)
sums.append(results)
减少步骤要复杂一些。 它涉及将函数应用于返回结果的每个元素以及某个初始值。 我们从初始值开始,然后将给定函数应用于该初始值和第一个值。 然后,我们将给定函数应用于结果和下一个值,依此类推。
我们首先创建一个将两个数字加在一起的函数。
def add(a, b):
return a + b
然后我们执行归约。 reduce 的签名是 reduce(函数,序列和初始),其中函数在每个步骤应用于序列。 第一步,将初始值用作第一个值,而不是列表的第一个元素:
from functools import reduce
print(reduce(add, sums, 0))
结果25是和列表中每个值的总和,因此是原始数组中每个元素的总和。
上面的代码等于以下代码:
initial = 0
current_result = initial
for element in sums:
current_result = add(current_result, element)
在这个琐碎的示例中,我们的代码可以大大简化,但是真正的收益来自于分布计算。 例如,如果我们有一百万个子列表,并且每个子列表包含一百万个元素,则可以在许多计算机上分布此计算。
为此,我们分配了地图步骤。 对于列表中的每个元素,我们将其以及功能说明发送到计算机。 然后,这台计算机将结果返回到我们的主计算机(主机)。
然后,主机将结果发送到计算机进行缩减步骤。 在上百万个子列表的示例中,我们会将一百万个作业发送到不同的计算机(同一台计算机在完成我们的第一份工作后可能会被重用)。 返回的结果只是一个一百万个数字的列表,然后我们计算它们的总和。
结果是,即使我们的原始数据中包含一万亿个数字,也没有计算机需要存储超过一百万个数字。
字数示例
MapReduce 的实现比仅使用 map and reduce 步骤要复杂一些。 这两个步骤都使用键来调用,这允许分离数据和跟踪值。
映射函数采用键和值对,并返回键+值对的列表。 输入和输出的键不一定相互关联。 例如,对于执行单词计数的 MapReduce 程序,输入键可能是示例文档的 ID 值,而输出键可能是给定的单词。 输入值将是文档的文本,输出值将是每个单词的频率:
from collections import defaultdict
def map_word_count(document_id, document):
我们首先计算每个单词的频率。 在这个简化的示例中,尽管有更好的选择,我们将文档拆分为空白以获取单词:
counts = defaultdict(int)
for word in document.split():
counts[word] += 1
然后我们产生每个单词,计数对。 这里的单词是关键,计数是 MapReduce 术语中的值:
for word in counts:
yield (word, counts[word])
通过使用单词作为关键字,我们可以执行随机播放步骤,该步骤将每个关键字的所有值分组:
def shuffle_words(results):
首先,我们将每个单词的结果计数汇总到计数列表中:
records = defaultdict(list)
然后,我们迭代 map 函数返回的所有结果;
for results in results_generators:
for word, count in results:
records[word].append(count)
接下来,我们产生每个单词以及在数据集中获得的所有计数:
for word in records:
yield (word, records[word])
最后一步是 reduce 步骤,该步骤采用一个键值对(在这种情况下,该值始终是一个列表)并生成一个键值对。 在我们的示例中,键是单词,输入列表是在随机播放步骤中产生的计数列表,输出值是计数之和:
def reduce_counts(word, list_of_counts):
return (word, sum(list_of_counts))
为了了解这一点,我们可以使用 scikit-learn 中提供的 20 个新闻组数据集:
from sklearn.datasets import fetch_20newsgroups
dataset = fetch_20newsgroups(subset='train')
documents = dataset.data
然后,我们应用地图步骤。 我们在此处使用枚举为我们自动生成文档 ID。 尽管它们在此应用中并不重要,但这些键在其他应用中却很重要。
map_results = map(map_word_count, enumerate(documents))
此处的实际结果只是一个生成器,没有实际计数。 就是说,这是一个生成(单词,计数)对的生成器。
接下来,我们执行洗牌步骤对这些字数进行排序:
shuffle_results = shuffle_words(map_results)
从本质上讲,这是 MapReduce 的工作。 但是,它仅在单个线程上运行,这意味着我们无法从 MapReduce 数据格式中获得任何好处。 在下一节中,我们将开始使用 Hadoop(MapReduce 的开源提供程序)开始从这种范例中获取好处。
Hadoop MapReduce
Hadoop 是 Apache 的开源工具集,其中包括 MapReduce 的实现。 在许多情况下,它是许多人事实上使用的实现。 该项目由 Apache 小组(负责著名的 Web 服务器)管理。
Hadoop 生态系统非常复杂,具有大量工具。 我们将使用的主要组件是 Hadoop MapReduce。 Hadoop 中包含的其他用于处理大数据的工具如下:
- Hadoop 分布式文件系统(HDFS):此是一种文件系统,可以在许多计算机上存储文件,目的是在提供高带宽的同时,防止硬件故障。
- YARN:此是用于调度应用和管理计算机集群的方法。
- Pig:这是一种用于 MapReduce 的高级编程语言。 Hadoop MapReduce 以 Java 实施,Pig 位于 Java 实施之上,允许您以其他语言(包括 Python)编写程序。
- 配置单元:此用于管理数据仓库和执行查询。
- HBase:此是 Google 的 BigTable(分布式数据库)的实现。
这些工具都解决了进行大数据实验(包括数据分析)时出现的不同问题。
也有非基于 Hadoop 的 MapReduce 实现,以及其他具有类似目标的项目。 此外,许多云提供商都使用基于 MapReduce 的系统。

应用
在此应用中,我们将研究根据使用不同单词来预测作家的性别。 我们将为此使用朴素贝叶斯方法,并在 MapReduce 中进行训练。 最终模型不需要 MapReduce,尽管我们可以使用 Map 步骤来执行-也就是说,在列表中的每个文档上运行预测模型。 这是 MapReduce 中用于数据挖掘的常见 Map 操作,而 reduce 步骤仅组织了预测列表,因此可以将其追溯到原始文档。
我们将使用亚马逊的基础设施来运行我们的应用,从而使我们能够利用其计算资源。
获取数据
我们将使用的数据是一组博客文章,其中标有年龄,性别,行业(即工作)以及有趣的是星号。 这些数据是 2004 年 8 月从这个页面收集的,在超过 60 万个帖子中有 1.4 亿个单词。 每个博客可能只由一个人撰写,并做了一些工作来验证这一点(尽管我们永远无法确定)。 帖子还与发布日期匹配,这使它成为非常丰富的数据集。
要获取数据,请转到这个页面并单击下载语料库。 从那里将文件解压缩到计算机上的目录。
数据集由一个博客组织成一个文件,文件名给出了类。 例如,文件名之一如下:
1005545.male.25.Engineering.Sagittarius.xml
文件名用句点分隔,字段如下:
- Blogger ID:此一个简单的 ID 值,用于组织身份。
- 性别:此是男性还是女性,并且所有博客都被标识为这两个选项之一(此数据集中不包含其他选项)。
- 年龄:给出了确切的年龄,但故意存在一些差距。 存在的年龄在 13-17、23-27 和 33-48(含)范围内。 出现差距的原因是允许将博客按年龄段划分,因为将 18 岁的写作与 19 岁的写作分开很难,而且年龄本身可能已经过时了。
- 行业:在中,是 40 个不同行业之一,包括科学,工程,艺术和房地产。 另外,还包括不知名行业的 indUnk。
- 星号:此是 12 个占星术星号之一。
所有值都是自我报告的,这意味着标签可能存在错误或不一致,但被认为是最可靠的-如果人们想以这种方式保护自己的隐私,则可以选择不设置值。
单个文件为pseudo-XML格式,包含<Blog>标签和<post>标签序列。 每个<post>标签都以<date>标签开头。 尽管我们可以将其解析为 XML,但逐行解析它要简单得多,因为文件不是完全格式正确的 XML,存在一些错误(主要是编码问题)。 要阅读文件中的帖子,我们可以使用循环遍历行。
我们设置了一个测试文件名,以便可以看到它的作用:
import os
filename = os.path.join(os.path.expanduser("~"), "Data", "blogs", "1005545.male.25.Engineering.Sagittarius.xml")
首先,我们创建一个列表,该列表将让我们存储每个帖子:
all_posts = []
然后,我们打开文件以读取:
with open(filename) as inf:
然后,我们设置一个标志,指示我们当前是否在帖子中。 当找到表示帖子开始的<post>标签时,将其设置为True;当找到结束的</post>标签时,将其设置为False;
post_start = False
然后,我们创建一个列表来存储当前帖子的行:
post = []
然后,我们遍历文件的每一行并删除空白:
for line in inf:
line = line.strip()
如前所述,如果我们找到开始的<post>标签,则表明我们在新帖子中。 同样,使用 close </post>标签:
if line == "<post>":
post_start = True
elif line == "</post>":
post_start = False
当我们找到结束</post>标记时,我们还将记录到目前为止找到的完整帖子。 然后,我们也开始一个新的“当前”帖子。 该代码与上一行的缩进级别相同:
all_posts.append("\n".join(post))
post = []
最后,当该行不是 end 标记的开始,而是在发布中时,我们将当前行的文本添加到当前发布中:
elif post_start:
post.append(line)
如果我们不在当前帖子中,则只需忽略该行。
然后,我们可以获取每个帖子的文本:
print(all_posts[0])
我们也可以找出该作者创建的帖子数:
print(len(all_posts))
朴素贝叶斯预测
现在,我们将要实现能够处理我们的数据集的朴素贝叶斯算法(从技术上讲,是贝叶斯算法的简化版本,没有许多更复杂的实现具有的功能)。
mrjob 程序包
mrjob 软件包使我们能够创建 MapReduce 作业,这些作业可以轻松地传输到 Amazon 的基础架构。 虽然 mrjob 听起来像是 Men 先生系列儿童读物中的可笑的补充,但它实际上代表 Map Reduce Job。 这是一个很棒的包装; 但是,在撰写本文时,Python 3 支持仍不成熟,这对于我们稍后将讨论的 Amazon EMR 服务是正确的。
注意
您可以使用以下命令为 Python 2 版本安装 mrjob:
sudo pip2 install mrjob
请注意,pip用于版本 2,而不用于版本 3。
本质上,mrjob 提供了大多数 MapReduce 作业所需的标准功能。 它最惊人的功能是,您可以编写相同的代码,在没有 Hadoop 的情况下在本地计算机上进行测试,然后推送到 Amazon 的 EMR 服务或另一台 Hadoop 服务器。
尽管无法神奇地使大问题变小,但这使测试代码变得更加容易-请注意,任何本地测试都使用数据集的子集而不是整个大型数据集。
提取博客文章
我们首先要创建一个 MapReduce 程序,该程序将从每个博客文件中提取每个帖子,并将它们存储为单独的条目。 由于我们对帖子作者的性别感兴趣,因此我们也将其提取出来并与帖子一起存储。
我们无法在 IPython Notebook 中执行此操作,因此请打开 Python IDE 进行开发。 如果您没有 Python IDE(例如 PyCharm),则可以使用文本编辑器。 我建议寻找具有语法高亮显示的 IDE。
注意
如果仍然找不到良好的 IDE,则可以在 IPython Notebook 中编写代码,然后单击文件 |。 下载为 | Python。 将此文件保存到目录并按照第 11 章,“使用深度学习”对图像中的对象进行分类中所述运行它。
为此,我们将需要os和re库,因为我们将获取环境变量,并且还将使用正则表达式进行单词分隔:
import os
import re
然后,我们导入MRJob类,该类将从我们的 MapReduce 作业中继承:
from mrjob.job import MRJob
然后,我们创建一个新类,该子类继承了 MRJob:
class ExtractPosts(MRJob):
与以前一样,我们将使用类似的循环从文件中提取博客文章。 接下来将定义的映射功能将在每一行中工作,这意味着我们必须在映射功能之外跟踪不同的帖子。 因此,我们将post_start和post类变量,而不是函数内部的变量:
post_start = False
post = []
然后,我们定义我们的 mapper 函数-这将文件中的一行作为输入并产生博客文章。 这些行保证可以从同一作业文件中订购。 这使我们可以使用上述类变量来记录当前的帖子数据:
def mapper(self, key, line):
在开始收集博客文章之前,我们需要获取博客作者的性别。 尽管我们通常不将文件名用作 MapReduce 作业的一部分,但对它的需求非常强烈(如本例所示),因此该功能可用。 当前文件存储为环境变量,我们可以使用以下代码行获取该文件:
filename = os.environ["map_input_file"]
然后,我们分割文件名以获取性别(这是第二个标记):
gender = filename.split(".")[1]
我们从行的开头和结尾删除空格(这些文档中有很多空格),然后像以前一样进行基于帖子的跟踪;
line = line.strip()
if line == "<post>":
self.post_start = True
elif line == "</post>":
self.post_start = False
与其像以前那样将存储在列表中,不如将它们存储在列表中,而是生成它们。 这使 mrjob 可以跟踪输出。 我们同时提供性别和职位,以便我们可以记录每条记录匹配的性别。 此函数的其余部分与上述循环的定义方式相同:
yield gender, repr("\n".join(self.post))
self.post = []
elif self.post_start:
self.post.append(line)
最后,在函数和类之外,我们设置脚本以在从命令行调用该 MapReduce 作业时运行该脚本:
if __name__ == '__main__':
ExtractPosts.run()
现在,我们可以使用以下 shell 命令运行此 MapReduce 作业。 请注意,我们使用的是 Python 2,而不是 Python 3。
python extract_posts.py <your_data_folder>/blogs/51* --output-dir=<your_data_folder>/blogposts –no-output
第一个参数<your_data_folder>/blogs/51*(只需记住将<your_data_folder >更改为数据文件夹的完整路径),将获得数据样本(所有以 51 开头的文件,只有 11 个文档)。 然后,我们将输出目录设置为新文件夹,并将其放置在 data 文件夹中,并指定不输出流数据。 如果没有最后一个选项,则运行时输出的数据将显示在命令行中,这对我们不是很有帮助,并且会大大降低计算机的速度。
运行脚本,很快就会提取每个博客帖子并将其存储在我们的output文件夹中。 该脚本仅在本地计算机上的单个线程上运行,因此我们根本无法获得加速,但是我们知道代码可以运行。
现在,我们可以在output文件夹中查找结果。 创建了一堆文件,每个文件在单独的行中包含每个博客文章,并在其前面加上博客作者的性别。

训练朴素贝叶斯
现在我们已经提取了博客文章,我们可以在其上训练我们的朴素贝叶斯模型。 直觉是我们记录了某个单词被特定性别书写的可能性。 为了对新样本进行分类,我们将概率相乘并找到最可能的性别。
此代码的目标是要输出一个文件,该文件列出了语料库中的每个单词以及该单词针对每个性别的出现频率。 输出文件将如下所示:
"'ailleurs" {"female": 0.003205128205128205}
"'air" {"female": 0.003205128205128205}
"'an" {"male": 0.0030581039755351682, "female": 0.004273504273504274}
"'angoisse" {"female": 0.003205128205128205}
"'apprendra" {"male": 0.0013047113868622459, "female": 0.0014172668603481887}
"'attendent" {"female": 0.00641025641025641}
"'autistic" {"male": 0.002150537634408602}
"'auto" {"female": 0.003205128205128205}
"'avais" {"female": 0.00641025641025641}
"'avait" {"female": 0.004273504273504274}
"'behind" {"male": 0.0024390243902439024}
"'bout" {"female": 0.002034152292059272}
第一个值是单词,第二个值是字典,将性别映射到该单词在该性别文字中的出现频率。
在 Python IDE 或文本编辑器中打开一个新文件。 我们将再次需要 mrjob 的os和re库以及NumPy和MRJob。 我们还需要 itemgetter,因为我们将对字典进行排序:
import os
import re
import numpy as np
from mrjob.job import MRJob
from operator import itemgetter
我们还将需要 MRStep,它概述了 MapReduce 作业中的步骤。 我们以前的工作只有一步,即定义为映射函数,然后定义为归约函数。 这项工作将包含三个步骤,我们先进行 Map,Reduce,然后再次进行 Map 和 Reduce。 直觉与我们在前面各章中使用的管道相同,其中一步的输出是下一步的输入:
from mrjob.step import MRStep
然后,我们创建单词搜索正则表达式并进行编译,从而使我们能够找到单词边界。 这种正则表达式比我们在前几章中使用的简单拆分功能要强大得多,但是如果您要寻找更准确的单词拆分器,我建议像在第 6 章中一样使用 NLTK,[ “使用朴素贝叶斯”的社交媒体见解:
word_search_re = re.compile(r"[\w']+")
我们为训练定义了一个新班级:
class NaiveBayesTrainer(MRJob):
我们定义了MapReduce工作的步骤。 有两个步骤。 第一步将提取单词出现概率。 第二步将比较两个性别,并将每个性别的概率输出到我们的输出文件中。 在每个 MRStep 中,我们定义映射器和化简器函数,它们是NaiveBayesTrainer类中的类函数(我们将在接下来编写这些函数):
def steps(self):
return [
MRStep(mapper=self.extract_words_mapping,
reducer=self.reducer_count_words),
MRStep(reducer=self.compare_words_reducer),
]
第一个功能是第一步的映射器功能。 此功能的目标是获取每个博客帖子,获取该帖子中的所有单词,然后记录出现的情况。 我们需要单词的频率,因此我们将返回1 / len(all_words),这允许我们稍后对频率值求和。 这里的计算并不完全正确-我们还需要针对文档数进行标准化。 但是,在此数据集中,类的大小是相同的,因此我们可以方便地忽略它,而对最终版本的影响很小。
我们还将输出该帖子作者的性别,因为稍后我们将需要它:
def extract_words_mapping(self, key, value):
tokens = value.split()
gender = eval(tokens[0])
blog_post = eval(" ".join(tokens[1:]))
all_words = word_search_re.findall(blog_post)
all_words = [word.lower() for word in all_words]
all_words = word_search_re.findall(blog_post)
all_words = [word.lower() for word in all_words]
for word in all_words:
yield (gender, word), 1\. / len(all_words)
提示
对于本示例,我们在前面的代码中使用eval来简化从文件中博客帖子的解析。 不建议这样做。 而是使用 JSON 之类的格式正确存储和解析文件中的数据。 具有对数据集的访问权限的恶意使用会将代码插入这些令牌中,并使该代码在您的服务器上运行。
在第一步的归约器中,我们将每个性别和单词对的频率相加。 我们也将关键字更改为单词,而不是组合,因为这允许我们在使用最终训练的模型时按单词搜索(尽管,我们仍然需要输出性别以备后用);
def reducer_count_words(self, key, frequencies):
s = sum(frequencies)
gender, word = key
yield word, (gender, s)
的最后一步不需要映射器功能,因此我们不需要添加一个映射器功能。 数据将作为identity mapper类型直接传递。 但是,归约器将组合给定单词下每个性别的频率,然后输出单词和频率字典。
这为我们提供了朴素贝叶斯实现所需的信息:
def compare_words_reducer(self, word, values):
per_gender = {}
for value in values:
gender, s = value
per_gender[gender] = s
yield word, per_gender
最后,当文件作为脚本运行时,我们设置代码来运行该模型;
if __name__ == '__main__':
NaiveBayesTrainer.run()
然后,我们可以运行此脚本。 该脚本的输入是前一个提取程序后脚本的输出(如果您愿意,我们可以在同一 MapReduce 作业中将它们实际上作为不同的步骤);
python nb_train.py <your_data_folder>/blogposts/
--output-dir=<your_data_folder>/models/
--no-output
输出目录是一个文件夹,该文件夹将存储一个包含此 MapReduce 作业的输出的文件,这将是我们运行 Naive Bayes 分类器所需的概率。
全部放在一起
现在,我们可以使用这些概率运行朴素贝叶斯分类器。 我们将在 IPython Notebook 中进行此操作,然后可以返回使用 Python 3(phe!)。
首先,查看上一个 MapReduce 作业中指定的 models 文件夹。 如果输出是多个文件,我们可以通过在 models 目录中使用命令行功能将文件彼此附加在一起来合并文件:
cat * > model.txt
如果这样做,则需要使用model.txt作为模型文件名更新以下代码。
回到我们的笔记本,我们首先导入脚本所需的一些标准导入:
import os
import re
import numpy as np
from collections import defaultdict
from operator import itemgetter
我们再次重新定义词搜索正则表达式-如果您是在实际应用中执行此操作,则建议将其集中化。 以相同的方式提取单词进行训练和测试非常重要:
word_search_re = re.compile(r"[\w']+")
接下来,我们创建一个从给定文件名加载模型的函数:
def load_model(model_filename):
模型参数将采用字典字典的形式,其中第一个键是单词,内部字典将每个性别映射为一个概率。 我们使用defaultdicts,如果不存在值,它将返回零;
model = defaultdict(lambda: defaultdict(float))
然后,我们打开模型并解析每一行;
with open(model_filename) as inf:
for line in inf:
该行分为两部分,用空格隔开。 第一个是单词本身,第二个是概率词典。 对于每个,我们对它们运行eval以获得实际值,该实际值是使用repr存储在前面的代码中的:
word, values = line.split(maxsplit=1)
word = eval(word)
values = eval(values)
然后,我们将值跟踪到模型中的单词:
model[word] = values
return model
接下来,我们加载实际模型。 您可能需要更改模型文件名,该文件名将位于最后一个 MapReduce 作业的输出dir中;
model_filename = os.path.join(os.path.expanduser("~"), "models", "part-00000")
model = load_model(model_filename)
例如,我们可以看到男性和女性在单词i(所有单词在 MapReduce 作业中都变为小写)的用法上的差异:
model["i"]["male"], model["i"]["female"]
接下来,我们创建一个可以使用该模型进行预测的函数。 在此示例中,我们将不使用 scikit-learn 接口,而仅创建一个函数。 我们的函数将模型和文档作为参数,并返回最可能的性别:
def nb_predict(model, document):
我们首先创建一个字典,将每个性别映射到计算出的概率:
probabilities = defaultdict(lambda : 1)
我们从文档中提取每个单词:
words = word_search_re.findall(document)
然后,我们遍历单词并在数据集中找到每种性别的概率:
for word in set(words):
probabilities["male"] += np.log(model[word].get("male", 1e-15))
probabilities["female"] += np.log(model[word].get("female", 1e-15))
然后,我们按性别将其值排序,获得最高价值,并将其返回为我们的预测:
most_likely_genders = sorted(probabilities.items(), key=itemgetter(1), reverse=True)
return most_likely_genders[0][0]
重要的是要注意,我们使用np.log来计算概率。 朴素贝叶斯模型中的概率通常很小。 在许多统计值中都必须乘以较小的值,这会导致下溢错误,其中计算机的精度不够好,只会使整个值变为 0。在这种情况下,这将导致两个性别的可能性为零 ,导致错误的预测。
为了解决这个问题,我们使用对数概率。 对于两个值 a 和 b,log(a,b)等于log(a) + log(b)。 小概率的对数为负值,但相对较大。 例如,log(0.00001)约为-11.5。 这意味着我们可以乘以对数概率并以相同的方式比较值,而不是乘以实际概率并冒下溢错误的风险(数字越大表示可能性越高)。
使用对数概率的的一个问题是它们不能很好地处理零值(尽管乘以零概率也不能)。 这是因为未定义 log(0)。 在朴素贝叶斯的某些实现中,所有计数都加 1 以消除此问题,但是还有其他方法可以解决此问题。 这是平滑值的简单形式。 在我们的代码中,如果对于给定的性别没有看到这个词,我们只会返回一个很小的值。
回到我们的预测功能,我们可以通过复制数据集中的帖子来进行测试:
new_post = """ Every day should be a half day. Took the afternoon off to hit the dentist, and while I was out I managed to get my oil changed, too. Remember that business with my car dealership this winter? Well, consider this the epilogue. The friendly fellas at the Valvoline Instant Oil Change on Snelling were nice enough to notice that my dipstick was broken, and the metal piece was too far down in its little dipstick tube to pull out. Looks like I'm going to need a magnet. Damn you, Kline Nissan, daaaaaaammmnnn yooouuuu.... Today I let my boss know that I've submitted my Corps application. The news has been greeted by everyone in the company with a level of enthusiasm that really floors me. The back deck has finally been cleared off by the construction company working on the place. This company, for anyone who's interested, consists mainly of one guy who spends his days cursing at his crew of Spanish-speaking laborers. Construction of my deck began around the time Nixon was getting out of office.
"""
然后,我们使用以下代码进行预测:
nb_predict(model, new_post)
对于此示例,最终的预测结果是男性,是正确的。 当然,我们绝不会在单个样本上测试模型。 我们使用以 51 开头的文件来训练该模型。 样本不多,所以我们不能指望准确性太高。
我们应该做的第一件事是训练更多样本。 我们将对以 6 或 7 开头的任何文件进行测试,然后对其余文件进行训练。
在命令行和 Blog 文件夹所在的数据文件夹(cd <your_data_folder)中,将 Blog 数据的副本创建到新文件夹中。
为我们的训练集创建一个文件夹:
mkdir blogs_train
从训练集中将所有以 6 或 7 开头的文件移到测试集中:
cp blogs/4* blogs_train/
cp blogs/8* blogs_train/
然后,为我们的测试集创建一个文件夹:
mkdir blogs_test
将以6或7开头的任何文件从训练集中移入测试集中:
cp blogs/6* blogs_test/
cp blogs/7* blogs_test/
我们将对训练集中的所有文件重新运行博客提取。 但是,这是一个比我们的系统更适合云基础架构的大型计算。 因此,我们现在将解析作业移至 Amazon 的基础架构。
和以前一样,在命令行上运行以下命令。 唯一的区别是我们在不同的输入文件文件夹上进行训练。 在运行以下代码之前,请删除博客文章和模型文件夹中的所有文件:
python extract_posts.py ~/Data/blogs_train --output-dir=/home/bob/Data/blogposts –no-output
python nb_train.py ~/Data/blogposts/ --output-dir=/home/bob/models/ --no-output
此处的代码将需要更长的时间才能运行。
我们将在测试集中的任何博客文件上进行测试。 要获取文件,我们需要提取它们。 我们将使用extract_posts.py MapReduce 作业,但将文件存储在单独的文件夹中:
python extract_posts.py ~/Data/blogs_test --output-dir=/home/bob/Data/blogposts_testing –no-output
回到 IPython Notebook,我们列出了所有输出的测试文件:
testing_folder = os.path.join(os.path.expanduser("~"), "Data", "blogposts_testing")
testing_filenames = []
for filename in os.listdir(testing_folder):
testing_filenames.append(os.path.join(testing_folder, filename))
对于每个文件,我们提取性别和文档,然后调用预测函数。 因为有很多文档,所以我们在生成器中执行此操作,并且我们不想使用过多的内存。 生成器产生实际性别和预测性别:
def nb_predict_many(model, input_filename):
with open(input_filename) as inf:
# remove leading and trailing whitespace
for line in inf:
tokens = line.split()
actual_gender = eval(tokens[0])
blog_post = eval(" ".join(tokens[1:]))
yield actual_gender, nb_predict(model, blog_post)
然后,我们记录整个数据集中的预测和实际性别。 我们在这里的预测是男性还是女性。 为了使用 scikit-learn 的f1_score函数,我们需要将它们变成 1 和 0。 为此,如果性别是男性,我们记录为 0,如果性别是 1,则记录为 1。 为此,我们使用布尔测试,查看性别是否为女性。 然后,我们使用NumPy将这些布尔值转换为int:
y_true = []
y_pred = []
for actual_gender, predicted_gender in nb_predict_many(model, testing_filenames[0]):
y_true.append(actual_gender == "female")
y_pred.append(predicted_gender == "female")
y_true = np.array(y_true, dtype='int')
y_pred = np.array(y_pred, dtype='int')
现在,我们使用 scikit-learn 中的F1分数来测试的质量:
from sklearn.metrics import f1_score
print("f1={:.4f}".format(f1_score(y_true, y_pred, pos_label=None)))
0.78 的结果还不错。 我们可能可以通过使用更多数据来改善此问题,但是要做到这一点,我们需要转向可以处理数据的功能更强大的基础架构。
关于亚马逊 EMR 基础设施的训练
我们正在使用 Amazon 的 Elastic Map Reduce(EMR)基础结构来运行我们的解析和模型构建作业。
为了做到这一点,我们首先需要在亚马逊的存储云中创建一个存储桶。 为此,请转到这个页面在 Web 浏览器中打开 Amazon S3 控制台,然后单击创建存储桶。 记住存储桶的名称,因为稍后我们将需要它。
右键单击新存储桶,然后选择Properties。 然后,更改权限,向所有人授予完全访问权限。 通常,这不是一个好的安全做法,建议您在完成本章后更改访问权限。
左键单击存储桶以将其打开,然后单击创建文件夹。 将文件夹命名为blogs_train。 我们将训练数据上传到此文件夹以在云上进行处理。
在您的计算机上,我们将使用 Amazon 的 AWS CLI,这是用于在 Amazon 的云上进行处理的命令行界面。
要安装,请使用以下命令:
sudo pip2 install awscli
按照这个页面上的说明设置此程序的凭据。
现在,我们要将数据上传到我们的新存储桶中。 首先,我们要创建数据集,该数据集不是所有博客都以 6 或 7 开头的博客。有更多优雅的方法可以完成此复制,但没有一个跨平台的方法值得推荐。 相反,只需复制所有文件,然后从训练数据集中删除以 6 或 7 开头的文件:
cp -R ~/Data/blogs ~/Data/blogs_train_large
rm ~/Data/blogs_train_large/6*
rm ~/Data/blogs_train_large/7*
接下来,将数据上传到您的 Amazon S3 存储桶。 请注意,这将花费一些时间,并且会使用大量的上传数据(几百兆字节)。 对于互联网连接速度较慢的用户,在连接速度较快的位置进行此操作可能值得;
aws s3 cp ~/Data/blogs_train_large/ s3://ch12/blogs_train_large --recursive --exclude "*" --include "*.xml"
我们将使用 mrjob 连接到 Amazon 的 EMR,它可以为我们处理所有事情。 它只需要我们的凭据即可。 按照这个页面上的说明,使用您的 Amazon 凭证设置 mrjob。
完成此操作后,我们只需稍稍更改 mrjob 运行即可在 Amazon EMR 上运行。 我们只是通过-r开关告诉 mrjob 使用emr,然后将s3容器设置为输入和输出目录。 即使这将在亚马逊的基础设施上运行,也将需要相当长的时间才能运行。
python extract_posts.py -r emr s3://ch12gender/blogs_train_large/ --output-dir=s3://ch12/blogposts_train/ --no-output
python nb_train.py -r emr s3://ch12/blogposts_train/ --output-dir=s3://ch12/model/ --o-output
注意
您还需要为使用付费。 这只有几美元,但是如果您要继续运行作业或在更大的数据集上执行其他作业,请记住这一点。 我做了很多工作,被收取了大约 20 美元。 仅运行这少数几个就应该少于$ 4。 但是,您可以转到这个页面来检查余额并设置价格提醒。
不需要blogposts_train和model文件夹存在-它们将由 EMR 创建。 实际上,如果它们存在,您将得到一个错误。 如果要重新运行,只需将这些文件夹的名称更改为新名称,但要记住将两个命令都更改为相同的名称(即,第一个命令的输出目录是第二个命令的输入目录)。
注意
如果您不耐烦,您可以在一段时间后随时停止第一份工作,而仅使用到目前为止收集的训练数据即可。 我建议离职至少 15 分钟,可能至少一个小时。 但是,您不能停止第二份工作并获得良好的结果。 第二份工作大概需要第一份工作的两倍到三倍。
您现在可以返回s3控制台,并从存储桶中下载输出模型。 将其保存在本地,我们可以返回 IPython Notebook 并使用新模型。 我们在此处重新输入代码-仅突出显示差异,以更新到我们的新模型:
aws_model_filename = os.path.join(os.path.expanduser("~"), "models", "aws_model")
aws_model = load_model(aws_model_filename)
y_true = []
y_pred = []
for actual_gender, predicted_gender in nb_predict_many(aws_model, testing_filenames[0]):
y_true.append(actual_gender == "female")
y_pred.append(predicted_gender == "female")
y_true = np.array(y_true, dtype='int')
y_pred = np.array(y_pred, dtype='int')
print("f1={:.4f}".format(f1_score(y_true, y_pred, pos_label=None)))
使用额外的数据(0.81),结果要好得多。
注意
如果一切按计划进行,则您可能要从 Amazon S3 中删除存储桶-您需要支付存储费用。



三十四、下一步...
在本书的学习过程中,没有采取很多途径,没有提出选择,并且没有充分探索主题。 在本附录中,我为那些希望进行更多学习并使用 Python 进行数据挖掘的人员创建了一系列后续步骤。 考虑本书的第二个问题是英雄模式。
本附录按章节进行了细分,包括文章,书籍和其他资源,以了解有关数据挖掘的更多信息。 还包括扩展本章中完成的工作的一些挑战。 其中一些将是小的改进; 有些工作要做得更多—我已经记下了那些明显比其他任务更广泛的任务。
第 1 章–数据挖掘入门
Scikit 学习教程
http://scikit-learn.org/stable/tutorial/index.html
scikit-learn 文档包含在中,是一系列有关数据挖掘的教程。 教程范围从基本介绍到玩具数据集,一直到最近研究中使用的技术的综合教程。
这里的教程将花费相当长的时间-它们非常全面-但是值得努力学习。
扩展 IPython Notebook
http://ipython.org/ipython-doc/1/interactive/public_server.html
IPython Notebook 是一个功能强大的工具。 它可以通过多种方式进行扩展,其中之一是与主计算机分开创建一个服务器来运行笔记本电脑。 如果您使用低功率的主计算机(例如小型笔记本电脑),但可以使用功能更强大的计算机,这将非常有用。 此外,您可以设置节点以执行并行计算。更多数据集可从这个页面获得。
第 2 章–使用 scikit-learn 估计器进行分类
更复杂的管道
http://scikit-learn.org/stable/modules/pipeline.html#featureunion-composite-feature-spaces
我们在模块中使用的 Pipelines 遵循单个流-一个步骤的输出是另一步骤的输入。
管道也遵循转换器和估计器接口-这使我们可以将管道嵌入管道中。 对于非常复杂的模型,这是一个有用的构造,但如上链接所示,当与 Feature Unions 结合使用时,其功能将非常强大。
这使我们可以一次提取多种类型的特征,然后将它们组合以形成单个数据集。 有关更多详细信息,请参见这个页面上的示例。
比较分类器
scikit-learn 中有可供使用的许多分类器。 您为特定任务选择的任务将基于多种因素。 您可以比较 f1 分数以查看哪种方法更好,并且可以调查那些分数的偏差以查看该结果是否具有统计意义。
一个重要的因素是它们是在相同的数据上进行训练和测试的,也就是说,一个分类器的测试集就是所有分类器的测试集。 我们对随机状态的使用使我们能够确保确实如此-这是复制实验的重要因素。
第 3 章:用决策树预测体育获胜者
有关 Pandas 的更多信息
pandas 库是一个很棒的软件包-您通常编写的用于加载数据的任何内容都可能已在 pandas 中实现。 您可以在教程中了解有关此内容的更多信息
克里斯·莫菲特(Chris Moffitt)也撰写了一篇很棒的博客文章,概述了人们在 Excel 中执行的常见任务以及如何在 Pandas 中执行这些任务
您还可以使用 Pandas 处理大型数据集。 请参阅从用户 Jeff(撰写本文时为时的最高答案)到此 StackOverflow 问题的答案,以获取有关该过程的广泛概述。
Brian Connelly 撰写了另一本关于 Pandas 的出色教程
第 7 章–使用图挖掘发现要遵循的帐户
更复杂的算法
在预测图形中的链接(包括社交网络)方面进行了广泛的研究。 例如,David Liben-Nowell 和 Jon Kleinberg 发表了有关该主题的论文,该论文将成为上面链接的更复杂算法的理想之地。 可在这个页面获得。
第 4 章-使用亲和力分析推荐电影
Eclat 算法
http://www.borgelt.net/eclat.html
本章中实现的先验算法很容易成为关联规则挖掘图中最著名的,但不一定是最好的。 Eclat 是一种更现代的算法,可以相对容易地实现。
第 5 章–使用提升器提取特征
Vowpal Wabbit
Vowpal Wabbit 是一个不错的项目,它为基于文本的问题提供了非常快速的特征提取。 它带有 Python 包装器,可让您从 Python 代码中调用它。 在大型数据集上进行测试,例如我们在第 12 章,“处理大数据”中使用的数据集。
第 6 章–使用朴素贝叶斯的社交媒体洞察
自然语言处理和词性标记
与其他领域中使用的某些语言模型相比,本章中使用的技术非常轻巧。 例如,词性标记可以帮助消除单词形式的歧义,从而提高准确性。 NLTK 随附的书中有一章对此有所说明。 整本书也很值得一读。
第 8 章–用神经网络击败验证码
更深层的网络
这些技术可能会愚弄我们当前的实现,因此需要进行改进以使该方法更好。 尝试使用第 11 章,“使用深度学习”对图像中的对象进行分类的更深层网络。
但是,较大的网络需要更多数据,因此,为了获得良好的性能,您可能需要生成本章中完成的几千个以上的样本。 生成这些数据集是并行化的一个很好的选择,并行化是许多可以独立执行的小任务。
强化学习
http://pybrain.org/docs/tutorial/reinforcement-learning.html
强化学习作为数据挖掘的下一件大事越来越受到关注-尽管已经有很长时间了! PyBrain 有一些强化学习算法,值得与此数据集(以及其他数据集)进行检查。
第 9 章-作者归属
本地 n-gram
分类器的另一种形式是局部 n-gram,它涉及为每个作者选择最佳功能,而不是为整个数据集全局选择最佳特征。 我写了一个关于使用本地 n-gram 进行作者身份归属的教程,该教程可在这个页面获得。
第 10 章-集群新闻文章
实时集群
k 均值算法可以随着时间进行迭代训练和更新,而不是在给定的时间范围内进行离散分析。 可以通过多种方式跟踪集群的移动,例如,您可以跟踪每个集群中流行的单词以及质心每天移动多少。 记住 API 限制-您可能只需要每隔几小时进行一次检查就可以使算法保持最新状态。
第 11 章–使用深度学习对图像中的对象进行分类
Keras 和 Pylearn2
如果要通过 Python 进行深度学习进一步学习,其他值得研究的深度学习库是 Keras 和 Pylearn2。 它们都基于 Theano 并具有不同的用法和功能。
在撰写本文时,两者都不是稳定的平台,尽管 Pylearn2 在两者中更稳定。 也就是说,他们俩都做得很好,值得为以后的项目进行研究。
另一个名为 Torch 的库非常受欢迎,但是在编写本文时,它没有 python 绑定(请参阅这个页面)。
Mahotas
另一个用于图像处理的软件包是 Mahotas,它包括更好,更复杂的图像处理技术,尽管它们可能会带来很高的计算成本,但它们可以帮助实现更高的准确性。 但是,许多图像处理任务是并行化的良好候选者。 可以在研究文献中找到更多的图像分类技术,以此调查文件作为一个良好的开端。
第 12 章-处理大数据
Hadoop 课程
Yahoo 和 Google 都提供了有关 Hadoop 的出色教程,从入门到相当高级。 他们没有专门使用 Python 解决问题,但是学习 Hadoop 概念,然后将其应用到 Pydoop 或类似的库中可以产生很好的结果。
Pydoop
Pydoop 是一个运行 Hadoop 作业的 python 库-它也有一个很棒的教程,可以在这里找到。
尽管您也可以在 mrjob 中获得该功能,但 Pydoop 还可以与 HDFS(Hadoop 文件系统)一起使用。 Pydoop 将使您对运行某些作业有更多控制。
推荐引擎
构建大型推荐引擎是对大数据技能的良好测试。 Mark Litwintschik 的一篇很棒的博客文章介绍了使用 Apache Spark 这一大数据技术的引擎。
更多资源
Kaggle 比赛:
Kaggle 定期举办数据挖掘比赛,并经常获得奖金。 在 Kaggle 比赛中测试您的技能是学习与现实世界中的数据挖掘问题一起工作的一种快速而出色的方法。 这些论坛非常好,可以共享环境-在竞赛期间,您通常会看到发布前 10 名参赛者的代码!
三十五、赋予计算机学习数据的能力
在我看来,机器学习是使数据有意义的算法的应用和科学,是所有计算机科学中最激动人心的领域! 我们生活在一个数据丰富的时代。 使用机器学习领域的自学习算法,我们可以将这些数据转化为知识。 得益于近年来开发的许多强大的开源库,可能再没有比现在更好的时机进入机器学习领域并学习如何利用强大的算法来发现数据模式并对未来事件做出预测。
在本章中,我们将学习机器学习的主要概念和不同类型。 连同对相关术语的基本介绍,我们将为成功使用机器学习技术解决实际问题奠定基础。
在本章中,我们将介绍以下主题:
- 机器学习的一般概念
- 三种学习和基本术语
- 成功设计机器学习系统的基础
如何将数据转化为知识
在现代技术的这个时代,我们拥有大量的资源:大量的结构化和非结构化数据。 在 20 世纪下半叶,机器学习作为人工智能的一个子领域发展起来,其中涉及开发自学习算法以从该数据中获取知识以进行预测。 机器学习不再需要人工从大量数据的分析中得出规则并建立模型,而是提供了一种更有效的替代方法来捕获数据中的知识,从而逐步提高了预测模型的性能并做出了数据驱动的决策。 机器学习不仅在计算机科学研究中变得越来越重要,而且在我们的日常生活中也起着越来越重要的作用。 借助机器学习,我们可以使用强大的电子邮件垃圾邮件过滤器,便捷的文本和语音识别软件,可靠的 Web 搜索引擎,具有挑战性的国际象棋棋手,以及希望不久之后可以使用的安全高效的自动驾驶汽车。
三种不同类型的机器学习
在本节中,我们将研究三种类型的机器学习:监督学习,非监督学习和强化学习。 我们将了解这三种不同学习类型之间的基本区别,并使用概念性示例,为可以应用这些问题的实际问题领域发展直觉:

通过监督学习对未来进行预测
监督学习中的主要目标是从标记的训练数据中学习一个模型,该模型可让我们对看不见或将来的数据进行预测。 在此,术语监督是指一组样本,其中所需的输出信号(标签)是已知的。
考虑到电子邮件垃圾邮件过滤的示例,我们可以在标签电子邮件,正确标记为垃圾邮件的电子邮件的语料库上使用监督机器学习算法训练模型,以预测是否有新的电子邮件 -mail 属于两个类别之一。 具有离散类标签的监督学习任务,例如在先前的电子邮件垃圾邮件过滤示例中,也称为分类任务。 监督学习的另一个子类别是回归,其中结果信号是一个连续值:

用于预测类别标签的分类
分类是监督学习的子类别,目的是根据过去的观察结果预测新实例的类别标签。 这些类标签是离散的无序值,可以理解为实例的组成员资格。 前面提到的电子邮件垃圾邮件检测示例代表了二进制分类任务的典型示例,其中机器学习算法学习了一组规则以区分两种可能的类别:垃圾邮件和非垃圾邮件。 垃圾邮件。
但是,类标签集不必具有二进制性质。 通过监督学习算法学习的预测模型可以将训练数据集中显示的任何类别标签分配给新的未标签实例。 多类别分类任务的典型示例是手写字符识别。 在这里,我们可以收集一个训练数据集,该数据集由字母表中每个字母的多个手写示例组成。 现在,如果用户通过输入设备提供了新的手写字符,我们的预测模型将能够以一定的准确性预测字母表中的正确字母。 但是,我们的机器学习系统将无法正确识别零到九的任何数字,例如,如果它们不是我们训练数据集的一部分。
下图说明了给定 30 个训练样本的二进制分类任务的概念:15 个训练样本被标记为负面类别(圆圈),而 15 个训练样本被标记为正面类别 ](加号)。 在这种情况下,我们的数据集是二维的,这意味着每个样本都有两个与之关联的值:x[1]和x[2]。 现在,我们可以使用监督式机器学习算法来学习一条规则(决策边界以黑色虚线表示),该规则可以将这两个类别分开,并根据x[1]和x[2]将新数据分为这两个类别中的每一个 值:

预测连续结果的回归
我们在的上一节中了解到,分类的任务是为实例分配分类无序的标签。 监督学习的第二种类型是对连续结果的预测,这也称为回归分析。 在回归分析中,我们得到了许多预测变量(解释性)变量和连续响应变量(结果),我们试图找到这些变量之间的关系,使我们能够 预测结果。
例如,让我们假设我们对预测学生的数学 SAT 成绩感兴趣。 如果为考试而花费的时间与最终分数之间存在关联,我们可以将其用作训练数据,以学习一个模型,该模型利用学习时间来预测计划参加此考试的未来学生的考试分数。
注意
术语回归由弗朗西斯·加尔顿(Francis Galton)于 1886 年在他的文章向遗传性中枢的平庸回归中提出。 人口不会随着时间增加。 他观察到父母的身高并未传递给孩子,但孩子的身高正在向人口均值回归。
下图说明了线性回归的概念。 给定一个预测变量x和一个响应变量y,我们对该数据拟合一条直线,以使采样点和拟合点之间的距离(通常是平均平方距离)最小化 线。 现在,我们可以使用从该数据中学到的截距和斜率来预测新数据的结果变量:

通过强化学习解决互动问题
机器学习的另一种类型是强化学习。 在强化学习中,目标是开发一个基于与环境的交互作用来提高其性能的系统( agent )。 由于有关环境当前状态的信息通常还包含所谓的奖励信号,因此我们可以将强化学习视为与监督的学习相关的领域。 但是,在强化学习中,此反馈不是正确的地面真理标签或值,而是衡量奖励函数对动作的评估程度的方法。 通过与环境的交互,代理可以使用强化学习来学习一系列行动,这些行动可以通过探索性的试错法或深思熟虑的计划来最大化这种奖励。
强化学习的一个流行示例是国际象棋引擎。 在这里,座席根据棋盘的状态(环境)决定一系列动作,奖励可以定义为游戏结束时赢或输:

通过无监督学习发现隐藏结构
在监督学习中,当我们训练模型时,我们事先知道正确答案,在强化学习中,我们定义了奖励针对特定动作的度量 代理人。 但是,在无监督学习中,我们正在处理未标记的数据或未知结构的数据。 使用无监督学习技术,我们可以在没有已知结果变量或奖励函数指导的情况下,探索数据结构以提取有意义的信息。
通过聚类查找子组
聚类是一种探索性数据分析技术,它使我们可以将一堆信息组织成有意义的子组(集群),而无需事先知道它们的组成员身份。 分析期间可能出现的每个聚类定义一组对象,这些对象具有一定程度的相似性,但与其他聚类中的对象更为不同,这就是为什么聚类有时也称为“无监督分类”的原因。 聚类是一种用于构造信息并在数据之间派生有意义的关系的出色技术,例如,它允许营销人员根据他们的兴趣来发现客户组,以开发独特的营销程序。
下图说明了如何基于它们的特征x[1]和x[2]的相似性,将聚类应用于将未标记的数据分为三个不同的组:

降低数据压缩量
无监督学习的另一个子字段是降维。 通常,我们使用的是高维数据(每次观察都带有大量测量值),这可能对有限的存储空间和机器学习算法的计算性能提出了挑战。 无监督降维是特征预处理中从数据中去除噪声的一种常用方法,这还会降低某些算法的预测性能,并在保留大多数相关信息的同时将数据压缩到较小的维子空间中。
有时,降维对于可视化数据也很有用,例如,可以将高维特征集投影到一维,二维或三维特征空间上,以便通过 3D 或 2D 对其进行可视化 -散点图或直方图。 下图显示了一个示例,其中应用了非线性降维将 3D Swiss Roll 压缩到新的 2D 特征子空间上:


基本术语和符号介绍
现在,我们已经讨论了机器学习的三大类(监督学习,无监督学习和强化学习),下面让我们看一下将在下一章中使用的基本术语。 下表描述了 Iris 数据集的摘录,其中是机器学习领域中的经典示例。 鸢尾花数据集包含来自三个不同物种的 150 种鸢尾花的测量值:Setosa,Versicolor 和 Virginica。 在这里,每个花朵样本代表我们数据集中的一行,并且以厘米为单位的花朵测量值存储为列,我们也将其称为数据集的特征:

为了使标记和实现简单而有效,我们将使用线性代数的一些基础知识。 在以下各章中,我们将使用矩阵和向量表示法来引用我们的数据。 我们将遵循通用约定将每个样本表示为特征矩阵X中的单独行,其中每个特征都存储为单独的列。
然后,可以将由 150 个样本和 4 个要素组成的 Iris 数据集写为150 x 4矩阵x ∈ R^(150x4):

注意
在本模块的其余部分,我们将使用上标(i)指代i训练样本,下标j指代j训练数据集的维度。
我们分别使用小写的粗体字母表示向量x ∈ R^(nx1)和使用大写的粗体字母表示矩阵X ∈ R^(nxm)。 要引用向量或矩阵中的单个元素,我们用斜体写字母(分别为x^(n)或x^(m, n))。
例如,x^(150, 1)是指花朵样品 150 的第一维度,即萼片长度。 因此,该特征矩阵中的每一行都代表一个花朵实例,并且可以写为四维行向量x^(i) ∈ R(1x4)和x^(i) = [x^(i, 1), x^(i, 2), x^(i, 3), x^(i, 4)]。
每个要素维都是一个 150 维的列向量x[j] ∈ R(150x1),例如:

同样,我们将目标变量(此处为类标签)存储为 150 维列向量y = [y^(1), ..., y^(150)]^T, y = {setosa, versicolor, verginica}。
建立机器学习系统的路线图
在前面的中,我们讨论了机器学习的基本概念以及三种不同的学习类型。 在本节中,我们将讨论伴随学习算法的机器学习系统的其他重要部分。 下图显示了在预测模型中使用机器学习的典型工作流程图,我们将在以下小节中进行讨论:

预处理–使数据成形
原始数据很少以学习算法的最佳性能所必需的形式出现。 因此,数据的预处理是任何机器学习应用中最关键的步骤之一。 如果以上一节中的鸢尾花数据集为例,我们可以将原始数据视为一系列花图像,以从中提取有意义的特征。 有用的功能可能是颜色,色调,花朵的强度,高度以及花朵的长度和宽度。 许多机器学习算法还要求所选特征必须具有相同的比例才能获得最佳性能,这通常是通过将特征转换为[0,1]范围或均值和单位方差为零的标准正态分布来实现的,因为我们将 请参阅后面的章节。
某些选定的特征可能高度相关,因此在一定程度上是多余的。 在那些情况下,降维技术可用于将特征压缩到较低维子空间上。 减少特征空间的维数具有以下优点:需要较少的存储空间,并且学习算法可以运行得更快。
为了确定我们的机器学习算法是否不仅在训练集上表现良好,而且还可以很好地推广到新数据,我们还希望将数据集随机分为单独的训练和测试集。 我们使用训练集来训练和优化我们的机器学习模型,同时保留测试集直到最后评估最终模型。
训练和选择预测模型
正如我们将在后面的章节中看到的那样,已经开发了许多不同的机器学习算法来解决不同的问题任务。 可以从 David Wolpert 著名的没有免费午餐定理中总结出一个重要的观点,那就是我们不能“免费”学习(学习算法之间缺乏先验区分, DH Wolpert 1996;没有免费的午餐定理用于优化,DH Wolpert 和 WG Macready,1997)。 直观地讲,我们可以将这一概念与流行语联系起来:“如果您仅有的工具是锤子,我想这很诱人,就好像把它当作钉子一样对待”(Abraham Maslow,1966 年 )。 例如,每种分类算法都有其固有的偏差,如果不对任务进行任何假设,则没有哪个分类模型会具有优势。 因此,在实践中,有必要比较至少几种不同的算法,以训练和选择性能最佳的模型。 但是,在我们可以比较不同的模型之前,我们首先必须确定一个衡量绩效的指标。 一种常用的度量标准是分类准确性,它定义为正确分类的实例的比例。
要问的一个合法问题是:如果不将测试集用于模型选择,而将其保留用于最终模型评估,那么我们如何知道哪个模型在最终测试数据集和真实数据上表现良好? 为了解决此问题中嵌入的问题,可以使用不同的交叉验证技术,其中将训练数据集进一步分为训练和验证子集以估计[HTG5 模型的泛化性能。 最后,我们也不能期望软件库提供的不同学习算法的默认参数对于我们的特定问题任务是最佳的。 因此,我们将在以后的章节中频繁使用超参数优化技术,这些技术可帮助我们微调模型的性能。 直观地,我们可以将这些超参数视为不是从数据中学习到的参数,而是代表我们可以用来提高其性能的模型的旋钮,当我们看到实际示例时,这些参数将在后面的章节中变得更加清楚。
评估模型并预测看不见的数据实例
在我们选择了适合训练数据集的模型之后,我们可以使用测试数据集来估计它在看不见的数据上的表现如何,以估计泛化误差。 如果我们对其性能感到满意,我们现在可以使用此模型来预测新的未来数据。 很重要,请注意,先前提到的过程的参数(例如特征缩放和降维)仅从训练数据集中获取,并且稍后再次应用相同的参数来转换测试数据集 与任何新数据样本一样,否则测试数据上的性能可能会过分乐观。
使用 Python 进行机器学习
Python 是上最流行的数据科学编程语言之一,因此拥有由其强大社区开发的大量有用的附加库。
尽管对于诸如计算密集型任务,解释性语言(如 Python)的性能不如低级编程语言,但已开发了诸如 NumPy 和 SciPy 等扩展库。 它们在较低层的 Fortran 和 C 实现的基础上进行,以便在多维数组上进行快速和向量化操作。
对于机器学习编程任务,我们将主要参考 scikit-learn 库,它是迄今为止最受欢迎和可访问的开源机器学习库之一。




三十六、训练机器学习分类算法
在本章中,我们将使用第一种以算法描述的机器学习算法进行分类,即感知器和自适应线性神经元。 我们将首先在 Python 中逐步实现感知器,然后对其进行训练,以对 Iris 数据集中的不同花卉种类进行分类。 这将帮助我们理解用于分类的机器学习算法的概念,以及如何在 Python 中有效地实现它们。 然后,在第 3 章,“使用 scikit-learn 机器学习库”中,讨论使用自适应线性神经元进行优化的基础将为使用更强大的分类器奠定基础。
我们将在本章中介绍的主题如下:
- 建立机器学习算法的直觉
- 使用 pandas,NumPy 和 matplotlib 读取,处理和可视化数据
- 在 Python 中实现线性分类算法
人工神经元–简要了解机器学习的早期历史
在更详细地讨论感知器和相关算法之前,让我们简要介绍一下机器学习的早期知识。 为了理解生物大脑如何设计 HT,Warren McCullock 和 Walter Pitts 发表了第一个简化的脑细胞概念,即所谓的 McCullock-Pitts(MCP)神经元。 1943 年(WS McCulloch 和 W. Pitts。神经活动中固有的思想的逻辑演算。数学生物物理学通报,5(4):115–133,1943 年)。 神经元是大脑中相互连接的神经细胞,参与处理和化学和电信号的传输,如下图所示:

McCullock 和 Pitts 将这种神经细胞描述为具有二进制输出的简单逻辑门。 多个信号到达树突,然后整合到细胞体中,如果累积的信号超过某个阈值,则会生成输出信号,该信号将被轴突传递。
仅仅几年后,弗兰克·罗森布拉特(Frank Rosenblatt)就发布了基于 MCP 神经元模型的感知器学习规则的第一个概念(F. Rosenblatt,感知器,感知和识别自动机,康奈尔航空实验室,1957 年)。 Rosenblatt 运用他的感知器规则,提出了一种算法,该算法将自动学习最佳权重系数,然后将其与输入特征相乘,从而确定神经元是否触发。 在监督学习和分类的情况下,可以使用这种算法来预测样本是否属于一个类别或另一个类别。
更正式地讲,我们可以将此问题作为二进制分类任务提出,为简单起见,我们将两个类称为1(正类)和-1(负类)。 然后,我们可以定义激活函数φ(z),该函数采用某些输入值x和相应的权重向量w的线性组合,其中z是所谓的净输入(z = w[1]x[1] + ... + w[m]x[m]):

现在,如果特定样本x^(i)的激活(即φ(z)的输出)大于定义的阈值θ,则我们将预测 1 类和-1 类,否则。 在感知器算法中,激活函数φ(·)是简单的单位阶跃函数,有时也称为 Heaviside 阶跃函数:

为简单起见,我们可以将阈值θ带到方程式的左侧,并将权重零定义为w[0] = -θ和x[0] = 1,以便我们以更紧凑的形式写z和φ(z):

注意
在以下各节中,我们将经常使用线性代数的基本符号。 例如,我们将使用向量点积来缩写x和w中值的乘积之和,而上标T代表转置,该操作可将列向量转换为行向量,反之亦然:

例如:

此外,转置操作还可以应用于矩阵以在其对角线上反射它,例如:

在本书中,我们将仅使用线性代数的基本概念。 但是,如果需要快速复习,请查看 Zico Kolter 出色的《线性代数复习和参考》,该书可从这个页面免费获得。
下图说明了如何通过感知器的激活函数将净输入z = w^T x压缩为二进制输出(-1 或 1)(左子图),以及如何将其用于区分两个线性可分离的类 (右图):

MCP 神经元和 Rosenblatt 的阈值感知器模型背后的整个想法是使用还原论方法来模拟大脑中单个神经元的工作方式:触发或不触发。 因此,Rosenblatt 的初始感知器规则非常简单,可以通过以下步骤进行总结:
-
将权重初始化为 0 或较小的随机数。
-
对于每个训练样本
x^(i)执行以下步骤:- 计算输出值
y_hat。 - 更新权重。
- 计算输出值
这里,输出值是由我们前面定义的单位步长函数预测的类别标签,权重向量w中每个权重w[j]的同时更新可以更正式地写为:

用于更新权重w[j]的Δw[j]值由感知器学习规则计算得出:

其中η是学习率(介于 0.0 和 1.0 之间的常数),y^(i)是第i个训练样本的真实类别标签,而y_hat^(i)是预测的类别标签。 重要的是要注意,权重向量中的所有权重都同时更新,这意味着在更新所有权重Δw[j]之前,我们不会重新计算y_hat^(i)。 具体来说,对于 2D 数据集,我们将编写以下更新:



在用 Python 实现感知器规则之前,让我们进行一个简单的思想实验,以说明该学习规则实际上有多么简单。 在感知器正确预测类标签的两种情况下,权重保持不变:


但是,在预测错误的情况下,将权重分别推向正目标类或负目标类的方向:


为了更好地了解乘法因子x[j]^(i),让我们来看另一个简单的示例,其中:

假设x[j]^(i) = 0.5,我们将该样本错误分类为-1。 在这种情况下,我们将相应的权重增加 1,这样,下次遇到该样本时,激活x[j]^(i) × w[j]^(i)会更强,因此更有可能超过单位阶跃函数对样本进行分类的阈值 +1:

权重更新与x[j]^(i)的值成比例。 例如,如果我们有另一个样本x[j]^(i) = 2被错误地分类为-1,我们将更大程度地推动决策边界,以在下一次正确分类该样本:

重要的是,请注意,只有当两类线性可分离并且学习速率足够小时,才能保证感知器的收敛。 如果两个类别不能由线性决策边界分开,我们可以设置训练数据集的最大通过次数(历元)和/或可容忍的错误分类数量的阈值-感知器 否则永远不会停止更新权重:

提示
下载示例代码
该课程所有四个部分的代码文件都可以在这个页面上找到。
现在,在进入下一部分的实现之前,让我们在一个简单的图中总结一下我们刚刚学到的东西,该图说明了感知器的一般概念:

前面的图说明了感知器如何接收样本x的输入并将其与权重w组合以计算净输入。 然后,将净输入传递到激活函数(此处为单位阶跃函数),该函数生成二进制输出-1 或+1(样本的预测类别标签)。 在学习阶段,此输出用于计算预测误差并更新权重。
在 Python 中实现感知器学习算法
在的上一节中,我们了解了 Rosenblatt 的感知器规则如何工作; 现在让我们继续在 Python 中实现它,并将其应用于在第 1 章,“赋予计算机从数据中学习能力的虹膜数据集”中。 我们将采用面向对象的方法将感知器接口定义为 Python Class,这使我们可以初始化可以通过fit方法从数据中学习的新感知器对象,并通过单独的predict方法进行预测 。 按照惯例,我们在对象初始化时未创建的属性上添加下划线,而是通过调用对象的其他方法(例如self.w_)来创建。
注意
如果您还不熟悉,但还不熟悉 Python 的科学库或需要复习,请参阅以下资源:
另外,为了更好地和遵循代码示例,我建议您从 Packt 网站下载 IPython 笔记本。 有关 IPython 笔记本的一般介绍,请访问这个页面。
import numpy as np
class Perceptron(object):
"""Perceptron classifier.
Parameters
------------
eta : float
Learning rate (between 0.0 and 1.0)
n_iter : int
Passes over the training dataset.
Attributes
-----------
w_ : 1d-array
Weights after fitting.
errors_ : list
Number of misclassifications in every epoch.
"""
def __init__(self, eta=0.01, n_iter=10):
self.eta = eta
self.n_iter = n_iter
def fit(self, X, y):
"""Fit training data.
Parameters
----------
X : {array-like}, shape = [n_samples, n_features]
Training vectors, where n_samples
is the number of samples and
n_features is the number of features.
y : array-like, shape = [n_samples]
Target values.
Returns
-------
self : object
"""
self.w_ = np.zeros(1 + X.shape[1])
self.errors_ = []
for _ in range(self.n_iter):
errors = 0
for xi, target in zip(X, y):
update = self.eta * (target - self.predict(xi))
self.w_[1:] += update * xi
self.w_[0] += update
errors += int(update != 0.0)
self.errors_.append(errors)
return self
def net_input(self, X):
"""Calculate net input"""
return np.dot(X, self.w_[1:]) + self.w_[0]
def predict(self, X):
"""Return class label after unit step"""
return np.where(self.net_input(X) >= 0.0, 1, -1)
使用此感知器实现,我们现在可以以给定的学习率eta和n_iter来初始化新的Perceptron对象,该学习率是历元数(经过训练集)。 通过fit方法,我们将self.w_中的权重初始化为零向量R^(m + 1),其中m代表数据集中的维数(特征)数,其中我们为零权重添加 1(即 , 门槛)。
注意
一维数组的 NumPy 索引与使用方括号([])表示法的 Python 列表相似。 对于二维数组,第一个索引器引用行号,第二个索引器引用列号。 例如,我们将使用X[2, 3]选择 2D 数组X的第三行和第四列。
权重初始化后,fit方法循环遍历训练集中的所有单个样本,并根据我们在上一节中讨论的感知器学习规则更新权重。 类别标签是通过predict方法预测的,在fit方法中也称为预测重量更新的类别标签,但在我们使用predict预测新数据的类别标签后, 拟合了我们的模型。 此外,我们还在列表self.errors_中的每个时期收集了错误分类的数量,以便稍后我们可以分析感知器在训练过程中的表现。 net_input方法中使用的np.dot函数仅计算向量点积w^T x。
注意
代替使用 NumPy 通过a.dot(b)或np.dot(a, b)计算两个数组 a 和 b 之间的向量点积,我们还可以通过sum([i*j for i,j in zip(a, b)]在纯 Python 中执行计算。 但是,与传统的 Python for 循环结构相比,使用 NumPy 的优势在于其算术运算是向量化的。 向量化 表示元素算术运算自动应用于数组中的所有元素。 通过将算术运算表示为数组上的指令序列,而不是一次对每个元素执行一组运算,我们可以更好地利用具有单指令,多数据的现代 CPU 体系结构 (SIMD)支持。 此外,NumPy 使用高度优化的线性代数库,例如基本线性代数子程序(BLAS)和线性代数程序包(LAPACK) 用 C 或 Fortran 编写的代码。 最后,NumPy 还允许我们使用线性代数的基础,例如向量和矩阵点积,以更紧凑和直观的方式编写代码。
在虹膜数据集上训练感知器模型
为了测试我们的感知器实现,我们将从 Iris 数据集中加载两个花类 Setosa 和 Versicolor。 尽管感知器规则不限于二维,但出于可视化目的,我们仅考虑两个特征萼片长度和花瓣长度。 同样,出于实际原因,我们只选择了两种花类 Setosa 和 Versicolor。 但是,感知器算法可以扩展到多类分类,例如,通过一对全技术。
注意
一对全(OvA),或有时也称为一对一静态(OvR) 是用于将的二进制分类器扩展到多类问题的技术。 使用 OvA,我们可以为每个类别训练一个分类器,其中将特定类别视为阳性类别,而将所有其他类别的样本视为阴性类别。 如果要对新数据样本进行分类,则将使用n分类器,其中n是类别标签的数量,然后将具有最高置信度的类别标签分配给特定样本。 对于感知器,我们将使用 OvA 选择与最大绝对净输入值关联的类别标签。
首先,我们将使用Pandas库将[IRIS 数据集]直接从 UCI 机器学习存储库加载到DataFrame对象中,并通过[ tail方法来检查数据是否正确加载:
>>> import pandas as pd
>>> df = pd.read_csv('https://archive.ics.uci.edu/ml/'
... 'machine-learning-databases/iris/iris.data', header=None)
>>> df.tail()

接下来,我们分别提取对应于 50 朵鸢尾花和 50 朵鸢尾花花的前 100 个类别标签,并将类别标签转换为两个整数类别标签[ 我们分配给向量y的 HTG0]( Versicolor )和-1( Setosa ),其中 PandasDataFrame的值方法产生相应的 NumPy 表示形式。 同样,我们提取这 100 个训练样本的第一特征列(萼片长度)和第三特征列(花瓣长度),并将它们分配给特征矩阵X, 我们可以通过二维散点图进行可视化:
>>> import matplotlib.pyplot as plt
>>> import numpy as np
>>> y = df.iloc[0:100, 4].values
>>> y = np.where(y == 'Iris-setosa', -1, 1)
>>> X = df.iloc[0:100, [0, 2]].values
>>> plt.scatter(X[:50, 0], X[:50, 1],
... color='red', marker='o', label='setosa')
>>> plt.scatter(X[50:100, 0], X[50:100, 1],
... color='blue', marker='x', label='versicolor')
>>> plt.xlabel('sepal length')
>>> plt.ylabel('petal length')
>>> plt.legend(loc='upper left')
>>> plt.show()
执行前面的代码示例之后,我们现在应该看到以下散点图:

现在是时候在刚刚提取的 Iris 数据子集上训练感知器算法了。 此外,我们将为每个时期绘制misclassification error,以检查算法是否收敛,并找到将两个鸢尾花类分开的决策边界:
>>> ppn = Perceptron(eta=0.1, n_iter=10)
>>> ppn.fit(X, y)
>>> plt.plot(range(1, len(ppn.errors_) + 1), ppn.errors_,
... marker='o')
>>> plt.xlabel('Epochs')
>>> plt.ylabel('Number of misclassifications')
>>> plt.show()
执行完前面的代码后,我们应该看到错误分类错误与时期数的关系图,如下所示:

如上图所示,我们的感知器在第六个时期之后已经收敛,现在应该能够对训练样本进行完美分类。 让我们实现一个小的便利函数,以可视化 2D 数据集的决策边界:
from matplotlib.colors import ListedColormap
def plot_decision_regions(X, y, classifier, resolution=0.02):
# setup marker generator and color map
markers = ('s', 'x', 'o', '^', 'v')
colors = ('red', 'blue', 'lightgreen', 'gray', 'cyan')
cmap = ListedColormap(colors[:len(np.unique(y))])
# plot the decision surface
x1_min, x1_max = X[:, 0].min() - 1, X[:, 0].max() + 1
x2_min, x2_max = X[:, 1].min() - 1, X[:, 1].max() + 1
xx1, xx2 = np.meshgrid(np.arange(x1_min, x1_max, resolution),
np.arange(x2_min, x2_max, resolution))
Z = classifier.predict(np.array([xx1.ravel(), xx2.ravel()]).T)
Z = Z.reshape(xx1.shape)
plt.contourf(xx1, xx2, Z, alpha=0.4, cmap=cmap)
plt.xlim(xx1.min(), xx1.max())
plt.ylim(xx2.min(), xx2.max())
# plot class samples
for idx, cl in enumerate(np.unique(y)):
plt.scatter(x=X[y == cl, 0], y=X[y == cl, 1],
alpha=0.8, c=cmap(idx),
marker=markers[idx], label=cl)
首先,我们定义多个colors和markers,然后通过ListedColormap从颜色列表中创建一个颜色图。 然后,我们确定两个特征的最小值和最大值,并使用这些特征向量通过 NumPy meshgrid函数创建一对网格阵列xx1和xx2。 由于我们在两个特征维度上训练了感知器分类器,因此我们需要展平栅格阵列并创建一个矩阵,该矩阵具有与 Iris 训练子集相同的列数,以便我们可以使用predict方法来预测类标签[ Z的相应网格点。 在将预测的类别标签Z重塑为具有与xx1和xx2相同尺寸的网格后,我们现在可以通过 matplotlib 的contourf函数绘制等高线图,该函数将不同的决策区域映射到每种预测的不同颜色 网格数组中的类:
>>> plot_decision_regions(X, y, classifier=ppn)
>>> plt.xlabel('sepal length [cm]')
>>> plt.ylabel('petal length [cm]')
>>> plt.legend(loc='upper left')
>>> plt.show()
执行完前面的代码示例后,我们现在应该看到决策区域的图,如下图所示:

正如我们在中看到的那样,感知器学习了一个决策边界,该边界能够完美地对虹膜训练子集中的所有花朵样本进行分类。
注意
尽管感知器对两个鸢尾花类别进行了完美分类,但是收敛是感知器最大的问题之一。 弗兰克·罗森布拉特(Frank Rosenblatt)在数学上证明,如果可以通过线性超平面将这两类分开,则感知器学习规则会收敛。 但是,如果无法通过这样的线性决策边界将类完美地分开,则权重将永远不会停止更新,除非我们设置最大时期数。

自适应线性神经元与学习的收敛
在本节中,我们将看一下另一种类型的单层神经网络:自适应线性神经网络(Adaline)。 伯纳德·威德罗(Bernard Widrow)和他的博士生特德·霍夫(Tedd Hoff)在弗兰克·罗森布拉特(Frank Rosenblatt)的感知器算法发布仅几年后,就发布了 Adaline ,可以认为是对后者的改进(B. Widrow 等人,自适应“ Adaline 使用化学“介导剂”的神经元。编号技术报告 1553-2。StanfordElectron。Labs。Stanford,CA,1960 年 10 月)。 Adaline 算法特别有趣,因为它说明了定义和最小化成本函数的关键概念,这将为理解更高级的机器学习分类算法(例如逻辑回归和支持向量机以及我们所使用的回归模型)奠定基础 将在以后的章节中讨论。
Adaline 规则(也称为 Widrow-Hoff 规则)和 Rosenblatt 感知器之间的主要区别是权重是根据线性激活函数而不是像 感知器。 在 Adaline 中,此线性激活函数φ(z)只是网络输入的身份函数,因此φ(w^T x) = w^T x。
使用线性激活函数来学习权重时,类似于我们之前看到的单位步长函数的量化器可以用于预测类别标签,如下所示 数字:

如果将上图与之前看到的感知器算法的图示进行比较,则不同之处在于我们知道使用线性激活函数的连续值输出来计算模型误差并更新权重,而不是二进制类 标签。
通过梯度下降最小化成本函数
监督机器学习算法的关键要素之一是定义一个目标函数,该目标函数将在学习过程中进行优化。 这个目标函数通常是我们要最小化的成本函数。 对于 Adaline,我们可以定义成本函数J,以将权重定义为计算结果和真实类别标签之间的平方误差总和(SSE)。
。
为方便起见,仅添加了1/2一词; 如下面的段落所示,它将使推导梯度变得更加容易。 与单位步长函数相比,此连续线性激活函数的主要优点是成本函数变得可微。 此成本函数的另一个不错的特性是它是凸的。 因此,我们可以使用称为梯度下降的简单但功能强大的优化算法来找到权重,以最小化我们的成本函数来对 Iris 数据集中的样本进行分类。
如下图所示,我们可以将梯度下降的原理描述为爬下山直到达到局部或全局最低成本。 在每次迭代中,我们都从梯度上走了一步,其中步长由学习率的值以及梯度的斜率确定:

现在,通过使用梯度下降,我们可以通过远离成本函数J(w)的梯度∇J(w)来更新权重:

这里,权重变化Δw定义为负梯度乘以学习率η:

要计算成本函数的梯度,我们需要针对每个权重w[j]和:

计算成本函数的偏导数,以便将权重w[j]的更新写为:

由于我们同时更新所有权重,因此我们的 Adaline 学习规则变为w := w + Δw。
注意
对于那些熟悉微积分的人,可以通过以下方法获得相对于jth 权重的 SSE 成本函数的偏导数:






尽管 Adaline 学习规则看起来与感知器规则相同,但是z^(i) = w^T x^(i)的φ(z^(i))是实数,而不是整数类标签。 此外,权重更新是基于训练集中的所有样本计算的(而不是在每个样本之后递增地更新权重),这就是为什么此方法也称为“批量”梯度下降的原因。
在 Python 中实现自适应线性神经元
由于的感知器规则和 Adaline 非常相似,因此我们将采用我们先前定义的感知器实现,并更改fit方法,以便通过梯度下降最小化成本函数来更新权重:
class AdalineGD(object):
"""ADAptive LInear NEuron classifier.
Parameters
------------
eta : float
Learning rate (between 0.0 and 1.0)
n_iter : int
Passes over the training dataset.
Attributes
-----------
w_ : 1d-array
Weights after fitting.
errors_ : list
Number of misclassifications in every epoch.
"""
def __init__(self, eta=0.01, n_iter=50):
self.eta = eta
self.n_iter = n_iter
def fit(self, X, y):
""" Fit training data.
Parameters
----------
X : {array-like}, shape = [n_samples, n_features]
Training vectors,
where n_samples is the number of samples and
n_features is the number of features.
y : array-like, shape = [n_samples]
Target values.
Returns
-------
self : object
"""
self.w_ = np.zeros(1 + X.shape[1])
self.cost_ = []
for i in range(self.n_iter):
output = self.net_input(X)
errors = (y - output)
self.w_[1:] += self.eta * X.T.dot(errors)
self.w_[0] += self.eta * errors.sum()
cost = (errors**2).sum() / 2.0
self.cost_.append(cost)
return self
def net_input(self, X):
"""Calculate net input"""
return np.dot(X, self.w_[1:]) + self.w_[0]
def activation(self, X):
"""Compute linear activation"""
return self.net_input(X)
def predict(self, X):
"""Return class label after unit step"""
return np.where(self.activation(X) >= 0.0, 1, -1)
而不是像在感知器中那样在评估每个单独的训练样本之后更新权重,我们基于整个训练数据集通过零重量的self.eta * errors.sum()和权重 1 至m的self.eta * X.T.dot(errors)计算梯度。 X.T.dot(errors)是我们的特征矩阵与误差向量之间的矩阵向量乘法。 与先前的感知器实现类似,我们在列表self.cost_中收集成本值,以检查算法在训练后是否收敛。
注意
执行矩阵向量乘法类似于计算向量点积,其中矩阵中的每一行都被视为单行向量。 此向量化方法表示更紧凑的符号,并使用 NumPy 进行更有效的计算。 例如:

在实践中,通常需要进行一些实验才能找到良好的学习率η,以实现最佳收敛。 因此,让我们选择两种不同的学习率η = 0.1和η = 0.0001作为起点,并绘制成本函数与时期数的关系图,以了解 Adaline 实施从训练数据中学习得如何。
注意
学习率η以及时期数n_iter是感知器和 Adaline 学习算法的所谓超参数。 在第 4 章,“建立良好的训练集-数据预处理”中,我们将介绍不同的技术来自动查找产生分类模型最佳性能的不同超参数的值。
现在,让我们针对两种不同学习率的时间数绘制成本:
>>> fig, ax = plt.subplots(nrows=1, ncols=2, figsize=(8, 4))
>>> ada1 = AdalineGD(n_iter=10, eta=0.01).fit(X, y)
>>> ax[0].plot(range(1, len(ada1.cost_) + 1),
... np.log10(ada1.cost_), marker='o')
>>> ax[0].set_xlabel('Epochs')
>>> ax[0].set_ylabel('log(Sum-squared-error)')
>>> ax[0].set_title('Adaline - Learning rate 0.01')
>>> ada2 = AdalineGD(n_iter=10, eta=0.0001).fit(X, y)
>>> ax[1].plot(range(1, len(ada2.cost_) + 1),
... ada2.cost_, marker='o')
>>> ax[1].set_xlabel('Epochs')
>>> ax[1].set_ylabel('Sum-squared-error')
>>> ax[1].set_title('Adaline - Learning rate 0.0001')
>>> plt.show()
正如我们在中看到的,接下来生成的成本函数图所示,我们遇到了两种不同类型的问题。 左图显示了如果我们选择一个太大的学习率会发生什么—而不是最小化成本函数,在每个时期误差都会变得更大,因为我们超过了全局最小值:

尽管我们看到正确的绘图可以看到成本降低了,但是所选的学习速率η = 0.0001很小,以至于算法需要大量的时间才能收敛。 下图说明了如何更改特定权重参数的值以最小化成本函数J(左子图)。 右侧的子图说明了如果我们选择的学习率太大而超出全局最小值,则会发生什么情况:

我们将在本书中遇到的许多机器学习算法都需要某种特征缩放以实现最佳性能,我们将在第 3 章,“使用 Scikit 学习的机器学习分类器导论”中对此进行详细讨论。 。 梯度下降是受益于特征缩放的众多算法之一。 在这里,我们将使用称为标准化的特征缩放方法,该方法使我们的数据具有标准正态分布的属性。 每个特征的均值以 0 为中心,特征列的标准偏差为 1。例如,要标准化第j个特征,我们只需要从每个训练样本中减去样本均值μ[j]并除以 通过其标准偏差σ[j]:

此处,x[j]是由所有训练样本n的第j个特征值组成的向量。
使用 NumPy 方法mean和std可以轻松实现标准化:
>>> X_std = np.copy(X)
>>> X_std[:,0] = (X[:,0] - X[:,0].mean()) / X[:,0].std()
>>> X_std[:,1] = (X[:,1] - X[:,1].mean()) / X[:,1].std()
标准化之后,我们将再次训练 Adaline,并看到它现在以学习率η = 0.01收敛:
>>> ada = AdalineGD(n_iter=15, eta=0.01)
>>> ada.fit(X_std, y)
>>> plot_decision_regions(X_std, y, classifier=ada)
>>> plt.title('Adaline - Gradient Descent')
>>> plt.xlabel('sepal length [standardized]')
>>> plt.ylabel('petal length [standardized]')
>>> plt.legend(loc='upper left')
>>> plt.show()
>>> plt.plot(range(1, len(ada.cost_) + 1), ada.cost_, marker='o')
>>> plt.xlabel('Epochs')
>>> plt.ylabel('Sum-squared-error')
>>> plt.show()
执行完前面的代码后,我们应该看到决策区域图以及下降成本图,如下图所示:

正如我们在前面的图中所看到的,Adaline 现在在使用学习率η = 0.01训练了标准化特征之后收敛。 但是,请注意,即使所有样本均已正确分类,SSE 仍为非零。
大规模机器学习和随机梯度下降
在上一节中,我们学习了如何通过从整个训练集中计算出的梯度的相反方向上走一步来最小化成本函数。 这就是为什么这种方法有时也称为批次梯度下降的原因。 现在想象一下,我们有一个非常庞大的数据集,其中包含数百万个数据点,这在许多机器学习应用中并不罕见。 在这种情况下,运行批次梯度下降在计算上可能会非常昂贵,因为每次我们向全局最小值迈出一步时,我们都需要重新评估整个训练数据集。
批量梯度下降算法的一种流行替代方法是随机梯度下降,有时也称为迭代或在线梯度下降。 代替基于所有样本的累积误差之和来更新权重x^(i):

我们为每个训练样本逐步更新权重:

尽管可以将随机梯度下降视为梯度下降的近似值,但由于更频繁的权重更新,它通常更快地达到收敛。 由于每个梯度是基于单个训练示例计算的,因此误差表面的噪声比梯度下降的噪声大,这还具有以下优点:随机梯度下降可以更容易地逃脱浅层局部最小值。 为了通过随机梯度下降获得准确的结果,将数据随机显示是很重要的,这就是为什么我们要为每个时期改组训练集以防止周期。
注意
在随机梯度下降实现中,固定学习率η通常由随时间降低的自适应学习率代替,例如c1 / (n_iters + c2),其中c1和c2是常数。 请注意,随机梯度下降未达到全局最小值,而是一个非常接近全局最小值的区域。 通过使用自适应学习率,我们可以进一步退火到更好的全局最小值
随机梯度下降的另一个优点是我们可以将其用于在线学习。 在在线学习中,随着新训练数据的到来,我们的模型将得到即时训练。 如果我们要积累大量数据(例如,典型 Web 应用中的客户数据),这将特别有用。 如果存在存储空间问题,则通过在线学习,系统可以立即适应变化,并且在更新模型后可以丢弃训练数据。
注意
批量梯度下降与随机梯度下降之间的折衷是所谓的微型批量学习。 迷你批量学习可以理解为将批量梯度下降应用于训练数据的较小子集,例如一次 50 个样本。 相对于批次梯度下降的优势在于,由于更频繁的重量更新,通过小批量可以更快地达到收敛。 此外,小批量学习允许我们通过向量化运算来替换随机梯度下降(SGD)中训练样本的 for 循环,这可以进一步提高我们的计算效率 学习算法。
由于我们已经使用梯度下降实现了 Adaline 学习规则,因此我们仅需进行一些调整即可修改学习算法,以通过随机梯度下降来更新权重。 在fit方法中,我们现在将在每个训练样本之后更新权重。 此外,我们将实现在线学习的附加partial_fit方法,该方法不会重新初始化权重。 为了检查训练后算法是否收敛,我们将成本计算为每个时期训练样本的平均成本。 此外,我们将在每个时期之前向shuffle训练数据添加一个选项,以避免在优化成本函数时出现周期; 通过random_state参数,我们可以指定随机种子的一致性:
from numpy.random import seed
class AdalineSGD(object):
"""ADAptive LInear NEuron classifier.
Parameters
------------
eta : float
Learning rate (between 0.0 and 1.0)
n_iter : int
Passes over the training dataset.
Attributes
-----------
w_ : 1d-array
Weights after fitting.
errors_ : list
Number of misclassifications in every epoch.
shuffle : bool (default: True)
Shuffles training data every epoch
if True to prevent cycles.
random_state : int (default: None)
Set random state for shuffling
and initializing the weights.
"""
def __init__(self, eta=0.01, n_iter=10,
shuffle=True, random_state=None):
self.eta = eta
self.n_iter = n_iter
self.w_initialized = False
self.shuffle = shuffle
if random_state:
seed(random_state)
def fit(self, X, y):
""" Fit training data.
Parameters
----------
X : {array-like}, shape = [n_samples, n_features]
Training vectors, where n_samples
is the number of samples and
n_features is the number of features.
y : array-like, shape = [n_samples]
Target values.
Returns
-------
self : object
"""
self._initialize_weights(X.shape[1])
self.cost_ = []
for i in range(self.n_iter):
if self.shuffle:
X, y = self._shuffle(X, y)
cost = []
for xi, target in zip(X, y):
cost.append(self._update_weights(xi, target))
avg_cost = sum(cost)/len(y)
self.cost_.append(avg_cost)
return self
def partial_fit(self, X, y):
"""Fit training data without reinitializing the weights"""
if not self.w_initialized:
self._initialize_weights(X.shape[1])
if y.ravel().shape[0] > 1:
for xi, target in zip(X, y):
self._update_weights(xi, target)
else:
self._update_weights(X, y)
return self
def _shuffle(self, X, y):
"""Shuffle training data"""
r = np.random.permutation(len(y))
return X[r], y[r]
def _initialize_weights(self, m):
"""Initialize weights to zeros"""
self.w_ = np.zeros(1 + m)
self.w_initialized = True
def _update_weights(self, xi, target):
"""Apply Adaline learning rule to update the weights"""
output = self.net_input(xi)
error = (target - output)
self.w_[1:] += self.eta * xi.dot(error)
self.w_[0] += self.eta * error
cost = 0.5 * error**2
return cost
def net_input(self, X):
"""Calculate net input"""
return np.dot(X, self.w_[1:]) + self.w_[0]
def activation(self, X):
"""Compute linear activation"""
return self.net_input(X)
def predict(self, X):
"""Return class label after unit step"""
return np.where(self.activation(X) >= 0.0, 1, -1)
我们现在在AdalineSGD分类器中使用的_shuffle方法的工作方式如下:通过numpy.random中的permutation函数,我们生成一个随机数的唯一数字,范围为 0 至 100.这些数字然后可以用作索引,以改组我们的特征矩阵和类标签向量。
然后,我们可以使用 fit 方法来训练AdalineSGD分类器,并使用我们的plot_decision_regions绘制训练结果:
>>> ada = AdalineSGD(n_iter=15, eta=0.01, random_state=1)
>>> ada.fit(X_std, y)
>>> plot_decision_regions(X_std, y, classifier=ada)
>>> plt.title('Adaline - Stochastic Gradient Descent')
>>> plt.xlabel('sepal length [standardized]')
>>> plt.ylabel('petal length [standardized]')
>>> plt.legend(loc='upper left')
>>> plt.show()
>>> plt.plot(range(1, len(ada.cost_) + 1), ada.cost_, marker='o')
>>> plt.xlabel('Epochs')
>>> plt.ylabel('Average Cost')
>>> plt.show()
下图显示了我们通过执行前面的代码示例获得的的两个图:

如我们所见,的平均成本下降得很快,而且 15 个纪元后的最终决策边界看起来与 Adaline 的批次梯度下降相似。 如果要更新模型(例如,在具有流数据的在线学习场景中),我们可以简单地对单个样本(例如ada.partial_fit(X_std[0, :], y[0]))调用partial_fit方法。



三十七、scikit-learn 机器学习分类器之旅
在本章中,我们将浏览学术界和行业中常用的流行且功能强大的机器学习算法。 在了解几种分类监督学习算法之间的差异的同时,我们还将对它们各自的优缺点进行直观的了解。 此外,我们将通过 scikit-learn 库迈出第一步,该库提供了一个用户友好的界面,可有效,高效地使用这些算法。
我们将在本章中学习的主题如下:
- 流行分类算法概念介绍
- 使用 scikit-learn 机器学习库
- 选择机器学习算法时要问的问题
选择分类算法
为特定问题任务选择合适的分类算法需要实践:每种算法都有自己的怪癖,并基于某些假设。 重申“无免费午餐”定理:在所有可能的情况下,没有一个分类器最有效。 在实践中,始终建议您比较至少几种不同学习算法的性能,以针对特定问题选择最佳模型。 这些可能在特征或样本的数量,数据集中的噪声量以及类别是否可线性分离方面有所不同。
最终,分类器的性能,计算能力以及预测能力在很大程度上取决于可用于学习的基础数据。 训练机器学习算法所涉及的五个主要步骤可以总结如下:
- 功能选择。
- 选择性能指标。
- 选择分类器和优化算法。
- 评估模型的性能。
- 调整算法。
由于本书的方法是逐步建立机器学习知识,因此在本章中,我们将主要关注不同算法的主要概念,并重新讨论诸如功能选择和预处理,性能指标以及超参数调整之类的主题。 有关本书后面的更详细的讨论。
scikit-learn 的第一步
在第 2 章和“训练机器学习分类算法”中,您了解了两种相关的学习分类算法:感知器 规则和[ Adaline,我们自己在 Python 中实现。 现在我们来看看 scikit-learn API,它结合了用户友好的界面和高度优化的几种分类算法的实现。 但是,scikit-learn 库不仅提供了多种学习算法,还提供了许多方便的功能来预处理数据以及微调和评估我们的模型。 我们将在第 4 章,“建立良好的训练集–数据预处理”和第 5 章,“通过降维压缩数据”中更详细地讨论这些基本概念。
通过 scikit-learn 训练感知器
为了开始使用 scikit-learn 库,我们将训练一个感知器模型,该模型类似于在第 2 章,“训练分类机器学习算法”中实现的模型。 为简单起见,在以下各节中,我们将使用已经熟悉的 Iris 数据集。 方便地,Iris 数据集已经可以通过 scikit-learn 获得,因为它是一个简单而流行的数据集,通常用于测试和试验算法。 另外,我们仅将鸢尾花数据集中的两个功能用于可视化目的。
我们将 150 个花朵样本的花瓣长度 和花瓣宽度 分配给特征矩阵X和花朵种类的相应类别标签 向量y:
>>> from sklearn import datasets
>>> import numpy as np
>>> iris = datasets.load_iris()
>>> X = iris.data[:, [2, 3]]
>>> y = iris.target
如果执行np.unique(y)返回存储在iris.target中的不同类别标签,则会看到鸢尾花的类别名称,鸢尾花-Setosa,鸢尾花和 Iris-Virginica 已作为存储为整数(0,1,2),为许多机器学习库的最佳性能而推荐使用。
为了评估训练好的模型在看不见的数据上的效果,我们将进一步将数据集拆分为单独的训练和测试数据集。 在第 5 章和“通过降维压缩”压缩数据中,我们将更详细地讨论围绕模型评估的最佳实践:
>>> from sklearn.cross_validation import train_test_split
>>> X_train, X_test, y_train, y_test = train_test_split(
... X, y, test_size=0.3, random_state=0)
使用 scikit-learn 的cross_validation模块中的train_test_split函数,我们将X和y阵列随机分为 30%的测试数据(45 个样本)和 70%的训练数据(105 个样本)。
正如我们从第 2 章,“训练分类的机器学习算法”中的梯度下降示例中所记得的那样,许多机器学习和优化算法也需要特征缩放以实现最佳性能。在这里,我们将使用 scikit-learn 的preprocessing模块中的StandardScaler类对功能进行标准化:
>>> from sklearn.preprocessing import StandardScaler
>>> sc = StandardScaler()
>>> sc.fit(X_train)
>>> X_train_std = sc.transform(X_train)
>>> X_test_std = sc.transform(X_test)
使用前面的代码,我们从预处理模块中加载了StandardScaler类,并初始化了一个新的StandardScaler对象,该对象已分配给变量sc。 StandardScaler使用fit方法,从训练数据中估算出每个特征维度的参数μ(样本平均值)和σ(标准偏差)。 通过调用transform方法,我们然后使用那些估计的参数μ和σ标准化训练数据。 请注意,我们使用相同的缩放参数来标准化测试集,以使训练数据集和测试数据集中的值彼此可比。
标准化训练数据后,我们现在可以训练感知器模型。 默认情况下,scikit-learn 中的大多数算法已通过单项相对于其余(OvR)方法支持多类分类,这使我们可以将三个花类馈入 感知器一次全部。 代码如下:
>>> from sklearn.linear_model import Perceptron
>>> ppn = Perceptron(n_iter=40, eta0=0.1, random_state=0)
>>> ppn.fit(X_train_std, y_train)
scikit-learn 接口使我们想起第 2 章,“训练机器学习算法”的感知器实现:从linear_model模块加载Perceptron类后 ,我们初始化了一个新的Perceptron对象,并通过fit方法训练了模型。 在这里,模型参数eta0等于我们在自己的感知器实现中使用的学习率eta,参数n_iter定义了历元数(经过训练集)。 我们从第 2 章和“分类训练机器学习算法”记得,找到合适的学习率需要做一些实验。 如果学习率太大,该算法将超出全局最小成本。 如果学习速率太小,则该算法需要更多的时间才能收敛,这可能会使学习变慢,尤其是对于大型数据集。 同样,我们使用random_state参数来实现每个时期后训练数据集的初始改组的可重复性。
在 scikit-learn 中训练了模型之后,我们可以通过predict方法进行预测,就像在第 2 章,“训练分类机器学习算法”中我们自己的感知器实现中一样。 代码如下:
>>> y_pred = ppn.predict(X_test_std)
>>> print('Misclassified samples: %d' % (y_test != y_pred).sum())
Misclassified samples: 4
在执行前面的代码时,我们看到感知器对 45 个花样本中的 4 个进行了错误分类。 因此,测试数据集上的错误分类错误为 0.089 或 8.9%(4/45 ≈ 0.089)。
注意
代替错误分类错误,许多机器学习从业者报告了模型的分类准确性,其计算方法如下:
1-错误分类错误 = 0.911 或 91.1%。
Scikit-learn 还实现了可通过metrics模块获得的各种不同的性能指标。 例如,我们可以如下计算感知器在测试集上的分类精度:
>>> from sklearn.metrics import accuracy_score
>>> print('Accuracy: %.2f' % accuracy_score(y_test, y_pred))
0.91
这里,y_test是真实类别标签,y_pred是我们之前预测的类别标签。
注意
请注意,我们根据本章中的测试集评估模型的性能。 在第 5 章,“通过降维压缩数据”中,您将学习有用的技术,包括图形分析(例如学习曲线),以检测和防止过拟合。 过度拟合意味着该模型很好地捕获了训练数据中的模式,但是无法很好地概括为看不见的数据。
最后,我们可以使用第 2 章和“分类训练机器学习算法”的plot_decision_regions函数,绘制新的决策区域的图 训练好的感知器模型,并可视化其如何分离不同的花朵样本。 但是,我们添加一个小的修改以通过小圆圈突出显示来自测试数据集的样本:
from matplotlib.colors import ListedColormap
import matplotlib.pyplot as plt
def plot_decision_regions(X, y, classifier,
test_idx=None, resolution=0.02):
# setup marker generator and color map
markers = ('s', 'x', 'o', '^', 'v')
colors = ('red', 'blue', 'lightgreen', 'gray', 'cyan')
cmap = ListedColormap(colors[:len(np.unique(y))])
# plot the decision surface
x1_min, x1_max = X[:, 0].min() - 1, X[:, 0].max() + 1
x2_min, x2_max = X[:, 1].min() - 1, X[:, 1].max() + 1
xx1, xx2 = np.meshgrid(np.arange(x1_min, x1_max, resolution),
np.arange(x2_min, x2_max, resolution))
Z = classifier.predict(np.array([xx1.ravel(), xx2.ravel()]).T)
Z = Z.reshape(xx1.shape)
plt.contourf(xx1, xx2, Z, alpha=0.4, cmap=cmap)
plt.xlim(xx1.min(), xx1.max())
plt.ylim(xx2.min(), xx2.max())
# plot all samples
for idx, cl in enumerate(np.unique(y)):
plt.scatter(x=X[y == cl, 0], y=X[y == cl, 1],
alpha=0.8, c=cmap(idx),
marker=markers[idx], label=cl)
# highlight test samples
if test_idx:
X_test, y_test = X[test_idx, :], y[test_idx]
plt.scatter(X_test[:, 0], X_test[:, 1], c='',
alpha=1.0, linewidths=1, marker='o',
s=55, label='test set')
通过对plot_decision_regions函数进行的轻微修改(在前面的代码中已突出显示),我们现在可以指定要在结果图上标记的样本的索引。 代码如下:
>>> X_combined_std = np.vstack((X_train_std, X_test_std))
>>> y_combined = np.hstack((y_train, y_test))
>>> plot_decision_regions(X=X_combined_std,
... y=y_combined,
... classifier=ppn,
... test_idx=range(105,150))
>>> plt.xlabel('petal length [standardized]')
>>> plt.ylabel('petal width [standardized]')
>>> plt.legend(loc='upper left')
>>> plt.show()
正如我们在结果图中看到的那样,三个花类不能通过线性决策边界完美地分开:

我们从第 2 章和“训练机器学习分类算法”的讨论中还记得,感知器算法从未收敛于不能完全线性分离的数据集,这就是为什么使用 实践中通常不建议使用感知器算法。 在以下各节中,我们将介绍功能更强大的线性分类器,即使这些类不是完全线性可分离的,它们也会收敛到最低成本。
注意
Perceptron以及其他 scikit-learn 函数和类具有其他参数,为清楚起见,我们将其省略。 您可以使用 Python 中的help函数(例如help(Perceptron))或阅读出色的 scikit-learn 在线文档,以了解有关这些参数的更多信息。

通过逻辑回归建模类概率
尽管感知器规则为机器学习算法分类提供了很好而又轻松的介绍,但其最大的缺点是,如果类不是完全线性可分离的,则它永远不会收敛。 上一节中的分类任务将是这种情况的一个示例。 凭直觉,我们可以认为原因是权重不断更新,因为每个时期始终至少存在一个错误分类的样本。 当然,您可以更改学习率并增加时期数,但要注意,感知器将永远不会收敛于该数据集。 为了更好地利用我们的时间,我们现在来看看另一种简单的功能更强大的线性和二进制分类问题算法:Logistic 回归。 请注意,尽管逻辑回归的名称如此,但它是分类的模型,而不是回归的模型。
Logistic 回归直觉和条件概率
Logistic 回归是一种分类模型,非常易于实现,但在线性可分离类上的表现很好。 它是工业上最广泛使用的分类算法之一。 与感知器和 Adaline 相似,本章中的逻辑回归模型也是用于二分类的线性模型,可以通过 OvR 技术将其扩展到多分类。
为了解释逻辑回归作为概率模型的思想,让我们首先介绍几率,这是支持特定事件的几率。 优势比可以写为p / (1 - p),其中p代表阳性事件的概率。 术语阳性事件不一定表示好,而是指我们要预测的事件,例如,患者患有某种疾病的概率; 我们可以将积极的事件视为类标签y = 1。 然后,我们可以进一步定义 logit 函数,它只是比值比(log-odds)的对数:

logit 函数采用 0 到 1 范围内的输入值,并将它们转换为整个实数范围内的值,我们可以用来表达特征值和对数奇数之间的线性关系:

在此,p(y = 1 | x)是特定样本由于其特征x而属于类别 1 的条件概率。
现在,我们真正感兴趣的是预测某个样本属于特定类别的概率,这是 logit 函数的逆形式。 它也被称为逻辑函数,由于其特征 S 形,有时有时缩写为 Sigmoid 函数。

此处,z是净输入,即权重和样本特征的线性组合,可以计算为z = w^T x = w[0] + w[1]x[1] + ... + w[m]x[m]。
现在,让我们简单地绘制 sigmoid 函数以得到-7 到 7 范围内的某些值,以查看其外观:
>>> import matplotlib.pyplot as plt
>>> import numpy as np
>>> def sigmoid(z):
... return 1.0 / (1.0 + np.exp(-z))
>>> z = np.arange(-7, 7, 0.1)
>>> phi_z = sigmoid(z)
>>> plt.plot(z, phi_z)
>>> plt.axvline(0.0, color='k')
>>> plt.axhspan(0.0, 1.0, facecolor='1.0', alpha=1.0, ls='dotted')
>>> plt.axhline(y=0.5, ls='dotted', color='k')
>>> plt.yticks([0.0, 0.5, 1.0])
>>> plt.ylim(-0.1, 1.1)
>>> plt.xlabel('z')
>>> plt.ylabel('$\phi (z)$')
>>> plt.show()
由于执行了前面的代码示例,现在我们应该看到 S 形(S 型)曲线:

我们可以看到,如果z趋于无穷大(z -> +∞),则φ(z)接近 1,因为对于z的较大值,exp(-z)变得非常小。 类似地,由于分母越来越大,z -> -∞的φ(z)趋于 0。 因此,我们得出的结论是,此 S 型函数将实数值作为输入并将其转换为[0, 1]范围内的值,并在φ(z) = 0.5处进行截距。
要为逻辑回归模型建立一些直觉,我们可以将其与我们先前在第 2 章,“训练机器学习分类算法”中的 Adaline 实现相关。 在 Adaline 中,我们使用身份函数φ(z) = z作为激活函数。 在逻辑回归中,此激活函数简单地变成了我们前面定义的 S 型函数,如下图所示:

然后,将 S 型函数的输出解释为属于类别 1 φ(z) = p(y = 1 | x; w)的特定样本的概率,因为其特征x由权重w参数化。 例如,如果我们为特定的花朵样本计算φ(z) = 0.8,则意味着该样本是鸢尾花-杂色花朵的机会是 80%。 同样,此花是鸢尾-Setosa 花的概率可以计算为p(y = 0 | x; w) = 1 - p(y = 1 | x; w) = 0.2或 20%。 然后,可以通过量化器(单位步长函数)将预测的概率简单地转换为二进制结果:

如果我们查看前面的 S 型曲线,则等效于以下内容:

实际上,在许多应用中,我们不仅对预测的类别标签感兴趣,而且在估计类别成员资格的概率方面特别有用。 Logistic 回归用于天气预报,例如,不仅可以预测特定日子是否下雨,还可以报告下雨的机会。 类似地,逻辑回归可以用于预测患者具有某些症状的特定疾病的机会,这就是为什么逻辑回归在医学领域享有广泛欢迎的原因。
学习物流成本函数的权重
您学习了,如何使用逻辑回归模型预测概率和类别标签。 现在,让我们简要讨论一下模型的参数,例如权重w。 在上一章中,我们定义了平方和误差成本函数:

为了学习 Adaline 分类模型的权重w,我们将其最小化。 为了解释如何得出逻辑回归的成本函数,我们首先定义在构建逻辑回归模型时要最大化的L的可能性,假设数据集中的各个样本为 彼此独立。 计算公式如下:

在实践中,更容易最大化此方程的(自然)对数,这称为对数似然函数:

首先,应用对数函数可减少出现数字下溢的可能性,如果可能性很小,则可能发生这种情况。 其次,我们可以将因子的乘积转换为因子的总和,这可以使您更容易通过加法获得此函数的导数,正如您可能从微积分中还记得的那样。
现在,我们可以使用诸如梯度上升之类的优化算法来最大化此对数似然函数。 或者,让我们将对数似然重写为成本函数J,可以使用梯度下降来将其最小化,如第 2 章和“训练机器学习分类算法”一样:

为了更好地了解这个成本函数,让我们看一下我们为一个单样本实例计算的成本:

查看前面的公式,我们可以看到,如果y = 0,则第一项变为零;如果y = 1,则第二项变为零:

下图显示了φ(z)不同值的单样本实例分类的成本:

如果我们正确地预测样本属于类别 1,我们可以看到成本接近 0(纯蓝色线)。类似地,我们可以在y轴上看到,如果我们正确预测y = 0(虚线)。 但是,如果预测错误,则成本将达到无穷大。 道德是我们会以越来越大的代价惩罚错误的预测。
使用 scikit-learn 训练逻辑回归模型
如果我们自己实现逻辑回归,则可以简单地将成本函数J替换为第 2 章,“训练机器学习分类算法”的 Adaline 实现, 新的成本函数:

这将计算出每个时期对所有训练样本进行分类的成本,最终我们将得到一个有效的逻辑回归模型。 但是,由于 scikit-learn 实现了高度逻辑优化的 Logistic 回归版本,该版本还支持现成的多类设置,因此我们将跳过该实现并使用sklearn.linear_model.LogisticRegression类以及熟悉的fit方法来训练模型 在标准化的花卉训练数据集上:
>>> from sklearn.linear_model import LogisticRegression
>>> lr = LogisticRegression(C=1000.0, random_state=0)
>>> lr.fit(X_train_std, y_train)
>>> plot_decision_regions(X_combined_std,
... y_combined, classifier=lr,
... test_idx=range(105,150))
>>> plt.xlabel('petal length [standardized]')
>>> plt.ylabel('petal width [standardized]')
>>> plt.legend(loc='upper left')
>>> plt.show()
将模型拟合到训练数据上之后,我们绘制了决策区域,训练样本和测试样本,如下所示:

看一下我们用来训练LogisticRegression模型的先前代码,您现在可能会想:“这个神秘的参数C是什么?” 我们将在一秒钟内解决这个问题,但让我们先在下一部分中简要讨论过度拟合和正则化的概念。
此外,我们可以通过predict_proba方法预测样本的类成员概率。 例如,我们可以预测第一个 Iris-Setosa 样本的概率:
>>> lr.predict_proba(X_test_std[0,:])
这将返回以下数组:
array([[ 0.000, 0.063, 0.937]])
前面的数组告诉我们,该模型预测样本属于 Iris-Virginica 类的机会为 93.7%,样本为 Iris-Versicolor 花的机会为 6.3%。
我们可以证明,通过梯度下降进行逻辑回归的权重更新确实等于在第 2 章和“训练机器学习分类算法”中的 Adaline 中使用的方程式。 让我们开始计算对数j权重的对数似然函数的偏导数:

在继续之前,让我们首先计算 S 型函数的偏导数:

现在,我们可以用我们的第一个等式替换∂φ(z)/∂z = φ(z)(1 - φ(z)),以获得以下信息:

请记住,目标是找到使对数可能性最大化的权重,以便我们对每个权重执行如下更新:

由于我们同时更新所有权重,因此我们可以编写以下通用更新规则:

我们定义Δw如下:

由于最大化对数可能性等于最小化我们先前定义的成本函数J,因此我们可以编写如下的梯度下降更新规则:


这等于,与第 2 章,“分类训练机器学习算法”中的 Adaline 中的梯度下降规则相同。
通过正则化处理过拟合
过度拟合是在机器学习中的一个常见问题,在该模型中,模型在训练数据上表现良好,但对于未见数据(测试数据)却不能很好地泛化。 如果模型遭受过度拟合,我们也可以说该模型具有很高的方差,这可能是由于参数过多导致给定基础数据的模型过于复杂所致。 同样,我们的模型也可能会遇到拟合不足(高偏差)的问题,这意味着我们的模型不够复杂,无法很好地捕获训练数据中的模式,因此在看不见的情况下也会表现出较低的性能 数据。
尽管到目前为止,我们仅遇到用于分类的线性模型,但是可以通过使用更复杂的非线性决策边界来最好地说明过度拟合和欠拟合的问题,如下图所示:

注意
如果我们要在训练数据集的不同子集上多次重新训练模型,则方差度量特定样本实例的模型预测的一致性(或可变性)。 我们可以说该模型对训练数据中的随机性敏感。 相反,如果我们在不同的训练数据集上多次重建模型,则偏差通常会衡量预测与正确值的差距。 偏差是系统误差的度量,该误差不是由于随机性引起的。
寻求良好偏差偏差权衡的一种方法是通过正则化调整模型的复杂性。 正则化是处理共线性(要素之间的高度相关性),从数据中滤除噪声并最终防止过度拟合的非常有用的方法。 正则化背后的概念是引入附加信息(偏差)以惩罚极端参数权重。 最常见的正则化形式是所谓的 L2 正则化(有时也称为 L2 收缩或权重衰减),其写法如下:

在此,λ是所谓的正则化参数。
注意
正则化是为什么诸如标准之类的特征缩放很重要的另一个原因。 为了使正则化正常工作,我们需要确保我们所有的功能都具有可比的规模。
为了应用正则化,我们只需要将正则化项添加到为逻辑回归定义的成本函数中即可缩小权重:

通过正则化参数λ,我们可以控制拟合数据的拟合程度,同时保持较小的权重。 通过增加λ的值,我们增加了正则化强度。
在 scikit-learn 中为LogisticRegression类实现的参数C来自支持向量机中的约定,这将是下一部分的主题。 C与正则化参数λ直接相关,它是反函数:

因此,我们可以如下重写逻辑回归的正则化成本函数:

因此,减小逆正则化参数C的值意味着我们正在增加正则化强度,这可以通过绘制两个权重系数的 L2 正则化路径来可视化:
>>> weights, params = [], []
>>> for c in np.arange(-5, 5):
... lr = LogisticRegression(C=10**c, random_state=0)
... lr.fit(X_train_std, y_train)
... weights.append(lr.coef_[1])
... params.append(10**c)
>>> weights = np.array(weights)
>>> plt.plot(params, weights[:, 0],
... label='petal length')
>>> plt.plot(params, weights[:, 1], linestyle='--',
... label='petal width')
>>> plt.ylabel('weight coefficient')
>>> plt.xlabel('C')
>>> plt.legend(loc='upper left')
>>> plt.xscale('log')
>>> plt.show()
通过执行上述代码,我们为十个逻辑逆模型拟合了反正则化参数C的不同值。 为了说明的目的,我们仅收集了第 2 类与所有分类器的权重系数。 请记住,我们使用 OvR 技术进行多类分类。
正如我们在结果图中看到的所示,如果我们减小参数C,即增加正则化强度,则权重系数会减小:

注意
由于对单个分类算法的深入介绍超出了本书的范围,因此我热烈推荐 Scott Menard 博士的 Logistic 回归:从入门到高级概念和应用,Sage 出版物,希望了解更多关于逻辑回归的读者。

支持向量机的最大边距分类
另一种强大且广泛使用的学习算法是支持向量机(SVM),可以将其视为感知器的扩展。 使用感知器算法,我们将错误分类错误最小化。 但是,在 SVM 中,我们的优化目标是使余量最大化。 余量定义为分离的超平面(决策边界)与最接近该超平面的训练样本之间的距离,即所谓的支持向量。 下图对此进行了说明:

最大直觉
具有较大边界的决策边界背后的理由是,它们倾向于具有较低的泛化误差,而具有较小边界的模型更倾向于过度拟合。 为了直观了解裕量最大化,让我们仔细看一下平行于决策边界的那些正和负超平面,它们可以表示为:


如果我们将这两个线性方程式(1)和(2)彼此相减,则会得到:

我们可以通过向量w的长度对其进行归一化,其定义如下:

因此,我们得出以下等式:

然后,前面等式的左侧可以解释为正超平面和负超平面之间的距离,这是我们想要最大化的所谓余量。
现在,在样本被正确分类的约束下,通过最大化2 / ||w||,SVM 的目标函数变为了该裕度的最大化,可以写成如下:


这两个等式基本上说所有负样本都应该落在负超平面的一侧,而所有正样本都应该落在正超平面的后面。 也可以更紧凑地编写如下:

但是,实际上,更容易将倒数项1/2 ||w||²最小化,这可以通过二次编程来解决。 但是,关于二次编程的详细讨论超出了本书的范围,但是,如果您有兴趣,可以在 Vladimir Vapnik 的中了解有关支持向量机(SVM)的更多信息。 ]统计学习理论,Springer Science &商业媒体或 Chris JC Burges 在模式识别支持向量机教程中的出色解释(Data 挖掘和知识发现,2(2):121-167,1998 年)。
使用松弛变量处理非线性可分情况
尽管我们不想深入探讨裕度分类背后更复杂的数学概念,但让我们简要地提及松弛变量ξ。 它是由弗拉基米尔·瓦普尼克(Vladimir Vapnik)在 1995 年提出的,并导致了所谓的软边际分类。 引入松弛变量ξ的动机是,对于非线性可分离的数据,需要放宽线性约束,以在存在错误分类的情况下,在适当的成本惩罚下允许优化的收敛。
正值松弛变量仅添加到线性约束中:


因此,要最小化的新目标(受前面的约束)变为:

使用变量C,我们可以控制错误分类的代价。 较大的C值对应较大的错误惩罚,而如果我们为C选择较小的值,则对误分类错误的要求就不那么严格。 然后,我们可以使用参数C来控制边距的宽度,从而调整偏差方差的权衡,如下图所示:

这个概念与正则化相关,我们先前在正则回归的背景下讨论过,其中增加C的值会增加偏差并降低模型的方差。
现在我们学习了线性 SVM 的基本概念,让我们训练一个 SVM 模型来对 Iris 数据集中的不同花朵进行分类:
>>> from sklearn.svm import SVC
>>> svm = SVC(kernel='linear', C=1.0, random_state=0)
>>> svm.fit(X_train_std, y_train)
>>> plot_decision_regions(X_combined_std,
... y_combined, classifier=svm,
... test_idx=range(105,150))
>>> plt.xlabel('petal length [standardized]')
>>> plt.ylabel('petal width [standardized]')
>>> plt.legend(loc='upper left')
>>> plt.show()
下图显示了执行前面的代码示例后可视化的 SVM 的决策区域:

注意
Logistic 回归与 SVM
在实际的分类任务中,线性逻辑回归和线性支持向量机通常会产生非常相似的结果。 Logistic 回归试图使训练数据的条件可能性最大化,这使其比 SVM 更容易出现异常值。 SVM 最关心的是最靠近决策边界的点(支持向量)。 另一方面,逻辑回归的优势在于它是一个更简单的模型,可以更轻松地实现。 此外,逻辑回归模型可以轻松更新,这在处理流数据时很有吸引力。
scikit-learn 中的替代实现
我们在上一节中通过 scikit-learn 使用的,Perceptron和LogisticRegression类利用了 LIBLINEAR 库,该库是由台湾大学开发的高度优化的 C/C++ 库。 同样,我们用来训练 SVM 的SVC类也使用了 LIBSVM,这是专门用于 SVM 的等效 C/C++ 库。
与本地 Python 实现相比,使用 LIBLINEAR 和 LIBSVM 的优势在于,它们可以非常快速地训练大量的线性分类器。 但是,有时我们的数据集太大而无法容纳计算机内存。 因此,scikit-learn 还可以通过SGDClassifier类提供替代实现,该类还支持通过partial_fit方法进行在线学习。 SGDClassifier类的概念类似于我们在第 2 章,“训练机器学习分类算法”中为 Adaline 实现的随机梯度算法。 我们可以使用以下默认参数初始化感知器的随机梯度下降版本,逻辑回归和支持向量机:
>>> from sklearn.linear_model import SGDClassifier
>>> ppn = SGDClassifier(loss='perceptron')
>>> lr = SGDClassifier(loss='log')
>>> svm = SGDClassifier(loss='hinge')
使用内核 SVM 解决非线性问题
SVM 在机器学习从业者中如此受欢迎的另一个原因是,它们可以轻松地内核化来解决非线性分类问题。 在讨论内核 SVM 背后的主要概念之前,让我们首先定义并创建一个样本数据集,以查看这种非线性分类问题的外观。
使用下面的代码,我们将使用 NumPy 中的logical_xor函数创建一个简单的数据集,该数据集具有 XOR 门的形式,其中将为 100 个样本分配类别标签,为 100 个样本分配类别标签 -1,分别为:
>>> np.random.seed(0)
>>> X_xor = np.random.randn(200, 2)
>>> y_xor = np.logical_xor(X_xor[:, 0] > 0, X_xor[:, 1] > 0)
>>> y_xor = np.where(y_xor, 1, -1)
>>> plt.scatter(X_xor[y_xor==1, 0], X_xor[y_xor==1, 1],
... c='b', marker='x', label='1')
>>> plt.scatter(X_xor[y_xor==-1, 0], X_xor[y_xor==-1, 1],
... c='r', marker='s', label='-1')
>>> plt.ylim(-3.0)
>>> plt.legend()
>>> plt.show()
执行完代码后,我们将获得一个具有随机噪声的 XOR 数据集,如下图所示:

显然,我们无法使用线性超平面作为决策边界,通过线性逻辑回归或线性 SVM 模型,将正负类样本很好地分离开来。
处理此类线性不可分离的数据的内核方法背后的基本思想是创建原始特征的非线性组合,以通过映射函数φ(·)将它们投影到更高维的空间,在该映射函数中线性可分离。 如下图所示,我们可以将二维数据集转换到新的三维特征空间上,在该空间中,类可以通过以下投影进行分离:

这使我们能够通过线性超平面来分离图中所示的两个类别,如果我们将其投影回原始特征空间,它将成为非线性决策边界:

使用内核技巧在高维空间中找到分离的超平面
为了使用 SVM 解决非线性问题,我们通过映射函数φ(·)将训练数据转换到更高维的特征空间,并训练线性 SVM 模型以对该新特征空间中的数据进行分类。 然后,我们可以使用相同的映射函数φ(·)来转换看不见的新数据,以使用线性 SVM 模型对其进行分类。
但是,这种映射方法的一个问题是,新功能的构建在计算上非常昂贵,尤其是当我们处理高维数据时。 这就是所谓的内核技巧起作用的地方。 尽管我们没有详细介绍如何解决二次编程任务以训练 SVM,但实际上,我们所需要做的只是用φ(x^(i)) · φ(x^(j))代替点积x^(i) · x^(j)。 在中,为了节省显式计算两点之间的点积的昂贵步骤,我们定义了一个所谓的核函数:k(x^(i), x^(j)) = φ(x^(i)) · φ(x^(j))。
使用最广泛的内核之一是径向基函数内核(RBF 内核)或高斯内核:

通常将其简化为:

此处,γ = 1 / (2σ²)是要优化的自由参数。
粗略地说,术语内核可以解释为一对样本之间的相似度函数。 负号将距离量度反转为相似度得分,并且由于指数项,所得相似度得分将落在 1(对于完全相似的样本)和 0(对于非常不同的样本)之间的范围内。
现在,我们定义了内核技巧背后的全局,让我们看看是否可以训练内核 SVM,该 SVM 能够绘制出将 XOR 数据很好地分开的非线性决策边界。 在这里,我们仅使用先前导入的 scikit-learn 中的SVC类,并将参数kernel='linear'替换为kernel='rbf':
>>> svm = SVC(kernel='rbf', random_state=0, gamma=0.10, C=10.0)
>>> svm.fit(X_xor, y_xor)
>>> plot_decision_regions(X_xor, y_xor, classifier=svm)
>>> plt.legend(loc='upper left')
>>> plt.show()
正如我们在中看到的结果图所示,内核 SVM 相对较好地分离了 XOR 数据:

我们设置为gamma=0.1的γ参数可以理解为高斯球的截止参数。 如果我们增加γ的值,则会增加训练样本的影响或影响范围,从而导致较软的决策边界。 为了更好地了解γ,让我们将 RBF 内核 SVM 应用于我们的鸢尾花数据集:
>>> svm = SVC(kernel='rbf', random_state=0, gamma=0.2, C=1.0)
>>> svm.fit(X_train_std, y_train)
>>> plot_decision_regions(X_combined_std,
... y_combined, classifier=svm,
... test_idx=range(105,150))
>>> plt.xlabel('petal length [standardized]')
>>> plt.ylabel('petal width [standardized]')
>>> plt.legend(loc='upper left')
>>> plt.show()
由于我们为γ选择了较小的值,因此 RBF 内核 SVM 模型的最终决策边界将相对较软,如下图所示:

现在让我们增加γ的值,并观察对决策边界的影响:
>>> svm = SVC(kernel='rbf', random_state=0, gamma=100.0, C=1.0)
>>> svm.fit(X_train_std, y_train)
>>> plot_decision_regions(X_combined_std,
... y_combined, classifier=svm,
... test_idx=range(105,150))
>>> plt.xlabel('petal length [standardized]')
>>> plt.ylabel('petal width [standardized]')
>>> plt.legend(loc='upper left')
>>> plt.show()
在结果图中,我们现在可以看到,使用相对较大的γ值,围绕类 0 和 1 的决策边界要紧密得多:

尽管模型非常适合训练数据集,但是这样的分类器可能会在看不见的数据上具有很高的泛化误差,这说明γ的优化在控制过度拟合方面也起着重要作用。
决策树学习
尽管我们在前面的模块中已经了解了决策树,但是让我们更深入地研究。 如果我们关注可解释性,则决策树分类器是有吸引力的模型。 就像名称决策树所暗示的那样,我们可以认为此模型是通过基于提出一系列问题来做出决策来分解数据。
让我们考虑以下示例,其中我们使用决策树来决定特定日期的活动:

基于我们训练集中的功能,决策树模型学习一系列问题以推断样本的类别标签。 尽管上图说明了基于分类变量的决策树的概念,但是如果我们的特征是像 Iris 数据集中那样的实数,则可以应用相同的概念。 例如,我们可以简单地沿萼片宽度特征轴定义一个截止值,然后问一个二元问题“分隔宽度>= 2.8厘米?”。
使用决策算法,我们从树的根部开始,对特征上的数据进行分割,从而获得最大的信息增益(IG),这将在更多内容中进行解释。 在下一节中详细介绍。 在一个迭代过程中,我们可以在每个子节点上重复此拆分过程,直到叶子纯净为止。 这意味着每个节点上的样本都属于同一类。 实际上,这可能会导致具有许多节点的非常深的树,这很容易导致过度拟合。 因此,我们通常希望通过设置树的最大深度的限制来修剪树。
最大限度地提高信息获取能力–物有所值
为了使在信息最多的特征上分割节点,我们需要定义一个目标函数,该目标函数要通过树学习算法进行优化。 在这里,我们的目标功能是使每次拆分的信息增益最大化,定义如下:

f是执行分割的功能,D[p]和D[j]是父级的数据集,第 j 个子节点,I [ 是我们的杂质度量,N[p]是父节点上的样本总数,N[j]是第j个子节点中的样本数。 可以看到,信息增益只是父节点的杂质与子节点杂质之和之间的差,子节点的杂质越低,信息增益就越大。 但是,为了简化并减少组合搜索空间,大多数库(包括 scikit-learn)都采用二进制决策树。 这意味着每个父节点都分为两个子节点D[left]和D[right]:

现在,二元决策树中常用的三种杂质测度或分裂标准是基尼杂质(I[G]),熵(I[H]) ,以及分类错误(I[E])。 让我们从所有非空类p(i | t) ≠ 0的熵的定义开始:

在此,p(i | t)是属于特定节点t的类别i的样本的比例。 因此,如果节点上的所有样本都属于同一类别,则熵为 0,如果我们具有统一的类别分布,则熵为最大。 例如,在二进制类别设置中,如果p(i = 1 | t) = 1或p(i = 0 | t) = 0,则熵为 0。 如果类别通过p(i = 1 | t) = 0.5和p(i = 0 | t) = 0.5均匀分布,则熵为 1。因此,可以说熵准则试图使树中的互信息最大化。
直观地,基尼杂质可以被理解为使错误分类的可能性最小化的标准:

类似于熵,如果类别完美混合,例如在二进制类别设置(c = 2)中,则基尼杂质最大。

但是,在实践中,基尼杂质和熵通常会产生非常相似的结果,通常不值得花大量时间使用不同的杂质标准评估树木,而不是尝试使用不同的修剪截止值。
另一个杂质度量是分类错误:

这是用于修剪的有用标准,但不建议用于增长决策树,因为它对节点的类概率的更改不太敏感。 我们可以通过查看下图中所示的两种可能的拆分方案来说明这一点:

我们从父节点D[p]的数据集D[p]开始,该数据集由 1 类的 40 个样本和 2 类的 40 个样本组成,我们分别分成两个数据集D[left]和D[right]。 在场景 A 和场景 B 中,使用分类误差作为划分标准的信息增益将是相同的(IG[E] = 0.25):







但是,基尼杂质比方案A(IG[G] = 0.125)更倾向于方案B(IG[G] = 0.166...)中的拆分,而方案A(IG[G] = 0.125)的确更纯净:







类似地,熵标准比方案A(IG[H] = 0.19)更倾向于方案B(IG[H] = 0.31):







为了更直观地比较前面讨论的三个不同杂质标准,让我们绘制类别 1 的概率范围[0,1]的杂质指数。请注意,我们还将添加熵的缩放版本 (熵/ 2 )观察到,基尼杂质是熵和分类误差之间的中间量度。 代码如下:
>>> import matplotlib.pyplot as plt
>>> import numpy as np
>>> def gini(p):
... return (p)*(1 - (p)) + (1 - p)*(1 - (1-p))
>>> def entropy(p):
... return - p*np.log2(p) - (1 - p)*np.log2((1 - p))
>>> def error(p):
... return 1 - np.max([p, 1 - p])
>>> x = np.arange(0.0, 1.0, 0.01)
>>> ent = [entropy(p) if p != 0 else None for p in x]
>>> sc_ent = [e*0.5 if e else None for e in ent]
>>> err = [error(i) for i in x]
>>> fig = plt.figure()
>>> ax = plt.subplot(111)
>>> for i, lab, ls, c, in zip([ent, sc_ent, gini(x), err],
... ['Entropy', 'Entropy (scaled)',
... 'Gini Impurity',
... 'Misclassification Error'],
... ['-', '-', '--', '-.'],
... ['black', 'lightgray',
... 'red', 'green', 'cyan']):
... line = ax.plot(x, i, label=lab,
... linestyle=ls, lw=2, color=c)
>>> ax.legend(loc='upper center', bbox_to_anchor=(0.5, 1.15),
... ncol=3, fancybox=True, shadow=False)
>>> ax.axhline(y=0.5, linewidth=1, color='k', linestyle='--')
>>> ax.axhline(y=1.0, linewidth=1, color='k', linestyle='--')
>>> plt.ylim([0, 1.1])
>>> plt.xlabel('p(i=1)')
>>> plt.ylabel('Impurity Index')
>>> plt.show()
前面的代码示例生成的图如下:

建立决策树
决策树可以通过将特征空间划分为矩形来建立复杂的决策边界。 但是,我们必须谨慎,因为决策树越深,决策边界变得越复杂,这很容易导致过度拟合。 使用 scikit-learn,我们现在将使用熵作为杂质标准来训练最大深度为 3 的决策树。 尽管出于可视化目的可能需要特征缩放,但是请注意,特征缩放不是决策树算法的要求。 代码如下:
>>> from sklearn.tree import DecisionTreeClassifier
>>> tree = DecisionTreeClassifier(criterion='entropy',
... max_depth=3, random_state=0)
>>> tree.fit(X_train, y_train)
>>> X_combined = np.vstack((X_train, X_test))
>>> y_combined = np.hstack((y_train, y_test))
>>> plot_decision_regions(X_combined, y_combined,
... classifier=tree, test_idx=range(105,150))
>>>plt.xlabel('petal length [cm]')
>>>plt.ylabel('petal width [cm]')
>>> plt.legend(loc='upper left')
>>> plt.show()
在执行上述代码示例的之后,我们获得了决策树的典型轴平行决策边界:

scikit-learn 的一个不错的功能是它允许我们在训练后将决策树导出为.dot文件,我们可以使用 GraphViz 程序将其可视化。 该程序可从这个页面免费获得,并受 Linux,Windows 和 Mac OS X 支持。
首先,我们使用tree子模块中的export_graphviz函数通过 scikit-learn 创建.dot文件,如下所示:
>>> from sklearn.tree import export_graphviz
>>> export_graphviz(tree,
... out_file='tree.dot',
... feature_names=['petal length', 'petal width'])
在计算机上安装 GraphViz 之后,可以通过在保存tree.dot文件的位置从命令行执行以下命令,将tree.dot文件转换为 PNG 文件:
> dot -Tpng tree.dot -o tree.png

查看我们通过 GraphViz 创建的决策树图,我们现在可以很好地追溯决策树从训练数据集中确定的拆分。 我们从根部的 105 个样本开始,使用花瓣宽度截止值≤0.75 厘米,将其分为两个子节点,分别具有 34 和 71 个样本。 第一次拆分后,我们可以看到左侧的子节点已经是纯节点,并且仅包含 Iris-Setosa 类的样本(熵= 0)。 然后使用右侧的其他拆分从 Iris-Versicolor 和 Iris-Virginica 类中分离样本。
通过随机森林将弱者和强者合并
随机森林 由于其良好的分类性能,可伸缩性和易用性,在过去的十年中在机器学习应用中获得了巨大的普及。 直观上,可以将随机森林视为决策树的集合。 集成学习的思想是结合弱学习者建立一个更健壮的模型强学习者,该模型具有更好的泛化误差并且不易受到影响 过度拟合。 随机森林算法可以归纳为四个简单步骤:
-
绘制大小为
n的随机引导程序样本(从训练集中随机选择n样本,并进行替换)。 -
从引导程序样本中增长决策树。 在每个节点上:
- 随机选择
d功能,无需替换。 - 使用根据目标函数提供最佳拆分的功能(例如,通过最大化信息增益)拆分节点。
- 随机选择
-
重复步骤 1 至 2
k次。 -
汇总每棵树的预测,以多数票分配类别标签。 多数投票将在第 7 章,“结合不同模型进行整合学习”中进行详细讨论。
当我们训练各个决策树时,第 2 步有一个轻微修改:我们不考虑评估所有功能以确定每个节点上的最佳分割,而只考虑其中的一个随机子集。
尽管随机森林不能提供与决策树相同的可解释性,但是随机森林的一大优势是我们不必为选择良好的超参数值而担心。 通常,我们不需要修剪随机森林,因为集成模型对于来自各个决策树的噪声非常鲁棒。 实际上,我们真正需要关心的唯一参数是我们为随机森林选择的树数k(第 3 步)。 通常,树的数量越多,随机森林分类器的性能越好,但以增加的计算成本为代价。
尽管在实践中不太常见,但可以优化随机森林分类器的其他超参数(使用我们将在第 5 章,“通过降维压缩”压缩数据中讨论的技术)的大小 自举样本的n(步骤 1)和为每个分割随机选择的特征数d(步骤 2.1)。 通过引导程序样本的样本大小n,我们控制了随机森林的偏差-方差折衷。 通过为n选择较大的值,我们可以减少随机性,因此森林更可能过度适应。 另一方面,我们可以通过为模型性能选择n较小的值来减少过拟合的程度。 在大多数实施方式中,包括 scikit-learn 中的RandomForestClassifier实施方式,引导程序样本的样本大小均应选择为等于原始训练集中的样本数,这通常会提供良好的偏差方差折衷。 对于每个拆分中的特征数d,我们希望选择一个比训练集中的特征总数小的值。 scikit-learn 和其他实现中使用的合理默认值是d = √m,其中m是训练集中功能的数量。
方便地,我们不必自己从各个决策树构造随机森林分类器。 scikit-learn 中已经有一个我们可以使用的实现:
>>> from sklearn.ensemble import RandomForestClassifier
>>> forest = RandomForestClassifier(criterion='entropy',
... n_estimators=10,
... random_state=1,
... n_jobs=2)
>>> forest.fit(X_train, y_train)
>>> plot_decision_regions(X_combined, y_combined,
... classifier=forest, test_idx=range(105,150))
>>> plt.xlabel('petal length')
>>> plt.ylabel('petal width')
>>> plt.legend(loc='upper left')
>>> plt.show()
执行完前面的代码后,我们应该看到由随机森林中的树木集合形成的决策区域,如下图所示:

使用前面的代码,我们通过n_estimators参数从 10 个决策树中训练了一个随机森林,并使用熵准则作为杂质度量来分割节点。 尽管我们从很小的训练数据集中生长出非常小的随机森林,但出于演示目的,我们使用了n_jobs参数,这使我们可以使用计算机的多个核(这里为两个)并行化模型训练。
K 近邻–惰性学习算法
我们将在本章中讨论的最后一个监督学习算法是 k 最近邻分类器(KNN),这特别有趣,因为它从根本上讲 与我们到目前为止讨论的学习算法不同。
KNN 是懒惰学习者的典型示例。 之所以称其为懒惰,并不是因为它看上去简单,而是因为它没有从训练数据中学习判别函数,而是存储了训练数据集。
注意
参数模型与非参数模型
机器学习算法可以分为参数和非参数模型。 使用参数模型,我们从训练数据集中估计参数,以学习可以对新数据点进行分类的功能,而不再需要原始训练数据集。 参数模型的典型示例是感知器,逻辑回归和线性 SVM。 相比之下,非参数模型无法通过一组固定的参数来表征,并且参数的数量随训练数据的增长而增加。 到目前为止,我们已经看到了两个非参数模型的例子:决策树分类器/随机森林和内核 SVM。
KNN 属于非参数模型的子类别,该子类别被描述为基于实例的学习。 基于实例学习的模型的特征是记忆训练数据集,而惰性学习是基于实例学习的特例,在学习过程中无需花费零成本。
KNN 算法本身非常简单,可以通过以下步骤进行总结:
- 选择
k的数量和距离度量。 - 找到我们要分类的样本的
k最近邻居。 - 通过多数表决分配班级标签。
下图说明了如何根据新数据点(?)在五个最邻近邻居之间的多数表决方式为其分配三角形类别标签。

基于选择的距离度量,KNN 算法在训练数据集中找到最接近(最相似)我们要分类的点的k个样本。 然后,由新数据点的k最近邻居中的多数票决定其类别标签。
这种基于内存的方法的主要优势在于,分类器会在我们收集新的训练数据时立即进行调整。 但是,不利的一面是,在最坏的情况下,用于对新样本进行分类的计算复杂度会随着训练数据集中的样本数量线性增加,除非该数据集的维数(特征)很少,并且该算法已使用有效数据来实现 结构,例如 KD 树。 (JH Friedman,JL Bentley 和 RA Finkel。一种用于寻找对数期望时间的最佳匹配的算法。ACMTransactions on Mathematical Software(TOMS),3(3):209-226,1977。)此外,我们不能放弃 训练样本,因为不涉及训练步骤。 因此,如果我们使用大型数据集,则存储空间可能会成为一个挑战。
通过执行以下代码,我们现在将使用欧几里德距离度量在 scikit-learn 中实现 KNN 模型:
>>> from sklearn.neighbors import KNeighborsClassifier
>>> knn = KNeighborsClassifier(n_neighbors=5, p=2,
... metric='minkowski')
>>> knn.fit(X_train_std, y_train)
>>> plot_decision_regions(X_combined_std, y_combined,
... classifier=knn, test_idx=range(105,150))
>>> plt.xlabel('petal length [standardized]')
>>> plt.ylabel('petal width [standardized]')
>>> plt.show()
通过在此数据集的 KNN 模型中指定五个邻居,我们获得了相对平滑的决策边界,如下图所示:

注意
在平局的情况下,KNN 算法的 scikit-learn 实现将更喜欢距离样本更近的邻居。 如果邻居的距离相似,则算法将选择训练数据集中最先出现的类别标签。
k的正确选择对于在过度拟合和欠拟合之间找到良好的平衡至关重要。 我们还必须确保选择适合于数据集中要素的距离度量。 通常,简单的欧几里德距离度量用于实值样本,例如,虹膜数据集中的花朵具有以厘米为单位的特征。 但是,如果我们使用欧几里德距离度量,则标准化数据也很重要,以使每个要素对距离的贡献均等。 我们在前面的代码中使用的'minkowski'距离只是欧几里得距离和曼哈顿距离的概括,可以写成如下形式:

如果我们将参数p=2或曼哈顿距离分别设置为p=1,则它成为欧几里德距离。 scikit-learn 中提供了许多其他距离度量,并且可以将提供给metric参数。 它们在这个页面中列出。
注意
维度的诅咒
值得一提的是,由于维度的诅咒,KNN 非常容易过拟合。 维度的诅咒描述了一种现象,其中随着固定大小的训练数据集的维度数量的增加,特征空间变得越来越稀疏。 凭直觉,我们可以想到即使是最接近的邻居在高维空间中也相距太远,无法给出良好的估计。
我们在关于逻辑回归的部分中讨论了正则化的概念,这是避免过度拟合的一种方法。 但是,在不适用正则化的模型(例如决策树和 KNN)中,我们可以使用特征选择和降维技术来帮助我们避免降维。 下一章将对此进行更详细的讨论。


三十八、建立良好的训练集——数据预处理
数据的质量和其中包含的有用信息的数量是决定机器学习算法学习程度的关键因素。 因此,在将数据集输入学习算法之前,确保对数据集进行检查和预处理绝对至关重要。 在本章中,我们将讨论基本的数据预处理技术,这些技术将帮助我们建立良好的机器学习模型。
我们将在本章中介绍的主题如下:
- 从数据集中删除和估算缺失值
- 将分类数据转化为机器学习算法的形状
- 选择模型构建的相关特征
处理丢失的数据
在实际应用中,并非经常因各种原因而缺少一个或多个值。 例如,数据收集过程中可能存在错误,某些度量不适用,某些字段可能仅在调查中留为空白。 我们通常会看到缺失值作为数据表中的空格或占位符字符串,例如NaN(非数字)。
不幸的是,如果我们简单地忽略它们,大多数计算工具将无法处理此类缺失值,或者会产生无法预测的结果。 因此,至关重要的是,在继续进行进一步分析之前,应对那些遗漏的值进行处理。 但是在讨论几种处理缺失值的技术之前,让我们从 CSV(逗号分隔值)文件创建一个简单的示例数据框,以便更好地了解 问题:
>>> import pandas as pd
>>> from io import StringIO
>>> csv_data = '''A,B,C,D
... 1.0,2.0,3.0,4.0
... 5.0,6.0,,8.0
... 10.0,11.0,12.0,'''
>>> # If you are using Python 2.7, you need
>>> # to convert the string to unicode:
>>> # csv_data = unicode(csv_data)
>>> df = pd.read_csv(StringIO(csv_data))
>>> df
A B C D
0 1 2 3 4
1 5 6 NaN 8
2 10 11 12 NaN
使用前面的代码,我们通过read_csv函数将 CSV 格式的数据读取到了 PandasDataFrame中,并注意到两个缺失的单元格被NaN替换了。 前面的代码示例中的StringIO功能仅用于说明目的。 它使我们可以将分配给csv_data的字符串读入 PandasDataFrame中,就好像它是我们硬盘上的常规 CSV 文件一样。
对于较大的DataFrame,手动查找缺失值可能很繁琐; 在这种情况下,我们可以使用isnull方法返回带有布尔值的DataFrame,该布尔值指示单元格是否包含数字值(False)或是否缺少数据(True)。 使用sum方法,我们可以按如下所示返回每列缺失值的数量:
>>> df.isnull().sum()
A 0
B 0
C 1
D 1
dtype: int64
这样,我们可以计算每列缺失值的数量。 在以下小节中,我们将研究如何处理这些丢失的数据的不同策略。
注意
尽管 scikit-learn 是为处理 NumPy 数组而开发的,但有时使用 pandas 的DataFrame预处理数据会更方便。 我们始终可以通过values属性访问DataFrame的基础 NumPy 数组,然后再将其提供给 scikit-learn 估计器:
>>> df.values
array([[ 1., 2., 3., 4.],
[ 5., 6., nan, 8.],
[ 10., 11., 12., nan]])
消除具有缺失值的样品或特征
处理丢失数据的最简单方法之一就是简单地从数据集中完全删除相应的特征(列)或样本(行)。 缺少值的行可以通过dropna方法轻松删除:
>>> df.dropna()
A B C D
0 1 2 3 4
类似地,我们可以通过将axis参数设置为1来删除任何行中至少具有NaN的列:
>>> df.dropna(axis=1)
A B
0 1 2
1 5 6
2 10 11
dropna方法支持一些方便使用的其他参数:
# only drop rows where all columns are NaN
>>> df.dropna(how='all')
# drop rows that have not at least 4 non-NaN values
>>> df.dropna(thresh=4)
# only drop rows where NaN appear in specific columns (here: 'C')
>>> df.dropna(subset=['C'])
尽管删除丢失的数据似乎是一种方便的方法,但它也具有某些缺点。 例如,我们最终可能会删除过多的样本,这将使可靠的分析变得不可能。 或者,如果我们删除太多的功能列,则将冒丢失分类器需要在类之间进行区分的有价值信息的风险。 因此,在下一节中,我们将介绍处理缺失值的最常用替代方法之一:插值技术。
插补缺失值
通常,样本的删除或整个特征列的删除根本不可行,因为我们可能会丢失太多有价值的数据。 在这种情况下,我们可以使用不同的插值技术来估计数据集中其他训练样本的缺失值。 最常见的插值技术之一是均值插补,其中我们仅将缺失值替换为整个特征列的平均值。 一种方便的方法是使用 scikit-learn 的Imputer类,如以下代码所示:
>>> from sklearn.preprocessing import Imputer
>>> imr = Imputer(missing_values='NaN', strategy='mean', axis=0)
>>> imr = imr.fit(df)
>>> imputed_data = imr.transform(df.values)
>>> imputed_data
array([[ 1., 2., 3., 4.],
[ 5., 6., 7.5, 8.],
[ 10., 11., 12., 6.]])
在这里,我们用相应的均值替换了每个NaN值,该均值是针对每个功能列分别计算的。 如果将axis=0设置更改为axis=1,我们将计算行均值。 strategy参数的其他选项是median或most_frequent,其中后者将丢失的值替换为最频繁的值。 这对于估算分类要素值很有用。
了解 scikit-learn 估算器 API
在的上一部分中,我们使用了 scikit-learn 的Imputer类来估算数据集中的缺失值。 Imputer 类属于 scikit-learn 中所谓的变换器类,用于数据转换。 这些估计量的两个基本方法是fit和transform。 fit方法用于从训练数据中学习参数,transform方法使用这些参数来转换数据。 任何要转换的数据数组都必须具有与用于模型拟合的数据数组相同数量的特征。 下图说明了如何使用安装在训练数据上的提升器来转换训练数据集和新的测试数据集:

在第 3 章,“使用 Scikit-learn” 的机器学习分类器中使用的分类器属于 scikit-learn 中所谓的估计器,其 API 在概念上与提升器类非常相似。 估计器具有predict方法,但也可以具有transform方法,我们将在后面看到。 您可能还记得,当我们训练那些估计器进行分类时,我们还使用了fit方法来学习模型的参数。 但是,在监督学习任务中,我们另外提供了适合模型的类标签,然后可以通过predict方法将其用于对新数据样本进行预测,如下图所示:


处理分类数据
到目前为止,我们仅使用数值。 但是,现实世界的数据集包含一个或多个分类要素列并不少见。 在讨论分类数据时,我们必须进一步区分标称和标称特征。 序数特征可以理解为可以排序或排序的分类值。 例如,T 恤尺寸将是一个常规特征,因为我们可以定义一个订单 XL >L>M。 相比之下,标称特征并不意味着任何顺序,并且继续前面的示例,我们可以将 T 恤颜色视为标称特征,因为通常来说,这是没有意义的, 例如,红色大于蓝色。
在探索用于处理此类分类数据的不同技术之前,让我们创建一个新的数据框来说明问题:
>>> import pandas as pd
>>> df = pd.DataFrame([
... ['green', 'M', 10.1, 'class1'],
... ['red', 'L', 13.5, 'class2'],
... ['blue', 'XL', 15.3, 'class1']])
>>> df.columns = ['color', 'size', 'price', 'classlabel']
>>> df
color size price classlabel
0 green M 10.1 class1
1 red L 13.5 class2
2 blue XL 15.3 class1
从前面的输出中可以看到,新创建的DataFrame包含一个名义特征(color),一个序数特征(size)和一个数值特征(price)列。 类标签(假设我们为监督学习任务创建了数据集)存储在最后一列中。 我们在本书中讨论的分类学习算法不在类标签中使用序数信息。
映射序数特征
为了确保的学习算法能够正确解释序数特征,我们需要将分类字符串值转换为整数。 不幸的是,没有方便的功能可以自动导出size功能标签的正确顺序。 因此,我们必须手动定义映射。 在下面的简单示例中,假设我们知道功能之间的区别,例如XL = L + 1 = M + 2。
>>> size_mapping = {
... 'XL': 3,
... 'L': 2,
... 'M': 1}
>>> df['size'] = df['size'].map(size_mapping)
>>> df
color size price classlabel
0 green 1 10.1 class1
1 red 2 13.5 class2
2 blue 3 15.3 class1
如果我们想要在稍后的阶段将整数值转换回原始字符串表示形式,我们可以简单地定义一个反向映射字典inv_size_mapping = {v: k for k, v in size_mapping.items()},然后可以通过 Pandas 的map方法在转换后的字典中使用它 功能列类似于我们之前使用的size_mapping词典。
编码类标签
许多机器学习库都要求将类标签编码为整数值。 尽管 scikit-learn 中用于分类的大多数估计器都会在内部将类标签转换为整数,但是将类标签作为整数数组提供以避免技术故障是一种很好的做法。 要对类标签进行编码,我们可以使用类似于前面讨论的序数特征映射的方法。 我们需要记住,类标签不是而是序数,并且我们将哪个整数分配给特定的字符串标签都没有关系。 因此,我们可以简单地枚举从 0 开始的类标签:
>>> import numpy as np
>>> class_mapping = {label:idx for idx,label in
... enumerate(np.unique(df['classlabel']))}
>>> class_mapping
{'class1': 0, 'class2': 1}
接下来,我们可以使用映射字典将类标签转换为整数:
>>> df['classlabel'] = df['classlabel'].map(class_mapping)
>>> df
color size price classlabel
0 green 1 10.1 0
1 red 2 13.5 1
2 blue 3 15.3 0
我们可以按如下所示反转映射字典中的键/值对,以将转换后的类标签映射回原始字符串表示形式:
>>> inv_class_mapping = {v: k for k, v in class_mapping.items()}
>>> df['classlabel'] = df['classlabel'].map(inv_class_mapping)
>>> df
color size price classlabel
0 green 1 10.1 class1
1 red 2 13.5 class2
2 blue 3 15.3 class1
另外,是一个方便的LabelEncoder类,直接在 scikit-learn 中实现以实现相同的目的:
>>> from sklearn.preprocessing import LabelEncoder
>>> class_le = LabelEncoder()
>>> y = class_le.fit_transform(df['classlabel'].values)
>>> y
array([0, 1, 0])
请注意,fit_transform方法只是分别调用fit和transform的快捷方式,我们可以使用inverse_transform方法将整数类标签转换回原始字符串表示形式:
>>> class_le.inverse_transform(y)
array(['class1', 'class2', 'class1'], dtype=object)
对名义特征执行一次热编码
在的上一部分中,我们使用了一种简单的字典映射方法将序数大小特征转换为整数。 由于 scikit-learn 的估计器对待类标签没有任何顺序,因此我们使用方便的LabelEncoder类将字符串标签编码为整数。 似乎我们可以使用类似的方法来转换数据集的标称color列,如下所示:
>>> X = df[['color', 'size', 'price']].values
>>> color_le = LabelEncoder()
>>> X[:, 0] = color_le.fit_transform(X[:, 0])
>>> X
array([[1, 1, 10.1],
[2, 2, 13.5],
[0, 3, 15.3]], dtype=object)
执行完前面的代码后,NumPy 数组X的第一列现在包含新的color值,其值编码如下:
- 蓝色→0
- 绿色→1
- 红色→2
如果我们此时停止并将数组提供给分类器,那么在处理分类数据时,我们将犯下最常见的错误之一。 你能发现问题吗? 尽管颜色值没有特定的顺序,但是学习算法现在将假定绿色大于蓝色,并且红色大于 绿色。 尽管此假设不正确,但该算法仍可以产生有用的结果。 但是,这些结果不是最佳的。
针对此问题的常见解决方法是使用一种称为单热编码的技术。 这种方法背后的想法是为名义特征列中的每个唯一值创建一个新的虚拟特征。 在这里,我们将color功能转换为三个新功能:blue,green和red。 然后可以使用二进制值来指示样品的特定颜色; 例如,蓝色样本可以被编码为blue=1,green=0和red=0。 要执行此转换,我们可以使用在scikit-learn.preprocessing模块中实现的OneHotEncoder:
>>> from sklearn.preprocessing import OneHotEncoder
>>> ohe = OneHotEncoder(categorical_features=[0])
>>> ohe.fit_transform(X).toarray()
array([[ 0\. , 1\. , 0\. , 1\. , 10.1],
[ 0\. , 0\. , 1\. , 2\. , 13.5],
[ 1\. , 0\. , 0\. , 3\. , 15.3]])
初始化OneHotEncoder时,我们通过categorical_features参数定义了要转换的变量的列位置(请注意color是特征矩阵X中的第一列)。 默认情况下,当我们使用transform方法时,OneHotEncoder返回一个稀疏矩阵,并且为了通过toarray 方法。 稀疏矩阵只是存储大型数据集的一种更有效的方法,并且是许多 scikit-learn 函数支持的一种方法,如果它包含很多零,则特别有用。 要省略toarray步骤,我们可以将编码器初始化为OneHotEncoder(…,sparse=False)以返回常规 NumPy 数组。
通过单热编码创建那些虚拟特征的一种更方便的方法是使用在 Pandas 中实现的get_dummies方法。 应用于DataFrame上的get_dummies方法将仅转换字符串列,并使所有其他列保持不变:
>>> pd.get_dummies(df[['price', 'color', 'size']])
price size color_blue color_green color_red
0 10.1 1 0 1 0
1 13.5 2 0 0 1
2 15.3 3 1 0 0
在训练和测试集中划分数据集
第 1 章,“使计算机具有从数据中学习的能力”和第 3 章中,我们简要介绍了将数据集划分为单独的数据集以进行训练和测试的概念。 和“使用 Scikit-learn”的机器学习分类器的浏览。 请记住,在我们将其释放到现实世界之前,可以将测试集理解为模型的最终测试。 在本节中,我们将准备一个新的数据集 Wine 数据集。 在对数据集进行预处理之后,我们将探索用于特征选择的不同技术,以减少数据集的维数。
Wine 数据集是可从 UCI 机器学习存储库获得的另一个开源数据集; 它由 178 个葡萄酒样品组成,具有 13 个描述其不同化学特性的特征。
使用 pandas 库,我们将直接从 UCI 机器学习存储库中读取开源 Wine 数据集:
>>> df_wine = pd.read_csv('https://archive.ics.uci.edu/ml/machine-learning-databases/wine/wine.data', header=None)
>>> df_wine.columns = ['Class label', 'Alcohol',
... 'Malic acid', 'Ash',
... 'Alcalinity of ash', 'Magnesium',
... 'Total phenols', 'Flavanoids',
... 'Nonflavanoid phenols',
... 'Proanthocyanins',
... 'Color intensity', 'Hue',
... 'OD280/OD315 of diluted wines',
... 'Proline']
>>> print('Class labels', np.unique(df_wine['Class label']))
Class labels [1 2 3]
>>> df_wine.head()
下表列出了葡萄酒数据集中的 13 种不同特征,它们描述了 178 种葡萄酒样品的化学特性:

样品属于 1、2 和 3 三个不同类别中的一个,它们分别是在意大利不同地区种植的三种不同类型的葡萄。
将此数据集随机划分为单独的测试和训练数据集的便捷方法是使用 scikit-learn 的cross_validation子模块中的train_test_split函数:
>>> from sklearn.cross_validation import train_test_split
>>> X, y = df_wine.iloc[:, 1:].values, df_wine.iloc[:, 0].values
>>> X_train, X_test, y_train, y_test = \
... train_test_split(X, y, test_size=0.3, random_state=0)
首先,我们将要素列 1-13 的 NumPy 数组表示形式分配给变量X,然后将第一列中的类标签分配给变量y。 然后,我们使用train_test_split函数将X和y随机分为单独的训练和测试数据集。 通过设置test_size=0.3,我们将 30%的葡萄酒样品分配给X_test和y_test,其余 70%的样品分别分配给X_train和y_train。
注意
如果将数据集分为训练和测试数据集,则必须记住,我们扣留了学习算法可以从中受益的有价值的信息。 因此,我们不想为测试集分配过多的信息。 但是,测试集越小,泛化误差的估计就越不准确。 将数据集划分为训练集和测试集都是为了平衡这种权衡。 实际上,最常用的分割是 60:40、70:30 或 80:20,具体取决于初始数据集的大小。 但是,对于大型数据集,将 90:10 或 99:1 分成训练和测试子集也是常见且适当的。 最好不要在模型训练和评估后丢弃分配的测试数据,而是在整个数据集上重新训练分类器以获得最佳性能。
使功能达到相同的规模
特征缩放是我们预处理流程中很容易忘记的关键步骤。 决策树和随机森林是我们无需担心特征缩放的极少数机器学习算法之一。 但是,如果功能相同,则大多数机器学习和优化算法的性能都会好得多,正如我们在实现时在第 2 章和“训练机器学习分类算法”中所看到的那样梯度下降优化算法。
一个简单的例子可以说明特征缩放的重要性。 假设我们有两个要素,其中一个要素的缩放比例为 1 到 10,第二个要素的缩放比例为 1 到 100,000。 当我们想到第 2 章和“训练机器学习分类算法”中的 Adaline 中的平方误差函数时,可以很直观地说该算法 将主要根据第二个功能中的较大误差忙于优化权重。 另一个例子是具有欧几里德距离测度的 k 最近邻(KNN)算法; 样本之间的计算距离将由第二个特征轴控制。
现在,有两种常见的方法可以将不同的功能放到相同的规模上:标准化 和标准化。 这些术语通常在不同领域中非常宽松地使用,其含义必须从上下文中得出。 通常,归一化是指将特征重新缩放到[0,1]的范围,这是最小-最大缩放的一种特殊情况。 为了规范化我们的数据,我们可以简单地将最小-最大缩放应用于每个特征列,其中样本x^(i)的新值x_norm^(i)可以如下计算:

在此,x^(i)是特定样本,x_min是特征列中的最小值,x_max是最大值。
最小-最大缩放过程在 scikit-learn 中实现,可以按以下方式使用:
>>> from sklearn.preprocessing import MinMaxScaler
>>> mms = MinMaxScaler()
>>> X_train_norm = mms.fit_transform(X_train)
>>> X_test_norm = mms.transform(X_test)
尽管通过最小-最大缩放进行归一化是一种常用的技术,当我们需要在有界区间中的值时很有用,但对于许多机器学习算法而言,标准化可能更为实用。 原因是很多线性模型,例如我们从第 3 章和“使用 Scikit-learn” 进行的机器学习分类器游记中所记得的逻辑回归和 SVM,将权重初始化为 0 或接近 0 的较小随机值。使用标准化,我们将特征列居中于标准偏差为 1 的均值 0,以便特征列采用正态分布的形式,这使学习权重变得更加容易。 此外,与最小-最大缩放相反,标准化可维护有关异常值的有用信息,并使算法对异常值的敏感性降低,后者可将数据缩放到有限的值范围。
标准化过程可以用以下公式表示:

在此,μ[x]分别是特定特征列的样本均值,σ[x]是相应的标准差。
下表说明了两种常用的特征缩放技术之间的区别,即对由数字 0 到 5 组成的简单样本数据集的标准化和规范化:
|输入
|
标准化的
|
归一化
|
| --- | --- | --- |
| 0.0 | -1.336306 | 0.0 |
| 1.0 | -0.801784 | 0.2 |
| 2.0 | -0.267261 | 0.4 |
| 3.0 | 0.267261 | 0.6 |
| 4.0 | 0.801784 | 0.8 |
| 5.0 | 1.336306 | 1.0 |
类似于MinMaxScaler,scikit-learn 还实现了一个用于标准化的类:
>>> from sklearn.preprocessing import StandardScaler
>>> stdsc = StandardScaler()
>>> X_train_std = stdsc.fit_transform(X_train)
>>> X_test_std = stdsc.transform(X_test)
同样,重要的是要强调我们只对训练数据拟合一次StandardScaler,并使用这些参数来变换测试集或任何新的数据点。
选择有意义的功能
如果我们注意到,模型在训练数据集上的表现要好于在测试数据集上的表现,则该观察结果是过拟合的有力指标。 过度拟合意味着模型太适合参数拟合训练数据集中的特定观测值,但不能很好地推广到真实数据—我们说该模型具有高方差。 过度拟合的原因是,对于给定的训练数据,我们的模型过于复杂,为了减少泛化误差,常见的解决方案如下:
- 收集更多训练数据
- 通过正则化引入对复杂性的惩罚
- 选择参数更少的更简单模型
- 降低数据的维度
收集更多的训练数据通常不适用。 在下一章中,我们将学习一种有用的技术,以检查更多的训练数据是否完全有用。 在以下各节中,我们将探讨通过正则化和通过特征选择减少维数来减少过度拟合的常见方法。
具有 L1 正则化的稀疏解决方案
我们从第 3 章,“使用 Scikit 学习的机器学习分类器”回顾,L2 正则化是一种方法 为了通过惩罚较大的单个权重来降低模型的复杂性,在这里我们定义了权重向量w的 L2 范数:

降低模型复杂度的另一种方法是相关的 L1 正则化:

在这里,我们简单地用将权重的平方替换为权重的绝对值之和。 与 L2 正则化相反,L1 正则化产生稀疏特征向量。 大多数功能权重将为零。 如果我们拥有一个具有许多不相关特征的高维数据集,则稀疏性在实践中可能会有用,尤其是在我们的不相关维度比样本多的情况下。 从这个意义上讲,L1 正则化可以理解为一种特征选择技术。
为了更好地理解 L1 正则化如何鼓励稀疏性,让我们退后一步,看看正则化的几何解释。 让我们为两个权重系数w[1]和w[2]绘制凸成本函数的轮廓。 在这里,我们将考虑在第 2 章,“训练机器学习分类算法”中用于 Adaline 的平方误差(SSE)成本函数的和。 因为它比 Logistic 回归的成本函数对称且易于绘制; 但是,相同的概念也适用于后者。 请记住,我们的目标是找到权重系数的组合,以最小化训练数据的成本函数,如下图所示(椭圆的中间点):

现在,我们可以认为正则化是在成本函数中添加惩罚项以鼓励较小的权重; 或者,换句话说,我们会惩罚较大的权重。
因此,通过通过正则化参数λ增加正则强度,我们将权重缩小为零,并减少了模型对训练数据的依赖性。 让我们在下图中针对 L2 惩罚项说明这个概念。

二次 L2 正则化项由阴影球表示。 在这里,我们的权重系数不能超过正则化预算-权重系数的组合不能落在阴影区域之外。 另一方面,我们仍然希望最小化成本函数。 在惩罚约束下,我们的最大努力是选择 L2 球与未惩罚成本函数的轮廓相交的点。 正则化参数λ的值越大,惩罚成本函数的增长越快,这导致 L2 球变窄。 例如,如果我们将正则化参数增加到无穷大,则权重系数将实际上变为零,由 L2 球的中心表示。 总结示例的主要信息:我们的目标是最小化未惩罚成本函数和惩罚项的总和,这可以理解为在没有足够训练数据的情况下增加偏差并倾向于使用更简单的模型来减少方差。 拟合模型。
现在让我们讨论 L1 正则化和稀疏性。 L1 正则化的主要概念与我们在此处讨论的相似。 但是,由于 L1 损失是绝对权重系数的总和(请记住 L2 项是二次项),因此我们可以将其表示为菱形预算,如下图所示:

在上图中,我们可以看到成本函数的轮廓触及w[1] = 0处的 L1 菱形。 由于 L1 正则化系统的轮廓很锐利,因此最有可能(即,成本函数的椭圆与 L1 钻石的边界之间的交点)位于轴上,这会鼓励稀疏性。 为什么 L1 正则化会导致稀疏解的数学细节超出了本书的范围。 如果您有兴趣,可以在统计学习的要素,Trevor Hastie,Robert Tibshirani 和 Jerome Friedman,的第 3.4 节中找到有关 L2 与 L1 正则化的出色部分。 斯普林格。
对于 scikit-learn 中支持 L1 正则化的正则化模型,我们可以简单地将penalty参数设置为'l1'以产生稀疏解:
>>> from sklearn.linear_model import LogisticRegression
>>> LogisticRegression(penalty='l1')
将应用于标准 Wine 数据,L1 正则逻辑回归将产生以下稀疏解:
>>> lr = LogisticRegression(penalty='l1', C=0.1)
>>> lr.fit(X_train_std, y_train)
>>> print('Training accuracy:', lr.score(X_train_std, y_train))
Training accuracy: 0.983870967742
>>> print('Test accuracy:', lr.score(X_test_std, y_test))
Test accuracy: 0.981481481481
训练和测试准确性(均为 98%)均未表明我们的模型有任何过拟合。 当我们通过lr.intercept_属性访问拦截项时,我们可以看到该数组返回三个值:
>>> lr.intercept_
array([-0.38379237, -0.1580855 , -0.70047966])
由于我们将LogisticRegression对象拟合到多类数据集上,因此默认情况下,它使用单对剩余(OvR)方法,其中第一个截距属于 适合 1 类与 2 类和 3 类的模型; 第二个值是适合类别 2 与类别 1 和类别 3 的模型的截距; 第三个值是分别适合 3 类与 1 类和 2 类的模型的截距:
>>> lr.coef_
array([[ 0.280, 0.000, 0.000, -0.0282, 0.000,
0.000, 0.710, 0.000, 0.000, 0.000,
0.000, 0.000, 1.236],
[-0.644, -0.0688 , -0.0572, 0.000, 0.000,
0.000, 0.000, 0.000, 0.000, -0.927,
0.060, 0.000, -0.371],
[ 0.000, 0.061, 0.000, 0.000, 0.000,
0.000, -0.637, 0.000, 0.000, 0.499,
-0.358, -0.570, 0.000
]])
我们通过lr.coef_属性访问的权重数组包含三行权重系数,每个类别一个权重向量。 每行包含 13 个权重,其中每个权重乘以 13 维 Wine 数据集中的相应特征以计算净输入:

我们注意到权重向量是稀疏的,这意味着它们只有几个非零的条目。 作为 L1 正则化的结果(用作特征选择方法),我们刚刚训练了一个模型,该模型对于此数据集中的潜在不相关特征具有鲁棒性。
最后,让我们绘制正则化路径,它是针对不同正则化强度的不同特征的权重系数:
>>> import matplotlib.pyplot as plt
>>> fig = plt.figure()
>>> ax = plt.subplot(111)
>>> colors = ['blue', 'green', 'red', 'cyan',
... 'magenta', 'yellow', 'black',
... 'pink', 'lightgreen', 'lightblue',
... 'gray', 'indigo', 'orange']
>>> weights, params = [], []
>>> for c in np.arange(-4, 6):
... lr = LogisticRegression(penalty='l1',
... C=10**c,
... random_state=0)
... lr.fit(X_train_std, y_train)
... weights.append(lr.coef_[1])
... params.append(10**c)
>>> weights = np.array(weights)
>>> for column, color in zip(range(weights.shape[1]), colors):
... plt.plot(params, weights[:, column],
... label=df_wine.columns[column+1],
... color=color)
>>> plt.axhline(0, color='black', linestyle='--', linewidth=3)
>>> plt.xlim([10**(-5), 10**5])
>>> plt.ylabel('weight coefficient')
>>> plt.xlabel('C')
>>> plt.xscale('log')
>>> plt.legend(loc='upper left')
>>> ax.legend(loc='upper center',
... bbox_to_anchor=(1.38, 1.03),
... ncol=1, fancybox=True)
>>> plt.show()
结果图为我们提供了有关 L1 正则化行为的更多见解。 如我们所见,如果使用强正则化参数(C < 0.1)惩罚模型,则所有特征权重将为零; C是正则化参数λ的倒数。

顺序特征选择算法
降低模型复杂性并避免过度拟合的另一种方法是通过特征选择来降低降维,这对于非正规模型尤其有用。 降维技术主要分为两类:特征选择 和特征提取。 使用特征选择,我们选择原始特征的子集。 在特征提取中,我们从特征集中获取信息以构造新的特征子空间。 在本节中,我们将介绍经典的特征选择算法系列。 在下一章,第 5 章和中,通过降维压缩数据,我们将学习有关将数据集压缩到低维特征子空间的不同特征提取技术。
顺序特征选择算法是一系列贪婪搜索算法,用于将初始 d 维特征空间缩小为 k 维特征子空间,其中k<d。 特征选择算法背后的动机是自动选择与问题最相关的特征子集,以通过消除不相关的特征或噪声来提高计算效率或减少模型的泛化误差,这对于不使用特征的算法很有用 支持正则化。 一种经典的顺序特征选择算法是顺序向后选择(SBS),该算法旨在以最小的分类器性能衰减来减小初始特征子空间的维数。 提高计算效率。 在某些情况下,如果模型过度拟合,SBS 甚至可以提高模型的预测能力。
注意
贪婪算法在组合搜索问题的每个阶段都进行局部最优选择,与穷举搜索算法相反,穷举搜索算法通常会得出次优的解决方案,穷举搜索算法评估所有可能的组合并保证找到最优解。 但是,在实践中,穷举搜索通常在计算上不可行,而贪婪算法则允许使用一种不太复杂,在计算上更有效的解决方案。
SBS 算法背后的思想非常简单:SBS 顺序从完整特征子集中删除特征,直到新特征子空间包含所需数量的特征为止。 为了确定在每个阶段要删除的功能,我们需要定义要最小化的标准函数J。 由标准函数计算的标准可以简单地是去除特定特征之后和之前分类器的性能差异。 然后,可以简单地将在每个阶段要删除的功能定义为使该标准最大化的功能。 或者,更直观地说,在每个阶段,我们都消除了删除后性能损失最小的功能。 根据前面的 SBS 定义,我们可以通过 4 个简单步骤概述该算法:
-
用
k = d初始化算法,其中d是整个特征空间X[d]的维数。 -
确定最大化标准
x⁻ = argmaxJ(X[k] - x)的特征x⁻,其中x ∈ X[k]。 -
从功能集
X[k-1] := X[k] - x⁻; k := k - 1中删除功能x⁻。 -
如果
k等于所需功能的数量,则终止;否则,请转到步骤 2。注意
您可以在 大规模特征选择技术的比较研究,F. Ferri,P。Pudil,M。Hatef 和 J. Kittler 中找到几种连续特征算法的详细评估。 大规模特征选择技术的比较研究。 练习 IV 中的模式识别,第 403-413 页,1994。
不幸的是, SBS 算法尚未在 scikit-learn 中实现。 但是,因为它是如此简单,所以让我们从头开始在 Python 中实现它:
from sklearn.base import clone
from itertools import combinations
import numpy as np
from sklearn.cross_validation import train_test_split
from sklearn.metrics import accuracy_score
class SBS():
def __init__(self, estimator, k_features,
scoring=accuracy_score,
test_size=0.25, random_state=1):
self.scoring = scoring
self.estimator = clone(estimator)
self.k_features = k_features
self.test_size = test_size
self.random_state = random_state
def fit(self, X, y):
X_train, X_test, y_train, y_test = \
train_test_split(X, y, test_size=self.test_size,
random_state=self.random_state)
dim = X_train.shape[1]
self.indices_ = tuple(range(dim))
self.subsets_ = [self.indices_]
score = self._calc_score(X_train, y_train,
X_test, y_test, self.indices_)
self.scores_ = [score]
while dim > self.k_features:
scores = []
subsets = []
for p in combinations(self.indices_, r=dim-1):
score = self._calc_score(X_train, y_train,
X_test, y_test, p)
scores.append(score)
subsets.append(p)
best = np.argmax(scores)
self.indices_ = subsets[best]
self.subsets_.append(self.indices_)
dim -= 1
self.scores_.append(scores[best])
self.k_score_ = self.scores_[-1]
return self
def transform(self, X):
return X[:, self.indices_]
def _calc_score(self, X_train, y_train,
X_test, y_test, indices):
self.estimator.fit(X_train[:, indices], y_train)
y_pred = self.estimator.predict(X_test[:, indices])
score = self.scoring(y_test, y_pred)
return score
在先前的实现中,我们定义了k_features参数以指定要返回的所需特征数量。 默认情况下,我们使用 scikit-learn 中的accuracy_score来评估模型和估计器的性能,以对特征子集进行分类。 在fit方法的while循环内部,评估并缩减itertools.combination函数创建的特征子集,直到特征子集具有所需的维数为止。 在每次迭代中,基于内部创建的测试数据集X_test将最佳子集的准确性得分收集在列表self.scores_中。 我们稍后将使用这些分数来评估结果。 最终特征子集的列索引已分配给self.indices_,我们可以通过transform方法使用它来返回具有所选特征列的新数据数组。 请注意,我们没有删除fit方法中的显式标准,而是仅删除了性能最好的子集中不包含的特征。
现在,让我们来看一下使用来自 scikit-learn 的 KNN 分类器的 SBS 实现:
>>> from sklearn.neighbors import KNeighborsClassifier
>>> import matplotlib.pyplot as plt
>>> knn = KNeighborsClassifier(n_neighbors=2)
>>> sbs = SBS(knn, k_features=1)
>>> sbs.fit(X_train_std, y_train)
尽管我们的 SBS 实现已将数据集拆分为fit函数中的测试和训练数据集,但我们仍将训练数据集X_train馈给了算法。 然后,SBS fit方法将创建用于测试(验证)和训练的新训练子集,这就是为什么此测试集也称为验证数据集的原因。 这种方法对于防止我们的原始测试集成为训练数据的一部分是必要的。
请记住,我们的 SBS 算法在每个阶段都会收集最佳特征子集的分数,因此,让我们继续进行实施中更令人兴奋的部分,并绘制在验证数据集上计算出的 KNN 分类器的分类精度。 代码如下:
>>> k_feat = [len(k) for k in sbs.subsets_]
>>> plt.plot(k_feat, sbs.scores_, marker='o')
>>> plt.ylim([0.7, 1.1])
>>> plt.ylabel('Accuracy')
>>> plt.xlabel('Number of features')
>>> plt.grid()
>>> plt.show()
如下图所示,由于减少了特征数量,验证数据集上的 KNN 分类器的精度有所提高,这很可能是由于我们在[...]中讨论的减少了第 3 章,“使用 Scikit-learn 的机器学习分类器”中的 KNN 算法上下文。 此外,我们可以在下图中看到,对于 k = {5,6,7,8,9,10},分类器达到了 100%的精度:

为了满足我们的好奇心,让我们看看在验证数据集上产生如此出色性能的这五个功能是什么:
>>> k5 = list(sbs.subsets_[8])
>>> print(df_wine.columns[1:][k5])
Index(['Alcohol', 'Malic acid', 'Alcalinity of ash', 'Hue', 'Proline'], dtype='object')
使用前面的代码,我们从sbs.subsets_属性中第 9 个位置的位置获得了 5 个特征子集的列索引,并从 Pandas Wine DataFrame的列索引中返回了相应的特征名称。 ]。
接下来,让我们评估原始测试集上的 KNN 分类器的性能:
>>> knn.fit(X_train_std, y_train)
>>> print('Training accuracy:', knn.score(X_train_std, y_train))
Training accuracy: 0.983870967742
>>> print('Test accuracy:', knn.score(X_test_std, y_test))
Test accuracy: 0.944444444444
在前面的代码中,我们使用了完整的功能集,并在训练数据集上获得了约 98.4%的准确性。 但是,测试数据集的准确性略低(〜94.4%),这表明略有过度拟合的迹象。 现在,让我们使用选定的 5 个功能子集,看看 KNN 的表现如何:
>>> knn.fit(X_train_std[:, k5], y_train)
>>> print('Training accuracy:',
... knn.score(X_train_std[:, k5], y_train))
Training accuracy: 0.959677419355
>>> print('Test accuracy:',
... knn.score(X_test_std[:, k5], y_test))
Test accuracy: 0.962962962963
使用的少于 Wine 数据集中原始特征的一半,测试集的预测准确性提高了近 2%。 此外,我们减少了过拟合,这可以从测试(〜96.3%)与训练(〜96.0%)准确性之间的微小差距中看出。
注意
scikit-learn 中的特征选择算法
可以通过 scikit-learn 获得更多功能选择算法。 其中包括基于特征权重的递归后向消除,基于树的重要性选择方法以及单变量统计检验。 关于不同功能选择方法的全面讨论超出了本书的范围,但是可以在这个页面中找到带有说明性示例的出色摘要。 ]。
评估随机森林的特征重要性
在前面的中,您学习了如何使用 L1 正则化通过 Logistic 回归将不相关的特征归零,以及如何使用 SBS 算法进行特征选择。 从数据集中选择相关特征的另一种有用方法是使用随机森林,这是我们在第 3 章,“使用 Scikit-learn”的机器学习分类器中引入的一种集成技术。 使用随机森林,我们可以根据森林中所有决策树计算出的平均杂质减少量来衡量特征重要性,而无需假设我们的数据是否可线性分离。 方便地,scikit-learn 中的随机森林实现已经为我们收集了功能重要性,因此我们可以在拟合RandomForestClassifier之后通过feature_importances_属性访问它们。 通过执行以下代码,我们现在将在 Wine 数据集上训练一万棵树木的森林,并通过它们各自的重要性度量对 13 个要素进行排名。 请记住(根据我们在第 3 章,“使用 Scikit-learn 进行的机器学习分类器”的讨论),我们不需要使用标准化或标准化的基于树的模型。 代码如下:
>>> from sklearn.ensemble import RandomForestClassifier
>>> feat_labels = df_wine.columns[1:]
>>> forest = RandomForestClassifier(n_estimators=10000,
... random_state=0,
... n_jobs=-1)
>>> forest.fit(X_train, y_train)
>>> importances = forest.feature_importances_
>>> indices = np.argsort(importances)[::-1]
>>> for f in range(X_train.shape[1]):
... print("%2d) %-*s %f" % (f + 1, 30,
... feat_labels[indices[f]],
... importances[indices[f]]))
1) Color intensity 0.182483
2) Proline 0.158610
3) Flavanoids 0.150948
4) OD280/OD315 of diluted wines 0.131987
5) Alcohol 0.106589
6) Hue 0.078243
7) Total phenols 0.060718
8) Alcalinity of ash 0.032033
9) Malic acid 0.025400
10) Proanthocyanins 0.022351
11) Magnesium 0.022078
12) Nonflavanoid phenols 0.014645
13) Ash 0.013916
>>> plt.title('Feature Importances')
>>> plt.bar(range(X_train.shape[1]),
... importances[indices],
... color='lightblue',
... align='center')
>>> plt.xticks(range(X_train.shape[1]),
... feat_labels[indices], rotation=90)
>>> plt.xlim([-1, X_train.shape[1]])
>>> plt.tight_layout()
>>> plt.show()
在执行前面的代码之后,我们创建了一个图,根据其相对重要性对 Wine 数据集中的不同特征进行排名; 请注意,功能重要性已归一化,因此它们的总和为 1.0。

我们可以得出结论,根据 10,000 个决策树中的平均杂质减少量,酒的颜色强度是数据集中最具区分性的特征。 有趣的是,上图中的前三个特征也在我们上一节中实现的 SBS 算法选择的前五个特征中。 但是,就可解释性而言,随机森林技术带有重要的陷阱,值得一提。 例如,如果两个或多个特征高度相关,则一个特征的排名可能很高,而其他特征的信息可能无法完全捕获。 另一方面,如果我们仅对模型的预测性能感兴趣,而对特征重要性的解释不感兴趣,则无需担心此问题。 总结本节有关特征重要性和随机森林的情况,值得一提的是,scikit-learn 还实现了transform方法,该方法在模型拟合后根据用户指定的阈值选择特征,如果要使用[ RandomForestClassifier作为功能选择器和 scikit-learn 管道中的中间步骤,这使我们可以将不同的预处理步骤与估算器连接起来,如我们在第 6 章,“评估和超参数调整,学习模型的最佳实践”中所见。 例如,我们可以使用以下代码将阈值设置为 0.15,以将数据集缩小为 3 个最重要的特征,即颜色强度,脯氨酸和类黄酮:
>>> X_selected = forest.transform(X_train, threshold=0.15)
>>> X_selected.shape
(124, 3)


三十九、通过降维压缩数据
在第 4 章和“建立良好的训练集–数据预处理”中,您了解了使用不同的特征选择技术来降低数据集维数的不同方法。 用于降维的特征选择的替代方法是特征提取。 在本章中,您将学习三种基本技术,这些技术可以帮助我们通过将数据集转换为维数比原始维数低的新特征子空间来总结数据集的信息内容。 数据压缩是机器学习中的一个重要主题,它有助于我们存储和分析在现代技术时代生成和收集的不断增长的数据量。 在本章中,我们将介绍以下主题:
- 用于无监督数据压缩的主成分分析(PCA)
- 线性判别分析(LDA)作为一种监督降维技术,可最大限度地提高类别可分离性
- 通过核主成分分析进行非线性降维
通过主成分分析进行无监督的降维
与特征选择类似,我们可以使用特征提取来减少数据集中的特征数量。 但是,虽然我们在使用特征选择算法(例如顺序向后选择)时保留了原始特征,但是我们使用特征提取将数据转换或投影到新的特征空间上。 在降维的情况下,特征提取可以理解为一种数据压缩的方法,其目的是保留大多数相关信息。 特征提取通常用于提高计算效率,但也可以帮助减少维度的诅咒-特别是在我们使用非正规模型的情况下。
主成分分析(PCA)是无监督线性变换技术,已广泛应用于不同领域,最显着的是用于降维。 PCA 的其他流行应用包括在股票市场交易中进行探索性数据分析和信号去噪,以及在生物信息学领域分析基因组数据和基因表达水平。 PCA 帮助我们根据特征之间的相关性来识别数据中的模式。 简而言之,PCA 旨在在高维数据中找到最大方差的方向,并将其投影到尺寸等于或小于原始维的新子空间中。 鉴于新特征轴彼此正交的约束,新子空间的正交轴(主分量)可以解释为最大方差方向,如下图所示。 这里,x[1]和x[2]是原始特征轴,PC1 和 PC2 是主要组成部分:

如果我们使用 PCA 进行降维,则会构建一个d × k-维变换矩阵W,该矩阵允许我们将样本向量x映射到一个新的k-维特征子空间,该子空间的维数少于 原始的d-维特征空间:



将原始d维数据转换到这个新的k维子空间(通常为k << d)的结果是,第一个主成分将具有最大的方差,并且所有随后的主成分将具有最大的可能方差 假设它们与其他主成分不相关(正交),则为方差。 请注意,PCA 方向对数据缩放高度敏感,如果在不同尺度上对特征进行测量,并且我们想对所有特征赋予同等的重要性,则我们需要先将特征优先于。
在更详细地研究用于降维的 PCA 算法之前,让我们通过几个简单的步骤来总结该方法:
- 标准化
d维数据集。 - 构造协方差矩阵。
- 将协方差矩阵分解为其特征向量和特征值。
- 选择与
k最大特征值相对应的k特征向量,其中k是新特征子空间(k <= d)的维数。 - 从“顶部”
k特征向量构造投影矩阵W。 - 使用投影矩阵
W变换d维输入数据集X,以获得新的k维特征子空间。
总计和解释方差
在本小节中,我们将处理主成分分析的前四个步骤:标准化数据,构建协方差矩阵,获得协方差矩阵的特征值和特征向量,以及通过将特征值递减排序以 对特征向量进行排名。
首先,我们将从加载第 4 章和“构建良好的训练集–数据预处理”中一直使用的葡萄酒数据集开始:
>>> import pandas as pd
>>> df_wine = pd.read_csv('https://archive.ics.uci.edu/ml/machine-learning-databases/wine/wine.data', header=None)
接下来,我们将 Wine 数据处理为单独的训练和测试集(分别使用 70%和 30%的数据)并将其标准化为单位差异。
>>> from sklearn.cross_validation import train_test_split
>>> from sklearn.preprocessing import StandardScaler
>>> X, y = df_wine.iloc[:, 1:].values, df_wine.iloc[:, 0].values
>>> X_train, X_test, y_train, y_test = \
... train_test_split(X, y,
... test_size=0.3, random_state=0)
>>> sc = StandardScaler()
>>> X_train_std = sc.fit_transform(X_train)
>>> X_test_std = sc.transform(X_test)
通过执行前面的代码完成强制性预处理步骤后,让我们进入第二步:构造协方差矩阵。 对称d × d维协方差矩阵(其中d是数据集中的维数)存储不同特征之间的成对协方差。 例如,可以通过以下公式计算总体水平上两个特征x[k]之间的协方差:

在此,μ[j]和μ[k]分别是特征j和k的样本均值。 请注意,如果我们标准化数据集,则样本均值为零。 两个特征之间的正协方差表示特征一起增加或减少,而负协方差则表示特征沿相反的方向变化。 例如,然后可以将三个特征的协方差矩阵写为(请注意Σ代表希腊字母 sigma,请勿与和符号混淆) :

协方差矩阵的特征向量表示主成分(最大方差的方向),而相应的特征值将定义其大小。 对于 Wine 数据集,我们将从13 × 13维协方差矩阵中获得 13 个特征向量和特征值。
现在,让我们获得协方差矩阵的特征对。 正如我们从线性代数或微积分入门课中肯定记得的那样,特征向量v满足以下条件:

在这里,λ是一个标量:特征值。 由于特征向量和特征值的手动计算是一项繁琐且复杂的任务,因此我们将使用 NumPy 的linalg.eig函数来获取 Wine 协方差矩阵的特征对:
>>> import numpy as np
>>> cov_mat = np.cov(X_train_std.T)
>>> eigen_vals, eigen_vecs = np.linalg.eig(cov_mat)
>>> print('\nEigenvalues \n%s' % eigen_vals)
Eigenvalues
[ 4.8923083 2.46635032 1.42809973 1.01233462 0.84906459 0.60181514
0.52251546 0.08414846 0.33051429 0.29595018 0.16831254 0.21432212
0.2399553 ]
使用numpy.cov函数,我们计算了标准化训练数据集的协方差矩阵。 使用linalg.eig函数,我们进行了特征分解,生成了一个向量(eigen_vals),该向量由 13 个特征值组成,并且对应的特征向量作为列存储在13 × 13-维矩阵(eigen_vecs)中。
注意
尽管numpy.linalg.eig函数旨在分解非对称方阵,但您可能会发现在某些情况下它会返回复杂的特征值。
已实现相关函数numpy.linalg.eigh来分解 Hermetian 矩阵,这是在数值上更稳定的方法,可用于处理对称矩阵(例如协方差矩阵); numpy.linalg.eigh始终返回实特征值。
由于我们想通过将压缩到新的特征子空间中来降低数据集的维数,因此,我们仅选择包含大部分信息(方差)的特征向量的子集(主要成分)。 由于特征值定义了特征向量的大小,因此我们必须通过减小大小对特征值进行排序。 我们基于其对应特征值的值对顶部k特征向量感兴趣。 但是在收集那些k信息最多的特征向量之前,让我们绘制特征值的方差解释比率。
特征值λ[j]的方差解释比率只是特征值λ[j]与特征值总和的分数:

使用 NumPy cumsum函数,我们可以计算出解释方差的累积和,我们将通过 matplotlib 的step函数进行绘制:
>>> tot = sum(eigen_vals)
>>> var_exp = [(i / tot) for i in
... sorted(eigen_vals, reverse=True)]
>>> cum_var_exp = np.cumsum(var_exp)
>>> import matplotlib.pyplot as plt
>>> plt.bar(range(1,14), var_exp, alpha=0.5, align='center',
... label='individual explained variance')
>>> plt.step(range(1,14), cum_var_exp, where='mid',
... label='cumulative explained variance')
>>> plt.ylabel('Explained variance ratio')
>>> plt.xlabel('Principal components')
>>> plt.legend(loc='best')
>>> plt.show()
生成的图表明仅第一个主成分占方差的 40%。 此外,我们可以看到前两个主要成分的组合几乎解释了数据中 60%的方差:

尽管解释的方差图使我们想起了功能重要性,但我们在第 4 章,“建立良好的训练集–数据预处理”中通过随机森林计算了, 提醒自己,PCA 是一种不受监督的方法,这意味着有关类标签的信息将被忽略。 随机森林使用类成员关系信息来计算节点杂质,而方差则测量值沿特征轴的分布。
功能转换
在成功将协方差矩阵分解为特征对之后,我们现在进行最后三个步骤,将 Wine 数据集转换到新的主成分轴上。 在本节中,我们将按照特征值的降序对特征对进行排序,从选定的特征向量构建投影矩阵,然后使用该投影矩阵将数据转换到低维子空间上。
我们首先通过降低特征值的顺序对特征对进行排序:
>>> eigen_pairs =[(np.abs(eigen_vals[i]),eigen_vecs[:,i])
... for i inrange(len(eigen_vals))]
>>> eigen_pairs.sort(reverse=True)
接下来,我们收集与两个最大值对应的两个特征向量,以捕获此数据集中约 60%的方差。 请注意,出于说明的目的,我们仅选择了两个特征向量,因为我们将在本小节的后面部分通过二维散点图绘制数据。 实际上,必须根据计算效率和分类器性能之间的折衷来确定主成分的数量:
>>> w= np.hstack((eigen_pairs[0][1][:, np.newaxis],
... eigen_pairs[1][1][:, np.newaxis]))
>>> print('Matrix W:\n',w)
Matrix W:
[[ 0.14669811 0.50417079]
[-0.24224554 0.24216889]
[-0.02993442 0.28698484]
[-0.25519002 -0.06468718]
[ 0.12079772 0.22995385]
[ 0.38934455 0.09363991]
[ 0.42326486 0.01088622]
[-0.30634956 0.01870216]
[ 0.30572219 0.03040352]
[-0.09869191 0.54527081]
[ 0.30032535 -0.27924322]
[ 0.36821154 -0.174365 ]
[ 0.29259713 0.36315461]]
通过执行前面的代码,我们从顶部的两个特征向量创建了13 × 2维投影矩阵W。 使用投影矩阵,我们现在可以将样本x(表示为1 × 13-维行向量)变换到 PCA 子空间上,从而获得x',这是一个由两个新功能组成的二维样本向量:

>>> X_train_std[0].dot(w)
array([ 2.59891628, 0.00484089])
类似地,我们可以通过计算矩阵点积,将将整个124 × 13维训练数据集转换为两个主成分:

>>> X_train_pca = X_train_std.dot(w)
最后,让我们在二维散点图中可视化转换后的 Wine 训练集,现在将其存储为124 × 2-维矩阵。
>>> colors = ['r', 'b', 'g']
>>> markers = ['s', 'x', 'o']
>>> for l, c, m in zip(np.unique(y_train), colors, markers):
... plt.scatter(X_train_pca[y_train==l, 0],
... X_train_pca[y_train==l, 1],
... c=c, label=l, marker=m)
>>> plt.xlabel('PC 1')
>>> plt.ylabel('PC 2')
>>> plt.legend(loc='lower left')
>>> plt.show()
正如我们在结果图中看到的那样(如下图所示),数据沿x轴(第一个主成分)分布的比第二个主成分(y-轴),这与我们在上一个小节中创建的解释的方差比图一致。 但是,我们可以直观地看到线性分类器很可能能够很好地分离这些类:

尽管出于前面的散点图中的说明目的,我们对类标签信息进行了编码,但我们必须记住 PCA 是一种不使用类标签信息的无监督技术。
scikit-learn 中的主成分分析
尽管前面小节中的详细方法帮助我们了解了 PCA 的内部工作原理,但是我们现在将讨论如何使用 scikit-learn 中实现的PCA类。 PCA是 scikit-learn 的提升器类中的另一类,在该类中,我们首先使用训练数据拟合模型,然后再使用相同的模型参数转换训练数据和测试数据。 现在,让我们在 Wine 训练数据集上使用来自 scikit-learn 的PCA,通过 logistic 回归对转换后的样本进行分类,并通过我们在中定义的plot_decision_region函数可视化决策区域 第 2 章和“训练机器学习分类算法”:
from matplotlib.colors import ListedColormap
def plot_decision_regions(X, y, classifier, resolution=0.02):
# setup marker generator and color map
markers = ('s', 'x', 'o', '^', 'v')
colors = ('red', 'blue', 'lightgreen', 'gray', 'cyan')
cmap = ListedColormap(colors[:len(np.unique(y))])
# plot the decision surface
x1_min, x1_max = X[:, 0].min() - 1, X[:, 0].max() + 1
x2_min, x2_max = X[:, 1].min() - 1, X[:, 1].max() + 1
xx1, xx2 = np.meshgrid(np.arange(x1_min, x1_max, resolution),
np.arange(x2_min, x2_max, resolution))
Z = classifier.predict(np.array([xx1.ravel(), xx2.ravel()]).T)
Z = Z.reshape(xx1.shape)
plt.contourf(xx1, xx2, Z, alpha=0.4, cmap=cmap)
plt.xlim(xx1.min(), xx1.max())
plt.ylim(xx2.min(), xx2.max())
# plot class samples
for idx, cl in enumerate(np.unique(y)):
plt.scatter(x=X[y == cl, 0], y=X[y == cl, 1],
alpha=0.8, c=cmap(idx),
marker=markers[idx], label=cl)
>>> from sklearn.linear_model import LogisticRegression
>>> from sklearn.decomposition import PCA
>>> pca = PCA(n_components=2)
>>> lr = LogisticRegression()
>>> X_train_pca = pca.fit_transform(X_train_std)
>>> X_test_pca = pca.transform(X_test_std)
>>> lr.fit(X_train_pca, y_train)
>>> plot_decision_regions(X_train_pca, y_train, classifier=lr)
>>> plt.xlabel('PC1')
>>> plt.ylabel('PC2')
>>> plt.legend(loc='lower left')
>>> plt.show()
通过执行前面的代码,我们现在应该看到训练模型的决策区域缩小为两个主要成分轴。

如果我们通过 scikit-learn 将 PCA 投影与我们自己的 PCA 实现进行比较,则我们会注意到,通过我们的分步方法,上图是先前 PCA 的镜像。 请注意,这不是由于这两种实现方式中的任何一种都会导致错误,而是这种差异的原因在于,根据本征求解器,本征向量可以具有负号或正号。 没关系,但是如果需要,我们可以通过将数据乘以-1来简单地还原镜像。 注意,特征向量通常按比例缩放到单位长度1。 为了完整起见,让我们在转换后的测试数据集上绘制逻辑回归的决策区域,以查看它是否可以很好地分离类:
>>> plot_decision_regions(X_test_pca, y_test, classifier=lr)
>>> plt.xlabel('PC1')
>>> plt.ylabel('PC2')
>>> plt.legend(loc='lower left')
>>> plt.show()
在通过执行前面的代码绘制测试集的决策区域之后,我们可以看到逻辑回归在这个小的二维特征子空间上执行得很好,并且只对测试数据集中的一个样本进行了错误分类。

如果我们对对不同主成分的解释方差比率感兴趣,我们可以简单地将n_components参数设置为None来初始化PCA类,以便保留所有主成分和解释方差比率 然后可以通过explained_variance_ratio_属性进行访问:
>>> pca = PCA(n_components=None)
>>> X_train_pca = pca.fit_transform(X_train_std)
>>> pca.explained_variance_ratio_
array([ 0.37329648, 0.18818926, 0.10896791, 0.07724389, 0.06478595,
0.04592014, 0.03986936, 0.02521914, 0.02258181, 0.01830924,
0.01635336, 0.01284271, 0.00642076])
请注意,我们在初始化 PCA 类时设置了n_components=None,以便它将按排序顺序返回所有主成分,而不是执行降维。
通过线性判别分析进行监督数据压缩
线性判别分析(LDA)可以用作特征提取技术,以提高计算效率并减少因非规则模型中的尺寸诅咒而引起的过拟合程度。
LDA 背后的一般概念与 PCA 非常相似,而 PCA 则试图在数据集中找到最大方差的正交分量轴。 LDA 中的目标是找到可优化类可分离性的特征子空间。 LDA 和 PCA 都是线性变换技术,可用于减少数据集中的维数。 前者是无监督算法,而后者是有监督的。 因此,我们可能会直观地认为,与 PCA 相比,LDA 是用于分类任务的高级特征提取技术。 但是,上午 Martinez 报告说,在某些情况下,例如,如果每个类别仅包含少量样本(AM Martinez 和 AC Kak。 PCA 与 LDA [。Pattern Analysis and Machine Intelligence,IEEE Transactions on,23(2):228-233,2001)。
注意
尽管 LDA 有时也称为 Fisher 的 LDA,但 Ronald A. Fisher 于 1936 年最初针对两类分类问题制定了 Fisher 线性判别式(RA Fisher。在分类学问题中使用多重度量 (《优生学年鉴》,7(2):179–188,1936 年)。 费舍尔线性判别式后来由 C.Radhakrishna Rao 在 1948 年等分类协方差和正态分布类的假设下推广到多类问题,我们现在将其称为 LDA(CR Rao。 生物学分类,英国皇家统计学会杂志,B 系列(方法论),10(2):159–203,1948 年)。
下图总结了针对两类问题的 LDA 概念。 来自类别 1 的样本显示为十字形,来自类别 2 的样本显示为圆形:

x-轴(LD 1)上显示的线性判别式可以很好地分隔两个正态分布的类。 尽管y轴(LD 2)上显示的示例性线性判别式捕获了数据集中的许多方差,但由于它无法捕获任何类别歧视性,因此它作为良好的线性判别式将失败 信息。
LDA 中的一种假设是数据是正态分布的。 同样,我们假设这些类具有相同的协方差矩阵,并且这些特征在统计上彼此独立。 但是,即使略微违反了这些假设中的一个或多个假设,用于降维的 LDA 仍然可以很好地发挥作用(RO Duda,PE Hart 和 DG Stork。模式分类。第二版,纽约, 2001)。
在下面的小节中,我们将深入研究 LDA 的内部工作原理,然后让我们总结一下 LDA 方法的关键步骤:
- 标准化
d维数据集(d是要素数量)。 - 对于每个类,计算
d维平均向量。 - 构造类间散布矩阵
S[B]和类内散布矩阵S[w]。 - 计算矩阵
S[w]^(-1)S[B]的特征向量和相应的特征值。 - 选择与
k最大特征值对应的k特征向量,以构建d × k维变换矩阵W; 特征向量是该矩阵的列。 - 使用变换矩阵
W将样本投影到新的特征子空间上。
注意
我们在使用 LDA 时所做的假设是,这些要素呈正态分布且彼此独立。 同样,LDA 算法假定各个类的协方差矩阵相同。 但是,即使我们在一定程度上违反了这些假设,LDA 在降维和分类任务(RO Duda,PE Hart 和 DG Stork。模式分类。2nd。Edition。New)上仍然可以很好地发挥作用。 约克,2001 年)。
计算散点矩阵
由于我们已经在本章开始的[PCA]部分中对葡萄酒 Wine 数据集的功能进行了标准化,因此我们可以跳过第一步,继续进行均值向量的计算,我们将 分别用于构造类内散布矩阵和类间散布矩阵。 每个平均值向量m[i]存储有关i类样本的平均特征值μ[m]:

结果是三个均值向量:

>>> np.set_printoptions(precision=4)
>>> mean_vecs = []
>>> for label in range(1,4):
... mean_vecs.append(np.mean(
... X_train_std[y_train==label], axis=0))
... print('MV %s: %s\n' %(label, mean_vecs[label-1]))
MV 1: [ 0.9259 -0.3091 0.2592 -0.7989 0.3039 0.9608 1.0515 -0.6306 0.5354
0.2209 0.4855 0.798 1.2017]
MV 2: [-0.8727 -0.3854 -0.4437 0.2481 -0.2409 -0.1059 0.0187 -0.0164 0.1095
-0.8796 0.4392 0.2776 -0.7016]
MV 3: [ 0.1637 0.8929 0.3249 0.5658 -0.01 -0.9499 -1.228 0.7436 -0.7652
0.979 -1.1698 -1.3007 -0.3912]
使用均值向量,我们现在可以计算类内散布矩阵S[w]:

这是通过将每个单独类别i的各个散布矩阵S[i]相加得出的:

>>> d = 13 # number of features
>>> S_W = np.zeros((d, d))
>>> for label,mv in zip(range(1,4), mean_vecs):
... class_scatter = np.zeros((d, d))
... for row in X_train[y_train == label]:
... row, mv = row.reshape(d, 1), mv.reshape(d, 1)
... class_scatter += (row-mv).dot((row-mv).T)
... S_W += class_scatter
>>> print('Within-class scatter matrix: %sx%s'
... % (S_W.shape[0], S_W.shape[1]))
Within-class scatter matrix: 13x13
我们在计算散点矩阵时所做的假设是训练集中的类标签是均匀分布的。 但是,如果我们打印类标签的数量,则会发现违反了该假设:
>>> print('Class label distribution: %s'
... % np.bincount(y_train)[1:])
Class label distribution: [40 49 35]
因此,在将各个散点矩阵S[i]汇总为散点矩阵S[w]之前,我们希望对它们进行缩放。 当将散布矩阵除以类别样本的数量N[i]时,我们可以看到,计算散布矩阵实际上与计算协方差矩阵Σ[i]相同。 协方差矩阵是散射矩阵的归一化版本:

>>> d = 13 # number of features
>>> S_W = np.zeros((d, d))
>>> for label,mv in zip(range(1, 4), mean_vecs):
... class_scatter = np.cov(X_train_std[y_train==label].T)
... S_W += class_scatter
>>> print('Scaled within-class scatter matrix: %sx%s'
... % (S_W.shape[0], S_W.shape[1]))
Scaled within-class scatter matrix: 13x13
在计算了缩放后的类内散布矩阵(或协方差矩阵)之后,我们可以继续进行下一步,并计算类间散布矩阵S[B]:

此处,m是所计算的总体平均值,包括来自所有类别的样本。
>>> mean_overall = np.mean(X_train_std, axis=0)
>>> d = 13 # number of features
>>> S_B = np.zeros((d, d))
>>> for i,mean_vec in enumerate(mean_vecs):
... n = X_train[y_train==i+1, :].shape[0]
... mean_vec = mean_vec.reshape(d, 1)
... mean_overall = mean_overall.reshape(d, 1)
S_B += n * (mean_vec - mean_overall).dot(
... (mean_vec - mean_overall).T)
print('Between-class scatter matrix: %sx%s'
... % (S_B.shape[0], S_B.shape[1]))
Between-class scatter matrix: 13x13
为新特征子空间选择线性判别式
LDA 的其余步骤与 PCA 的步骤相似。 但是,我们没有对协方差矩阵进行特征分解,而是解决了矩阵S[w]^(-1)S[B]的广义特征值问题:
>>>eigen_vals, eigen_vecs =\
...np.linalg.eig(np.linalg.inv(S_W).dot(S_B))
计算完特征对之后,我们现在可以按降序对特征值进行排序:
>>> eigen_pairs = [(np.abs(eigen_vals[i]), eigen_vecs[:,i])
... for i in range(len(eigen_vals))]
>>> eigen_pairs = sorted(eigen_pairs,
... key=lambda k: k[0], reverse=True)
>>> print('Eigenvalues in decreasing order:\n')
>>> for eigen_val in eigen_pairs:
... print(eigen_val[0])
Eigenvalues in decreasing order:
452.721581245
156.43636122
8.11327596465e-14
2.78687384543e-14
2.78687384543e-14
2.27622032758e-14
2.27622032758e-14
1.97162599817e-14
1.32484714652e-14
1.32484714652e-14
1.03791501611e-14
5.94140664834e-15
2.12636975748e-16
在 LDA 中,线性判别式的数量最多为c-1,其中c是类别标签的数量,因为类别之间的散布矩阵S[B]是等级为 1 或更小的c矩阵之和。 我们确实可以看到我们只有两个非零特征值(特征值 3-13 不完全为零,但这是由于 NumPy 中的浮点算法所致)。 请注意,在极少数情况下,理想的共线性(所有对齐的样本点都位于一条直线上),协方差矩阵的秩为 1,这将导致仅一个特征向量具有非零特征值。
为了测量线性判别式(特征向量)捕获了多少类别区分信息,让我们通过减少特征值来绘制线性判别式,类似于在 PCA 部分中创建的解释方差图。 为简单起见,我们将类别区分信息的内容称为可辨别性。
>>> tot = sum(eigen_vals.real)
>>> discr = [(i / tot) for i in sorted(eigen_vals.real, reverse=True)]
>>> cum_discr = np.cumsum(discr)
>>> plt.bar(range(1, 14), discr, alpha=0.5, align='center',
... label='individual "discriminability"')
>>> plt.step(range(1, 14), cum_discr, where='mid',
... label='cumulative "discriminability"')
>>> plt.ylabel('"discriminability" ratio')
>>> plt.xlabel('Linear Discriminants')
>>> plt.ylim([-0.1, 1.1])
>>> plt.legend(loc='best')
>>> plt.show()
从结果图中可以看出,前两个线性判别式在 Wine 训练数据集中捕获了约 100%的有用信息:

现在让我们堆叠两个最有区别的特征向量列,以创建变换矩阵W:
>>> w = np.hstack((eigen_pairs[0][1][:, np.newaxis].real,
... eigen_pairs[1][1][:, np.newaxis].real))
>>> print('Matrix W:\n', w)
Matrix W:
[[ 0.0662 -0.3797]
[-0.0386 -0.2206]
[ 0.0217 -0.3816]
[-0.184 0.3018]
[ 0.0034 0.0141]
[-0.2326 0.0234]
[ 0.7747 0.1869]
[ 0.0811 0.0696]
[-0.0875 0.1796]
[-0.185 -0.284 ]
[ 0.066 0.2349]
[ 0.3805 0.073 ]
[ 0.3285 -0.5971]]
将样本投影到新特征空间上
使用我们在上一节中创建的转换矩阵W,我们现在可以通过乘以矩阵来转换训练数据集:

>>> X_train_lda = X_train_std.dot(w)
>>> colors = ['r', 'b', 'g']
>>> markers = ['s', 'x', 'o']
>>> for l, c, m in zip(np.unique(y_train), colors, markers):
... plt.scatter(X_train_lda[y_train==l, 0]*(-1)
... X_train_lda[y_train==l, 1]*(-1)
... c=c, label=l, marker=m)
>>> plt.xlabel('LD 1')
>>> plt.ylabel('LD 2')
>>> plt.legend(loc='lower right')
>>> plt.show()
正如我们在结果图中看到的那样,这三个酒类现在在新功能子空间中是线性可分离的:

通过 scikit-learn 进行的 LDA
分步实施对于了解 LDA 的内部工作原理以及了解 LDA 与 PCA 之间的差异是一个很好的练习。 现在,让我们看一下在 scikit-learn 中实现的LDA类:
>>> from sklearn.lda import LDA
>>> lda = LDA(n_components=2)
>>> X_train_lda = lda.fit_transform(X_train_std, y_train)
接下来,让我们看看逻辑回归分类器在 LDA 转换后如何处理低维训练数据集:
>>> lr = LogisticRegression()
>>> lr = lr.fit(X_train_lda, y_train)
>>> plot_decision_regions(X_train_lda, y_train, classifier=lr)
>>> plt.xlabel('LD 1')
>>> plt.ylabel('LD 2')
>>> plt.legend(loc='lower left')
>>> plt.show()
查看结果图,我们发现逻辑回归模型对类别 2 的样本之一进行了错误分类:

通过降低正则化强度,我们可能可以改变决策边界,以便逻辑回归模型可以对训练数据集中的所有样本进行正确分类。 但是,让我们看一下测试集上的结果:
>>> X_test_lda = lda.transform(X_test_std)
>>> plot_decision_regions(X_test_lda, y_test, classifier=lr)
>>> plt.xlabel('LD 1')
>>> plt.ylabel('LD 2')
>>> plt.legend(loc='lower left')
>>> plt.show()
正如我们在结果图中看到的那样,逻辑回归分类器通过仅使用二维特征子空间而不是原始的 13 Wine,就可以对测试数据集中的样本进行分类,从而获得完美的准确性得分。 特征:

使用内核主成分分析进行非线性映射
许多机器学习算法都对输入数据的线性可分离性进行了假设。 您了解到,感知器甚至需要完全线性可分离的训练数据来收敛。 到目前为止,我们已经介绍的其他算法都假设缺乏完美的线性可分离性是由于噪声引起的:Adaline,逻辑回归和(标准)支持向量机(SVM)[ 仅举几例。 但是,如果要处理非线性问题(在实际应用中可能会经常遇到),则用于降维的线性变换技术(例如 PCA 和 LDA)可能不是最佳选择。 在本节中,我们将研究 PCA 的内核版本,或“内核 PCA”,它与我们从第 3 章中记住的内核 SVM 的概念有关。 使用 Scikit 学习*的机器学习分类器的浏览。 使用内核 PCA,我们将学习如何将不可线性分离的数据转换为适合线性分类器的新的较低维子空间。

内核功能和内核技巧
我们在第 3 章和“使用 Scikit-learn” 进行的机器学习分类器讨论中对内核 SVM 的讨论中还记得,我们可以通过投影非线性问题来解决它们 到更高维度的新特征空间上,这些类可以线性分离。 为了将样本x ∈ R^d转换到这个更高的k维子空间,我们定义了非线性映射函数φ:

我们可以将φ视为创建原始特征的非线性组合以将原始d-维数据集映射到更大的k-维特征空间的函数。 例如,如果我们具有二维d = 2的特征向量x ∈ R^d(x是由d特征组成的列向量),则到 3D 空间的潜在映射可能如下:



换句话说,通过内核 PCA,我们执行了非线性映射,将数据转换为到更高维度的空间,并在该更高维度的空间中使用标准 PCA 将数据投射回更低维度的空间, 样本可以通过线性分类器分离(在样本可以通过输入空间中的密度分离的条件下)。 但是,这种方法的一个缺点是它在计算上非常昂贵,这就是我们使用内核技巧的地方。 使用内核技巧,我们可以计算原始特征空间中两个高维特征向量之间的相似度。
在继续使用内核技巧来解决这个计算量巨大的问题的更多细节之前,让我们回顾一下在本章开始时实现的标准 PCA 方法。 我们计算了两个特征k和j之间的协方差,如下所示:

由于特征的标准化使它们居中于零均值(例如μ[j] = 0和μ[k] = 0),因此可以如下简化此方程:

请注意,前面的方程是指两个特征之间的协方差。 现在,让我们编写通用方程式以计算协方差矩阵Σ:

Bernhard Scholkopf 推广了这种方法(B. Scholkopf,A。Smola 和 K.-R. Muller。核主成分分析。第 583-588 页,1997 年),以便我们可以替换样本之间的点积 通过φ的非线性特征组合在原始特征空间中:

要从此协方差矩阵中获得特征向量(主要成分),我们必须求解以下方程式:



此处,λ和v是协方差矩阵Σ的特征值和特征向量,而a可以是通过提取内核(相似性)矩阵K的特征向量而获得的。 我们将在以下段落中看到。
内核矩阵的推导如下:
首先,让我们以矩阵表示法编写协方差矩阵,其中φ(X)是n × k-维矩阵:

现在,我们可以写出特征向量公式,如下所示:

由于Σv = λv,我们得到:

在两侧将其乘以φ(X)会得到以下结果:



在这里,K是相似度(内核)矩阵:

正如我们在第 3 章,“使用 Scikit-learn”的机器学习分类器中的 SVM 部分所回顾的那样,我们使用内核技巧来避免计算成对的点。 使用内核函数K显式地提取φ下的样本x的乘积,因此我们无需显式地计算特征向量:

换句话说,我们在内核 PCA 之后获得的是已经投影到各个组件上的样本,而不是像标准 PCA 方法那样构造转换矩阵。 基本上,内核函数(或简称为内核)可以理解为一种计算两个向量之间的点积(一种相似性度量)的函数。
最常用的内核如下:
-
多项式内核:
![Kernel functions and the kernel trick]()
此处,
θ是阈值,p是用户必须指定的功率。 -
双曲正切(Sigmoid)核:
![Kernel functions and the kernel trick]()
-
下一节将在以下示例中使用的“径向基函数”(RBF)或高斯内核:
![Kernel functions and the kernel trick]()
它也写成如下:
![Kernel functions and the kernel trick]()
总结到目前为止,我们可以定义以下三个步骤来实现 RBF 内核 PCA:
-
我们计算内核(相似度)矩阵
k,我们需要计算以下内容:![Kernel functions and the kernel trick]()
我们对每对样本执行此操作:
![Kernel functions and the kernel trick]()
例如,如果我们的数据集包含 100 个训练样本,则成对相似性的对称核矩阵将是
100 × 100维。 -
我们使用以下等式将内核矩阵
k居中:![Kernel functions and the kernel trick]()
这里,
l[n]是一个n × n维矩阵(与内核矩阵相同的维),其中所有值都等于l / n。 -
我们基于中心核矩阵的相应特征值收集顶部的
k特征向量,这些特征向量通过减小幅度进行排序。 与标准 PCA 相比,特征向量不是主要成分轴,而是投影到这些轴上的样本。
此时,您可能想知道为什么我们需要在第二步中将内核矩阵居中。 以前我们假设我们正在使用标准化数据,当我们制定协方差矩阵并通过φ将非线性特征组合替换为点积时,所有特征均均值为零。 因此,由于我们没有明确地计算新特征空间,并且不能保证新特征空间也以零为中心,因此在第二步中将内核矩阵居中成为必要。
在下一节中,我们将通过在 Python 中实现内核 PCA 来将这三个步骤付诸实践。
用 Python 实现内核主成分分析
在前面的小节中,我们讨论了内核 PCA 背后的核心概念。 现在,我们将按照概述内核 PCA 方法的三个步骤,用 Python 实现 RBF 内核 PCA。 使用 SciPy 和 NumPy 帮助函数,我们将看到实现内核 PCA 实际上非常简单:
from scipy.spatial.distance import pdist, squareform
from scipy import exp
from scipy.linalg import eigh
import numpy as np
def rbf_kernel_pca(X, gamma, n_components):
"""
RBF kernel PCA implementation.
Parameters
------------
X: {NumPy ndarray}, shape = [n_samples, n_features]
gamma: float
Tuning parameter of the RBF kernel
n_components: int
Number of principal components to return
Returns
------------
X_pc: {NumPy ndarray}, shape = [n_samples, k_features]
Projected dataset
"""
# Calculate pairwise squared Euclidean distances
# in the MxN dimensional dataset.
sq_dists = pdist(X, 'sqeuclidean')
# Convert pairwise distances into a square matrix.
mat_sq_dists = squareform(sq_dists)
# Compute the symmetric kernel matrix.
K = exp(-gamma * mat_sq_dists)
# Center the kernel matrix.
N = K.shape[0]
one_n = np.ones((N,N)) / N
K = K - one_n.dot(K) - K.dot(one_n) + one_n.dot(K).dot(one_n)
# Obtaining eigenpairs from the centered kernel matrix
# numpy.eigh returns them in sorted order
eigvals, eigvecs = eigh(K)
# Collect the top k eigenvectors (projected samples)
X_pc = np.column_stack((eigvecs[:, -i]
for i in range(1, n_components + 1)))
return X_pc
使用 RBF 内核 PCA 进行降维的一个缺点是我们必须先指定参数γ。 为γ找到合适的值需要进行实验,最好使用参数调整算法来完成,例如网格搜索,我们将在第 6 章,“模型评估和超参数调整最佳实践”中详细讨论。
示例 1 –分离半月形
现在,让我们将rbf_kernel_pca应用于某些非线性示例数据集。 我们将首先创建一个包含两个半月形的 100 个采样点的二维数据集:
>>> from sklearn.datasets import make_moons
>>> X, y = make_moons(n_samples=100, random_state=123)
>>> plt.scatter(X[y==0, 0], X[y==0, 1],
... color='red', marker='^', alpha=0.5)
>>> plt.scatter(X[y==1, 0], X[y==1, 1],
... color='blue', marker='o', alpha=0.5)
>>> plt.show()
为了说明的目的,三角形符号的半月形代表一个类别,圆形符号表示的半月形代表另一个类别的样本:

显然,这两个半月形不是线性可分离的,我们的目标是通过内核 PCA 展开半月,以便数据集可以用作线性分类器的合适输入。 但是首先,让我们看看如果通过标准 PCA 将数据集投影到主要组件上,数据集会是什么样子:
>>> from sklearn.decomposition import PCA
>>> scikit_pca = PCA(n_components=2)
>>> X_spca = scikit_pca.fit_transform(X)
>>> fig, ax = plt.subplots(nrows=1,ncols=2, figsize=(7,3))
>>> ax[0].scatter(X_spca[y==0, 0], X_spca[y==0, 1],
... color='red', marker='^', alpha=0.5)
>>> ax[0].scatter(X_spca[y==1, 0], X_spca[y==1, 1],
... color='blue', marker='o', alpha=0.5)
>>> ax[1].scatter(X_spca[y==0, 0], np.zeros((50,1))+0.02,
... color='red', marker='^', alpha=0.5)
>>> ax[1].scatter(X_spca[y==1, 0], np.zeros((50,1))-0.02,
... color='blue', marker='o', alpha=0.5)
>>> ax[0].set_xlabel('PC1')
>>> ax[0].set_ylabel('PC2')
>>> ax[1].set_ylim([-1, 1])
>>> ax[1].set_yticks([])
>>> ax[1].set_xlabel('PC1')
>>> plt.show()
显然,我们可以在结果图中看到,线性分类器将无法在通过标准 PCA 转换的数据集上很好地发挥作用:

请注意,当我们仅绘制第一个主成分(右子图)时,我们将三角形样本稍微向上移动,将圆形样本稍微向下移动,以更好地可视化类别重叠。
注意
请记住,PCA 是一种不受监督的方法,并且不使用类标签信息来最大化与 LDA 相比的差异。 此处,仅出于可视化目的添加了三角形和圆形符号以指示分离程度。
现在,让我们尝试一下我们在上一节中实现的内核 PCA 函数rbf_kernel_pca:
>>> from matplotlib.ticker import FormatStrFormatter
>>> X_kpca = rbf_kernel_pca(X, gamma=15, n_components=2)
>>> fig, ax = plt.subplots(nrows=1,ncols=2, figsize=(7,3))
>>> ax[0].scatter(X_kpca[y==0, 0], X_kpca[y==0, 1],
... color='red', marker='^', alpha=0.5)
>>> ax[0].scatter(X_kpca[y==1, 0], X_kpca[y==1, 1],
... color='blue', marker='o', alpha=0.5)
>>> ax[1].scatter(X_kpca[y==0, 0], np.zeros((50,1))+0.02,
... color='red', marker='^', alpha=0.5)
>>> ax[1].scatter(X_kpca[y==1, 0], np.zeros((50,1))-0.02,
... color='blue', marker='o', alpha=0.5)
>>> ax[0].set_xlabel('PC1')
>>> ax[0].set_ylabel('PC2')
>>> ax[1].set_ylim([-1, 1])
>>> ax[1].set_yticks([])
>>> ax[1].set_xlabel('PC1')
>>> ax[0].xaxis.set_major_formatter(FormatStrFormatter('%0.1f'))
>>> ax[1].xaxis.set_major_formatter(FormatStrFormatter('%0.1f'))
>>> plt.show()
现在我们可以看到两个类(圆形和三角形)线性良好地分开,因此它成为线性分类器的合适训练数据集:

不幸的是,没有适用于不同数据集的调整参数γ的通用值。 要找到适合给定问题的γ值,需要进行实验。 在第 6 章,“学习模型评估和超参数调整的最佳实践”中,我们将讨论可以帮助我们自动执行优化调整参数任务的技术。 在这里,我将使用我发现产生良好结果的γ值。
示例 2 –分离同心圆
在上一小节中,我们向您展示了如何通过内核 PCA 分离半月形。 由于我们花了很多精力来理解内核 PCA 的概念,因此让我们看一下另一个有趣的非线性问题示例:同心圆。
代码如下:
>>> from sklearn.datasets import make_circles
>>> X, y = make_circles(n_samples=1000,
... random_state=123, noise=0.1, factor=0.2)
>>> plt.scatter(X[y==0, 0], X[y==0, 1],
... color='red', marker='^', alpha=0.5)
>>> plt.scatter(X[y==1, 0], X[y==1, 1],
... color='blue', marker='o', alpha=0.5)
>>> plt.show()
同样,我们假设一个两类问题,其中三角形分别代表一类,而圆形分别代表另一类:

让我们从标准 PCA 方法开始,将其与 RBF 内核 PCA 的结果进行比较:
>>> scikit_pca = PCA(n_components=2)
>>> X_spca = scikit_pca.fit_transform(X)
>>> fig, ax = plt.subplots(nrows=1,ncols=2, figsize=(7,3))
>>> ax[0].scatter(X_spca[y==0, 0], X_spca[y==0, 1],
... color='red', marker='^', alpha=0.5)
>>> ax[0].scatter(X_spca[y==1, 0], X_spca[y==1, 1],
... color='blue', marker='o', alpha=0.5)
>>> ax[1].scatter(X_spca[y==0, 0], np.zeros((500,1))+0.02,
... color='red', marker='^', alpha=0.5)
>>> ax[1].scatter(X_spca[y==1, 0], np.zeros((500,1))-0.02,
... color='blue', marker='o', alpha=0.5)
>>> ax[0].set_xlabel('PC1')
>>> ax[0].set_ylabel('PC2')
>>> ax[1].set_ylim([-1, 1])
>>> ax[1].set_yticks([])
>>> ax[1].set_xlabel('PC1')
>>> plt.show()
同样,我们可以看到标准 PCA 无法产生适合训练线性分类器的结果:

给定γ的适当值,让我们看看使用 RBF 内核 PCA 实现是否更幸运:
>>> X_kpca = rbf_kernel_pca(X, gamma=15, n_components=2)
>>> fig, ax = plt.subplots(nrows=1,ncols=2, figsize=(7,3))
>>> ax[0].scatter(X_kpca[y==0, 0], X_kpca[y==0, 1],
... color='red', marker='^', alpha=0.5)
>>> ax[0].scatter(X_kpca[y==1, 0], X_kpca[y==1, 1],
... color='blue', marker='o', alpha=0.5)
>>> ax[1].scatter(X_kpca[y==0, 0], np.zeros((500,1))+0.02,
... color='red', marker='^', alpha=0.5)
>>> ax[1].scatter(X_kpca[y==1, 0], np.zeros((500,1))-0.02,
... color='blue', marker='o', alpha=0.5)
>>> ax[0].set_xlabel('PC1')
>>> ax[0].set_ylabel('PC2')
>>> ax[1].set_ylim([-1, 1])
>>> ax[1].set_yticks([])
>>> ax[1].set_xlabel('PC1')
>>> plt.show()
再次,RBF 内核 PCA 将数据投影到新的子空间上,在这两个类之间可以线性分离:

投影新数据点
在内核 PCA 的前两个示例应用,半月形和同心圆中,我们将单个数据集投影到了新功能上。 但是,在实际应用中,我们可能要转换一个以上的数据集,例如训练和测试数据,并且通常还会在模型构建和评估后收集新样本。 在本节中,您将学习如何投影不属于训练数据集的数据点。
我们从本章开始的标准 PCA 方法中记得,我们通过计算转换矩阵与输入样本之间的点积来投影数据。 投影矩阵的列是我们从协方差矩阵获得的顶部v)。 现在,问题是如何将这一概念转移到内核 PCA? 如果我们回想一下内核 PCA 背后的想法,我们记得我们已经获得了中心内核矩阵(不是协方差矩阵)的特征向量(a),这意味着这些是已经投影到主成分上的样本 轴v。 因此,如果要将新样本x'投影到该主分量轴上,则需要计算以下内容:

幸运的是,我们可以使用内核技巧,因此我们不必显式计算投影φ(x')^T v。 但是,值得注意的是,与标准 PCA 相比,内核 PCA 是一种基于内存的方法,这意味着我们每次都必须重用原始训练集来投影新样本。 我们必须计算训练数据集中的每个i样本与新样本x'之间的成对 RBF 核(相似性):


在此,内核矩阵K的特征向量a和特征值λ在公式中满足以下条件:

在计算新样本与训练集中样本之间的相似度后,我们必须通过特征向量a对其特征值进行归一化。 因此,让我们修改我们先前实现的rbf_kernel_pca函数,使其也返回内核矩阵的特征值:
from scipy.spatial.distance import pdist, squareform
from scipy import exp
from scipy.linalg import eigh
import numpy as np
def rbf_kernel_pca(X, gamma, n_components):
"""
RBF kernel PCA implementation.
Parameters
------------
X: {NumPy ndarray}, shape = [n_samples, n_features]
gamma: float
Tuning parameter of the RBF kernel
n_components: int
Number of principal components to return
Returns
------------
X_pc: {NumPy ndarray}, shape = [n_samples, k_features]
Projected dataset
lambdas: list
Eigenvalues
"""
# Calculate pairwise squared Euclidean distances
# in the MxN dimensional dataset.
sq_dists = pdist(X, 'sqeuclidean')
# Convert pairwise distances into a square matrix.
mat_sq_dists = squareform(sq_dists)
# Compute the symmetric kernel matrix.
K = exp(-gamma * mat_sq_dists)
# Center the kernel matrix.
N = K.shape[0]
one_n = np.ones((N,N)) / N
K = K - one_n.dot(K) - K.dot(one_n) + one_n.dot(K).dot(one_n)
# Obtaining eigenpairs from the centered kernel matrix
# numpy.eigh returns them in sorted order
eigvals, eigvecs = eigh(K)
# Collect the top k eigenvectors (projected samples)
alphas = np.column_stack((eigvecs[:,-i]
for i in range(1,n_components+1)))
# Collect the corresponding eigenvalues
lambdas = [eigvals[-i] for i in range(1,n_components+1)]
return alphas, lambdas
现在,让我们创建一个新的半月数据集,并使用更新的 RBF 内核 PCA 实现将其投影到一维子空间上:
>>> X, y = make_moons(n_samples=100, random_state=123)
>>> alphas, lambdas =rbf_kernel_pca(X, gamma=15, n_components=1)
为了确保实现用于投影新样本的代码,我们假设半月数据集的第 26 个点是一个新数据点x',我们的任务是将其投影到这个新子空间上:
>>> x_new = X[25]
>>> x_new
array([ 1.8713187 , 0.00928245])
>>> x_proj = alphas[25] # original projection
>>> x_proj
array([ 0.07877284])
>>> def project_x(x_new, X, gamma, alphas, lambdas):
... pair_dist = np.array([np.sum(
... (x_new-row)**2) for row in X])
... k = np.exp(-gamma * pair_dist)
... return k.dot(alphas / lambdas)
通过执行以下代码,我们可以再现原始投影。 使用project_x功能,我们也可以投影任何新的数据样本。 代码如下:
>>> x_reproj = project_x(x_new, X,
... gamma=15, alphas=alphas, lambdas=lambdas)
>>> x_reproj
array([ 0.07877284])
最后,让我们可视化第一个主要成分上的投影:
>>> plt.scatter(alphas[y==0, 0], np.zeros((50)),
... color='red', marker='^',alpha=0.5)
>>> plt.scatter(alphas[y==1, 0], np.zeros((50)),
... color='blue', marker='o', alpha=0.5)
>>> plt.scatter(x_proj, 0, color='black',
... label='original projection of point X[25]',
... marker='^', s=100)
>>> plt.scatter(x_reproj, 0, color='green',
... label='remapped point X[25]',
... marker='x', s=500)
>>> plt.legend(scatterpoints=1)
>>> plt.show()
正如我们在散点图的中所看到的,我们将样本x'正确映射到了第一个主成分上:

scikit-learn 中的内核主成分分析
为了我们的方便,scikit-learn 在sklearn.decomposition子模块中实现了内核 PCA 类。 用法类似于标准 PCA 类,我们可以通过kernel参数指定内核:
>>> from sklearn.decomposition import KernelPCA
>>> X, y = make_moons(n_samples=100, random_state=123)
>>> scikit_kpca = KernelPCA(n_components=2,
... kernel='rbf', gamma=15)
>>> X_skernpca = scikit_kpca.fit_transform(X)
为了查看是否获得与我们自己的内核 PCA 实现一致的结果,让我们将转换后的半月形数据绘制到前两个主要成分上:
>>> plt.scatter(X_skernpca[y==0, 0], X_skernpca[y==0, 1],
... color='red', marker='^', alpha=0.5)
>>> plt.scatter(X_skernpca[y==1, 0], X_skernpca[y==1, 1],
... color='blue', marker='o', alpha=0.5)
>>> plt.xlabel('PC1')
>>> plt.ylabel('PC2')
>>> plt.show()
如我们所见, scikit-learn KernelPCA的结果与我们自己的实现一致:

注意
Scikit-learn 还实现了非线性降维的高级技术,这超出了本书的范围。 您可以通过这个页面上的说明示例,在 scikit-learn 中找到有关当前实现的很好概述。



四十、学习模型评估和超参数调整的最佳实践
在前面的章节中,您了解了用于分类的基本机器学习算法,以及在将数据输入这些算法之前如何使数据成形。 现在,是时候通过微调算法和评估模型的性能来学习构建良好的机器学习模型的最佳实践! 在本章中,我们将学习如何:
- 获得模型性能的无偏估计
- 诊断机器学习算法的常见问题
- 微调机器学习模型
- 使用不同的绩效指标评估预测模型
使用管道简化工作流程
当在前几章中应用了不同的预处理技术时,例如第 4 章和“构建良好的训练集–数据预处理”标准化** 用于特征缩放。 第 5 章或“通过降维压缩”进行数据压缩的或主数据分析,您了解到必须重用参数 这些数据是在训练数据拟合以缩放和压缩任何新数据(例如,单独的测试数据集中的样本)期间获得的。 在本节中,您将学习一个非常方便的工具,scikit-learn 中的Pipeline类。 它使我们能够拟合包含任意数量的转换步骤的模型,并将其应用于对新数据的预测。
加载乳腺癌威斯康星州数据集
在本章中,我们将与乳腺癌威斯康星州数据集一起使用,该数据集包含 569 个恶性和良性肿瘤细胞样本。 数据集中的前两列分别存储样本的唯一 ID 号和相应的诊断( M =恶性,B =良性)。 第 3-32 栏包含 30 个实值特征,这些特征是根据细胞核的数字化图像计算得出的,可用于构建模型来预测肿瘤是良性还是恶性。 威斯康星州乳腺癌数据集已保存在 UCI 机器学习存储库中,有关此数据集的更多详细信息,请访问这个页面。
在本节中,我们将读取数据集,并通过三个简单的步骤将其分为训练和测试数据集:
-
我们将直接使用 Pandas 从 UCI 网站读取数据集:
>>> import pandas as pd >>> df = pd.read_csv('https://archive.ics.uci.edu/ml/machine-learning-databases/breast-cancer-wisconsin/wdbc.data', header=None) -
接下来,我们将 30 个特征分配给 NumPy 数组
X。 使用LabelEncoder,我们将类标签从其原始字符串表示形式(M和B)转换为整数:>>> from sklearn.preprocessing import LabelEncoder >>> X = df.loc[:, 2:].values >>> y = df.loc[:, 1].values >>> le = LabelEncoder() >>> y = le.fit_transform(y)在将类别标签(诊断)编码为数组
y后,现在将恶性肿瘤表示为1类,将良性肿瘤表示为0类,我们可以通过称为transform来说明 两个假类标签上的LabelEncoder的]方法:>>> le.transform(['M', 'B']) array([1, 0]) -
在以下小节中构建第一个模型管道之前,让我们将数据集分为一个单独的训练数据集(数据的 80%)和一个单独的测试数据集(数据的 20%):
>>> from sklearn.cross_validation import train_test_split >>> X_train, X_test, y_train, y_test = \ ... train_test_split(X, y, test_size=0.20, random_state=1)
在管道中组合提升器和估计器
在的前一章中,您了解到许多学习算法需要使用相同规模的输入功能才能获得最佳性能。 因此,我们需要在之前将乳腺癌威斯康星州数据集中的列标准化,然后才能将其输入到线性分类器中,例如逻辑回归。 此外,假设我们要通过主成分分析(PCA)将数据从最初的 30 个维压缩到较低的二维子空间,这是一种用于降维的特征提取技术 我们在第 5 章,“通过降维压缩数据”中介绍。 无需分别进行训练和测试数据集的拟合和转换步骤,我们可以将StandardScaler,PCA和LogisticRegression对象链接在管道中:
>>> from sklearn.preprocessing import StandardScaler
>>> from sklearn.decomposition import PCA
>>> from sklearn.linear_model import LogisticRegression
>>> from sklearn.pipeline import Pipeline
>>> pipe_lr = Pipeline([('scl', StandardScaler()),
... ('pca', PCA(n_components=2)),
... ('clf', LogisticRegression(random_state=1))])
>>> pipe_lr.fit(X_train, y_train)
>>> print('Test Accuracy: %.3f' % pipe_lr.score(X_test, y_test))
Test Accuracy: 0.947
Pipeline对象将一个元组列表作为输入,其中每个元组中的第一个值是一个任意的标识符字符串,我们可以使用它来访问管道中的各个元素,如本章稍后所述,第二个 每个元组中的元素是一个 scikit 学习转换器或估计器。
流水线中的中间步骤构成 scikit-learn 提升器,最后一步是估算器。 在前面的代码示例中,我们构建了一个包含两个中间步骤的管道,一个是StandardScaler和一个PCA转换器,另一个是逻辑回归分类器作为最终估计量。 当我们在管道pipe_lr上执行fit方法时,StandardScaler对训练数据执行fit和transform,然后将转换后的训练数据传递到管道中的下一个对象,即[ PCA。 与上一步相似,PCA也对缩放后的输入数据执行了fit和transform,并将其传递给管道的最后一个元素(估计器)。 我们应注意,此管道中的中间步骤数量没有限制。 下图概述了管道如何工作的概念:

使用 k 倍交叉验证来评估模型性能
建立机器学习模型的关键步骤之一是根据该模型以前未见的数据评估其性能。 让我们假设使我们的模型适合于训练数据集,并使用相同的数据来估计其在实践中的表现。 我们从第 3 章,“使用 Scikit-learn” 的机器学习分类器的通过正则化处理部分解决过拟合问题中记得,模型可能会遭受拟合不足 (高偏差)(如果模型太简单),或者如果模型对于基础训练数据来说太复杂,则可能过度拟合训练数据(高方差)。 为了找到可接受的偏差方差折衷,我们需要仔细评估模型。 在本节中,您将学习有用的交叉验证技术保持交叉验证和 k 倍交叉验证,这可以帮助我们获得 模型泛化误差的可靠估计,即模型在看不见的数据上的表现如何。
保持方法
估计机器学习模型泛化性能的经典且流行的方法是保持交叉验证。 使用保持方法,我们将初始数据集分为单独的训练和测试数据集-前者用于模型训练,而后者用于估计其性能。 但是,在典型的机器学习应用中,我们也对调整和比较不同的参数设置感兴趣,以进一步提高对看不见的数据进行预测的性能。 此过程称为模型选择,其中术语“模型选择”指的是给定的分类问题,我们要为其选择调整参数的最佳最佳值(也称为超参数)。 但是,如果我们在模型选择期间一遍又一遍地重复使用相同的测试数据集,则它将成为我们训练数据的一部分,因此该模型更可能过拟合。 尽管存在此问题,但许多人仍将测试集用于模型选择,这不是一个好的机器学习实践。
使用保留方法进行模型选择的一种更好的方法是将数据分为三个部分:训练集,验证集和测试集。 训练集用于拟合不同的模型,然后将验证集上的性能用于模型选择。 拥有模型在训练和模型选择步骤中从未见过的测试集的优点是,我们可以获得对其通用化为新数据的能力的较少偏倚的估计。 下图说明了保持交叉验证的概念,其中在使用不同的参数值训练后,我们使用验证集重复评估模型的性能。 一旦对参数值的调整感到满意,就可以在测试数据集上估计模型的泛化误差:

保持方法的缺点是,性能估算对我们如何将训练集划分为训练和验证子集非常敏感; 对于不同的数据样本,估算值将有所不同。 在下一个小节中,我们将介绍一种用于性能评估的更强大的技术,即 k 倍交叉验证,其中我们对k子集的重复保留方法k次 训练数据。
K 折交叉验证
在 k 倍交叉验证中,我们将训练数据集随机分为k折叠,而无需替换,其中K - 1折叠用于模型训练,而一折叠用于测试。 重复此过程k次,以便获得k模型和性能估算。
注意
如果您不熟悉在不替换的情况下使用和采样的术语,让我们进行一个简单的思想实验。 假设我们正在玩彩票游戏,我们从 an 中随机抽取数字。 我们从一个 holds 着五个唯一数字 0、1、2、3 和 4 的开始,然后每转一圈绘制一个正好一个数字。 在第一轮中,从骨灰盒中提取特定数字的机会是 1/5。 现在,在不更换样本的情况下,我们不会在每次旋转后将数字重新放入骨灰盒。 因此,在下一轮中从剩余号码集合中提取特定号码的可能性取决于前一轮。 例如,如果我们有剩余的一组数字 0、1、2 和 4,则在下一轮中绘制数字 0 的机会将变为 1/4。**
但是,在随机抽样替换中,我们总是将抽取的数字返回到骨灰盒,以使每次旋转绘制特定数字的概率都不会改变。 我们可以多次绘制相同的数字。 换句话说,在替换抽样中,样本(数量)是独立的,并且协方差为零。 例如,五轮绘制随机数的结果如下所示:
- 随机抽样而不更换:2、1、3、4、0
- 随机抽样替换:1、3、3、4、1
然后,我们基于不同的独立折叠计算模型的平均性能,以获得与保持方法相比对训练数据细分不那么敏感的性能估计。 通常,我们使用 k 倍交叉验证进行模型调整,也就是说,找到产生令人满意的泛化性能的最佳超参数值。 一旦找到令人满意的超参数值,我们就可以在完整的训练集上对模型进行重新训练,并使用独立的测试集获得最终的性能估算。
由于 k 倍交叉验证是一种无需替换的重采样技术,因此该方法的优势在于,每个样本点将仅是训练和测试数据集的一部分,一次即可得出模型性能的较低方差估算值 比保持方法。 下图概述了k = 10与 k 倍交叉验证背后的概念。 训练数据集分为 10 折,在 10 次迭代期间,将 9 折用于训练,将 1 折用作模型评估的测试集。 同样,每个折叠的估计性能E[i](例如,分类准确度或错误)然后用于计算模型的估计平均性能E:

k 倍交叉验证中k的标准值为 10,对于大多数应用而言,这通常是一个合理的选择。 但是,如果我们使用相对较小的训练集,则增加折叠数可能很有用。 如果我们增加k的值,则在每次迭代中将使用更多的训练数据,这将导致通过平均各个模型估计值来降低对泛化性能的估计偏差。 但是,k的较大值也会增加交叉验证算法的运行时间,并且由于训练折叠会彼此更类似于,因此具有较高方差的产量估算值。 另一方面,如果我们使用大型数据集,则可以为k选择一个较小的值,例如k = 5,并且仍然可以获得模型平均性能的准确估计值,同时降低 在不同折痕处重新拟合和评估模型的计算成本。
注意
k 倍交叉验证的一种特殊情况是留一法则(LOO)交叉验证方法。 在 LOO 中,我们将折数设置为等于训练样本的数量( k = n ),以便在每次迭代期间仅使用一个训练样本进行测试。 这是处理非常小的数据集的推荐方法。
分层 k 折叠交叉验证比标准 k 折叠交叉验证方法稍有改进,这可以产生更好的偏差和方差估计,尤其是在类比例不相等的情况下,正如 R 的研究表明的那样。 Kohavi 等。 (R. Kohavi 等人交叉验证和自举的研究,用于准确性估计和模型选择。在伊贾伊,第 14 卷,第 1137-1145 页,1995 年)。 在分层交叉验证中,每个折叠中的类比例都保留下来,以确保每个折叠都代表训练数据集中的类比例,我们将在 scikit-learn 中使用StratifiedKFold迭代器进行说明:
>>> import numpy as np
>>> from sklearn.cross_validation import StratifiedKFold
>>> kfold = StratifiedKFold(y=y_train,
... n_folds=10,
... random_state=1)
>>> scores = []
>>> for k, (train, test) in enumerate(kfold):
... pipe_lr.fit(X_train[train], y_train[train])
... score = pipe_lr.score(X_train[test], y_train[test])
... scores.append(score)
... print('Fold: %s, Class dist.: %s, Acc: %.3f' % (k+1,
... np.bincount(y_train[train]), score))
Fold: 1, Class dist.: [256 153], Acc: 0.891
Fold: 2, Class dist.: [256 153], Acc: 0.978
Fold: 3, Class dist.: [256 153], Acc: 0.978
Fold: 4, Class dist.: [256 153], Acc: 0.913
Fold: 5, Class dist.: [256 153], Acc: 0.935
Fold: 6, Class dist.: [257 153], Acc: 0.978
Fold: 7, Class dist.: [257 153], Acc: 0.933
Fold: 8, Class dist.: [257 153], Acc: 0.956
Fold: 9, Class dist.: [257 153], Acc: 0.978
Fold: 10, Class dist.: [257 153], Acc: 0.956
>>> print('CV accuracy: %.3f +/- %.3f' % (
... np.mean(scores), np.std(scores)))
CV accuracy: 0.950 +/- 0.029
首先,我们使用训练集中的类标签y_train从sklearn.cross_validation模块初始化了StratifiedKfold迭代器,并通过n_folds参数指定了折叠次数。 当我们使用kfold迭代器遍历k折叠时,我们使用train中返回的索引来适应本章开始时设置的逻辑回归管线。 使用pile_lr管道,我们确保在每次迭代中都正确缩放了样本(例如标准化)。 然后,我们使用test指数来计算模型的准确性得分,然后在scores列表中收集该模型以计算估计的平均准确性和标准偏差。
尽管前面的代码示例对于说明 k 折交叉验证的工作原理很有用,但是 scikit-learn 还实现了 k 折交叉验证评分器,这使我们能够更有效地使用分层 k 折交叉验证来评估模型:
>>> from sklearn.cross_validation import cross_val_score
>>> scores = cross_val_score(estimator=pipe_lr,
... X=X_train,
... y=y_train,
... cv=10,
... n_jobs=1)
>>> print('CV accuracy scores: %s' % scores)
CV accuracy scores: [ 0.89130435 0.97826087 0.97826087
0.91304348 0.93478261 0.97777778
0.93333333 0.95555556 0.97777778
0.95555556]
>>> print('CV accuracy: %.3f +/- %.3f' % (np.mean(scores), np.std(scores)))
CV accuracy: 0.950 +/- 0.029
cross_val_score方法的极其有用的功能是,我们可以在机器上的多个 CPU 上分布不同倍数的评估。 如果我们将n_jobs参数设置为1,则就像前面的StratifiedKFold示例一样,将仅使用一个 CPU 来评估性能。 但是,通过设置n_jobs=2,我们可以将 10 轮交叉验证分配给两个 CPU(如果在我们的计算机上可用),并且通过设置n_jobs=-1,我们可以使用我们计算机上的所有可用 CPU 并行进行计算 。
注意
请注意,关于如何在交叉验证中估算泛化性能方差的详细讨论超出了本书的范围,但是您可以在 M. Markatou 等人的出色文章(M. Markatou, H. Tian,S。Biswas 和 GM Hripcsak。泛化误差的交叉验证估计量方差分析机器学习研究杂志,6:1127-1168,2005 )。
您还可以阅读有关其他交叉验证技术的信息,例如.632 Bootstrap 交叉验证方法(B. Efron 和 R. Tibshirani。交叉验证的改进:632+ Bootstrap 方法。美国统计协会杂志,92(438):548-560,1997)。

具有学习和验证曲线的调试算法
在此部分中,我们将介绍两个非常简单但功能强大的诊断工具,这些工具可帮助我们改善学习算法的性能:学习曲线和验证曲线。 在接下来的小节中,我们将讨论如何使用学习曲线来诊断学习算法是否存在过拟合(高方差)或欠拟合(高偏差)的问题。 此外,我们将查看验证曲线,这些曲线可帮助我们解决学习算法的常见问题。
使用学习曲线诊断偏差和方差问题
如果模型对于给定的训练数据集过于复杂(该模型中的自由度或参数太多),则该模型往往会过度拟合训练数据,而不能很好地推广到看不见的数据。 通常,它可以帮助收集更多的训练样本以减少过度拟合的程度。 但是,实际上,收集更多数据通常非常昂贵,或者根本不可行。 通过将模型训练和验证准确性绘制为训练集大小的函数,我们可以轻松地检测出模型是遭受高方差还是高偏差,以及收集更多数据是否可以帮助解决该问题。 但是在讨论如何在 sckit-learn 中绘制学习曲线之前,让我们通过遍历下图讨论这两个常见的模型问题:

左上方的图显示了具有高偏差的模型。 该模型具有较低的训练和交叉验证的准确性,这表明它不适合训练数据。 解决此问题的常用方法是增加模型参数的数量,例如,通过收集或构造其他特征,或通过降低正则化的程度,例如在 SVM 或逻辑回归分类器中。 右上方的图形显示了一个模型,该模型存在较大的差异,这由训练和交叉验证准确性之间的巨大差异表示。 为了解决过度拟合的问题,我们可以收集更多训练数据或降低模型的复杂性,例如,通过增加正则化参数; 对于非正规模型,它还可以通过特征选择(第 4 章,“构建良好的训练集–数据预处理”)或特征提取(第 5 章)来减少特征数量 ,通过降维压缩数据。 我们将注意到,收集更多的训练数据会减少过度拟合的机会。 但是,例如当训练数据非常嘈杂或模型已经非常接近最佳值时,它可能并不总是有帮助。
在的下一个小节中,我们将看到如何使用验证曲线解决这些模型问题,但首先让我们看看如何使用 scikit-learn 的学习曲线函数来评估模型:
>>> import matplotlib.pyplot as plt
>>> from sklearn.learning_curve import learning_curve
>>> pipe_lr = Pipeline([
... ('scl', StandardScaler()),
... ('clf', LogisticRegression(
... penalty='l2', random_state=0))])
>>> train_sizes, train_scores, test_scores =\
... learning_curve(estimator=pipe_lr,
... X=X_train,
... y=y_train,
... train_sizes=np.linspace(0.1, 1.0, 10),
... cv=10,
... n_jobs=1)
>>> train_mean = np.mean(train_scores, axis=1)
>>> train_std = np.std(train_scores, axis=1)
>>> test_mean = np.mean(test_scores, axis=1)
>>> test_std = np.std(test_scores, axis=1)
>>> plt.plot(train_sizes, train_mean,
... color='blue', marker='o',
... markersize=5,
... label='training accuracy')
>>> plt.fill_between(train_sizes,
... train_mean + train_std,
... train_mean - train_std,
... alpha=0.15, color='blue')
>>> plt.plot(train_sizes, test_mean,
... color='green', linestyle='--',
... marker='s', markersize=5,
... label='validation accuracy')
>>> plt.fill_between(train_sizes,
... test_mean + test_std,
... test_mean - test_std,
... alpha=0.15, color='green')
>>> plt.grid()
>>> plt.xlabel('Number of training samples')
>>> plt.ylabel('Accuracy')
>>> plt.legend(loc='lower right')
>>> plt.ylim([0.8, 1.0])
>>> plt.show()
成功执行了前面的代码后,我们将获得以下学习曲线图:

通过learning_curve功能中的train_sizes参数,我们可以控制用于生成学习曲线的训练样本的绝对或相对数量。 在这里,我们将train_sizes=np.linspace(0.1, 1.0, 10)设置为使用 10 个均匀间隔的相对间隔作为训练集大小。 默认情况下,learning_curve函数使用分层的 k 倍交叉验证来计算交叉验证的准确性,我们通过cv参数设置了k = 10。 然后,我们简单地根据返回的交叉验证的训练和测试得分(针对不同大小的训练集)计算平均准确度,并使用 matplotlib 的plot函数对其进行绘制。 此外,我们使用fill_between函数将平均准确度的标准偏差添加到该图以指示估计的方差。
正如我们在前面的学习曲线图中所看到的,我们的模型在测试数据集上的表现非常好。 但是,它可能会略微拟合训练数据和交叉验证准确性曲线之间相对较小但可见的间隙所指示的训练数据。
使用验证曲线解决过度拟合和过度拟合
验证曲线是通过解决过度拟合或拟合不足等问题来改善模型性能的有用工具。 验证曲线与学习曲线有关,但是我们不改变训练和测试精度作为样本量的函数,而是改变模型参数的值,例如,逻辑回归中的逆正则化参数C。 让我们继续看看如何通过 sckit-learn 创建验证曲线:
>>> from sklearn.learning_curve import validation_curve
>>> param_range = [0.001, 0.01, 0.1, 1.0, 10.0, 100.0]
>>> train_scores, test_scores = validation_curve(
... estimator=pipe_lr,
... X=X_train,
... y=y_train,
... param_name='clf__C',
... param_range=param_range,
... cv=10)
>>> train_mean = np.mean(train_scores, axis=1)
>>> train_std = np.std(train_scores, axis=1)
>>> test_mean = np.mean(test_scores, axis=1)
>>> test_std = np.std(test_scores, axis=1)
>>> plt.plot(param_range, train_mean,
... color='blue', marker='o',
... markersize=5,
... label='training accuracy')
>>> plt.fill_between(param_range, train_mean + train_std,
... train_mean - train_std, alpha=0.15,
... color='blue')
>>> plt.plot(param_range, test_mean,
... color='green', linestyle='--',
... marker='s', markersize=5,
... label='validation accuracy')
>>> plt.fill_between(param_range,
... test_mean + test_std,
... test_mean - test_std,
... alpha=0.15, color='green')
>>> plt.grid()
>>> plt.xscale('log')
>>> plt.legend(loc='lower right')
>>> plt.xlabel('Parameter C')
>>> plt.ylabel('Accuracy')
>>> plt.ylim([0.8, 1.0])
>>> plt.show()
使用前面的代码,我们获得了参数C的验证曲线图:

与learning_curve函数类似的,如果我们使用分类算法,则validation_curve函数默认情况下使用分层的 k 倍交叉验证来估计模型的性能。 在validation_curve函数中,我们指定了要评估的参数。 在这种情况下,它是C,LogisticRegression分类器的逆正则化参数,我们将其写为'clf__C'来访问 scikit-learn 管道内的LogisticRegression对象,以获取我们通过设置的指定值范围 param_range参数。 与上一节中的学习曲线示例相似,我们绘制了平均训练和交叉验证精度以及相应的标准偏差。
尽管C的变化值在精度上的差异很小,但是我们可以看到,当我们增加正则强度时,该模型会略微拟合数据(C的值很小)。 但是,对于C较大的值,这意味着降低正则化的强度,因此该模型倾向于稍微拟合数据。 在这种情况下,最有效点似乎在C=0.1附近。
通过网格搜索对机器学习模型进行微调
在机器学习中,我们有两种类型的参数:从训练数据中学习的参数,例如 logistic 回归中的权重,以及分别优化的学习算法的参数。 后者是模型的调整参数,也称为超参数,例如,逻辑回归中的正则化参数或决策树的深度参数 。
在上一节中,我们使用验证曲线通过调整模型的超参数之一来改善其性能。 在本节中,我们将介绍一种称为网格搜索的强大超参数优化技术,该技术可以通过找到以下内容的最佳组合来进一步帮助改善模型的性能: 超参数值。
通过网格搜索调整超参数
网格搜索的方法非常简单,它是一种蛮力的穷举搜索范例,其中我们为不同的超参数指定值列表,并且计算机针对每种组合的模型性能进行评估,以获得最优的 放:
>>> from sklearn.grid_search import GridSearchCV
>>> from sklearn.svm import SVC
>>> pipe_svc = Pipeline([('scl', StandardScaler()),
... ('clf', SVC(random_state=1))])
>>> param_range = [0.0001, 0.001, 0.01, 0.1, 1.0, 10.0, 100.0, 1000.0]
>>> param_grid = [{'clf__C': param_range,
... 'clf__kernel': ['linear']},
... {'clf__C': param_range,
... 'clf__gamma': param_range,
... 'clf__kernel': ['rbf']}]
>>> gs = GridSearchCV(estimator=pipe_svc,
... param_grid=param_grid,
... scoring='accuracy',
... cv=10,
... n_jobs=-1)
>>> gs = gs.fit(X_train, y_train)
>>> print(gs.best_score_)
0.978021978022
>>> print(gs.best_params_)
{'clf__C': 0.1, 'clf__kernel': 'linear'}
使用前面的代码,我们从sklearn.grid_search模块初始化了GridSearchCV对象,以训练和调整支持向量机(SVM)管道。 我们将GridSearchCV的param_grid参数设置为词典列表,以指定要调整的参数。 对于线性 SVM,我们仅评估反正则化参数C; 对于 RBF 内核 SVM,我们调整了C和gamma参数。 请注意,gamma参数特定于内核 SVM。 在使用训练数据执行网格搜索之后,我们通过best_score_属性获得了性能最佳模型的得分,并查看了可通过best_params_属性访问的参数。 在这种特殊情况下,带有'clf__C'= 0.1'的线性 SVM 模型产生了最佳的 k 倍交叉验证准确性:97.8%。
最后,我们将使用独立的测试数据集来评估最佳选择模型的性能,该模型可通过GridSearchCV对象的best_estimator_属性获得:
>>> clf = gs.best_estimator_
>>> clf.fit(X_train, y_train)
>>> print('Test accuracy: %.3f' % clf.score(X_test, y_test))
Test accuracy: 0.965
注意
尽管网格搜索是用于找到最佳参数集的强大方法,但是对所有可能参数组合的评估在计算上也非常昂贵。 使用 scikit-learn 采样不同参数组合的另一种方法是随机搜索。 使用 scikit-learn 中的RandomizedSearchCV类,我们可以从具有指定预算的采样分布中绘制随机参数组合。 可在这个页面中找到更多有关其用法的详细信息和示例。
具有嵌套交叉验证的算法选择
结合使用网格搜索将 k 倍交叉验证与网格搜索结合使用是一种有用的方法,可以通过更改机器学习模型的超参数值来对其性能进行微调,正如我们在上一小节中看到的那样。 如果我们想在不同的机器学习算法中进行选择,则另一种推荐的方法是嵌套交叉验证,并且在对误差估计的偏差进行了很好的研究中,Varma 和 Simon 得出结论:相对于误差估计,估计的真实误差几乎是无偏的。 使用嵌套交叉验证的测试集(S. Varma 和 R. Simon。使用交叉验证进行模型选择时的误差估计偏差。BMC bioinformatics,7(1):91,2006)。
在嵌套交叉验证中,我们有一个外部 k 折叠交叉验证循环将数据分为训练和测试折叠,并使用一个内部循环在训练上使用 k 折叠交叉验证选择模型 折叠。 选择模型后,然后使用测试倍数评估模型的性能。 下图说明了具有五个外部折叠和两个内部折叠的嵌套交叉验证的概念,这对于计算性能很重要的大型数据集很有用; 这种特殊的嵌套交叉验证类型也称为 5x2 交叉验证:

在 scikit-learn 中,我们可以执行嵌套的交叉验证,如下所示:
>>> gs = GridSearchCV(estimator=pipe_svc,
... param_grid=param_grid,
... scoring='accuracy',
... cv=2,
... n_jobs=-1)
>>> scores = cross_val_score(gs, X_train, y_train, scoring='accuracy', cv=5)
>>> print('CV accuracy: %.3f +/- %.3f' % (
... np.mean(scores), np.std(scores)))
CV accuracy: 0.965 +/- 0.025
返回的平均交叉验证准确性为我们提供了一个很好的估计,即如果我们调整模型的超参数然后将其用于看不见的数据,该期望什么。 例如,我们可以使用嵌套交叉验证方法将 SVM 模型与简单的决策树分类器进行比较。 为简单起见,我们仅调整其 depth 参数:
>>> from sklearn.tree import DecisionTreeClassifier
>>> gs = GridSearchCV(
... estimator=DecisionTreeClassifier(random_state=0),
... param_grid=[
... {'max_depth': [1, 2, 3, 4, 5, 6, 7, None]}],
... scoring='accuracy',
... cv=5)
>>> scores = cross_val_score(gs,
... X_train,
... y_train,
... scoring='accuracy',
... cv=2)
>>> print('CV accuracy: %.3f +/- %.3f' % (
... np.mean(scores), np.std(scores)))
CV accuracy: 0.921 +/- 0.029
正如我们在这里看到的那样,SVM 模型的嵌套交叉验证性能(97.8%)明显优于决策树的性能(90.8%)。 因此,我们希望这可能是对来自与该特定数据集相同总体的新数据进行分类的更好的选择。
查看不同的绩效评估指标
在的前面的章节中,我们使用模型准确性评估了模型,该准确性是量化模型总体性能的有用度量。 但是,还有一些其他性能指标可用于测量模型的相关性,例如精度,召回和 F1 得分。
读取混淆矩阵
在进入不同评分指标的细节之前,让我们打印一个所谓的混淆矩阵,该矩阵列出学习算法的性能。 混淆矩阵只是一个正方形矩阵,它报告真阳性,真阴性,假阳性和假阴性的计数 分类器的预测,如下图所示:

尽管可以通过比较真实和预测的类标签轻松地手动计算这些指标,但是 scikit-learn 提供了便捷的confusion_matrix函数,我们可以按以下方式使用它:
>>> from sklearn.metrics import confusion_matrix
>>> pipe_svc.fit(X_train, y_train)
>>> y_pred = pipe_svc.predict(X_test)
>>> confmat = confusion_matrix(y_true=y_test, y_pred=y_pred)
>>> print(confmat)
[[71 1]
[ 2 40]]
执行前面的代码后返回的数组为我们提供了有关测试数据集上分类器不同类型错误的信息,我们可以使用 matplotlib 的matshow函数将其映射到上图中的混淆矩阵图中:
>>> fig, ax = plt.subplots(figsize=(2.5, 2.5))
>>> ax.matshow(confmat, cmap=plt.cm.Blues, alpha=0.3)
>>> for i in range(confmat.shape[0]):
... for j in range(confmat.shape[1]):
... ax.text(x=j, y=i,
... s=confmat[i, j],
... va='center', ha='center')
>>> plt.xlabel('predicted label')
>>> plt.ylabel('true label')
>>> plt.show()
现在,如下所示的混淆矩阵图应该使结果更易于解释:

假设在此示例中类别 1(恶性)为阳性类别,我们的模型分别正确分类了属于类别 0 的样本 71 个(真阴性)和属于类别 1 的 40 个样本(真阳性)。 但是,我们的模型还将 0 类中的 1 个样本错误地错误分类为 1 类(假阳性),并且尽管它是恶性肿瘤(假阴性),但它预测 2 个样本是良性的。 在下一节中,我们将学习如何使用此信息来计算各种不同的错误度量。
优化分类模型的精度和召回率
预测误差(ERR)和精度(ACC) 许多样本被错误分类。 可以将错误理解为所有错误预测的总和除以总预测的数目,准确度是计算为正确预测的总和除以预测的总数目:

然后可以直接从误差中计算出预测精度:

真阳性率(TPR)和假阳性率(FPR)是特别重要的性能指标 对于班级不平衡问题很有用:


例如,在肿瘤诊断中,我们更加关注恶性肿瘤的检测,以帮助患者进行适当的治疗。 但是,减少不必要分类为恶性肿瘤(假阳性)的良性肿瘤的数量也很重要,以免不必要地引起患者的注意。 与 FPR 相比,真实的阳性率提供了有关阳性(或相关)样本中被正确识别出的阳性总数(P)的分数的有用信息。
精度(PRE)和召回(REC)是与那些真实阳性和 真正的负税率,实际上,回忆是真正的正税率的代名词:


实际上,通常会结合使用精度和查全率,即所谓的 F1 分数:

这些评分指标都在 scikit-learn 中实现,可以从sklearn.metrics模块导入,如以下代码片段所示:
>>> from sklearn.metrics import precision_score
>>> from sklearn.metrics import recall_score, f1_score
>>> print('Precision: %.3f' % precision_score(
... y_true=y_test, y_pred=y_pred))
Precision: 0.976
>>> print('Recall: %.3f' % recall_score(
... y_true=y_test, y_pred=y_pred))
Recall: 0.952
>>> print('F1: %.3f' % f1_score(
... y_true=y_test, y_pred=y_pred))
F1: 0.964
此外,我们可以通过评分参数使用除GridSearch中的准确性以外的其他评分指标。 可在这个页面上找到评分参数接受的不同值的完整列表。
请记住,scikit-learn 中的正类是标记为类 1 的类。如果我们要指定不同的正标签,我们可以通过make_scorer函数构造自己的得分器, 然后可以直接在GridSearchCV中将 scoring 参数作为参数提供:
>>> from sklearn.metrics import make_scorer, f1_score
>>> scorer = make_scorer(f1_score, pos_label=0)
>>> gs = GridSearchCV(estimator=pipe_svc,
... param_grid=param_grid,
... scoring=scorer,
... cv=10)
绘制接收机工作特性
接收器操作员特征(ROC)图是有用的工具,可根据其相对于假阳性率和真阳性率的性能来选择分类模型,这些值是通过移位计算得出的 分类器的决策阈值。 ROC 图的对角线可以解释为随机猜测,而低于对角线的分类模型被认为比随机猜测更糟。 理想的分类器将落在图的左上角,其真率为 1,假率为 0。基于 ROC 曲线,我们可以计算曲线下的面积(AUC)表征分类模型的性能。
注意
与 ROC 曲线类似,我们可以为分类器的不同概率阈值计算精确召回曲线。 scikit-learn 中还实现了绘制这些精确调用曲线的功能,并记录在这个页面中。
通过执行以下代码示例,我们将绘制仅使用威斯康星州乳腺癌数据集中的两个特征来预测肿瘤是良性还是恶性的分类器的 ROC 曲线。 尽管我们将使用与先前定义的逻辑回归管道,但对于分类器来说,使分类任务更具挑战性,从而使生成的 ROC 曲线在视觉上变得更加有趣。 出于类似的原因,我们还将StratifiedKFold验证程序中的折叠数减少为三。 代码如下:
>>> from sklearn.metrics import roc_curve, auc
>>> from scipy import interp
>>> pipe_lr = Pipeline([('scl', StandardScaler()),
... ('pca', PCA(n_components=2)),
... ('clf', LogisticRegression(penalty='l2',
... random_state=0,
... C=100.0))])
>>> X_train2 = X_train[:, [4, 14]]
>>> cv = StratifiedKFold(y_train,
... n_folds=3,
... random_state=1)
>>> fig = plt.figure(figsize=(7, 5))
>>> mean_tpr = 0.0
>>> mean_fpr = np.linspace(0, 1, 100)
>>> all_tpr = []
>>> for i, (train, test) in enumerate(cv):
... probas = pipe_lr.fit(X_train2[train], >>> y_train[train]).predict_proba(X_train2[test])
... fpr, tpr, thresholds = roc_curve(y_train[test],
... probas[:, 1],
... pos_label=1)
... mean_tpr += interp(mean_fpr, fpr, tpr)
... mean_tpr[0] = 0.0
... roc_auc = auc(fpr, tpr)
... plt.plot(fpr,
... tpr,
... lw=1,
... label='ROC fold %d (area = %0.2f)'
... % (i+1, roc_auc))
>>> plt.plot([0, 1],
... [0, 1],
... linestyle='--',
... color=(0.6, 0.6, 0.6),
... label='random guessing')
>>> mean_tpr /= len(cv)
>>> mean_tpr[-1] = 1.0
>>> mean_auc = auc(mean_fpr, mean_tpr)
>>> plt.plot(mean_fpr, mean_tpr, 'k--',
... label='mean ROC (area = %0.2f)' % mean_auc, lw=2)
>>> plt.plot([0, 0, 1],
... [0, 1, 1],
... lw=2,
... linestyle=':',
... color='black',
... label='perfect performance')
>>> plt.xlim([-0.05, 1.05])
>>> plt.ylim([-0.05, 1.05])
>>> plt.xlabel('false positive rate')
>>> plt.ylabel('true positive rate')
>>> plt.title('Receiver Operator Characteristic')
>>> plt.legend(loc="lower right")
>>> plt.show()
在前面的代码示例中,我们使用了 scikit-learn 中已经熟悉的StratifiedKFold类,并使用sklearn.metrics中的roc_curve函数计算了pipe_lr流水线中LogisticRegression分类器的 ROC 性能。 每次迭代分别使用模块。 此外,我们通过从 SciPy 导入的interp函数对三倍平均 ROC 曲线进行插值,并通过auc函数计算了曲线下的面积。 产生的 ROC 曲线表明不同倍数之间存在一定程度的差异,并且平均 ROC AUC(0.75)介于完美分数(1.0)和随机猜测(0.5)之间:

如果仅对 ROC AUC 分数感兴趣,我们也可以直接从sklearn.metrics子模块导入roc_auc_score函数。 以下代码将其拟合到具有两个特征的训练集后,可在独立测试数据集上计算分类器的 ROC AUC 分数:
>>> pipe_lr = pipe_lr.fit(X_train2, y_train)
>>> y_pred2 = pipe_lr.predict(X_test[:, [4, 14]])
>>> from sklearn.metrics import roc_auc_score
>>> from sklearn.metrics import accuracy_score
>>> print('ROC AUC: %.3f' % roc_auc_score(
... y_true=y_test, y_score=y_pred2))
ROC AUC: 0.662
>>> print('Accuracy: %.3f' % accuracy_score(
... y_true=y_test, y_pred=y_pred2))
Accuracy: 0.711
将分类器的性能报告为 ROC AUC 可以针对分类器针对不平衡样本的性能提供进一步的见解。 但是,虽然准确度得分可以解释为 ROC 曲线上的单个分界点,但 AP Bradley 指出,ROC AUC 和准确度指标大多彼此一致(AP Bradley。 机器学习算法评估中 ROC 曲线下的面积。模式识别,30(7):1145-1159,1997)。
多类别分类的评分指标
我们在本节中讨论的评分指标特定于二进制分类系统。 但是,scikit-learn 还实现了宏和微型平均方法,以通过将单项与全部(OvA)分类。 微观平均值是根据系统的各个真实肯定,真实否定,错误肯定和错误否定来计算的。 例如,k 级系统中精度得分的微平均值可以计算如下:

宏平均值可以简单地计算为不同系统的平均分数:

如果我们想对每个实例或预测进行平均加权,则微平均很有用;而对每个类进行宏平均加权,则可以对最频繁使用的类标签评估分类器的整体性能。
如果我们使用二进制性能指标来评估 scikit-learn 中的多类分类模型,则默认情况下使用宏平均值的归一化或加权变体。 在计算平均值时,通过将每个类别标签的得分乘以真实实例的数量进行加权,可以计算出加权宏平均值。 如果我们要处理类不平衡问题,即每个标签的实例数量不同,则加权宏平均值很有用。
对于 scikit-learn 中的多类问题,默认为加权宏平均,但我们可以通过sklearn.metrics模块导入的不同评分函数中的average参数指定平均方法,例如, precision_score或make_scorer功能:
>>> pre_scorer = make_scorer(score_func=precision_score,
... pos_label=1,
... greater_is_better=True,
... average='micro')



四十一、组合不同的模型以便集成学习
在上一章中,我们重点介绍了优化和评估不同分类模型的最佳实践。 在本章中,我们将基于这些技术并探索构建一组分类器的不同方法,这些分类器通常比其任何单个成员具有更好的预测性能。 你将学到如何:
- 根据多数投票做出预测
- 通过绘制带有重复的训练集的随机组合来减少过度拟合
- 从弱小学习者建立强大的模型,从他们的错误中学习
通过合奏学习
集成方法 背后的目标是将不同的分类器组合成一个元分类器,该分类器比单独的每个分类器具有更好的泛化性能。 例如,假设我们收集了 10 位专家的预测,那么集成方法将使我们能够策略性地组合 10 位专家的这些预测,以得出比每位专家的预测更准确,更可靠的预测。 正如我们将在本章稍后看到的那样,有几种不同的方法可以创建分类器集合。 在本节中,我们将介绍有关集成的工作原理以及为什么通常以产生良好的泛化性能而闻名的原因的基本认识。
在本章中,我们将重点介绍使用多数表决原理的最流行的合奏方法。 多数投票只是意味着我们选择大多数分类器预测的分类标签,即获得超过 50%的投票。 严格来说,多数票一词仅指二进制类设置。 但是,很容易将多数表决原则推广到多类别设置,这称为多个表决。 在这里,我们选择获得最多投票(模式)的类别标签。 下图说明了由 10 个分类器组成的集合的多数表决和多数表决的概念,其中每个唯一符号(三角形,正方形和圆形)代表一个唯一的类别标签:

使用训练集合,我们从训练m不同分类器(C[1], ..., C[m])开始。 根据技术的不同,可以根据不同的分类算法(例如决策树,支持向量机,逻辑回归分类器等)构建整体。 或者,我们也可以使用适合训练集不同子集的相同基础分类算法。 这种方法的一个突出示例是随机森林算法,该算法结合了不同的决策树分类器。 下图说明了使用多数投票的一般合奏方法的概念:

为了通过简单多数或多次投票来预测类别标签,我们将每个分类器C[j]的预测类别标签进行组合,然后选择获得最多投票的类别标签y_hat:

例如,在class1 = -1和class2 = +1的二进制分类任务中,我们可以编写如下的多数投票预测:

为了说明,为什么集成方法比单独的分类器可以更好地工作,让我们应用组合器的简单概念。 对于以下示例,我们假设二进制分类任务的所有n个基本分类器均具有相同的错误率ε。 此外,我们假设分类器是独立的,并且错误率不相关。 在这些假设下,我们可以简单地将一组基本分类器的错误概率表示为二项式分布的概率质量函数:

此处,C(n, k)是二项式系数 n 选择 k。 换句话说,我们计算整体预测错误的概率。 现在,让我们看一下 11 个基本分类器(n = 11)的更具体示例,错误率为 0.25(ε = 0.25):

如我们所见,如果满足所有假设,则集合的错误率(0.034)远低于每个单独分类器的错误率(0.25)。 注意,在此简化图示中,将 50-50 除以偶数个分类器n视为错误,而这只有一半的时间是真实的。 为了在一系列不同的基本错误率上将这种理想的整体分类器与基本分类器进行比较,让我们在 Python 中实现概率质量函数:
>>> from scipy.misc import comb
>>> import math
>>> def ensemble_error(n_classifier, error):
... k_start = math.ceil(n_classifier / 2.0)
... probs = [comb(n_classifier, k) *
... error**k *
... (1-error)**(n_classifier - k)
... for k in range(k_start, n_classifier + 1)]
... return sum(probs)
>>> ensemble_error(n_classifier=11, error=0.25)
0.034327507019042969
在实现ensemble_error函数之后,我们可以计算范围从 0.0 到 1.0 的不同基础误差的整体误差率,以在折线图中可视化整体和基础误差之间的关系:
>>> import numpy as np
>>> error_range = np.arange(0.0, 1.01, 0.01)
>>> ens_errors = [ensemble_error(n_classifier=11, error=error)
... for error in error_range]
>>> import matplotlib.pyplot as plt
>>> plt.plot(error_range, ens_errors,
... label='Ensemble error',
... linewidth=2)
>>> plt.plot(error_range, error_range,
... linestyle='--', label='Base error',
... linewidth=2)
>>> plt.xlabel('Base error')
>>> plt.ylabel('Base/Ensemble error')
>>> plt.legend(loc='upper left')
>>> plt.grid()
>>> plt.show()
正如我们在结果图中看到的那样,只要基本分类器的性能优于随机猜测(ε < 0.25),则集成的错误概率总是比单个基本分类器的错误概率要好。 请注意,y轴描述了基本误差(虚线)以及整体误差(实线):

实现简单的多数投票分类器
在上一节对集成学习的简短介绍之后,让我们开始进行热身练习,并为 Python 中的多数投票实现一个简单的集成分类器。 尽管以下算法也可以通过复数投票将其推广到多类别设置,但为简便起见,我们将使用术语多数投票,这在文献中也经常这样做。
我们将要实现的算法将使我们能够将与各个权重相关联的不同分类算法组合在一起,以提高置信度。 我们的目标是建立一个更强大的元分类器,以平衡特定数据集上各个分类器的弱点。 用更精确的数学术语,我们可以编写加权多数投票,如下所示:

在这里,w[j]是与基本分类器关联的权重,C[j],y_hat是集合的预测类别标签,χ[A](希腊语 chi )是特征函数C[j](x) = i ∈ A和A是唯一类标签的集合。 对于相等的权重,我们可以简化此等式并将其编写如下:

为了更好地理解加权的概念,我们现在来看一个更具体的示例。 假设我们有三个基本分类器C[j](j ∈ {0, 1})的集合,并且要预测给定样本实例的类别标签x。三个基本分类器中有两个预测类别标签 0, 一个C[3]预测该样本属于 1 类。如果我们对每个基本分类器的预测进行平均加权,则多数投票将预测该样本属于 0 类:


现在让我们分别为C[3]分配 0.6 的权重,并为C[1]和C[2]分配权重 0.2 的系数。


直观地讲,更多,因为3 x 0.2 = 0.6可以说,C[3]的预测权重分别比C[1]或C[2]的预测权重高三倍。 我们可以这样写:

要将加权多数投票的概念转换为 Python 代码,我们可以使用 NumPy 方便的argmax和bincount函数:
>>> import numpy as np
>>> np.argmax(np.bincount([0, 0, 1],
... weights=[0.2, 0.2, 0.6]))
1
如第 3 章和“使用 Scikit-learn” 进行的机器学习分类器之旅中所述,scikit-learn 中的某些分类器还可以通过predict_proba返回预测的类标签的概率。 方法。 如果对我们集成中的分类器进行了很好的校准,则使用预测的分类概率代替多数投票的分类标签将很有用。 用于根据概率预测类别标签的多数投票的修改版本可以写成:

在此,p[ij]是分类标签i的第 jth 个分类器的预测概率。
继续前面的示例,我们假设我们有一个带有类别标签i ∈ {0, 1}和三个分类器C[j](j ∈ {1, 2, 3})的集合的二进制分类问题。 假设分类器C[j]针对特定样本x返回以下类成员资格概率:

然后,我们可以如下计算各个类别的概率:



为了实现基于类概率的加权多数投票,我们可以再次使用numpy.average和np.argmax使用 NumPy:
>>> ex = np.array([[0.9, 0.1],
... [0.8, 0.2],
... [0.4, 0.6]])
>>> p = np.average(ex, axis=0, weights=[0.2, 0.2, 0.6])
>>> p
array([ 0.58, 0.42])
>>> np.argmax(p)
0
将所有放在一起,现在让我们在 Python 中实现一个MajorityVoteClassifier:
from sklearn.base import BaseEstimator
from sklearn.base import ClassifierMixin
from sklearn.preprocessing import LabelEncoder
from sklearn.externals import six
from sklearn.base import clone
from sklearn.pipeline import _name_estimators
import numpy as np
import operator
class MajorityVoteClassifier(BaseEstimator,
ClassifierMixin):
""" A majority vote ensemble classifier
Parameters
----------
classifiers : array-like, shape = [n_classifiers]
Different classifiers for the ensemble
vote : str, {'classlabel', 'probability'}
Default: 'classlabel'
If 'classlabel' the prediction is based on
the argmax of class labels. Else if
'probability', the argmax of the sum of
probabilities is used to predict the class label
(recommended for calibrated classifiers).
weights : array-like, shape = [n_classifiers]
Optional, default: None
If a list of `int` or `float` values are
provided, the classifiers are weighted by
importance; Uses uniform weights if `weights=None`.
"""
def __init__(self, classifiers,
vote='classlabel', weights=None):
self.classifiers = classifiers
self.named_classifiers = {key: value for
key, value in
_name_estimators(classifiers)}
self.vote = vote
self.weights = weights
def fit(self, X, y):
""" Fit classifiers.
Parameters
----------
X : {array-like, sparse matrix},
shape = [n_samples, n_features]
Matrix of training samples.
y : array-like, shape = [n_samples]
Vector of target class labels.
Returns
-------
self : object
"""
# Use LabelEncoder to ensure class labels start
# with 0, which is important for np.argmax
# call in self.predict
self.lablenc_ = LabelEncoder()
self.lablenc_.fit(y)
self.classes_ = self.lablenc_.classes_
self.classifiers_ = []
for clf in self.classifiers:
fitted_clf = clone(clf).fit(X,
self.lablenc_.transform(y))
self.classifiers_.append(fitted_clf)
return self
我在代码中添加了很多注释,以更好地理解各个部分。 但是,在实现其余方法之前,让我们先休息一下,讨论一些乍一看可能令人困惑的代码。 我们使用父类BaseEstimator和ClassifierMixin免费获得了一些基本功能,包括用于设置和返回分类器参数以及score的方法get_params和set_params 分别计算预测精度的方法。 还要注意,我们导入了six以使MajorityVoteClassifier与 Python 2.7 兼容。
接下来,如果我们使用vote='classlabel'初始化新的MajorityVoteClassifier对象,则将添加predict方法以基于类标签通过多数表决来预测类标签。 或者,我们将能够使用vote='probability'初始化整体分类器,以根据类成员资格概率预测类标签。 此外,我们还将添加predict_proba方法以返回平均概率,这对于计算曲线(ROC AUC)下的接收机操作员特征区域很有用。
def predict(self, X):
""" Predict class labels for X.
Parameters
----------
X : {array-like, sparse matrix},
Shape = [n_samples, n_features]
Matrix of training samples.
Returns
----------
maj_vote : array-like, shape = [n_samples]
Predicted class labels.
"""
if self.vote == 'probability':
maj_vote = np.argmax(self.predict_proba(X),
axis=1)
else: # 'classlabel' vote
# Collect results from clf.predict calls
predictions = np.asarray([clf.predict(X)
for clf in
self.classifiers_]).T
maj_vote = np.apply_along_axis(
lambda x:
np.argmax(np.bincount(x,
weights=self.weights)),
axis=1,
arr=predictions)
maj_vote = self.lablenc_.inverse_transform(maj_vote)
return maj_vote
def predict_proba(self, X):
""" Predict class probabilities for X.
Parameters
----------
X : {array-like, sparse matrix},
shape = [n_samples, n_features]
Training vectors, where n_samples is
the number of samples and
n_features is the number of features.
Returns
----------
avg_proba : array-like,
shape = [n_samples, n_classes]
Weighted average probability for
each class per sample.
"""
probas = np.asarray([clf.predict_proba(X)
for clf in self.classifiers_])
avg_proba = np.average(probas,
axis=0, weights=self.weights)
return avg_proba
def get_params(self, deep=True):
""" Get classifier parameter names for GridSearch"""
if not deep:
return super(MajorityVoteClassifier,
self).get_params(deep=False)
else:
out = self.named_classifiers.copy()
for name, step in\
six.iteritems(self.named_classifiers):
for key, value in six.iteritems(
step.get_params(deep=True)):
out['%s__%s' % (name, key)] = value
return out
另外,请注意,我们定义了自己的get_params方法的修改版本以使用_name_estimators函数,以便访问集合中各个分类器的参数。 乍一看,这可能看起来有些复杂,但是当我们在稍后的部分中使用网格搜索进行超参数调整时,这将非常有意义。
注意
尽管我们的MajorityVoteClassifier实现对于演示非常有用,但我还在 scikit-learn 中实现了多数表决分类器的更高级版本。 在下一发行版(v0.17)中将以sklearn.ensemble.VotingClassifier的形式提供。
结合不同的分类算法和多数票
现在是时候,将我们在上一节中实现的MajorityVoteClassifier付诸实践。 但是首先,让我们准备一个可以测试的数据集。 由于我们已经熟悉从 CSV 文件加载数据集的技术,因此,我们将采取捷径并从 scikit-learn 的数据集模块中加载 Iris 数据集。 此外,我们将仅选择萼片宽度 和花瓣长度这两个特征,以使分类任务更具挑战性。 尽管我们的MajorityVoteClassifier泛化为多类问题,但我们仅将 Iris-Versicolor 和 Iris-Virginica 这两个类别的花朵样本进行分类,以计算 ROC AUC。 代码如下:
>>> from sklearn import datasets
>>> from sklearn.cross_validation import train_test_split
>>> from sklearn.preprocessing import StandardScaler
>>> from sklearn.preprocessing import LabelEncoder
>>> iris = datasets.load_iris()
>>> X, y = iris.data[50:, [1, 2]], iris.target[50:]
>>> le = LabelEncoder()
>>> y = le.fit_transform(y)
注意
请注意,scikit-learn 使用predict_proba方法(如果适用)来计算 ROC AUC 分数。 在第 3 章和“使用 Scikit-learn” 的机器学习分类器中,我们看到了如何在逻辑回归模型中计算类概率。 在决策树中,概率是根据在训练时为每个节点创建的频率向量计算的。 该向量收集从该节点处的类标签分布计算出的每个类标签的频率值。 然后对频率进行归一化,使它们的总和为 1。类似地,将 k 个近邻的类别标签聚合在一起,以在 k 近邻算法中返回归一化的类别标签频率。 尽管决策树和 k 最近邻分类器返回的归一化概率看起来与从 Logistic 回归模型获得的概率相似,但我们必须意识到,这些概率实际上并非来自概率质量函数。
接下来,我们将虹膜样本分成 50%的训练和 50%的测试数据:
>>> X_train, X_test, y_train, y_test =\
... train_test_split(X, y,
... test_size=0.5,
... random_state=1)
使用训练数据集,我们现在将训练三个不同的分类器(逻辑回归分类器,决策树分类器和 k 最近邻分类器),并通过对训练数据集进行 10 倍交叉验证来查看它们的个人表现, 我们将它们组合成一个整体分类器:
>>> from sklearn.cross_validation import cross_val_score
>>> from sklearn.linear_model import LogisticRegression
>>> from sklearn.tree import DecisionTreeClassifier
>>> from sklearn.neighbors import KNeighborsClassifier
>>> from sklearn.pipeline import Pipeline
>>> import numpy as np
>>> clf1 = LogisticRegression(penalty='l2',
... C=0.001,
... random_state=0)
>>> clf2 = DecisionTreeClassifier(max_depth=1,
... criterion='entropy',
... random_state=0)
>>> clf3 = KNeighborsClassifier(n_neighbors=1,
... p=2,
... metric='minkowski')
>>> pipe1 = Pipeline([['sc', StandardScaler()],
... ['clf', clf1]])
>>> pipe3 = Pipeline([['sc', StandardScaler()],
... ['clf', clf3]])
>>> clf_labels = ['Logistic Regression', 'Decision Tree', 'KNN']
>>> print('10-fold cross validation:\n')
>>> for clf, label in zip([pipe1, clf2, pipe3], clf_labels):
... scores = cross_val_score(estimator=clf,
>>> X=X_train,
>>> y=y_train,
>>> cv=10,
>>> scoring='roc_auc')
>>> print("ROC AUC: %0.2f (+/- %0.2f) [%s]"
... % (scores.mean(), scores.std(), label))
如以下代码段所示,我们收到的输出表明各个分类器的预测性能几乎相等:
10-fold cross validation:
ROC AUC: 0.92 (+/- 0.20) [Logistic Regression]
ROC AUC: 0.92 (+/- 0.15) [Decision Tree]
ROC AUC: 0.93 (+/- 0.10) [KNN]
您可能想知道为什么我们训练逻辑回归和 k 最近邻分类器作为管道的一部分。 其背后的原因是,如第 3 章和“使用 Scikit-learn” 进行的机器学习分类器之旅中所述,逻辑回归和 k 最近邻算法(使用欧几里得 距离度量)与决策树相比,其比例不变。 尽管所有虹膜特征都是在相同的比例尺(cm)上测量的,但是使用标准化特征是一个好习惯。
现在,让我们继续进行更令人兴奋的部分,并在我们的MajorityVoteClassifier中结合各个分类器进行多数规则投票:
>>> mv_clf = MajorityVoteClassifier(
... classifiers=[pipe1, clf2, pipe3])
>>> clf_labels += ['Majority Voting']
>>> all_clf = [pipe1, clf2, pipe3, mv_clf]
>>> for clf, label in zip(all_clf, clf_labels):
... scores = cross_val_score(estimator=clf,
... X=X_train,
... y=y_train,
... cv=10,
... scoring='roc_auc')
... print("Accuracy: %0.2f (+/- %0.2f) [%s]"
... % (scores.mean(), scores.std(), label))
ROC AUC: 0.92 (+/- 0.20) [Logistic Regression]
ROC AUC: 0.92 (+/- 0.15) [Decision Tree]
ROC AUC: 0.93 (+/- 0.10) [KNN]
ROC AUC: 0.97 (+/- 0.10) [Majority Voting]
如我们所见,在 10 倍交叉验证评估中,MajorityVotingClassifier的性能大大优于单个分类器。
评估和调整集成分类器
在本节中,我们将根据测试集计算 ROC 曲线,以检查MajorityVoteClassifier是否能很好地推广到看不见的数据。 我们应该记住,测试集不能用于模型选择; 它的唯一目的是报告分类器系统的泛化性能的无偏估计。 代码如下:
>>> from sklearn.metrics import roc_curve
>>> from sklearn.metrics import auc
>>> colors = ['black', 'orange', 'blue', 'green']
>>> linestyles = [':', '--', '-.', '-']
>>> for clf, label, clr, ls \
... in zip(all_clf, clf_labels, colors, linestyles):
... # assuming the label of the positive class is 1
... y_pred = clf.fit(X_train,
... y_train).predict_proba(X_test)[:, 1]
... fpr, tpr, thresholds = roc_curve(y_true=y_test,
... y_score=y_pred)
... roc_auc = auc(x=fpr, y=tpr)
... plt.plot(fpr, tpr,
... color=clr,
... linestyle=ls,
... label='%s (auc = %0.2f)' % (label, roc_auc))
>>> plt.legend(loc='lower right')
>>> plt.plot([0, 1], [0, 1],
... linestyle='--',
... color='gray',
... linewidth=2)
>>> plt.xlim([-0.1, 1.1])
>>> plt.ylim([-0.1, 1.1])
>>> plt.grid()
>>> plt.xlabel('False Positive Rate')
>>> plt.ylabel('True Positive Rate')
>>> plt.show()
正如我们在生成的 ROC 中看到的那样,集成分类器在测试集上的效果也不错( ROC AUC = 0.95 ),而 k 最近邻分类器似乎过度拟合了训练数据(训练 ROC AUC = 0.93,测试 ROC AUC = 0.86 ):

由于我们仅选择作为分类示例,因此很有趣的是查看集合分类器的决策区域实际上是什么样的。 尽管在模型拟合之前不必标准化训练功能,因为我们的逻辑回归和 k 最近邻管道会自动处理此问题,但我们将标准化训练集,以便决策树的决策区域位于 出于视觉目的相同的比例。 代码如下:
>>> sc = StandardScaler()
>>> X_train_std = sc.fit_transform(X_train)
>>> from itertools import product
>>> x_min = X_train_std[:, 0].min() - 1
>>> x_max = X_train_std[:, 0].max() + 1
>>> y_min = X_train_std[:, 1].min() - 1
>>> y_max = X_train_std[:, 1].max() + 1
>>> xx, yy = np.meshgrid(np.arange(x_min, x_max, 0.1),
... np.arange(y_min, y_max, 0.1))
>>> f, axarr = plt.subplots(nrows=2, ncols=2,
... sharex='col',
... sharey='row',
... figsize=(7, 5))
>>> for idx, clf, tt in zip(product([0, 1], [0, 1]),
... all_clf, clf_labels):
... clf.fit(X_train_std, y_train)
... Z = clf.predict(np.c_[xx.ravel(), yy.ravel()])
... Z = Z.reshape(xx.shape)
... axarr[idx[0], idx[1]].contourf(xx, yy, Z, alpha=0.3)
... axarr[idx[0], idx[1]].scatter(X_train_std[y_train==0, 0],
... X_train_std[y_train==0, 1],
... c='blue',
... marker='^',
... s=50)
... axarr[idx[0], idx[1]].scatter(X_train_std[y_train==1, 0],
... X_train_std[y_train==1, 1],
... c='red',
... marker='o',
... s=50)
... axarr[idx[0], idx[1]].set_title(tt)
>>> plt.text(-3.5, -4.5,
... s='Sepal width [standardized]',
... ha='center', va='center', fontsize=12)
>>> plt.text(-10.5, 4.5,
... s='Petal length [standardized]',
... ha='center', va='center',
... fontsize=12, rotation=90)
>>> plt.show()
有趣的是,但也正如预期的那样,集成分类器的决策区域似乎是各个分类器的决策区域的混合体。 乍一看,多数表决决策边界看起来很像 k 近邻分类器的决策边界。 但是,我们可以看到它与sepal_width >= 1的y轴正交,就像决策树树桩一样:

在您学习如何调整单个分类器参数以进行整体分类之前,让我们调用get_params方法以基本了解如何访问GridSearch对象内的单个参数:
>>> mv_clf.get_params()
{'decisiontreeclassifier': DecisionTreeClassifier(class_weight=None, criterion='entropy', max_depth=1,
max_features=None, max_leaf_nodes=None, min_samples_leaf=1,
min_samples_split=2, min_weight_fraction_leaf=0.0,
random_state=0, splitter='best'),
'decisiontreeclassifier__class_weight': None,
'decisiontreeclassifier__criterion': 'entropy',
[...]
'decisiontreeclassifier__random_state': 0,
'decisiontreeclassifier__splitter': 'best',
'pipeline-1': Pipeline(steps=[('sc', StandardScaler(copy=True, with_mean=True, with_std=True)), ('clf', LogisticRegression(C=0.001, class_weight=None, dual=False, fit_intercept=True,
intercept_scaling=1, max_iter=100, multi_class='ovr',
penalty='l2', random_state=0, solver='liblinear', tol=0.0001,
verbose=0))]),
'pipeline-1__clf': LogisticRegression(C=0.001, class_weight=None, dual=False, fit_intercept=True,
intercept_scaling=1, max_iter=100, multi_class='ovr',
penalty='l2', random_state=0, solver='liblinear', tol=0.0001,
verbose=0),
'pipeline-1__clf__C': 0.001,
'pipeline-1__clf__class_weight': None,
'pipeline-1__clf__dual': False,
[...]
'pipeline-1__sc__with_std': True,
'pipeline-2': Pipeline(steps=[('sc', StandardScaler(copy=True, with_mean=True, with_std=True)), ('clf', KNeighborsClassifier(algorithm='auto', leaf_size=30, metric='minkowski',
metric_params=None, n_neighbors=1, p=2, weights='uniform'))]),
'pipeline-2__clf': KNeighborsClassifier(algorithm='auto', leaf_size=30, metric='minkowski',
metric_params=None, n_neighbors=1, p=2, weights='uniform'),
'pipeline-2__clf__algorithm': 'auto',
[...]
'pipeline-2__sc__with_std': True}
基于get_params方法返回的值,我们现在知道如何访问各个分类器的属性。 现在,我们通过网格搜索调整逻辑回归分类器的逆正则化参数C和决策树深度,以进行演示。 代码如下:
>>> from sklearn.grid_search import GridSearchCV
>>> params = {'decisiontreeclassifier__max_depth': [1, 2],
... 'pipeline-1__clf__C': [0.001, 0.1, 100.0]}
>>> grid = GridSearchCV(estimator=mv_clf,
... param_grid=params,
... cv=10,
... scoring='roc_auc')
>>> grid.fit(X_train, y_train)
网格搜索完成后,我们可以打印不同的超参数值组合和通过 10 倍交叉验证计算的平均 ROC AUC 得分。 代码如下:
>>> for params, mean_score, scores in grid.grid_scores_:
... print("%0.3f+/-%0.2f %r"
... % (mean_score, scores.std() / 2, params))
0.967+/-0.05 {'pipeline-1__clf__C': 0.001, 'decisiontreeclassifier__max_depth': 1}
0.967+/-0.05 {'pipeline-1__clf__C': 0.1, 'decisiontreeclassifier__max_depth': 1}
1.000+/-0.00 {'pipeline-1__clf__C': 100.0, 'decisiontreeclassifier__max_depth': 1}
0.967+/-0.05 {'pipeline-1__clf__C': 0.001, 'decisiontreeclassifier__max_depth': 2}
0.967+/-0.05 {'pipeline-1__clf__C': 0.1, 'decisiontreeclassifier__max_depth': 2}
1.000+/-0.00 {'pipeline-1__clf__C': 100.0, 'decisiontreeclassifier__max_depth': 2}
>>> print('Best parameters: %s' % grid.best_params_)
Best parameters: {'pipeline-1__clf__C': 100.0, 'decisiontreeclassifier__max_depth': 1}
>>> print('Accuracy: %.2f' % grid.best_score_)
Accuracy: 1.00
如我们所见,当我们选择较低的正则化强度(C = 100.0)时,我们会获得最佳的交叉验证结果,而树的深度似乎根本不会影响性能,这表明了一个决定 树桩足以分隔数据。 为了提醒自己,多次使用测试数据集进行模型评估是一种不好的做法,在本节中,我们将不估计已调整超参数的泛化性能。 我们将迅速转向集成学习的另一种方法:套袋。
注意
我们在本节中实现的多数表决方法有时也称为堆叠。 但是,堆叠算法通常与逻辑回归模型结合使用,该逻辑回归模型使用集合中各个分类器的预测作为输入来预测最终分类标签,这已由 DH Wolpert 的 David H. Wolpert 进行了更详细的描述。 。 堆叠概括。 神经网络,5(2):241-259,1992 年。
套袋–从引导程序样本构建分类器集合
套袋是的一种整体学习技术,与我们在上一节中实现的MajorityVoteClassifier紧密相关,如下图所示:

但是,我们没有使用相同的训练集来适合集合中的各个分类器,而是从初始训练集中绘制了引导样本(带有替换的随机样本),这就是为什么袋装也称为引导聚集。 为了提供有关引导过程的更具体示例,让我们考虑下图所示的示例。 在这里,我们有七个不同的训练实例(表示为索引 1-7),它们在每轮装袋中都随机抽样替换。 然后,每个引导程序样本都用于拟合分类器C[j],该分类器通常是未修剪的决策树:

套袋与也与我们在第 3 章,“使用 Scikit-learn” 的机器学习分类器介绍中引入的随机森林分类器有关。 实际上,随机森林是装袋的一种特殊情况,在这种情况下,我们还使用随机特征子集来拟合各个决策树。 套袋是 Leo Breiman 在 1994 年的一份技术报告中首次提出的; 他还表明,套袋可以提高不稳定模型的准确性,并减少过度拟合的程度。 我强烈建议您阅读有关他在布莱曼(L. Breiman)的研究。 套袋预测器。 机器学习,24(2):123–140,1996,可以免费在线在线获取有关装袋的更多信息。
为了了解实际情况,让我们使用我们在第 4 章,“建立良好的训练集–数据预处理”中引入的 Wine 数据集创建一个更复杂的分类问题。 在这里,我们只考虑 Wine 类 2 和 3,我们选择两个功能:酒精和色相。
>>> import pandas as pd
>>> df_wine = pd.read_csv('https://archive.ics.uci.edu/ml/machine-learning-databases/wine/wine.data', header=None)
>>> df_wine.columns = ['Class label', 'Alcohol',
... 'Malic acid', 'Ash',
... 'Alcalinity of ash',
... 'Magnesium', 'Total phenols',
... 'Flavanoids', 'Nonflavanoid phenols',
... 'Proanthocyanins',
... 'Color intensity', 'Hue',
... 'OD280/OD315 of diluted wines',
... 'Proline']
>>> df_wine = df_wine[df_wine['Class label'] != 1]
>>> y = df_wine['Class label'].values
>>> X = df_wine[['Alcohol', 'Hue']].values
接下来,我们将类标签编码为二进制格式,并将数据集分别分为 60%训练和 40%测试集:
>>> from sklearn.preprocessing import LabelEncoder
>>> from sklearn.cross_validation import train_test_split
>>> le = LabelEncoder()
>>> y = le.fit_transform(y)
>>> X_train, X_test, y_train, y_test =\
... train_test_split(X, y,
... test_size=0.40,
... random_state=1)
scikit-learn 中已经实现了BaggingClassifier算法,我们可以从ensemble子模块中导入该算法。 在这里,我们将使用未修剪的决策树作为基础分类器,并在训练数据集的不同引导样本上创建一个由 500 个决策树组成的集合:
>>> from sklearn.ensemble import BaggingClassifier
>>> tree = DecisionTreeClassifier(criterion='entropy',
... max_depth=None,
... random_state=1)
>>> bag = BaggingClassifier(base_estimator=tree,
... n_estimators=500,
... max_samples=1.0,
... max_features=1.0,
... bootstrap=True,
... bootstrap_features=False,
... n_jobs=1,
... random_state=1)
接下来,我们将在训练和测试数据集上计算预测的准确性得分,以将装袋分类器的性能与单个未修剪的决策树的性能进行比较:
>>> from sklearn.metrics import accuracy_score
>>> tree = tree.fit(X_train, y_train)
>>> y_train_pred = tree.predict(X_train)
>>> y_test_pred = tree.predict(X_test)
>>> tree_train = accuracy_score(y_train, y_train_pred)
>>> tree_test = accuracy_score(y_test, y_test_pred)
>>> print('Decision tree train/test accuracies %.3f/%.3f'
... % (tree_train, tree_test))
Decision tree train/test accuracies 1.000/0.833
根据我们通过执行前面的代码段打印的精度值,未修剪的决策树会正确预测训练样本的所有类别标签; 但是,实质上较低的测试准确性表明该模型具有较高的方差(过度拟合):
>>> bag = bag.fit(X_train, y_train)
>>> y_train_pred = bag.predict(X_train)
>>> y_test_pred = bag.predict(X_test)
>>> bag_train = accuracy_score(y_train, y_train_pred)
>>> bag_test = accuracy_score(y_test, y_test_pred)
>>> print('Bagging train/test accuracies %.3f/%.3f'
... % (bag_train, bag_test))
Bagging train/test accuracies 1.000/0.896
尽管决策树和装袋分类器的训练精度在训练集上都相似(均为 1.0),但我们可以看到,装袋分类器具有比测试集更高的泛化性能。 接下来,让我们比较决策树和装袋分类器之间的决策区域:
>>> x_min = X_train[:, 0].min() - 1
>>> x_max = X_train[:, 0].max() + 1
>>> y_min = X_train[:, 1].min() - 1
>>> y_max = X_train[:, 1].max() + 1
>>> xx, yy = np.meshgrid(np.arange(x_min, x_max, 0.1),
... np.arange(y_min, y_max, 0.1))
>>> f, axarr = plt.subplots(nrows=1, ncols=2,
... sharex='col',
... sharey='row',
... figsize=(8, 3))
>>> for idx, clf, tt in zip([0, 1],
... [tree, bag],
... ['Decision Tree', 'Bagging']):
... clf.fit(X_train, y_train)
...
... Z = clf.predict(np.c_[xx.ravel(), yy.ravel()])
... Z = Z.reshape(xx.shape)
... axarr[idx].contourf(xx, yy, Z, alpha=0.3)
... axarr[idx].scatter(X_train[y_train==0, 0],
... X_train[y_train==0, 1],
... c='blue', marker='^')
... axarr[idx].scatter(X_train[y_train==1, 0],
... X_train[y_train==1, 1],
... c='red', marker='o')
... axarr[idx].set_title(tt)
>>> axarr[0].set_ylabel(Alcohol', fontsize=12)
>>> plt.text(10.2, -1.2,
... s=Hue',
... ha='center', va='center', fontsize=12)
>>> plt.show()
正如我们在结果图中看到的所示,在装袋集合中,三节点深度决策树的分段线性决策边界看起来更平滑:

在本节中,我们仅看一个非常简单的装袋示例。 实际上,更复杂的分类任务和数据集的高维性很容易导致单个决策树的过度拟合,而这正是装袋算法可以真正发挥其优势的地方。 最后,我们将注意到装袋算法可能是减少模型方差的有效方法。 但是,套袋不能有效地减少模型偏差,这就是的原因,我们想要选择低偏差的分类器集合,例如未修剪的决策树。

通过自适应提升来利用弱势学习者
在本节中有关集成方法的部分,我们将讨论提升,特别关注其最常见的实现,AdaBoost([ Adaptive Boosting)。
注意
AdaBoost 背后的最初想法是由 Robert Schapire 在 1990 年提出的(R. E. Schapire。弱学习能力。机器学习,5(2):197–227,1990)。 Robert Schapire 和 Yoav Freund 在第十三届国际会议论文集(ICML 1996)中提出 AdaBoost 算法后,AdaBoost 成为随后几年中使用最广泛的集成方法之一(Y. Freund,RE Schapire 等人[ 使用新的增强算法进行实验。在 ICML 中,第 96 卷,第 148-156 页,1996 年)。 在 2003 年,Freund 和 Schapire 的开创性工作获得了戈德尔奖,这是计算机科学领域最杰出出版物的著名奖项。
在增强中,合奏由非常简单的基本分类器组成,这些分类器通常也称为弱学习者和,它们在性能上比随机猜测略有优势。 学习者能力弱的一个典型例子是决策树桩。 增强后的关键概念是集中于难以分类的训练样本,即让弱学习者随后从错误分类的训练样本中学习,以提高整体表现。 与装袋(加强的最初公式)相反,该算法使用从训练数据集中抽取的训练样本的随机子集而无需替换。 原始的增强过程分为四个关键步骤,如下所示:
- 抽取训练样本
d[1]的随机子集,而不用从训练集D进行替换来训练弱学习者C[1]。 - 从训练集中抽取第二个随机训练子集
d[2]而不进行替换,并添加 50%先前被错误分类以训练弱学习者的样本C[2]。 - 在训练集
D中找到训练样本d[3],在该训练集上C[1]和C[2]不同意训练第三位弱学习者C[3]。 - 通过多数投票将弱学习者
C[1],C[2]和C[3]合并在一起。
正如 Leo Breiman(L。Breiman。 Bias,Variance 和 Arcing 分类器。1996)所讨论的,与套袋模型相比,提振可以导致偏差和方差的减少。 然而,实际上,诸如 AdaBoost 之类的增强算法也因其高方差而闻名,也就是说,倾向于过度拟合训练数据(G. Raetsch,T。Onoda 和 KR Mueller。 Adaboost 的一种避免方法) 过度拟合,见《国际神经信息处理会议公报》(Citeseer,1998 年)。
与此处所述的原始增强过程相反,AdaBoost 使用完整的训练集来训练弱学习者,在每次迭代中对训练样本进行加权,以建立一个强大的分类器,该学习者从集合中以前的弱学习者的错误中学习。 在深入研究 AdaBoost 算法的具体细节之前,让我们看一下下图以更好地了解 AdaBoost 背后的基本概念:

为了逐步通过 AdaBoost 插图介绍,我们从子图1开始,它代表了针对二进制分类的训练集,其中所有训练样本均被分配了相同的权重。 基于此训练集,我们训练一个决策树桩(以虚线显示),该树桩试图对两个类别(三角形和圆形)的样本进行分类,并通过最小化成本函数(或特殊样本中的杂质评分) 决策树集成的案例)。 对于下一轮(子图2),我们将较大的权重分配给两个先前错误分类的样本(圆圈)。 此外,我们降低了正确分类的样本的权重。 现在,下一个决策树桩将更加集中于权重最大的训练样本,即据称难以分类的训练样本。 子图2中显示的弱学习者对圆形类的三个不同样本进行了错误分类,如子图3所示,它们被赋予了较大的权重。 假设我们的 AdaBoost 合奏仅由三轮提升组成,然后我们将通过加权多数投票将在不同的重新加权训练子集中训练的三个弱学习者组合在一起,如子图4所示。
现在,对 AdaBoost 的基本概念有了的更好的理解,让我们更详细地了解使用伪代码的算法。 为了清楚起见,我们将分别用叉号×和按两个符号之间的点积·分别表示元素乘积。 步骤如下:
-
将权重向量
w设置为统一权重,其中sum(w) = 1 -
对于
m增强回合中的j,请执行以下操作: -
训练加权的弱学习者:
C[j] = train(X, y, w)。 -
预测类别标签:
y_hat = predict(C[j], X)。 -
计算加权错误率:
ε = w · (y_hat == y)。 -
计算系数:
α = 0.5 log((1 - ε) / ε)。 -
更新权重:
w := w × exp(-α[j] × y_hat × y)。 -
将权重归一化为 1:
w = w / sum(w)。 -
计算最终预测:
![Leveraging weak learners via adaptive boosting]()
注意,步骤 5 中的表达式(y_hat == y)表示 1s 和 0s 的向量,如果预测不正确,则将其分配为 1,否则将其分配为 0。
尽管 AdaBoost 算法看似非常简单,但让我们通过一个包含 10 个训练样本的训练集来遍历更具体的示例,如下表所示:

该表的第一列描述了训练样本 1 至 10 的样本索引。在第二列中,假设这是一维数据集,我们将看到各个样本的特征值。 第三列显示每个训练样本x[i]的真实类别标签y[i],其中y[i] ∈ {1, -1}。 初始权重显示在第四列; 我们将权重初始化为统一的并将其标准化为总和。 因此,在 10 个样本训练集的情况下,我们将 0.1 分配给权重向量w中的每个权重w[i]。 假设我们的分割标准为!x <= 3.0,则预测的类别标签y_hat显示在第五列中。 然后,表格的最后一列显示基于我们在伪代码中定义的更新规则的更新权重。
由于权重更新的计算乍看起来可能有点复杂,因此我们现在将逐步进行计算。 我们首先按照步骤 5 中所述计算加权错误率ε:

接下来,我们计算系数α[j](在步骤 6 中显示),该系数随后在步骤 7 中用于更新权重以及多数表决预测中的权重(步骤 10):

在计算了系数α[j]之后,我们现在可以使用以下公式更新权重向量:

在此,y_hat × y分别是预测类别标签和真实类别标签的向量之间的逐元素乘法。 因此,如果预测y_hat[i]是正确的,则y_hat[i] × y[i]将具有正号,因此由于α[j]也是正数,因此我们将的权重降低了。

同样,如果y_hat[i]像这样错误地预测标签,我们将增加的权重:

或像这样:

更新权重向量中的每个权重后,我们将权重归一化,以使它们的总和为 1(第 8 步):

在这里:

因此,对应于正确分类的样本的每个权重将从初始值 0.1 降低到0.065 / 0.914 ≈ 0.071,以进行下一轮增强。 类似地,每个错误分类的样本的权重将从 0.1 增加到0.153 / 0.914 ≈ 0.167。
简而言之,这就是 AdaBoost 。 跳到更实际的部分,让我们现在通过 scikit-learn 训练 AdaBoost 集成分类器。 我们将使用与上一节相同的 Wine 子集来训练装袋元分类器。 通过base_estimator属性,我们将在 500 个决策树树桩上训练AdaBoostClassifier:
>>> from sklearn.ensemble import AdaBoostClassifier
>>> tree = DecisionTreeClassifier(criterion='entropy',
... max_depth=None,
... random_state=0)
>>> ada = AdaBoostClassifier(base_estimator=tree,
... n_estimators=500,
... learning_rate=0.1,
... random_state=0)
>>> tree = tree.fit(X_train, y_train)
>>> y_train_pred = tree.predict(X_train)
>>> y_test_pred = tree.predict(X_test)
>>> tree_train = accuracy_score(y_train, y_train_pred)
>>> tree_test = accuracy_score(y_test, y_test_pred)
>>> print('Decision tree train/test accuracies %.3f/%.3f'
... % (tree_train, tree_test))
Decision tree train/test accuracies 0.845/0.854
如我们所见,与上一节中未修剪的决策树相比,决策树树桩倾向于不适合训练数据:
>>> ada = ada.fit(X_train, y_train)
>>> y_train_pred = ada.predict(X_train)
>>> y_test_pred = ada.predict(X_test)
>>> ada_train = accuracy_score(y_train, y_train_pred)
>>> ada_test = accuracy_score(y_test, y_test_pred)
>>> print('AdaBoost train/test accuracies %.3f/%.3f'
... % (ada_train, ada_test))
AdaBoost train/test accuracies 1.000/0.875
我们可以看到,AdaBoost 模型可以正确预测训练集的所有类别标签,并且与决策树树桩相比,还显示出测试集性能略有改善。 但是,我们也看到通过尝试减少模型偏差而引入了额外的方差。
尽管我们使用进行演示的另一个简单示例,但我们可以看到,与决策树桩相比,AdaBoost 分类器的性能略有提高,并且获得了与上一节中训练的装袋分类器非常相似的准确性得分。 但是,我们应该注意,基于重复使用测试集来选择模型被认为是不好的做法。 泛化性能的估计可能过于乐观,我们将在第 6 章,“学习模型评估和超参数调整”的最佳实践中对此进行更详细的讨论。
最后,让我们检查决策区域是什么样的:
>>> x_min = X_train[:, 0].min() - 1
>>> x_max = X_train[:, 0].max() + 1
>>> y_min = X_train[:, 1].min() - 1
>>> y_max = X_train[:, 1].max() + 1
>>> xx, yy = np.meshgrid(np.arange(x_min, x_max, 0.1),
... np.arange(y_min, y_max, 0.1))
>>> f, axarr = plt.subplots(1, 2,
... sharex='col',
... sharey='row',
... figsize=(8, 3))
>>> for idx, clf, tt in zip([0, 1],
... [tree, ada],
... ['Decision Tree', 'AdaBoost']):
... clf.fit(X_train, y_train)
... Z = clf.predict(np.c_[xx.ravel(), yy.ravel()])
... Z = Z.reshape(xx.shape)
... axarr[idx].contourf(xx, yy, Z, alpha=0.3)
... axarr[idx].scatter(X_train[y_train==0, 0],
... X_train[y_train==0, 1],
... c='blue',
... marker='^')
... axarr[idx].scatter(X_train[y_train==1, 0],
... X_train[y_train==1, 1],
... c='red',
... marker='o')
... axarr[idx].set_title(tt)
... axarr[0].set_ylabel('Alcohol', fontsize=12)
>>> plt.text(10.2, -1.2,
... s=Hue',
... ha='center',
... va='center',
... fontsize=12)
>>> plt.show()
通过查看决策区域,我们可以看到 AdaBoost 模型的决策边界实际上比决策树桩的决策边界复杂得多。 此外,我们注意到 AdaBoost 模型与上一节中训练的装袋分类器非常相似,将要素空间分开。

作为关于集成技术的总结,值得注意的是,与单个分类器相比,集成学习会增加计算复杂性。 在实践中,我们需要仔细考虑是否要为通常相对适度的预测性能提高付出付出的计算成本。
这种权衡取舍的一个经常被引用的例子是著名的 100 万美元的 Netflix 奖,该奖项是通过合奏技术获得的。 有关该算法的详细信息发表在 A. Toescher,M。Jahrer 和 R.M. Bell 中。 Netflix 大奖的 Bigchaos 解决方案。 Netflix 奖状文档,2009 年(可在这个页面中找到)。 尽管获胜的团队获得了 100 万美元的奖金,但 Netflix 由于其复杂性而从未实施他们的模型,这使其在现实应用中不可行。 引用他们的确切字词:
“ […]我们测得的额外精度增益似乎不足以证明将其投入生产环境所需的工程努力。”


四十二、使用回归分析预测连续目标变量
在前几章中,您了解了监督学习背后的主要概念,并为分类任务训练了许多不同的模型以预测组成员或分类变量。 在本章中,我们将深入研究监督学习的另一个子类别:回归分析。
回归模型用于在连续规模上预测目标变量,这使它们对于解决科学和工业应用中的许多问题具有吸引力,例如理解变量之间的关系,评估趋势或进行预测。 一个例子是预测未来几个月公司的销售额。
在本章中,我们将讨论回归模型的主要概念,并涉及以下主题:
- 探索和可视化数据集
- 研究实现线性回归模型的不同方法
- 训练对异常值具有鲁棒性的回归模型
- 评估回归模型并诊断常见问题
- 将回归模型拟合到非线性数据
介绍一个简单的线性回归模型
简单(单变量)线性回归的目标是为单个特征(解释变量x)与连续值响应之间的关系建模的模型( 目标变量和)。 具有一个解释变量的线性模型方程定义如下:

在此,权重w[0]代表y轴截距,w[1]是解释变量的系数。 我们的目标是学习线性方程的权重,以描述解释变量与目标变量之间的关系,然后将其用于预测不属于训练数据集的新解释变量的响应。
根据我们先前定义的线性方程,线性回归可以理解为通过样本点找到最合适的直线,如下图所示:

此最佳拟合线也称为回归线,从回归线到采样点的垂直线为偏移量或[ 残差-我们预测的误差。
一个解释变量的特殊情况也称为简单线性回归,但是我们当然也可以将线性回归模型推广到多个解释变量。 因此,此过程称为多元线性回归:

此处,w[0]是与x[0] = 1相对的y轴截距。
探索房屋数据集
在实施第一个线性回归模型之前,我们将介绍一个新数据集住房数据集,其中包含 D. Harrison 和 D.L.收集的波士顿郊区房屋信息。 1978 年鲁宾菲尔德(Rubinfeld)。住房数据集已免费提供,可以从 UCI 机器学习存储库下载。
506 个样本的特征可以汇总,如数据集描述的摘录所示:
- CRIM:这是城镇的人均犯罪率
- ZN:这是划分给大于 25,000 平方英尺的地块的住宅用地比例。
- 印度:这是每个城镇的非零售营业面积的比例
- CHAS:这是查尔斯河虚拟变量(如果束缚河,则等于 1;否则为 0)
- NOX:这是一氧化氮的浓度(百万分之几)
- RM:这是每个住宅的平均房间数
- 年龄:这是 1940 年之前建造的自有住房的比例
- DIS:这是到五个波士顿就业中心的加权距离
- RAD:这是径向公路的可达性指数
- 税:这是每 10,000 美元的全值财产税率
- PTRATIO:这是按城镇划分的师生比率
B:这是按 1000(Bk-0.63)^ 2 计算的,其中 Bk 是按城镇划分的非裔美国人的比例- LSTAT:这是总人口比例降低的百分比
- MEDV:这是自用房屋的中位数,单位为$ 1000
对于本章的其余部分,我们将房价(MEDV)视为目标变量,即我们要使用 13 个解释变量中的一个或多个来预测的变量。 在进一步探索该数据集之前,让我们将其从 UCI 存储库中提取到 PandasDataFrame中:
>>> import pandas as pd
>>> df = pd.read_csv('https://archive.ics.uci.edu/ml/machine-learning-databases/housing/housing.data',
... header=None, sep='\s+')
>>> df.columns = ['CRIM', 'ZN', 'INDUS', 'CHAS',
... 'NOX', 'RM', 'AGE', 'DIS', 'RAD',
... 'TAX', 'PTRATIO', 'B', 'LSTAT', 'MEDV']
>>> df.head()
为了确认数据集已成功加载,我们显示了数据集的前五行,如以下屏幕截图所示:

可视化数据集的重要特征
探索性数据分析(EDA)是训练机器学习模型之前重要的第一步。 在本节的其余部分,我们将使用来自图形 EDA 工具箱的一些简单但有用的技术,这些技术可能有助于我们直观地检测异常值的存在,数据的分布以及要素之间的关系。
首先,我们将创建一个 散点图矩阵,该矩阵使我们可以在一处可视化此数据集中不同特征之间的成对相关性。 为了绘制散点图矩阵,我们将使用seaborn库(http://stanford.edu/~mwaskom/software/seaborn/)中的pairplot函数,该函数是一个 Python 库,用于基于 matplotlib 绘制统计图:
>>> import matplotlib.pyplot as plt
>>> import seaborn as sns
>>> sns.set(style='whitegrid', context='notebook')
>>> cols = ['LSTAT', 'INDUS', 'NOX', 'RM', 'MEDV']
>>> sns.pairplot(df[cols], size=2.5)
>>> plt.show()
如下图所示,散点图矩阵为我们提供了数据集中关系的有用图形摘要:

注意
导入 seaborn 库会修改当前 Python 会话的 matplotlib 的默认外观。 如果您不想使用 seaborn 的样式设置,则可以通过执行以下命令来重置 matplotlib 设置:
>>> sns.reset_orig()
由于篇幅所限,并且出于可读性考虑,我们仅从数据集中绘制了五列:LSTAT,INDUS,NOX,RM 和 MEDV。 但是,建议您创建整个DataFrame的散点图矩阵,以进一步探索数据。
使用此散点图矩阵,我们现在可以快速查看数据的分布方式以及是否包含异常值。 例如,我们可以看到 RM 与房价 MEDV 之间存在线性关系(第四行的第五列)。 此外,我们可以在直方图中(散点图矩阵的右下子图)看到 MEDV 变量似乎呈正态分布,但包含多个异常值。
注意
请注意,与通常的看法相反,训练线性回归模型不需要解释变量或目标变量呈正态分布。 正态性假设只是某些统计检验和假设检验的要求,这些检验和假设检验不在本书的范围内(蒙哥马利 DC,Peck,EA 和 Vining,GG 线性回归分析简介,John Wiley 和 儿子,2012 年,第 318-319 页)。
为了量化特征之间的线性关系,我们现在将创建一个相关矩阵。 相关矩阵与在第 4 章中主成分分析(PCA)一节中看到的协方差矩阵密切相关。 HTG7]建立良好的训练集-数据预处理。 直观地,我们可以将相关矩阵解释为协方差矩阵的重新缩放版本。 实际上,相关矩阵与根据标准化数据计算的协方差矩阵相同。
相关矩阵是一个平方矩阵,包含皮尔逊积矩相关系数(通常缩写为 Pearson r),用于测量要素对之间的线性相关性。 相关系数的范围为-1 和 1。如果r = 1,则两个特征分别具有完全正相关;如果r = 0,则两个特征分别具有完全正相关;如果r = -1,则具有完全负相关。 如前所述,皮尔逊相关系数可以简单地计算为两个特征x和y(分子)之间的协方差除以它们的标准偏差(分母)的乘积:

这里,μ表示相应特征的样本均值,σ[xy]是特征x和y之间的协方差,σ[x]和σ[y]分别是特征的标准偏差。 。
注意
我们可以证明标准化特征之间的协方差实际上等于它们的线性相关系数。
让我们首先对特征x和y进行标准化,以获得它们的 z 分数,我们分别将其表示为x'和y':

请记住,我们计算两个要素之间的(人口)协方差如下:

由于标准化将特征变量的中心均值设为 0,因此我们现在可以按以下方式计算缩放特征之间的协方差:

通过重新替换,我们得到以下结果:


我们可以简化如下:

在下面的代码示例中,我们将在以前在散点图矩阵中可视化的五个特征列上使用 NumPy 的corrcoef函数,并且将使用 seaborn 的heatmap函数将相关矩阵数组绘制为热图 :
>>> import numpy as np
>>> cm = np.corrcoef(df[cols].values.T)
>>> sns.set(font_scale=1.5)
>>> hm = sns.heatmap(cm,
... cbar=True,
... annot=True,
... square=True,
... fmt='.2f',
... annot_kws={'size': 15},
... yticklabels=cols,
... xticklabels=cols)
>>> plt.show()
从结果图中可以看出,相关矩阵为我们提供了另一个有用的摘要图形,可以帮助我们根据特征各自的线性相关性来选择特征:

为了拟合线性回归模型,我们对与目标变量 MEDV 具有高度相关性的那些特征感兴趣。 看前面的相关矩阵,我们看到我们的目标变量 MEDV 与 LSTAT 变量显示最大的相关性(-0.74)。 但是,您可能会从散点图矩阵记住,LSTAT 和 MEDV 之间存在明显的非线性关系。 另一方面,RM 和 MEDV 之间的相关性也相对较高(0.70),并给出了在散点图中观察到的这两个变量 RM 之间的线性关系 似乎是解释变量在下一节中介绍简单线性回归模型概念的不错选择。
实现普通的最小二乘线性回归模型
在本章的开头,我们讨论了线性回归可以理解为通过训练数据的采样点找到最合适的直线。 但是,我们既没有定义最适合的术语,也没有讨论适合这种模型的不同技术。 在下面的小节中,我们将使用普通最小二乘法(OLS)方法来填充此难题的缺失部分,以估算将 到采样点的垂直距离的平方和(残差或误差)。**
用梯度下降法求解回归参数的回归
考虑我们对自适应线性神经网络(Adaline)的实现,该知识来自第 2 章,“训练机器学习算法,用于分类”; 我们记得人工神经元使用线性激活函数,并定义了成本函数J(·),我们通过优化算法将其最小化以学习权重,例如梯度下降(GD)和随机梯度下降(SGD)。 Adaline 中的成本函数是平方误差总和(SSE)。 这与我们定义的 OLS 成本函数相同:

这里,y_hat是预测值y_hat = w^T x(注意,为了方便起见,术语 1/2 仅用于导出 GD 的更新规则)。 本质上,OLS 线性回归可以理解为没有单位步长函数的 Adaline,以便我们获得连续的目标值,而不是类标签-1 和 1。为演示相似性,让我们以 Adaline 的 GD 实现方式 摘自第 2 章和“训练机器学习分类算法”,并删除单位步长函数以实现我们的第一个线性回归模型:
class LinearRegressionGD(object):
def __init__(self, eta=0.001, n_iter=20):
self.eta = eta
self.n_iter = n_iter
def fit(self, X, y):
self.w_ = np.zeros(1 + X.shape[1])
self.cost_ = []
for i in range(self.n_iter):
output = self.net_input(X)
errors = (y - output)
self.w_[1:] += self.eta * X.T.dot(errors)
self.w_[0] += self.eta * errors.sum()
cost = (errors**2).sum() / 2.0
self.cost_.append(cost)
return self
def net_input(self, X):
return np.dot(X, self.w_[1:]) + self.w_[0]
def predict(self, X):
return self.net_input(X)
如果需要重新了解权重的更新方式-向渐变的相反方向迈进-请重新访问第 2 章,“训练机器学习分类算法”中的 Adaline 部分。
要查看我们的LinearRegressionGD回归器的运行情况,让我们使用“住房数据集”中的 RM(房间数)变量作为解释变量来训练可以预测 MEDV(住房价格)的模型。 此外,我们将对变量进行标准化,以更好地收敛 GD 算法。 代码如下:
>>> X = df[['RM']].values
>>> y = df['MEDV'].values
>>> from sklearn.preprocessing import StandardScaler
>>> sc_x = StandardScaler()
>>> sc_y = StandardScaler()
>>> X_std = sc_x.fit_transform(X)
>>> y_std = sc_y.fit_transform(y)
>>> lr = LinearRegressionGD()
>>> lr.fit(X_std, y_std)
我们在第 2 章,“用于分类的训练机器学习算法”中进行了讨论,将成本绘制为历元数(遍历训练数据集)总是一个好主意。 ),当我们使用优化算法(例如梯度下降)来检查收敛性时。 简而言之,让我们针对时期数绘制成本,以检查线性回归是否收敛:
>>> plt.plot(range(1, lr.n_iter+1), lr.cost_)
>>> plt.ylabel('SSE')
>>> plt.xlabel('Epoch')
>>> plt.show()
正如我们在下图中看到的,GD 算法在第五个时期之后收敛:

接下来,让我们可视化线性回归线拟合训练数据的程度。 为此,我们将定义一个简单的辅助函数,该函数将绘制训练样本的散点图并添加回归线:
>>> def lin_regplot(X, y, model):
... plt.scatter(X, y, c='blue')
... plt.plot(X, model.predict(X), color='red')
... return None
现在,我们将使用此lin_regplot函数根据房价绘制房间数量:
>>> lin_regplot(X_std, y_std, lr)
>>> plt.xlabel('Average number of rooms [RM] (standardized)')
>>> plt.ylabel('Price in $1000\'s [MEDV] (standardized)')
>>> plt.show()
如下图所示,线性回归线反映了房价趋于随房间数量增加的总体趋势:

尽管此观察具有直观意义,但数据还告诉我们,在许多情况下,房间数量不能很好地解释房价。 在本章的后面,我们将讨论如何量化回归模型的性能。 有趣的是,我们还观察到一条奇怪的线y = 3,这表明价格可能已被削减。 在某些应用中,以其原始规模报告预测结果变量也可能很重要。 为了将预测的价格结果重新按$ 1000 的轴上的价格,我们可以简单地应用StandardScaler的inverse_transform方法:
>>> num_rooms_std = sc_x.transform([5.0])
>>> price_std = lr.predict(num_rooms_std)
>>> print("Price in $1000's: %.3f" % \
... sc_y.inverse_transform(price_std))
Price in $1000's: 10.840
在前面的代码示例中,我们使用了先前训练的线性回归模型来预测具有五个房间的房屋的价格。 根据我们的模型,这样的房子价值 10840 美元。
另外,值得一提的是,如果我们使用标准化变量,则从技术上讲,我们不必更新截距的权重,因为在这些变量中,y轴截距始终为 0 案件。 我们可以通过打印权重来快速确认:
>>> print('Slope: %.3f' % lr.w_[1])
Slope: 0.695
>>> print('Intercept: %.3f' % lr.w_[0])
Intercept: -0.000
通过 scikit-learn 估算回归模型的系数
在的上一部分中,我们实现了用于回归分析的工作模型。 但是,在实际的应用中,我们可能对更高效的实现感兴趣,例如 scikit-learn 的LinearRegression对象,该对象利用了 LIBLINEAR 库和有效的高级优化算法 与非标准化变量相比更好。 有时对于某些应用是理想的:
>>> from sklearn.linear_model import LinearRegression
>>> slr = LinearRegression()
>>> slr.fit(X, y)
>>> print('Slope: %.3f' % slr.coef_[0])
Slope: 9.102
>>> print('Intercept: %.3f' % slr.intercept_)
Intercept: -34.671
正如我们通过执行前面的代码所看到的,装有未标准化的 RM 和 MEDV 变量的 scikit-learn 的LinearRegression模型产生了不同的模型系数。 通过将 MEDV 与 RM 作图,将其与我们自己的 GD 实现进行比较:
>>> lin_regplot(X, y, slr)
>>> plt.xlabel('Average number of rooms [RM]')
>>> plt.ylabel('Price in $1000\'s [MEDV]')
>>> plt.show()w
现在,当我们通过执行上面的代码来绘制训练数据和拟合模型时,我们可以看到总体结果看起来与我们的 GD 实现相同:

注意
作为替代使用机器学习库的替代方法,还有一种用于解决 OLS 的封闭式解决方案,其中涉及一个线性方程组,可以在大多数入门级统计教科书中找到:

我们可以在 Python 中实现它,如下所示:
# adding a column vector of "ones"
>>> Xb = np.hstack((np.ones((X.shape[0], 1)), X))
>>> w = np.zeros(X.shape[1])
>>> z = np.linalg.inv(np.dot(Xb.T, Xb))
>>> w = np.dot(z, np.dot(Xb.T, y))
>>> print('Slope: %.3f' % w[1])
Slope: 9.102
>>> print('Intercept: %.3f' % w[0])
Intercept: -34.671
这种方法的优点是可以保证找到最佳解决方案。 但是,如果正在使用非常大的数据集,则在此公式中将矩阵求逆可能在计算上过于昂贵(有时也称为正规方程),或者样本矩阵可能是 单数(不可逆),这就是为什么我们在某些情况下可能更喜欢迭代方法的原因。
如果您对有关如何获得正态方程的更多信息感兴趣,我建议您从他在莱斯特大学的演讲中阅读斯蒂芬·波洛克博士的章节经典线性回归模型。 可从这个页面免费获得。
使用 RANSAC 拟合稳健的回归模型
异常值的存在会严重影响线性回归模型。 在某些情况下,我们数据的一小部分会对估计的模型系数产生很大的影响。 有许多统计测试可用于检测离群值,这超出了本书的范围。 但是,消除异常值始终需要我们作为数据科学家的判断力以及我们的领域知识。
作为排除异常值的替代方法,我们将使用随机抽样共识(RANSAC)算法研究一种可靠的回归方法,该算法将回归模型拟合到子集 数据,即所谓的内线。
我们可以将 RANSAC 迭代算法总结如下:
- 选择随机数的样本作为内点并拟合模型。
- 针对拟合模型测试所有其他数据点,并将落入用户给定公差范围内的那些点添加到内点。
- 使用所有 inlier 调整模型。
- 估计拟合模型与内部模型的误差。
- 如果性能达到某个用户定义的阈值或已达到固定的迭代次数,则终止算法; 否则,请返回步骤 1。
现在,让使用 scikit-learn 的RANSACRegressor对象将线性模型包装在 RANSAC 算法中:
>>> from sklearn.linear_model import RANSACRegressor
>>> ransac = RANSACRegressor(LinearRegression(),
... max_trials=100,
... min_samples=50,
... residual_metric=lambda x: np.sum(np.abs(x), axis=1),
... residual_threshold=5.0,
... random_state=0)
>>> ransac.fit(X, y)
我们将RANSACRegressor的最大迭代次数设置为 100,并使用min_samples=50,将随机选择的样本的最小次数设置为至少 50。使用residual_metric参数,我们提供了可调用的lambda函数,可以简单地计算拟合线和采样点之间的绝对垂直距离。 通过将residual_threshold参数设置为5.0,如果样本到拟合线的垂直距离在 5 个距离单位以内,则我们仅允许样本包含在内部样本集中,这在此特定数据集上效果很好。 默认情况下,scikit-learn 使用 MAD 估计值选择内部阈值,其中 MAD 代表目标值y的中位数绝对偏差。 但是,为内部阈值选择合适的值是特定于问题的,这是 RANSAC 的缺点之一。 近年来,已经开发出许多不同的方法来自动选择良好的内部阈值。 您可以在 R. Toldo 和 A. Fusiello 的书中找到详细的讨论。 健壮的多结构拟合中的阈值自动估计(在图像分析和处理– ICIAP 2009 中,第 123–131 页。Springer,2009 年)。
拟合完 RANSAC 模型后,让我们从拟合的 RANSAC 线性回归模型中获得离群值和离群值,并将它们与线性拟合一起绘制:
>>> inlier_mask = ransac.inlier_mask_
>>> outlier_mask = np.logical_not(inlier_mask)
>>> line_X = np.arange(3, 10, 1)
>>> line_y_ransac = ransac.predict(line_X[:, np.newaxis])
>>> plt.scatter(X[inlier_mask], y[inlier_mask],
... c='blue', marker='o', label='Inliers')
>>> plt.scatter(X[outlier_mask], y[outlier_mask],
... c='lightgreen', marker='s', label='Outliers')
>>> plt.plot(line_X, line_y_ransac, color='red')
>>> plt.xlabel('Average number of rooms [RM]')
>>> plt.ylabel('Price in $1000\'s [MEDV]')
>>> plt.legend(loc='upper left')
>>> plt.show()
正如我们可以在下面的散点图中看到的一样,线性回归模型被拟合到检测到的以圆圈表示的一组象素上:

当我们打印执行以下代码的模型的斜率和截距时,我们可以看到线性回归线与上一节中未使用 RANSAC 的拟合度略有不同:
>>> print('Slope: %.3f' % ransac.estimator_.coef_[0])
Slope: 9.621
>>> print('Intercept: %.3f' % ransac.estimator_.intercept_)
Intercept: -37.137
使用 RANSAC,我们减少了此数据集中异常值的潜在影响,但我们不知道这种方法是否会对看不见的数据的预测性能产生积极影响。 因此,在下一部分中,我们将讨论如何评估不同方法的回归模型,这是构建预测模型系统的关键部分。

评估线性回归模型的性能
在的上一部分中,我们讨论了如何在训练数据上拟合回归模型。 但是,您在前面的章节中了解到,至关重要的是,在训练过程中未见过的数据上对该模型进行测试,以获取对其性能的无偏估计。
正如我们从第 6 章,“学习模型评估和超参数调整”的最佳实践中所记起的那样,我们希望将数据集拆分为单独的训练和测试数据集,并在其中使用前者来拟合模型 后者评估其性能以将其推广到看不见的数据。 现在,我们不再使用简单的回归模型,而是使用数据集中的所有变量并训练多元回归模型:
>>> from sklearn.cross_validation import train_test_split
>>> X = df.iloc[:, :-1].values
>>> y = df['MEDV'].values
>>> X_train, X_test, y_train, y_test = train_test_split(
... X, y, test_size=0.3, random_state=0)
>>> slr = LinearRegression()
>>> slr.fit(X_train, y_train)
>>> y_train_pred = slr.predict(X_train)
>>> y_test_pred = slr.predict(X_test)
由于我们的模型使用了多个解释变量,因此我们无法在二维图中可视化线性回归线(或精确地说是超平面),但可以绘制残差(实际值与预测值之间的差或垂直距离) 与预测值进行比较以诊断我们的回归模型。 那些残留图是诊断回归模型的常用图形分析,以检测非线性和离群值,并检查误差是否随机分布。
使用以下代码,我们现在将绘制残差图,在该残差图中,我们简单地从预测的响应中减去真实的目标变量:
>>> plt.scatter(y_train_pred, y_train_pred - y_train,
... c='blue', marker='o', label='Training data')
>>> plt.scatter(y_test_pred, y_test_pred - y_test,
... c='lightgreen', marker='s', label='Test data')
>>> plt.xlabel('Predicted values')
>>> plt.ylabel('Residuals')
>>> plt.legend(loc='upper left')
>>> plt.hlines(y=0, xmin=-10, xmax=50, lw=2, color='red')
>>> plt.xlim([-10, 50])
>>> plt.show()
执行代码后,我们应该看到一条残差图,其中有一条线穿过x轴原点,如下所示:

在完美预测的情况下,残差将恰好为零,在现实和实际应用中我们可能从未遇到过。 但是,对于一个好的回归模型,我们希望误差是随机分布的,并且残差应该随机分散在中心线周围。 如果我们在残差图中看到模式,则意味着我们的模型无法捕获一些解释性信息,这些信息泄漏到了残差中,就像我们在先前的残差图中略微看到的那样。 此外,我们还可以使用残差图来检测离群值,这些离群值由与中心线有较大偏差的点表示。
衡量模型性能的另一个有用的量化方法是所谓的均方误差(MSE),这只是我们最小化为 SSE 成本函数的平均值 拟合线性回归模型。 MSE 对于比较不同的回归模型或通过网格搜索和交叉验证调整其参数非常有用:

执行以下代码:
>>> from sklearn.metrics import mean_squared_error
>>> print('MSE train: %.3f, test: %.3f' % (
mean_squared_error(y_train, y_train_pred),
mean_squared_error(y_test, y_test_pred)))
我们将看到训练集的 MSE 为 19.96,测试集的 MSE 更大,值为 27.20,这表明我们的模型过度拟合了训练数据。
有时,报告确定系数(R²)可能更有用,可以将其理解为 MSE 的标准化版本,以更好地解释模型性能。 换句话说,R²是模型捕获的响应方差的一部分。 R²值定义如下:

在此,SSE 是平方误差的总和,而 SST 是平方和的总和,换句话说,它只是响应的方差。

让我们快速展示一下R²确实只是 MSE 的重新缩放版本:



对于训练数据集,R²的范围在 0 到 1 之间,但对于测试集可能变为负数。 如果为R² = 1,则模型将数据与相应的MSE = 0完美拟合。
根据训练数据评估,我们模型的R²为 0.765,听起来还不错。 但是,测试数据集上的R²仅为 0.673,我们可以通过执行以下代码进行计算:
>>> from sklearn.metrics import r2_score
>>> print('R^2 train: %.3f, test: %.3f' %
... (r2_score(y_train, y_train_pred),
... r2_score(y_test, y_test_pred)))
使用正则化方法进行回归
正如我们在第 3 章,“使用 Scikit-learn 进行的机器学习分类器”讨论中一样,正则化是一种通过添加其他信息来解决过拟合问题的方法。 缩小模型的参数值以降低复杂度。 进行正则化线性回归的最流行方法是岭回归,最小绝对收缩和选择算子(LASSO)和弹性网方法。
Ridge 回归是一个 L2 惩罚模型,我们只需将权重的平方和加到最小二乘成本函数中:

这里:

通过增加超参数λ的值,我们增加了正则化强度并缩小了模型的权重。 请注意,我们没有对拦截项w[0]进行正则化。
可以导致模型稀疏的另一种方法是 LASSO。 根据正则化强度,某些权重可以变为零,这使得 LASSO 也可以用作有监督的特征选择技术:

这里:

但是,LASSO 的局限性在于,如果m > n,它最多选择n变量。 Ridge 回归和 LASSO 之间的折衷是 Elastic Net,它具有产生稀疏性的 L1 损失和克服 LASSO 的某些限制(例如所选变量的数量)的 L2 损失。

这些正则回归模型都可以通过 scikit-learn 获得,其用法与正则回归模型相似,只是我们必须通过参数λ指定正则化强度,例如,通过 k 倍交叉验证进行了优化。
可以按以下方式初始化 Ridge 回归模型:
>>> from sklearn.linear_model import Ridge
>>> ridge = Ridge(alpha=1.0)
注意,正则化强度由参数alpha调节,该参数类似于参数λ。 同样,我们可以从linear_model子模块初始化 LASSO 回归器:
>>> from sklearn.linear_model import Lasso
>>> lasso = Lasso(alpha=1.0)
最后,ElasticNet实现允许我们更改 L1 与 L2 的比率:
>>> from sklearn.linear_model import ElasticNet
>>> lasso = ElasticNet(alpha=1.0, l1_ratio=0.5)
例如,如果我们将l1_ratio设置为1.0,则ElasticNet回归变量将等于 LASSO 回归变量。 有关线性回归的不同实现的更多详细信息,请参见这个页面上的文档。
将线性回归模型变成曲线-多项式回归
在前面的中,我们假设解释变量和响应变量之间存在线性关系。 解决违反线性假设的一种方法是通过添加多项式项来使用多项式回归模型:

在此,d表示多项式的次数。 尽管我们可以使用多项式回归来建模非线性关系,但是由于线性回归系数w,仍将其视为多元线性回归模型。
现在,我们将讨论如何使用 scikit-learn 中的PolynomialFeatures转换器类,将一个二次项(d = 2)添加到带有一个解释变量的简单回归问题中,并将多项式与线性拟合进行比较。 步骤如下:
-
添加二次多项式项:
from sklearn.preprocessing import PolynomialFeatures >>> X = np.array([258.0, 270.0, 294.0, … 320.0, 342.0, 368.0, … 396.0, 446.0, 480.0, … 586.0])[:, np.newaxis] >>> y = np.array([236.4, 234.4, 252.8, … 298.6, 314.2, 342.2, … 360.8, 368.0, 391.2, … 390.8]) >>> lr = LinearRegression() >>> pr = LinearRegression() >>> quadratic = PolynomialFeatures(degree=2) >>> X_quad = quadratic.fit_transform(X) -
拟合简单的线性回归模型进行比较:
>>> lr.fit(X, y) >>> X_fit = np.arange(250,600,10)[:, np.newaxis] >>> y_lin_fit = lr.predict(X_fit) -
在多项式回归的变换特征上拟合多元回归模型:
>>> pr.fit(X_quad, y) >>> y_quad_fit = pr.predict(quadratic.fit_transform(X_fit)) Plot the results: >>> plt.scatter(X, y, label='training points') >>> plt.plot(X_fit, y_lin_fit, ... label='linear fit', linestyle='--') >>> plt.plot(X_fit, y_quad_fit, ... label='quadratic fit') >>> plt.legend(loc='upper left') >>> plt.show()
在结果图中,我们可以看到多项式拟合比线性拟合更好地捕获了响应和解释变量之间的关系:

>>> y_lin_pred = lr.predict(X)
>>> y_quad_pred = pr.predict(X_quad)
>>> print('Training MSE linear: %.3f, quadratic: %.3f' % (
... mean_squared_error(y, y_lin_pred),
... mean_squared_error(y, y_quad_pred)))
Training MSE linear: 569.780, quadratic: 61.330
>>> print('Training R^2 linear: %.3f, quadratic: %.3f' % (
... r2_score(y, y_lin_pred),
... r2_score(y, y_quad_pred)))
Training R^2 linear: 0.832, quadratic: 0.982
正如我们可以在执行前面的代码后看到的一样,MSE 从 570(线性拟合)降低到 61(二次拟合),并且确定系数反映出更接近二次模型(R² = 0.982) 与该特定玩具问题中的线性拟合(R² = 0.832)相反。
在房屋数据集中建模非线性关系
在讨论了如何构造多项式特征以使其适合玩具问题中的非线性关系之后,现在让我们看一个更具体的示例,并将这些概念应用于住房数据集中的数据。 通过执行以下代码,我们将使用二阶(二次)多项式和三次(三次)多项式对房价与 LSTAT(较低人口百分比)之间的关系进行建模,并将其与线性拟合进行比较。
代码如下:
>>> X = df[['LSTAT']].values
>>> y = df['MEDV'].values
>>> regr = LinearRegression()
# create polynomial features
>>> quadratic = PolynomialFeatures(degree=2)
>>> cubic = PolynomialFeatures(degree=3)
>>> X_quad = quadratic.fit_transform(X)
>>> X_cubic = cubic.fit_transform(X)
# linear fit
>>> X_fit = np.arange(X.min(), X.max(), 1)[:, np.newaxis]
>>> regr = regr.fit(X, y)
>>> y_lin_fit = regr.predict(X_fit)
>>> linear_r2 = r2_score(y, regr.predict(X))
# quadratic fit
>>> regr = regr.fit(X_quad, y)
>>> y_quad_fit = regr.predict(quadratic.fit_transform(X_fit))
>>> quadratic_r2 = r2_score(y, regr.predict(X_quad))
# cubic fit
>>> regr = regr.fit(X_cubic, y)
>>> y_cubic_fit = regr.predict(cubic.fit_transform(X_fit))
>>> cubic_r2 = r2_score(y, regr.predict(X_cubic))
# plot results
>>> plt.scatter(X, y,
... label='training points',
... color='lightgray')
>>> plt.plot(X_fit, y_lin_fit,
... label='linear (d=1), $R^2=%.2f$'
... % linear_r2,
... color='blue',
... lw=2,
... linestyle=':')
>>> plt.plot(X_fit, y_quad_fit,
... label='quadratic (d=2), $R^2=%.2f$'
... % quadratic_r2,
... color='red',
... lw=2,
... linestyle='-')
>>> plt.plot(X_fit, y_cubic_fit,
... label='cubic (d=3), $R^2=%.2f$'
... % cubic_r2,
... color='green',
... lw=2,
... linestyle='--')
>>> plt.xlabel('% lower status of the population [LSTAT]')
>>> plt.ylabel('Price in $1000\'s [MEDV]')
>>> plt.legend(loc='upper right')
>>> plt.show()
正如我们在结果图中看到的所示,三次拟合比线性和二次拟合更好地捕获了房价和 LSTAT 之间的关系。 但是,我们应该意识到,添加越来越多的多项式特征会增加模型的复杂度,因此会增加过拟合的机会。 因此,在实践中,始终建议您在单独的测试数据集上评估模型的性能,以评估泛化性能:

此外,多项式特征并非始终是建模非线性关系的最佳选择。 例如,仅通过查看 MEDV-LSTAT 散点图,我们可以建议 LSTAT 特征变量和 MEDV 的平方根的对数变换 将数据投影到适合线性回归拟合的线性特征空间上。 让我们通过执行以下代码来检验这个假设:
# transform features
>>> X_log = np.log(X)
>>> y_sqrt = np.sqrt(y)
# fit features
>>> X_fit = np.arange(X_log.min()-1,
... X_log.max()+1, 1)[:, np.newaxis]
>>> regr = regr.fit(X_log, y_sqrt)
>>> y_lin_fit = regr.predict(X_fit)
>>> linear_r2 = r2_score(y_sqrt, regr.predict(X_log))
# plot results
>>> plt.scatter(X_log, y_sqrt,
... label='training points',
... color='lightgray')
>>> plt.plot(X_fit, y_lin_fit,
... label='linear (d=1), $R^2=%.2f$' % linear_r2,
... color='blue',
... lw=2)
>>> plt.xlabel('log(% lower status of the population [LSTAT])')
>>> plt.ylabel('$\sqrt{Price \; in \; \$1000\'s [MEDV]}$')
>>> plt.legend(loc='lower left')
>>> plt.show()
将解释性变量R² = 0.69转换为对数空间并取目标变量的平方根后,我们能够使用似乎比数据更适合(R² = 0.69)的线性回归线捕获两个变量之间的关系。 以前的任何多项式特征转换:

使用随机森林处理非线性关系
在此部分中,我们将研究随机森林回归,它在概念上与本章中的先前回归模型不同。 与前面讨论的全局线性和多项式回归模型相比,随机森林是多个决策树的集合,可以理解为分段线性函数的总和。 换句话说,通过决策树算法,我们将输入空间细分为较小的区域,这些区域变得更更易于管理。
决策树回归
决策树算法的优势在于,如果我们处理非线性数据,则不需要对特征进行任何转换。 我们从第 3 章和“使用 Scikit-learn” 进行的机器学习分类器之旅中还记得,我们通过迭代拆分其节点直到叶子是纯净的或停止准则为 使满意。 当我们使用决策树进行分类时,我们将熵定义为杂质的度量,以确定哪个特征分割最大化信息增益(IG),其定义如下 对于二进制拆分:

在这里,x是执行分割的功能,N[p]是父节点中样本的数量,I是杂质函数,D[p]是父节点中训练样本的子集,并且D[left]和D[right]是分割后左右子节点中训练样本的子集。 请记住,我们的目标是找到使信息增益最大化的特征分割,换句话说,我们希望找到减少子节点中杂质的特征分割。 在第 3 章,“使用 Scikit-learn”的机器学习分类器中,我们使用熵作为杂质的度量,这是分类的有用标准。 为了使用决策树进行回归,我们将用 MSE 代替熵作为节点t的杂质度量:

在此,N[t]是节点t处的训练样本的个数量,D[t]是节点t处的训练子集,y^(i)是真实的目标值,y_hat[t]是预测的目标 值(样本均值):

在决策树回归的情况下,MSE 通常也称为节点内方差,这就是为什么拆分标准也被更好地称为方差减少的原因。 要查看决策树的线拟合情况,让我们使用 scikit-learn 中实现的DecisionTreeRegressor对 MEDV 和 LSTAT 变量之间的非线性关系进行建模:
>>> from sklearn.tree import DecisionTreeRegressor
>>> X = df[['LSTAT']].values
>>> y = df['MEDV'].values
>>> tree = DecisionTreeRegressor(max_depth=3)
>>> tree.fit(X, y)
>>> sort_idx = X.flatten().argsort()
>>> lin_regplot(X[sort_idx], y[sort_idx], tree)
>>> plt.xlabel('% lower status of the population [LSTAT]')
>>> plt.ylabel('Price in $1000\'s [MEDV]')
>>> plt.show()
从结果图中可以看出,决策树捕获了数据中的总体趋势。 但是,此模型的局限性在于它无法捕获所需预测的的连续性和可微性。 此外,我们需要谨慎选择树的深度值,以免数据过拟合或欠拟合。 在这里,深度为 3 似乎是一个不错的选择:

在下一部分中,我们将介绍一种更合适的回归树拟合方法:随机森林。
随机森林回归
正如我们在第 3 章,“使用 Scikit-learn 进行的机器学习分类器之旅”中所讨论的那样,随机森林算法是一种结合了多个决策树的集成技术。 由于随机性有助于减少模型方差,因此随机森林通常比单个决策树具有更好的泛化性能。 随机森林的其他优点是它们对数据集中的异常值较不敏感,不需要太多的参数调整。 我们通常需要试验的随机森林中的唯一参数是合奏中树木的数量。 用于回归的基本随机森林算法与我们在第 3 章,“使用 Scikit-learn” 的机器学习分类器中讨论的用于分类的随机森林算法几乎相同。 唯一的不同是,我们使用 MSE 标准来增长各个决策树,并且将预测目标变量计算为所有决策树的平均预测。
现在,让我们使用 Housing Dataset 中的所有功能,对 60%的样本拟合随机森林回归模型,并在其余 40%的样本上评估其性能。 代码如下:
>>> X = df.iloc[:, :-1].values
>>> y = df['MEDV'].values
>>> X_train, X_test, y_train, y_test =\
... train_test_split(X, y,
... test_size=0.4,
... random_state=1)
>>> from sklearn.ensemble import RandomForestRegressor
>>> forest = RandomForestRegressor( ... n_estimators=1000,
... criterion='mse',
... random_state=1,
... n_jobs=-1)
>>> forest.fit(X_train, y_train)
>>> y_train_pred = forest.predict(X_train)
>>> y_test_pred = forest.predict(X_test)
>>> print('MSE train: %.3f, test: %.3f' % (
... mean_squared_error(y_train, y_train_pred),
... mean_squared_error(y_test, y_test_pred)))
>>> print('R^2 train: %.3f, test: %.3f' % (
... r2_score(y_train, y_train_pred),
... r2_score(y_test, y_test_pred)))
MSE train: 1.642, test: 11.635
R^2 train: 0.960, test: 0.871
不幸的是,我们看到随机森林倾向于过度拟合训练数据。 但是,仍然能够很好地解释目标变量和解释变量之间的关系(测试数据集上的R² = 0.871)。
最后,让我们看一下预测的残差:
>>> plt.scatter(y_train_pred,
... y_train_pred - y_train,
... c='black',
... marker='o',
... s=35,
... alpha=0.5,
... label='Training data')
>>> plt.scatter(y_test_pred,
... y_test_pred - y_test,
... c='lightgreen',
... marker='s',
... s=35,
... alpha=0.7,
... label='Test data')
>>> plt.xlabel('Predicted values')
>>> plt.ylabel('Residuals')
>>> plt.legend(loc='upper left')
>>> plt.hlines(y=0, xmin=-10, xmax=50, lw=2, color='red')
>>> plt.xlim([-10, 50])
>>> plt.show()
正如已经由R²系数总结的那样,我们可以看到该模型比训练数据更适合训练数据,如y轴方向上的异常值所指示。 同样,残差的分布在零中心点附近似乎并不是完全随机的,这表明该模型无法捕获所有探索性信息。 但是,残差图表示相对于本章前面绘制的线性模型的残差图有很大的改进:

注意
在第 3 章和“使用 Scikit-learn” 的机器学习分类器中,我们还讨论了可与支持向量机结合使用的内核技巧(支持向量机)用于分类,这在处理非线性问题时很有用。 尽管讨论超出了本书的范围,但 SVM 也可以用于非线性回归任务。 有兴趣的读者可以在 S. R. Gunn 的出色报告中找到有关支持向量机回归的更多信息:S. R. Gunn 等。 用于分类和回归的支持向量机。 (ISIS 技术报告,1998 年 14 月)。 SVM 回归器也在 scikit-learn 中实现,有关其用法的更多信息,请参见这个页面。




四十三、附录 A:答案
模块 2:数据分析
第 1 章:数据分析和库简介
| Q1 | 3 | | Q2 | 1 | | Q3 | 2 |第 2 章:面向对象的设计
| Q1 | 5 | | Q2 | 2 | | Q3 | 1 | | Q4 | 3 |第 3 章:使用 Pandas 进行数据分析
| Q1 | 1 | | Q2 | 2 | | Q3 | 3 | | Q4 | 1 | | Q5 | 3 |第 4 章:数据可视化
| Q1 | 2 | | Q2 | 4 | | Q3 | 2 | | Q4 | 1 |第 5 章:时间序列
| Q1 | 3 | | Q2 | 2 | | Q3 | 3 | | Q4 | 2 |第 6 章:与数据库交互
| Q1 | 3 | | Q2 | 4 | | Q3 | 2 |第 7 章:数据分析应用示例
| Q1 | 1 | | Q2 | 2 |模块 3:数据挖掘
第 1 章:数据挖掘入门
| Q1 | 2 | | Q2 | 1 | | Q3 | 4 |第 2 章:使用 scikit-learn 估计器进行分类
| Q1 | 3 | | Q2 | 2 | | Q3 | 2 | | Q4 | 3 |第 3 章:用决策树预测体育获胜者
| Q1 | 2 | | Q2 | 1 |第 4 章:使用相似性分析推荐电影
| Q1 | 4 |第 5 章:使用提升器提取特征
| Q1 | 2 | | Q2 | 3 | | Q3 | 3 |第 6 章:使用朴素贝叶斯进行社交媒体洞察
| Q1 | 2 | | Q2 | 2 |第 7 章:使用图形挖掘发现要遵循的帐户
| Q1 | 2 |第 8 章:使用神经网络击败 CAPTCHA
| Q1 | 3 |第 9 章:作者身份归属
| Q1 | 2 | | Q2 | 1 |第 10 章:群集新闻文章
| Q1 | 3 | | Q2 | 2 | | Q3 | 3 |第 11 章:使用深度学习对图像中的对象进行分类
| Q1 | 3 | | Q2 | 2 |第 12 章:使用大数据
| Q1 | 2 | | Q2 | 3 | | Q3 | 1 |模块 4:机器学习
第 1 章:赋予计算机学习数据的能力
| Q1 | 4 | | Q2 | 2 |第 2 章:训练机器学习
| Q1 | 3 |第 3 章:使用 scikit-learn 进行机器学习分类器之旅
| Q1 | 2 | | Q2 | 3 |第 4 章:建立良好的训练集-数据预处理
| Q1 | 1 |第 5 章:通过降维压缩数据
| Q1 | 2 |第 6 章:学习模型评估和超参数优化的最佳实践
| Q1 | 2 | | Q2 | 3 |第 7 章:组合不同的模型进行集成学习
| Q1 | 1 |第 8 章:使用回归分析预测连续目标变量
| Q1 | 2 |四十四、附录 B:参考书目
本课程融合了文字和测验,所有内容都打包在一起,牢记您的旅程。 它包含以下 Packt 产品的内容:
- Python 3 面向对象编程,第二版,Dusty Phillips
- 学习 Python,Fabrizio Romano
- Python 数据分析入门,Phuong Vo.T.H 和 Martin Czygan
- 使用 Python,Robert Layton 学习数据挖掘
- Python 机器学习,Sebastian Raschka
第一部分:Python 基础知识



第二部分:数据分析



第三部分:数据挖掘



第四部分:机器学习














浙公网安备 33010602011771号