人工智能与机器学习基础知识-全-

人工智能与机器学习基础知识(全)

原文:annas-archive.org/md5/0fdec4717e84cfdfa09316f6d2652a3f

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

关于

本节简要介绍了作者、本书涵盖的内容、开始学习所需的技术技能,以及完成所有包含的活动和练习所需的硬件和软件要求。

关于本书

机器学习和神经网络正迅速成为构建智能应用的支柱。本书首先向您介绍 Python,并讨论人工智能搜索算法的使用。您将学习数学密集型主题,如回归和分类,并通过 Python 示例进行说明。

然后您将进入高级人工智能技术和概念的学习,并使用真实数据集来形成决策树和聚类。您将介绍神经网络,这是一种受益于摩尔定律应用于 21 世纪计算能力的强大工具。到本书结束时,您将信心满满,并期待使用您新获得的技术构建自己的 AI 应用程序!

关于作者

Zsolt Nagy 是一家数据科学占主导地位的广告技术公司的工程经理。在获得关于本体推理的硕士学位后,他主要使用人工智能分析在线扑克策略,以帮助职业扑克选手做出决策。随着扑克热潮的结束,他将精力投入到构建领导力和软件工程 T 型轮廓中。

目标

  • 理解人工智能的重要性、原则和领域

  • 学习如何使用 Python 实现基本的路径寻找和游戏击败的人工智能

  • 在 Python 中实现回归和分类练习,应用于现实世界问题

  • 使用决策树和随机森林在 Python 中进行预测分析

  • 使用 k-means 和 mean shift 算法在 Python 中进行聚类

  • 通过实际示例了解深度学习的原理

读者对象

软件开发人员认为他们的未来作为数据科学家更有利可图,或者希望使用机器学习来丰富他们当前的个人或专业项目。虽然不需要 AI 的先前经验,但至少需要了解一种编程语言(最好是 Python)和高中水平的数学知识。尽管这是一本关于人工智能的入门级书籍,但中级学生将通过实现实际应用、使用和更新他们的基本人工智能知识来从提高 Python 技能中受益。

方法

本书采用实践方法教授您使用 Python 学习人工智能和机器学习。它包含多个活动,使用真实场景让您练习并应用您的新技能,在高度相关的环境中进行实践。

最小硬件要求

为了获得最佳的学生体验,我们推荐以下硬件配置:

  • 处理器:Intel Core i5 或等效

  • 内存:8 GB RAM

  • 存储:35 GB 可用空间

软件要求

您还需要预先安装以下软件:

  • 操作系统:Windows 7 SP1 64 位,Windows 8.1 64 位或 Windows 10 64 位,Ubuntu Linux,或最新版本的 macOS

  • 浏览器:Google Chrome(最新版本)

  • Anaconda(最新版本)

  • IPython(最新版本)

通用约定

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称显示如下:“最常见的激活函数是sigmoidtanh(双曲正切函数)”

代码块设置如下:

from sklearn.metrics.pairwise import euclidean_distances
points = [[2,3], [3,7], [1,6]]
euclidean_distances([[4,4]], points)

新术语和重要词汇以粗体显示。屏幕上显示的单词,例如在菜单或对话框中,在文本中显示如下:“支持向量机找到的最佳分隔符称为最佳分离超平面。”

安装和设置

在您开始本书之前,您需要安装 Python 3.6 和 Anaconda。您将在这里找到安装它们的步骤:

安装 Python

按照以下链接中的说明安装 Python 3.6:realpython.com/installing-python/

安装虚拟环境

从以下链接安装 Anaconda 版本。Anaconda 对于避免冲突的包至关重要,通过避免令人沮丧的错误,可以节省您的时间和精力。

要安装 Anaconda,请点击以下链接:www.anaconda.com/download/

选择您的操作系统并选择 Python 的最新版本。一旦您的包下载完成,运行它。

点击下一步后,您将看到一份许可协议。点击我同意后,您可以选择是否要为计算机上的所有用户安装 Anaconda。后者需要管理员权限。选择仅为我

然后,您必须选择您想要安装 Anaconda 的文件夹。请确保文件夹名称中没有空格或长 Unicode 字符。请确保您的计算机上至少有 3 GB 的空闲空间,并且您有一个足够快的互联网连接来下载文件。

在下一屏幕上,您可以选择是否要将 Anaconda 添加到PATH环境变量中。不要选择此选项,因为您将能够从开始菜单启动 Anaconda。

点击安装。安装 Anaconda 到您的计算机上可能需要几分钟。安装完成后,您可以选择了解更多关于 Anaconda Cloud 和 Anaconda 支持的信息,或者您可以选择取消勾选这些框并完成安装。

启动 Anaconda

您可以在开始菜单中找到已安装的 Anaconda。如果您在开始本书之前已经安装了 Anaconda,您可以选择将其升级到 Python 3。最干净的方法是卸载并重新安装它。

Anaconda Navigator 为您提供了访问本书所需的大部分工具。通过选择右上角的选项启动IPython

图片

Jupyter Notebook 是你将执行本书 Python 代码的地方。

其他资源

本书代码包也托管在 GitHub 上:https://github.com/TrainingByPackt/Artificial-Intelligence-and-Machine-Learning-Fundamentals。

我们还有其他来自我们丰富图书和视频目录的代码包,可在 github.com/PacktPublishing/ 找到。去看看吧!

第一章:人工智能原理

学习目标

到本章结束时,你将能够:

  • 描述人工智能的各个领域

  • 解释在 AI 中使用的的主要学习模型

  • 解释为什么 Python 是 AI 项目的流行语言

  • 为给定游戏在人工智能中建模状态空间

在本章中,你将了解 AI 的目的、领域和应用程序,以及我们将使用的 Python 特性的简要概述。

引言

在讨论不同的 AI 技术和算法之前,我们将探讨人工智能和机器学习的基础知识,并回顾一些基本定义。然后,通过引人入胜的例子,我们将继续在书中前进。将使用现实世界的例子以易于消化的方式展示人工智能的基本概念。

如果你想要在某个领域成为专家,你需要非常擅长基础知识。因此,让我们首先了解什么是人工智能:

定义:人工智能(AI)是一门利用硬件和软件解决方案构建智能的科学。

它受到逆向工程的影响,例如,在人类大脑中神经元工作的方式。我们的大脑由称为神经元的微小单元组成,以及称为神经网络的神经元网络。除了神经网络之外,还有许多其他神经科学模型可以用于解决人工智能中的现实世界问题。

机器学习是一个经常与人工智能混淆的术语。它起源于 20 世纪 50 年代,最早由亚瑟·李·塞缪尔在 1959 年定义。

定义:机器学习是研究如何使计算机在没有明确编程的情况下获得学习能力的一个研究领域。

托马斯·米切尔提出了一个更精确的数学定义的机器学习。

定义:如果一个计算机程序在经验 E、任务 T 和性能度量 P 方面,其性能随着经验 E 的提高而提高,那么我们说该程序从经验 E 中学习,关于任务 T 和性能度量 P。

从这两个定义中,我们可以得出结论,机器学习是实现人工智能的一种方式。然而,你可以没有机器学习而拥有人工智能。例如,如果你硬编码规则和决策树,或者应用搜索技术,你创建了一个人工智能代理,尽管你的方法与机器学习关系不大。

人工智能如何解决现实世界的问题?

人工智能通过模仿人类大脑处理信息的方式自动化人类智能。

每当我们解决问题或与人互动时,我们都会经历一个过程。每当我们限制问题或互动的范围时,这个过程通常可以被建模和自动化。

人工智能使计算机看起来像人类一样思考。

有时候,感觉 AI 知道我们需要的。想想你在网上购物后收到的个性化优惠券。到这本书的结尾,你将理解,为了选择最成功的产品,你需要了解如何最大化你的购买——这是一个相对简单的任务。然而,它也如此高效,以至于我们常常认为计算机“知道”我们需要的。

AI 是由执行低级指令的计算机执行的。

即使一个解决方案看起来很智能,我们编写的代码,就像任何其他软件解决方案一样。即使我们在模拟神经元,简单的机器代码和计算机硬件也在执行“思考”过程。

大多数 AI 应用都有一个主要目标。当我们与 AI 应用互动时,它看起来像人类,因为它可以将问题域限制在主要目标上。因此,我们有机会通过低级计算机指令来分解复杂的过程并模拟智能。

AI 可以刺激特定领域的人类感官和思维过程。

我们必须模拟人类的感觉和思维,有时甚至要欺骗 AI,让它相信我们在与另一个人互动。在特殊情况下,我们甚至可以增强我们自己的感官。

类似地,当我们与聊天机器人互动时,我们希望机器人能理解我们。我们希望聊天机器人或甚至语音识别系统能够提供一个满足我们期望的计算机-人界面。为了满足这些期望,计算机需要模拟人类的思维过程。

学科多样性

一辆无法感知到同一高速公路上其他车辆正在行驶的自动驾驶汽车将极其危险。AI 代理需要处理和感知其周围的环境,以便驾驶汽车。但那还不够。如果不理解移动物体的物理学,在正常环境中驾驶汽车将是一项几乎不可能完成的任务,更不用说致命的任务了。

为了创建一个可用的 AI 解决方案,需要涉及不同的学科。例如:

  • 机器人学:在空间中移动物体

  • 算法理论:构建高效的算法

  • 统计学:得出有用的结果,预测未来,分析过去

  • 心理学:模拟人类大脑的工作方式

  • 软件工程:创建可维护的解决方案,经受时间的考验

  • 计算机科学或计算机编程:为了在实践中实施我们的软件解决方案

  • 数学:执行复杂的数学运算

  • 控制理论:创建前馈和反馈系统

  • 信息论:表示、编码、解码和压缩信息

  • 图论:模拟和优化空间中的不同点,以及表示层次结构

  • 物理学:模拟现实世界

  • 计算机图形学和图像处理:显示和处理图像和电影

在这本书中,我们将介绍这些学科中的几个。记住,专注就是力量,我们现在正专注于对人工智能的高层次理解。

人工智能的领域和应用

既然我们已经知道了什么是人工智能,那么让我们继续前进,调查人工智能应用的不同领域。

人类行为模拟

人类有五种基本感官,简单分为视觉、听觉、动觉、嗅觉和味觉。然而,为了理解如何创建智能机器,我们可以将学科分为以下几类:

  • 听力和口语

  • 理解语言

  • 记忆事物

  • 思考

  • 看见

  • 移动

其中一些超出了我们的范围,因为这本书的目的是理解基础。例如,为了移动机器人手臂,我们可能需要学习复杂的大学水平数学来理解发生了什么。

听力和口语

使用语音识别系统,人工智能可以收集信息。使用语音合成,它可以把内部数据转换成可理解的声音。语音识别和语音合成技术处理人类发出的或人类可以理解的声音的识别和构建。

想象你正在一个你不会说当地语言的国家的旅行中。你可以对着手机的麦克风说话,期待它“理解”你所说的话,然后将其翻译成另一种语言。反之亦然,当地人说话,人工智能将声音翻译成你理解的语言。语音识别和语音合成使得这一切成为可能。

注意

语音合成的例子是谷歌翻译。您可以访问translate.google.com/,点击翻译词下面的扬声器按钮,让翻译器用非英语语言朗读翻译后的单词。

理解语言

我们可以通过处理自然语言来理解自然语言。这个领域被称为自然语言处理,简称 NLP。

当涉及到自然语言处理时,我们倾向于基于统计学习来学习语言。

记忆事物

我们需要表示我们对世界的了解。这就是创建知识库和称为本体的层次表示发挥作用的地方。本体将我们世界中的事物和思想分类,并包含这些类别之间的关系。

思考

我们的人工智能系统必须通过使用专家系统成为某个领域的专家。专家系统可以基于确定性数学逻辑,也可以基于模糊、非确定性的方式。

专家系统的知识库使用不同的技术来表示。随着问题领域的扩大,我们创建了层次化的本体。

我们可以通过在脑的构建块上建模网络来复制这种结构。这些构建块被称为神经元,而网络本身被称为神经网络

你还需要将另一个关键术语与神经网络联系起来:深度学习。深度学习之所以称为深度,是因为它超越了模式识别和分类。学习被印入了网络的神经网络结构中。例如,一个特殊的深度学习任务就是使用计算机视觉进行物体识别

视觉

我们必须通过我们的感官与真实世界互动。到目前为止,我们只触及了听觉感官,在语音识别和合成方面。如果我们必须看到东西呢?那么,我们就必须创建计算机视觉技术来了解我们的环境。毕竟,识别面部是有用的,而且大多数人类都是这方面的专家。

计算机视觉依赖于图像处理。虽然图像处理不是直接的人工智能学科,但它却是人工智能的必备学科。

移动

移动和触摸对我们人类来说是自然而然的,但对计算机来说却是非常复杂的任务。移动由机器人技术处理。这是一个非常数学化的主题。

机器人技术基于控制理论,其中你创建一个反馈回路,并根据收集到的反馈来控制你的物体运动。有趣的是,控制理论在其他领域也有应用,这些领域与在空间中移动物体毫无关系。这是因为所需的反馈回路与经济学中建模的回路相似。

模拟智能 – 图灵测试

阿兰·图灵,图灵机的发明者,一个在算法理论中使用的抽象概念,提出了一种测试智能的方法。这种测试在人工智能文献中被称为图灵测试

使用文本界面,审问者与人类和聊天机器人聊天。聊天机器人的任务是误导审问者,使他们无法判断计算机是人还是机器。

我们需要哪些学科才能通过图灵测试?

首先,我们需要理解一种口语语言,以了解审问者说了什么。我们通过使用自然语言处理NLP)来完成这项工作。我们还得做出回应。

我们需要成为人类思维倾向于感兴趣的事物的专家。我们需要建立一个关于人类的知识专家系统,涉及我们世界中的物体分类和抽象思维,以及历史事件甚至情感。

通过图灵测试非常困难。目前的预测表明,我们可能要到 2020 年代后期才能创造出足够好的系统来通过图灵测试。更进一步,如果这还不够,我们可以推进到全面图灵测试,这还包括运动和视觉。

人工智能工具和学习模型

在前面的章节中,我们发现了人工智能的基础。人工智能的一个核心任务是学习。

智能体

在解决人工智能问题时,我们在环境中创建一个可以收集其周围数据的演员,并影响其周围环境。这个演员被称为智能体。

智能体:

  • 是自主的

  • 通过传感器观察其周围环境

  • 使用执行器在其环境中行动

  • 将其活动直接指向实现目标

智能体也可能学习和访问知识库。

我们可以将智能体视为一个将感知映射到动作的函数。如果智能体有一个内部知识库,感知、动作和反应也可能改变知识库。

行为可能会受到奖励或惩罚。设定一个正确目标并实施胡萝卜加大棒策略有助于智能体学习。如果目标设定得当,智能体有机会战胜通常更为复杂的人类大脑。这是因为人类大脑的首要目标是生存,无论我们玩什么游戏。智能体的首要动机是达到目标本身。因此,智能体在没有任何知识的情况下随机移动时不会感到尴尬。

分类和预测

不同的目标需要不同的过程。让我们探讨两种最流行的 AI 推理类型:分类预测

分类是一个确定一个对象如何用另一个对象来定义的过程。例如,父亲是一个有一个或多个孩子的男性。如果简是一个孩子的父母,并且简是女性,那么简就是一个母亲。此外,简是人类、哺乳动物和生物体。我们知道简还有国籍和出生日期。

预测是根据模式和概率预测事物的过程。例如,如果一个在标准超市购物的顾客购买了有机牛奶,那么这个顾客比普通顾客更有可能购买有机酸奶。

学习模型

人工智能学习的过程可以是监督式或非监督式的。监督学习基于标记数据,并从训练数据中推断函数。线性回归是一个例子。非监督学习基于未标记数据,通常在聚类分析上工作。

Python 在人工智能中的作用

为了将基本的人工智能概念付诸实践,我们需要一种支持人工智能的编程语言。在这本书中,我们选择了 Python。Python 之所以成为人工智能的良好选择,有几个原因:

  • Python 是一种高级编程语言。这意味着你不必担心内存分配、指针或通用机器代码。你可以以方便的方式编写代码,并依赖 Python 的健壮性。Python 也是跨平台兼容的。

  • 开发者体验的强烈重视使 Python 成为软件开发人员非常受欢迎的选择。事实上,根据 2018 年www.hackerrank.com进行的一项开发者调查,在所有年龄段中,Python 被评为软件开发人员首选的语言。这是因为 Python 易于阅读且简单。因此,Python 非常适合快速应用开发

  • 尽管 Python 是一种解释型语言,但它与数据科学中使用的其他语言(如 R)相当。其主要优势是内存效率,因为 Python 可以处理大型内存数据库。

    注意

    Python 是一种多用途语言。它可以用来创建桌面应用程序、数据库应用程序、移动应用程序以及游戏。Python 的网络编程特性也值得提及。此外,Python 是一个出色的原型设计工具。

为什么 Python 在机器学习、数据科学和人工智能中占据主导地位?

要理解 Python 在机器学习、数据科学和人工智能中的主导地位,我们必须将 Python 与其他在这些领域中使用的语言进行比较。

其中一个主要的替代品是 R。与 R 相比,Python 的优势在于它更加通用且更实用。

与 Java 和 C++相比,用 Python 编写程序要快得多。Python 还提供了高度的可灵活性。

在灵活性和便利性方面,有一些语言在性质上相似:Ruby 和 JavaScript。Python 在这些语言中具有优势,因为 Python 拥有可用的 AI 生态系统。在任何领域,开源和第三方库的支持在很大程度上决定了该语言的成功。Python 的第三方 AI 库支持非常出色。

Anaconda 在 Python 中

我们已经在前言中安装了 Anaconda。当涉及到实验人工智能时,Anaconda 将成为我们的首选工具。

这个列表远远不完整,因为 Anaconda 中可用的库超过 700 个。然而,如果你了解这些库,那么你就已经迈出了良好的第一步,因为你可以用 Python 实现基本的 AI 算法。

Anaconda 提供了一站式的包、IDE、数据可视化库和高性能并行计算工具。Anaconda 隐藏了数据科学、机器学习和人工智能配置问题以及维护堆栈的复杂性。这一特性在 Windows 上尤其有用,因为在那里版本不匹配和配置问题最容易出现。

Anaconda 附带 IPython 控制台,你可以在其中以文档风格编写代码和注释。当你实验 AI 功能时,你的思维流程类似于一个交互式教程,你可以运行代码的每个步骤。

注意

IDE 代表集成开发环境。虽然文本编辑器提供了一些用于突出显示和格式化代码的功能,但 IDE 的功能超越了文本编辑器,它提供了自动重构、测试、调试、打包、运行和部署代码的工具。

人工智能的 Python 库

这里列出的库列表并不完整,因为在 Anaconda 中还有 700 多个可用。然而,这些特定的库将帮助你有一个良好的开始,因为它们将为你提供一个坚实的基础,以便能够在 Python 中实现基本的 AI 算法:

  • NumPy:NumPy 是 Python 的计算库。由于 Python 没有内置的数组数据结构,我们必须使用库来高效地模拟向量和矩阵。在数据科学中,我们需要这些数据结构来执行简单的数学运算。我们将在未来的模块中广泛使用 NumPy。

  • SciPy:SciPy 是一个包含用于数据科学算法的高级库。它是 NumPy 的一个很好的补充库,因为它为你提供了所有需要的先进算法,无论是线性代数算法、图像处理工具还是矩阵运算。

  • pandas:pandas 提供了快速、灵活和表达性强的数据结构,如一维序列和二维 DataFrame。它有效地加载、格式化和处理不同类型的复杂表格。

  • scikit-learn:scikit-learn 是 Python 的主要机器学习库。它基于 NumPy 和 SciPy 库。scikit-learn 为你提供了执行分类和回归、数据预处理以及监督和非监督学习所需的功能。

  • NLTK:在这本书中,我们不会处理自然语言处理,但 NLTK 仍然值得提及,因为这个库是 Python 的主要自然语言工具包。你可以使用这个库进行分类、分词、词干提取、标注、解析、语义推理以及许多其他服务。

  • TensorFlow:TensorFlow 是 Google 的神经网络库,非常适合实现深度学习人工智能。TensorFlow 的灵活核心可以用来解决大量的数值计算问题。TensorFlow 的一些实际应用包括 Google 语音识别和物体识别。

NumPy 库简介

NumPy 库在这本书中将扮演重要角色,因此值得进一步探索。

在启动你的 IPython 控制台后,你可以简单地按照以下方式导入 NumPy:

import numpy as np

导入 NumPy 后,你可以使用其别名np来访问它。NumPy 包含一些数据结构的有效实现,如向量和矩阵。Python 没有内置的数组结构,因此 NumPy 的数组非常有用。让我们看看我们如何定义向量和矩阵:

np.array([1,3,5,7])

输出如下:

array([1, 3, 5, 7])

我们可以使用以下语法声明一个矩阵:

A = np.mat([[1,2],[3,3]])
A

输出如下:

matrix([[1, 2],
        [3, 3]])

数组方法创建一个数组数据结构,而 mat 创建一个矩阵。

我们可以对矩阵执行许多操作。这些包括加法、减法和乘法:

矩阵中的加法:

A + A

输出如下:

matrix([[2, 4],
        [6, 6]])

矩阵中的减法:

A - A

输出如下:

matrix([[0, 0],
        [0, 0]])

矩阵中的乘法:

A * A

输出如下:

matrix([[ 7, 8],
        [12, 15]])

矩阵的加法和减法是按单元格进行的。

矩阵乘法遵循线性代数规则。要手动计算矩阵乘法,你必须将两个矩阵对齐,如下所示:

图 1.1:两个矩阵的乘法计算

图 1.1:两个矩阵的乘法计算

要得到矩阵的 (i,j) 元素,你需要在矩阵的第 i 行和第 j 列上计算点(标量)积。两个向量的标量积是它们对应坐标乘积的和。

另一个常见的矩阵操作是矩阵的行列式。行列式是与方阵相关联的数。使用 NumPy 计算行列式很容易:

np.linalg.det( A )

输出为 -3.0000000000000004

技术上,行列式可以计算为 1*3 – 2*3 = -3。注意,NumPy 使用浮点运算来计算行列式,因此结果并不完美。误差是由于大多数编程语言中浮点数的表示方式造成的。

我们还可以像这样转置一个矩阵:

np.matrix.transpose(A)

输出如下:

matrix([[1, 3],
        [2, 3]])

当计算矩阵的转置时,我们将其值翻转至主对角线。

NumPy 有许多其他重要特性,因此我们将在这本书的大部分章节中使用它。

练习 1:使用 NumPy 进行矩阵运算

我们将使用 IPython 和以下矩阵来解决这个问题。我们将首先理解 NumPy 语法:

图 1.2:简单矩阵

图 1.2:简单矩阵

使用 NumPy,计算以下内容:

  • 矩阵的平方

  • 矩阵的行列式

  • 矩阵的转置

让我们从 NumPy 矩阵运算开始:

  1. 导入 NumPy 库。

    import numpy as np
    
  2. 创建一个存储矩阵的二维数组:

    A = np.mat([[1,2,3],[4,5,6],[7,8,9]])
    

    注意到 np.mat 构造。如果你创建的是 np.array 而不是 np.mat,则数组乘法的解决方案将是错误的。

  3. NumPy 支持使用星号进行矩阵乘法:

    A * A
    

    输出如下:

    matrix([[ 30, 36, 42],
             [ 66, 81, 96],
             [102, 126, 150]])
    

    如以下代码所示,矩阵 A 的平方是通过执行矩阵乘法来计算的。例如,矩阵的左上角元素是按照以下方式计算的:

    1 * 1 + 2 * 4 + 3 * 7
    

    输出为 30

  4. 使用 np.linalg.det 来计算矩阵的行列式:

    np.linalg.det( A )
    

    输出为 is -9.51619735392994e-16

    根据前面的计算,行列式几乎为零。这种低效是由于浮点运算造成的。实际的行列式为零。

    你可以通过手动计算行列式来得出这个结论:

    1*5*9 + 2*6*7 + 3*4*8 - 1*6*8 - 2*4*9 - 3*5*7
    

    输出为 0

    无论何时你使用 NumPy,都要考虑到浮点数算术舍入误差的可能性,即使你看起来是在处理整数。

  5. 使用 np.matrix.transpose 获取矩阵的转置:

    np.matrix.transpose(A)
    

    输出如下:

    matrix([[1, 4, 7],
             [2, 5, 8],
             [3, 6, 9]])
    

    如果 T 是矩阵 A 的转置,那么 T[j][i] 等于 A[i][j]

    NumPy 为向量、矩阵和其他数学结构提供了许多有用的功能。

游戏人工智能的 Python

AI 游戏玩家不过是一个具有明确目标的智能体:赢得游戏并击败所有其他玩家。在游戏方面,人工智能实验取得了令人惊讶的结果。今天,没有人能在国际象棋游戏中击败人工智能。

围棋是最后一种人类围棋大师能够持续击败计算机玩家的游戏。然而,在 2017 年,谷歌的游戏人工智能击败了围棋大师。

游戏中的智能体

一个智能体会按照游戏的规则来行动。智能体可以通过其传感器感知游戏的当前状态,并评估潜在步骤的效用。一旦智能体找到最佳可能的步骤,它就会使用其执行器执行动作。智能体根据它拥有的信息找到最佳可能的动作以达到目标。动作要么受到奖励,要么受到惩罚。胡萝卜加大棒是奖励和惩罚的绝佳例子。想象一下,在你的车前有一只驴。你把胡萝卜放在驴的眼睛前面,所以这只可怜的动物开始朝它走去。一旦驴停下来,骑手可能会用棍子进行惩罚。这不是人类移动的方式,但奖励和惩罚在一定程度上控制着生物体。在学校、工作和日常生活中,人类也会发生同样的事情。我们用收入和法律惩罚来塑造我们的行为,而不是胡萝卜和大棒。

在大多数游戏和游戏化应用中,一系列良好的动作会导致奖励。当人类玩家感到被奖励时,会释放一种叫做多巴胺的激素。多巴胺也被称为奖励的化学物质。当人类达到目标或完成任务时,多巴胺会被释放。这种激素让你感到快乐。人类倾向于以最大化快乐的方式行动。这一系列的动作被称为强制循环。另一方面,智能体只对其目标感兴趣,即最大化奖励和最小化惩罚。

在模拟游戏时,我们必须确定它们的状态空间。一个动作会导致状态转换。当我们探索所有可能动作的后果时,我们得到一个决策树。当我们开始探索所有玩家可能的未来动作,直到游戏结束,这棵树会越来越深。

人工智能的优势在于每秒执行数百万个可能的步骤。因此,游戏人工智能通常归结为一个搜索练习。在探索游戏中所有可能的移动序列时,我们得到游戏的状态树

考虑一个国际象棋人工智能。通过构建包含所有可能移动序列的状态树来评估所有可能的移动有什么问题?

从复杂性理论的角度来看,国际象棋是一个 EXPTIME 游戏。可能的移动数量呈组合爆炸。

白方开始时有 20 种可能的移动:8 个兵可以移动一步或两步,两个骑士可以向上向上左移或向上向上右移。然后,黑方可以做出这二十种移动中的任何一种。在每名玩家一步的情况下,已经有 20*20=400 种可能的组合。

第二步之后,我们得到 8,902 种可能的棋盘布局,这个数字还在不断增长。只需七步,你就必须搜索 10,921,506 种可能的布局。

国际象棋比赛的平均长度大约是 40 步。有些特殊情况下的比赛需要超过 200 步才能结束。

因此,计算机玩家根本没有时间探索整个状态空间。因此,搜索活动必须通过适当的奖励、惩罚和规则的简化来引导。

广度优先搜索和深度优先搜索

创建游戏人工智能通常是一个搜索练习。因此,我们需要熟悉两种主要的搜索技术:广度优先搜索(BFS)和深度优先搜索(DFS)。

这些搜索技术应用于有向根树。树是一种数据结构,它具有节点,以及连接这些节点的边,使得树中的任何两个节点都恰好通过一条路径连接:

图 1.3:一个有向根树

图 1.3:一个有向根树

当树有根时,树中有一个特殊的节点称为根节点,我们从这个节点开始遍历。有向树是一种树,其中边只能沿一个方向遍历。节点可以是内部节点或叶子。内部节点至少有一条边可以通过它离开节点。一个叶子没有从节点指向的边。

在人工智能搜索中,树的根是起始状态。我们通过生成搜索树的后续节点从这个状态进行遍历。搜索技术不同之处在于我们访问这些后续节点的顺序。

假设我们有一个由其根和从根生成所有后续节点的函数定义的树。在这个例子中,每个节点都有一个值和深度。我们从 1 开始,可以增加 1 或 2。我们的目标是达到值 5。

root = {'value': 1, 'depth': 1}
def succ(node):
    if node['value'] == 5:
return []
elif node['value'] == 4:
return [{'value': 5,'depth': node['depth']+1}]
else:
return [
{'value': node['value']+1, 'depth':node['depth']+1},
{'value': node['value']+2, 'depth':node['depth']+1}
]

我们首先将在这个例子上执行 DFS:

def bfs_tree(node):
nodes_to_visit = [node]
visited_nodes = []
while len(nodes_to_visit) > 0:
current_node = nodes_to_visit.pop(0)
visited_bodes.append(current_node)
nodes_to_visit.extend(succ(current_node))
return visited_nodes
bfs_tree(root)

输出如下:

[{'depth': 1, 'value': 1},
{'depth': 2, 'value': 2},
{'depth': 2, 'value': 3},
{'depth': 3, 'value': 3},
{'depth': 3, 'value': 4},
{'depth': 3, 'value': 4},
{'depth': 3, 'value': 5},
{'depth': 4, 'value': 4},
{'depth': 4, 'value': 5},
{'depth': 4, 'value': 5},
{'depth': 4, 'value': 5},
{'depth': 5, 'value': 5}]

注意,广度优先搜索首先找到到达叶子的最短路径,因为它按递增深度的顺序枚举所有节点。

如果我们必须遍历一个图而不是一个有向根树,广度优先搜索将有所不同:每次访问一个节点时,我们都需要检查该节点是否之前已被访问过。如果节点之前已被访问过,我们就会简单地忽略它。

在本章中,我们只在树上使用广度优先遍历。深度优先搜索与广度优先搜索惊人地相似。深度优先遍历与 BFS 之间的区别在于访问节点的顺序。虽然 BFS 在访问任何其他节点之前先访问一个节点的所有子节点,但 DFS 首先深入树中:

def dfs_tree(node):
nodes_to_visit = [node]
visited_nodes = []
while len(nodes_to_visit) > 0:
current_node = nodes_to_visit.pop()
visited_nodes.append(current_node)
nodes_to_visit.extend(succ(current_node))
return visited_nodes
dfs_tree(root)

输出如下:

[{'depth': 1, 'value': 1},
{'depth': 2, 'value': 3},
{'depth': 3, 'value': 5},
{'depth': 3, 'value': 4},
{'depth': 4, 'value': 5},
{'depth': 2, 'value': 2},
{'depth': 3, 'value': 4},
{'depth': 4, 'value': 5},
{'depth': 3, 'value': 3},
{'depth': 4, 'value': 5},
{'depth': 4, 'value': 4},
{'depth': 5, 'value': 5}]

如您所见,深度优先搜索算法快速深入。它不一定首先找到最短路径,但它保证在探索第二条路径之前找到叶子节点。

在游戏 AI 中,广度优先搜索算法通常更适合评估游戏状态,因为深度优先搜索可能会迷失方向。想象一下开始一场棋局,深度优先搜索算法可能会轻易迷失在搜索中。

探索游戏的态空间

让我们探索一个简单游戏的态空间:井字棋。

在井字棋中,给出了一个 3x3 的游戏棋盘。两名玩家玩这个游戏。一名玩家使用 X 标志,另一名玩家使用 O 标志。X 开始游戏,每个玩家在对方之后进行移动。游戏的目标是水平、垂直或对角线地获得三个自己的标志。

让我们按照以下方式表示井字棋盘的单元格:

图 1.4:井字棋盘

图 1.4:井字棋盘

在以下示例中,X 从位置 1 开始,O 在位置 5 进行反击,X 在位置 9 进行移动,然后 O 移动到位置 3:

图 1.5:带有十字和圆圈的井字棋盘

图 1.5:带有十字和圆圈的井字棋盘

这是第二玩家的一个错误,因为现在 X 被迫在单元格 7 上放置一个标志,从而为游戏赢得胜利创造了两个未来的场景。无论 O 是否通过移动到单元格 4 或 8 进行防守,X 都会通过选择另一个未占用的单元格赢得游戏。

注意

您可以在www.half-real.net/tictactoe/上尝试这款游戏。

为了简单起见,我们只探索当 AI 玩家开始时的态空间。我们将从一个随机放置标志在空单元格上的 AI 玩家开始。在与这个 AI 玩家玩游戏后,我们将创建一个完整的决策树。一旦我们生成所有可能的游戏状态,您将体验到它们的组合爆炸。由于我们的目标是使这些复杂性简单化,我们将使用几种不同的技术来使 AI 玩家更聪明,并减少决策树的大小。在这个实验结束时,我们将有一个少于 200 种不同游戏结局的决策树,并且作为额外奖励,AI 玩家将不会输掉任何一场游戏。

要进行随机移动,你需要知道如何使用 Python 从列表中选择一个随机元素。我们将使用random库中的choice函数:

from random import choice
choice([2, 4, 6, 8])

输出是6

choice函数的输出是列表中的一个随机元素。

注意

在接下来的练习中,我们将使用阶乘符号。阶乘用感叹号"!"表示。根据定义,0! = 1,n! = n(n-1)!。在我们的例子中,9! = 98! = 987! = … = 987654321。

练习 2:估计 Tic-Tac-Toe 游戏中可能的状态数量

对 Tic-Tac-Toe 游戏状态空间中的每个级别进行可能的州的数量的大致估计:

  • 在我们的估计中,我们不会停止,直到棋盘上的所有格子都被填满。玩家可能在游戏结束前获胜,但为了统一,我们将继续游戏。

  • 第一位玩家将选择九个格子中的一个。第二位玩家将选择剩余八个格子中的一个。然后第一位玩家可以选择剩余七个格子中的一个。这个过程一直持续到某位玩家赢得游戏,或者第一位玩家被迫做出第九步也是最后一步。

  • 可能的决策序列数量因此是 9! = 362880。其中一些序列是无效的,因为玩家可能在不到九步内赢得游戏。赢得游戏至少需要五步,因为第一位玩家需要移动三次。

  • 为了计算状态空间的精确大小,我们需要计算在五步、六步、七步和八步内获胜的游戏数量。这个计算很简单,但由于其穷尽性质,它超出了我们的范围。因此,我们将满足于状态空间的大小。

    注意

    在生成所有可能的 Tic-Tac-Toe 游戏后,研究人员统计了 255,168 种可能的游戏。在这些游戏中,第一位玩家赢得了 131,184 场,第二位玩家赢得了 77,904 场,46,080 场游戏以平局结束。访问www.half-real.net/tictactoe/allgamesoftictactoe.zip下载所有可能的 Tic-Tac-Toe 游戏。

即使像 Tic-Tac-Toe 这样的简单游戏也有很多状态。想象一下探索所有可能的国际象棋游戏会有多难。因此,我们可以得出结论,穷举搜索很少是理想的。

练习 3:创建一个随机 AI

在本节中,我们将为 Tic-Tac-Toe 游戏创建一个实验框架。我们将基于 AI 玩家总是先开始游戏的假设来模拟游戏。创建一个打印你内部表示的函数,并允许你的对手随机输入一步。确定是否有玩家获胜。为了确保这一点,你需要完成之前的练习:

  1. 我们将导入random库中的choice函数:

    from random import choice
    
  2. 为了简单起见,我们将九个单元格建模为一个简单的字符串。一个九字符长的 Python 字符串按照以下顺序存储这些单元格:"123456789 "。让我们确定必须包含匹配符号的索引三元组,以便玩家赢得游戏:

    combo_indices = [
        [0, 1, 2],
        [3, 4, 5],
        [6, 7, 8],
        [0, 3, 6],
        [1, 4, 7],
        [2, 5, 8],
        [0, 4, 8],
        [2, 4, 6]
    ]
    
  3. 让我们定义空单元格、AI 玩家和对手玩家的符号常量:

    EMPTY_SIGN = '.'
    AI_SIGN = 'X'
    OPPONENT_SIGN = 'O'
    
  4. 让我们创建一个打印棋盘的函数。我们将在棋盘前后添加一个空行,以便我们可以轻松地读取游戏状态:

    def print_board(board):
        print(" ")
        print(' '.join(board[:3]))
        print(' '.join(board[3:6]))
        print(' '.join(board[6:]))
        print(" ")
    
  5. 我们将描述人类玩家的一个移动。输入参数是棋盘、行号从 1 到 3,以及列号从 1 到 3。此函数的返回值是包含新移动的棋盘:

    def opponent_move(board, row, column):
        index = 3 * (row - 1) + (column - 1)
        if board[index] == EMPTY_SIGN:
            return board[:index] + OPPONENT_SIGN + board[index+1:]
        return board
    
  6. 是时候定义 AI 玩家的随机移动了。我们将使用all_moves_from_board函数生成所有可能的移动,然后从可能的移动列表中选择一个随机移动:

    def all_moves_from_board_list(board, sign):
        move_list = []
        for i, v in enumerate(board):
            if v == EMPTY_SIGN:
                move_list.append(board[:i] + sign + board[i+1:])
        return move_list
    def ai_move(board):
        return choice(all_moves_from_board(board, AI_SIGN))
    
  7. 在定义移动之后,我们必须确定是否有玩家赢得了游戏:

    def game_won_by(board):
        for index in combo_indices:
            if board[index[0]] == board[index[1]] == board[index[2]] != EMPTY_SIGN:
                return board[index[0]]
        return EMPTY_SIGN
    
  8. 最后,但同样重要的是,我们将创建一个游戏循环,以便我们可以测试计算机玩家和人类玩家之间的交互。尽管我们将在以下示例中进行穷举搜索:

    def game_loop():
        board = EMPTY_SIGN * 9
        empty_cell_count = 9
        is_game_ended = False
        while empty_cell_count > 0 and not is_game_ended:
            if empty_cell_count % 2 == 1:
                board = ai_move(board)
            else:
                row = int(input('Enter row: '))
                col = int(input('Enter column: '))
                board = opponent_move(board,	 row, col)
            print_board(board)
            is_game_ended = game_won_by(board) != EMPTY_SIGN
        empty_cell_count = sum(
                1 for cell in board if cell == EMPTY_SIGN
            )
        print('Game has been ended.')
    
  9. 使用game_loop函数运行游戏:

    game_loop()
    

如您所见,即使是一个随机出牌的对手,如果他们的对手犯错误,也可能时不时地获胜。

活动一:生成井字棋游戏中所有可能的步骤序列

此活动将探索当两个玩家随机出牌时可能出现的组合爆炸。我们将使用一个程序,基于之前的结果,生成计算机玩家和人类玩家之间所有可能的移动序列。假设人类玩家可以做出任何可能的移动。在这个例子中,由于计算机玩家是随机出牌,我们将检查两个随机出牌玩家的胜负和和局情况:

  1. 创建一个函数,将all_moves_from_board函数映射到棋盘空间/方格列表的每个元素上。这样,我们将拥有决策树的所有节点。

  2. 决策树从[ EMPTY_SIGN * 9 ]开始,并在每次移动后扩展。让我们创建一个filter_wins函数,该函数从移动列表中取出已完成的游戏,并将它们追加到一个包含 AI 玩家和对手玩家赢得的棋盘状态的数组中:

  3. 然后,使用一个count_possibilities函数,该函数打印出以平局结束的决策树叶子节点数、被第一玩家赢得的节点数和被第二玩家赢得的节点数。

  4. 在每个状态下,我们最多有 9 步。在 0th、2nd、4th、6th 和 8th 次迭代中,AI 玩家移动。在所有其他迭代中,对手移动。我们在所有步骤中创建所有可能的移动,并从移动列表中取出已完成的游戏。

  5. 然后,执行所有可能性的数量,以体验组合爆炸。

如您所见,棋盘状态树包含 266,073 个叶子节点。count_possibilities函数本质上实现了一个广度优先搜索(BFS)算法来遍历游戏的所有可能状态。请注意,我们多次计算这些状态,因为在第一步在右上角放置 X 和在第三步在左上角放置 X 会导致与从左上角开始然后放置 X 在右上角相似的可能状态。如果我们实现了重复状态的检测,我们就需要检查更少的节点。然而,在这个阶段,由于游戏的深度有限,我们将省略这一步。

决策树count_possibilities考察的数据结构非常相似。在决策树中,我们通过调查所有可能未来步骤的效用来探索每一步的效用。在我们的例子中,我们可以通过观察固定前几步后的胜负次数来计算第一步的效用。

注意

树的根是初始状态。树的内状态是游戏尚未结束且可以进行移动的状态。树的叶子包含游戏结束的状态。

这个活动的解决方案可以在第 258 页找到。

摘要

在本章中,我们学习了什么是人工智能,以及它的多个学科。

我们已经看到了人工智能如何被用来增强或替代人类大脑能力,用于倾听、说话、理解语言、存储和检索信息、思考、看和移动。然后,我们继续学习关于智能代理在环境中行动的知识,它们以看似智能的方式解决问题,以追求先前确定的目标。当代理学习时,它们可以通过监督或非监督的方式进行学习。我们可以使用智能代理来分类事物或对未来进行预测。

然后,我们介绍了 Python,并学习了它在人工智能领域的角色。我们查看了一些用于开发智能代理和为代理准备数据的 Python 重要库。作为热身,我们以一个例子结束了本章,在这个例子中,我们使用 NumPy 库在 Python 中执行了一些矩阵运算。我们还学习了如何为井字棋游戏创建搜索空间。在下一章中,我们将学习如何借助搜索空间赋予智能。

第二章:基于搜索技术和游戏的 AI

学习目标

到本章结束时,你将能够:

  • 使用 Python 基于静态规则构建简单的游戏 AI

  • 确定启发式在游戏 AI 中的作用

  • 使用搜索技术和路径查找算法

  • 实现使用 Minmax 算法的两人游戏 AI

在本章中,我们将探讨创建智能体。

简介

在上一章中,我们了解了智能体的意义。我们还检查了游戏 AI 的游戏状态。在本章中,我们将专注于如何创建并引入智能体中的智能。

我们将研究减少状态空间中的状态数量,分析游戏棋盘可能经历的阶段,并使环境以我们获胜的方式工作。到本章结束时,我们将拥有一个永远不会输掉比赛的井字棋玩家。

练习 4:教会智能体获胜

在这个练习中,我们将看到如何减少赢得所需的步骤。我们将使我们在上一章中开发的智能体检测它能够赢得游戏的情况。以随机游戏为例,比较可能的状态数量。

  1. 我们将定义两个函数,ai_moveall_moves_from_board。我们将创建 ai_move,使其返回一个考虑其先前移动的移动。如果在该移动中可以赢得游戏,ai_move 将选择该移动。

    def ai_move(board):
        new_boards = all_moves_from_board(board, AI_SIGN)
        for new_board in new_boards:
        if game_won_by(new_board) == AI_SIGN:
        return new_board
        return choice(new_boards)
    
  2. 让我们通过游戏循环测试应用程序。每当 AI 有机会赢得游戏时,它总是会将 X 放在正确的单元格中:

    game_loop()
    
  3. 输出如下:

    . X .
    . . .
    . . .
    Enter row: 3
    Enter column: 1
    . X .
    . . .
    O . .
    
    . X X
    . . .
    O . .
    
    Enter row: 2
    Enter column: 1
    
    . X X
    O . .
    O . .
    
    X X X
    O . .
    O . .
    Game has been ended.
    
  4. 为了计算所有可能的移动,我们必须将 all_moves_from_board 函数更改为包括这个改进。我们必须这样做,以便如果游戏被 AI_SIGN 赢得,它将返回该值:

    def all_moves_from_board(board, sign):
        move_list = []
        for i, v in enumerate(board):
            if v == EMPTY_SIGN:
                new_board = board[:i] + sign + board[i+1:]
                move_list.append(new_board)
                if game_won_by(new_board) == AI_SIGN:
                    return [new_board]
        return move_list
    
  5. 然后,我们生成所有可能的移动。一旦我们找到一个为 AI 赢得游戏的移动,我们就返回它。我们不在乎 AI 是否在一次移动中有多个赢得游戏的机会 - 我们只返回第一个可能性。如果 AI 无法赢得游戏,我们返回所有可能的移动。

  6. 让我们看看这在对每一步计算所有可能性方面意味着什么:

    count_possibilities()
    
  7. 输出如下:

    step 0\. Moves: 1
    step 1\. Moves: 9
    step 2\. Moves: 72
    step 3\. Moves: 504
    step 4\. Moves: 3024
    step 5\. Moves: 8525
    step 6\. Moves: 28612
    step 7\. Moves: 42187
    step 8\. Moves: 55888
    First player wins: 32395
    Second player wins: 23445
    Draw 35544
    Total 91344
    

活动二:教会智能体意识到防御损失的情况

在本节中,我们将讨论如何让计算机玩家玩得更好,以便我们可以减少状态空间和损失的数量。我们将迫使计算机防御玩家连续放置第三个标记的情况,即行、列或对角线:

  1. 创建一个名为 player_can_win 的函数,该函数使用 all_moves_from_board 函数从棋盘获取所有移动,并使用一个名为 next_move 的变量遍历它。在每次迭代中,它检查游戏是否可以通过标记赢得,然后返回 true 或 false。

  2. 扩展 AI 的移动,使其更倾向于做出安全的移动。一个移动是安全的,如果对手在下一步无法赢得游戏。

  3. 测试新应用程序。你会发现 AI 已经做出了正确的移动。

  4. 将这个逻辑放入状态空间生成器中,并通过生成所有可能的比赛来检查计算机玩家的表现。

我们不仅又去掉了几乎所有三分之二的可能比赛,而且大多数时候,AI 玩家要么获胜,要么接受平局。尽管我们努力使 AI 变得更好,但它仍然有 962 种失败的方式。我们将在下一个活动中消除所有这些损失。

注意

本活动的解决方案可以在第 261 页找到。

活动三:修复 AI 的第一和第二次移动以使其无敌

本节将讨论如何将穷举搜索集中在更有用的移动上。我们将通过硬编码第一和第二次移动来减少可能的比赛:

  1. 计算棋盘上空格的数量,并在有 9 个或 7 个空格的情况下进行硬编码移动。你可以尝试不同的硬编码移动。

  2. 占据任何角落,然后占据对角角落,都不会导致损失。如果对手占据了对角角落,在中部移动会导致没有损失。

  3. 修复前两个步骤后,我们只需要处理 8 种可能性,而不是 504 种。我们还引导 AI 进入一个状态,其中硬编码的规则足以永远不会输掉比赛。

    注意

    本活动的解决方案可以在第 263 页找到。

让我们总结一下我们应用以减少状态空间的重要技术:

  1. 经验简化:我们接受最佳第一次移动是角落移动。我们简单地硬编码了一个移动,而不是考虑替代方案,以关注游戏的其它方面。在更复杂的游戏中,经验移动往往是误导性的。最著名的国际象棋 AI 胜利往往包含违反象棋大师的常识。

  2. 对称性:在我们开始使用角落移动后,我们注意到位置 1、3、7 和 9 在赢得游戏的角度上是等效的。即使我们没有进一步发展这个想法,请注意,我们甚至可以旋转桌子以进一步减少状态空间,并将所有四个角落移动视为完全相同的移动。

  3. 减少导致相同状态的不同的排列:假设我们可以移动 A 或 B,假设我们的对手移动 X,其中 X 不等于移动 A 或 B。如果我们探索序列 A、X、B,然后我们开始探索序列 B、X,那么我们不需要考虑序列 B、X、A。这是因为这两个序列导致完全相同的状态,我们之前已经探索了一个包含这三个移动的状态。

  4. 对玩家的强制移动:当一个玩家在水平、垂直或对角线上收集到两个标记,并且该行的第三个单元格为空时,我们被迫占据那个空单元格,要么是为了赢得游戏,要么是为了防止对手赢得游戏。强制移动可能意味着其他强制移动,这进一步减少了状态空间。

  5. 对对手的强制移动:当对手的移动明显是最佳选择时,考虑对手不做出最佳移动的情况是没有意义的。当对手可以通过占据一个单元格来赢得游戏时,我们是否对对手错过最佳移动的情况进行长期探索并不重要。我们不探索对手未能阻止我们赢得游戏的情况可以节省很多。这是因为在对手犯错误后,我们简单地赢得游戏。

  6. 随机移动:当我们无法决定且没有搜索能力时,我们会随机移动。随机移动几乎总是不如基于搜索的有根据的猜测,但有时我们别无选择。

启发式方法

在这个主题中,我们将通过定义和应用启发式方法来形式化有信息搜索技术。

无信息和有信息搜索

在井字棋的例子中,我们实现了一个贪婪算法,首先关注获胜,然后关注不输。当涉及到立即赢得游戏时,贪婪算法是最佳的,因为没有比赢得游戏更好的步骤。当涉及到不输时,我们如何避免损失很重要。我们的算法只是简单地选择一个安全的随机移动,而不考虑我们创造了多少获胜机会。

广度优先搜索和深度优先搜索是无信息的,因为它们考虑了游戏中所有可能的状态。有信息搜索智能地探索可用状态的空间。

创建启发式方法

如果我们想要做出更好的决策,我们会应用启发式方法,通过考虑长期效用来引导搜索的正确方向。这样,我们可以根据未来可能发生的情况,在现在做出更明智的决策。这也可以帮助我们更快地解决问题。我们可以如下构建启发式方法:

  • 对游戏中移动的效用进行有根据的猜测

  • 从玩家视角对给定游戏状态的效用进行有根据的猜测

  • 对我们目标距离的有根据的猜测

启发式方法是评估游戏状态或过渡到新游戏状态的函数,基于它们的效用。启发式方法是使搜索问题有信息的基础。

在这本书中,我们将使用效用和成本作为否定术语。最大化效用和最小化移动成本被认为是同义的。

一个用于启发式评估函数的常用例子出现在路径查找问题中。假设我们正在寻找一条路径,这条路径在状态树中通向目标状态。每一步都有一个相关的成本,象征着旅行距离。我们的目标是使达到目标状态的成本最小化。

以下是一个用于解决路径查找问题的启发式示例:取当前状态和目标状态的坐标。无论连接这些点的路径如何,计算这些点之间的距离。平面内两点之间的距离是连接这些点的直线长度。这种启发式被称为欧几里得距离。

假设我们在迷宫中定义一个路径查找问题,我们只能向上、向下、向左或向右移动。迷宫中有几个障碍物阻挡了我们的移动。我们可以用来评估我们离目标状态有多近的启发式称为曼哈顿距离,它被定义为当前状态和最终状态对应坐标的水平距离和垂直距离之和。

可接受和非可接受启发式

我们在路径查找问题中定义的两个启发式,当在给定的问题域中使用时,被称为可接受启发式。可接受意味着我们可能会低估达到最终状态的成本,但我们永远不会高估它。在下一个主题中,我们将探讨一个寻找当前状态和目标状态之间最短路径的算法。这个算法的最优性取决于我们是否可以定义一个可接受启发式函数。

一个非可接受启发式的例子是将曼哈顿距离应用于二维地图。想象一下,从我们的当前状态到目标状态有一条直接路径。当前状态位于坐标(2,5),目标状态位于坐标(5,1)。

两个节点之间的曼哈顿距离如下:

abs(5-2) + abs(1-5) = 3 + 4 = 7

由于我们高估了从当前节点到目标状态的旅行成本,当我们可以斜向移动时,曼哈顿距离不是可接受的。

启发式评估

从起始玩家的角度创建一个井字棋游戏状态的启发式评估。

我们可以定义游戏状态或移动的效用。两者都适用,因为游戏状态的效用可以定义为导致该状态的移动的效用。

启发式 1:简单评估终局

让我们通过评估棋盘来定义一个简单的启发式:我们可以定义游戏状态或移动的效用。两者都适用,因为游戏状态的效用可以定义为导致该状态的移动的效用。游戏的效用可以是:

  • 如果状态意味着 AI 玩家将赢得游戏,则返回+1

  • 如果状态意味着 AI 玩家将输掉游戏,则返回-1

  • 如果已经达到平局或无法从当前状态中识别出明确的胜者,则返回 0

这个启发式方法很简单,因为任何人都可以查看棋盘并分析玩家是否即将获胜。

这个启发式方法的效用取决于我们能否提前玩很多步。请注意,我们甚至无法在五步内赢得游戏。我们在主题 A 中看到,当我们达到第 5 步时,有 13,680 种可能的组合。在这些 13,680 种情况中的大多数,我们的启发式方法返回零。

如果我们的算法没有超过这五步,我们对如何开始游戏完全一无所知。因此,我们可以发明一个更好的启发式方法。

启发式 2:移动的效用

  • 在一行、一列或对角线上有两个 AI 符号,第三个单元格为空:空单元格+1000 分。

  • 对手在一行、一列或对角线上有两个连续的符号,第三个单元格为空:空单元格+100 分。

  • 一个 AI 符号在一行、一列或对角线上,其他两个单元格为空:空单元格+10 分。

  • 没有 AI 或对手的符号在一行、一列或对角线上:空单元格+1 分。

  • 被占据的单元格得到负无穷大的值。在实践中,由于规则的性质,-1 也足够了。

为什么我们使用 10 的乘数因子来计算这四条规则?因为在一行、一列和对角线上,有八种可能的方式组成三个连续的符号。所以,即使我们对这个游戏一无所知,我们也可以确定低级规则可能不会累积到覆盖高级规则。换句话说,如果我们能赢得游戏,我们永远不会防御对手的移动。

备注

由于对手的任务也是获胜,我们可以从对手的角度计算这个启发式方法。我们的任务是最大化这个值,以便我们可以防御对手的最佳策略。这也是 Minmax 算法背后的思想。如果我们想将这个启发式方法转换为描述当前棋盘的启发式方法,我们可以计算所有开放单元格的启发式值,并取 AI 字符值的最大值,以便我们可以最大化我们的效用。

对于每个棋盘,我们将创建一个效用矩阵。例如,考虑以下棋盘:

图 2.1 井字棋游戏状态

图 2.1:井字棋游戏状态

从这里,我们可以构建它的效用矩阵:

图 2.2 井字棋游戏效用矩阵

图 2.2:井字棋游戏效用矩阵

在第二行,如果我们选择左边的单元格,它并不是很有用。请注意,如果我们有一个更优化的效用函数,我们会奖励阻止对手。

第三列的两个单元格都因为有两个连续的符号而得到 10 分的提升。

右上角的单元格也因为防御对手的对角线而得到 100 分。

从这个矩阵中,很明显我们应该选择右上角的移动。

我们可以使用这个启发式方法来指导我们走向最佳下一步,或者通过取这些值的最大值来对当前棋盘给出一个更明智的评分。我们在主题 A 中实际上已经以硬编码规则的形式使用了这个启发式方法的一部分。然而,需要注意的是,启发式方法的真正效用不是对棋盘的静态评估,而是它提供的在限制搜索空间方面的指导。

练习 5:使用启发式函数的井字棋静态评估

使用启发式函数对井字棋游戏进行静态评估。

  1. 在本节中,我们将创建一个函数,该函数接受可能的移动的效用向量,接受效用向量内部的三个索引表示一个三元组,并返回一个函数。返回的函数期望一个点数参数,并修改效用向量,使得在(i, j, k)三元组中的每个单元格增加点数,只要该单元格的原始值是非负的。换句话说,我们只增加空单元格的效用。

    def init_utility_matrix(board):
        return [0 if cell == EMPTY_SIGN else -1 for cell in board]
    def generate_add_score(utilities, i, j, k):
        def add_score(points):
            if utilities[i] >= 0:
                utilities[i] += points
            if utilities[j] >= 0:
                utilities[j] += points
            if utilities[k] >= 0:
                utilities[k] += points
        return add_score
    
  2. 现在我们已经拥有了创建任何棋盘配置的效用矩阵所需的一切:

    def utility_matrix(board):
        utilities = init_utility_matrix(board)
        for [i, j, k] in combo_indices:
            add_score = generate_add_score(utilities, i, j, k)
            triple = [board[i], board[j], board[k]]
            if triple.count(EMPTY_SIGN) == 1:
                if triple.count(AI_SIGN) == 2:
                    add_score(1000)
                elif triple.count(OPPONENT_SIGN) == 2:
                    add_score(100)
            elif triple.count(EMPTY_SIGN) == 2 and triple.count(AI_SIGN) == 1:
                add_score(10)
            elif triple.count(EMPTY_SIGN) == 3:
                add_score(1)
        return utilities
    
  3. 现在,我们将创建一个函数,该函数严格选择具有最高效用值的移动。如果有多个移动具有相同的效用值,该函数将返回这两个移动。

    def best_moves_from_board(board, sign):
        move_list = []
        utilities = utility_matrix(board)
        max_utility = max(utilities)
        for i, v in enumerate(board):
            if utilities[i] == max_utility:
                move_list.append(board[:i] + sign + board[i+1:])
        return move_list
    def all_moves_from_board_list(board_list, sign):
        move_list = []
        get_moves = best_moves_from_board if sign == AI_SIGN else all_moves_from_board
        for board in board_list:
            move_list.extend(get_moves(board, sign))
        return move_list
    
  4. 让我们运行这个应用程序。

    count_possibilities()
    

    输出将如下所示:

    step 0\. Moves: 1
    step 1\. Moves: 1
    step 2\. Moves: 8
    step 3\. Moves: 24
    step 4\. Moves: 144
    step 5\. Moves: 83
    step 6\. Moves: 214
    step 7\. Moves: 148
    step 8\. Moves: 172
    First player wins: 504
    Second player wins: 12
    Draw 91
    Total 607
    

使用启发式方法进行信息搜索

我们还没有真正体验到启发式方法的威力,因为我们是在不知道未来移动效果的情况下进行移动的,因此影响了对手的合理游戏。

这就是为什么一个更精确的启发式方法会导致比简单地硬编码游戏的前两步更多的损失。注意,在前一个主题中,我们是根据基于运行具有固定第一步的游戏所生成的统计数据来选择这两个移动的。这种方法本质上就是启发式搜索应该关注的内容。静态评估无法与生成数以万计的未来状态并选择最大化我们奖励的玩法相竞争。

启发式方法的类型

因此,一个更精确的启发式方法会导致比简单地硬编码游戏的前两步更多的损失。注意,在主题 A 中,我们是根据基于运行具有固定第一步的游戏所生成的统计数据来选择这两个移动的。这种方法本质上就是启发式搜索应该关注的内容。静态评估无法与生成数以万计的未来状态并选择最大化我们奖励的玩法相竞争。

  • 这是因为我们的启发式方法并不精确,而且很可能也不是可接受的。

在前面的练习中,我们看到了启发式方法并不总是最优的:在第一个主题中,我们提出了允许 AI 总是赢得游戏或以平局结束的规则。这些启发式方法使 AI 在大多数情况下都能获胜,但在少数情况下会失败。

  • 如果我们可以低估游戏状态的效用,但永远不会高估它,那么一个启发式方法被认为是可接受的。

在井字棋的例子中,我们可能高估了一些游戏状态的价值。为什么?因为我们最终输了十二次。导致失败的一些游戏状态具有最大的启发式分数。为了证明我们的启发式方法不可接受,我们只需要找到一个可能获胜的游戏状态,这个状态在我们选择导致失败的游戏状态时被忽略了。

描述启发式方法的还有两个特性:最优和完全:

  • 最优启发式总是找到最佳可能的解决方案。

  • 完全启发式有两个定义,这取决于我们如何定义问题域。在广义上,如果启发式方法总是找到解决方案,则称其为完全的。在狭义上,如果启发式方法找到所有可能的解决方案,则称其为完全的。我们的井字棋启发式方法不是完全的,因为我们故意忽略了许多可能的获胜状态,而选择了失败状态。

使用 A*算法进行路径查找

在前两个主题中,我们学习了如何定义智能体,以及如何创建一个引导智能体向目标状态前进的启发式方法。我们了解到这并不完美,因为有时我们为了几个失败状态而忽略了几个获胜状态。

现在,我们将学习一种结构化和最优的方法,以便我们可以执行搜索以找到当前状态和目标状态之间的最短路径:A*算法("A star"而不是"A asterisk"):

图 2.3 最短路径查找游戏棋盘

图 2.3:在迷宫中找到最短路径

对于人类来说,通过仅仅查看图像就能找到最短路径很简单。我们可以得出结论,存在两个潜在的最短路径候选者:一条路径向上开始,另一条路径向左开始。然而,AI 并不知道这些选项。实际上,对于计算机玩家来说,最合逻辑的第一步是移动到以下图中标记为数字 3 的方格:

为什么?因为这是唯一一个减少起始状态和目标状态之间距离的步骤。所有其他步骤最初都远离目标状态:

图 2.4 最短路径查找游戏棋盘带有效用

图 2.4:带有效用的最短路径查找游戏棋盘

练习 6:找到达到目标的最短路径

找到最短路径的步骤如下:

  1. 使用 Python 描述棋盘、初始状态和最终状态。创建一个函数,该函数返回可能的后继状态列表。

  2. 我们将使用元组,其中第一个坐标表示从 1 到 7 的行号,第二个坐标表示从 1 到 9 的列号:

    size = (7, 9)
    start = (5, 3)
    end = (6, 9)
    obstacles = {
        (3, 4), (3, 5), (3, 6), (3, 7), (3, 8),
        (4, 5),
        (5, 5), (5, 7), (5, 9),
        (6, 2), (6, 3), (6, 4), (6, 5), (6, 7),
        (7, 7)
    }
    
  3. 我们将使用数组推导来生成以下方式的后继状态。我们从当前列向左和向右移动一个单位,只要我们保持在棋盘上。我们从当前行向上和向下移动一个单位,只要我们保持在棋盘上。我们取新的坐标,生成所有四个可能的元组,并过滤结果,以确保新的状态不能在障碍物列表中。同时,排除回到我们之前访问过的场地的移动也是有意义的,以避免无限循环:

    def successors(state, visited_nodes):
        (row, col) = state
        (max_row, max_col) = size
        succ_states = []
        if row > 1:
            succ_states += [(row-1, col)]
        if col > 1:
            succ_states += [(row, col-1)]
        if row < max_row:
            succ_states += [(row+1, col)]
        if col < max_col:
            succ_states += [(row, col+1)]
        return [s for s in succ_states if s not in visited_nodes if s not in obstacles]
    

练习 7:使用 BFS 寻找最短路径

要找到最短路径,请按照以下步骤操作:

使用 BFS 算法找到最短路径。

回顾基本的 BFS 实现。

  1. 我们必须修改这个实现以包含成本。让我们测量成本:

    import math
    def initialize_costs(size, start):
        (h, w) = size
        costs = [[math.inf] * w for i in range(h)]
        (x, y) = start
        costs[x-1][y-1] = 0
        return costs
    def update_costs(costs, current_node, successor_nodes):
        new_cost = costs[current_node[0]-1][current_node[1]-1] + 1
        for (x, y) in successor_nodes:
            costs[x-1][y-1] = min(costs[x-1][y-1], new_cost)
    def bfs_tree(node):
        nodes_to_visit = [node]
        visited_nodes = []
        costs = initialize_costs(size, start)
        while len(nodes_to_visit) > 0:
            current_node = nodes_to_visit.pop(0)
            visited_nodes.append(current_node)
            successor_nodes = successors(current_node, visited_nodes)
            update_costs(costs, current_node, successor_nodes)
            nodes_to_visit.extend(successor_nodes)
        return costs
    bfs_tree(start)
    
  2. 输出将如下所示:

    [[6, 5, 4, 5, 6, 7, 8, 9, 10],
    [5, 4, 3, 4, 5, 6, 7, 8, 9],
    [4, 3, 2, inf, inf, inf, inf, inf, 10],
    [3, 2, 1, 2, inf, 12, 13, 12, 11],
    [2, 1, 0, 1, inf, 11, inf, 13, inf],
    [3, inf, inf, inf, inf, 10, inf, 14, 15],
    [4, 5, 6, 7, 8, 9, inf, 15, 16]]
    
  3. 你可以看到,一个简单的 BFS 算法成功地确定了从起始节点到任何节点(包括目标节点)的成本。让我们测量找到目标节点所需的步数:

    def bfs_tree_verbose(node):
        nodes_to_visit = [node]
        visited_nodes = []
        costs = initialize_costs(size, start)
        step_counter = 0
        while len(nodes_to_visit) > 0:
            step_counter += 1
            current_node = nodes_to_visit.pop(0)
            visited_nodes.append(current_node)
    
            successor_nodes = successors(current_node, visited_nodes)
            update_costs(costs, current_node, successor_nodes)
            nodes_to_visit.extend(successor_nodes)
            if current_node == end:
                print(
                    'End node has been reached in ',
                    step_counter, '
                    steps'
                )
                return costs
        return costs
    bfs_tree_verbose(start)
    
  4. 目标节点在 110 步后到达:

    [[6, 5, 4, 5, 6, 7, 8, 9, 10],
    [5, 4, 3, 4, 5, 6, 7, 8, 9],
    [4, 3, 2, inf, inf, inf, inf, inf, 10],
    [3, 2, 1, 2, inf, 12, 13, 12, 11],
    [2, 1, 0, 1, inf, 11, inf, 13, inf],
    [3, inf, inf, inf, inf, 10, inf, 14, 15],
    [4, 5, 6, 7, 8, 9, inf, 15, 16]]
    

我们现在将学习一个可以找到从起始节点到目标节点的最短路径的算法:A* 算法。

介绍 A* 算法

A* 是一种完整且最优的启发式搜索算法,它可以在当前游戏状态和获胜状态之间找到可能的最短路径。在此状态下,完整和最优的定义如下:

  • 完整意味着 A* 总是能找到解决方案。

  • 最优意味着 A* 将找到最佳解决方案。

要设置 A* 算法,我们需要以下内容:

  • 初始状态

  • 描述目标状态

  • 可行的启发式方法来衡量通向目标状态的过程

  • 一种生成通向目标下一步的方法

一旦设置完成,我们将按照以下步骤在初始状态下执行 A* 算法:

  1. 我们生成所有可能的后继步骤。

  2. 我们将按照它们与目标距离的顺序存储这些子节点。

  3. 我们首先选择得分最高的子节点,然后以得分最高的子节点作为初始状态重复这三个步骤。这是从起始节点到达节点的最短路径。

    distance_from_end( node ) 是一个可接受的启发式估计,显示了我们从目标节点有多远。

在路径查找中,一个好的启发式方法是欧几里得距离。如果当前节点是 (x, y) 且目标节点是 (u, v),那么:

distance_from_end( node ) = sqrt( abs( x – u ) ** 2 + abs( y – v ) ** 2 )

其中:

  • sqrt 是平方根函数。别忘了从 math 库中导入它。

  • abs 是绝对值函数。abs( -2 ) = abs( 2 ) = 2

  • x ** 2x 的平方。

我们将使用 distance_from_start 矩阵来存储从起始节点到距离。在算法中,我们将把这个成本矩阵称为 distance_from_start( n1 ) 。对于任何具有坐标 (x1, y1) 的节点 n1 ,这个距离等同于 distance_from_start[x1][y1]

我们将使用 succ( n ) 符号从 n 生成后继节点列表。

让我们看看算法的伪代码:

frontier = [start], internal = {}
# Initialize the costs matrix with each cell set to infinity.
# Set the value of distance_from_start(start) to 0.
while frontier is not empty:
    # notice n has the lowest estimated total
    # distance between start and end.
    n = frontier.pop()
    # We'll learn later how to reconstruct the shortest path
    if n == end:
        return the shortest path.
    internal.add(n)
    for successor s in succ(n):
        if s in internal:
            continue # The node was already examined
        new_distance = distance_from_start(n) + distance(n, s)
        if new_distance >= distance_from_start(s):
            # This path is not better than the path we have
            # already examined.
            continue
        if s is a member of frontier:
            update the priority of s
        else:
            Add s to frontier.

关于检索最短路径,我们可以利用成本矩阵。这个矩阵包含从起始节点到路径上每个节点的距离。由于在向后行走时成本总是减少,我们只需要从终点开始,贪婪地向减少的成本方向行走:

path = [end_node], distance = get_distance_from_start( end_node )
while the distance of the last element in the path is not 0:
    for each neighbor of the last node in path:
        new_distance = get_distance_from_start( neighbor )
        if new_distance < distance:
            add neighbor to path, and break out from the for loop
return path

当我们有一个起始状态和一个目标状态时,A* 算法表现得尤为出色。A* 算法的复杂度是 O( E ),其中 E 代表场中所有可能的边。在我们的例子中,任何节点最多有四个离开的边:上、下、左和右。

注意

为了按正确顺序对前沿列表进行排序,我们必须使用特殊的 Python 数据结构:优先队列。

# Import heapq to access the priority queue
import heapq
# Create a list to store the data
data = []
# Use heapq.heappush to push (priorityInt, value) pairs to the queue
heapq.heappush(data, (2, 'first item'))
heapq.heappush(data, (1, 'second item'))
# The tuples are stored in data in the order of ascending priority
[(1, 'second item'), (2, 'first item')]
# heapq.heappop pops the item with the lowest score from the queue
heapq.heappop(data)

输出结果如下:

(1, 'second item')
# data still contains the second item
data

输出结果如下:

[(2, 'first item')]

为什么算法使用的启发式函数必须是可接受的很重要?

因为这就是我们保证算法最优性的方法。对于任何节点 x,我们测量以下总和:从起始节点到 x 的距离,从 x 到终点节点的估计距离。如果估计永远不会高估 x 到终点节点的距离,我们就永远不会高估总距离。一旦我们到达目标节点,我们的估计为零,从起始点到终点的总距离就变成了一个确切的数字。

我们可以确信我们的解决方案是最优的,因为优先队列中没有其他项目具有更低的估计成本。鉴于我们从未高估我们的成本,我们可以确信算法前沿的所有节点要么具有与找到的路径相似的总成本,要么具有更高的总成本。

在以下游戏场景中实现 A* 算法以找到最低成本的路径:

图 2.5 最短路径查找游戏板

图 2.5:最短路径查找游戏板

我们将重用游戏建模练习中的初始化代码:

import math
import heapq
size = (7, 9)
start = (5, 3)
end = (6, 9)
obstacles = {
    (3, 4), (3, 5), (3, 6), (3, 7), (3, 8),
    (4, 5),
    (5, 5), (5, 7), (5, 9),
    (6, 2), (6, 3), (6, 4), (6, 5), (6, 7),
    (7, 7)
}
# Returns the successor nodes of State, excluding nodes in VisitedNodes
def successors(state, visited_nodes):
    (row, col) = state
    (max_row, max_col) = size
    succ_states = []
    if row > 1:
        succ_states += [(row-1, col)]
    if col > 1:
        succ_states += [(row, col-1)]
    if row < max_row:
        succ_states += [(row+1, col)]
    if col < max_col:
        succ_states += [(row, col+1)]
    return [s for s in succ_states if s not in visited_nodes if s not in obstacles]

我们还编写了初始化成本矩阵的代码:

import math
def initialize_costs(size, start):
    costs = [[math.inf] * 9 for i in range(7)]
    (x, y) = start
    costs[x-1][y-1] = 0
    return costs

我们将省略更新成本的函数,因为我们将在 A* 算法内部执行此操作:

让我们初始化 A* 算法的前沿和内部列表。对于前沿,我们将使用 Python PriorityQueue。请勿直接执行此代码,因为我们将在 A* 搜索函数内部使用这四行:

frontier = []
internal = set()
heapq.heappush(frontier, (0, start))
costs = initialize_costs(size, start)

现在是时候实现一个启发式函数,该函数使用我们在理论部分看到的算法来衡量当前节点和目标节点之间的距离:

def distance_heuristic(node, goal):
    (x, y) = node
    (u, v) = goal
    return math.sqrt(abs(x - u) ** 2 + abs(y - v) ** 2)

最后一步是将 A* 算法转换为可执行的代码:

def astar(start, end):
    frontier = []
    internal = set()
    heapq.heappush(frontier, (0, start))
    costs = initialize_costs(size, start)
    def get_distance_from_start(node):
        return costs[node[0] - 1][node[1] - 1]
    def set_distance_from_start(node, new_distance):
        costs[node[0] - 1][node[1] - 1] = new_distance
    while len(frontier) > 0:
        (priority, node) = heapq.heappop(frontier)
        if node == end:
            return priority
        internal.add(node)
        successor_nodes = successors(node, internal)
        for s in successor_nodes:
            new_distance = get_distance_from_start(node) + 1
            if new_distance < get_distance_from_start(s):
                set_distance_from_start(s, new_distance)
                # Filter previous entries of s
                frontier = [n for n in frontier if s != n[1]]
                heapq.heappush(frontier, (
                    new_distance + distance_heuristic(s, end), s
                )
                )
astar(start, end)
15.0

我们的实施方案与原始算法之间有一些差异:

我们定义了一个 distance_from_start 函数,使其更容易且更具语义地访问 costs 矩阵。请注意,我们从 1 开始编号节点索引,而在矩阵中,索引从 0 开始。因此,我们从节点值中减去 1 以获得索引。

在生成后续节点时,我们自动排除了内部集合中的节点。successors = succ(node, internal) 确保我们只得到那些尚未完成检查的邻居,这意味着它们的分数不一定是最优的。

作为结果,我们可能可以跳过步骤检查,因为内部节点永远不会出现在succ( n )中。

由于我们使用的是优先队列,在插入节点 s 之前,我们必须确定节点 s 的估计优先级。然而,我们只会将节点插入到边界,如果我们知道这个节点没有比它分数更低的条目。

可能会发生节点 s 已经在边界队列中,但分数更高的情况。在这种情况下,我们在将其插入到优先队列的正确位置之前,先删除这个条目。当我们找到终点节点时,我们只需返回最短路径的长度,而不是路径本身。

为了获取更多关于执行的信息,让我们将这些信息打印到控制台。为了跟踪 A*算法的执行过程,执行以下代码并研究日志:

def astar_verbose(start, end):
    frontier = []
    internal = set()
    heapq.heappush(frontier, (0, start))
    costs = initialize_costs(size, start)
    def get_distance_from_start(node):
        return costs[node[0] - 1][node[1] - 1]
    def set_distance_from_start(node, new_distance):
        costs[node[0] - 1][node[1] - 1] = new_distance
    steps = 0
    while len(frontier) > 0:
        steps += 1
        print('step ', steps, '. frontier: ', frontier)
        (priority, node) = heapq.heappop(frontier)
        print(
            'node ',
            node,
            'has been popped from frontier with priority',
            priority
        )
        if node == end:
            print('Optimal path found. Steps: ', steps)
            print('Costs matrix: ', costs)
            return priority
        internal.add(node)
        successor_nodes = successors(node, internal)
        print('successor_nodes', successor_nodes)
        for s in successor_nodes:
            new_distance = get_distance_from_start(node) + 1
            print(
                's:',
                s,
                'new distance:',
                new_distance,
                ' old distance:',
                get_distance_from_start(s)
            )
            if new_distance < get_distance_from_start(s):
                set_distance_from_start(s, new_distance)
                # Filter previous entries of s
                frontier = [n for n in frontier if s != n[1]]
                new_priority = new_distance + distance_heuristic(s, end)
                heapq.heappush(frontier, (new_priority, s))
                print(
        'Node',
        s,
        'has been pushed to frontier with priority',
        new_priority
    )
    print('Frontier', frontier)
    print('Internal', internal)
    print(costs)
astar_verbose(start, end)

输出如下:

step 1 . Frontier: [(0, (5, 3))]
Node (5, 3) has been popped from Frontier with priority 0
successors [(4, 3), (5, 2), (5, 4)]
s: (4, 3) new distance: 1 old distance: inf
Node (4, 3) has been pushed to Frontier with priority 7.324555320336759
s: (5, 2) new distance: 1 old distance: inf
Node (5, 2) has been pushed to Frontier with priority 8.071067811865476
s: (5, 4) new distance: 1 old distance: inf
Node (5, 4) has been pushed to Frontier with priority 6.0990195135927845
step 2 . Frontier: [(6.0990195135927845, (5, 4)), (8.071067811865476, (5, 2)), (7.324555320336759, (4, 3))]
Node (5, 4) has been popped from Frontier with priority 6.0990195135927845
successors [(4, 4)]
s: (4, 4) new distance: 2 old distance: inf
Node (4, 4) has been pushed to Frontier with priority 7.385164807134504
…
step 42 . Frontier: [(15.0, (6, 8)), (15.60555127546399, (4, 6)), (15.433981132056603, (1, 1)), (15.82842712474619, (4, 7))]
Node (6, 8) has been popped from Frontier with priority 15.0
successors [(7, 8), (6, 9)]
s: (7, 8) new distance: 15 old distance: inf
Node (7, 8) has been pushed to Frontier with priority 16.414213562373096
s: (6, 9) new distance: 15 old distance: inf
Node (6, 9) has been pushed to Frontier with priority 15.0
step 43 . Frontier: [(15.0, (6, 9)), (15.433981132056603, (1, 1)), (15.82842712474619, (4, 7)), (16.414213562373096, (7, 8)), (15.60555127546399, (4, 6))]
Node (6, 9) has been popped from Frontier with priority 15.0
Optimal path found. Steps: 43
Costs matrix: [[6, 5, 4, 5, 6, 7, 8, 9, 10], [5, 4, 3, 4, 5, 6, 7, 8, 9], [4, 3, 2, inf, inf, inf, inf, inf, 10], [3, 2, 1, 2, inf, 12, 13, 12, 11], [2, 1, 0, 1, inf, 11, inf, 13, inf], [3, inf, inf, inf, inf, 10, inf, 14, 15], [4, 5, 6, 7, 8, 9, inf, 15, inf]]

我们已经看到 A*搜索返回了正确的值。问题是,我们如何重建整个路径?

为了清晰起见,从代码中删除 print 语句,并继续使用我们在第 4 步中实现的 A算法。我们不仅要返回最短路径的长度,还要返回路径本身。我们将编写一个函数,通过从终点节点向后遍历,分析成本矩阵来提取此路径。暂时不要在全局定义此函数。我们将将其定义为之前创建的 A算法中的局部函数:

def get_shortest_path(end_node):
    path = [end_node]
    distance = get_distance_from_start(end_node)
    while distance > 0:
        for neighbor in successors(path[-1], []):
            new_distance = get_distance_from_start(neighbor)
            if new_distance < distance:
                path += [neighbor]
                distance = new_distance
                break # for
    return path

现在我们知道了如何分解路径,让我们将其返回到 A*算法中:

def astar_with_path(start, end):
    frontier = []
    internal = set()
    heapq.heappush(frontier, (0, start))
    costs = initialize_costs(size, start)
    def get_distance_from_start(node):
        return costs[node[0] - 1][node[1] - 1]
    def set_distance_from_start(node, new_distance):
        costs[node[0] - 1][node[1] - 1] = new_distance
    def get_shortest_path(end_node):
        path = [end_node]
        distance = get_distance_from_start(end_node)
        while distance > 0:
            for neighbor in successors(path[-1], []):
                new_distance = get_distance_from_start(neighbor)
                if new_distance < distance:
                    path += [neighbor]
                    distance = new_distance
                    break # for
        return path
    while len(frontier) > 0:
        (priority, node) = heapq.heappop(frontier)
        if node == end:
            return get_shortest_path(end)
        internal.add(node)
        successor_nodes = successors(node, internal)
        for s in successor_nodes:
            new_distance = get_distance_from_start(node) + 1
            if new_distance < get_distance_from_start(s):
                set_distance_from_start(s, new_distance)
                # Filter previous entries of s
                frontier = [n for n in frontier if s != n[1]]
                heapq.heappush(frontier, (
                    new_distance + distance_heuristic(s, end), s
                )
                )
astar_with_path( start, end )

输出如下:

[(6, 9),
(6, 8),
(5, 8),
(4, 8),
(4, 9),
(3, 9),
(2, 9),
(2, 8),
(2, 7),
(2, 6),
(2, 5),
(2, 4),
(2, 3),
(3, 3),
(4, 3),
(5, 3)]

技术上,我们不需要从成本矩阵中重建路径。我们可以在矩阵中记录每个节点的父节点,并简单地检索坐标以节省一些搜索。

使用 simpleai 库进行 A*搜索实践

simpleai库可在 GitHub 上找到,并包含许多流行的 AI 工具和技术。

注意

你可以通过github.com/simpleai-team/simpleai 访问该库。Simple AI 库的文档可以在这里访问:simpleai.readthedocs.io/en/latest/。要访问simpleai库,首先你必须安装它:

pip install simpleai

一旦安装了 simpleai,你就可以在 Python 的 Jupyter QtConsole 中导入 simpleai 库中的类和函数:

from simpleai.search import SearchProblem, astar

搜索问题为你提供了一个定义任何搜索问题的框架。astar导入负责在搜索问题中执行 A*算法。

为了简单起见,我们在之前的代码示例中没有使用类,以便在没有杂乱的情况下专注于算法。不过,simpleai库将迫使我们使用类。

要描述一个搜索问题,你需要提供以下信息:

  • constructor:此函数初始化状态空间,从而描述问题。我们将通过将这些值添加为属性来使 Size、Start、End 和 Obstacles 值在对象中可用。在构造函数的末尾,别忘了调用超构造函数,别忘了提供初始状态。

  • actions( state ):此函数返回从给定状态可以执行的动作列表。我们将使用此函数来生成新状态。从语义上讲,创建动作常量如 UP、DOWN、LEFT 和 RIGHT,然后将这些动作常量解释为结果会更合理。然而,在这个实现中,我们将简单地解释一个动作为“移动到(x, y)”,并将此命令表示为(x, y)。此函数包含的逻辑与我们在succ函数中实现的逻辑大致相同,只是我们不会根据一组已访问节点过滤结果。

  • result( state0, action):此函数返回在 state0 上应用动作后的新状态。

  • is_goal( state ):如果状态是目标状态,则此函数返回 true。在我们的实现中,我们将不得不将状态与最终状态坐标进行比较。

  • cost( self, state, action, newState ):这是通过动作从状态移动到newState的成本。在我们的例子中,移动的成本是均匀的 1:

    import math
    from simpleai.search import SearchProblem, astar
    class ShortestPath(SearchProblem):
        def __init__(self, size, start, end, obstacles):
            self.size = size
            self.start = start
            self.end = end
    
            self.obstacles = obstacles
            super(ShortestPath, self).__init__(initial_state=self.start)
        def actions(self, state):
            (row, col) = state
            (max_row, max_col) = self.size
            succ_states = []
            if row > 1:
                succ_states += [(row-1, col)]
            if col > 1:
                succ_states += [(row, col-1)]
            if row < max_row:
                succ_states += [(row+1, col)]
            if col < max_col:
                succ_states += [(row, col+1)]
            return [s for s in succ_states if s not in self._obstacles]
        def result(self, state, action):
            return action
        def is_goal(self, state):
            return state == end
        def cost(self, state, action, new_state):
            return 1
        def heuristic(self, state):
            (x, y) = state
            (u, v) = self.end
            return math.sqrt(abs(x-u) ** 2 + abs(y-v) ** 2)
    size = (7, 9)
    start = (5, 3)
    end = (6, 9)
    obstacles = {
        (3, 4), (3, 5), (3, 6), (3, 7), (3, 8),
        (4, 5),
        (5, 5), (5, 7), (5, 9),
        (6, 2), (6, 3), (6, 4), (6, 5), (6, 7),
        (7, 7)
    }
    searchProblem = ShortestPath(Size, Start, End, Obstacles)
    result = astar( searchProblem, graph_search=True )
    result
    Node <(6, 9)>
    result.path()
    [(None, (5, 3)),
    ((4, 3), (4, 3)),
    ((3, 3), (3, 3)),
    ((2, 3), (2, 3)),
    ((2, 4), (2, 4)),
    ((2, 5), (2, 5)),
    ((2, 6), (2, 6)),
    ((2, 7), (2, 7)),
    ((2, 8), (2, 8)),
    ((2, 9), (2, 9)),
    ((3, 9), (3, 9)),
    ((4, 9), (4, 9)),
    ((4, 8), (4, 8)),
    ((5, 8), (5, 8)),
    ((6, 8), (6, 8)),
    ((6, 9), (6, 9))]
    

simpleai库使搜索描述比手动实现容易得多。我们只需要定义几个基本方法,然后我们就可以访问有效的搜索实现了。

使用最小-最大算法和 Alpha-Beta 剪枝的游戏 AI

在前两个主题中,我们看到了创建一个简单游戏如井字棋的获胜策略是多么困难。最后一个主题介绍了几种使用 A*算法解决搜索问题的结构。我们还看到,像simpleai库这样的工具帮助我们减少用代码描述任务所付出的努力。

我们将利用所有这些知识来提升我们的游戏 AI 技能并解决更复杂的问题。

轮流制多人游戏的搜索算法

轮流制多人游戏,如井字棋,与路径查找问题类似。我们有一个初始状态,我们有一组目标状态,在那里我们赢得游戏。

轮流制多人游戏的挑战在于对手可能移动的组合爆炸。这种差异使得将轮流制游戏与常规路径查找问题区别对待是合理的。

例如,在井字棋游戏中,从一个空板中,我们可以选择九个单元格中的一个,并将我们的标志放在那里,假设我们开始游戏。让我们用函数succ表示这个算法,表示后继状态的创建。考虑我们有一个用Si表示的初始状态。

succ(Si)返回[ S1, S2, ..., Sn ],其中S1, S2, ..., Sn是后继状态:

图 2.6 表示函数后继状态的树状图

图 2.6:表示函数后继状态的树状图

然后,对手也进行了一步移动,这意味着从每个可能的状态,我们必须检查更多的状态:

图 2.7 表示父-后继关系的树状图

图 2.7:表示父-后继关系的树状图

可能的未来状态的扩展在两种情况下停止:

  • 游戏结束

  • 由于资源限制,对于具有特定效用的状态,在某个深度以上的移动不值得进一步解释

一旦我们停止扩展,就必须对状态进行静态启发式评估。这正是我们在前两个主题中在选择最佳移动时所做的事情;然而,我们从未考虑过未来的状态。

因此,尽管我们的算法变得越来越复杂,但没有使用对未来状态的了解,我们很难检测我们的当前移动是否可能是一个赢家或输家。我们控制未来的唯一方法是通过改变启发式方法,了解我们将来会赢多少场、输多少场或平局。我们可以最大化我们的胜利或最小化我们的损失。我们仍然没有深入挖掘,看看我们的损失是否可以通过 AI 端更聪明的玩法来避免。

所有这些问题都可以通过深入挖掘未来状态并递归评估分支的效用来避免。为了考虑未来状态,我们将学习最小-最大算法及其变体,即 Negamax 算法。

最小-最大算法

假设有一个游戏,启发式函数可以从 AI 玩家的角度评估游戏状态。例如,我们在井字棋练习中使用了特定的评估:

  • 赢得游戏的移动加 1,000 分

  • 阻止对手赢得游戏的移动加 100 分

  • 创建两行、列或对角线的移动加 10 分

  • 创建一行、列或对角线的一个移动加 1 分

这种静态评估在任何节点上都非常容易实现。问题是,当我们深入到所有可能未来状态的树中时,我们还不知道如何处理这些分数。这就是最小-最大算法发挥作用的地方。

假设我们构建一个树,其中包含每个玩家可以执行的可能移动,直到一定深度。在树的底部,我们评估每个选项。为了简化起见,让我们假设我们有一个如下所示的搜索树:

图 2.8 搜索树到一定深度的示例

图 2.8:搜索树到一定深度的示例

AI 用 X 玩,玩家用 O 玩。带有 X 的节点意味着轮到 X 移动。带有 O 的节点意味着轮到 O 行动。

假设树底部的所有 O 都是叶子节点,由于资源限制,我们没有计算更多的值。我们的任务是评估叶子的效用:

图 2.9 具有可能移动的搜索树示例

图 2.9:具有可能移动的搜索树示例

我们必须从我们的角度选择最佳可能的移动,因为我们的目标是最大化我们移动的效用。这种最大化收益的愿望代表了 MinMax 算法中的 Max 部分:

图 2.10 具有最佳移动的搜索树示例

图 2.10:具有最佳移动的搜索树示例

如果我们再往上一层,就轮到对手行动。对手选择对我们最不利的值。这是因为对手的职责是降低我们赢得游戏的机会。这是 MinMax 算法中的 Min 部分:

图 2.11 MinMax 算法的搜索树示例

图 2.11:最小化赢得游戏的机会

在顶部,我们可以在具有效用值 101 的移动和另一个具有效用值 21 的移动之间进行选择。由于我们正在最大化我们的价值,我们应该选择 101。

图 2.12 最大化效用值的搜索树

图 2.12:最大化赢得游戏的机会

让我们看看我们如何实现这个想法:

def min_max( state, depth, is_maximizing):
    if depth == 0 or is_end_state( state ):
    &#9;return utility( state )
    if is_maximizing:
        utility = 0
        for s in successors( state ):
            score = MinMax( s, depth - 1, false )
            utility = max( utility, score )
        return utility
    else
        utility = infinity
        for s in successors( state ):
            score = MinMax( s, depth - 1, true )
            utility = min( utility, score )
        return utility

这就是 MinMax 算法。我们从自己的角度评估叶子节点。然后,从下往上,我们应用递归定义:

  • 对手通过从我们的角度选择最坏可能的节点来最优地玩游戏。

  • 我们通过从我们的角度选择最佳可能的节点来最优地玩游戏。

我们需要考虑更多因素来理解 MinMax 算法在井字棋游戏中的应用:

  • is_end_state是一个函数,用于确定是否应该评估状态而不是进一步挖掘,要么是因为游戏已经结束,要么是因为游戏即将通过强制移动结束。使用我们的效用函数,我们可以安全地说,一旦我们达到 1000 分或更高,我们就已经有效地赢得了游戏。因此,is_end_state可以简单地检查节点的分数并确定我们是否需要进一步挖掘。

  • 虽然successors函数只依赖于状态,但在实际操作中,传递谁轮到移动的信息是有用的。因此,如果需要,不要犹豫添加一个参数;你不必遵循伪代码。

  • 我们希望最小化实现 MinMax 算法的努力。因此,我们将评估现有的算法实现,并且我们还将简化本主题中算法描述的对偶性。

  • 与我们在这个算法中可能使用的效用函数相比,建议的效用函数相当准确。一般来说,我们走得越深,我们的效用函数就越不需要准确。例如,如果我们能在井字棋游戏中深入九步,我们只需要为胜利奖励 1 分,平局 0 分,失败 -1 分。考虑到在九步中,棋盘是完整的,我们拥有所有必要的信息来进行评估。如果我们只能看四步深,这个效用函数在游戏开始时将完全无用,因为我们至少需要五步才能赢得游戏。

  • 通过剪枝树,Minmax 算法可以进一步优化。剪枝是一种去除对最终结果没有贡献的分支的行为。通过消除不必要的计算,我们节省了宝贵的资源,这些资源可以用来深入树中。

使用 Alpha-Beta 剪枝优化 Minmax 算法

在之前的思考过程中,最后一个考虑因素促使我们探索通过关注重要的节点来减少搜索空间的可能的优化。

在树中存在一些节点组合,我们可以确信子树的评估不会对最终结果产生影响。我们将找到、检查并概括这些组合以优化 Minmax 算法。

让我们通过之前的节点示例来检查剪枝:

图 2.12 展示剪枝节点的搜索树

图 2.13:展示剪枝节点的搜索树

在计算了具有值 101、23 和 110 的节点后,我们可以得出结论,在两步之上,将选择值 101。为什么?

  • 假设 X <= 110。那么 110 和 X 的最大值将被选择,即 110,X 将被省略。

  • 假设 X > 110。那么 110 和 X 中的最大值是 X。在更高一级,算法将选择两个中的最小值。因为 X > 110,所以 101 和 X 的最小值始终是 101。因此,X 将在更高一级被省略。

这就是我们剪枝树的方法。

在右侧,假设我们计算了分支 10 和 21。它们的最大值是 21。计算这些值的含义是我们可以省略节点 Y1、Y2 和 Y3 的计算,并且我们将知道 Y4 的值小于或等于 21。为什么?

21 和 Y3 的最小值永远不会大于 21。因此,Y4 永远不会大于 21。

现在,我们可以在具有效用 101 的节点和具有最大效用 21 的另一个节点之间进行选择。很明显,我们必须选择具有效用 101 的节点。

图 2.13 剪枝树示例

图 2.14:剪枝树示例

这就是 alpha-beta 剪枝背后的想法。我们剪枝那些我们知道不需要的子树。

让我们看看我们如何在 Minmax 算法中实现 alpha-beta 剪枝。

首先,我们将向 Minmax 的参数列表中添加一个 alpha 和一个 beta 参数:

def min_max(state, depth, is_maximizing, alpha, beta):
    if depth == 0 or is_end_state(state):
    &#9;return utility(state)
    if is_maximizing:
        utility = 0
        for s in successors(state):
            score = MinMax(s, depth - 1, false, alpha, beta)
            utility = max(utility, score)
        return utility
    else
        utility = infinity
        for s in successors(state):
            score = MinMax(s, depth - 1, true, alpha, beta)
            utility = min(utility, score)
        return utility

对于isMaximizing分支,我们计算新的 alpha 分数,并在beta <= alpha时跳出循环:

def min_max(state, depth, is_maximizing, alpha, beta):
    if depth == 0 or is_end_state(state):
    &#9;return utility(state)
    if is_maximizing:
        utility = 0
        for s in successors(state):
            score = MinMax(s, depth - 1, false, alpha, beta)
            utility = max(utility, score)
            alpha = max(alpha, score)
            if beta <= alpha:
                break
        return utility
    else
        utility = infinity
        for s in successors(state):
            score = MinMax(s, depth - 1, true, alpha, beta)
            utility = min(utility, score)
        return utility

我们需要对最小化分支做双重处理:

def min_max(state, depth, is_maximizing, alpha, beta):
    if depth == 0 or is_end_state( state ):
    &#9;return utility(state)
    if is_maximizing:
        utility = 0
        for s in successors(state):
            score = min_max(s, depth - 1, false, alpha, beta)
            utility = max(utility, score)
            alpha = max(alpha, score)
            if beta <= alpha: break
        return utility
    else
        utility = infinity
        for s in successors(state):
            score = min_max(s, depth - 1, true, alpha, beta)
            utility = min(utility, score)
            beta = min(beta, score)
            if beta <= alpha: break
        return utility

我们已经完成了实现。建议你逐步在示例树中执行算法,以获得对实现的感受。

缺少一个重要部分,阻止我们正确执行:alpha 和 beta 的初始值。任何在效用值可能范围之外的数字都可以。我们将使用正负无穷大作为初始值来调用 Minmax 算法:

alpha = infinity
beta = -infinity

Minmax 算法的 DRY 化 – NegaMax 算法

Minmax 算法工作得很好,特别是与 alpha-beta 剪枝结合使用时。唯一的问题是算法中有两个分支,一个 if 和一个 else,它们本质上相互否定。

如我们所知,在计算机科学中,有 DRY 代码和 WET 代码。DRY 代表不要重复自己。WET 代表我们喜欢打字。当我们写相同的代码两次时,我们增加了一半在编写时犯错的几率。我们也增加了未来每次维护工作被执行的几率。因此,重用我们的代码会更好。

在实现 Minmax 算法时,我们总是从 AI 玩家的视角计算节点的效用。这就是为什么在本质上是双重的实现中,我们必须有一个效用最大化分支和一个效用最小化分支。由于我们更喜欢只描述问题的干净代码,我们可以通过改变评估的视角来消除这种双重性。

当 AI 玩家的回合到来时,算法中没有任何变化。

每当对手的回合到来时,我们否定视角。最小化 AI 玩家的效用等同于最大化对手的效用。

这简化了 Minmax 算法:

def Negamax(state, depth, is_players_point_of_view):
    if depth == 0 or is_end_state(state):
        return utility(state, is_players_point_of_view)
    utility = 0
    for s in successors(state):
        score = Negamax(s,depth-1,not is_players_point_of_view)
    return score

使用 Negamax 算法有一些必要条件:棋盘状态的评估必须是对称的。如果一个游戏状态从第一个玩家的视角来看是+20,那么从第二个玩家的视角来看就是-20。因此,我们通常将分数标准化在零周围。

使用 EasyAI 库

我们已经看到了simpleai库,它帮助我们执行路径查找问题的搜索。现在我们将使用 EasyAI 库,它可以轻松处理两人游戏的 AI 搜索,将井字棋问题的实现简化为编写几个评估棋盘效用和确定游戏何时结束的函数。

你可以在 GitHub 上阅读库的文档:github.com/Zulko/easyAI

要安装 EasyAI 库,请运行以下命令:

pip install easyai

注意

和往常一样,如果你使用 Anaconda,你必须在这个 Anaconda 提示符中执行此命令,而不是在 Jupyter QtConsole 中。

一旦 EasyAI 可用,按照文档的结构描述井字棋问题是有意义的。这个实现是从zulko.github.io/easyAI/examples/games.html中取的,在那里井字棋问题被简洁而优雅地描述:

from easyAI import TwoPlayersGame
from easyAI.Player import Human_Player
class TicTacToe( TwoPlayersGame ):
    """ The board positions are numbered as follows:
            7 8 9
            4 5 6
            1 2 3
    """    
    def __init__(self, players):
        self.players = players
        self.board = [0 for i in range(9)]
        self.nplayer = 1 # player 1 starts.

    def possible_moves(self):
        return [i+1 for i,e in enumerate(self.board) if e==0]

    def make_move(self, move):
        self.board[int(move)-1] = self.nplayer
    def unmake_move(self, move): # optional method (speeds up the AI)
        self.board[int(move)-1] = 0

    def lose(self):
        """ Has the opponent "three in line ?" """
        return any( [all([(self.board[c-1]== self.nopponent)
                      for c in line])
                      for line in [[1,2,3],[4,5,6],[7,8,9],
                                   [1,4,7],[2,5,8],[3,6,9],
                                   [1,5,9],[3,5,7]]])

    def is_over(self):
        return (self.possible_moves() == []) or self.lose()

    def show(self):
        print ('\n'+'\n'.join([
                        ' '.join([['.','O','X'][self.board[3*j+i]]
                        for i in range(3)])
                 for j in range(3)]) )

    def scoring(self):
        return -100 if self.lose() else 0

if __name__ == "__main__":

    from easyAI import AI_Player, Negamax
    ai_algo = Negamax(6)
    TicTacToe( [Human_Player(),AI_Player(ai_algo)]).play()

在这个实现中,电脑玩家永远不会输,因为 Negamax 算法在深度 6 中探索搜索标准。

注意评分函数的简单性。胜利或失败可以引导 AI 玩家达到永不输掉游戏的目标。

活动 4:四子棋

在本节中,我们将练习使用EasyAI库并开发一种启发式方法。我们将使用四子棋来进行这个练习。游戏棋盘宽度为 7 个单元格,高度为 7 个单元格。当你移动时,你只能选择放置你的标记的列。然后,重力将标记拉到最低的空单元格。你的目标是先于对手水平、垂直或对角线连接四个自己的标记,或者你用完所有空位。游戏规则可以在en.wikipedia.org/wiki/Connect_Four找到。

我们可以保留一些函数的定义不变。我们必须实现以下方法:

  • __init__

  • possible_moves

  • make_move

  • unmake_move (可选)

  • lose

  • show

  1. 我们将重用井字棋的基本评分函数。一旦你测试了游戏,你就会看到游戏并非不可战胜,尽管我们只使用了基本的启发式方法,但它的表现却出人意料地好。

  2. 然后,让我们编写init方法。我们将定义棋盘为一个一维列表,就像井字棋的例子。我们也可以使用二维列表,但建模并不会变得更容易或更难。我们将生成游戏中所有可能的获胜组合,并保存它们以供将来使用。

  3. 让我们处理移动。可能的移动函数是一个简单的枚举。注意,我们在移动名称中使用从 1 到 7 的列索引,因为在人类玩家界面中从 1 开始列索引比从 0 开始更方便。对于每一列,我们检查是否有未占用的区域。如果有,我们将该列设为可能的移动。

  4. 移动与可能的移动函数类似。我们检查移动的列,并从底部开始找到第一个空单元格。一旦找到,我们就占据它。你还可以阅读make_move函数的对应函数unmake_move的实现。在unmake_move函数中,我们从顶部到底部检查列,并在第一个非空单元格处移除移动。注意,我们依赖于easyAi的内部表示,这样它就不会撤销它没有做出的移动。如果我们不这样做,这个函数就会在没有检查移除的是哪个玩家的标记的情况下移除另一个玩家的标记。

  5. 由于我们已经有了必须检查的元组,我们可以主要重用井字棋示例中的 lose 函数。

  6. 我们最后一个任务是实现一个打印棋盘的显示方法。我们将重用井字棋的实现,只需更改变量。

  7. 现在所有函数都已完成,你可以尝试运行示例。你可以随意与对手玩几轮。你可以看到对手并不完美,但它的表现相当合理。如果你有一台强大的计算机,你可以增加 Negamax 算法的参数。我鼓励你提出一个更好的启发式函数。

    注意

    这个活动的解决方案可以在第 265 页找到。

摘要

在本章中,我们学习了如何将搜索技术应用于游戏。

首先,我们创建了一个静态方法,该方法基于预定义的规则玩井字棋游戏,而不进行前瞻。然后,我们将这些规则量化为一个我们称之为启发式函数的数字。在下一个主题中,我们学习了如何在 A*搜索算法中使用启发式函数来找到一个问题的最优解。

最后,我们了解了 Minmax 和 NegaMax 算法,这样人工智能就能赢得两人游戏。

既然你已经了解了编写游戏人工智能的基础知识,那么是时候学习人工智能领域内的另一个不同领域:机器学习了。在下一章中,你将学习关于回归的内容。

第三章:回归

学习目标

到本章结束时,你将能够:

  • 描述回归中涉及的数学逻辑

  • 阐述 NumPy 库在回归中的应用

  • 识别一元和多元线性回归

  • 使用多项式回归

本章涵盖了线性回归和多项式回归的基础。

简介

回归是一个广泛的领域,它将数学统计学、数据科学、机器学习和人工智能联系起来。由于回归的基本原理根植于数学,我们将从探索数学基础开始。

本主题的大部分内容将涉及不同形式的线性回归,包括一元线性回归、多元线性回归、一元多项式回归和多元多项式回归。Python 提供了大量支持进行回归操作的功能。

在比较和对比支持向量回归与线性回归形式的同时,我们还将使用替代的回归模型。在本章中,我们将使用从在线服务提供商加载的股价数据进行回归分析。本章中的模型并不旨在提供交易或投资建议。

注意

虽然不建议使用本章中的模型提供交易或投资建议,但这确实是一次非常激动人心且有趣的旅程,它解释了回归的基本原理。

一元线性回归

一个一般的回归问题可以定义为如下。假设我们有一组数据点。我们需要找出一个最佳拟合曲线来近似拟合给定的数据点。这条曲线将描述我们的输入变量 x(数据点)与输出变量 y(曲线)之间的关系。

在现实生活中,我们经常有多个输入变量决定一个输出变量。回归帮助我们理解当我们将所有但一个输入变量固定时,输出变量是如何变化的,并且我们改变剩余的输入变量。

什么是回归?

在本章中,我们将研究二维平面上的回归。这意味着我们的数据点是二维的,我们正在寻找一条曲线来近似如何从一个变量计算另一个变量。

我们将学习以下类型的回归:

  • 使用一元多项式(度数为 1)进行一元线性回归:这是回归的最基本形式,其中一条直线近似表示未来数据集的轨迹。

  • 使用一元多项式(度数为 1)进行多元线性回归:我们将使用一元方程,但现在我们将允许多个输入变量,也称为特征。

  • 一元多项式回归:这是单变量线性回归的一种通用形式。由于用来近似输入和输出之间关系的多项式是任意阶的,我们可以创建比直线更好地拟合数据点的曲线。回归仍然是线性的——不是因为多项式是线性的,而是因为回归问题可以用线性代数来建模。

  • 多元多项式回归:这是使用高阶多项式和多个特征预测未来的最通用回归问题。

  • 支持向量回归:这种回归形式使用支持向量机来预测数据点。这种回归形式包括在内,以便将其与其他四种回归形式的用法进行比较。

在这个主题中,我们将处理第一种类型的线性回归:我们将使用一个变量,回归的多项式将描述一条直线。

在二维平面上,我们将使用笛卡尔坐标系,更通俗地称为直角坐标系。我们有一个 X 轴和一个 Y 轴,这两个轴的交点是原点。我们用它们的 X 和 Y 坐标来表示点。

例如,点 (2, 1) 对应于以下坐标系中的橙色点:

图 3.1:在坐标系中表示点 (2,1)

一条直线可以用方程 y = a*x + b 来描述,其中 a 是方程的斜率,决定了方程上升的陡峭程度,b 是一个常数,决定了直线与 Y 轴的交点。

在以下图中,你可以看到三个方程:

  • 蓝线用 y = 2*x + 1 方程描述。

  • 橙线用 y = x + 1 方程描述。

  • 紫色线用 y = 0.5*x + 1 方程描述。

你可以看到,所有三个方程都在 y 轴上交于 1,它们的斜率由我们乘以 x 的因子决定。

如果你知道 x,你可以找出 y。如果你知道 y,你可以找出 x:

图 3.2:在坐标系中表示方程 y = 2x + 1, y = x + 1, 和 y = 0.5x + 1

我们也可以用方程描述更复杂的曲线:

图 3.3:显示复杂曲线的图像

注意

如果你想更多地实验笛卡尔坐标系,你可以使用以下绘图器:s3-us-west-2.amazonaws.com/oerfiles/College+Algebra/calculator.html

特征和标签

在机器学习中,我们区分特征和标签。特征被认为是我们的输入变量,标签是我们的输出变量。

当谈论回归时,标签的可能值是一个连续的有理数集合。

将特征视为 X 轴上的值,将标签视为 Y 轴上的值。

回归的任务是根据特征值预测标签值。我们通常通过将特征值前移来创建标签。例如,如果我们想预测 1 个月后的股价,并且通过将股价特征前移 1 个月来创建标签,那么:

  • 对于每个至少有 1 个月历史的股价特征值,都有可用的训练数据,显示了 1 个月后的预测股价数据。

  • 对于上个月,预测数据不可用,因此这些值都是 NaN(不是一个数字)。

我们必须丢弃上个月的数据,因为我们不能使用这些值进行预测。

特征缩放

有时,我们有多组特征,它们的值可能完全在不同的范围内。想象一下,在地图上比较微米和现实世界中的千米。由于数量级相差九个零,它们将很难处理。

一个不那么戏剧性的差异是英制和公制数据之间的差异。磅和千克,厘米和英寸,它们之间的比较并不好。

因此,我们通常将特征缩放到归一化值,这样处理起来更容易,因为我们更容易比较这个范围内的值。我们将训练数据和测试数据一起缩放。范围通常缩放到[-1;1]之间。

我们将演示两种类型的缩放:

  • 最小-最大归一化

  • 均值归一化

最小-最大缩放的计算如下:

x_scaled[n] = (x[n] - min(x)) / (max(x)-min(x))

均值归一化的计算如下:

avg = sum(x) / len(x)
x_scaled[n] = (x[n] – avg) / (max(x)-min(x))
[(float(i)-avg)/(max(fibonacci)-min(fibonacci)) for i in fibonacci]

这里是一个最小-最大和最大-最小归一化的例子:

fibonacci = [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144]
# Min-Max scaling:
[(float(i)-min(fibonacci))/(max(fibonacci)-min(fibonacci)) for i in fibonacci]
[0.0,
0.006944444444444444,
0.006944444444444444,
0.013888888888888888,
0.020833333333333332,
0.034722222222222224,
0.05555555555555555,
0.09027777777777778,
0.14583333333333334,
0.2361111111111111,
0.3819444444444444,
0.6180555555555556,
1.0]
# Mean normalization:
avg = sum(fibonacci) / len(fibonacci)
# 28.923076923076923
[(float(i)-avg)/(max(fibonacci)-min(fibonacci)) for i in fibonacci]
[-0.20085470085470086,
-0.19391025641025642,
-0.19391025641025642,
-0.18696581196581197,
-0.18002136752136752,
-0.16613247863247863,
-0.1452991452991453,
-0.11057692307692307,
-0.05502136752136752,
0.035256410256410256,
0.18108974358974358,
0.4172008547008547,
0.7991452991452992]

缩放可能会增加处理时间,但通常这是一个合理的步骤。

在 scikit-learn 库中,我们可以访问一个用于缩放 NumPy 数组的函数:

import numpy as np
from sklearn import preprocessing
preprocessing.scale(fibonacci)
array([-0.6925069 , -0.66856384, -0.66856384, -0.64462079, -0.62067773,
       -0.57279161, -0.50096244, -0.38124715, -0.18970269, 0.12155706,
        0.62436127, 1.43842524, 2.75529341])

缩放方法执行均值归一化。请注意,结果是 NumPy 数组。

基于训练和测试数据的交叉验证

交叉验证衡量统计模型的预测性能。交叉验证结果越好,你就越可以相信你的模型可以用来预测未来。

在交叉验证期间,我们测试模型在真实测试数据上预测未来的能力。测试数据不用于预测过程。

训练数据用于构建预测结果的模型。

一旦我们从数据源加载数据,我们通常将数据分成较大的训练数据块和较小的测试数据块。这种分离会随机打乱训练和测试数据的条目。然后,它给你一个包含训练特征、相应的训练标签、测试特征和相应的测试标签的数组。

我们可以使用 scikit-learn 的model_selection库进行训练-测试分割。

假设在我们这个假设的例子中,我们已经缩放了斐波那契数据及其索引作为标签:

features = preprocessing.scale(fibonacci)
label = np.array(range(13))

让我们使用 10%的数据作为测试数据。

from sklearn import model_selection
(x_train, x_test, y_train, y_test) =
model_selection.train_test_split(features, label, test_size=0.1)
x_train
array([-0.66856384, 0.12155706, -0.18970269, -0.64462079, 1.43842524,
        2.75529341, -0.6925069 , -0.38124715, -0.57279161, -0.62067773,
       -0.66856384])
x_test
array([-0.50096244, 0.62436127])
y_train
array([1, 9, 8, 3, 11, 12, 0, 7, 5, 4, 2])
y_test
array([6, 10])

在训练和测试过程中,如果我们得到错误的比率,我们就有可能过度拟合或欠拟合模型。

当我们训练模型过于完美,以至于它对训练数据集拟合得太好时,就会发生过拟合。模型将在训练数据上非常准确,但在现实生活中却无法使用,因为当它用于任何其他数据时,其准确性会下降。模型调整到训练数据中的随机噪声,并假设在这个噪声上的模式会产生错误的预测。当模型没有很好地拟合训练数据,以至于无法识别数据的重要特征时,就会发生欠拟合。结果,它无法对新数据进行必要的预测。一个例子是当我们尝试对非线性数据进行线性回归时。例如,斐波那契数不是线性的,因此,斐波那契序列上的模型也不能是线性的。

注意

如果你记得笛卡尔坐标系,你知道水平轴是 X 轴,而垂直轴是 Y 轴。我们的特征位于 X 轴,而我们的标签位于 Y 轴。因此,我们将特征和 X 视为同义词,而标签通常用 Y 表示。因此,x_test 表示特征测试数据,x_train 表示特征训练数据,y_test 表示标签测试数据,y_train 表示标签训练数据。

使用 scikit-learn 在数据上拟合模型

我们将通过一个虚拟示例来展示回归的过程,在这个示例中,我们只有一个特征和非常有限的数据。

由于我们只有一个特征,我们必须通过x_train.reshape (-1,1)x_train格式化为一个包含一个特征的 NumPy 数组。

因此,在执行拟合最佳线的代码之前,请执行以下代码:

x_train = x_train.reshape(-1, 1)

x_test = x_test.reshape(-1, 1)

# array([a, b, c]).reshape(-1, 1) becomes:

# array([[a, b, c]])

假设我们为我们的特征和标签有训练和测试数据。

我们可以在这些数据上拟合一个模型来进行预测。现在我们将使用线性回归来完成这个目的:

from sklearn import linear_model
linear_regression = linear_model.LinearRegression()
model = linear_regression.fit(x_train, y_train)
model.predict(x_test)
array([4.16199119, 7.54977143])

我们也可以计算与模型相关的分数:

model.score(x_test, y_test)
-0.17273705326696565

这个分数是均方误差,代表了模型的准确性。它表示我们能够从标签中预测特征的好坏程度。

这个数字表示一个非常差的模型。最佳可能的分数是 1.0。如果我们始终通过忽略特征来预测标签,我们可以达到 0.0 的分数。在这本书中,我们将省略这个分数的数学背景。

我们模型表现不佳有两个原因:

  • 11 个训练数据和 2 个测试数据对于进行适当的预测分析来说远远不够。

  • 即使忽略点的数量,斐波那契x -> y函数也不描述 x 和 y 之间的线性关系。用线来近似非线性函数只有在非常接近训练区间时才有用。

在未来,我们将看到更多更准确的模型,甚至可能达到 0.9 的模型分数。

使用 NumPy 数组进行线性回归

NumPy 数组比 Python 列表更方便的一个原因是它们可以被当作向量来处理。有一些定义在向量上的操作可以简化我们的计算。我们可以对长度相似的向量执行操作。两个向量的和以及它们的(向量)积等于一个向量,其中每个坐标是相应坐标的和或(向量)积。

例如:

import numpy as np
v1 = np.array([1,2,3])
v2 = np.array([2,0,2])
v1 + v2 # array([3, 2, 5])
v1 * v2 # array([2, 0, 6])

向量与标量的乘积是一个向量,其中每个坐标都乘以标量:

v1 * 2 # array([2, 4, 6])

向量的平方等于向量与自身的向量积。双星号表示幂运算符:

v1 ** 2 # array([1, 4, 9], dtype=int32)

假设我们在平面上有一组点。我们的任务是找到最佳拟合线。

让我们看看两个示例。

我们的第一个示例包含 13 个看似线性的值。我们正在绘制以下数据:

[2, 8, 8, 18, 25, 21, 32, 44, 32, 48, 61, 45, 62]

如果你想要绘制一条最接近这些点的线,你的合理猜测将非常接近现实:

图片

图 3.4:值[2, 8, 8, 18, 25, 21, 32, 44, 32, 48, 61, 45, 62]的绘制图表

我们的第二个示例是缩放后的斐波那契数列的前 13 个值。尽管我们可以定义一条最接近这些点的线,但我们从点的分布中可以看出,我们的模型不会很有用:

图片

图 3.5:斐波那契值的绘制图表

我们已经学过了直线的方程:y = a * x + b

在这个方程中,a 是斜率,by 轴截距。为了找到最佳拟合线,我们必须找到系数 ab

我们的任务是使最佳拟合线到各点的距离之和最小化。

在这本书中,我们将保存计算系数 ab 的思维过程,因为你会发现它几乎没有实际用途。我们更愿意使用列表中值的算术平均值作为均值。我们可以使用 NumPy 提供的均值函数来完成这个任务。

让我们找到这两个示例的最佳拟合线:

import numpy as np
from numpy import mean
x = np.array(range(1, 14))
y = np.array([2, 8, 8, 18, 25, 21, 32, 44, 32, 48, 61, 45, 62])
a = (mean(x)*mean(y) - mean(x*y)) / (mean(x) ** 2 - mean( x ** 2 ))
4.857142857142859
b = mean(y) - a*mean(x)
-2.7692307692307843

一旦我们用前面的系数绘制出线 y = a*x + b,我们得到以下图表:

图片

图 3.6:绘制图表显示数组值[2, 8, 8, 18, 25, 21, 32, 44, 32, 48, 61, 45, 62]和线 y=a*x+b

注意

你可以在www.endmemo.com/statistics/lr.php找到一个线性回归计算器。你还可以检查计算器,以了解给定数据集上的最佳拟合线看起来像什么。

关于缩放后的斐波那契值,最佳拟合线看起来如下:

图片

图 3.7:绘制图表显示斐波那契值和线 y=a*x+b

第二个数据集的最佳拟合线显然比训练区间外的任何地方都要偏离。

注意

我们不必使用这种方法来进行线性回归。包括 scikit-learn 在内的许多库将帮助我们自动化这个过程。一旦我们进行了多元线性回归,我们最好使用库来为我们执行回归。

使用 NumPy Polyfit 拟合模型

NumPy Polyfit 也可以用于创建一元线性回归的最佳拟合线。

回忆一下最佳拟合线的计算:

import numpy as np
from numpy import mean
x = np.array(range(1, 14))
y = np.array([2, 8, 8, 18, 25, 21, 32, 44, 32, 48, 61, 45, 62])
a = (mean(x)*mean(y) - mean(x*y)) / (mean(x) ** 2 - mean( x ** 2 ))
4.857142857142859
b = mean(y) - a*mean(x)
-2.7692307692307843

找到系数 a 和 b 的方程相当长。幸运的是,numpy.polyfit 执行这些计算以找到最佳拟合线的系数。polyfit 函数接受三个参数:x 值的数组,y 值的数组,以及要查找的多项式度数。由于我们正在寻找一条直线,多项式中 x 的最高次幂是 1:

import numpy as np
x = np.array(range(1, 14))
y = np.array([2, 8, 8, 18, 25, 21, 32, 44, 32, 48, 61, 45, 62])
[a, b] = np.polyfit(x, y, 1)
[4.857142857142858, -2.769230769230769]

在 Python 中绘制结果

假设你有一组数据点和一条回归线。我们的任务是绘制点和线,以便我们可以用我们的眼睛看到结果。

我们将使用 matplotlib.pyplot 库来完成这个任务。这个库有两个重要的函数:

  • 散点图: 这个函数在平面上显示散点,由一系列 x 坐标和一系列 y 坐标定义。

  • 绘图: 除了两个参数外,这个函数绘制由两个点定义的段,或者由多个点定义的段序列。绘图类似于散点图,除了点不是显示出来,而是通过线连接。

一个有三个参数的绘图函数绘制一个段和/或两个点,格式根据第三个参数

一个段由两个点定义。当 x 在 1 和 14 之间变化时,显示 0 和 15 之间的段是有意义的。我们必须将 x 的值代入方程 a*x+b 中,以得到相应的 y 值:

import matplotlib.pyplot as plot
x = np.array(range(1, 14))
y = np.array([2, 8, 8, 18, 25, 21, 32, 44, 32, 48, 61, 45, 62])
a = (mean(x)*mean(y) - mean(x*y)) / (mean(x) ** 2 - mean(x ** 2))
4.857142857142859
b = mean(y) - a*mean(x)
-2.7692307692307843
# Plotting the points
plot.scatter(x, y)
# Plotting the line
plot.plot([0, 15], [b, 15*a+b])

输出如下:

图 3.8:显示数据点如何拟合回归线的图形

你可能需要调用 plot.show() 来显示前面的图形。在 IPython 控制台中,坐标系会自动显示。

段和散点数据点按预期显示。

Plot 有一个高级签名。你可以使用一个 plot 调用来在这个图表上绘制散点、线和任何曲线。这些变量被解释为三组:

  • X 值

  • Y 值

  • 以字符串形式表示的格式选项

让我们创建一个函数,从近似 x 值的数组推导出近似 y 值的数组:

def fitY( arr ):
    return [4.857142857142859 * x - 2.7692307692307843 for x in arr]

我们将使用 fit 函数来绘制值:

plot.plot(
    x, y, 'go',
    x, fitY(x), 'r--o'
)

输出如下:

图 3.9:使用 fit 函数的绘图函数的图形

Python 绘图库为大多数图形问题提供了一个简单的解决方案。你可以在这张图上绘制尽可能多的线、点和曲线。

每第三个变量负责格式化。字母 g 代表绿色,而字母 r 代表红色。您可以使用 b 代表蓝色,y 代表黄色,等等。如果没有指定颜色,每个三元组将使用不同的颜色显示。字符 o 表示我们希望在数据点所在的位置显示一个点。因此,'go' 与移动无关——它要求绘图器绘制绿色点。'-' 字符负责显示虚线。如果您只使用一个减号,则会出现直线而不是虚线。

如果我们简化这种格式化,我们可以指定我们只想绘制任意颜色的点,以及另一种任意颜色的直线。通过这样做,我们可以简单地写出以下绘图调用:

plot.plot(
    x, y, 'o',
    x, fitY(x), '-'
)

输出如下:

图 3.10:带有虚线的 plot 函数图形

在显示曲线时,绘图器使用线段连接点。同时,请注意,即使是复杂的曲线序列也只是连接点的近似。例如,如果您执行来自 gist.github.com/traeblain/1487795 的代码,您将识别出 batman 函数的段是连接的线条:

图 3.11:batman 函数的图形

有许多种绘制曲线的方法。我们已经看到,NumPy 库的 polyfit 方法返回一个系数数组来描述一个线性方程:

import numpy as np
x = np.array(range(1, 14))
y = np.array([2, 8, 8, 18, 25, 21, 32, 44, 32, 48, 61, 45, 62])
np.polyfit(x, y, 1)
# array([ 4.85714286, -2.76923077])

这个数组描述了方程 4.85714286 * x - 2.76923077

假设我们现在想要绘制一条曲线,y = -x**2 + 3*x - 2。这个二次方程由系数数组 [-1, 3, -2] 描述。我们可以编写自己的函数来计算属于 x 值的 y 值。然而,NumPy 库已经有一个功能可以为我们完成这项工作:np.poly1d

import numpy as np
x = np.array(range( -10, 10, 0.2 ))
f = np.poly1d([-1,3,-2])

由 poly1d 调用创建的 f 函数不仅适用于单个值,还适用于列表或 NumPy 数组:

f(5)
# -12
f(x)
# array([-132, -110, -90, -72, -56, -42, -30, -20, -12, -6, -2, 0, 0, -2, -6, -12, -20, -30, -42, -56])

我们现在可以使用这些值来绘制非线性曲线:

import matplotlib.pyplot as plot
plot.plot(x, f(x))

输出如下:

图 3.12:pyplot 函数的图形

使用线性回归预测值

假设我们感兴趣的是 x 坐标 20 对应的 y 值。根据线性回归模型,我们只需要将 20 的值替换到 x 的位置:

# Plotting the points
plot.scatter(x, y)
# Plotting the prediction belonging to x = 20
plot.scatter(20, a * 20 + b, color='red')
# Plotting the line
plot.plot([0, 25], [b, 25*a+b])

输出如下:

图 3.13:使用线性回归显示预测值的图形

在这里,我们用红色表示预测值。这个红色点位于最佳拟合线上。

活动 5:预测人口

您正在 Metropolis 政府办公室工作,试图预测小学容量需求。您的任务是确定 2025 年和 2030 年开始上小学的儿童数量预测。过去的数据如下:

图 3.14:表示 2001 年至 2018 年开始上小学的儿童数量的表格

在二维图表上绘制这些趋势。为此,你必须:

  1. 使用线性回归。特征是从 2001 年到 2018 年的年份。为了简化,我们可以将 2001 年表示为第 1 年,2018 年表示为第 18 年。

  2. 使用 np.polyfit 确定回归线的系数。

  3. 使用 matplotlib.pyplot 绘制结果以确定未来的趋势。

    注意

    该活动的解决方案可在第 269 页找到。

多变量线性回归

在上一个主题中,我们处理了一元线性回归。现在我们将学习线性回归的扩展版本,我们将使用多个输入变量来预测输出。

我们将依靠加载和预测股票价格的例子。因此,我们将实验用于加载股票价格的主要库。

多元线性回归

如果你还记得线性回归中最佳拟合线的公式,它被定义为 y = a*x + b,其中 a 是线的斜率,b 是线的 y 截距,x 是特征值,y 是计算出的标签值。

在多元回归中,我们有多个特征和一个标签。假设我们有三个特征,x1x2x3,我们的模型将如下变化:

y = a1 * x1 + a2 * x2 + a3 * x3 + b

在 NumPy 数组格式中,我们可以将此方程写成以下形式:

y = np.dot(np.array([a1, a2, a3]), np.array([x1, x2, x3])) + b

为了方便,将整个方程定义为向量乘积形式是有意义的。b 的系数将是 1

y = np.dot(np.array([b, a1, a2, a3]) * np.array([1, x1, x2, x3]))

多元线性回归是两个向量之间的简单标量积,其中系数 ba1a2a3 决定了四维空间中的最佳拟合方程。

要理解多元线性回归的公式,你需要两个向量的标量积。由于标量积的另一个名称是点积,执行此操作的 NumPy 函数被称为 dot:

import numpy as np

v1 = [1, 2, 3]

v2 = [4, 5, 6]

np.dot( v1, v2 ) = 1 * 4 + 2 * 5 + 3 * 6 = 32

我们只需将每个相应坐标的乘积相加。

我们可以通过最小化数据点与方程描述的最近点之间的误差来确定这些系数。为了简化,我们将省略最佳拟合方程的数学解,并使用 scikit-learn。

注意

在 n 维空间中,其中 n 大于 3,维数的数量决定了我们模型中的不同变量。在前面的例子中,我们有三个特征和一个标签。这产生了四个维度。如果你想想象四维空间,你可以想象三维空间和时间来简化。五维空间可以想象为四维空间,其中每个时间点都有一个温度。维度只是特征(和标签);它们不一定与我们三维空间的概念相关。

线性回归的过程

我们将遵循以下简单步骤来解决线性回归问题:

  1. 从数据源加载数据。

  2. 准备预测数据(归一化、格式化、过滤)。

  3. 计算回归线的参数。无论我们使用单变量线性回归还是多变量线性回归,我们都会遵循以下步骤。

从数据源导入数据

有多个库可以为我们提供访问数据源的方法。由于我们将处理股票数据,让我们看看两个针对检索金融数据的示例,Quandl 和 Yahoo Finance:

  • scikit-learn 包含一些数据集,可用于练习您的技能。

  • Quandl.com 提供免费和付费的金融数据集。

  • pandas.io 帮助您加载任何 .csv、.excel、.json 或 SQL 数据。

  • Yahoo Finance 为您提供金融数据集。

使用 Yahoo Finance 加载股票价格

使用 Yahoo Finance 加载股票数据的过程很简单。您只需要在 CLI 中使用以下命令安装 fix_yahoo_finance 包:

pip install fix_yahoo_finance

我们将下载一个包含从 2015 年开始的 S&P 500 指数的开盘价、最高价、最低价、收盘价、调整后的收盘价和交易量的数据集:

import fix_yahoo_finance as yahoo
spx_data_frame = yahoo.download("^GSPC", "2015-01-01")

这就是您需要做的。包含 S&P 500 指数的数据框已准备好。

您可以使用 plot 方法绘制指数价格:

spx_data_frame.Close.plot()

输出如下:

图片

图 3.15:显示 Yahoo Finance 股票价格的图表

您还可以使用以下命令将数据保存到 CSV 文件:

spx.to_csv("spx.csv")

使用 pandas 加载文件

假设有一个包含股票数据的 CSV 文件。我们现在将使用 pandas 从该文件加载数据:

import pandas as pd
spx_second_frame = pd.read_csv("spx.csv", index_col="Date", header=0, parse_dates=True)

为了正确解析数据,我们必须设置索引列名称,指定没有标题,并确保日期被解析为日期。

使用 Quandl 加载股票价格

Quandl.com 是金融和经济数据集的可靠来源。

练习 8:使用 Quandl 加载股票价格

  1. 打开 Anaconda 提示符并使用以下命令安装 Quandl:

    pip install quandl
    
  2. 前往 www.quandl.com/

  3. 点击“金融数据”。

  4. 在过滤器中,点击免费标签旁边的复选框。如果您有 Quandl 订阅,您可以使用它下载股票数据。

  5. 选择您想使用的股票或指数。在我们的例子中,我们将使用耶鲁大学经济学系收集的 S&P 综合指数数据。此链接为 www.quandl.com/data/YALE/SPCOMP-S-P-Composite

  6. 找到您想加载的仪器的 Quandl 代码。我们用于 S&P 500 数据的 Quandl 代码是 "YALE/SPCOMP"。

  7. 从 Jupyter QtConsole 加载数据:

    import quandl
    data_frame = quandl.get("YALE/SPCOMP")
    
  8. 所有导入值的列都是特征:

    data_frame.head()
    
  9. 输出如下:

                S&P Composite Dividend Earnings        CPI Long Interest Rate \
    Year                                                                        
    1871-01-31         4.44     0.26     0.4 12.464061            5.320000
    1871-02-28         4.50     0.26     0.4 12.844641            5.323333
    1871-03-31         4.61     0.26     0.4 13.034972            5.326667
    1871-04-30         4.74     0.26     0.4 12.559226            5.330000
    1871-05-31         4.86     0.26     0.4 12.273812            5.333333
                Real Price Real Dividend Real Earnings \
    Year                                                
    1871-01-31 89.900119     5.264421     8.099110
    1871-02-28 88.415295     5.108439     7.859137
    1871-03-31 89.254001     5.033848     7.744382
    1871-04-30 95.247222     5.224531     8.037740
    1871-05-31 99.929493     5.346022     8.224650
                Cyclically Adjusted PE Ratio
    Year                                    
    1871-01-31                         NaN
    1871-02-28                         NaN
    1871-03-31                         NaN
    1871-04-30                         NaN
    1871-05-31                         NaN
                                     ...
    2016-02-29                     24.002607
    2016-03-31                     25.372299
    

准备预测数据

在我们进行回归之前,我们必须选择我们感兴趣的特征,并且我们还需要确定我们进行回归的数据范围。

准备预测数据是回归过程的第二步。这一步也有几个子步骤。我们将在以下顺序中通过这些子步骤:

  1. 假设有一个包含预加载数据的数据帧。

  2. 选择你感兴趣的列。

  3. 用数值替换 NaN 值以避免删除数据。

  4. 确定预测区间 T,确定你希望在未来查看的时间长度或数据行数。

  5. 从你希望预测的值创建一个标签列。对于数据帧的第 i 行,标签的值应该属于时间点 i+T。

  6. 对于最后 T 行,标签值是 NaN。从数据帧中删除这些行。

  7. 从特征和标签创建 NumPy 数组。

  8. 缩放特征数组。

  9. 将训练数据和测试数据分开。

一些特征高度相关。例如,实际股息列与实际价格成比例增长。它们之间的比率并不总是相同的,但它们确实相关。

由于回归不是关于检测特征之间的相关性,我们宁愿去除一些已知的冗余属性,并在非相关特征上执行回归。

如果你已经完成了“使用 Quandl 加载股票价格”部分,你已经有了一个包含 S&P 500 指数历史数据的数据帧。我们将保留长期利率、实际价格和实际股息列:

data_frame[['Long Interest Rate', 'Real Price', 'Real Dividend', 'Cyclically Adjusted PE Ratio']]

由于你不能处理 NaN 数据,你可以通过用数字替换 NaN 来替换它。通常,你有两种选择:

  • 删除数据

  • 用有意义的默认值替换数据

data_frame.fillna(-100, inplace=True)

我们可以使用len函数检查数据帧的长度,如下面的代码所示:

len(data_frame)
1771

我们的数据帧长度是 1771。

如果我们想要预测未来 20 年的实际价格,我们将必须预测 240 个值。这大约是数据帧长度的 15%,这是完全合理的。

因此,我们将通过将实际价格值向上移动 240 个单位来创建一个实际价格标签:

data_frame['Real Price Label'] = data_frame['Real Price'].shift( -240 )

这样,每个实际价格标签值将是 20 年后的实际价格值。

这些值移动的副作用是在最后 240 个值中出现 NaN 值:

data_frame.tail()

输出如下:

            S&P Composite Dividend Earnings     CPI Long Interest Rate \
Year                                                                        
2018-03-31        2702.77     50.00     NaN 249.5540             2.840
2018-04-30        2653.63     50.33     NaN 250.5460             2.870
2018-05-31        2701.49     50.66     NaN 251.5880             2.976
2018-06-30        2754.35     50.99     NaN 252.1090             2.910
2018-07-31        2736.61     NaN     NaN 252.3695             2.830
             Real Price Real Dividend Real Earnings \
Year                                                    
2018-03-31 2733.262995     50.564106            NaN
2018-04-30 2672.943397     50.696307            NaN
2018-05-31 2709.881555     50.817364            NaN
2018-06-30 2757.196024     51.042687            NaN
2018-07-31 2736.610000            NaN            NaN
            Cyclically Adjusted PE Ratio Real Price Label
Year                                                        
2018-03-31                     31.988336             NaN
2018-04-30                     31.238428             NaN
2018-05-31                     31.612305             NaN
2018-06-30                     32.091415             NaN
2018-07-31                     31.765318             NaN

我们可以通过在数据帧上执行 dropna 来去除它们:

data_frame.dropna(inplace=True)

这样,我们就有到了 1998 年 7 月的数据,并且在实际价格标签列中有未来值直到 2018 年:

data_frame.tail()

输出如下:

            S&P Composite Dividend Earnings    CPI Long Interest Rate \
Year                                                                    
1998-03-31        1076.83 15.6400 39.5400 162.2                5.65
1998-04-30        1112.20 15.7500 39.3500 162.5                5.64
1998-05-31        1108.42 15.8500 39.1600 162.8                5.65
1998-06-30        1108.39 15.9500 38.9700 163.0                5.50
1998-07-31        1156.58 16.0167 38.6767 163.2                5.46
             Real Price Real Dividend Real Earnings \
Year                                                    
1998-03-31 1675.456527     24.334519     61.520900
1998-04-30 1727.294510     24.460428     61.112245
1998-05-31 1718.251850     24.570372     60.705096
1998-06-30 1716.097117     24.695052     60.336438
1998-07-31 1788.514193     24.767932     59.808943
            Cyclically Adjusted PE Ratio Real Price Label
Year                                                        
1998-03-31                     36.296928     2733.262995
1998-04-30                     37.276934     2672.943397
1998-05-31                     36.956599     2709.881555
1998-06-30                     36.802293     2757.196024
1998-07-31                     38.259645     2736.610000

让我们为回归准备我们的特征和标签。

对于特征,我们将使用数据帧的 drop 方法。drop 方法返回一个新的数据帧,其中不包含被删除的列:

import numpy as np
features = np.array(data_frame.drop('Real Price Label', 1))
label = np.array(data_frame['Real Price Label'])

第二个参数中的 1 指定了我们正在删除列。由于原始数据帧没有被修改,标签可以直接从中提取。

现在是时候使用 Scikit Learn 的预处理模块缩放特征了:

from sklearn import preprocessing
scaled_features = preprocessing.scale(features)
features
array([[6.19000000e+00, 2.65000000e-01, 4.85800000e-01, ...,
        7.10000389e+00, 1.30157807e+01, 1.84739523e+01],
       [6.17000000e+00, 2.70000000e-01, 4.81700000e-01, ...,
        7.16161179e+00, 1.27768459e+01, 1.81472582e+01],
       [6.24000000e+00, 2.75000000e-01, 4.77500000e-01, ...,
        7.29423423e+00, 1.26654431e+01, 1.82701191e+01],
       ...,
       [1.10842000e+03, 1.58500000e+01, 3.91600000e+01, ...,
        2.45703721e+01, 6.07050959e+01, 3.69565985e+01],
       [1.10839000e+03, 1.59500000e+01, 3.89700000e+01, ...,
        2.46950523e+01, 6.03364381e+01, 3.68022935e+01],
       [1.15658000e+03, 1.60167000e+01, 3.86767000e+01, ...,
        2.47679324e+01, 5.98089427e+01, 3.82596451e+01]])
scaled_features
array([[-0.47564285, -0.62408514, -0.57496262, ..., -1.23976862,
        -0.84099698, 0.6398416 ],
       [-0.47577027, -0.62273749, -0.5754623 , ..., -1.22764677,
        -0.85903686, 0.57633607],
       [-0.47532429, -0.62138984, -0.57597417, ..., -1.20155224,
        -0.86744792, 0.60021881],
       ...,
       [ 6.54690076, 3.57654404, 4.13838295, ..., 2.19766676,
         2.75960615, 4.23265262],
       [ 6.54670962, 3.60349707, 4.11522706, ..., 2.22219859,
         2.73177202, 4.20265751],
       [ 6.85373845, 3.62147473, 4.07948167, ..., 2.23653834,
         2.69194545, 4.48594968]])

如你所见,缩放后的特征更容易阅读和解释。在缩放数据时,我们必须一起缩放所有数据,包括训练数据和测试数据。

执行和验证线性回归

现在缩放完成后,我们的下一个任务是分别将训练数据和测试数据分开。我们将使用 90%的数据作为训练数据,其余的(10%)将用作测试数据:

from sklearn import model_selection
(features_train, features_test, label_train, label_test) =
    model_ selection.train_test_split(
        scaled_features, label, test_size=0.1
    )

train_test_split 函数会打乱我们数据的行,保持对应关系,并将大约 10%的所有数据放入测试变量中,保留 90%用于训练变量。这将帮助我们评估我们的模型有多好。

我们现在可以根据训练数据创建线性回归模型:

from sklearn import linear_model
model = linear_model.LinearRegression()
model.fit(features_train, label_train)

一旦模型准备就绪,我们就可以用它来预测属于测试特征值的标签:

label_predicted = model.predict(features_test)

如果你感兴趣的是预测特征值和准确测试特征值之间的关系,你可以使用 Python 二维图表绘制实用程序来绘制它们:

from matplotlib import pyplot as plot
plot.scatter(label_test, label_predicted)

这给你一个图表的图像,其中测试数据与预测结果进行比较。这些值越接近y = x线,就越好。

你可以从以下图表中看到,预测确实围绕y=x线进行,存在一定的误差。这种误差很明显,否则我们就能用这样的简单预测赚很多钱,每个人都会追求预测股价而不是在自己的专业领域工作:

图片

图 3.16:散点图绘制函数的图表

我们可以得出结论,模型存在一定的误差。问题是,我们如何量化这个误差?答案是简单的:我们可以使用内置的实用程序对模型进行评分,该实用程序计算模型的均方误差:

model.score(features_test, label_test)
0.9051697119010782

我们可以得出结论,该模型非常准确。这并不令人惊讶,因为每个金融顾问骗子都倾向于告诉我们市场每年增长大约 6-7%。这是一种线性增长,模型本质上预测市场将继续以线性速度增长。得出市场长期倾向于上涨的结论并不是什么科学。

预测未来

我们已经使用了测试数据的预测。现在,是时候使用实际数据来看向未来了。

import quandl
import numpy as np
from sklearn import preprocessing
from sklearn import model_selection
from sklearn import linear_model
data_frame = quandl.get("YALE/SPCOMP")
data_frame[['Long Interest Rate', 'Real Price', 'Real Dividend', 'Cyclically Adjusted PE Ratio']]
data_frame.fillna(-100, inplace=True)
data_frame['Real Price Label'] = data_frame['Real Price'].shift(-240)
data_frame.dropna(inplace=True)
features = np.array(data_frame.drop('Real Price Label', 1))
label = np.array(data_frame['Real Price Label'])
scaled_features = preprocessing.scale(features)
(features_train, features_test, label_train, label_test) =
    model_ selection.train_test_split(
        scaled_features, label, test_size=0.1
    )
model = linear_model.LinearRegression()
model.fit(features_train, label_train)
label_predicted = model.predict(features_test)

预测未来的技巧是我们必须保存构建模型时丢弃的值的值。我们基于 20 年前的历史数据构建了股价模型。现在,我们必须保留这些数据,还必须在缩放时包含这些数据:

import quandl
import numpy as np
from sklearn import preprocessing
from sklearn import model_selection
from sklearn import linear_model
data_frame = quandl.get("YALE/SPCOMP")
data_frame[['Long Interest Rate', 'Real Price', 'Real Dividend', 'Cyclically Adjusted PE Ratio']]
data_frame.fillna(-100, inplace=True)
# We shift the price data to be predicted 20 years forward
data_frame[ 'Real Price Label'] = data_frame['Real Price'].shift(-240)
# Then exclude the label column from the features
features = np.array(data_frame.drop('Real Price Label', 1))
# We scale before dropping the last 240 rows from the
# features
scaled_features = preprocessing.scale(features)
# Save the last 240 rows before dropping them
scaled_features_latest_240 = scaled_features[-240:]
# Exclude the last 240 rows from the data used for model
# building
scaled_features = scaled_features[:-240]
# Now we can drop the last 240 rows from the data frame
data_frame.dropna(inplace=True)
# Then build the labels from the remaining data
label = np.array(data_frame['Real Price Label'])
# The rest of the model building stays
(features_train, features_test, label_train, label_test) =
    model_ selection.train_test_split(
        scaled_features, label, test_size=0.1
    )
model = linear_model.LinearRegression()
model.fit(features_train, label_train)

现在我们已经可以访问过去 20 年的特征值的缩放值,我们可以使用以下代码预测未来 20 年的指数价格:

label_predicted = model.predict(scaled_features_latest_240)

理论上听起来很棒,但在实践中,使用这个模型通过下注预测来赚钱,绝对不比在赌场赌博更好。这只是一个用于说明预测的示例模型;它绝对不足以用于对市场价格的短期或长期投机。

如果你查看这些值,你可以看到为什么这个预测可能会轻易失败。首先,有几个负值,这对于指数来说是不可能的。然后,由于几次重大的市场崩盘,线性回归对未来某个时间点的末日预测进行了预测,其中指数将在一年内从超过 3,000 点降至零。线性回归并不是基于有限数据向前看 20 年的完美工具。此外,请注意,股价应接近时间不变系统。这意味着过去并不预示未来有任何模式。

让我们输出属于前十年的预测:

from matplotlib import pyplot as plot
plot.plot(list(range(1,241)), label_predicted[:240])

输出如下:

图片

图 3.17:范围在 1 到 241 之间的绘图函数的图表,预测标签为 240

由于极端值,图表在末尾难以阅读。让我们通过省略最后五年,只绘制预测中的前 180 个月来得出结论:

plot.plot(list(range(1,181)), label_predicted[:180])

输出如下:

图片

图 3.18:范围在 1 到 181 之间的绘图函数的图表,预测标签为 180

这对美国经济来说是一个令人恐惧的未来。根据这个模型,标普 500 指数将经历大约 2.5-3 年的牛市,并且很长时间内无法恢复。此外,请注意,我们的模型并不知道指数值不能为负。

多项式和支持向量回归

在进行多项式回归时,x 和 y 之间的关系,或者使用它们的其它名称,特征和标签,不是一个线性方程,而是一个多项式方程。这意味着我们可以在方程中有多多个系数和多个 x 的幂。

要使事情更加复杂,我们可以使用多个变量进行多项式回归,其中每个特征可能乘以不同幂的特征系数。

我们的任务是找到一个最适合我们的数据集的曲线。一旦多项式回归扩展到多个变量,我们将学习支持向量机模型来进行多项式回归。

单变量多项式回归

作为回顾,我们迄今为止已经执行了两种回归:

  • 简单线性回归:y = a*x + b

  • 多元线性回归:y = b + a1 * x1 + a2 * x2 + … + an * xn

我们现在将学习如何使用一个变量进行多项式线性回归。多项式线性回归的方程如下:

y = b + a1*x + a2*(x ** 2) + a3*(x ** 3) + … + an * (x ** n)

有一个系数向量(b, a1, a2, …, an)乘以多项式中 x 的度数向量(1, x**1, x**2, …, x**n)

有时,多项式回归比线性回归效果更好。如果标签和特征之间的关系可以用线性方程描述,那么使用线性方程是完美的。如果我们有一个非线性增长,多项式回归往往能更好地近似特征和标签之间的关系。

线性回归的单变量最简单实现是 NumPy 库中的polyfit方法。在下一个练习中,我们将执行二阶和三阶的多项式线性回归。

注意

尽管我们的多项式回归有一个包含 x 的 n 次方系数的方程,但这个方程在文献中仍被称为多项式线性回归。回归之所以是线性的,并不是因为我们限制了方程中 x 的高次幂的使用,而是因为系数 a1、a2、……等等在方程中是线性的。这意味着我们使用线性代数的工具集,与矩阵和向量一起工作,以找到最小化近似误差的缺失系数。

练习 9:一阶、二阶和三阶多项式回归

在以下两个数据集上执行一阶、二阶和三阶多项式回归:

Declare the two datasets
import numpy as np
from matplotlib import pyplot as plot
# First dataset:
x1 = np.array(range(1, 14))
y1 = np.array([2, 8, 8, 18, 25, 21, 32, 44, 32, 48, 61, 45, 62])
# Second dataset:
x2 = np.array(range(1, 14))
y2 = np.array([0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144])

然后在图表上绘制你的结果:

让我们从绘制第一个例子开始:

import matplotlib.pyplot as plot
deg1 = np.polyfit(x1, y1, 1)
# array([ 4.85714286, -2.76923077])
f1 = np.poly1d(deg1)
deg2 = np.polyfit(x1, y1, 2)
# array([-0.03196803, 5.3046953 , -3.88811189])
f2 = np.poly1d(deg2)
deg3 = np.polyfit(x1, y1, 3)
# array([-0.01136364, 0.20666833, 3.91833167, -1.97902098])
f3 = np.poly1d(deg3)
plot.plot(
    x1, y1, 'o',
    x1, f1(x1),
    x1, f2(x1),
    x1, f3(x1)
 )
plot.show()

输出结果如下:

图 3.19:显示第一个数据集的线性曲线

由于系数是按照降序排列的,我们可以看到高阶系数接近于可忽略不计。换句话说,这三条曲线几乎重叠在一起,我们只能在右边缘附近检测到偏差。这是因为我们正在处理一个可以用线性模型很好地近似的数据集。

事实上,第一个数据集是由一个线性函数创建的。对于 x 的平方和 x 的三次方,任何非零系数都是基于可用数据过度拟合模型的结果。与任何更高阶的多项式相比,线性模型更适合预测训练数据范围之外的价值。

让我们对比第二个例子中的这种行为。我们知道斐波那契序列是非线性的。因此,使用线性方程来近似它是一个明显的欠拟合案例。在这里,我们期望更高阶的多项式表现更好:

deg1 = np.polyfit(x2, y2, 1)
# array([ 9.12087912, -34.92307692])
f1 = np.poly1d(deg1)
deg2 = np.polyfit(x2, y2, 2)
# array([ 1.75024975, -15.38261738, 26.33566434])
f2 = np.poly1d(deg2)
deg3 = np.polyfit(x2, y2, 3)
# array([0.2465035, -3.42632368, 14.69080919,
# -15.07692308])
f3 = np.poly1d(deg3)
plot.plot(
    x2, y1, 'o',# blue dots
    x2, f1(x2), # orange
    x2, f2(x2), # green
    x2, f3(x2) # red
)

输出结果如下:

图 3.19:显示第二个数据集点和三个多项式曲线的图表

图 3.20:显示第二个数据集点和三个多项式曲线的图表

差异很明显。二次曲线比线性曲线更好地拟合点。三次曲线甚至更好。

如果你研究比奈公式,你会发现斐波那契函数是一个指数函数,因为第 x 个斐波那契数是计算为常数的第 x 次幂。因此,我们使用的高阶多项式越高,我们的近似就越准确。

多变量多项式回归

当我们有一个 n 次方变量时,方程中有 n+1 个系数:

y = b + a1*x + a2*(x ** 2) + a3*(x ** 3) + … + an * (x ** n)

一旦我们处理了多个特征 x1, x2, …, xm 及其最高 n 次方,我们就会得到一个 m * (n+1)的系数矩阵。一旦我们开始探索细节并证明多项式模型是如何工作的,数学就会变得相当冗长。我们也会失去二维曲线的优美可视化。

因此,我们将应用之前章节中学到的关于一元多项式回归的知识,并省略数学推导。在训练和测试线性回归模型时,我们可以计算均方误差来查看模型近似的好坏。

在 scikit-learn 中,用于近似的多项式的度数是模型中的一个简单参数。

由于多项式回归是线性回归的一种形式,我们可以在不改变回归模型的情况下执行多项式回归。我们所需做的只是转换输入并保持线性回归模型。

输入的转换是通过PolynomialFeatures包的fit_transform方法完成的:

import numpy as np
import quandl
from sklearn import preprocessing
from sklearn import model_selection
from sklearn import linear_model
from matplotlib import pyplot as plot
from sklearn.preprocessing import PolynomialFeatures
data_frame = quandl.get("YALE/SPCOMP")
data_frame.fillna(-100, inplace=True)
# We shift the price data to be predicted 20 years forward
data_frame['Real Price Label'] = data_frame['Real Price'].shift(-240)
# Then exclude the label column from the features
features = np.array(data_frame.drop('Real Price Label', 1))
# We scale before dropping the last 240 rows from the features
scaled_features = preprocessing.scale(features)
# Save the last 240 rows before dropping them
scaled_features_latest_240 = scaled_features[-240:]
# Exclude the last 240 rows from the data used for model building
scaled_features = scaled_features[:-240]
# Now we can drop the last 240 rows from the data frame
data_frame.dropna(inplace=True)
# Then build the labels from the remaining data
label = np.array(data_frame['Real Price Label'])
# Create a polynomial regressor model and fit it to the training data
poly_regressor = PolynomialFeatures(degree=3)
poly_scaled_features = poly_regressor.fit_transform(scaled_features)
# Split to training and testing data
(
    poly_features_train, poly_features_test,
    poly_label_train, poly_label_test
) = model_selection.train_test_split(
    poly_scaled_ features,
    label, test_size=0.1
)
# Apply linear regression
model = linear_model.LinearRegression()
model.fit(poly_features_train, poly_label_train)
# Model score
print('Score: ', model.score(poly_features_test, poly_label_test))
# Prediction
poly_label_predicted = model.predict(poly_features_test)
plot.scatter(poly_label_test, poly_label_predicted)

模型得分过高。很可能,多项式模型对数据集进行了过拟合。

scikit-learn 中还有一个执行多项式回归的模型,称为 SVM 模型,代表支持向量机。

支持向量回归

支持向量机是在向量空间上定义的二分类器。向量机通过一个表面来划分状态空间。支持向量机分类器接收已分类的数据,并尝试预测未分类数据所属的位置。一旦确定数据点的分类,它就会被标记。

支持向量机也可以用于回归。我们不是对数据进行标记,而是可以预测序列中的未来值。支持向量回归模型使用数据之间的空间作为误差范围。基于误差范围,它对未来值做出预测。

如果误差范围太小,我们可能会对现有数据集进行过拟合。如果误差范围太大,我们可能会对现有数据集进行欠拟合。

核函数描述了在分类器情况下划分状态空间的表面。核函数也用于测量回归器中的误差范围。这个核可以使用线性模型、多项式模型或许多其他可能的模型。默认核是RBF,代表径向基函数

支持向量回归是一个高级主题,超出了本书的范围。因此,我们只将介绍如何在测试数据上尝试另一个回归模型的过程。

假设我们的特征和标签分别存储在两个 NumPy 数组中。让我们回顾一下我们是如何对它们执行线性回归的:

from sklearn import model_selection
from sklearn import linear_model
(features_train, features_test, label_train,
 label_test) = model_selection.train_test_split(scaled_features, label, test_size=0.1)
model = linear_model.LinearRegression()
model.fit(features_train, label_train)

我们可以通过将线性模型更改为支持向量模型来使用支持向量机进行回归:

from sklearn import model_selection
from sklearn import svm
from matplotlib import pyplot as plot
# The rest of the model building stays the same
(features_train, features_test, label_train,
 label_test) = model_selection.train_test_split(scaled_features, label, test_size=0.1)
model = svm.SVR()
model.fit(features_train, label_train)
label_predicted = model.predict(features_test)
print('Score: ', model.score(features_test, label_test))
plot.plot(label_test, label_predicted, 'o')

输出如下:

图片

图 3.21:显示使用线性模型的支持向量回归的图表

输出如下:

 -0.19365084431020874

模型得分相当低,点没有对齐在y=x线上。使用默认值进行预测相当低。

模型的输出描述了支持向量机的参数:

SVR(C=1.0, cache_size=200, coef0=0.0, degree=3, epsilon=0.1, gamma='auto', kernel='rbf', max_iter=-1, shrinking=True, tol=0.001, verbose=False)

我们可以通过调整这些参数来通过创建更好的算法来提高预测的准确性。

三次多项式核的支持向量机

让我们将支持向量机的核切换到 poly。多项式的默认次数为 3:

model = svm.SVR(kernel='poly')
model.fit(features_train, label_train)
label_predicted = model.predict(features_test)
plot.plot(label_test, label_predicted, 'o')

输出如下:

图片

图 3.22:显示使用三次多项式核的支持向量回归的图表
model.score(features_test, label_test)

输出如下:

0.06388628722032952

使用支持向量机时,我们经常发现点集中在小区域内。我们可以改变误差范围以使点分离得更开一些。

活动 6:使用多变量二次和三次线性多项式回归预测股价

在本节中,我们将讨论如何使用 scikit-learn 执行线性、多项式和支持向量回归。我们还将学习如何为给定任务找到最佳拟合模型。我们将假设你是金融机构的一名软件工程师,你的雇主想知道线性回归或支持向量回归哪个更适合预测股价。你需要从数据源加载 S&P 500 的所有数据。然后,你需要使用线性回归、三次多项式线性回归和三次多项式核的支持向量回归构建回归器,在分离训练数据和测试数据之前。绘制测试标签和预测结果,并与 y=x 线进行比较。最后,比较三个模型的得分情况。

  1. 使用 Quandl 加载 S&P 500 指数数据,然后准备数据以进行预测。

  2. 使用一次多项式对模型进行评估和预测。

    点越接近 y=x 线,模型的误差就越小。

    使用二次多项式进行线性多重回归。唯一的变化是在线性回归模型中。

  3. 使用三次多项式核进行支持向量回归。

这个模型看起来完全不高效。由于某种原因,这个模型明显更偏好 S&P 500 的较低值,这在假设股市一天内不会损失 80%的价值的情况下是完全不切实际的。

注意

本活动的解决方案可在第 271 页找到。

摘要

在本章中,我们学习了线性回归的基础知识。

在学习了一些基本数学知识后,我们通过一个变量和多个变量深入研究了线性回归的数学。

在回归中遇到的问题包括从外部源(如.csv 文件、Yahoo Finance 或 Quandl)加载数据,这些问题已经得到解决。加载数据后,我们学习了如何识别特征和标签,如何缩放数据以及如何格式化数据以进行回归。

我们学习了如何训练和测试线性回归引擎,以及如何预测未来。我们的结果通过一个易于使用的 Python 图形绘图库pyplot进行可视化。

线性回归的一种更复杂的形式是任意度数的线性多项式回归。我们学习了如何在多个变量上定义这些回归问题。我们比较了它们在股票价格预测问题上的性能。作为多项式回归的替代,我们还介绍了支持向量机作为回归模型,并实验了两种核函数。

你很快就会了解机器学习领域内的另一个分支。这种机器学习方法的设置和代码结构将与回归非常相似,而问题域则有所不同。在下一章中,你将学习分类的方方面面。

第四章:分类

学习目标

到本章结束时,你将能够:

  • 描述分类的基本概念

  • 加载和预处理用于分类的数据

  • 实现 k-最近邻和支持向量机分类器

本章将重点关注分类的目标,并学习 k-最近邻和支持向量机。

4

简介

在本章中,我们将学习关于分类器的内容,特别是 k-最近邻分类器和支持向量机。我们将使用这种分类来对数据进行分类。就像我们对回归所做的那样,我们将基于训练数据构建一个分类器,并使用测试数据来测试我们分类器的性能。

分类基础

当回归关注于创建一个最佳拟合我们数据的模型以预测未来时,分类则是关于创建一个将我们的数据分离成不同类别的模型。

假设你有一些属于不同类别的数据,分类可以帮助你预测新数据点所属的类别。分类器是一个模型,它确定域中任何数据点的标签值。假设你有一组点,P = {p1, p2, p3, ..., pm},以及另一组点,Q = {q1, q2, q3, ..., qn}。你将这些点视为不同类别的成员。为了简单起见,我们可以想象 P 包含值得信赖的个人,而 Q 包含在信用还款倾向方面有风险的个人。

你可以将状态空间划分为所有点都在 P 的一个状态空间簇中,然后与包含 Q 中所有点的状态空间簇不相交。一旦你找到了这些被称为的边界空间,你就在状态空间内成功执行了聚类

假设我们有一个点 x,它不等于任何前面的点。点 x 属于簇 P 还是簇 Q?这个问题的答案是一个分类练习,因为我们正在对点 x 进行分类。

分类通常由邻近性决定。点 x 越接近簇 P 中的点,它属于簇 P 的可能性就越大。这是最近邻分类背后的理念。在 k-最近邻分类的情况下,我们找到点 x 的 k 个最近邻,并根据来自同一类的最近邻的最大数量对其进行分类。除了 k-最近邻,我们还将使用支持向量机进行分类。在本章中,我们将详细介绍这种信用评分方法。

我们可以自己组装随机虚拟数据,或者我们可以选择使用包含数百个数据点的在线数据集。为了使这次学习体验尽可能真实,我们将选择后者。让我们继续进行一个练习,让我们下载一些我们可以用于分类的数据。下载机器学习数据集的一个流行地方是 archive.ics.uci.edu/ml/datasets.html 。您可以在信用批准方面找到五个不同的数据集。现在我们将加载德国信用批准的数据集,因为 1,000 个数据点的规模非常适合示例,并且其文档是可用的。

german 数据集以 CSV 格式提供

CSV 代表逗号分隔值。CSV 文件是一个简单的文本文件,其中文件的每一行都包含数据集中的数据点。数据点的属性以固定顺序给出,由分隔符字符(如逗号)分隔。这个字符可能不会出现在数据中,否则我们就不知道分隔符字符是数据的一部分还是作为分隔符。尽管逗号分隔值这个名字暗示分隔符字符是逗号,但这并不总是如此。例如,在我们的例子中,分隔符字符是空格。CSV 文件在许多应用程序中使用,包括 Excel。CSV 是不同应用程序之间一个非常简洁的接口。

练习 10:加载数据集

  1. 访问 archive.ics.uci.edu/ml/datasets/Statlog+%28German+Credit+Data%29 。数据文件位于 archive.ics.uci.edu/ml/machine-learning-databases/statlog/german/

    从空格分隔的 german.data 文件加载数据。确保您为 DataFrame 添加标题,这样您就可以通过名称而不是列号来引用您的特征和标签。

    german.data 文件保存在本地。将标题数据插入到您的 CSV 文件中。

    数据集的前几行如下:

    A11 6 A34 A43 1169 A65 A75 4 A93 A101 4 A121 67 A143 A152 2 A173 1 A192 A201 1
    A12 48 A32 A43 5951 A61 A73 2 A92 A101 2 A121 22 A143 A152 1 A173 1 A191 A201 2
    A14 12 A34 A46 2096 A61 A74 2 A93 A101 3 A121 49 A143 A152 1 A172 2 A191 A201 1
    

    解释这些数据的说明在 german.doc 文件中,您可以查看属性列表。这些属性包括:现有支票账户状态(A11 – A14)、期限(数值,月数)、信用历史(A30 - A34)、信用目的(A40 – A410)、信用金额(数值)、储蓄账户/债券(A61 – A65)、自就业以来(A71 – A75)、可支配收入百分比(数值)、个人状况和性别(A91 – A95)、其他债务人担保人(A101 – A103)、自居住以来(数值)、财产(A121 – A124)、年龄(数值,年)、其他分期付款计划(A141 – A143)、住房(A151 – A153)、在本银行现有信用数量、工作(A171 – A174)、需要提供赡养费的人数(数值)、电话(A191 – A192)和外国工人(A201 – A202)。

    分类结果如下:1 表示良好债务人,而 2 表示不良债务人

    我们的任务是确定如何将二十个输入变量的状态空间分为两个簇:良好债务人和不良债务人。

  2. 我们将使用 pandas 库来加载数据。不过,在加载数据之前,我建议在german.data文件中添加一个标题。在第一行之前插入以下标题行:

    CheckingAccountStatus DurationMonths CreditHistory CreditPurpose CreditAmount SavingsAccount EmploymentSince DisposableIncomePercent PersonalStatusSex OtherDebtors PresentResidenceMonths Property Age OtherInstallmentPlans Housing NumberOfExistingCreditsInBank Job LiabilityNumberOfPeople Phone ForeignWorker CreditScore
    

    注意,前面的标题只有一行,这意味着直到第 21 个标签CreditScore的末尾都没有换行符。

    注意

    标题很有帮助,因为 pandas 可以将第一行解释为列名。实际上,这是 pandas 的read_csv方法的默认行为。.csv文件的第一行将是标题,其余行是实际数据。

    让我们使用pandas.read_csv方法导入 CSV 数据:

    import pandas
    data_frame = pandas.read_csv('german.data', sep=' ')
    
  3. read_csv的第一个参数是文件路径。例如,如果你将其保存在 Windows PC 的 E 驱动器上,你也可以在那里写一个绝对路径:e:\german.data

图片

图 4.1:显示各自单元格中属性列表的表格

让我们看看数据的格式。data_frame.head()调用打印 CSV 文件的前五行,由 pandas DataFrame 结构化:

data_frame.head()

输出将如下所示:

  CheckingAccountStatus DurationMonths CreditHistory CreditPurpose \
0                 A11             6         A34         A43
..
4                 A11             24         A33         A40
   CreditAmount SavingsAccount EmploymentSince DisposableIncomePercent \
0         1169            A65             A75                        4
..
4         4870            A61             A73                        3
  PersonalStatusSex OtherDebtors     ...     Property Age \
0             A93         A101     ...         A121 67
..
4             A93         A101     ...         A124 53
   OtherInstallmentPlans Housing NumberOfExistingCreditsInBank Job \
0                 A143    A152                             2 A173
..
4                 A143    A153                             2 A173
  LiabilityNumberOfPeople Phone ForeignWorker CreditScore
0                     1 A192         A201         1
..
4                     2 A191         A201         2
[5 rows x 21 columns]

我们已经成功将数据加载到 DataFrame 中。

数据预处理

在构建分类器之前,我们最好将我们的数据格式化,以便我们可以以最适合分类的格式保留相关数据,并删除我们不感兴趣的所有数据。

  1. 替换或删除值

例如,如果数据集中有N/A(或NA)值,我们可能更愿意用我们可以处理的数值替换这些值。NA 代表不可用。我们可能选择忽略包含 NA 值的行,或者用异常值替换它们。异常值是一个像-1,000,000 这样的值,它明显不同于数据集中的常规值。DataFrame 的 replace 方法就是这样进行替换的。用异常值替换 NA 值看起来如下:

data_frame.replace('NA', -1000000, inplace=True)

replace 方法将所有 NA 值更改为数值。

这个数值应该远远超出 DataFrame 中任何合理的值。减去一百万被分类器识别为异常,假设那里只有正值。

用极端值替换不可用数据的替代方法是删除包含不可用数据的行:

data_frame.dropna(0, inplace=True)

第一个参数指定我们删除行,而不是列。第二个参数指定我们执行删除操作,而不克隆 DataFrame。删除 NA 值不太理想,因为你通常会丢失数据集的一部分。

  1. 删除列

如果有一列我们不希望包含在分类中,我们最好将其删除。否则,分类器可能会在完全没有相关性的地方检测到虚假模式。例如,你的电话号码本身与你的信用评分几乎不可能相关。它是一个 9 到 12 位的数字,可能会很容易地向分类器提供大量噪声。因此,我们删除电话列。

data_frame.drop(['Phone'], 1, inplace=True)

第二个参数表示我们删除的是列,而不是行。第一个参数是我们想要删除的列的枚举。inplace 参数是为了让调用修改原始 DataFrame。

  1. 转换数据

通常,我们处理的数据格式并不总是最适合分类过程。我们可能出于多个原因想要将我们的数据转换成不同的格式,例如以下原因:

  • 为了突出我们感兴趣的数据方面(例如,Minmax 缩放或归一化)

  • 为了删除我们不感兴趣的数据方面(例如,二值化)

  • 标签编码

Minmax 缩放可以通过 scikit 预处理工具的 MinMaxScaler 方法执行:

from sklearn import preprocessing
data = np.array([
    [19, 65],
    [4, 52],
    [2, 33]
])
preprocessing.MinMaxScaler(feature_range=(0,1)).fit_transform(data)

输出结果如下:

 array([[1\.        , 1\.        ],
     [0.11764706, 0.59375 ],
     [0\.        , 0\.        ]])

MinMaxScaler 将数据中的每一列缩放,使得列中的最小数变为 0,最大数变为 1,而介于两者之间的所有值按比例缩放到 0 和 1 之间。

二值化根据条件将数据转换为 1 和 0:

preprocessing.Binarizer(threshold=10).transform(data)
array([[1, 1],
     [0, 1],
     [0, 1]])

标签编码对于准备你的特征以便 scikit-learn 处理非常重要。虽然你的某些特征是字符串标签,但 scikit-learn 预期这些数据是数字。

这就是 scikit-learn 预处理库发挥作用的地方。

注意

你可能已经注意到,在信用评分示例中,有两个数据文件。一个包含字符串形式的标签,另一个是整数形式。我故意让你用字符串标签加载数据,这样你就可以获得一些使用标签编码器正确预处理数据的经验。

标签编码不是什么高深的技术。它创建了一个字符串标签和数值之间的映射,这样我们就可以向 scikit-learn 提供数字:

from sklearn import preprocessing
labels = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']
label_encoder = preprocessing.LabelEncoder()
label_encoder.fit(labels)

让我们列举编码方式:

[x for x in enumerate(label_encoder.classes_)]

输出结果如下:

[(0, 'Friday'),
 (1, 'Monday'),
 (2, 'Thursday'),
 (3, 'Tuesday'),
 (4, 'Wednesday')]

我们可以使用编码器来转换值:

encoded_values = label_encoder.transform(['Wednesday', 'Friday'])

输出结果如下:

 array([4, 0], dtype=int64)

将编码值转换回标签的反向转换是通过 inverse_transform 函数执行的:

label_encoder.inverse_transform([0, 4])

输出结果如下:

 array(['Wednesday', 'Friday'], dtype='<U9')

练习 11:数据预处理

在这个练习中,我们将使用 pandas 数据集。

  1. github.com/TrainingByPackt/Artificial-Intelligence-and-Machine-Learning-Fundamentals/blob/master/Lesson04/Exercise%2011%20Pre-processing%20Data/ks-projects-201801.csv加载 2017-2018 年 1 月 Kickstarter 项目的 CSV 数据,并在加载的数据上应用预处理步骤。

    注意

    注意,你需要一个有效的互联网连接才能完成这个练习。

  2. 如果你打开文件,你会看到你不需要麻烦地添加标题,因为它包含在 CSV 文件中:

    ID,name,category,main_category,currency,deadline,goal,launched,pledged,state,backers,country,usd pledged,usd_pledged_real,usd_goal_real
    
  3. 导入数据并使用 pandas 创建 DataFrame:

    import pandas
    data_frame = pandas.read_csv('ks-projects-201801.csv', sep=',')
    data_frame.head()
    
  4. 之前的命令打印了属于数据集的前五个条目。我们可以看到每个列的名称和格式。现在我们有了数据,是时候进行一些预处理步骤了。

  5. 假设数据集中有一些 NA 或 N/A 值。你可以用以下replace操作来替换它们:

       data_frame.replace('NA', -1000000, inplace=True)
       data_frame.replace('N/A', -1000000, inplace=True)
    
  6. 在执行分类或回归时,保留 ID 列只会带来麻烦。在大多数情况下,ID 与最终结果不相关。因此,删除 ID 列是有意义的:

    data_frame.drop(['ID'], 1, inplace=True)
    
  7. 假设我们只对项目是否有支持者感兴趣。这是一个二值化的完美案例:

    from sklearn import preprocessing
    preprocessing.Binarizer(threshold=1).transform([data_frame['backers']])
    
  8. 输出将如下所示:

     array([[0, 1, 1, ..., 0, 1, 1]], dtype=int64)
    

    注意

    我们正在丢弃生成的二进制数组。为了使用二进制数据,我们必须用它来替换支持者列。为了简单起见,我们将省略这一步。

  9. 让我们编码标签,使它们成为可以被分类器解释的数值:

    labels = ['AUD', 'CAD', 'CHF', 'DKK', 'EUR', 'GBP', 'HKD', 'JPY', 'MXN', 'NOK', 'NZD', 'SEK', 'SGD', 'USD']
    label_encoder = preprocessing.LabelEncoder()
    label_encoder.fit(labels)
    label_encoder.transform(data_frame['currency'])
    
  10. 输出将如下所示:

     array([ 5, 13, 13, ..., 13, 13, 13], dtype=int64)
    

你必须了解文件中可能出现的所有可能的标签。文档负责提供可用的选项。在不太可能的情况下,如果你无法获得文档,你必须从文件中逆向工程可能的值。

一旦返回编码后的数组,就与前一点相同的问题:我们必须利用这些值,通过用这些新值替换 DataFrame 中的currency列来使用这些值。

目标列的 Minmax 缩放

当介绍 Minmax 缩放时,你看到的是,不是对矩阵中每个向量的值进行缩放,而是对每个向量中每个坐标的值一起缩放。这就是矩阵结构描述数据集的方式。一个向量包含一个数据点的所有属性。当只缩放一个属性时,我们必须转置我们想要缩放的列。

你在第一章人工智能原理中学习了 NumPy 的转置操作:

import numpy as np
values_to_scale = np.mat([data_frame['goal']]).transpose()

然后,我们必须应用MinMaxScaler来缩放转置的值。为了将结果放在一个数组中,我们可以将结果转置回原始形式:

preprocessing
    .MinMaxScaler(feature_range=(0,1))
    .fit_transform(values_to_scale)
    .transpose()

输出如下所示:

array([[9.999900e-06, 2.999999e-04, 4.499999e-04, ..., 1.499999e-04, 1.499999e-04, 1.999990e-05]])

这些值看起来很奇怪,因为 Kickstarter 上可能有某些目标金额很高,可能使用了七位数。除了线性 Minmax 缩放外,还可以使用幅度和按对数尺度缩放,计算目标价格有多少位数字。这是另一种可能有助于减少分类练习复杂性的转换。

和往常一样,你必须将结果放入 DataFrame 的相应列中,以便使用转换后的值。

我们在这里停止预处理。希望现在对这些不同方法的用法已经清楚,你将能够熟练地使用这些预处理方法。

识别特征和标签

与回归类似,在分类中,我们也必须将我们的特征和标签分开。从原始示例继续,我们的特征是所有列,除了最后一列,它包含信用评分的结果。我们唯一的标签是信用评分列。

我们将使用 NumPy 数组来存储我们的特征和标签:

import numpy as np
features = np.array(data_frame.drop(['CreditScore'], 1))
label = np.array(data_frame['CreditScore'])

现在我们已经准备好了特征和标签,我们可以使用这些数据来进行交叉验证。

使用 scikit-learn 进行交叉验证

关于回归的另一个相关点是,我们可以使用交叉验证来训练和测试我们的模型。这个过程与回归问题的情况完全相同:

from sklearn import model_selection
features_train, features_test, label_train, label_test =
    model_selection.train_test_split(
        features,
        label,
        test_size=0.1
    )

train_test_split方法会打乱数据,然后将我们的特征和标签分为训练数据集和测试数据集。我们可以指定测试数据集的大小为一个介于01之间的数字。test_size0.1表示10%的数据将进入测试数据集。

活动七:为分类准备信用数据

在本节中,我们将讨论如何为分类器准备数据。我们将使用来自archive.ics.uci.edu/ml/machine-learning-databases/statlog/german/german.data作为示例,并将准备数据用于训练和测试分类器。确保所有标签都是数值型,并且值已准备好用于分类。使用 80%的数据点作为训练数据:

  1. archive.ics.uci.edu/ml/machine-learning-databases/statlog/german/中的german.data保存,并在文本编辑器(如 Sublime Text 或 Atom)中打开它。向其中添加标题行。

  2. 使用 pandas 导入数据文件,并用异常值替换 NA 值。

  3. 执行标签编码。将数据框中的所有标签转换为整数。

  4. 将特征与标签分开。我们可以应用我们在理论部分看到的相同方法。

  5. 将训练数据和测试数据进行缩放。使用 Scikit 的预处理库中的MinMaxScaler

  6. 最后一步是交叉验证。打乱我们的数据,并使用 80%的所有数据用于训练,20%用于测试。

    注意

    本活动的解决方案可在第 276 页找到。

K 近邻分类器

我们将从第一主题结束的地方继续。我们有训练数据和测试数据,现在是时候准备我们的分类器以执行 k 近邻分类了。在介绍 K 近邻算法后,我们将使用 scikit-learn 进行分类。

介绍 K 近邻算法

分类算法的目标是将数据分割,以便我们可以确定哪些数据点属于哪个区域。假设给定了一组分类点。我们的任务是确定新数据点属于哪个类别。

K 近邻分类器接收具有给定特征和标签值的数据点的类别。算法的目标是对数据进行分类。这些数据点包含特征坐标,分类的目标是确定标签值。分类基于邻近性。邻近性定义为欧几里得距离。如果点 A 和点 B 之间的欧几里得距离短于点 A 和点 C 之间的欧几里得距离,则点 A 比点 C 更接近点 B

K 近邻分类器获取数据点的 k 个最近邻居。属于点 A 的标签是点 A 的 k 个最近邻居中最频繁出现的标签值。确定 K 的值是一个不明显的问题。显然,如果有两组,例如有信用和无信用,我们需要 K 至少为 3,否则,如果 K=2,我们很容易在邻居数量上出现平局。然而,一般来说,K 的值并不依赖于组数或特征数。

K 近邻的一个特殊情况是当 K=1 时。在这种情况下,分类简化为找到一个点的最近邻居。K=1 通常比 K=3 或更大的结果差得多。

距离函数

许多距离度量可以与 k 近邻算法一起工作。现在我们将计算两个数据点的欧几里得距离和曼哈顿距离。欧几里得距离是我们计算平面或三维空间中两点距离的推广。

A = (a1, a2, …, an) 和点 B=(b1, b2, …, bn) 之间的距离是连接这两个点的线段长度:

图 4.1 点 A 和点 B 之间的距离

图 4.2:点 A 和点 B 之间的距离

从技术上讲,当我们只是寻找最近的邻居时,我们不需要计算平方根,因为平方根是一个单调函数。

由于本书将使用欧几里得距离,让我们看看如何使用一个 scikit-learn 函数调用计算多个点的距离。我们必须从 sklearn.metrics.pairwise 中导入 euclidean_distances。此函数接受两组点,并返回一个矩阵,其中包含每个点与第一组和第二组点的成对距离:

from sklearn.metrics.pairwise import euclidean_distances
points = [[2,3], [3,7], [1,6]]
euclidean_distances([[4,4]], points)

输出如下:

array([[2.23606798, 3.16227766, 3.60555128]])

例如,点(4,4)和(3,7)的距离大约是 3.162。

我们还可以计算同一集合中点之间的欧几里得距离:

euclidean_distances(points)
array([[0\.        , 4.12310563, 3.16227766],
     [4.12310563, 0\.        , 2.23606798],
     [3.16227766, 2.23606798, 0\.        ]])

曼哈顿/汉明距离

汉明距离和曼哈顿距离代表相同的公式。

曼哈顿距离依赖于计算数据点坐标差的绝对值:

图 4.2 曼哈顿和汉明距离

图 4.3:曼哈顿和汉明距离

欧几里得距离是距离的更准确推广,而曼哈顿距离计算起来稍微容易一些。

练习 12:展示 K 最近邻分类器算法

假设我们有一个员工数据列表。我们的特征是每周工作小时数和年薪。我们的标签表示员工是否在我们公司工作超过两年。停留时间用零表示如果少于两年,如果大于或等于两年则用一表示。

我们希望创建一个 3-最近邻分类器,该分类器可以确定员工是否至少在我们公司工作两年。

然后,我们希望使用这个分类器来预测一个每周请求工作 32 小时、年薪 52,000 美元的员工是否会在这家公司工作两年或更长时间。

数据集如下:

employees = [
    [20, 50000, 0],
    [24, 45000, 0],
    [32, 48000, 0],
    [24, 55000, 0],
    [40, 50000, 0],
    [40, 62000, 1],
    [40, 48000, 1],
    [32, 55000, 1],
    [40, 72000, 1],
    [32, 60000, 1]
]
  1. 缩放特征:

    import matplotlib.pyplot as plot
    from sklearn import preprocessing
    import numpy as np
    from sklearn.preprocessing import MinMaxScaler
    scaled_employees = preprocessing.MinMaxScaler(feature_range=(0,1))
        .fit_transform(employees)
    

    缩放后的结果如下:

    array([[0\.        , 0.18518519, 0\.        ],
         [0.2     , 0\.        , 0\.        ],
         [0.6     , 0.11111111, 0\.        ],
         [0.2     , 0.37037037, 0\.        ],
         [1\.        , 0.18518519, 0\.        ],
         [1\.        , 0.62962963, 1\.        ],
         [1\.        , 0.11111111, 1\.        ],
         [0.6     , 0.37037037, 1\.        ],
         [1\.        , 1\.        , 1\.        ],
         [0.6     , 0.55555556, 1\.        ]])
    

    在这个阶段,将我们请求的员工进行缩放也是有意义的:[32, 52000]变为[ (32-24)/(40 - 24), (52000-45000)/(72000 - 45000)] = [0.5, 0.25925925925925924]

  2. 在二维平面上绘制这些点,使得前两个坐标代表平面上的一个点,第三个坐标确定点的颜色:

    import matplotlib.pyplot as plot
    [&#9;
        plot.scatter(x[0], x[1], color = 'g' if x[2] > 0.5 else 'r')
        for x in scaled_employees
    ] + [plot.scatter(0.5, 0.25925925925925924, color='b')]
    

    输出如下:

    图 4.3 在二维平面上绘制的点

    图 4.4:在二维平面上绘制的点
  3. 要计算蓝色点与其他所有点的距离,我们将应用来自第一章,人工智能原理的转置函数。如果我们转置scaledEmployee矩阵,我们得到三个包含十个元素的数组。特征值在前两个数组中。我们可以简单地使用[:2]索引来保留它们。然后,将这个矩阵转置回其原始形式,我们得到特征数据点的数组:

    scaled_employee_features = scaled_employees.transpose()[:2].transpose()
    scaled_employee_features
    

    输出如下:

     array([[0\.        , 0.18518519],
        [0.2     , 0\.        ],
        [0.6     , 0.11111111],
        [0.2     , 0.37037037],
        [1\.        , 0.18518519],
        [1\.        , 0.62962963],
        [1\.        , 0.11111111],
        [0.6     , 0.37037037],
        [1\.        , 1\.        ],
         [0.6     , 0.55555556]])
    
  4. 使用以下方法计算欧几里得距离:

    from sklearn.metrics.pairwise import euclidean_distances
    euclidean_distances(
        [[0.5, 0.25925925925925924]],
        scaled_employee_features
    )
    

    输出如下:

     array([[0.50545719, 0.39650393, 0.17873968, 0.31991511, 0.50545719,
            0.62223325, 0.52148622, 0.14948471, 0.89369841, 0.31271632]])
    

最短的距离如下:

  • 对于点[0.6, 0.37037037, 1.]0.14948471

  • 对于点[0.6, 0.11111111, 0.]0.17873968

  • 对于点[0.6, 0.55555556, 1.]0.31271632

由于三个点中有两个标签为 1,我们发现有两个绿色点和一个红色点。这意味着我们的 3-最近邻分类器将新员工分类为更有可能至少工作两年,而不是完全不工作。

注意

虽然,第四个点仅以非常小的差距错过了前三名。事实上,如果存在两个不同颜色的点,它们与目标点的第三小距离相等,我们的算法就会找到平局。在距离的竞争条件下,可能会有平局。但这是一种边缘情况,几乎不会在实际问题中发生。

练习 13:scikit-learn 中的 k 近邻分类

  1. 将我们的数据分为四个类别:训练测试特征标签

    from sklearn import model_selection
    import pandas
    import numpy as np
    from sklearn import preprocessing
    features_train, features_test, label_train, label_test =
    model_selection.train_test_split(
        scaled_features,
        label,
        test_size=0.2
    )
    
  2. 创建一个 K-近邻分类器来执行此分类:

    from sklearn import neighbors
    classifier = neighbors.KNeighborsClassifier()
    classifier.fit(features_train, label_train)
    

    由于我们没有提到 K 的值,默认是5

  3. 检查我们的分类器在测试数据上的表现如何:

    classifier.score(features_test, label_test)
    

    输出是0.665

您可能会在其他数据集上找到更高的评分,但超过 20 个特征可能很容易包含一些随机噪声,这使得数据分类变得困难。

练习 14:使用 k 近邻分类器进行预测

这段代码是基于之前练习的代码构建的。

  1. 我们将创建一个数据点,我们将通过取第 i 个测试数据点的第 i 个元素来对其进行分类:

    data_point = [None] * 20
    for i in range(20):
        data_point[i] = features_test[i][i]
    data_point = np.array(data_point)
    
  2. 我们有一个一维数组。分类器期望一个包含数据点数组的数组。因此,我们必须将我们的数据点重塑为数据点数组:

    data_point = data_point.reshape(1, -1)
    
  3. 通过这种方式,我们创建了一个完全随机的角色,我们感兴趣的是他们是否被分类为有信用价值:

    credit_rating = classifier.predict(data_point)
    

    现在,我们可以安全地使用预测来确定数据点的信用评级:

    classifier.predict(data_point)
    

    输出如下:

    array([1], dtype=int64)
    

我们已经根据输入数据成功评估了一个新用户。

scikit-learn 中 k 近邻分类器的参数化

您可以在此处访问 k 近邻分类器的文档:scikit-learn.org/stable/modules/generated/sklearn.neighbors.KNeighborsClassifier.html

分类器的参数化可以微调您分类器的准确性。由于我们还没有学习到所有可能的 k 近邻变体,我们将专注于基于这个主题您已经理解的参数。

n_neighbors:这是 k 近邻算法的 k 值。默认值是 5。

metric:在创建分类器时,您会看到一个奇怪的名字——“Minkowski”。不要担心这个名字——您已经学过第一和第二阶的 Minkowski 度量。这个度量有一个幂参数。对于p=1,Minkowski 度量与曼哈顿度量相同。对于p=2,Minkowski 度量与欧几里得度量相同。

p:这是 Minkowski 度量的幂。默认值是 2。

在创建分类器时,您必须指定这些参数:

classifier = neighbors.KNeighborsClassifier(n_neighbors=50)

活动 8:提高信用评分的准确性

在本节中,我们将学习 k 近邻分类器的参数化如何影响最终结果。信用评分的准确率目前相当低:66.5%。找到一种方法将其提高几个百分点。为确保正确实现,你需要完成之前的练习。

完成这项练习有许多方法。在这个解决方案中,我将向你展示一种提高信用评分的方法,这将通过改变参数化来实现:

  1. 将 k 近邻分类器的 k 值从默认的 5 增加到 10、15、25 和 50。

  2. 对所有四个n_neighbors值运行此分类器并观察结果。

  3. 较高的 K 值并不一定意味着更好的分数。然而,在这个例子中,K=50K=5得到了更好的结果。

    注意

    本活动的解决方案可在第 280 页找到。

支持向量机分类

我们在第三章回归中首次使用支持向量机进行回归。在本主题中,你将了解如何使用支持向量机进行分类。一如既往,我们将使用 scikit-learn 在实践中的例子中运行我们的示例。

支持向量机分类器是什么?

在 n 维向量空间上定义的支持向量机的目标是找到该 n 维空间中的一个表面,将空间中的数据点分成多个类别。

在二维空间中,这个表面通常是直线。在三维空间中,支持向量机通常找到一个平面。一般来说,支持向量机找到一个超平面。这些表面在优化意义上是最佳的,因为基于机器可用的信息,它优化了 n 维空间的分隔。

支持向量机找到的最佳分隔表面被称为最佳分隔超平面

支持向量机用于找到将两组数据点分开的一个表面。换句话说,支持向量机是二元分类器。这并不意味着支持向量机只能用于二元分类。尽管我们只讨论了一个平面,但支持向量机可以通过泛化任务本身来将空间划分为任意数量的类别。

分隔表面在优化意义上是最佳的,因为它最大化了每个数据点到分隔表面的距离。

向量是在具有大小(长度)和方向的 n 维空间上定义的数学结构。在二维空间中,你从原点绘制向量(x, y)到点(x, y)。基于几何学,你可以使用勾股定理计算向量的长度,并通过计算水平轴与向量之间的角度来确定向量的方向。

例如,在二维空间中,向量(3, -4)具有以下长度:

sqrt( 3 * 3 + 4 * 4 ) = sqrt( 25 ) = 5

它具有以下方向:

np.arctan(-4/3) / 2 / np.pi * 360 = -53.13010235415597 度

理解支持向量机

假设有两组点,红色蓝色。为了简单起见,我们可以想象一个二维平面,具有两个特征:一个映射在水平轴上,另一个映射在垂直轴上。

支持向量机的目标是找到最佳分离线,将点ADCBH与点EFG分开:

图 4.4 分离红色和蓝色成员的线

图 4.5:分离红色和蓝色成员的线

分离并不总是那么明显。例如,如果在 E、F 和 G 之间有一个蓝色点,就没有一条线可以无误差地分离所有点。如果蓝色类中的点围绕红色类中的点形成一个完整的圆,就没有一条直线可以分离这两个集合:

图 4.5 包含两个异常点的图

图 4.6:包含两个异常点的图

例如,在前面的图中,我们容忍两个异常点,O 和 P。

在以下解决方案中,我们不容忍异常值,而不是用一条线,我们创建由两条半线组成的最优分离路径:

图 4.6 移除两个异常点的分离图

图 4.7:移除两个异常点的分离图

完美分离所有数据点很少值得投入资源。因此,支持向量机可以被正则化以简化并限制最优分离形状的定义,并允许异常值。

支持向量机的正则化参数决定了允许或禁止误分类的错误率。

支持向量机有一个核参数。线性核严格使用线性方程来描述最优分离超平面。多项式核使用多项式,而指数核使用指数表达式来描述超平面。

边距是围绕分离器的区域,由最接近分离器的点界定。平衡边距有来自每个类的点,它们与线的距离相等。

当涉及到定义最优分离超平面的允许错误率时,一个 gamma 参数决定了是否只有靠近分离器的点在确定分离器位置时计数,或者是否连最远离线的点也要计数。gamma 值越高,影响分离器位置的点就越少。

scikit-learn 中的支持向量机

我们的起点是先前活动的最终结果。一旦我们分割了训练数据和测试数据,我们就可以设置分类器:

features_train, features_test, label_train, label_test = model_selection
    .train_test_split(
        scaled_features,
        label,
        test_size=0.2
    )

我们将使用svm.SVC()分类器而不是使用 K-最近邻分类器:

from sklearn import svm
classifier = svm.SVC()
classifier.fit(features_train, label_train)
# Let's can check how well our classifier performs on the
# test data:
classifier.score(features_test, label_test)

输出是0.745

看起来,scikit-learn 的默认支持向量机分类器比 k 最近邻分类器做得稍微好一些。

scikit-learn SVM 的参数

以下为 scikit-learn SVM 的参数:

Kernel:这是一个字符串或可调用参数,用于指定算法中使用的核。预定义的核有线性、poly、rbf、sigmoid 和预计算。默认值为 rbf。

Degree:当使用多项式时,你可以指定多项式的次数。默认值为 3。

Gamma:这是 rbf、poly 和 sigmoid 核的系数。默认值为 auto,计算为number_of_features的倒数。

C:这是一个默认为 1.0 的浮点数,描述了误差项的惩罚参数。

你可以在scikit-learn.org/stable/modules/generated/sklearn.svm.SVC.html的参考文档中阅读关于其余参数的内容。

这里是一个 SVM 的例子:

classifier = svm.SVC(kernel="poly", C=2, degree=4, gamma=0.05)

活动 9:scikit-learn 中的支持向量机优化

在本节中,我们将讨论如何使用支持向量机分类器的不同参数。我们将使用、比较和对比你已学到的不同支持向量回归分类器参数,并找到一组参数,在之前活动中加载和准备的训练和测试数据上获得最高的分类数据。为了确保你能完成这个活动,你需要完成本章的第一个活动。

我们将尝试几种组合。你可能需要选择不同的参数并检查结果:

  1. 让我们先选择线性核,并检查分类器的拟合和评分。

  2. 完成这些后,选择四次方的多项式核,C=2,gamma=0.05,并检查分类器的拟合和评分。

  3. 然后,选择四次方的多项式核,C=2,gamma=0.25,并检查分类器的拟合和评分。

  4. 之后,选择四次方的多项式核,C=2,gamma=0.5,并检查分类器的拟合和评分。

  5. 选择下一个分类器为 sigmoid 核。

  6. 最后,选择默认核,gamma=0.15,并检查分类器的拟合和评分。

    注意

    本活动的解决方案可以在第 280 页找到。

摘要

在本章中,我们学习了分类的基础知识。在发现分类的目标、加载数据和格式化数据后,我们发现了两种分类算法:K-最近邻和支持向量机。我们使用基于这两种方法的自定义分类器来预测值。在下一章中,我们将使用树进行预测分析。

第五章:使用树进行预测分析

学习目标

到本章结束时,你将能够:

  • 理解用于评估数据模型效用度的指标

  • 根据决策树对数据点进行分类

  • 根据随机森林算法对数据点进行分类

在本章中,我们将详细介绍两种监督学习算法。第一个算法将帮助我们使用决策树对数据点进行分类,而另一个算法将帮助我们使用随机森林进行分类。

决策树简介

在决策树中,我们在训练数据中有输入和相应的输出。决策树,就像任何树一样,有叶子、分支和节点。叶子是像是是或否的终端节点。节点是做出决策的地方。决策树由我们用来对数据点的预测做出决策的规则组成。

决策树的每个节点代表一个特征,每个从内部节点出来的边代表树的可能值或可能值的区间。树的每个叶子代表树的标签值。

如我们在前几章所学,数据点有特征和标签。决策树的任务是根据固定规则预测标签值。规则来自观察训练数据上的模式。

让我们考虑一个确定标签值的例子

假设以下训练数据集给出。制定规则以帮助你确定标签值:

图片

图 5.1:制定规则的数据库

在这个例子中,我们根据四个特征预测标签值。为了设置决策树,我们必须对可用数据进行观察。基于我们可用的数据,我们可以得出以下结论:

  • 所有房屋贷款都被认定为有信用资格。

  • 只要债务人就业,助学贷款就有信用资格。如果债务人没有就业,他就/她没有信用资格。

  • 年收入超过 75,000 元的贷款有信用资格。

  • 在 75,000 元/年或以下,如果债务人没有就业,汽车贷款就有信用资格。

根据我们考虑这些规则的顺序,我们可以构建一棵树并描述一种可能的信用评分方式。例如,以下树映射了前面的四个规则:

图片

图 5.2:贷款类型的决策树

我们首先确定贷款类型。根据第一条规则,房屋贷款自动具有信用资格。助学贷款由第二条规则描述,导致包含另一个关于就业决策的子树。因为我们已经涵盖了房屋和助学贷款,只剩下汽车贷款。第三条规则描述了收入决策,而第四条规则描述了就业决策。

每当我们需要评分一个新债务人以确定他/她是否有信用资格时,我们必须从决策树顶部到底部进行遍历,并观察底部的真或假值。

显然,基于七个数据点的模型非常不准确,因为我们可以概括出一些根本不符合现实的规则。因此,规则通常是基于大量数据确定的。

这不是创建决策树的唯一方法。我们也可以根据其他规则的序列构建决策树。让我们从图 5.1 中的数据集中提取一些其他规则。

观察 1:注意,严格大于 75,000 的个体工资都是有信用资格的。这意味着我们可以用一个决策将七个数据点中的四个进行分类。

规则 1:收入> 75,000 => 有信用资格is true

规则 1将七个数据点中的四个进行了分类;我们需要为剩下的三个数据点制定更多规则。

观察 2:在剩下的三个数据点中,有两个不是有工作的。有一个是有工作但无信用资格的。通过一个模糊的概括,我们可以提出以下规则:

规则 2:假设收入<= 75,000,以下成立:有工作== true => 有信用资格is false

前两条规则对五个数据点进行了分类。只剩下两个数据点。我们知道他们的收入小于或等于 75,000,并且他们都没有工作。尽管如此,他们之间还是有一些差异:

  • 有信用资格的人收入为 75,000,而没有信用资格的人收入为 25,000。

  • 有信用资格的人借了汽车贷款,而没有信用资格的人借了学习贷款。

  • 有信用资格的人借了 30,000 元,而没有信用资格的人借了 15,000 元。

这些差异中的任何一个都可以提取成规则。对于离散的范围,如汽车、学习和房屋,规则是一个简单的成员资格检查。在连续范围的情况下,如工资和贷款金额,我们需要确定一个范围来分支。

假设我们选择了贷款金额作为我们的第三条规则的依据。

规则 3

假设Income <= 75,000Employed == false

如果LoanAmount <= AMOUNT

然后CreditWorthyfalse

否则CreditWorthytrue

第一行描述了导致这个决策的路径。第二行制定了条件,最后两行描述了结果。

注意,规则中有一个恒定的数量。这个数量应该是多少?

答案是,在 15,000 <= AMOUNT < 30,000 的范围内,任何数字都是可以的。我们可以自由选择任何数字。在这个例子中,我们选择了范围的低端:

图 5.3:收入决策树

第二个决策树更简单。同时,我们不能忽视模型所说的,“较高的贷款金额比较低的贷款金额更有可能被偿还。”同样,我们也无法忽视这样一个事实,即收入较低的有工作的人从未偿还过他们的贷款。不幸的是,可用的训练数据不足,这使得我们最终得出错误结论的可能性很大。

在决策树中,基于少量数据点做出决策时,过拟合是一个常见问题。这个决策很少具有代表性。

由于我们可以在任何可能的顺序中构建决策树,因此定义算法构建决策树所需的方式是有意义的。因此,我们现在将探索一个用于在决策过程中最优排序特征的良好度量。

在信息论中,熵衡量一个属性的可能的值分布的随机性。随机性程度越高,属性的熵就越高。

熵是事件最高可能性。如果我们事先知道结果将会是什么,那么事件就没有随机性。因此,熵为零。

在测量要分类的系统的熵时,我们测量标签的熵。

熵被定义为如下:

  • [v1, v2, ..., vn] 是一个属性的可能的值

  • [p1, p2, ..., pn] 是这些值对于该属性的假设概率,假设这些值是均匀分布的

  • p1 + p2 + ... + pn = 1 图片 Image00052.jpg

图 5.4:熵公式

在信息论中,熵的符号是 H。不是因为熵与 h 声音有什么关系,而是因为 H 是大写希腊字母 eta 的符号。Eta 是熵的符号。

注意

我们使用熵对决策树中的节点进行排序,因为熵越低,其值的分布就越不随机。分布中的随机性越少,确定标签值的可能性就越大。

要在 Python 中计算分布的熵,我们必须使用 NumPy 库:

import numpy as np
distribution = list(range(1,4))
minus_distribution = [-x for x in distribution]
log_distribution = [x for x in map(np.log2,distribution)]
entropy_value = np.dot(minus_distribution, log_distribution)

分布以 NumPy 数组或常规列表的形式给出。在第 2 行,你必须将你自己的分布 [p1, p2, …, pn] 插入其中。

我们需要创建一个包含第 3 行分布的负值的向量。

在第 4 行,我们必须对分布列表中的每个值取以 2 为底的对数

最后,我们通过标量积(也称为两个向量的点积)来计算总和。

让我们将前面的计算以函数的形式定义:

def entropy(distribution):
    minus_distribution = [-x for x in distribution]
    log_distribution = [x for x in map(np.log2, distribution)]
    return np.dot(minus_distribution, log_distribution)

注意

你首先在 第三章回归 中了解了点积。两个向量的点积是通过将第一个向量的第 i 个坐标乘以第二个向量的第 i 个坐标来计算的,对于每个 i。一旦我们有了所有的乘积,我们求和这些值:

np.dot([1, 2, 3], [4, 5, 6]) # 1*4 + 2*5 + 3*6 = 32

练习 15:计算熵

计算数据集 图 5.1 中特征的熵。

我们将为所有特征计算熵。

  1. 我们有四个特征:EmployedIncomeLoanType,和 LoanAmount。为了简单起见,我们现在将 IncomeLoanAmount 中的值视为离散值。

  2. 下表是 Employed 的值分布:

    true 4/7 times

    false 3/7 times

  3. 让我们使用熵函数来计算 Employed 列的熵:

    H_employed = entropy([4/7, 3/7])
    

    输出结果是 0.9852

  4. 以下是对 Income 的值分布:

    25,000 1/7 次

    75,000 2/7 次

    80,000 1/7 次

    100,000 2/7 次

    125,000 1/7 次

  5. 让我们使用熵函数来计算 Income 列的熵:

    H_income = entropy([1/7, 2/7, 1/7, 2/7, 1/7])
    

    输出结果是 2.2359

  6. 以下是对 LoanType 的值分布:

    car 3/7 次

    studies 2/7 次

    house 2/7 次

  7. 让我们使用熵函数来计算 LoanType 列的熵:

    H_loanType = entropy([3/7, 2/7, 2/7])
    

    输出结果是 1.5567

  8. 以下是对 LoanAmount 的值分布:

    15,000 1/7 次

    25,000 1/7 次

    30,000 3/7 次

    125,000 1/7 次

    150,000 1/7 次

  9. 让我们使用熵函数来计算 LoanAmount 列的熵:

    H_LoanAmount = entropy([1/7, 1/7, 3/7, 1/7, 1/7])
    

    输出结果是 2.1281

  10. 如您所见,分布越接近均匀分布,熵就越高。

  11. 在这个练习中,我们有点作弊,因为这些不是我们将用于构建树的熵。在两个树中,我们都有像“大于 75,000”这样的条件。因此,我们将计算我们在原始树中使用的决策点的熵。

  12. 以下是对 Income>75000 的值分布:

    true 4/7 次

    false 3/7 次

  13. 让我们使用熵函数来计算 Income>75,000 列的熵:

    H_incomeMoreThan75K = entropy([4/7, 3/7])
    

    输出结果是 0.9852

  14. 以下是对 LoanAmount>15000 的值分布:

    true 6/7 次

    false 1/7 次

  15. 让我们使用熵函数来计算 LoanAmount 的熵:

    15,000 列:

    H_loanMoreThan15K = entropy([6/7, 1/7])
    

    输出结果是 0.5917

直观地说,分布 [1] 是最确定的分布。这是因为我们知道一个事实,即特征值保持固定的概率是 100%。

H([1]) = 1 * np.log2( 1 ) # 1*0 =0

我们可以得出结论,分布的熵是严格非负的。

信息增益

当我们根据属性的值划分数据集中的数据点时,我们降低了系统的熵。

要描述信息增益,我们可以计算标签的分布。最初,在我们的数据集中有五个有信用和两个无信用的人。初始分布的熵如下:

H_label = entropy([5/7, 2/7])
0.863120568566631

让我们看看如果我们根据贷款金额是否大于 15,000 来划分数据集会发生什么。

  • 在第 1 组中,我们得到一个属于 15,000 贷款金额的数据点。这个数据点无信用。

  • 在第 2 组中,我们有 5 个有信用和 1 个无信用的人。

每个组中标签的熵如下:

H_group1 = entropy([1]) #0
H_group2 = entropy([5/6, 1/6]) #0.65

要计算信息增益,让我们计算组熵的加权平均值:

H_group1 * 1/7 + H_group2 * 6/7 #0.55
Information_gain = 0.8631 – 0.5572 #0.30

在创建决策树时,在每个节点上,我们的任务是使用最大化信息增益的规则来划分数据集。

我们也可以使用 Gini 不纯度代替基于熵的信息增益来构建决策树的最佳分割规则。

Gini 不纯度

除了熵之外,还有一个广泛使用的指标可以用来衡量分布的随机性:Gini 不纯度。

Gini 不纯度定义为如下:

图片

图 5.5:Gini 不纯度

对于两个变量,Gini 不纯度为:

图片

图 5.6:两个变量的 Gini 不纯度

由于使用了对数,熵的计算可能稍微慢一些。另一方面,在衡量随机性方面,Gini 不纯度不够精确。

注意

信息增益与熵或 Gini 不纯度哪一个更适合创建决策树?

有些人更喜欢 Gini 不纯度,因为不需要计算对数。从计算的角度来看,这些解决方案都不特别复杂,因此两者都可以使用。在性能方面,以下研究得出结论,这两个指标之间通常只有微小的差异:www.unine.ch/files/live/sites/imi/files/shared/documents/papers/Gini_index_fulltext.pdf

我们已经了解到我们可以根据信息增益或 Gini 不纯度来优化决策树。不幸的是,这些指标仅适用于离散值。如果标签定义在连续区间,比如价格范围或薪资范围,怎么办呢?

我们必须使用其他指标。从技术上讲,你可以理解基于连续标签创建决策树的想法,这涉及到回归。我们可以从本章重用的指标是均方误差。我们不仅要最小化 Gini 不纯度或信息增益,还要最小化均方误差来优化决策树。由于这是一本入门书籍,我们将省略此指标。

退出条件

我们可以根据规则值连续分割数据点,直到决策树的每个叶子节点熵为零。问题是这种最终状态是否可取。

通常,这种状态并不理想,因为我们可能会过度拟合模型。当我们的模型规则过于具体和过于挑剔,并且决策所依据的样本量太小,我们可能会得出错误的结论,从而在数据集中识别出在现实生活中并不存在的模式。

例如,如果我们旋转轮盘三次,得到 12、25、12,得出每次奇数次旋转的结果都是 12 的结论,这不是一个明智的策略。通过假设每次奇数次旋转等于 12,我们发现了一个仅由随机噪声产生的规则。

因此,对我们可以分割的数据集的最小大小的限制是一个在实践中效果很好的退出条件。例如,如果你在数据集大小低于 50、100、200 或 500 时停止分割,你就可以避免在随机噪声上得出结论,从而最小化模型过拟合的风险。

另一个流行的退出条件是对树深度的最大限制。一旦我们达到固定的树深度,我们就在叶子节点上对数据点进行分类。

使用 scikit-learn 构建决策树分类器

我们已经学习了如何从.csv文件加载数据,如何对数据进行预处理,以及如何将数据分割成训练集和测试集。如果你需要复习这方面的知识,请回到前面的章节,在那里你会在回归和分类的上下文中进行这个过程。

现在,我们将假设一组训练特征、训练标签、测试特征和测试标签作为 scikit-learn train-test-split 调用的返回值。

注意,在 scikit-learn 的旧版本中,你必须导入 cross_validation 而不是 model selection:

features_train, features_test, label_train, label_test =
    model_selection.train_test_split(
        features,
        label,
        test_size=0.1
    )

我们不会关注我们如何得到这些数据点,因为过程与回归和分类的情况完全相同。

是时候导入并使用 scikit-learn 的决策树分类器了:

from sklearn.tree import DecisionTreeClassifier
decision_tree = DecisionTreeClassifier(max_depth=6)
decision_tree.fit( features_train, label_train )

我们在DecisionTreeClassifier中设置了一个可选参数,即max_depth,以限制决策树的深度。你可以阅读官方文档以获取参数的完整列表:scikit-learn.org/stable/modules/generated/sklearn.tree.DecisionTreeClassifier.html。一些更重要的参数如下:

  • criterion:Gini 代表基尼不纯度,而 entropy 代表信息增益。

  • max_depth:这是树的最大深度。

  • min_samples_split:这是分割内部节点所需的最小样本数。

你还可以在文档中列举的所有其他参数上进行实验。我们将在本主题中省略它们。

模型构建完成后,我们可以使用决策树分类器来预测数据:

decision_tree.predict(features_test)

你将在本主题末尾的活动构建一个决策树分类器。

评估分类器的性能

在分割训练和测试数据后,决策树模型有一个score方法来评估测试数据被模型分类得有多好。我们已经在第三章和第四章中学习了如何使用score方法:

decision_tree.score(features_test, label_test)

score方法的返回值是一个小于或等于 1 的数字。我们越接近 1,我们的模型就越好。

现在,我们将学习另一种评估模型的方法。你也可以在上一章中构建的模型上使用这种方法。

假设我们有一个测试特征和一个测试标签:

# testLabel denotes the test label
predicted_label = decision_tree.predict(testFeature)

假设我们正在调查一个标签值,positiveValue。

我们将使用以下定义来定义一些指标,帮助您评估您的分类器有多好:

  • 定义(真正阳性)positiveValue == predictedLabel == testLabel

  • 定义(真正阴性)positiveValue != predictedLabel == testLabel

  • 定义(假阳性)positiveValue == predictedLabel != testLabel

  • 定义(假阴性)positiveValue != predictedLabel != testLabel

假阳性是指预测结果等于正值,但测试数据中的实际标签并不等于这个正值。例如,在技术面试中,假阳性是指一个能力不足的软件开发者因为表现得很令人信服而被录用,隐藏了他完全缺乏的能力。

不要将假阳性与假阴性混淆。使用技术面试的例子,假阴性是指一个有能力完成工作的软件开发者,但他没有得到录用。

使用前面的四个定义,我们可以定义三个指标来描述我们的模型如何预测现实。符号 #( X ) 表示 X 中值的数量。使用技术术语,#( X ) 表示 X 的基数:

定义(精度)

#( 真正值 ) / (#( 真正值 ) + #( 假阳性值 ))

定义(召回率):

#( 真正值 ) / (#( 真正值 ) + #( 假阴性值 ))

精度关注的是我们的分类器找到的正值。其中一些结果是真正的正值,而其他结果是假阳性。高精度意味着与真正的正值相比,假阳性结果的数量非常低。这意味着一个精确的分类器在寻找正值时很少出错。

召回率关注的是测试数据中值为正的部分。其中一些结果是由分类器找到的。这些是真正的正值。那些未被分类器找到的正值是假阴性。具有高召回率的分类器能找到大多数正值。

练习 16:精度和召回率

找出以下两个分类器的精度和召回率值:

# Classifier 1
TestLabels1 = [True, True, False, True, True]
PredictedLabels1 = [True, False, False, False, False]
# Classifier 2
TestLabels2 = [True, True, False, True, True]
PredictedLabels = [True, True, True, True, True]
  1. 根据公式,让我们计算分类器 1 中真正的正值、假阳性和假阴性的数量:

    TruePositives1 = 1 # both the predicted and test labels are true
    FalsePositives1 = 0 # predicted label is true, test label is false
    FalseNegatives1 = 3 # predicted label is false, test label is true
    Precision1 = TruePositives1 / (TruePositives1 + FalsePositives1)
    Precision1 # 1/1 = 1
    Recall1 = TruePositives1 / (TruePositives1 + FalseNegatives1)
    Recall1 #1/4 = 0.25
    
  2. 第一个分类器具有出色的精度,但召回率不佳。让我们为第二个分类器计算相同的结果。

    TruePositives2 = 4
    FalsePositives2 = 1
    FalseNegatives2 = 0
    Precision2 = TruePositives2 / (TruePositives2 + FalsePositives2) Precision2 #4/5 = 0.8
    Recall2 = TruePositives2 / (TruePositives2 + FalseNegatives2)
    Recall2 # 4/4 = 1
    
  3. 第二个分类器具有出色的召回率,但其精度并不完美。

  4. F1 分数是精度和召回率的调和平均值。其值介于 0 和 1 之间。F1 分数的优势在于它考虑了假阳性和假阴性。

练习 17:计算 F1 分数

计算前一个练习中两个分类器的 F1 分数:

  1. 计算 F1 分数的公式如下:

    2*Precision*Recall / (Precision + Recall)
    
  2. 第一个分类器的 F1 分数如下:

    2 * 1 * 0.25 / (1 + 0.25) # 0.4
    
  3. 第二个分类器的 F1 分数如下:

    2 * 0.8 * 1 / (0.8 + 1) # 0.888888888888889
    

现在我们已经知道了精确率、召回率和 F1 分数的含义,让我们使用 scikit-learn 实用工具来计算并打印这些值:

from sklearn.metrics import classification_report
print(
    classification_report(
        label_test,
        decision_tree.predict(features_test)
    )
)

输出结果将如下所示:

             precision    recall f1-score support
          0     0.97     0.97     0.97        36
          1     1.00     1.00     1.00         5
          2     1.00     0.99     1.00     127
          3     0.83     1.00     0.91         5
avg / total     0.99     0.99     0.99     173

在这个例子中,有四个可能的标签值,分别用 0、1、2 和 3 表示。在每一行中,你得到属于每个可能标签值的精确率、召回率和 F1 分数值。你还可以在支持列中看到这些标签值在数据集中存在的数量。最后一行包含汇总的精确率、召回率和 f1 分数。

如果你使用标签编码将字符串标签编码为数字,你可能想要执行逆变换以找出哪些行属于哪个标签。在以下示例中,Class 是标签的名称,而 labelEncoders['Class'] 是属于 Class 标签的标签编码器:

labelEncoders['Class'].inverse_transform([0, 1, 2, 3])
array(['acc', 'good', 'unacc', 'vgood'])

如果你更喜欢单独计算精确率、召回率和 F1 分数,你可以使用单独的调用。请注意,在下一个示例中,我们将对每个分数函数调用两次:一次使用 average=None ,另一次使用 average='weighted'

当平均指定为 None 时,我们得到属于每个可能标签值的分数值。如果你将结果与对应列的前四个值进行比较,你可以看到相同的值四舍五入在表中。

当平均指定为加权时,你得到属于分数名称列的单元格值和 avg/total 行:

from sklearn.metrics import recall_score, precision_score, f1_score
label_predicted = decision_tree.predict(features_test)

使用无平均计算精确率分数可以这样进行:

precision_score(label_test, label_predicted, average=None)

输出结果如下:

 array([0.97222222, 1\.        , 1\.        , 0.83333333])

使用加权平均计算精确率分数可以这样进行:

precision_score(label_test, label_predicted, average='weighted')

输出结果为 0.989402697495183

使用无平均计算召回率分数可以这样进行:

recall_score(label_test, label_predicted, average=None)

输出结果如下:

 array([0.97222222, 1\.        , 0.99212598, 1\.        ])

使用加权平均计算召回率分数可以这样进行:

recall_score(label_test, label_predicted, average='weighted')

输出结果为 0.9884393063583815

使用无平均计算 f1_score 可以这样进行:

f1_score(label_test, label_predicted, average=None)

输出结果如下:

 array([0.97222222, 1\.        , 0.99604743, 0.90909091])

使用加权平均计算 f1_score 可以这样进行:

f1_score(label_test, label_predicted, average='weighted')

输出结果为 0.988690625785373

值得进一步研究的一个分数是准确率分数。假设 #( Dataset ) 表示总数据集的长度,换句话说,是真正例、真负例、假正例和假负例的总和。

准确率的定义如下:

定义(准确率): #( 真正例 ) + #( 真负例 ) / #( 数据集 )

准确率是一个用于确定分类器给出正确答案次数的指标。这是我们用来评估分类器分数的第一个指标。每次我们调用分类器模型的分数方法时,我们都计算其准确率:

from sklearn.metrics import accuracy_score
accuracy_score(label_test, label_predicted )

输出结果为 0.9884393063583815

计算决策树分数可以这样进行:

decisionTree.score(features_test, label_test)

输出结果为 0.9884393063583815

混淆矩阵

我们将用一个有助于评估分类模型性能的数据结构来结束这个主题:混淆矩阵。

混淆矩阵是一个方阵,其中行数和列数等于不同标签值的数量。在矩阵的列中,我们放置每个测试标签值。在矩阵的行中,我们放置每个预测标签值。

对于每个数据点,我们根据预测的实际标签值,将其加到混淆矩阵的相应单元格中。

练习 18:混淆矩阵

构建以下两个分布的混淆矩阵:

# Classifier 1
TestLabels1 = [True, True, False, True, True]
PredictedLabels1 = [True, False, False, False, False]
# Classifier 2
TestLabels2 = [True, True, False, True, True]
PredictedLabels = [True, True, True, True, True]
  1. 我们将从第一个分类器开始。列确定测试标签的位置,而行确定预测标签的位置。第一个条目是TestLabels1[0]PredictedLabels1[0]。这些值都是真实的,所以我们将其加到左上角的列中。

  2. 第二个值是TestLabels1[1] = TruePredictedLabels1[1] = False。这些值决定了 2x2 矩阵的左下角单元格。

  3. 在完成所有五个标签对的放置后,我们得到以下混淆矩阵:

           True False
    True     1     0
    False     3     1
    
  4. 在完成所有五个标签对的放置后,我们得到以下混淆矩阵:

           True False
    True     4     1
    False     0     0
    
  5. 在一个 2x2 矩阵中,我们有以下分布:

                    True            False
    True    TruePositives FalsePositives
    False FalseNegatives TrueNegatives    
    
  6. 混淆矩阵可以用来计算精确率、召回率、准确率和 f1_score 指标。计算是直接的,并且由指标的定义隐含。

  7. 混淆矩阵可以通过 scikit-learn 计算:

    from sklearn.metrics import confusion_matrix
    confusion_matrix(label_test, label_predicted)
    array([[ 25, 0, 11, 0],
         [ 5, 0, 0, 0],
         [ 0, 0, 127, 0],
           [ 5, 0, 0, 0]])
    
  8. 注意,这并不是我们在上一节中使用过的相同示例。因此,如果你使用混淆矩阵中的值,你将得到不同的精确率、召回率和 f1_score 值。

  9. 你也可以使用 pandas 创建混淆矩阵:

    import pandas
    pandas.crosstab(label_test, label_predicted)
    col_0 0    2
    row_0        
    0     25 11
    1     5    0
    2     0 127
    3     5    0
    

让我们通过计算模型的准确率来验证这些值:

  • 我们有 127 + 25 = 152 个被正确分类的数据点。

  • 数据点的总数是 152 + 11 + 5 + 5 = 173。

  • 152/173 是 0.8786127167630058。

让我们使用之前使用的 scikit-learn 工具来计算准确率:

from sklearn.metrics import accuracy_score
accuracy_score(label_test, label_predicted)

输出如下:

0.8786127167630058

我们得到了相同的价值。所有指标都可以从混淆矩阵中推导出来。

活动 10:汽车数据分类

在本节中,我们将讨论如何构建一个可靠的决策树模型,该模型能够帮助你的公司在寻找客户可能购买的汽车方面发挥作用。我们将假设你受雇于一家汽车租赁代理机构,该机构专注于与客户建立长期关系。你的任务是构建一个决策树模型,将汽车分类为以下四个类别之一:不可接受、可接受、良好和非常好。

本活动的数据集可以在此处访问:archive.ics.uci.edu/ml/datasets/Car+Evaluation。点击数据文件夹链接下载数据集。然后,点击数据集描述链接以访问属性描述。

让我们来评估你的决策树模型的效用:

  1. 从这里下载汽车数据文件:archive.ics.uci.edu/ml/machine-learning-databases/car/car.data。在 CSV 文件的前面添加一个标题行,这样你就可以在 Python 中轻松引用它。我们简单地将标签称为 Class。我们根据archive.ics.uci.edu/ml/machine-learning-databases/car/car.names中的描述命名了六个特征。

  2. 将数据集加载到 Python 中,并检查是否正确加载。

    是时候使用 scikit-learn 的交叉验证(在新版本中,这是模型选择)功能来分离训练数据和测试数据了。我们将使用 10%的测试数据。

    注意,从 scikit-learn 0.20 版本开始,train_test_split方法将在model_selection模块中可用,而不是在cross_validation模块中。在之前的版本中,model_selection已经包含了train_test_split方法。

    构建决策树分类器。

  3. 检查基于测试数据的模型得分。

  4. 基于classification_report功能创建对模型的更深入评估。

    注意

    该活动的解决方案可在第 282 页找到。

随机森林分类器

如果你考虑随机森林分类器的名字,可以得出以下结论:

  • 森林由多棵树组成。

  • 这些树可以用于分类。

  • 由于我们迄今为止用于分类的唯一树是决策树,因此随机森林是决策树的森林是有意义的。

  • 树的随机性意味着我们的决策树是以随机方式构建的。

  • 因此,我们将基于信息增益或基尼不纯度构建决策树。

一旦你理解了这些基本概念,你本质上就知道了随机森林分类器是什么。森林中的树越多,预测的准确性就越高。在执行预测时,每棵树都会进行分类。我们收集结果,得票最多的类别获胜。

随机森林既可以用于回归,也可以用于分类。当使用随机森林进行回归时,我们不是对类别的投票进行计数,而是取预测结果算术平均值的平均值(平均)并返回它。尽管如此,随机森林在回归方面并不像在分类方面那样理想,因为用于预测值的模型往往失控,并且经常返回一个很宽的范围的值。这些值的平均值通常并不太有意义。在回归练习中管理噪声比在分类中更难。

随机森林通常比一个简单的决策树更好,因为它们提供了冗余。它们处理异常值更好,并且有更低的模型过拟合概率。只要你在创建模型时使用的数据上使用决策树,它们似乎表现得很好。一旦你使用它们来预测新数据,随机森林就会失去优势。随机森林被广泛用于分类问题,无论是银行的客户细分、电子商务、图像分类还是医学。如果你拥有一台带有 Kinect 的 Xbox,你的 Kinect 设备中就包含一个用于检测你身体部位的随机森林分类器。

随机森林分类和回归是集成算法。集成学习的理念是我们对多个潜在具有不同弱点的代理人的决策进行聚合视图。由于聚合投票,这些弱点相互抵消,多数投票很可能代表正确的结果。

构建随机森林

构建随机森林的树的一种方法是在分类任务中限制使用的特征数量。假设你有一个特征集,F。特征集的长度是 #(F)。特征集中的特征数量是 dim(F),其中 dim 表示维度。

假设我们将训练数据限制为大小为 s < #(F) 的不同子集,并且每个随机森林接收一个大小为 s 的不同训练数据集。假设我们指定我们将使用 k < dim(F) 个可能的特征来构建随机森林中的树。k 个特征的选取是随机的。

我们完全构建每个决策树。一旦我们得到一个新的数据点进行分类,我们就执行随机森林中的每个树来进行预测。一旦预测结果出来,我们统计投票,得票最多的类别将成为随机森林预测的数据点的类别。

在随机森林术语中,我们用一个词来描述随机森林的性能优势:袋装法。袋装法是一种由自助法和聚合决策组成的技巧。自助法负责创建一个包含原始数据集条目子集的数据集。原始数据集和自助数据集的大小仍然相同,因为我们允许在自助数据集中多次选择相同的数据点。

出袋数据点是那些最终没有出现在某些自助数据集中的数据点。为了衡量随机森林分类器的出袋误差,我们必须在未考虑出袋数据点的随机森林分类器的树上运行所有出袋数据点。误差范围是正确分类的出袋数据点与所有出袋数据点之间的比率。

使用 scikit-learn 进行随机森林分类

我们的起点是训练-测试分割的结果:

from sklearn import model_selection
features_train, features_test, label_train, label_test =
    model_selection.train_test_split(
        features,
        label,
        test_size=0.1
    )

随机森林分类器可以如下实现:

from sklearn.ensemble import RandomForestClassifier
random_forest_classifier = RandomForestClassifier(
    n_estimators=100,
    max_depth=6
)
randomForestClassifier.fit(features_train, label_train)
labels_predicted = random_forest_classifier.predict(features_test)

scikit-learn 的接口使得处理随机森林分类器变得容易。在整个前三章中,我们已经习惯了这种调用分类器或回归模型进行预测的方式。

随机森林分类器的参数化

如往常一样,请查阅完整参数列表的文档。您可以在以下位置找到文档:http://scikit-learn.org/stable/modules/generated/sklearn.ensemble.RandomForestClassifier.html#sklearn.ensemble.RandomForestClassifier。

我们将只考虑可能参数的一个子集,这是基于您已经了解的内容,即基于构建随机森林的描述:

  • n_estimators:随机森林中的树的数量。默认值为 10。

  • criterion:使用 Gini 或熵来确定是否在每个树中使用 Gini 不纯度或使用熵的信息增益。

  • max_features:森林中任何树中考虑的最大特征数。可能的值包括一个整数。您还可以添加一些字符串,如"sqrt",表示特征数的平方根。

  • max_depth:每棵树的最大深度。

  • min_samples_split:在给定节点中,数据集中样本的最小数量,以执行分割。这也可能减少树的大小。

  • bootstrap:一个布尔值,表示在构建树时是否对数据点使用自助法。

特征重要性

随机森林分类器会告诉你数据分类中每个特征的重要性。记住,我们使用大量的随机构造的决策树来分类数据点。我们可以测量这些数据点的行为准确性,我们还可以看到哪些特征在决策中至关重要。

我们可以使用以下查询检索特征重要性分数数组:

random_forest_classifier.feature_importances_

输出如下:

array([0.12794765, 0.1022992 , 0.02165415, 0.35186759, 0.05486389,
       0.34136752])

在这个六特征分类器中,第四和第六个特征显然比其他任何特征都重要得多。第三个特征的重要性得分非常低。

当我们有很多特征并且想要减少特征大小以避免分类器陷入细节时,特征重要性分数很有用。当我们有很多特征时,我们可能会过度拟合模型。因此,通过删除最不显著的特征来减少特征数量通常是有帮助的。

极端随机树

极端随机树通过在随机森林中随机化分割规则来增加随机化,这些规则是在随机森林中已经随机化的因素之上。

参数化与随机森林分类器相似。您可以在以下位置查看完整参数列表:scikit-learn.org/stable/modules/generated/sklearn.ensemble.ExtraTreesClassifier.html

Python 实现如下:

from sklearn.ensemble import ExtraTreesClassifier
extra_trees_classifier = ExtraTreesClassifier(
    n_estimators=100,
    max_depth=6
)
extra_trees_classifier.fit(features_train, label_train)
labels_predicted = extra_trees_classifier.predict(features_test)&#9;

活动 11:为您的租车公司进行随机森林分类

在本节中,我们将优化你的分类器,以便在选择未来的车队车辆时更能满足客户的需求。我们将对你在本章前一个活动中工作的汽车经销商数据集执行随机森林和超随机树分类。为提高分类器的性能,提出对模型的进一步改进建议:

  1. 按照前一个活动的步骤 1 到 5 进行。

  2. 如果你使用的是IPython,你的变量可能已经在你的控制台中可访问。

  3. 创建一个随机森林和一个超随机树分类器,并训练模型。

  4. 估计两个模型在测试数据上的表现如何。我们还可以计算准确率评分。

  5. 作为一种首次优化技术,让我们看看哪些特征更重要,哪些特征不太重要。由于随机化,移除最不重要的特征可能会减少模型中的随机噪声。

  6. 从模型中移除第三个特征并重新训练分类器。比较新模型与原始模型的表现如何。

  7. 稍微调整一下分类器的参数化。

注意,我们通过允许最大特征数增加到这个程度来减少了非确定性,这最终可能导致一定程度上的过拟合。

注意

本活动的解决方案可在第 285 页找到。

摘要

在本章中,我们学习了如何使用决策树进行预测。通过集成学习技术,我们创建了复杂的强化学习模型来预测任意数据点的类别。

独立的决策树在表面上证明非常准确,但它们容易过拟合模型。随机森林和超随机树通过引入一些随机元素和投票算法(多数胜出)来对抗过拟合。

除了决策树、随机森林和超随机树之外,我们还学习了评估模型效用的新方法。在采用众所周知的准确率评分之后,我们开始使用精确率、召回率和 F1 评分指标来评估我们的分类器工作得如何。所有这些值都是从混淆矩阵中推导出来的。

在下一章中,我们将描述聚类问题,并比较和对比两种聚类算法。

第六章:聚类

学习目标

到本章结束时,你将能够:

  • 总结聚类的要点

  • 使用 k-means 算法执行平面聚类

  • 使用均值漂移算法执行层次聚类

在本章中,你将了解聚类的原理,这将通过两个无监督学习算法进行说明。

聚类简介

在前面的章节中,我们处理了监督学习算法以执行分类和回归。我们使用训练数据来训练我们的分类或回归模型,然后我们使用测试数据来验证我们的模型。

在本章中,我们将通过使用聚类算法进行无监督学习。

我们可以使用聚类来分析数据,以找到某些模式和创建组。除此之外,聚类还可以用于许多目的:

  • 市场细分检测你在基本面应该关注的最佳股票。我们可以使用聚类检测趋势、细分客户或向特定客户类型推荐某些产品。

  • 在计算机视觉中,使用聚类进行图像分割,我们可以在图像中找到计算机处理的不同对象。

  • 聚类可以与分类相结合,其中聚类可以生成多个特征的紧凑表示,然后将其输入到分类器中。

  • 聚类还可以通过检测异常值来过滤数据点。

无论我们是将聚类应用于遗传学、视频、图像还是社交网络,如果我们使用聚类分析数据,我们可能会发现数据点之间的相似性,这些相似性值得统一处理。

我们执行聚类而不指定标签。聚类根据数据点之间的距离定义聚类。而在分类中,我们定义精确的标签类别以分组分类数据点,在聚类中则没有标签。我们只是给机器学习模型提供特征,模型必须找出这些特征集所属的聚类。

定义聚类问题

假设你是一位负责确保商店盈利性的商店经理。你的产品被分为不同的类别。不同顾客的商店偏好不同的商品。

例如,对生物产品感兴趣的顾客倾向于选择天然生物产品。如果你查看亚马逊,你也会找到针对不同产品组的建议。这是基于用户可能感兴趣的内容。

我们将定义聚类问题,以便我们能够找到数据点之间的这些相似性。假设我们有一个由点组成的数据集。聚类帮助我们通过描述这些点的分布来理解这种结构。

让我们看看二维空间中数据点的例子:

图片

图 6.1:二维空间中的数据点

在这个例子中,很明显有三个聚类:

图片

图 6.2:使用二维空间中的数据点形成的三个簇

由于点彼此靠近,因此三个簇很容易检测到。聚类确定彼此靠近的数据点。还有一些不属于任何簇的异常点。聚类算法应该准备好适当处理这些异常点,而不会将它们移动到簇中。

虽然在二维空间中识别簇很容易,但我们通常有多维数据点。因此,了解哪些数据点彼此靠近很重要。同样,定义检测数据点彼此接近的距离度量也很重要。一个著名的距离度量是欧几里得距离。在数学中,我们经常使用欧几里得距离来测量两点之间的距离。因此,当涉及到聚类算法时,欧几里得距离是一个直观的选择,这样我们就可以确定数据点在定位簇时的邻近程度。

大多数距离度量(包括欧几里得距离)有一个缺点:当我们增加维度时,这些距离相对于彼此将变得更加均匀。因此,去除作为噪声而不是有用信息的特征可能会大大提高聚类模型的准确性。

聚类方法

聚类有两种类型:平面层次

在平面聚类中,我们指定机器要找到的簇数量。平面聚类的一个例子是 k-means 算法,其中 K 指定了算法要使用的簇数量。

在层次聚类中,机器学习算法找出所需的簇数量。

层次聚类也有两种方法:

  • 自底向上的层次聚类将每个点视为一个簇。这种方法将彼此靠近的簇合并在一起。

  • 自顶向下的层次聚类将数据点视为一个覆盖整个状态空间的簇。然后,聚类算法将我们的簇分割成更小的簇。

  • 点分配聚类根据新数据点到现有簇的接近程度将新数据点分配给现有簇。

scikit-learn 支持的聚类算法

在本章中,我们将学习 scikit-learn 支持的两种聚类算法:k-means算法和均值漂移算法。

k-means是平面聚类的例子,我们必须提前指定簇的数量。k-means 是一种通用目的的聚类算法,如果簇的数量不是太高且簇的大小均匀,则表现良好。

均值漂移是层次聚类的一个例子,其中聚类算法确定簇的数量。当事先不知道簇的数量时,使用均值漂移。与 k-means 相比,均值漂移支持存在许多簇的用例,即使簇的大小差异很大。

scikit-learn 提供了其他聚类算法。这些如下:

  • 亲和传播:与均值漂移表现相似

  • 谱聚类:如果只有少数簇存在,且簇的大小均匀,则表现更好

  • Ward 层次聚类:当预期有许多簇时使用

  • 层次聚类:当预期有许多簇时使用

  • DBSCAN 聚类:支持不均匀的簇大小和点分布的非平面几何

  • 高斯混合:使用平面几何,这对于密度估计很有用

  • Birch 聚类:支持大型数据集,去除异常值,并支持数据降维

要完整描述聚类算法,包括性能比较,请访问 scikit-learn 的聚类页面,网址为scikit-learn.org/stable/modules/clustering.html

k-means 算法

k-means 算法是一种平面聚类算法。它的工作原理如下:

  • 设置 K 的值。

  • 从数据集中选择 K 个数据点作为各个簇的初始中心。

  • 计算每个数据点到所选中心点的距离,并将每个点分组到其初始中心点最接近的数据点所在的簇中。

  • 当所有点都位于 K 个簇之一中时,计算每个簇的中心点。这个中心点不必是数据集中的现有数据点;它只是一个平均值。

  • 重复将每个数据点分配到中心点最接近数据点的簇的过程。重复进行,直到中心点不再移动。

为了确保 k-means 算法终止,我们需要以下条件:

  • 当质心移动小于容差值时,我们退出时的最大容差级别

  • 移动点的最大重复次数

由于 k-means 算法的性质,它将很难处理大小差异很大的簇。

k-means 算法有许多用例,这些都是我们日常生活中的一部分:

市场细分:公司从其客户群中收集各种数据。对公司客户群进行 k-means 聚类分析可以揭示具有定义特性的市场细分。属于同一细分市场的客户可以类似对待。不同的细分市场将接受不同的处理。

书籍、电影或其他文档的分类:当影响者建立他们的个人品牌时,作者会写书并创作书籍,或者公司管理其社交媒体账户,内容为王。内容通常由标签和其他数据描述。这些数据可以用作聚类的依据,以定位性质相似的文档组。

欺诈和犯罪活动的检测:欺诈者经常以异常的客户或访客行为的形式留下线索。例如,汽车保险保护驾驶者免受盗窃和事故造成的损害。真实盗窃和虚假盗窃具有不同的特征值。同样,故意撞毁汽车留下的痕迹与意外撞毁汽车留下的痕迹不同。聚类通常可以检测欺诈,帮助行业专业人士更好地了解他们最差的客户的行为。

练习 19:scikit-learn 中的 k-means

要在二维平面上绘制数据点并对其执行 k-means 算法以进行聚类,请执行以下步骤:

  1. 我们将创建一个 NumPy Array 的人工数据集来演示 k-means 算法:

    import numpy as np
    data_points = np.array([
        [1, 1],
        [1, 1.5],
        [2, 2],
        [8, 1],
        [8, 0],
        [8.5, 1],
        [6, 1],
        [1, 10],
        [1.5, 10],
        [1.5, 9.5],
        [10, 10],
        [1.5, 8.5]
    ])
    

    我们可以使用matplotlib.pyplot在二维平面上绘制这些数据点:

    import matplotlib.pyplot as plot
    plot.scatter(data_points.transpose()[0], data_points.transpose()[1])
    

    输出如下:

    图片

    图 6.3:使用 matplotlib.pyplot 在二维平面上显示数据点的图表

    注意

    我们使用了transpose array方法来获取第一个特征和第二个特征值。这与前面的章节一致。我们也可以使用适当的数组索引来访问这些列:dataPoints[:,0]等同于dataPoints.transpose()[0]

  2. 现在我们有了数据点,是时候对它们执行 k-means 算法了。如果我们把 k-means 算法中的 K 定义为3,我们期望在图表的左下角、右上角和右下角有一个簇:

    from sklearn.cluster import KMeans
    k_means_model = KMeans(n_clusters=3)
    k_means_model.fit(data_points)
    
  3. 聚类完成后,我们可以访问每个簇的中心点:

    k_means_model.cluster_centers_
    

    输出将如下:

     array([[1.33333333, 1.5     ],
            [3.1     , 9.6     ],
            [7.625     , 0.75     ]])
    

    事实上,簇的中心点似乎位于图表的左下角、右上角和右下角。右上角簇的 X 坐标是 3.1,这很可能是由于它包含了我们的异常数据点[10, 10]。

  4. 让我们用不同的颜色绘制簇及其中心点。要知道哪个数据点属于哪个簇,我们必须查询 k-means 分类器的labels_属性:

    k_means_model.labels_
    

    输出将如下:

    array([0, 0, 0, 2, 2, 2, 2, 1, 1, 1, 1, 1])
    
  5. 输出数组显示了哪些数据点属于哪个簇。这是我们绘制数据所需的所有信息:

    plot.scatter(
        k_means_model.cluster_centers_[:,0],
        k_means_model.cluster_centers_[:,1]
    )
    for i in range(len(data_points)):
        plot.plot(
            data_points[i][0],
            data_points[i][1],
            ['ro','go','yo'][k_means_model.labels_[i]]
        )
    plot.show()
    

    输出如下:

    图片

    图 6.4:显示红色、绿色和蓝色数据点并选择三个簇的图表

    蓝色的中心点确实位于它们的簇中,这些簇由红色点、绿色点和黄色点表示。

  6. 让我们看看如果我们选择两个簇而不是三个簇会发生什么:

    k_means_model = KMeans(n_clusters=2)
    k_means_model.fit(data_points)
    plot.scatter(
        k_means_model.cluster_centers_[:,0],
        k_means_model.cluster_centers_[:,1]
    )
    for i in range(len(data_points)):
        plot.plot(
            data_points[i][0],
            data_points[i][1],
            ['ro','go'][k_means_model.labels_[i]]
        )
    plot.show()
    

    输出如下:

    图片

    图 6.5:显示在选择两个簇时红色、蓝色和绿色数据点的图形

    这次,我们只有红色和绿色点,我们有一个底部簇和一个顶部簇。有趣的是,第二个示例中的顶部红色簇包含与第一个示例中顶部簇相同的点。第二个示例的底部簇由第一个示例的底部左簇和底部右簇的数据点组成。

  7. 我们还可以使用 k-means 模型进行预测。输出是一个包含每个数据点所属簇编号的数组:

    k_means_model.predict([[5,5],[0,10]])
    

    输出结果如下:

     array([1, 0])
    

scikit-learn 中 k-means 算法的参数化

与第 3、4 和 5 章中的分类和回归模型一样,k-means 算法也可以参数化。完整的参数列表可以在scikit-learn.org/stable/modules/generated/sklearn.cluster.KMeans.html 找到。

以下是一些示例:

  • n_clusters:数据点被分离的簇的数量。默认值是 8

  • max_iter:最大迭代次数。

  • tol:检查我们是否可以退出 k-means 算法的容差。

在上一节中,我们使用了两个属性来检索簇中心点和簇本身:

cluster_centers_:这返回簇中心点的坐标。

labels_:这返回一个表示数据点所属簇编号的整数数组。编号从零开始。

练习 20:检索中心点和标签

要了解 cluster_centers_labels_ 的用法,请执行以下步骤:

  1. 回想一下我们从 scikit-learn 中执行 k-means 算法时的示例。我们有 12 个数据点和三个簇:

    data_points = np.array([
        [1, 1],
        [1, 1.5],
        [2, 2],
        [8, 1],
        [8, 0],
        [8.5, 1],
        [6, 1],
        [1, 10],
        [1.5, 10],
        [1.5, 9.5],
        [10, 10],
        [1.5, 8.5]
    ])
    k_means_model.cluster_centers_
    

    输出结果如下:

     array([[1.33333333, 1.5     ],
          [3.1     , 9.6     ],
           [7.625     , 0.75     ]])
    
  2. 在簇上应用 labels_ 属性:

    k_means_model.labels_
    

    输出结果如下:

     array([0, 0, 0, 2, 2, 2, 2, 1, 1, 1, 1, 1])
    

    cluster_centers_ 属性的输出很明显:它显示了中心点的 X 和 Y 坐标。labels_ 属性是一个长度为 12 的数组,显示了它所属的 12 个数据点的簇。第一个簇与数字 0 相关联,第二个与 1 相关联,第三个与 2 相关联,以此类推。

销售数据的 k-means 聚类

在即将进行的活动过程中,我们将考虑销售数据,并将在这些销售数据上执行 k-means 聚类。

活动 12:销售数据的 k-means 聚类

在本节中,我们将检测表现相似的产品销售,以识别产品销售趋势。

我们将使用以下 URL 中的销售交易周数据集:

archive.ics.uci.edu/ml/datasets/Sales_Transactions_Dataset_Weekly 使用 k-means 算法对数据集进行聚类。确保你根据之前章节中学到的知识准备你的聚类数据。

使用 k-means 算法的默认设置:

  1. 使用 pandas 加载数据集。如果你检查 CSV 文件中的数据,你会意识到第一列包含产品 ID 字符串。这些值只是给聚类过程添加噪声。此外,请注意,对于第 0 周到第 51 周,存在带有 W 前缀的标签和归一化标签。使用归一化标签更有意义,这样我们就可以从数据集中删除常规的每周标签。创建一个 k-means 聚类模型,并将数据点拟合到 8 个聚类中。从聚类算法中检索中心点和标签。

  2. 使用 labels_ 属性可以检索每个数据点的标签。这些标签决定了原始数据框中行的聚类。这些标签有什么好处?

假设,在原始数据框中,产品名称是给出的。你可以很容易地认识到类似类型的产品销售情况相似。也有一些产品波动很大,还有一些季节性产品。例如,如果某些产品宣传减肥和塑形,它们往往在年初的销售,在海滩季节之前。

注意

此活动的解决方案可在第 291 页找到。

均值漂移算法

均值漂移是一种层次聚类算法。与 k-means 算法不同,在均值漂移中,聚类算法确定需要多少个聚类,并执行聚类。这有优势,因为我们很少知道我们正在寻找多少个聚类。

此算法在我们的日常生活中也有许多应用场景。例如,Xbox Kinect 设备使用均值漂移算法检测人体部位。一些手机也使用均值漂移算法来检测人脸。随着社交媒体平台的增长,图像分割已成为许多用户习惯的功能。由于图像分割也是计算机视觉的基础,因此在该领域可以找到一些应用。均值漂移算法甚至可能拯救生命,因为它被集成到许多现代汽车的车辆检测软件中。想象一下,如果有人在你面前紧急刹车。你的汽车图像分割软件会检测到你前面的车辆正危险地接近你,并在你意识到紧急情况之前施加紧急制动。这些驾驶辅助系统在现代汽车中很常见。自动驾驶汽车仅一步之遥。

练习 21:在 2D 中展示均值漂移

要通过使用均值漂移算法学习聚类,请执行以下步骤:

  1. 让我们回顾一下之前主题中的数据点:

    data_points = np.array([
        [1, 1],
        [1, 1.5],
        [2, 2],
        [8, 1],
        [8, 0],
        [8.5, 1],
        [6, 1],
        [1, 10],
        [1.5, 10],
        [1.5, 9.5],
        [10, 10],
        [1.5, 8.5]
    ])
    r = 2;
    
  2. 我们现在的任务是找到一个点 P (x, y),使得从点 P 出发,半径 R 内的数据点数量最大化。点分布如下:

    图 6.6:显示 data_points 数组数据点的图表
  3. 假设我们最初将点 P 等同于第一个数据点,[1, 1]:

    P = [1, 1]
    
  4. 让我们找到距离这个点 R 距离内的点:

    from scipy.spatial import distance
    points = np.array([
        p0 for p0 in data_points if distance.euclidean(p0, P) <= r
    ])
    points
    

    输出将如下所示:

     array([[1\. , 1\. ],
       [1\. , 1.5],
       [2\. , 2\. ]])
    
  5. 让我们计算数据点的平均值:

    import numpy as np
    P = [
        np.mean( points.transpose()[0] ),
        np.mean(points.transpose()[1] )
    ]
    P
    

    输出将如下所示:

     [1.3333333333333333, 1.5]
    
  6. 现在新的平均值已经计算出来,我们可以再次检索给定半径内的点:

    points = np.array([
        p0 for p0 in data_points if distance.euclidean( p0, P) <= r
    ])
    points
    

    输出将如下所示:

     array([[1\. , 1\. ],
       [1\. , 1.5],
       [2\. , 2\. ]])
    
  7. 这些是相同的三个点,所以我们在这里可以停止。已经找到了围绕平均值[1.3333333333333333, 1.5]的三个点。在这个中心半径为 2 的点形成了一个簇。

  8. 如果我们检查数据点[1, 1.5]和[2, 2],我们会得到相同的结果。让我们继续处理列表中的第四个点,[8, 1]:

    P = [8, 1]
    points = np.array( [
        p0 for p0 in data_points if distance.euclidean(p0, P) <= r
    ])
    points
    

    输出将如下所示:

     array([[8\. , 1\. ],
            [8\. , 0\. ],
            [8.5, 1\. ],
            [6\. , 1\. ]])
    
  9. 这次,我们找到了该区域的所有四个点。因此,我们可以简单地计算它们的平均值:

    P = [
        np.mean(points.transpose()[0]),
        np.mean(points.transpose()[1])
    ]
    

    输出将如下所示:

     [7.625, 0.75]
    

    这个平均值不会改变,因为在下一次迭代中,我们会找到相同的数据点。

  10. 注意,我们在选择点[8, 1]时很幸运。如果我们从P = [8, 0]P = [8.5, 1]开始,我们只会找到三个点而不是四个:

    P = [8, 0]
    points = np.array([
        p0 for p0 in data_points if distance.euclidean(p0, P) <= r
    ])
    points
    

    输出将如下所示:

     array([[8\. , 1\. ],
       [8\. , 0\. ],
       [8.5, 1\. ]])
    
  11. 计算这三个点的平均值后,我们必须重新运行距离计算,使用偏移的平均值:

    P = [
        np.mean(points.transpose()[0]),
        np.mean(points.transpose()[1])
    ]
    P
    

    输出将如下所示:

     [8.166666666666666, 0.6666666666666666]
    
  12. 点 P = [8.5, 1]的输出如下数组:

     array([[8\. , 1\. ],
       [8\. , 0\. ],
       [8.5, 1\. ]])
    

    我们只又找到了相同的三个点。这意味着从[8,1]开始,我们得到的簇比从[8, 0]或[8.5, 1]开始要大。因此,我们必须选择包含最多数据点的中心点。

  13. 现在,让我们看看如果我们从第四个数据点开始发现会发生什么,[6, 1]

    P = [6, 1]
    points = np.array([
        p0 for p0 in data_points if distance.euclidean(p0, P) <= r
    ])
    points
    

    输出将如下所示:

     array([[8., 1.],
      [6., 1.]])
    
  14. 我们成功找到了数据点[8, 1]。因此,我们必须将平均值从[6, 1]移动到计算出的新平均值[7, 1]:

    P = [
        np.mean(points.transpose()[0]),
        np.mean(points.transpose()[1])
    ]
    P
    

    输出将如下所示:

     [7.0, 1.0]
    
  15. 让我们检查是否找到了更多的点:

    points = np.array([
        p0 for p0 in data_points if distance.euclidean(p0, P) <= r
    ])
    points
    

    输出将如下所示:

     array([[8\. , 1\. ],
       [8\. , 0\. ],
       [8.5, 1\. ],
       [6\. , 1\. ]])
    

    是的——我们成功找到了所有四个点!因此,我们成功地定义了一个大小为 4 的簇。平均值将与之前相同:[7.625, 0.75]。

    这是一个简单的聚类示例,应用了均值漂移算法。我们只提供了算法考虑用于找到聚类的说明。尽管如此,还有一个问题:半径的值。

    注意,如果半径为 2 没有设置,我们可以简单地从一个包含所有数据点的巨大半径开始,然后减小半径,或者从一个非常小的半径开始,确保每个数据点都在它自己的簇中,然后增加半径,直到我们得到期望的结果。

scikit-learn 中的均值漂移算法

让我们使用与 k-means 算法相同的数据点:

import numpy as np
data_points = np.array([
    [1, 1],
    [1, 1.5],
    [2, 2],
    [8, 1],
    [8, 0],
    [8.5, 1],
    [6, 1],
    [1, 10],
    [1.5, 10],
    [1.5, 9.5],
    [10, 10],
    [1.5, 8.5]
])

均值漂移聚类算法的语法与 k-means 聚类算法类似。

from sklearn.cluster import MeanShift
mean_shift_model = MeanShift()
mean_shift_model.fit(data_points)

一旦完成聚类,我们可以访问每个聚类的中心点:

mean_shift_model.cluster_centers_

输出将如下:

array([[ 1.375     , 9.5     ],
       [ 1.33333333, 1.5     ],
       [ 8.16666667, 0.66666667],
       [ 6\.        , 1\.        ],
       [10\.        , 10\.        ]])

均值漂移模型找到了 5 个聚类,其中心点在前面代码中显示。

与 k-means 类似,我们也可以获取标签:

mean_shift_model.labels_

输出将如下:

 array([1, 1, 1, 2, 2, 2, 3, 0, 0, 0, 4, 0], dtype=int64)

输出数组显示了哪些数据点属于哪个聚类。这是我们绘制数据所需的所有信息:

plot.scatter(
    mean_shift_model.cluster_centers_[:,0],
    mean_shift_model.cluster_centers_[:,1]
)
for i in range(len(data_points)):
    plot.plot(
        data_points[i][0],
        data_points[i][1],
        ['ro','go','yo', 'ko', 'mo'][mean_shift_model.labels_[i]]
    )
plot.show()

输出将如下:

图 6.7:基于 k-means 的图,

三个蓝色点分别是红色、绿色和黄色聚类的中心点。在坐标系中还有两个单独的点聚类,分别属于点(6,1)和(10,10)。

Python 中的图像处理

要解决即将到来的活动,你需要知道如何在 Python 中处理图像。我们将使用 SciPy 库来完成这项工作。

你可以从路径读取图像文件有多种方式。

最简单的一种方法是来自 Python Imaging Library (PIL)的Image接口:

from PIL import Image
image = Image.open('file.jpg')

前面的代码假设在open方法的字符串参数中指定的文件路径指向一个有效的图像文件。

我们可以通过查询大小属性来获取图像的大小:

image.size

输出将如下:

 (750, 422)

我们可以从包含每个像素 RGB 值的图像创建一个二维 NumPy 数组:

import numpy as np
pixel_array = np.array(image)

一旦构建了像素数组,我们就可以轻松地检索和操作每个像素:

pixel_array[411][740]

输出将如下:

 array([29, 33, 32], dtype=uint8)

我们也可以通过图像的load()方法使图像的像素可访问。一旦我们获得了对这些像素的访问权限,我们可以根据文件格式获取每个像素的 RGB 或 RGBA 值:

pixels = image.load()
pixels[740, 411]

输出将如下:

 (29, 33, 32)

注意,像素坐标的顺序是相反的,即从左到右读取时为pixel_array[411][740]。我们正在读取完全相同的像素,但我们必须以不同的方式提供坐标。

我们还可以将像素设置为新的值:

pixels[740, 411] = (255, 0, 0)

如果你想要保存更改,请使用图像的save()方法:

image.save('test.jpg')

要对图像的像素进行聚类分析,我们需要将图像转换为数据框。这意味着我们必须将图像的像素转换为['x', 'y', 'red', 'green', 'blue']值的元组或数组。一旦我们有一个这些值的单维数组,我们可以将它们转换为 pandas DataFrame:

import pandas
data_frame = pandas.DataFrame(
    [[x,y,pixels[x,y][0], pixels[x,y][1], pixels[x,y][2]]
        for x in range(image.size[0])
        for y in range(image.size[1])
    ],
    columns=['x', 'y', 'r', 'g', 'b' ]
)
data_frame.head()

输出将如下:

   x y r g b
0 0 0 6 29 71
1 0 1 7 32 73
2 0 2 8 37 77
3 0 3 8 41 82
4 0 4 7 45 84

这是完成使用均值漂移算法处理图像活动所需了解的所有内容。

活动第 13 项:使用均值漂移算法进行形状识别

在本节中,我们将学习如何对图像进行聚类。想象一下,你正在为一家公司工作,该公司从照片中检测人类情绪。你的任务是提取头像照片中构成脸部的像素。

创建一个使用均值漂移进行图像像素聚类的聚类算法。检查均值漂移算法的结果,并检查在用于头像图像时,是否有任何聚类包含面部。

然后,应用具有固定默认聚类数(在这种情况下为 8)的 k 均值算法。将你的结果与均值漂移聚类算法进行比较:

  1. 选择你想要聚类的图像并加载图像。

  2. 将像素转换为数据帧以执行聚类。使用 scikit-learn 在图像上执行均值漂移聚类。请注意,这次,我们将跳过特征归一化,因为像素的邻近性和颜色成分的邻近性以几乎相等的权重表示。算法将找到两个聚类。

  3. 根据你使用的图像,注意均值漂移算法如何处理人类肤色,以及图像的哪些其他部分被放置在同一个聚类中。包含头像中大部分肤色的聚类通常包括非常接近和/或与肤色颜色相似的数据点。

  4. 让我们使用 K 均值算法在相同的数据上形成八个聚类。

你会看到聚类算法确实定位了接近且颜色相似的数据点。

注意

此活动的解决方案可在第 293 页找到。

摘要

在本章中,我们学习了聚类是如何工作的。聚类是一种无监督学习形式,其中特征是给定的,聚类算法找到标签。

聚类有两种类型:平面和层次。

K 均值算法是一种平面聚类算法,其中我们为我们的 K 个聚类确定 K 个中心点,算法找到数据点。

均值漂移是层次聚类算法的一个例子,其中要确定的唯一标签值的数量由算法决定。

最后一章将介绍一个由于计算能力的爆炸式增长和廉价、可扩展的在线服务器容量而成为本十年热门领域的科学——神经网络和深度学习。

第七章:使用神经网络进行深度学习

学习目标

到本章结束时,你将能够:

  • 执行基本的 TensorFlow 操作以解决各种表达式

  • 描述人工神经网络的工作原理

  • 使用 TensorFlow 训练和测试神经网络

  • 使用 TensorFlow 实现深度学习神经网络模型

在本章中,我们将使用 TensorFlow 库检测手写数字。

简介

在本章中,我们将学习另一种监督学习技术。然而,这次,我们不会使用像分类或回归这样的简单数学模型,而将使用一个完全不同的模型:神经网络。虽然我们将使用神经网络进行监督学习,但请注意,神经网络也可以模拟无监督学习技术。这个模型的重要性在上个世纪有所增加,因为在过去,使用这个模型进行监督学习所需的计算能力不足。因此,在上个世纪,神经网络在实践中应运而生。

TensorFlow for Python

TensorFlow 是 Google 维护的最重要的人工智能和开源库之一。TensorFlow API 在许多语言中可用,包括 Python、JavaScript、Java 和 C。由于 TensorFlow 支持监督学习,我们将使用 TensorFlow 构建图模型,然后使用此模型进行预测。

TensorFlow 与张量一起工作。张量的例子包括:

  • 标量值,例如浮点数。

  • 长度任意的向量。

  • 一个包含 p 乘 q 个值的常规矩阵,其中 p 和 q 是有限的整数。

  • 一个 p x q x r 的广义矩阵结构,其中 p、q、r 是有限的整数。想象这个结构在三维空间中是一个具有边长 p、q 和 r 的长方体对象。这个数据结构中的数字可以在三维空间中可视化。

  • 观察上述四种数据结构,更复杂、n 维数据结构也可以是张量的有效示例。

在本章中,我们将坚持使用标量、向量和常规矩阵张量。在本章范围内,将张量视为标量值、数组或数组的数组。

TensorFlow 用于创建人工神经网络,因为它模拟了其输入、输出、内部节点以及这些节点之间的有向边。TensorFlow 还附带数学函数来转换信号。当神经网络中的神经元被激活时,这些数学函数在建模时也会很有用。

注意

张量是类似数组的对象。流符号表示对张量数据的操作。因此,本质上,TensorFlow 是一个数组数据处理库。

TensorFlow 的主要用途是人工神经网络,因为这个领域需要对大数组和大矩阵进行操作。TensorFlow 附带了许多与深度学习相关的函数,因此它是神经网络的最佳环境。TensorFlow 用于语音识别、语音搜索,也是 translate.google.com 背后的大脑。在本章的后面部分,我们将使用 TensorFlow 来识别手写字符。

在 Anaconda Navigator 中安装 TensorFlow

让我们打开 Anaconda Prompt,使用pip安装 TensorFlow:

pip install tensorflow

安装需要几分钟,因为包本身相当大。如果你更喜欢使用你的显卡 GPU 而不是 CPU,你也可以使用tensorflow-gpu。确保只有在你有一个足够好的显卡时才使用 GPU 版本。

安装完成后,你可以在 IPython 中导入 TensorFlow:

import tensorflow as tf

首先,我们将使用 TensorFlow 构建一个图。这个模型的执行是分开的。这种分离很重要,因为执行是资源密集型的,因此可能需要在专门解决计算密集型问题的服务器上运行。

TensorFlow 操作

TensorFlow 提供了许多操作来操作数据。以下是一些这些操作的例子:

  • 算术运算

  • 指数运算explog

  • 关系运算大于小于等于

  • 数组操作concatslicesplit

  • 矩阵运算matrix_inversematrix_determinantmatmul

  • 神经网络相关操作sigmoidReLUsoftmax

练习 22:使用基本操作和 TensorFlow 常量

使用 TensorFlow 中的算术运算来解决表达式:2 * 3 + 4

这些操作可以用来构建图形。为了更深入地了解 TensorFlow 常量和基本算术运算符,让我们考虑一个简单的表达式2 * 3 + 4,这个表达式的图形如下:

图 7.1 表达式 2*3+4 的图形

图 7.1:表达式 2*3+4 的图形
  1. 使用以下代码在 TensorFlow 中模拟此图形:

    import tensorflow as tf
    input1 = tf.constant(2.0, tf.float32, name='input1')
    input2 = tf.constant(3.0, tf.float32, name='input2')
    input3 = tf.constant(4.0, tf.float32, name='input3')
    product12 = tf.multiply(input1, input2)
    sum = tf.add(product12, input3)
    
  2. 图形构建完成后,为了进行计算,我们必须打开一个 TensorFlow 会话并执行我们的节点:

    with tf.Session() as session:
        print(session.run(product12))
        print(session.run(sum))
    

    中间结果和最终结果将打印到控制台:

    6.0
    10.0
    

占位符和变量

现在你可以使用 TensorFlow 构建表达式了,让我们更进一步,构建占位符和变量。

当会话开始执行时,占位符会被替换为一个常量值。占位符本质上是在解决表达式之前被替换的参数。变量是在会话执行过程中可能发生变化的值。

让我们使用 TensorFlow 创建一个参数化表达式:

import tensorflow as tf
input1 = tf.constant(2.0, tf.float32, name='input1')
input2 = tf.placeholder(tf.float32, name='p')
input3 = tf.Variable(0.0, tf.float32, name='x')
product12 = tf.multiply(input1, input2)
sum = tf.add(product12, input3)
with tf.Session() as session:
    initializer = tf.global_variables_initializer()
    session.run(initializer)
    print(session.run(sum, feed_dict={input2: 3.0}))

输出是6.0

tf.global_variables_initializer()调用在session.run执行后,将input3中的变量初始化为其默认值,即零。

通过使用 feed 字典在另一个session.run语句中计算了总和,因此用常数3.0代替了input2参数。

注意,在这个特定示例中,变量 x 被初始化为零。在 TensorFlow 会话执行期间,x 的值不会改变。稍后,当我们使用 TensorFlow 来描述神经网络时,我们将定义一个优化目标,并且会话将优化变量的值以满足这个目标。

全局变量初始化器

由于 TensorFlow 经常使用矩阵运算,因此学习如何初始化一个随机变量的矩阵到一个以零为中心的正态分布随机生成的值是有意义的。

不仅矩阵,所有全局变量都是在会话内部通过调用tf.global_variables_initializer()来初始化的:

randomMatrix = tf.Variable(tf.random_normal([3, 4]))
with tf.Session() as session:
    initializer = tf.global_variables_initializer()
    print( session.run(initializer))
    print( session.run(randomMatrix))

None
[[-0.41974232 1.8810892 -1.4549098 -0.73987174]
[ 2.1072254 1.7968426 -0.38310152 0.98115194]
[-0.550108 -0.41858754 1.3511614 1.2387075 ]]

如您所见,tf.Variable的初始化需要一个参数:tf.random_normal([3,4])的值。

神经网络简介

神经网络是人工智能的最新分支。神经网络受到人类大脑工作方式的启发。最初,它们是在 20 世纪 40 年代由沃伦·麦卡洛克和沃尔特·皮茨发明的。神经网络是一个数学模型,用于描述人类大脑如何解决问题。

当我们谈论数学模型时,我们将使用“人工神经网络”这个短语,当我们谈论人类大脑时,我们将使用“生物神经网络”。人工神经网络是监督学习算法。

神经网络的学习方式比其他分类或回归模型更复杂。神经网络模型有很多内部变量,输入变量和输出变量之间的关系可能要通过多个内部层。与其他监督学习算法相比,神经网络具有更高的准确性。

注意

掌握 TensorFlow 中的神经网络是一个复杂的过程。本节的目的就是为你提供一个入门资源,帮助你开始学习。

在本章中,我们将使用的主要示例是从图像中识别数字。我们考虑这个图像是因为它很小,我们大约有 70,000 张图像可用。处理这些图像所需的处理能力与普通计算机相似。

人工神经网络的工作原理与人类大脑的工作原理相似。在人类大脑中,树突连接到细胞核,细胞核连接到轴突。在这里,树突充当输入,细胞核是计算发生的地方(加权总和和激活函数),轴突的作用类似于输出。

然后,我们通过将加权总和传递给激活函数来确定哪个神经元会激发。如果这个函数确定一个神经元必须激发,信号就会出现在输出中。这个信号可以是网络中其他神经元的输入:

图 7.2 展示人工神经网络工作原理的图

图 7.2:展示人工神经网络工作原理的图

假设f是激活函数,x1x2x3x4是输入,它们的和与权重w1w2w3w4相乘:

y = f(x1*w1 + x2*w2 + x3*w3 + x4*w4)

假设向量x是(x1x2x3x4),向量w是(w1w2w3w4),我们可以将这个方程写成这两个向量的标量或点积:

y = f(x ⋅ w)

我们定义的结构是一个神经元:

让我们隐藏这个神经元的细节,以便更容易构建神经网络:

图 7.4 表示神经元隐藏层的图

图 7.4:表示神经元隐藏层的图

我们可以创建多个框和多个输出变量,这些变量可能由于读取输入的加权平均值而被激活。

虽然在下面的图中,所有输入都指向所有框的箭头,但请记住,箭头上的权重可能为零。我们仍然在图中显示这些箭头:

图 7.5:表示神经网络的图

描述输入和输出之间关系的框被称为隐藏层。只有一个隐藏层的神经网络被称为常规神经网络

在连接输入和输出时,我们可能有多个隐藏层。具有多个层的神经网络被称为深度神经网络

图 7.6 表示深度神经网络的图

图 7.6:表示深度神经网络的图

深度学习这个术语来源于多层结构的存在。在创建人工神经网络时,我们可以指定隐藏层的数量。

偏置

让我们再次看看神经网络中神经元的模型:

图 7.7 神经网络中神经元的图

图 7.7:神经网络中神经元的图

我们了解到这个神经元的方程如下:

y = f(x1*w1 + x2*w2 + x3*w3 + x4*w4)

这个方程的问题是没有依赖于输入 x1,x2,x3 和 x4 的常数因子。这意味着神经网络中的每个神经元,如果没有偏置,总是会在每个权重-输入对的乘积为零时产生这个值。

因此,我们在方程中添加偏置:

y = f(x1*w1 + x2*w2 + x3*w3 + x4*w4 + b)
y = f(x ⋅ w + b)

第一个方程是详细形式,描述了每个坐标、权重系数和偏置的作用。第二个方程是向量形式,其中 x = (x1, x2, x3, x4)和 w = (w1, w2, w3, w4)。向量之间的点运算符表示两个向量的点积或标量积。这两个方程是等价的。我们将在实践中使用第二种形式,因为它比逐个定义每个变量更容易使用 TensorFlow 定义变量向量。

同样,对于w1w2w3w4,偏置b是一个变量,意味着它的值可以在学习过程中改变。

由于每个神经元都内置了这个常数因子,神经网络模型在拟合特定训练数据集方面变得更加灵活。

注意

可能会发生这样的情况,由于存在一些负权重,乘积 p = x1*w1 + x2*w2 + x3*w3 + x4*w4 是负的。我们可能仍然希望模型具有灵活性,以在超过给定负数的值上激活神经元。因此,添加一个常数偏置 b = 5,例如,可以确保神经元在 -5 到 0 之间的值上也能激活。

人工神经网络的用例

人工神经网络在监督学习技术中占有一席之地。它们可以模拟分类和回归问题。分类神经网络寻求特征和标签之间的关系。特征是输入变量,而分类器可以选择作为返回值的每个类别是一个单独的输出。在回归的情况下,输入变量是特征,而有一个单一的输出:预测值。虽然传统的分类和回归技术在人工智能中有其用例,但人工神经网络通常在寻找输入和输出之间的复杂关系方面表现得更好。

激活函数

神经网络中使用了不同的激活函数。没有这些函数,神经网络将是一个可以用矩阵乘法轻松描述的线性模型。

神经网络的激活函数提供了非线性。最常用的激活函数是 sigmoidtanh(双曲正切函数)。

sigmoid 的公式如下:

import numpy as np
def sigmoid(x):
    return 1 / (1 + np.e ** (-x))

让我们使用 pyplot 绘制这个函数:

import matplotlib.pylab as plt
x = np.arange(-10, 10, 0.1)
plt.plot(x, sigmoid(x))
plt.show()

输出如下:

图 7.8 显示 sigmoid 曲线的图形

图 7.8:显示 sigmoid 曲线的图形

Sigmoid 函数存在一些问题。

首先,它可能会不成比例地放大或衰减权重。

第二,sigmoid(0) 不为零。这使得学习过程更加困难。

双曲正切的公式如下:

def tanh(x):
    return 2 / (1 + np.e ** (-2*x)) - 1

我们也可以这样绘制这个函数:

x = np.arange(-10, 10, 0.1)
plt.plot(x, tanh(x))
plt.show()

输出如下:

图 7.9 绘制双曲正切后的图形

图 7.9:绘制双曲正切后的图形

这两个函数都给神经元发出的值增加了一点点非线性。sigmoid 函数看起来更平滑,而 tanh 函数给出稍微尖锐的结果。

最近另一种激活函数也变得流行起来:ReLUReLU 代表修正线性单元:

def relu(x):
    return 0 if x < 0 else x

使神经网络模型非线性化使得模型更容易逼近非线性函数。没有这些非线性函数,无论网络有多少层,我们都只能逼近线性问题:

def reluArr(arr):
   return [relu(x) for x in arr]
x = np.arange(-10, 10, 0.1)
plt.plot(x, reluArr(x))
plt.show()

输出如下:

图 7.10 显示 ReLU 函数的图形

图 7.10:显示 ReLU 函数的图形

从快速收敛到神经网络权重和偏差的最终值的角度来看,ReLU 激活函数表现得非常出色。

在本章中,我们将使用一个额外的函数:softmax

softmax 函数将列表中的值缩小到 01 之间,使得列表中所有元素的和变为 1softmax 函数的定义如下:

def softmax(list):
    return np.exp(list) / np.sum(np.exp(list))

这里有一个例子:

softmax([1,2,1])

输出结果如下:

array([0.21194156, 0.57611688, 0.21194156])

当我们过滤列表而不是单个值时,可以使用 softmax 函数。列表中的每个元素都将被转换。

让我们尝试不同的激活函数。观察这些函数如何通过解决以下练习来抑制加权的输入。

练习 23:激活函数

考虑以下神经网络:

y = f( 2 * x1 + 0.5 * x2 + 1.5 * x3 - 3 ).

假设 x1 是 1,x2 是 2,计算以下 x 值(-1,0,1,2)对应的 y 值:

  • f 是 sigmoid 函数

  • f 是 tanh 函数

  • f 是 ReLU 函数

执行以下步骤:

  1. 代入已知的系数:

    def get_y( f, x3 ):
        return f(2*1+0.5*2+1.5*x3)
    
  2. 使用以下三个激活函数:

    import numpy as np
    def sigmoid(x):
        return 1 / (1 + np.e ** (-x))
    def tanh(x):
        return 2 / (1 + np.e ** (-2*x)) - 1
    def relu(x):
        return 0 if x < 0 else x
    
  3. 使用以下命令计算 sigmoid 值:

    get_y( sigmoid, -2 )
    

    输出结果为 0.5

    get_y(sigmoid, -1)
    

    输出结果为 0.8175744761936437

    get_y(sigmoid, 0)
    

    输出结果为 0.9525741268224331

    get_y(sigmoid, 1)
    

    输出结果为 0.9890130573694068

    get_y(sigmoid, 2)
    

    输出结果为 0.9975273768433653

  4. 如您所见,当 sigmoid 函数内部的求和表达式增加时,变化迅速被抑制。我们期望 tanh 函数具有更大的抑制效果:

    get_y(tanh, -2)
    

    输出结果为 0.0

    get_y(tanh, -1)
    

    输出结果为 0.9051482536448663

    get_y(tanh, 0)
    

    输出结果为 0.9950547536867307

    get_y(tanh, 1)
    

    输出结果为 0.9997532108480274

    get_y(tanh, 2)
    

    输出结果为 0.9999877116507956

  5. 根据函数 tanh 的特性,输出比 sigmoid 函数更快地接近 1 的渐近线。对于 x3 = -2,我们计算 f(0)。而 sigmoid(0)0.5tanh(0)0。与另外两个函数不同,ReLu 函数不会抑制正值:

    get_y(relu,-2)
    

    输出结果为 0.0

    get_y(relu,-1)
    

    输出结果为 1.5

    get_y(relu,0)
    

    输出结果为 3.0

    get_y(relu,1)
    

    输出结果为 4.5

    get_y(relu,2)
    

    输出结果为 6.0

    ReLU 函数的另一个优点是,它的计算是所有激活函数中最简单的。

前向和反向传播

由于人工神经网络提供了一种监督学习技术,我们必须使用训练数据来训练我们的模型。训练网络的过程是找到属于每个变量输入对的权重。权重优化的过程包括重复执行两个步骤:前向传播和反向传播。

前向传播和反向传播这两个名称暗示了这些技术的工作方式。我们首先初始化神经网络箭头上的权重。然后,我们进行前向传播,接着进行反向传播。

前向传播根据输入值计算输出值。反向传播根据模型创建的标签值与训练数据中实际标签值之间的误差范围调整权重和偏差。权重调整的速率取决于神经网络的 学习率。学习率越高,反向传播期间权重和偏差的调整就越多。神经网络的动量决定了过去的结果如何影响权重和偏差的即将到来的值。

配置神经网络

以下参数通常用于创建神经网络:

  • 隐藏层的数量

  • 每个隐藏层的节点数

  • 激活函数

  • 学习率

  • 动量

  • 前向和反向传播的迭代次数

  • 错误容忍度

有一些经验法则可以用来确定每个隐藏层的节点数。如果你的隐藏层包含的节点数多于输入的大小,你可能会使模型过拟合。通常,节点数在输入和输出之间是合理的。

导入 TensorFlow 数字数据集

初看起来,识别手写数字似乎是一个简单的任务。然而,这个任务是一个具有十个可能标签值的简单分类问题。TensorFlow 提供了一个用于识别数字的示例数据集。

注意

你可以在 TensorFlow 网站上阅读有关此数据集的信息:www.tensorflow.org/tutorials/

我们将使用keras来加载数据集。你可以在 Anaconda Prompt 中使用以下命令进行安装:

pip install keras

记住,我们将对这些数据集进行监督学习,因此我们需要训练和测试数据:

import tensorflow.keras.datasets.mnist as mnist
(features_train, label_train),(features_test, label_test) =
mnist.load_ data()

特征是包含 28x28 图像像素值的数组。标签是介于 0 到 9 之间的一位整数。让我们看看第五个元素的特性和标签。我们将使用与上一节相同的图像库:

from PIL import Image
Image.fromarray(features_train[5])
图 7.11 训练图像
图 7.11:训练图像
label_train[5]
2

在本章末尾的活动结束时,你的任务将是创建一个神经网络,根据这些手写数字的值进行分类。

建模特征和标签

我们将通过 TensorFlow 数字数据集中识别手写数字的特征和标签建模示例来讲解。

我们以一个 28x28 像素的图像作为输入。每个图像的值要么是黑色,要么是白色。因此,特征集由一个 28 * 28 = 784 像素的向量组成。

图像为灰度图,颜色从 0 到 255 不等。为了处理它们,我们需要对数据进行缩放。通过将训练和测试特征除以 255.0,我们确保我们的特征缩放在 0 到 1 之间:

features_train = features_train / 255.0
features_test = features_test / 255.0

注意,我们可以有一个 28x28 的方阵来描述特征,但我们更愿意将矩阵展平并简单地使用一个向量。这是因为神经网络模型通常处理一维数据。

关于标签的建模,许多人认为用单个标签建模这个问题最有意义:一个介于 0 到 9 之间的整数值。这种方法是有问题的,因为计算中的小错误可能会导致完全不同的数字。我们可以想象,5 和 6 是相似的,所以相邻的值在这里工作得很好。然而,在 1 和 7 的情况下,一个小错误可能会让神经网络将 1 识别为 2,或者将 7 识别为 6。这非常令人困惑,并且可能需要更多的时间来训练神经网络以减少相邻值的错误。

更重要的是,当我们的神经网络分类器返回结果为 4.2 时,我们可能像《银河系漫游指南》中的英雄一样难以解释这个答案。4.2 很可能是 4。但如果不是,它可能是一个 5,或者一个 3,或者一个 6。这不是数字检测的工作方式。

因此,使用十个标签的向量来模拟这个任务更有意义。当使用 TensorFlow 进行分类时,为每个可能的类别创建一个标签,其值介于 0 和 1 之间,这是完全合理的。这些数字描述了读取的数字被分类为标签所代表的类别的成员的概率。

例如,值 [0, 0.1, 0, 0, 0.9, 0, 0, 0, 0, 0] 表示我们的数字有 90%的可能性是 4,有 10%的可能性是 2。

在分类问题中,我们总是为每个类别使用一个输出值。

让我们继续讨论权重和偏差。为了连接 28*28 = 784 个特征和 10 个标签,我们需要一个 784 行 10 列的权重矩阵。

因此,方程变为 y = f( x ⋅ W + b ) ,其中 x 是一个 784 维空间中的向量,W 是一个 784 x 10 的矩阵,b 是一个包含十个维度的偏差向量。y 向量也包含十个坐标。f 函数定义在具有十个坐标的向量上,并且应用于每个坐标。

注意

为了将二维 28x28 的数据点矩阵转换为 28x28 元素的单一维向量,我们需要展平矩阵。与许多其他语言和库不同,Python 没有展平方法。

由于展平是一个简单的任务,让我们构建一个展平方法:

def flatten(matrix):
    return [elem for row in matrix for elem in row]
flatten([[1,2],[3,4]])

输出如下:

 [1, 2, 3, 4]

让我们将 28*28 矩阵的特征展平到 784 维空间的向量:

features_train_vector = [
    flatten(image) for image in features_train
]
features_test_vector = [
    flatten(image) for image in features_test
]

为了将标签转换为向量形式,我们需要进行归一化:

import numpy as np
label_train_vector = np.zeros((label_train.size, 10))
for i, label in enumerate(label_train_vector):
    label[label_train[i]] = 1
label_test_vector = np.zeros((label_test.size, 10))
for i, label in enumerate(label_test_vector):
    label[label_test[i]] = 1

TensorFlow 多标签建模

我们现在将在 TensorFlow 中建模以下方程:y = f( x ⋅ W + b )

在导入 TensorFlow 后,我们将定义特征、标签和权重:

import tensorflow as tf
f = tf.nn.sigmoid
x = tf.placeholder(tf.float32, [None, 28 * 28])
W = tf.Variable(tf.random_normal([784, 10]))
b = tf.Variable(tf.random_normal([10]))

如果我们知道如何使用 TensorFlow 执行点积乘法,我们可以简单地写出方程 y = f( x ⋅ W + b )

如果我们将 x 视为一个1x84矩阵,我们可以使用tf.matmul函数将其与784x10的 W 矩阵相乘。

因此,我们的方程变为以下:y = f( tf.add( tf.matmul( x, W ), b ) )

你可能已经注意到 x 包含占位符,而 W 和 b 是变量。这是因为 x 的值是已知的。我们只需要在方程中替换它们。TensorFlow 的任务是优化 W 和 b 的值,以最大化我们读取正确数字的概率。

让我们将 y 的计算表达为一个函数形式:

def classify(x):
    return f(tf.add(tf.matmul(x, W), b))

注意

这是定义激活函数的地方。在本章末尾的活动结束时,你最好使用 softmax 激活函数。这意味着你将不得不在代码中将 sigmoid 替换为 softmax:f = tf.nn.softmax

优化变量

占位符代表输入。TensorFlow 的任务是优化变量。

为了执行优化,我们需要使用一个成本函数:交叉熵。交叉熵具有以下特性:

  • 如果预测输出与实际输出匹配,其值为零

  • 其值在之后是严格正的

我们的任务是最小化交叉熵:

y = classify(x)
y_true = tf.placeholder(tf.float32, [None, 10])
cross_entropy = tf.nn.sigmoid_cross_entropy_with_logits(
    logits=y,
    labels=y_true
)

虽然计算 y 的函数被称为 classify,但我们在这里并没有执行实际的分类。记住,我们正在使用占位符代替 x,实际值在运行 TensorFlow 会话时被替换。

sigmoid_cross_entropy_with_logits函数接受两个参数来比较它们的值。第一个参数是标签值,而第二个参数是预测结果。

为了计算成本,我们必须调用 TensorFlow 的reduce_mean方法:

cost = tf.reduce_mean(cross_entropy)

成本最小化通过一个优化器进行。我们将使用带有学习率的GradientDescentOptimizer。学习率是影响模型调整速度的神经网络参数:

optimizer = tf.train.GradientDescentOptimizer(learning_rate = 0.5).minimize(cost)

在这个阶段不执行优化,因为我们还没有运行 TensorFlow。我们将在主循环中执行优化。

如果你使用的是不同的激活函数,如 softmax,你将不得不在源代码中替换它。而不是以下语句:

cross_entropy = tf.nn.sigmoid_cross_entropy_with_logits(
    logits=y,
    labels=y_true
)

使用以下内容:

cross_entropy = tf.nn.softmax_cross_entropy_with_logits_v2(

logits=y,

labels=y_true

)

注意

方法名称中的 _v2 后缀。这是因为原始的tf.nn.softmax_cross_entropy_with_logits方法已被弃用。

训练 TensorFlow 模型

我们需要创建一个 TensorFlow 会话并运行模型:

session = tf.Session()

首先,我们使用tf.global_variables_initializer()初始化变量:

session.run(tf.global_variables_initializer())

然后是优化循环。我们将确定迭代次数和批量大小。在每次迭代中,我们将随机选择与批量大小相等的特征-标签对。

为了演示目的,我们不会创建随机批次,而是在每次新迭代开始时简单地提供即将到来的前一百张图像。

由于我们总共有 60,000 张图像,我们可能会有多达 300 次迭代和每次迭代 200 张图像。实际上,我们只会运行几次迭代,这意味着我们只会使用部分可用的训练数据:

iterations = 300
batch_size = 200
for i in range(iterations):
    min = i * batch_size
    max = (i+1) * batch_size
    dictionary = {
        x: features_train_vector[min:max],
        y_true: label_train_vector[min:max]
    }
    session.run(optimizer, feed_dict=dictionary)
    print('iteration: ', i)

使用模型进行预测

我们现在可以使用训练好的模型进行预测。语法很简单:我们将测试特征输入到会话的字典中,并请求classify(x)值:

session.run(classify(x), feed_dict={
    x: features_test_vector[:10]
} )

测试模型

现在我们已经训练了模型,并且可以使用它进行预测,是时候测试其性能了:

label_predicted = session.run(classify(x), feed_dict={
    x: features_test_vector
})

我们必须通过从每个结果中取最大值的索引,将labelsPredicted值转换回 0 到 9 的整数范围。我们将使用 NumPy 函数来完成这个转换。

argmax函数返回其列表或数组参数中具有最大值的索引。以下是一个示例:

np.argmax([0.1, 0.3, 0.5, 0.2, 0, 0, 0, 0.2, 0, 0 ])

输出是2

这里是带有argmax函数的第二个示例

np.argmax([1, 0, 1])

输出是0

让我们进行转换:

label_predicted = [
    np.argmax(label) for label in label_predicted
]

我们可以使用我们在前几章中学到的 scikit-learn 中的度量。让我们先计算混淆矩阵:

from sklearn.metrics import confusion_matrix, accuracy_score, precision_score, recall_score, f1_score
confusion_matrix(label_test, label_predicted)
accuracy_score(label_test, label_predicted)
precision_score( label_test, label_predicted, average='weighted' )
recall_score( label_test, label_predicted, average='weighted' )
f1_score( label_test, label_predicted, average='weighted' )

随机化样本大小

回忆神经网络的训练函数:

iterations = 300
batch_size = 200
for i in range(iterations):
    min = i * batch_size
    max = (i+1) * batch_size
    dictionary = {
        x: features_train_vector[min:max],
        y_true: label_train_vector[min:max]
    }
    session.run(optimizer, feed_dict=dictionary)

问题在于,在 60,000 个数字中,我们只能进行 5 次迭代。如果我们想超过这个阈值,我们就会面临重复这些输入序列的风险。

我们可以通过随机选择训练数据中的值来最大化使用训练数据的有效性。

我们可以使用random.sample方法来完成这个目的:

iterations = 6000
batch_size = 100
sample_size = len(features_train_vector)
for _ in range(iterations):
    indices = random.sample(range(sample_size), batchSize)
    batch_features = [
        features_train_vector[i] for i in indices
    ]
    batch_labels = [
        label_train_vector[i] for i in indices
    ]
    min = i * batch_size
    max = (i+1) * batch_size
    dictionary = {
        x: batch_features,
        y_true: batch_labels
    }
    session.run(optimizer, feed_dict=dictionary)

注意

随机样本方法从列表中随机选择给定数量的元素。例如,在匈牙利,主要的国家级彩票是基于从 90 个数字中选择 5 个数字。我们可以使用以下表达式来模拟一轮彩票:

import random
random.sample(range(1,91), 5)

输出如下:

[63, 58, 25, 41, 60]

活动 14:手写数字检测

在本节中,我们将讨论如何通过检测手写数字为加密货币交易者提供更多安全性。我们将假设你是一家新加密货币交易平台上的软件开发者。你正在实施的最新安全措施需要识别手写数字。使用 MNIST 库训练一个神经网络来识别数字。你可以在www.tensorflow.org/tutorials/了解更多关于这个数据集的信息。

通过执行以下步骤尽可能提高模型的准确度:

  1. 加载数据集并格式化输入。

  2. 设置 TensorFlow 图。现在我们将使用ReLU函数而不是 sigmoid 函数。

  3. 训练模型。

  4. 测试模型并计算准确度得分。

  5. 通过重新运行负责训练数据集的代码段,我们可以提高其准确性。运行代码 50 次。

  6. 打印混淆矩阵。

在第五十次运行结束时,混淆矩阵已经改进。

这不是一个坏的结果。超过 8 个数字被准确识别。

注意

这个活动的解决方案可以在第 298 页找到。

如你所见,神经网络并不呈线性提高。可能看起来训练网络在一段时间内对准确性的增量改进很小或没有。然而,在达到某个阈值后,会出现突破,准确度会大幅提高。

这种行为与人类学习相似。你可能现在也会在神经网络方面遇到困难。然而,在深入材料并尝试一些练习之后,你将实现一次又一次的突破,你的进步将加速。

深度学习

在这个主题中,我们将增加神经网络的层数。你可能记得我们可以向我们的图中添加隐藏层。我们将通过实验隐藏层来提高我们模型的准确性。

添加层

回想一下具有两个隐藏层的神经网络图:

图 7.12 展示神经网络中两个隐藏层的图

图 7.12:展示神经网络中两个隐藏层的图

我们可以通过复制权重和偏差并确保 TensorFlow 变量的维度匹配来在等式中添加第二个层。注意,在第一个模型中,我们将 784 个特征转换成了 10 个标签。

在这个模型中,我们将把 784 个特征转换成指定数量的输出。然后我们将这些输出转换成 10 个标签。

确定添加的隐藏层的节点数并不完全是科学。在这个例子中,我们将使用 200 个节点,因为它位于特征和标签维度之间。

由于我们有两个层,我们将定义两个矩阵(W1, W2)和向量(b1, b2)来分别表示权重和偏差。

首先,我们使用W1b1减少 784 个输入点,并创建 200 个变量值。我们将这些值作为第二层的输入,并使用W2b2创建 10 个标签值:

x = tf.placeholder(tf.float32, [None, 28 * 28 ])
f = tf.nn.softmax
W1 = tf.Variable(tf.random_normal([784, 200]))
b1 = tf.Variable(tf.random_normal([200]))
layer1_out = f(tf.add( tf.matmul(x, W1), b1))
W2 = tf.Variable(tf.random_normal([200, 10]))
b2 = tf.Variable(tf.random_normal([10]))
y = f(tf.add(tf.matmul(layer1_out, W2), b2))

如果需要,我们可以这样增加层数。层 n 的输出必须是层 n+1 的输入。其余的代码保持不变。

卷积神经网络

卷积神经网络CNNs)是针对模式识别优化的人工神经网络。CNNs 基于深度神经网络中的卷积层。卷积层由使用卷积操作转换其输入的神经元组成。

当使用卷积层时,我们使用一个 mn 的矩阵在图像中检测模式,其中 m 和 n 分别小于图像的宽度和高度。在执行卷积操作时,我们将这个 mn 矩阵在图像上滑动,匹配每一个可能性。我们计算 m*n 卷积滤波器与当前卷积滤波器所在的 3x3 图像像素值的标量积。卷积操作从原始图像创建一个新的图像,其中我们图像的重要方面被突出显示,不那么重要的方面则被模糊化。

卷积操作总结了它所观察到的窗口上的信息。因此,它是一个识别图像中形状的理想操作符。形状可以出现在图像的任何位置,卷积操作符识别相似图像信息,无论其确切位置和方向如何。卷积神经网络超出了本书的范围,因为它是一个更高级的话题。

活动十五:使用深度学习进行手写数字检测

在本节中,我们将讨论深度学习如何提高你模型的表现。我们假设你的老板对你提交的第 14 个活动的结果不满意,并要求你考虑在你的原始模型中添加两个隐藏层,以确定新层是否能提高模型的准确性。为了确保你能正确完成这个活动,你需要对深度学习有所了解:

  1. 执行上一个活动的步骤,并测量模型的准确率。

  2. 通过添加新层来改变神经网络。我们将结合ReLUsoftmax激活函数。

  3. 重新训练模型。

  4. 评估模型。找到准确率分数。

  5. 运行代码 50 次。

  6. 打印混淆矩阵。

这个深度神经网络比单层神经网络表现得更加混沌。它经过 600 次迭代,每次迭代 200 个样本,从准确率 0.572 提升到 0.5723。不久之后,在相同的迭代次数中,准确率从 0.6076 跃升至 0.6834。

由于深度神经网络的灵活性,我们预计达到准确率上限的时间会比简单模型晚。由于深度神经网络的复杂性,它也更可能长时间陷入局部最优。

注意

这个活动的解决方案可以在第 302 页找到。

摘要

在这本书中,我们在“人工智能原理”章节学习了人工智能的基础知识以及人工智能的应用,然后我们编写了 Python 代码来模拟井字棋游戏。

在“使用搜索技术和游戏的人工智能”这一章节中,我们使用游戏人工智能工具和搜索技术解决了井字棋游戏。我们学习了广度优先搜索和深度优先搜索的搜索算法。A*算法帮助学生建模路径查找问题。这一章节以建模多人游戏作为结束。

在接下来的几章中,我们学习了使用回归和分类进行监督学习。这些章节包括数据预处理、训练-测试分割以及在几个实际场景中使用的模型。当预测股票数据时,线性回归、多项式回归和支持向量机都非常有用。分类使用了 k 近邻和支持向量机分类器。几个活动帮助学生将分类的基本原理应用于一个有趣的现实生活用例:信用评分。

第五章使用树进行预测分析中,我们介绍了决策树、随机森林和超随机树。本章介绍了评估模型效用性的不同方法。我们学习了如何计算模型的准确率、精确率、召回率和 F1 分数。我们还学习了如何创建模型的混淆矩阵。本章的模型通过评估汽车数据得到了实际应用。

第六章聚类中介绍了无监督学习,以及 k 均值和均值漂移聚类算法。这些算法的一个有趣方面是,标签不是预先给出的,而是在聚类过程中检测到的。

本书以第七章,使用神经网络进行深度学习结束,其中介绍了神经网络和 TensorFlow 的深度学习。我们使用这些技术在现实生活中的一个例子上应用了这些技术:手写数字的检测。

附录

关于

本节包含以帮助学生执行书中活动的内容。它包括学生为实现活动目标需要执行的详细步骤。

第一章:人工智能原理

在代码中,反斜杠(\)表示换行,当代码一行放不下时使用。行尾的反斜杠会转义换行符。这意味着在反斜杠后面的内容应该被读取为从反斜杠字符开始的内容。

活动一:生成井字棋游戏中所有可能的步骤序列

本节将探讨当两位玩家随机出牌时可能出现的组合爆炸。我们将使用一个程序,基于之前的结果生成计算机玩家和人类玩家之间所有可能的移动序列。确定根据行动序列的不同,胜负和平局的数量。假设人类玩家可以做出任何可能的移动。在这个例子中,由于计算机玩家是随机出牌,我们将检查两个随机出牌玩家的胜负和平局:

  1. 创建一个函数,将 all_moves_from_board 函数映射到每个棋盘列表的元素上。这样,我们将在每个深度上拥有决策树的全部节点:

    def all_moves_from_board(board, sign):
        move_list = []
        for i, v in enumerate(board):
            if v == EMPTY_SIGN:
                move_list.append(board[:i] + sign + board[i+1:])
        return move_list
    
  2. 决策树从 [ EMPTY_SIGN * 9 ] 开始,并在每一步之后扩展:

    all_moves_from_board_list( [ EMPTY_SIGN * 9 ], AI_SIGN )
    
  3. 输出如下:

     ['X........',
     '.X.......',
     '..X......',
     '...X.....',
     '....X....',
     '.....X...',
     '......X..',
     '.......X.',
     '........X']
     ['XO.......',
     'X.O......',
     'X..O.....',
     'X...O....',
     'X....O...',
     'X.....O..',
     'X......O.',
    .
    .
    .
    .
    '......OX.',
     '.......XO',
     'O.......X',
     '.O......X',
     '..O.....X',
     '...O....X',
     '....O...X',
     '.....O..X',
     '......O.X',
     '.......OX']
    
  4. 让我们创建一个 filter_wins 函数,它从移动列表中移除结束的游戏,并将包含 AI 玩家和对手玩家赢得的棋盘状态的数组附加到其中:

    def filter_wins(move_list, ai_wins, opponent_wins):
        for board in move_list:
            won_by = game_won_by(board)
            if won_by == AI_SIGN:
                ai_wins.append(board)
                move_list.remove(board)
            elif won_by == OPPONENT_SIGN:
                opponent_wins.append(board)
                move_list.remove(board)
    
  5. 在这个函数中,三个列表可以被视为引用类型。这意味着函数不返回值,而是操作这三个列表而不返回它们。

  6. 让我们完成这一节。然后使用一个 count_possibilities 函数,打印出以平局结束、第一玩家获胜和第二玩家获胜的决策树叶子的数量:

    def count_possibilities():
        board = EMPTY_SIGN * 9
        move_list = [board]
        ai_wins = []
        opponent_wins = []
        for i in range(9):
            print('step ' + str(i) + '. Moves: ' + \        str(len(move_list)))
            sign = AI_SIGN if i % 2 == 0 else OPPONENT_SIGN
            move_list = all_moves_from_board_list(move_list, sign)
            filter_wins(move_list, ai_wins, opponent_wins)
        print('First player wins: ' + str(len(ai_wins)))
        print('Second player wins: ' + str(len(opponent_wins)))
        print('Draw', str(len(move_list)))
        print('Total', str(len(ai_wins) + len(opponent_wins) + \    len(move_list)))
    
  7. 在每个状态下,我们最多有 9 步。在 0th、2nd、4th、6th 和 8th 次迭代中,AI 玩家移动。在所有其他迭代中,对手移动。我们在所有步骤中创建所有可能的移动,并从移动列表中移除结束的游戏。

  8. 然后执行可能性数量以体验组合爆炸。

    count_possibilities()
    
  9. 输出如下:

    step 0\. Moves: 1
    step 1\. Moves: 9
    step 2\. Moves: 72
    step 3\. Moves: 504
    step 4\. Moves: 3024
    step 5\. Moves: 13680
    step 6\. Moves: 49402
    step 7\. Moves: 111109
    step 8\. Moves: 156775
    First player wins: 106279
    Second player wins: 68644
    Draw 91150
    Total 266073
    

如您所见,棋盘状态的树由 266,073 个叶子组成。count_possibilities函数本质上实现了一个广度优先搜索算法来遍历游戏的所有可能状态。请注意,我们确实多次计算了这些状态,因为第 1 步在右上角放置 X,第 3 步在左上角放置 X,会导致与从左上角开始然后放置右上角 X 相似的可能状态。如果我们实现了重复状态的检测,我们就需要检查更少的节点。然而,在这个阶段,由于游戏的深度有限,我们省略了这个步骤。

第二章:具有搜索技术和游戏的 AI

活动二:教会智能体识别它防守损失的情况

按照以下步骤完成活动:

  1. 创建一个名为player_can_win的函数,它使用all_moves_from_board函数从棋盘上获取所有移动,并使用变量next_move遍历它。在每次迭代中,它检查游戏是否可以通过标记获胜,然后返回 true 否则返回 false。

    def player_can_win(board, sign):
        next_moves = all_moves_from_board(board, sign)
        for next_move in next_moves:
            if game_won_by(next_move) == sign:
                return True
        return False
    
  2. 我们将扩展 AI 的移动,使其更喜欢安全的移动。一个移动是安全的,如果对手在下一步不能赢得比赛。

    def ai_move(board):
        new_boards = all_moves_from_board(board, AI_SIGN)
        for new_board in new_boards:
            if game_won_by(new_board) == AI_SIGN:
                return new_board
        safe_moves = []
        for new_board in new_boards:
            if not player_can_win(new_board, OPPONENT_SIGN):
                safe_moves.append(new_board)
        return choice(safe_moves) if len(safe_moves) > 0 else \        new_boards[0]
    
  3. 您可以测试我们的新应用程序。您会发现 AI 已经做出了正确的移动。

  4. 我们现在将这个逻辑放入状态空间生成器中,并通过生成所有可能的比赛来检查计算机玩家的表现。

    def all_moves_from_board( board, sign ):
    
  5. 我们现在将这个逻辑放入状态空间生成器中,并通过生成所有可能的比赛来检查计算机玩家的表现。

    def all_moves_from_board(board, sign):
        move_list = []
        for i, v in enumerate(board):
            if v == EMPTY_SIGN:
                new_board = board[:i] + sign + board[i+1:]
                move_list.append(new_board)
                if game_won_by(new_board) == AI_SIGN:
                    return [new_board]
        if sign == AI_SIGN:
            safe_moves = []
            for move in move_list:
                if not player_can_win(move, OPPONENT_SIGN):
                    safe_moves.append(move)
            return safe_moves if len(safe_moves) > 0 else \            move_list[0:1]
        else:
            return move_list
    
  6. 计算所有可能的情况。

    count_possibilities()
    
  7. 输出如下:

    step 0\. Moves: 1
    step 1\. Moves: 9
    step 2\. Moves: 72
    step 3\. Moves: 504
    step 4\. Moves: 3024
    step 5\. Moves: 5197
    step 6\. Moves: 18606
    step 7\. Moves: 19592
    step 8\. Moves: 30936
    
    First player wins: 20843
    Second player wins: 962
    Draw 20243
    Total 42048
    

我们的表现比以前更好。我们不仅又消除了几乎三分之二的可能游戏,而且大多数时候,AI 玩家要么获胜,要么接受平局。尽管我们努力使 AI 变得更好,但它仍然有 962 种方式会输掉比赛。我们将在下一个活动中消除所有这些损失。

活动三:固定 AI 的前两个移动使其无敌

按照以下步骤完成活动:

  1. 我们将计算棋盘上空格的数量,并在有 9 个或 7 个空格的情况下进行硬编码的移动。您可以尝试不同的硬编码移动。我们发现占据任何角落,然后占据对角角落会导致没有损失。如果对手占据了对角角落,然后在中间移动会导致没有损失。

    def all_moves_from_board(board, sign):
        if sign == AI_SIGN:
            empty_field_count = board.count(EMPTY_SIGN)
            if empty_field_count == 9:
                return [sign + EMPTY_SIGN * 8]
            elif empty_field_count == 7:
                return [
                    board[:8] + sign if board[8] == \                    EMPTY_SIGN else
                    board[:4] + sign + board[5:]
                ]
        move_list = []
        for i, v in enumerate(board):
            if v == EMPTY_SIGN:
                new_board = board[:i] + sign + board[i+1:]
                move_list.append(new_board)
                if game_won_by(new_board) == AI_SIGN:
                    return [new_board]
        if sign == AI_SIGN:
    
            safe_moves = []
            for move in move_list:
                if not player_can_win(move, OPPONENT_SIGN):
                    safe_moves.append(move)
            return safe_moves if len(safe_moves) > 0 else \            move_list[0:1]
        else:
            return move_list
    
  2. 让我们验证状态空间

    countPossibilities()
    
  3. 输出如下:

    step 0\. Moves: 1
    step 1\. Moves: 1
    step 2\. Moves: 8
    step 3\. Moves: 8
    step 4\. Moves: 48
    step 5\. Moves: 38
    step 6\. Moves: 108
    step 7\. Moves: 76
    step 8\. Moves: 90
    First player wins: 128
    Second player wins: 0
    Draw 60
    
  4. 在固定前两个步骤之后,我们只需要处理 8 种可能性,而不是 504 种。我们还引导 AI 进入一个状态,其中硬编码的规则足以确保永远不会输掉比赛。

  5. 固定步骤并不重要,因为我们将会给 AI 提供硬编码的步骤作为起始点,但它是重要的,因为它是一个评估和比较每个步骤的工具。

  6. 在修复了前两步之后,我们只需要处理 8 种可能性,而不是 504 种。我们还引导 AI 进入一个状态,其中硬编码的规则足以确保永远不会输掉游戏。

活动 4:四子棋

本节将练习使用EasyAI库并开发启发式方法。我们将使用四子棋游戏。游戏板宽度为 7 个单元格,高度为 6 个单元格。当你移动时,你只能选择放下你的标记的列。然后重力将标记拉到最低的空单元格。你的目标是先于对手水平、垂直或对角线连接四个自己的标记,或者你用完所有空位。游戏规则可以在en.wikipedia.org/wiki/Connect_Four找到。

  1. 让我们设置 TwoPlayersGame 框架:

    from easyAI import TwoPlayersGame
    from easyAI.Player import Human_Player
    class ConnectFour(TwoPlayersGame):
        def __init__(self, players):
            self.players = players
        def possible_moves(self):
            return []
        def make_move(self, move):
            return
        def unmake_move(self, move):
    # optional method (speeds up the AI)
            return
        def lose(self):
            return False
        def is_over(self):
            return (self.possible_moves() == []) or self.lose()
        def show(self):
            print ('board')
        def scoring(self):
            return -100 if self.lose() else 0
    
    if __name__ == "__main__":
        from easyAI import AI_Player, Negamax
        ai_algo = Negamax(6)
    
  2. 我们可以保留定义中的一些函数不变。我们必须实现以下方法:

    __init__
    possible_moves
    make_move
    unmake_move (optional)
    lose
    show
    
  3. 我们将重用 tic-tac-toe 中的基本评分函数。一旦你测试了游戏,你会发现游戏并非不可战胜,尽管我们只使用了基本的启发式方法,但游戏表现却出人意料地好。

  4. 让我们编写 init 方法。我们将定义棋盘为一个一维列表,类似于 tic-tac-toe 示例。我们也可以使用二维列表,但建模不会变得容易或困难很多。除了像 tic-tac-toe 游戏中那样进行初始化之外,我们还会稍微提前工作。我们将生成游戏中所有可能的获胜组合,并将它们保存供将来使用:

    def __init__(self, players):
            self.players = players
            # 0 1 2 3 4 5 6
            # 7 8 9 10 11 12 13
            # ...
            # 35 36 37 38 39 40 41
            self.board = [0 for i in range(42)]
            self.nplayer = 1 # player 1 starts.
            def generate_winning_tuples():
                tuples = []
                # horizontal
                tuples += [
                    list(range(row*7+column, row*7+column+4, 1))
                    for row in range(6)
                    for column in range(4)]
                # vertical
                tuples += [
                    list(range(row*7+column, row*7+column+28, 7))
                    for row in range(3)
                    for column in range(7)
                ]
    
                # diagonal forward
                tuples += [
                    list(range(row*7+column, row*7+column+32, 8))
                    for row in range(3)
                    for column in range(4)
                ]
                # diagonal backward
                tuples += [
                    list(range(row*7+column, row*7+column+24, 6))
                    for row in range(3)
                    for column in range(3, 7, 1)
                ]
                return tuples
    self.tuples=generate_winning_tuples()
    
  5. 让我们处理移动。可能的移动函数是一个简单的枚举。注意我们在移动名称中使用从 1 到 7 的列索引,因为在人类玩家界面中从 1 开始列索引比从 0 开始更方便。对于每一列,我们检查是否存在未占用的字段。如果有一个,我们将该列设为可能的移动。

    def possible_moves(self):
            return [column+1
                    for column in range(7)
                    if any([
                        self.board[column+row*7] == 0
                        for row in range(6)
                    ])
                    ]
    
  6. 进行移动与可能的移动函数类似。我们检查移动的列,并从底部开始找到第一个空单元格。一旦找到,我们就占据它。你也可以阅读 make_move 函数的逆函数:unmake_move 的实现。在 unmake_move 函数中,我们从顶部到底部检查列,并在第一个非空单元格处移除移动。注意我们依赖于 easyAi 的内部表示,这样它就不会撤销它没有做出的移动。否则,这个函数会移除其他玩家的标记,而不会检查是哪个玩家的标记被移除。

    def make_move(self, move):
            column = int(move) - 1
            for row in range(5, -1, -1):
                index = column + row*7
                if self.board[index] == 0:
                    self.board[index] = self.nplayer
                    return
        def unmake_move(self, move):
    # optional method (speeds up the AI)
            column = int(move) - 1
            for row in range(6):
                index = column + row*7
                if self.board[index] != 0:
                    self.board[index] = 0
                    return
    
  7. 我们已经有了需要检查的元组,因此我们可以大部分重用 tic-tac-toe 示例中的 lose 函数。

    def lose(self):
            return any([all([(self.board[c] == self.nopponent)
                             for c in line])
                        for line in self.tuples])
        def is_over(self):
            return (self.possible_moves() == []) or self.lose()
    
  8. 我们最后一个任务是 show 方法,它将打印出棋盘。我们将重用 tic-tac-toe 的实现,并仅更改变量。

    def show(self):
            print('\n'+'\n'.join([
                ' '.join([['.', 'O', 'X'][self.board[7*row+column]]
                         for column in range(7)]
                         )
                for row in range(6)])
            )
    

现在所有函数都已完成,你可以尝试一下示例。请随意与对手玩几轮。你可以看到对手并不完美,但它的表现相当不错。如果你有一台强大的计算机,你可以增加 Negamax 算法的参数。我鼓励你提出更好的启发式方法。

第三章:回归

活动 5:预测人口

你在 Metropolis 政府办公室工作,试图预测小学容量需求。你的任务是确定 2025 年和 2030 年开始上小学的儿童数量预测。以下为历史数据:

图 3.21:小学数据

在二维图表上绘制趋势。使用线性回归。

我们的特征是 2001 年至 2018 年的年份。为了简单起见,我们可以将 2001 年表示为第 1 年,2018 年表示为第 18 年。

x = np.array(range(1, 19))
y = np.array([
    147026,
    144272,
    140020,
    143801,
    146233,
    144539,
    141273,
    135389,
    142500,
    139452,
    139722,
    135300,
    137289,
    136511,
    132884,
    125683,
    127255,
    124275
])

使用 np.polyfit 来确定回归线的系数。

[a, b] = np.polyfit(x, y, 1)
[-1142.0557275541753, 148817.5294117646]

使用 matplotlib.pyplot 绘制结果以确定未来的趋势。

import matplotlib.pyplot as plot
plot.scatter( x, y )
plot.plot( [0, 30], [b, 30*a+b] )
plot.show()

活动 6:使用多元二次和三次线性多项式回归预测股价

本节将讨论如何使用 scikit-learn 执行线性、多项式和支持向量回归。我们还将学习预测给定任务的最佳拟合模型。我们将假设你是金融机构的一名软件工程师,你的雇主想知道线性回归或支持向量回归哪个更适合预测股价。你必须从数据源中加载所有 S&P 500 的数据。然后使用线性回归、三次多项式线性回归和具有三次多项式核的支持向量回归构建回归器。然后分离训练数据和测试数据。绘制测试标签和预测结果,并与 y = x 线进行比较。最后,比较三个模型的得分情况。

让我们使用 Quandl 加载 S&P 500 指数数据,然后准备预测数据。你可以在“多元线性回归”主题的“预测未来”部分中阅读这个过程。

import quandl
import numpy as np
from sklearn import preprocessing
from sklearn import model_selection
from sklearn import linear_model
from sklearn.preprocessing import PolynomialFeatures
from matplotlib import pyplot as plot
from sklearn import svm
data_frame = quandl.get("YALE/SPCOMP")
data_frame[['Long Interest Rate', 'Real Price',
           'Real Dividend', 'Cyclically Adjusted PE Ratio']]
data_frame.fillna(-100, inplace=True)
# We shift the price data to be predicted 20 years forward
data_frame['Real Price Label'] = data_frame['RealPrice'].shift(-240)
# Then exclude the label column from the features
features = np.array(data_frame.drop('Real Price Label', 1))
# We scale before dropping the last 240 rows from the features
scaled_features = preprocessing.scale(features)
# Save the last 240 rows before dropping them
scaled_features_latest240 = scaled_features[-240:]
# Exclude the last 240 rows from the data used for # # modelbuilding
scaled_features = scaled_features[:-240]
# Now we can drop the last 240 rows from the data frame
data_frame.dropna(inplace=True)
# Then build the labels from the remaining data
label = np.array(data_frame['Real Price Label'])
# The rest of the model building stays
(features_train,
    features_test,
    label_train,
    label_test
) = model_selection.train_test_split(
    scaled_features,
    label,
   test_size=0.1
)

让我们先使用一次多项式来评估模型并进行预测。我们仍在重新创建第二个主题中的主要示例。

model = linear_model.LinearRegression()
model.fit(features_train, label_train)
model.score(features_test, label_test)
  1. 输出如下:

     0.8978136465083912
    
  2. 输出始终取决于测试数据,因此每次运行后的值可能不同。

    label_predicted = model.predict(features_test)
    plot.plot(
        label_test, label_predicted, 'o',
        [0, 3000], [0, 3000]
    )
    

图 3.22:显示输出的图表

点越接近 y=x 线,模型的误差就越小。

It is now time to perform a linear multiple regression with quadratic polynomials. The only change is in the Linear Regression model
poly_regressor = PolynomialFeatures(degree=3)
poly_scaled_features = poly_regressor.fit_transform(scaled_features)
(poly_features_train,
 poly_features_test,
 poly_label_train,
 poly_label_test) = model_selection.train_test_split(
    poly_scaled_features,
    label,
    test_size=0.1)
model = linear_model.LinearRegression()
model.fit(poly_features_train, poly_label_train)
print('Polynomial model score: ', model.score(
    poly_features_test, poly_label_test))
print('\n')
poly_label_predicted = model.predict(poly_features_test)
plot.plot(
    poly_label_test, poly_label_predicted, 'o',
    [0, 3000], [0, 3000]
)

模型在测试数据上的表现令人惊讶。因此,我们可以怀疑我们的多项式在训练和测试场景中过度拟合。

我们现在将执行一个具有三次多项式核的支持向量回归。

model = svm.SVR(kernel='poly')
model.fit(features_train, label_train)
label_predicted = model.predict(features_test)
plot.plot(
    label_test, label_predicted, 'o',
    [0,3000], [0,3000]
)

图 3.23:显示输出的图表
model.score(features_test, label_test)

输出将是 0.06388628722032952

我们现在将执行一个具有三次多项式核的支持向量回归。

第四章:分类

活动 7:为分类准备信用数据

本节将讨论如何为分类器准备数据。我们将使用 german.data 从archive.ics.uci.edu/ml/machine-learning-databases/statlog/german/作为示例,并为训练和测试分类器准备数据。确保所有标签都是数值的,并且值已准备好进行分类。使用 80%的数据点作为训练数据。

  1. archive.ics.uci.edu/ml/machine-learning-databases/statlog/german/保存 german.data,并在 Sublime Text 或 Atom 等文本编辑器中打开它。向其中添加以下第一行:

    CheckingAccountStatus DurationMonths CreditHistory CreditPurpose CreditAmount SavingsAccount EmploymentSince DisposableIncomePercent PersonalStatusSex OtherDebtors PresentResidenceMonths Property Age OtherInstallmentPlans Housing NumberOfExistingCreditsInBank Job LiabilityNumberOfPeople Phone ForeignWorker CreditScore
    
  2. 使用 pandas 导入数据文件,并用异常值替换 NA 值:

    import pandas
    data_frame = pandas.read_csv('german.data', sep=' ')
    data_frame.replace('NA', -1000000, inplace=True)
    
  3. 执行标签编码。我们需要将数据框中的所有标签转换为整数。我们可以在一维数组中创建所有标签。然而,这将非常低效,因为每个标签恰好出现在一个列中。按列分组我们的标签更有意义:

    labels = {
     'CheckingAccountStatus': ['A11', 'A12', 'A13', 'A14'],
     'CreditHistory': ['A30', 'A31', 'A32', 'A33', 'A34'],
     'CreditPurpose': ['A40', 'A41', 'A42', 'A43', 'A44', 'A45', 'A46', 'A47', 'A48', 'A49', 'A410'],
     'SavingsAccount': ['A61', 'A62', 'A63', 'A64', 'A65'],
     'EmploymentSince': ['A71', 'A72', 'A73', 'A74', 'A75'],
     'PersonalStatusSex': ['A91', 'A92', 'A93', 'A94', 'A95'],
     'OtherDebtors': ['A101', 'A102', 'A103'],
     'Property': ['A121', 'A122', 'A123', 'A124'],
     'OtherInstallmentPlans': ['A141', 'A142', 'A143'],
     'Housing': ['A151', 'A152', 'A153'],
     'Job': ['A171', 'A172', 'A173', 'A174'],
     'Phone': ['A191', 'A192'],
     'ForeignWorker': ['A201', 'A202']
    }
    
  4. 让我们为每一列创建一个标签编码器并编码值:

    from sklearn import preprocessing
    label_encoders = {}
    data_frame_encoded = pandas.DataFrame()
    for column in data_frame:
        if column in labels:
            label_encoders[column] = preprocessing.LabelEncoder()
            label_encoders[column].fit(labels[column])
            data_frame_encoded[column] = label_encoders[
                column].transform(data_frame[column])
        else:
            data_frame_encoded[column] = data_frame[column]
    

    让我们验证我们是否一切都做对了:

data_frame_encoded.head()
CheckingAccountStatus DurationMonths CreditHistory CreditPurpose \
0                     0             6             4             4
1                     1             48             2             4
2                     3             12             4             7
3                     0             42             2             3
4                     0             24             3             0
   CreditAmount SavingsAccount EmploymentSince DisposableIncomePercent \
0         1169             4                4                        4
1         5951             0                2                        2
2         2096             0                3                        2
3         7882             0                3                        2
4         4870             0                2                        3
   PersonalStatusSex OtherDebtors     ...     Property Age \
0                 2             0     ...             0 67
1                 1             0     ...             0 22
2                 2             0     ...             0 49
3                 2             2     ...             1 45
4                 2             0     ...             3 53
   OtherInstallmentPlans Housing NumberOfExistingCreditsInBank Job \
0                     2        1                             2    2
1                     2        1                             1    2
2                     2        1                             1    1
3                     2        2                             1    2
4                     2        2                             2    2
   LiabilityNumberOfPeople Phone ForeignWorker CreditScore
0                        1     1             0            1
1                        1     0             0            2
2                        2     0             0            1
3                        2     0             0            1
4                        2     0             0            2
[5 rows x 21 columns]
label_encoders
{'CheckingAccountStatus': LabelEncoder(),
 'CreditHistory': LabelEncoder(),
 'CreditPurpose': LabelEncoder(),
 'EmploymentSince': LabelEncoder(),
 'ForeignWorker': LabelEncoder(),
 'Housing': LabelEncoder(),
 'Job': LabelEncoder(),
 'OtherDebtors': LabelEncoder(),
 'OtherInstallmentPlans': LabelEncoder(),
 'PersonalStatusSex': LabelEncoder(),
 'Phone': LabelEncoder(),
 'Property': LabelEncoder(),
 'SavingsAccount': LabelEncoder()}

所有 21 列都可用,标签编码器也已保存到对象中。我们的数据现在已预处理。

如果您不想解码编码后的值,则不需要保存这些标签编码器。我们只是为了完整性而保存了它们。

  1. 是时候将特征与标签分开。我们可以应用我们在理论部分看到的方法:

    import numpy as np
    features = np.array(
        data_frame_encoded.drop(['CreditScore'], 1)
    )
    label = np.array(data_frame_encoded['CreditScore'])
    

    我们的特征尚未缩放。这是一个问题,因为信用金额的距离可能比年龄的差异显著更高。

    我们必须一起对训练数据和测试数据进行缩放,因此,我们可以在将训练数据从测试数据中分割出来之前进行缩放的最新步骤。

  2. 让我们使用 scikit 的预处理库中的 Min-Max 缩放器:

    scaled_features = preprocessing.MinMaxScaler(
    feature_range=(0,1)).fit_transform(features)
    
  3. 最后一步是交叉验证。我们将打乱我们的数据,并使用所有数据的 80%进行训练,20%进行测试。

    from sklearn import model_selection
    features_train, features_test, label_train,
    label_test = model_selection.train_test_split(
        scaled_features,
        label,
        test_size = 0.2
    )
    

活动 8:提高信用评分的准确性

本节将学习 k 最近邻分类器的参数化如何影响最终结果。信用评分的准确性目前相当低:66.5%。找到一种方法将其提高几个百分点。并且为了确保它正确发生,您需要完成之前的练习。

完成这个练习有很多方法。在这个解决方案中,我将向您展示一种通过改变参数化来提高信用评分的方法。

您必须完成第 13 项练习,才能完成此活动。

  1. 将 k 最近邻分类器的 K 值从默认的 5 增加到 10、15、25 和 50。评估结果:

    You must have completed Exercise 13, to be able to complete this activity
    classifier = neighbors.KNeighborsClassifier(n_neighbors=10)
    classifier.fit(
        features_train,label_train
        )
    classifier.score(features_test, label_test)
    
  2. 在为所有四个n_neighbors值运行这些行之后,我得到了以下结果:

    K=10: accuracy is 71.5%
    K=15: accuracy is 70.5%
    K=25: accuracy is 72%
    K=50: accuracy is 74%
    
  3. 较高的 K 值并不一定意味着更好的分数。然而,在这个例子中,K=50K=5得到了更好的结果。

活动 9:在 scikit-learn 中进行支持向量机优化

本节将讨论如何使用支持向量机分类器的不同参数。我们将通过比较和对比你之前学习过的不同支持向量回归分类器参数,找到一组在之前活动中加载和准备好的训练和测试数据上产生最高分类数据的参数。为确保正确执行,你需要完成之前的活动和练习。

我们将尝试几种组合。你可以选择不同的参数,例如

  1. 线性核

    classifier = svm.SVC(kernel="linear")
    classifier.fit(features_train, label_train)
    classifier.score(features_test, label_test)
    
  2. 四次方多项式核,C=2,gamma=0.05

    classifier = svm.SVC(kernel="poly", C=2, degree=4, gamma=0.05)
    classifier.fit(features_train, label_train)
    classifier.score(features_test, label_test)
    

    输出如下:0.705。

  3. 四次方多项式核,C=2,gamma=0.25

    classifier = svm.SVC(kernel="poly", C=2, degree=4, gamma=0.25)
    classifier.fit(features_train, label_train)
    classifier.score(features_test, label_test)
    

    输出如下:0.76。

  4. 四次方多项式核,C=2,gamma=0.5

    classifier = svm.SVC(kernel="poly", C=2, degree=4, gamma=0.5)
    classifier.fit(features_train, label_train)
    classifier.score(features_test, label_test)
    

    输出如下:0.72。

  5. Sigmoid 核

    classifier = svm.SVC(kernel="sigmoid")
    classifier.fit(features_train, label_train)
    classifier.score(features_test, label_test)
    

    输出如下:0.71。

  6. 默认核,gamma 为 0.15

    classifier = svm.SVC(kernel="rbf", gamma=0.15)
    classifier.fit(features_train, label_train)
    classifier.score(features_test, label_test)
    

    输出如下:0.76。

第五章:使用树进行预测分析

活动 10:汽车数据分类

本节将讨论如何构建一个可靠的决策树模型,以帮助你的公司在寻找可能购买汽车的客户方面发挥作用。我们将假设你受雇于一家汽车租赁代理机构,专注于与客户建立长期关系。你的任务是构建一个决策树模型,将汽车分类为以下四个类别之一:不可接受、可接受、好、非常好。

数据集可以通过以下链接访问:archive.ics.uci.edu/ml/datasets/Car+Evaluation。点击数据文件夹链接下载数据集。点击数据集描述链接以访问属性描述。

评估你的决策树模型的效用。

  1. 从这里下载汽车数据文件:archive.ics.uci.edu/ml/machine-learning-databases/car/car.data。在 CSV 文件的前面添加一个标题行,以便在 Python 中更容易引用:

    Buying,Maintenance,Doors,Persons,LuggageBoot,Safety,Class

    我们简单地将标签称为 Class。我们根据archive.ics.uci.edu/ml/machine-learning-databases/car/car.names中的描述命名了六个特征。

  2. 将数据集加载到 Python 中

    import pandas
    data_frame = pandas.read_csv('car.data')
    

    让我们检查数据是否正确加载:

    data_frame.head()
    Buying Maintenance Doors Persons LuggageBoot Safety Class
    0 vhigh     vhigh     2     2     small    low unacc
    1 vhigh     vhigh     2     2     small    med unacc
    2 vhigh     vhigh     2     2     small high unacc
    3 vhigh     vhigh     2     2         med    low unacc
    4 vhigh     vhigh     2     2         med    med unacc
    
  3. 由于分类与数值数据一起工作,我们必须执行标签编码,如前一章所示。

    labels = {
        'Buying': ['vhigh', 'high', 'med', 'low'],
        'Maintenance': ['vhigh', 'high', 'med', 'low'],
        'Doors': ['2', '3', '4', '5more'],
        'Persons': ['2', '4', 'more'],
        'LuggageBoot': ['small', 'med', 'big'],
        'Safety': ['low', 'med', 'high'],
        'Class': ['unacc', 'acc', 'good', 'vgood']
    }
    from sklearn import preprocessing
    label_encoders = {}
    data_frame_encoded = pandas.DataFrame()
    for column in data_frame:
        if column in labels:
            label_encoders[column] = preprocessing.LabelEncoder()
            label_encoders[column].fit(labels[column])
            data_frame_encoded[column] = label_encoders[column].transform(data_frame[column])
        else:
    data_frame_encoded[column] = data_frame[column]
    
  4. 让我们分离特征和标签:

    import numpy as np
    features = np.array(data_frame_encoded.drop(['Class'], 1))
    label = np.array( data_frame_encoded['Class'] )
    
  5. 是时候使用 scikit-learn 的交叉验证(在新版本中为模型选择)功能来分离训练数据和测试数据了。我们将使用 10%的测试数据:

    from sklearn import model_selection
    features_train, features_test, label_train, label_test = model_selection.train_test_split(
        features,
        label,
        test_size=0.1
    )
    

    注意,train_test_split 方法将在 model_selection 模块中可用,而不是从 scikit-learn 0.20 版本开始在 cross_validation 模块中可用。在之前的版本中,model_selection 已经包含了 train_test_split 方法。

  6. 我们已经拥有了构建决策树分类器所需的一切:

    from sklearn.tree import DecisionTreeClassifier
    decision_tree = DecisionTreeClassifier()
    decision_tree.fit(features_train, label_train)
    

    fit 方法的输出如下:

    DecisionTreeClassifier(
        class_weight=None,
        criterion='gini',
        max_depth=None,
        max_features=None,
        max_leaf_nodes=None,
        min_impurity_decrease=0.0,
        min_impurity_split=None,
        min_samples_leaf=1,
        min_samples_split=2,
        min_weight_fraction_leaf=0.0,
        presort=False,
        random_state=None,
        splitter='best'
    )
    

    您可以看到决策树分类器的参数化。我们可以设置很多选项来调整分类器模型的性能。

  7. 让我们根据测试数据对我们的模型进行评分:

    decision_tree.score( features_test, label_test )
    

    输出如下:

     0.9884393063583815
    
  8. 这就是您在第四章之前的知识将如何帮助您进行模型评估的时刻。现在,我们将进一步深入,基于我们在这个主题中学到的 classification_report 特征创建一个更深入的模型评估:

    from sklearn.metrics import classification_report
    print(
        classification_report(
            label_test,
            decision_tree.predict(features_test)
        )
    )
    

    输出如下:

                 precision    recall f1-score support
             0     0.97     0.97     0.97        36
             1     1.00     1.00     1.00         5
             2     1.00     0.99     1.00     127
             3     0.83     1.00     0.91         5
    avg / total     0.99     0.99     0.99     173
    

    该模型已被证明相当准确。在这种情况下,高分可能表明存在过拟合的可能性。

活动 11:为您的租车公司进行随机森林分类

  1. 本节将优化您的分类器,以便在选择未来车队车辆时更好地满足客户需求。我们将对您在本章活动 1 中处理过的汽车经销商数据集执行随机森林和极端随机森林分类。建议进一步改进模型以提高分类器的性能。

    我们可以重用活动 1 的第 1 步至第 5 步。第 5 步的结束看起来如下:

    from sklearn import model_selection
    features_train, features_test, label_train, label_test = model_selection.train_test_split(
        features,
        label,
        test_size=0.1
    )
    

    如果您使用 IPython,您的变量可能已经在您的控制台中可用。

  2. 让我们创建一个随机森林和一个极端随机化树分类器,并训练这些模型。

    from sklearn.ensemble import RandomForestClassifier,ExtraTreesClassifier
    random_forest_classifier = RandomForestClassifier(n_estimators=100, max_depth=6)
    random_forest_classifier.fit(features_train, label_train)
    extra_trees_classifier =ExtraTreesClassifier(
        n_estimators=100, max_depth=6
    )
    extra_trees_classifier.fit(features_train, label_train)
    
  3. 让我们来评估这两个模型在测试数据上的表现:

    from sklearn.metrics import classification_report
    print(
        classification_report(
            label_test,
            random_forest_classifier.predict(features_test)
        )
    )
    

    模型 1 的输出如下:

                     precision    recall f1-score support
             0     0.78     0.78     0.78        36
             1     0.00     0.00     0.00         5
             2     0.94     0.98     0.96     127
             3     0.75     0.60     0.67         5
    avg / total     0.87     0.90     0.89     173
    

    模型 1 的输出如下:

    print(
        classification_report(
            label_test,
            extra_trees_classifier.predict(features_test)
        )
    )
    
                 precision    recall f1-score support
              0     0.72     0.72     0.72        36
              1     0.00     0.00     0.00         5
              2     0.93     1.00     0.96     127
              3     0.00     0.00     0.00         5
    avg / total     0.83     0.88     0.86     173
    
  4. 我们也可以计算准确度分数:

    random_forest_classifier.score(features_test, label_test)
    

    输出如下:

     0.9017341040462428
    

    extraTreesClassifier的输出如下:

    extra_trees_classifier.score(features_test, label_test)
    

    输出如下:

     0.884393063583815
    

    我们可以看到,随机森林分类器在性能上略优于额外树分类器。

  5. 作为一种初步的优化技术,让我们看看哪些特征更重要,哪些特征不太重要。由于随机化,移除不太重要的特征可能会减少模型中的随机噪声。

    random_forest_classifier.feature_importances_
    

    输出如下:

    array([0.12656512, 0.09934031, 0.02073233, 0.35550329, 0.05411809, 0.34374086])
    

    extra_trees_classifier的输出如下:

    extra_trees_classifier.feature_importances_
    

    输出如下:

    array([0.08699494, 0.07557066, 0.01221275, 0.38035005, 0.05879822, 0.38607338])
    

    两个分类器都认为第三个和第五个属性相当不重要。我们可能对第五个属性不太确定,因为两个模型中的重要性分数都超过 5%。然而,我们可以相当确定第三个属性是决策中最不重要的属性。让我们再次查看特征名称。

    data_frame_encoded.head()
    

    输出如下:

    Buying Maintenance Doors Persons LuggageBoot Safety Class
    0     3            3     0        0            2     1    
    1     3            3     0        0            2     2    
    2     3            3     0        0            2     0    
    3     3            3     0        0            1     1    
    4     3            3     0        0            1     2    
    

    最不重要的特征是车门。事后看来很明显:车门数量对汽车评分的影响不如安全评级大。

  6. 从模型中移除第三个特征并重新训练分类器。

    features2 = np.array(data_frame_encoded.drop(['Class', 'Doors'], 1))
    label2 = np.array(data_frame_encoded['Class'])
    features_train2,
    features_test2,
    label_train2,
    label_test2 = model_selection.train_test_split(
        features2,
        label2,
        test_size=0.1
    )
    random_forest_classifier2 = RandomForestClassifier(
        n_estimators=100, max_depth=6
    )
    random_forest_classifier2.fit(features_train2, label_train2)
    extra_trees_classifier2 = ExtraTreesClassifier(
        n_estimators=100, max_depth=6
    )
    extra_trees_classifier2.fit(features_train2, label_train2)
    
  7. 让我们比较新模型与原始模型的表现如何:

    print(
        classification_report(
            label_test2,
            random_forest_classifier2.predict(features_test2)
        )
    )
    

    输出结果如下:

                precision    recall f1-score support
             0     0.89     0.85     0.87        40
             1     0.00     0.00     0.00         3
             2     0.95     0.98     0.96     125
             3     1.00     1.00     1.00         5
    avg / total     0.92     0.93     0.93     173
    
  8. 第二个模型:

    print(
        classification_report(
            label_test2,
            extra_trees_classifier2.predict(features_test2)
        )
    )
    

    输出结果如下:

                precision    recall f1-score support
             0     0.78     0.78     0.78        40
             1     0.00     0.00     0.00         3
             2     0.93     0.98     0.95     125
             3     1.00     0.40     0.57         5
    avg / total     0.88     0.90     0.88     173
    

    虽然我们确实提高了几个百分点,但请注意,由于以下原因,直接的比较是不可能的。首先,训练-测试分割选择了不同的数据用于训练和测试。一些错误选择的数据点可能会轻易导致分数的几个百分点增加或减少。其次,我们训练分类器的方式也有随机元素。这种随机化也可能稍微改变分类器的性能。在解释结果时,始终使用最佳判断,并在必要时在多个训练-测试分割上多次测量你的结果。

  9. 让我们进一步调整分类器的参数。以下参数集将随机森林分类器的 F1 分数提高到 97%:

    random_forest_classifier2 = RandomForestClassifier(
        n_estimators=150,
        max_ depth=8,
        criterion='entropy',
        max_features=5
    )
    random_forest_classifier2.fit(features_train2, label_train2)
    print(
        classification_report(
            label_test2,
            random_forest_classifier2.predict(features_test2)
        )
    )
    

    输出结果如下:

               precision    recall f1-score support
              0     0.95     0.95     0.95        40
              1     0.50     1.00     0.67         3
              2     1.00     0.97     0.98     125
              3     0.83     1.00     0.91         5
    avg / total     0.97     0.97     0.97     173
    
  10. 在 Extra Trees 分类器上使用相同的参数,我们也得到了令人惊讶的好结果:

    extra_trees_classifier2 = ExtraTreesClassifier(
        n_estimators=150,
        max_depth=8,
        criterion='entropy',
        max_features=5
    )
    extra_trees_classifier2.fit(features_train2, label_train2)
    print(
        classification_report(
            label_test2,
            extra_trees_classifier2.predict(features_test2)
        )
    )
    

    输出结果如下:

                precision    recall f1-score support
              0     0.92     0.88     0.90        40
              1     0.40     0.67     0.50         3
              2     0.98     0.97     0.97     125
              3     0.83     1.00     0.91         5
    avg / total     0.95     0.94     0.94     173
    

第六章:聚类

活动 12:销售数据的 k-means 聚类

本节将检测在本质上表现相似的产品销售,以识别产品销售趋势。

我们将使用以下 URL 的 Sales Transactions Weekly 数据集:

archive.ics.uci.edu/ml/datasets/Sales_Transactions_Dataset_Weekly 使用 k-means 算法对数据集进行聚类。确保你根据前几章学到的知识准备你的聚类数据。

使用 k-means 算法的默认设置。

  1. 使用 pandas 加载数据集。

    import pandas
    pandas.read_csv('Sales_Transactions_Dataset_Weekly.csv')
    
  2. 如果你检查 CSV 文件中的数据,你可以意识到第一列包含产品 ID 字符串。这些值只是给聚类过程添加噪声。同时请注意,对于第 0 周到第 51 周,有一个以 W 为前缀的标签和一个归一化标签。使用归一化标签更有意义,因此我们可以从数据集中删除常规的每周标签。

    import numpy as np
    drop_columns = ['Product_Code']
    for w in range(0, 52):
        drop_columns.append('W' + str(w))
    features = data_frame.drop(dropColumns, 1)
    
  3. 我们的数据点已归一化,除了最小值和最大值

    from sklearn.preprocessing import MinMaxScaler
    scaler = MinMaxScaler()
    scaled_features = scaler.fit_transform(features)
    
  4. 创建一个 k-means 聚类模型,并将数据点拟合到 8 个聚类中。

    from sklearn.cluster import KMeans
    k_means_model = KMeans()
    k_means_model.fit(scaled_features)
    
  5. 可以使用 labels_ 属性检索每个数据点的标签。这些标签决定了原始数据框中行的聚类。

    k_means_model.labels_
    
  6. 从聚类算法中检索中心点和标签:

    k_means_model.cluster_centers_
    

    输出结果如下:

     array([5, 5, 4, 5, 5, 3, 4, 5, 5, 5, 5, 5, 4, 5, 0, 0, 0, 0, 0, 4, 4, 4,
           4, 0, 0, 5, 0, 0, 5, 0, 4, 4, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
           0, 0, 0, 0, 0, 5, 0, 0, 5, 0, 0, 0, 0, 0, 4, 0, 0, 5, 0, 0, 5, 0,
           ...
           1, 7, 3, 2, 6, 7, 6, 2, 2, 6, 2, 7, 2, 7, 2, 6, 1, 3, 2, 2, 6, 6,
           7, 7, 7, 1, 1, 2, 1, 2, 7, 7, 6, 2, 7, 6, 6, 6, 1, 6, 1, 6, 7, 7,
           1, 1, 3, 5, 3, 3, 3, 5, 7, 2, 2, 2, 3, 2, 2, 7, 7, 3, 3, 3, 3, 2,
           2, 6, 3, 3, 5, 3, 2, 2, 6, 7, 5, 2, 2, 2, 6, 2, 7, 6, 1])
    

这些标签有什么好处?

假设原始数据框中给出了产品名称。你可以轻松地认识到相似类型的产品销售情况相似。也有一些波动很大的产品,以及具有季节性的产品。例如,如果某些产品推广减肥和塑形,它们往往在一年中的前半段销售,在海滩季节之前。

活动 13:使用 Mean Shift 算法进行形状识别

本节将学习如何对图像进行聚类。我们将假设你正在为一家公司工作,该公司从照片中检测人类情绪。你的任务是提取头像照片中构成面部的像素。

使用均值漂移算法创建一个聚类算法来聚类图像的像素。检查均值漂移算法的结果,并检查在应用于头像图像时,是否有任何聚类包含面部。

然后使用固定默认数量的聚类数:8,应用 k-means 算法。将你的结果与均值漂移聚类算法进行比较。

  1. 选择你想要聚类的图像并加载图像。

  2. 我们从作者的 YouTube 频道选择了这张图片img/Image00076.jpg

    图 7.13:包含作者图片的图像
  3. 图像大小已经显著减小,以便我们的算法能够更快地终止。

    image = Image.open('destructuring.jpg')
    pixels = image.load()
    
  4. 将像素转换成数据框以执行聚类

    import pandas
    data_frame = pandas.DataFrame(
        [[x,y,pixels[x,y][0], pixels[x,y][1], pixels[x,y][2]]
            for x in range(image.size[0])
            for y in range(image.size[1])
        ],
        columns=['x', 'y', 'r', 'g', 'b']
    )
    
  5. 使用 scikit-learn 在图像上执行均值漂移聚类。请注意,这次我们将跳过特征归一化,因为像素的邻近性和颜色成分的邻近性几乎以相同的权重表示。像素距离的最大差异是 750,而颜色成分的最大差异是 256。

    from sklearn.cluster import MeanShift
    mean_shift_model = MeanShift()
    mean_shift_model.fit(data_frame)
    for i in range(len(mean_shift_model.cluster_centers_)):
        image = Image.open('destructuring.jpg')
        pixels = image.load()
        for j in range(len(data_frame)):
            if (mean_shift_model.labels_[j] != i ):
               pixels[ int(data_frame['x'][j]),
           int(data_frame['y'][j]) ] = (255, 255, 255)
        image.save( 'cluster' + str(i) + '.jpg' )
    
  6. 算法找到了以下两个聚类img/Image00077.jpg

    图 7.14:执行 k-means 聚类后的图像
  7. 均值漂移算法将我的皮肤和黄色 JavaScript 以及解构文本处理得足够接近,以至于形成了相同的聚类。

  8. 让我们使用 k-means 算法在相同的数据上形成八个聚类。

    k_means_model = KMeans(n_clusters=8)
    k_means_model.fit(data_frame)
    for i in range(len(k_means_model.cluster_centers_)):
        image = Image.open('destructuring.jpg')
        pixels = image.load()
        for j in range(len(data_frame)):
            if (k_means_model.labels_[j] != i):
                pixels[int(data_frame['x'][j]), int(data_frame['y'][j])] = (255, 255, 255)
        image.save('kmeanscluster' + str(i) + '.jpg')
    
  9. 以下 8 个聚类如下:

    第一个输出的结果如下:

执行 K-means 聚类后的图像

图 7.15:执行 k-means 聚类后的图像

第二个输出的结果如下:

图 7.16:执行 K-means 聚类后的图像

图 7.16:执行 K-means 聚类后的图像

第三个输出的结果如下:

图 7.17:执行 K-means 聚类后的图像

图 7.17:执行 k-means 聚类后的图像

第四个输出的结果如下:

图 7.18:执行 K-means 聚类后的图像

图 7.18:执行 K-means 聚类后的图像

第五个输出的结果如下:

图 7.19:执行 K-means 聚类后的图像

图 7.19:执行 K-means 聚类后的图像

第六个输出的结果如下:

图 7.20:执行 K-means 聚类后的图像

图 7.20:执行 K-means 聚类后的图像

第七个输出的结果如下:

图 7.21:执行 K-means 聚类后的图像

图 7.21:执行 K-means 聚类后的图像

第八个输出的结果如下:

图 7.22:执行 K-means 聚类后的图像

图 7.22:执行 K-means 聚类后的图像

如你所见,第五个簇很好地识别了我的脸。聚类算法确实定位了接近且颜色相似的数据点。

第七章:使用神经网络的深度学习

活动 14:手写数字检测

  1. 本节将讨论如何通过检测手写数字为加密货币交易者提供更多安全性。我们将假设你是新加密货币交易平台的一名软件开发者。你正在实施的最新安全措施需要识别手写数字。使用 MNIST 库训练一个神经网络来识别数字。你可以在 www.tensorflow.org/tutorials/ 上了解更多关于这个数据集的信息。

  2. 尽可能提高模型的准确度。为了确保正确发生,你需要完成前面的主题。

  3. 加载数据集并格式化输入

    import tensorflow.keras.datasets.mnist as mnist
    (features_train, label_train),
    (features_test, label_test) = mnist.load_data()
    features_train = features_train / 255.0
    features_test = features_test / 255.0
    def flatten(matrix):
        return [elem for row in matrix for elem in row]
    features_train_vector = [
        flatten(image) for image in features_train
    ]
    features_test_vector = [
        flatten(image) for image in features_test
    ]
    import numpy as np
    label_train_vector = np.zeros((label_train.size, 10))
    for i, label in enumerate(label_train_vector):
        label[label_train[i]] = 1
    label_test_vector = np.zeros((label_test.size, 10))
    for i, label in enumerate(label_test_vector):
        label[label_test[i]] = 1
    
  4. 设置 Tensorflow 图。现在我们将使用 relu 函数而不是 sigmoid 函数。

    import tensorflow as tf
    f = tf.nn.softmax
    x = tf.placeholder(tf.float32, [None, 28 * 28 ])
    W = tf.Variable( tf.random_normal([784, 10]))
    b = tf.Variable( tf.random_normal([10]))
    y = f(tf.add(tf.matmul( x, W ), b ))
    
  5. 训练模型。

    import random
    y_true = tf.placeholder(tf.float32, [None, 10])
    cross_entropy = tf.nn.softmax_cross_entropy_with_logits_v2(
        logits=y,
        labels=y_true
    )
    cost = tf.reduce_mean(cross_entropy)
    optimizer = tf.train.GradientDescentOptimizer(
        learning_rate = 0.5
    ).minimize(cost)
    session = tf.Session()
    session.run(tf.global_variables_initializer())
    iterations = 600
    batch_size = 200
    sample_size = len(features_train_vector)
    for _ in range(iterations):
        indices = random.sample(range(sample_size), batchSize)
        batch_features = [
            features_train_vector[i] for i in indices
        ]
        batch_labels = [
            label_train_vector[i] for i in indices
        ]
        min = i * batch_size
        max = (i+1) * batch_size
        dictionary = {
            x: batch_features,
            y_true: batch_labels
        }
        session.run(optimizer, feed_dict=dictionary)
    
  6. 测试模型

    label_predicted = session.run(classify( x ), feed_dict={
        x: features_test_vector
    })
    label_predicted = [
        np.argmax(label) for label in label_predicted
    ]
    confusion_matrix(label_test, label_predicted)
    

    输出如下:

    array([[ 0, 0, 223, 80, 29, 275, 372, 0, 0, 1],
       [ 0, 915, 4, 10, 1, 13, 192, 0, 0, 0],
       [ 0, 39, 789, 75, 63, 30, 35, 0, 1, 0],
       [ 0, 6, 82, 750, 13, 128, 29, 0, 0, 2],
       [ 0, 43, 16, 16, 793, 63, 49, 0, 2, 0],
       [ 0, 22, 34, 121, 40, 593, 76, 5, 0, 1],
       [ 0, 29, 34, 6, 44, 56, 788, 0, 0, 1],
       [ 1, 54, 44, 123, 715, 66, 24, 1, 0, 0],
       [ 0, 99, 167, 143, 80, 419, 61, 0, 4, 1],
       [ 0, 30, 13, 29, 637, 238, 58, 3, 1, 0]], dtype=int64)
    
  7. 计算准确度分数:

    accuracy_score(label_test, label_predicted)
    

    输出如下:

     0.4633
    
  8. 通过重新运行负责训练数据集的代码段,我们可以提高准确度:

    for _ in range(iterations):
        indices = random.sample(range(sample_size), batch_size)
        batch_features = [
            features_train_vector[i] for i in indices
        ]
        batch_labels = [
            label_train_vector[i] for i in indices
        ]
        min = i * batch_size
        max = (i+1) * batch_size
        dictionary = {
            x: batch_features,
            y_true: batch_labels
        }
        session.run(optimizer, feed_dict=dictionary)
    

    第二次运行:0.5107

    第三次运行:0.5276

    第四次运行:0.5683

    第五次运行:0.6002

    第六次运行:0.6803

    第七次运行:0.6989

    第八次运行:0.7074

    第九次运行:0.713

    第十次运行:0.7163

    第二十次运行:0.7308

    第三十次运行:0.8188

    第四十次运行:0.8256

    第五十次运行:0.8273

    在第五十次运行的末尾,改进后的混淆矩阵如下:

    array([
     [946, 0,    6,    3,    0,    1, 15,    2,    7,    0],
     [ 0,1097,    3,    7,    1,    0,    4,    0, 23,    0],
     [11, 3, 918, 11, 18,    0, 13,    8, 50,    0],
     [3,    0, 23, 925,    2, 10,    4,    9, 34,    0],
     [2,    2,    6,    1, 929,    0, 14,    2, 26,    0],
     [16, 4,    7, 62,    8, 673, 22,    3, 97,    0],
     [8,    2,    4,    3,    8,    8, 912,    2, 11,    0],
     [5,    9, 33,    6,    9,    1,    0, 949, 16,    0],
     [3,    4,    5, 12,    7,    4, 12,    3, 924,    0],
     [8,    5,    7, 40, 470, 11,    5, 212, 251,    0]
    ],
         dtype=int64)
    

结果还不错。超过 8 个数字被准确识别。

活动 15:使用深度学习进行手写数字检测

本节将讨论深度学习如何提高你模型的性能。我们将假设你的老板对你之前活动中展示的结果不满意,要求你考虑在你的原始模型中添加两个隐藏层,并确定新层是否提高了模型的准确度。为了确保正确发生,你需要了解深度学习。

  1. 执行前一个活动的代码并测量模型的准确度。

  2. 通过添加新层来改变神经网络。我们将结合 relusoftmax 激活函数:

    x = tf.placeholder(tf.float32, [None, 28 * 28 ])
    f1 = tf.nn.relu
    W1 = tf.Variable(tf.random_normal([784, 200]))
    b1 = tf.Variable(tf.random_normal([200]))
    layer1_out = f1(tf.add(tf.matmul(x, W1), b1))
    f2 = tf.nn.softmax
    W2 = tf.Variable(tf.random_normal([200, 100]))
    b2 = tf.Variable(tf.random_normal([100]))
    layer2_out = f2(tf.add(tf.matmul(layer1_out, W2), b2))
    f3 = tf.nn.softmax
    W3 = tf.Variable(tf.random_normal([100, 10]))
    b3 = tf.Variable( tf.random_normal([10]))
    y = f3(tf.add(tf.matmul(layer2_out, W3), b3))
    
  3. 重新训练模型

    y_true = tf.placeholder(tf.float32, [None, 10])
    cross_entropy = tf.nn.softmax_cross_entropy_with_logits_v2(
        logits=y,
        labels=y_true
    )
    cost = tf.reduce_mean(cross_entropy)
    optimizer = tf.train.GradientDescentOptimizer(
    learning_rate=0.5).minimize(cost)
    session = tf.Session()
    session.run(tf.global_variables_initializer())
    iterations = 600
    batch_size = 200
    sample_size = len(features_train_vector)
    for _ in range(iterations):
        indices = random.sample(range(sample_size), batchSize)
        batch_features = [
            features_train_vector[i] for i in indices
        ]
        batch_labels = [
            label_train_vector[i] for i in indices
        ]
        min = i * batch_size
        max = (i+1) * batch_size
        dictionary = {
            x: batch_features,
            y_true: batch_labels
        }
        session.run(optimizer, feed_dict=dictionary)
    
  4. 评估模型

    label_predicted = session.run(y, feed_dict={
        x: features_test_vector
    })
    label_predicted = [
        np.argmax(label) for label in label_predicted
    ]
    confusion_matrix(label_test, label_predicted)
    

    输出如下:

    array([[ 801, 11,    0, 14,    0,    0, 56,    0, 61, 37],
         [ 2, 1069,    0, 22,    0,    0, 18,    0,    9, 15],
         [ 276, 138,    0, 225,    0,    2, 233,    0, 105, 53],
         [ 32, 32,    0, 794,    0,    0, 57,    0, 28, 67],
         [ 52, 31,    0, 24,    0,    3, 301,    0, 90, 481],
         [ 82, 50,    0, 228,    0,    3, 165,    0, 179, 185],
         [ 71, 23,    0, 14,    0,    0, 712,    0, 67, 71],
         [ 43, 85,    0, 32,    0,    3, 31,    0, 432, 402],
         [ 48, 59,    0, 192,    0,    2, 45,    0, 425, 203],
         [ 45, 15,    0, 34,    0,    2, 39,    0, 162, 712]],
         dtype=int64)
    
  5. 计算准确度分数。

    accuracy_score(label_test, label_predicted)
    

输出为 0.4516

准确度没有提高。

让我们看看进一步的运行是否能提高模型的准确度。

第二次运行:0.5216

第三次运行:0.5418

第四次运行:0.5567

第五次运行:0.564

第六次运行:0.572

第七次运行:0.5723

第八次运行:0.6001

第九次运行:0.6076

第十次运行:0.6834

第二十次运行:0.7439

第三十次运行:0.7496

第四十次运行:0.7518

第五十次运行:0.7536

此后,我们得到了以下结果:0.755,0.7605,0.7598,0.7653

最终的混淆矩阵:

array([[ 954,    0,    2,    1,    0,    6,    8,    0,    5,    4],
     [ 0, 1092,    5,    3,    0,    0,    6,    0, 27,    2],
     [ 8,    3, 941, 16,    0,    2, 13,    0, 35, 14],
     [ 1,    1, 15, 953,    0, 14,    2,    0, 13, 11],
     [ 4,    3,    8,    0,    0,    1, 52,    0, 28, 886],
     [ 8,    1,    5, 36,    0, 777, 16,    0, 31, 18],
     [ 8,    1,    6,    1,    0,    6, 924,    0,    9,    3],
     [ 3, 10, 126, 80,    0,    4,    0,    0, 35, 770],
     [ 4,    0,    6, 10,    0,    6,    4,    0, 926, 18],
     [ 4,    5,    1,    8,    0,    2,    2,    0, 18, 969]],
     dtype=int64)

这个深度神经网络的行为甚至比单层网络更加混沌。它需要经过 600 次迭代,每次迭代 200 个样本,才能将准确率从 0.572 提升到 0.5723。在这个迭代过程不久之后,我们就在同样的迭代次数内将准确率从 0.6076 提升到了 0.6834。

posted @ 2025-09-04 14:13  绝不原创的飞龙  阅读(6)  评论(0)    收藏  举报