Python-人工智能秘籍-全-

Python 人工智能秘籍(全)

原文:zh.annas-archive.org/md5/11b175e592527142ad4d19f0711517be

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

人工智能(AI)是一门致力于以某种方式自动化任务的领域,以展示对人类观众具有某种智能形式。这种表现的智能可能类似于人类智能,或者只是机器或程序给我们带来的一些见解性行动。随着我们对世界的理解随着我们的工具而改善,我们对于什么会让我们感到惊讶或认为具有智能的期望不断提高。AI 领域的知名研究人员 Rodney Brooks 表达了这种效应(通常称为AI 效应):

每当我们揭开其中一部分的面纱,它就不再神秘;我们会说,“哦,那只是一种计算。”我们曾开玩笑说,AI 的意思是“几乎实现”。

(引自 Jennifer Kahn(2002 年 3 月)。It's Alive,刊登于Wired,10(30):www.wired.com/2002/03/everywhere/

近年来,AI 在硬件方面取得了巨大进展,例如图形处理单元(GPUs)和现在的张量处理单元(TPUs),这些硬件可以支持更强大的模型,如具有数十万、百万甚至数十亿参数的深度学习模型。这些模型在基准测试中表现越来越好,通常达到人类甚至超人类水平。对于任何涉足该领域的人来说,一些经过数千小时训练的模型,如果在亚马逊网络服务(AWS)上运行,其价值可能达到数十万美元,现在可以下载使用和扩展。

这种在图像处理、音频处理和越来越多的自然语言处理中表现出来的性能飞跃特别显著。在游戏领域中,这种表现在媒体上尤为突出。虽然 1997 年卡斯帕罗夫与深蓝的国际象棋比赛仍深入人心,但可以说,机器对人类国际象棋冠军的成功主要归因于强大超级计算机每秒搜索和分析 2 亿个位置的蛮力。然而,此后,算法和计算能力的结合使得机器在更复杂的游戏中表现出了熟练和掌握。

下表展示了 AI 的进展:

游戏 冠军年份 合法状态(以 10 为底的对数)
黑白棋(翻转棋) 1997 28
跳棋(国际象棋) 1994 21
国际象棋 1997 46
Scrabble 2006
Shogi 2017 71
围棋 2016 172
2p 无限制德州扑克 2017
星际争霸 - 270+

请参考维基百科文章人工智能进展以获取更多信息。你可以看到,对于各种复杂性不同的游戏(如第三列所示,显示 10 的幂中的合法状态),AI 何时达到了顶级人类玩家的水平。更普遍地说,你可以在专门的网站上找到有关不同学科最新性能的更多信息:paperswithcode.com/sota

因此,现在比以往任何时候更及时地查看并学习使用 AI 的最先进方法,这就是本书的内容。你将找到精心选择的配方,这些配方将帮助你更新你的知识并了解最新的算法。

如果你希望为工作或甚至是业余项目构建 AI 解决方案,你会发现这本食谱书很有用。借助易于遵循的配方,本书将带你了解构建解决问题的智能模型所需的 AI 算法。通过本书,你将能够确定解决应用问题的 AI 方法,实现和测试算法,并处理模型版本控制、报告和监控。

本书适合谁

这本 AI 机器学习书籍适用于 Python 开发者、数据科学家、机器学习工程师和深度学习从业者,他们想要学习如何用简单的配方构建人工智能解决方案。如果你希望在各种使用情境中找到执行不同机器学习任务的最新解决方案,本书也会对你有所帮助。掌握 Python 编程语言和机器学习概念的基本工作知识将有助于你在本书中有效地处理代码。

本书内容涵盖了以下内容

第一章,Python 中的人工智能入门,描述了使用 Python 进行数据处理和 AI 的基本设置。我们将在 pandas 中进行数据加载、绘图,并在 scikit-learn 和 Keras 中编写第一个模型。由于数据准备是一项耗时的活动,我们将介绍最先进的技术来简化这一活动。

第二章,监督机器学习的高级主题,讲述了如何处理监督机器学习中的常见问题,如类别不平衡、时间序列以及处理算法偏差。

第三章,模式、异常值和推荐,通过一个涉及真实世界聚类的示例,讲述了如何使用 sklearn 和 Keras 检测数据中的异常和离群值。接着我们将介绍如何构建一个模糊字符串匹配的最近邻搜索、通过构建潜在空间进行协同过滤以及在图网络中进行欺诈检测。

第四章,概率建模,解释了如何构建预测股票价格的概率模型,以及如何在不确定条件下估计客户寿命,诊断疾病和量化信用风险。

第五章,启发式搜索技术和逻辑推理,介绍了广泛的问题解决工具类,从本体论和基于知识的推理开始,到在满足性背景下的优化,以及使用粒子群算法、遗传算法等方法的组合优化。我们将模拟多主体系统中的疫情传播,为棋盘引擎实现蒙特卡洛树搜索,编写基本的逻辑求解器,并通过图算法嵌入知识。

第六章,深度强化学习,应用多臂老丨虎丨机优化网站,实施 REINFORCE 算法用于控制任务和简单游戏的深度 Q 网络。

第七章,高级图像应用,带您从基础到最先进的图像识别方法之旅。然后,我们将学习如何使用生成对抗网络创建图像样本,以及如何使用对抗自编码器进行风格转移。

第八章,移动图像处理,从视频流中的图像检测开始,然后使用深度伪造模型创建视频。

第九章,音频与语音深度学习,对不同的语音命令进行分类,然后介绍了文本到语音架构,并以递归神经网络模型和生成音乐序列的配方结束。

第十章,自然语言处理,解释了如何分类情感,创建聊天机器人,并使用序列到序列模型翻译文本。最后,我们将尝试使用最先进的文本生成模型撰写一本流行小说。

第十一章,生产中的人工智能,涵盖了监控和模型版本控制,可视化为仪表板,并解释了如何保护模型免受可能泄露用户数据的恶意黑客攻击。

为了从本书中获得最大收益

第一章,Python 中的人工智能入门,详细介绍了需求和适当的设置,包括环境的安装,您还将学到许多提高效率的实用技巧。通常情况下,您需要配置 Python 及其库,如 scikit-learn、PyTorch 和 TensorFlow。我们推荐使用 GPU 或在线服务,如 Google Colab。所有代码样本在 Linux 和 macOS 上均经过测试。但必要时,我们会针对 Windows 操作系统进行评论。

本书中涵盖的一些最显著的软件和库列在以下表中:

本书涵盖的软件/硬件 操作系统要求
Python 3.6 或更新版本 Windows、macOS X 和 Linux(任意)
TensorFlow 2.0 或更新版本 Windows、macOS X 和 Linux(任意)
PyTorch 1.6 或更新版本 Windows、macOS X 和 Linux(任意)
Pandas 1.0 或更新版本 Windows、macOS X 和 Linux(任意)
Scikit-learn 0.22.0 或更新版本 Windows、macOS X 和 Linux(任意)

如果您正在使用本书的数字版本,建议您自己输入代码或通过 GitHub 仓库(链接在下一节中提供)访问代码。这样做可以避免与复制粘贴代码相关的任何潜在错误。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件,网址为 github.com/PacktPublishing/Artificial-Intelligence-with-Python-Cookbook。如果代码有更新,将在现有的 GitHub 仓库上更新。

我们还提供来自我们丰富书籍和视频目录的其他代码包,可在 github.com/PacktPublishing/ 上查看!

下载彩色图片

您可以从 GitHub 下载本书的示例代码文件,网址为 static.packt-cdn.com/downloads/9781789133967_ColorImages.pdf。如果代码有更新,将在现有的 GitHub 仓库上更新。

我们还提供来自我们丰富书籍和视频目录的其他代码包,可在 github.com/PacktPublishing/ 上查看!

使用的约定

本书中使用了多种文本约定。

CodeInText:指示文本中的代码字词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟网址、用户输入和 Twitter 句柄。例如:“这里是RangeTransformer的简化代码。”

代码块设置如下:

import openml
dataset = openml.datasets.get_dataset(40536)
X, y, categorical_indicator, _ = dataset.get_data(
  dataset_format='DataFrame',
  target=dataset.default_target_attribute
)

当我们希望引起您对代码块特定部分的注意时,相关行或项目将以粗体显示:

import operator
operator.sub(1, 2) == 1 - 2
# True

任何命令行输入或输出如下所示:

$ mkdir css
$ cd css

粗体:表示新术语、重要词汇或屏幕上显示的文字。例如,菜单或对话框中的单词会以这种方式出现在文本中。这里有一个例子:“从管理面板中选择系统信息。”

警告或重要提示会以这种方式出现。

提示和技巧会以这种方式出现。

章节

在本书中,您将经常看到几个标题(准备就绪如何操作工作原理还有更多另请参阅)。

要清晰地说明如何完成配方,请使用以下章节:

准备就绪

本节告诉您配方中可以期待什么,并描述如何设置配方所需的任何软件或初步设置。

如何操作…

本节包含了跟随配方所需的步骤。

工作原理…

本节通常详细解释了前一节中发生的事情。

还有更多内容…

本节包含有关配方的附加信息,以进一步增加你对其的了解。

另请参阅

本节为配方提供了有用链接,指向其他相关信息。

联系我们

我们的读者反馈一直受欢迎。

一般反馈:如果您对本书的任何方面有疑问,请在消息主题中提及书名,并发送邮件至 customercare@packtpub.com

勘误:尽管我们已尽一切努力确保内容的准确性,但错误不可避免。如果您在本书中发现错误,我们将不胜感激地接受您的反馈。请访问 www.packtpub.com/support/errata,选择您的书籍,点击“勘误提交表格”链接,并输入详细信息。

盗版:如果您在互联网上发现我们作品的任何形式的非法拷贝,请向我们提供位置地址或网站名称,我们将不胜感激。请通过链接 copyright@packt.com 联系我们。

如果你有兴趣成为作者:如果你在某个主题上有专业知识,并且有意编写或贡献一本书,请访问 authors.packtpub.com

评论

请留下您的评论。一旦您阅读并使用了本书,为什么不在购买它的网站上留下一篇评论呢?潜在的读者可以通过您的公正意见来做购买决策,我们在 Packt 可以了解您对我们产品的看法,而我们的作者可以看到您对他们书籍的反馈。谢谢!

欲了解更多关于 Packt 的信息,请访问 packt.com

第一章:Python 中的人工智能入门

在本章中,我们将首先设置一个 Jupyter 环境来运行我们的实验和算法,我们将介绍不同的巧妙 Python 和 Jupyter 的 AI 技巧,我们将在 scikit-learn、Keras 和 PyTorch 中进行一个玩具示例,然后在 Keras 中进行稍微复杂的示例以完成这些事情。本章主要是介绍性的,本章中看到的许多内容将在后续章节中进一步构建,因为我们将涉及更高级的应用。

在本章中,我们将涵盖以下示例:

  • 设置 Jupyter 环境

  • Python AI 熟练掌握

  • 在 scikit-learn、Keras 和 PyTorch 中进行分类

  • 使用 Keras 进行建模

技术要求

为了能够运行本书中的一些示例,您确实应该有一个 GPU 可用,或者最好使用 Google Colab。需要一些额外步骤来确保您安装了正确的 NVIDIA 图形驱动程序,以及一些额外的库。在 TensorFlow 网站上,Google 提供了有关最新的 NVIDIA 驱动程序的安装指南,网址为 https:/​/​www. tensorflow.​org/​install/​gpu。同样,PyTorch 版本对 NVIDIA 驱动程序版本有最低要求(您需要手动检查每个 PyTorch 版本)。让我们看看如何使用 Docker 环境来帮助设置这些。

您可以在本书的 GitHub 存储库中找到本章的示例,网址为 github.com/PacktPublishing/Artificial-Intelligence-with-Python-Cookbook

设置 Jupyter 环境

正如你所知,自从你获取了这本书以来,Python 已经成为人工智能领域的主导编程语言。它拥有所有编程语言中最丰富的生态系统,包括许多最先进算法的实现,使用它们通常只需简单导入并设置少量参数。无需多言,在许多情况下我们将超越基本用法,并在我们深入实例时讨论很多潜在的思想和技术。

我们无法强调快速原型设计和查看其作为解决方案的效果的重要性。这通常是 AI 或数据科学工作的主要部分。当将想法转化为原型时,读取-求值-打印循环(REPL)是快速迭代的关键,您希望具有编辑历史、图形化等功能。这就解释了为什么 Jupyter Notebook(其中 Jupyter 简称 Julia, Python, R)在 AI 工作中如此重要。

请注意,尽管我们将专注于 Jupyter Notebook 或 Google Colab(它在云中运行 Jupyter 笔记本),但还有几个功能上类似的替代方案,例如 JupyterLab 或者使用远程解释器运行的 PyCharm。然而,Jupyter Notebook 仍然是最流行(可能也是最受支持)的选择。

在这个步骤中,我们将确保我们有一个正常工作的 Python 环境,并安装我们在本书中需要的软件库。我们将处理安装与 AI 相关的 Python 库,并设置一个 Jupyter Notebook 服务器。

准备工作

首先确保您已安装 Python,并有安装库的方法。根据以下两种情况使用和安装库有不同的方法:

  • 您可以使用像 Google Colab 这样的服务来托管交互式笔记本。

  • 您可以在自己的机器上安装 Python 库。

在 Python 中,模块是一个包含函数、变量或类的 Python 文件。是在同一路径中包含多个模块的集合。是相关功能的集合,通常以不同的包或模块形式存在。非正式地,将 Python 库称为包是很常见的,我们这里有时也会这样称呼。

如何实施...

让我们设置我们的 Python 环境!

正如我们提到的,我们将看两种情况:

  • 使用 Google Colab

  • 自行设置计算机以托管 Jupyter Notebook 实例

在第一种情况下,我们无需在服务器上设置任何东西,只需安装几个额外的库。在第二种情况下,我们将安装 Anaconda 发行版的环境,并查看 Jupyter 的设置选项。

在这两种情况下,我们将有一个交互式的 Python 笔记本,通过它我们将运行大部分的实验。

在 Google Colab 中安装库

Google Colab 是 Jupyter Notebook 的修改版本,在 Google 硬件上运行,并提供访问支持硬件加速(如 TPU 和 GPU)的运行时。

使用 Colab 的缺点是最长运行时间为 12 小时;也就是说,超过 12 小时的作业将停止。如果想要避开这个限制,可以采取以下任一方法:

对于 Google Colab,只需访问 colab.research.google.com/,使用您的 Google 凭据登录。在接下来的部分,我们将处理在自己的机器上托管笔记本。

在 Google Colab 中,您可以将模型保存到 Google 服务器的远程磁盘并重新加载。从那里,您可以将模型下载到自己的计算机或与 Google Drive 同步。Colab GUI 为这些用例提供了许多有用的代码片段。以下是如何从 Colab 下载文件的方法:

from joblib import dump

dump(

my_model,

'my_model_auc0.84.joblib'

)

files.download('my_model_auc0.84.joblib')

自托管 Jupyter Notebook 环境

有不同的方法来维护您的 Python 库(有关更多详细信息,请参阅packaging.python.org/tutorials/installing-packages/)。对于 Jupyter Notebook 和所有库的安装,我们推荐 Anaconda Python 分发版,它与conda环境管理器兼容。

Anaconda 是一个 Python 发行版,带有自己的包安装程序和环境管理器,称为conda。这使得保持你的库更新更容易,它还处理系统依赖性管理以及 Python 依赖性管理。稍后我们会提到一些 Anaconda/conda 的替代方案;现在,我们将快速浏览本地安装的说明。在线材料中,您会找到说明如何为团队中的其他人提供类似安装,例如在使用 docker 化设置的公司中,这有助于管理设置一个或一组机器上的 Python 环境。

如果您的计算机已经设置好,并且您熟悉condapip,请随意跳过此部分。

对于 Anaconda 安装,我们需要下载一个安装程序,然后选择一些设置:

  1. 转到 Anaconda 分发页面,网址是www.anaconda.com/products/individual,下载适合您系统的 Python 3.7 的安装程序,例如 64 位(x86)安装程序(506 MB)。

Anaconda 支持 Linux、macOS 和 Windows 安装程序。

对于 macOS 和 Windows,您也可以选择图形安装程序。这在 Anaconda 文档中都有详细说明;但是,我们将快速浏览终端安装。

  1. 从您的终端执行下载的 shell 脚本:
bash Anaconda3-2019.10-Linux-x86_64.sh

你需要阅读并确认许可协议。你可以通过按下空格键直到看到要求同意的问题。你需要按下Y然后按Enter

您可以选择建议的下载位置或选择在您计算机上共享的目录。完成后,您可以享用美味的咖啡或观看 Python 和许多 Python 库的安装。

最后,您可以决定是否运行 conda init 程序。这将在您的终端上设置 PATH 变量,以便在输入 pythonpipcondajupyter 时,conda 版本将优先于计算机上安装的任何其他版本。

请注意,在基于 Unix/Linux 的系统上,包括 macOS,您始终可以按如下方式检查您正在使用的 Python 二进制文件的位置:

> which Python

在 Windows 上,您可以使用 where.exe 命令。

如果看到类似以下内容,则知道您正在使用正确的 Python 运行时:

/home/ben/anaconda3/bin/Python

如果未看到正确的路径,可能需要运行以下命令:

source ~/.bashrc

这将设置您的环境变量,包括 PATH。在 Windows 上,您需要检查您的 PATH 变量。

同一台机器上也可以设置和切换不同的环境。Anaconda 默认带有 Jupyter/iPython,因此您可以通过终端启动 Jupyter 笔记本,如下所示:

> jupyter notebook

当您看到 Jupyter Notebook 服务器启动时,部分信息将打印到屏幕上,包括登录的 URL。

如果您从访问的服务器运行此操作,请确保使用 GNU screen 或 tmux 等屏幕复用器,以确保您的 Jupyter Notebook 客户端在终端断开连接后不会停止。

我们将在本书中使用许多库,如 pandas、NumPy、scikit-learn、TensorFlow、Keras、PyTorch、Dash、Matplotlib 等,因此在逐步介绍配方时,我们将经常进行安装,通常如下所示:

pip install <LIBRARY_NAME>

或者,有时候像这样:

conda install <LIBRARY_NAME>

如果使用 conda 的 pip 或直接使用 conda,这意味着所有库将由 Anaconda 的 Python 安装管理。

  1. 您可以像这样安装前述的库:
pip install scikit-learn pandas numpy tensorflow-gpu torch

请注意,对于 tensorflow-gpu 库,您需要有可用且准备好使用的 GPU。如果没有,将其更改为 tensorflow(即去掉 -gpu)。

这将使用 Anaconda 提供的 pip 二进制文件来安装前述库。请注意,Keras 是 TensorFlow 库的一部分。

或者,您可以如下运行 conda 包安装程序:

conda install scikit-learn pandas numpy tensorflow-gpu pytorch

干得好!您已成功设置好计算机,准备开始使用即将介绍的许多精彩的配方。

工作原理...

Conda 是一个环境和包管理器。与本书中将使用的许多其他库以及 Python 语言本身一样,conda 是开源的,因此我们总是可以准确了解算法的操作并轻松修改它。Conda 还是跨平台的,不仅支持 Python,还支持 R 和其他语言。

包管理可能会带来许多棘手的挑战,如果您已经使用了一段时间,可能会记得花费许多时间处理诸如冲突依赖关系、重新编译包和修复路径等问题 —— 如果只是这些问题的话,您可能算是幸运的。

Conda 不仅仅是之前的 pip 包管理器(参见 pip.pypa.io/en/stable/),它还检查环境中所有安装的包的依赖关系,并尝试找出解决所有要求的方法。它不仅安装包,还允许我们设置具有来自不同软件仓库的 Python 和二进制文件的环境,如 Bioconda(bioconda.github.io/,专门用于生物信息学)或 Anaconda 仓库(anaconda.org/anaconda/repo)。

有数百个专门的渠道可以与 conda 一起使用。这些是包含数百甚至数千种不同包的子仓库。其中一些由开发特定库或软件的公司维护。

例如,您可以按如下方式从 PyTorch 渠道安装 pytorch 包:

conda install -c pytorch pytorch

虽然尝试启用许多渠道以获取所有领域的最新技术是非常诱人的。然而,这里有一个要注意的地方。如果您启用了许多渠道,或者是非常大的渠道,conda 的依赖解析可能会变得非常慢。因此,在使用许多额外的渠道时要小心,特别是如果它们包含大量的库。

还有更多内容……

您可能应该熟悉一些 Jupyter 的选项。这些选项在文件 $HOME/.jupyter/jupyter_notebook_config.py 中。如果您还没有这个文件,可以使用以下命令创建它:

> jupyter notebook --generate-config

这里是 /home/ben/.jupyter/jupyter_notebook_config.py 的一个示例配置:

import random, string
from notebook.auth import passwd

c = get_config()
c.NotebookApp.ip = '*'

password = ''.join(random.SystemRandom().choice(string.ascii_letters + string.digits + string.punctuation) for _ in range(8))
print('The password is {}'.format(password))
c.NotebookApp.password = passwd(password)
c.NotebookApp.open_browser = False
c.NotebookApp.port = 8888

如果您将 Python 环境安装在希望从笔记本电脑访问的服务器上(我把我的本地计算服务器放在了阁楼上),您首先需要确保可以从其他计算机(如笔记本电脑)远程访问计算服务器(c.NotebookApp.ip = '*')。

然后我们创建一个随机密码并进行配置。我们禁用在运行 Jupyter Notebook 时自动打开浏览器的选项,然后将默认端口设置为 8888

因此,当您在浏览器中打开 localhost:8888 时,Jupyter Notebook 将可用。如果您作为较大组织中的团队一部分,大部分时间都在远程数值计算机上工作,那么为了方便起见,您或者您的系统管理员可以设置托管的 Jupyter Notebook 环境。这有几个优点:

  • 您可以通过浏览器简单地访问强大服务器的资源。

  • 您可以在该服务器上的封闭环境中管理您的包,而不会影响服务器本身。

  • 您将发现自己正在与 Jupyter Notebook 熟悉的 REPL 交互,它允许您快速测试想法和原型项目。

如果你是单独工作,你不需要这个工具;但是,如果你在团队中工作,你可以使用 Docker 或 JupyterHub 将每个人放入独立的环境中。在线上,你可以找到设置 Jupyter 环境与 Docker 的设置指南。

另请参阅

你可以在以下各自的文档站点上阅读更多关于 conda、Docker、JupyterHub 及其他相关工具的信息:

在 Python AI 方面提高技能

在这组快速操作的示例中,我们将看看如何在 Jupyter 中变得更加高效,并编写更有效率的 Python 代码。如果你对 Jupyter Notebook 不熟悉,请先阅读一篇教程,然后再回到这里。你可以在realPython.com/jupyter-notebook-introduction/找到一篇写得很好的教程。在接下来的示例中,我们假设你对 Jupyter Notebook 有一定的了解。

让我们看看一些简单但非常实用的技巧,使在笔记本中工作更加舒适和高效。这些技巧适用于无论你是在本地环境还是托管的 Python 环境中工作。

在这个示例中,我们将探讨在你的笔记本中工作并为 AI 解决方案编写 Python 代码时,可以帮助你提高效率的各种技巧。一些内置或外部可用的魔术命令或扩展也可能会派上用场(详细信息请参阅iPython.readthedocs.io/en/stable/interactive/magics.html)。

当涉及到机器学习时,尤其是处理较大数据集或更复杂算法时,了解一些 Python 的效率技巧非常重要。有时,你的任务可能需要很长时间才能完成,但通常有解决的办法。例如,加速完成任务的一个相对容易的方法是使用并行处理。

以下简短的示例涵盖了以下内容:

  • 获取 Jupyter 命令历史记录

  • 自动重新加载包

  • 调试

  • 计时代码执行时间

  • 编译你的代码

  • 加速 pandas DataFrames

  • 显示进度条

  • 并行化你的代码

准备工作

如果您使用自己的安装,无论是直接在您的系统上还是在 Docker 环境中,都要确保它正在运行。然后将您的 Colab 或 Jupyter 笔记本实例的地址放入浏览器中,然后按下Enter键。

我们将使用以下库:

  • 使用tqdm来显示进度条

  • 使用swifter来加快 pandas 的处理速度

  • 使用rayjoblib进行多进程处理

  • 使用numba进行即时编译(JIT)

  • 使用jax(稍后在本节中)进行带有自动微分的数组处理

  • 使用cython在笔记本中编译 Cython 扩展

我们可以像之前一样使用pip来安装它们:

pip install swifter tqdm ray joblib jax jaxlib seaborn numba cython

完成这些后,让我们来看看一些提高在 Jupyter 中工作效率的技巧。

怎么做...

这里的小贴士简洁明了,都提供了在 Jupyter 和 Python 中提高生产力的方法。

如果没有特别说明,所有的代码都需要在笔记本中运行,或者更准确地说,在一个笔记本单元格中。

让我们来看看这些小贴士吧!

获取 Jupyter 命令和输出的历史记录

在 Jupyter 单元格中,有很多不同的方式可以通过程序来获取代码。除了这些输入之外,还可以查看生成的输出。我们将介绍这两个方面,并可以使用全局变量来实现。

执行历史记录

为了获取您的单元格执行历史记录,_ih列表保存了已执行单元格的代码。要获取完整的执行历史记录并将其写入文件,可以按以下步骤操作:

with open('command_history.py', 'w') as file:
    for cell_input in _ih[:-1]:
        file.write(cell_input + '\n')

如果到目前为止,我们只运行了一个单元格,其中包含print('hello, world!'),那么在我们新创建的文件command_history.py中正好会看到这样的内容:

!cat command_history.py
print('hello, world!')

在 Windows 上,要打印文件的内容,可以使用type命令。

我们可以使用_ih的简写来代替最近三个单元格的内容。 _i表示刚刚执行的单元格的代码,_ii表示上一个执行的单元格的代码,而_iii则表示再上一个。

输出

为了获取最近的输出,可以使用_(单个下划线)、__(双下划线)和___(三个下划线),分别对应最近的、第二个和第三个最近的输出。

自动重新加载包

autoreload是一个内置扩展,当您对磁盘上的模块进行更改时会重新加载该模块。一旦您保存了模块,它就会自动重新加载模块。

不需要手动重新加载您的包或重新启动笔记本,使用autoreload,您只需要加载并启用该扩展,它将自动完成其工作。

我们首先加载扩展,如下所示:

%load_ext autoreload 

然后我们按如下方式启用它:

%autoreload 2

在开发(和测试)库或模块时,这可以节省大量时间。

调试

如果您找不到错误并且错误的回溯信息不足以找到问题,调试可以大大加快错误搜索过程。让我们快速看一下调试魔法:

  1. 将以下代码放入一个单元格中:
def normalize(x, norm=10.0):
  return x / norm

normalize(5, 1)

你应该看到单元格输出为5.0

然而,在函数中有一个错误,我相信细心的读者已经注意到了。让我们进行调试!

  1. 将这段代码放入新的单元格中:
%debug
normalize(5, 0)
  1. 通过按下Ctrl + EnterAlt + Enter执行该单元格。您将会得到一个调试提示:
> <iPython-input-11-a940a356f993>(2)normalize() 
     1 def normalize(x, norm=10): ----> 
     2   return x / norm 
     3 
     4 normalize(5, 1) 
ipdb> a 
x = 5 
norm = 0 
ipdb> q
--------------------------------------------------------------------------- ZeroDivisionError                         Traceback (most recent call last)
<iPython-input-13-8ade44ebcb0c> in <module>()
     1 get_iPython().magic('debug') ---->
     2 normalize(5, 0)

<iPython-input-11-a940a356f993> in normalize(a, norm)
     1 def normalize(x, norm=10): ----> 
     2   return x / norm 
     3 
     4 normalize(5, 1) 
ZeroDivisionError: division by zero

我们已经使用参数命令打印出执行函数的参数,然后使用quit命令退出调试器。您可以在Python 调试器pdb)文档页面上找到更多命令,网址是docs.Python.org/3/library/pdb.html

让我们看看几个更有用的魔术命令。

代码执行计时

一旦您的代码按预期运行,通常会开始尽可能提高模型或算法的性能。为此,您将检查执行时间并使用它们创建基准测试。让我们看看如何计时执行。

有一个内置的计时单元执行的魔术命令 - timeittimeit功能是 Python 标准库的一部分(网址是docs.Python.org/3/library/timeit.html)。它默认在一个循环内运行一条命令 10,000 次,并显示平均执行时间:

%%timeit -n 10 -r 1
import time
time.sleep(1)

我们看到以下输出:

1 s ± 0 ns per loop (mean ± std. dev. of 1 run, 10 loops each)

iPython-autotime库(网址是github.com/cpcloud/iPython-autotime)是一个外部扩展,为执行的所有单元格提供时间,而不是每次都使用%%timeit

  1. 安装autotime如下所示:
pip install iPython-autotime

请注意,这种语法适用于 Colab,但不适用于标准的 Jupyter Notebook。安装库的永远有效的方法是使用pipconda的魔术命令,分别是%pip%conda。此外,如果您在行首使用感叹号,如下所示,还可以从笔记本中执行任何 shell 命令:

!pip install iPython-autotime
  1. 现在让我们按如下方式使用它:
%load_ext autotime
  1. 使用以下命令测试简单列表理解所需的时间:
sum([i for i in range(10)])

我们将看到这个输出:time: 5.62 ms

希望您能看到这在比较不同实现时有多有用。特别是在处理大量数据或复杂处理的情况下,这非常有用。

显示进度条

即使您的代码已经优化,知道它将在几分钟、几小时或几天内完成也是很好的。tqdm提供带有时间估计的进度条。如果您不确定作业将运行多长时间,这只是一步之遥 - 在许多情况下,只需将range替换为trange即可:

from tqdm.notebook import trange
from tqdm.notebook import tqdm
tqdm.pandas()

tqdm pandas 集成(可选)意味着您可以看到 pandas apply操作的进度条。只需将apply替换为progress_apply

对于 Python 循环,只需用tqdm函数包装您的循环,完成时将显示进度条和时间估计!

global_sum = 0.0
for i in trange(1000000):
   global_sum += 1.0

tqdm 提供了不同的方法来实现这一点,它们都需要最小的代码更改 - 有时仅仅是一个字母,正如您在前面的例子中所看到的。更通用的语法是像这样将您的循环迭代器用 tqdm 包装起来:

for _ in tqdm(range(10)):
   print()

您应该看到类似以下截图的进度条:

因此,下次当您即将启动长时间运行的循环时,而且您不确定它将花费多长时间时,只需记住这个子方法,并使用 tqdm

编译您的代码

Python 是一种解释性语言,这对于进行实验是一个很大的优势,但对速度可能是不利的。有不同的方法来编译您的 Python 代码,或者使用从 Python 编译的代码。

让我们首先看看 Cython。Cython 是一个用于 Python 的优化静态编译器,通过 Cython 编译器编译的编程语言。其主要思想是用一种非常类似 Python 的语言编写代码,并生成 C 代码。这些 C 代码可以编译为二进制 Python 扩展。SciPy(和 NumPy)、scikit-learn 等许多库的重要部分都使用 Cython 编写以加速。您可以在其网站 cython.org/ 上了解更多关于 Cython 的信息:

  1. 您可以在笔记本中使用 Cython 扩展来构建 Cython 函数:
%load_ext Cython
  1. 加载扩展后,将您的单元格标记如下:
%%cython
def multiply(float x, float y):
    return x * y
  1. 我们可以像调用任何 Python 函数一样调用这个函数 - 而且它已经编译好了,这是额外的好处:
multiply(10, 5)  # 50

这可能不是编译代码最有用的例子。对于这样一个小函数,编译的开销太大了。您可能希望编译一些更复杂的内容。

Numba 是一个用于 Python 的 JIT 编译器(numba.pydata.org/)。您可以通过使用 numba 并编写符合 Python 习惯的代码,通常获得类似于 C 或 Cython 的加速:

from numba import jit
@jit
def add_numbers(N):
    a = 0
    for i in range(N):
        a += i

add_numbers(10)

使用 autotime 激活后,您应该看到类似以下的输出:

time: 2.19 s

因此,再次编译的开销对于产生有意义的影响来说太大了。当然,我们只有在与编译相抵消时才能看到其好处。然而,如果我们再次使用这个函数,应该会看到加速。您可以自行尝试!一旦代码已经编译,时间显著改善:

add_numbers(10)

您应该看到类似以下的输出:

time: 867 µs

还有其他提供 JIT 编译的库,包括 TensorFlow、PyTorch 和 JAX,这些都可以帮助您获得类似的好处。

下面的例子直接来自 JAX 文档,网址为 jax.readthedocs.io/en/latest/index.html

import jax.numpy as np
from jax import jit
def slow_f(x):
    return x * x + x * 2.0

x = np.ones((5000, 5000)) 
fast_f = jit(slow_f) 
fast_f(x)

因此,有不同的方法可以通过使用 JIT 或预编译来获得速度优势。我们将在接下来的部分看到一些加快代码速度的其他方法。

加速 pandas 的 DataFrames

在本书中最重要的库之一将是pandas,一个用于表格数据的库,非常适用于提取转换加载ETL)任务。Pandas 是一个很棒的库,但是一旦涉及到更复杂的任务,您可能会遇到一些限制。Pandas 是处理数据加载和转换的首选库。数据处理的一个问题是,即使您向量化函数或使用df.apply(),它也可能速度较慢。

通过并行化apply可以进一步提升效率。某些库,如swifter,可以通过为您选择计算后端来帮助您,或者您可以自行选择:

  • 如果您希望在同一台或多台机器上的多个核心上运行,可以使用 Dask DataFrames 代替 pandas 在网络上运行。

  • 如果您希望在 GPU 上而不是 CPU 上运行计算,可以使用 CuPy 或 cuDF。它们与 Dask 稳定集成,因此可以在多核和多 GPU 上运行,并且仍然可以依赖于类似 pandas 的语法(参见docs.dask.org/en/latest/gpu.html)。

如我们所提到的,swifter可以为您选择后端,而不改变语法。这里是使用pandasswifter的快速设置:

import pandas as pd
import swifter

df = pd.read_csv('some_big_dataset.csv')
df['datacol'] = df['datacol'].swifter.apply(some_long_running_function)

通常情况下,apply()要比循环 DataFrame 快得多。

通过直接使用底层的 NumPy 数组并访问 NumPy 函数,例如使用df.values.apply(),您可以进一步提高执行速度。NumPy 向量化真是太方便了。以下是在 pandas DataFrame 列上应用 NumPy 向量化的示例:

squarer = lambda t: t ** 2
vfunc = np.vectorize(squarer)
df['squared'] = vfunc(df[col].values)

这只是两种方法之一,但如果您查看下一个子配方,应该能够编写并行映射函数作为另一种选择。

并行化您的代码

要更快地完成某些事情,一种方法是同时做多件事情。有不同的方法可以使用并行处理来实现您的例程或算法。Python 有许多支持此功能的库。让我们看看使用 multiprocessing、Ray、joblib 的几个示例,以及如何利用 scikit-learn 的并行处理。

multiprocessing 库作为 Python 标准库的一部分。让我们首先来看一下它。我们这里没有提供数百万点的数据集 – 关键是展示一个使用模式 – 不过,请想象一个大数据集。以下是使用我们的伪数据集的代码片段:

# run on multiple cores
import multiprocessing

dataset = [
    {
        'data': 'large arrays and pandas DataFrames',
        'filename': 'path/to/files/image_1.png'
    }, # ... 100,000 datapoints
]

def get_filename(datapoint):
    return datapoint['filename'].split('/')[-1]

pool = multiprocessing.Pool(64)
result = pool.map(get_filename, dataset)

使用 Ray,除了多核心,还可以在多台机器上并行化,几乎不需要更改代码。Ray 通过共享内存(和零拷贝序列化)高效处理数据,并使用具有容错性的分布式任务调度器:

# run on multiple machines and their cores
import ray
ray.init(ignore_reinit_error=True)

@ray.remote
def get_filename(datapoint):
    return datapoint['filename'].split('/')[-1]

result = []
for datapoint in dataset:
    result.append(get_filename.remote(datapoint))

Scikit-learn,我们之前安装的机器学习库,内部使用joblib进行并行化。以下是一个例子:


from joblib import Parallel, delayed

def complex_function(x):
    '''this is an example for a function that potentially coult take very long.
    '''
    return sqrt(x)

Parallel(n_jobs=2)(delayed(complex_function)(i ** 2) for i in range(10))

这将给您 [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]。我们从 joblib 并行循环示例中获取此示例,详细信息请参阅 joblib.readthedocs.io/en/latest/parallel.html

使用 scikit-learn 时,请注意具有 n_jobs 参数的函数。此参数直接传递给 joblib.Parallel (github.com/joblib/joblib/blob/master/joblib/parallel.py)。none(默认设置)意味着顺序执行,换句话说,没有并行化。因此,如果要并行执行代码,请务必设置此 n_jobs 参数,例如将其设置为 -1,以充分利用所有 CPU。

PyTorch 和 Keras 都支持多 GPU 和多 CPU 执行。多核并行化是默认的。Keras 中的多机执行在每个 TensorFlow 发布中都变得更加容易作为默认后端。

另请参阅

虽然笔记本很方便,但通常很混乱,不利于良好的编码习惯,并且无法干净地进行版本控制。Fastai 开发了一个称为 nbdev 的笔记本文学代码开发扩展(github.com/fastai/nbdev),提供了导出和文档化代码的工具。

还有很多有用的扩展可以在不同的地方找到:

我们还想强调以下扩展:

本配方中使用或提到的其他库包括以下内容:

在 scikit-learn、Keras 和 PyTorch 中进行分类

在本节中,我们将探讨数据探索和在三个最重要的库中建模。因此,我们将将事物分解为以下子食谱:

  • 在 Seaborn 中可视化数据

  • 在 scikit-learn 中进行建模

  • 在 Keras 中进行建模

  • 使用 PyTorch 进行建模

在这些食谱和随后的几个食谱中,我们将专注于首先涵盖 Python 中 AI 三个最重要库的基础:scikit-learn、Keras 和 PyTorch。通过这些,我们将介绍监督机器学习和深度神经网络等中级和基础技术。

我们将依次通过 scikit-learn、Keras 和 PyTorch 进行一个简单的分类任务。我们将同时运行这两个深度学习框架的离线模式。

这些食谱旨在介绍三个库的基础知识。但即使你已经使用过它们所有,你可能仍会发现一些感兴趣的内容。

准备工作

鸢尾花数据集是仍在使用的最古老的机器学习数据集之一。它由罗纳德·费希尔于 1936 年发布,用于说明线性判别分析。问题是基于萼片和花瓣的宽度和长度的测量来分类三种鸢尾花物种中的一种。

尽管这是一个非常简单的问题,但基本工作流程如下:

  1. 加载数据集。

  2. 可视化数据。

  3. 预处理和转换数据。

  4. 选择要使用的模型。

  5. 检查模型性能。

  6. 解释和理解模型(这个阶段通常是可选的)。

这是一个标准的流程模板,我们将不得不应用到本书中所展示的大多数问题上。典型的工业规模问题,步骤 12可能需要更长的时间(有时估计要花费大约 95%的时间),而不像在 Kaggle 竞赛或 UCI 机器学习存储库中获得的已预处理数据集那样。我们将在后面的食谱和章节中深入探讨每个步骤的复杂性。

我们假设您之前已安装了这三个库,并且您的 Jupyter Notebook 或 Colab 实例正在运行。此外,我们还将使用 seaborn 和 scikit-plot 库进行可视化,因此我们也会安装它们:

!pip install seaborn scikit-plot

使用一个如此广为人知的数据集的便利之处在于,我们可以从许多包中轻松加载它,例如:

import seaborn as sns
iris = sns.load_dataset('iris')

让我们直接开始,从数据可视化开始。

如何做…

让我们首先查看数据集。

在 seaborn 中可视化数据

在这个示例中,我们将介绍数据探索的基本步骤。理解问题的复杂性和数据中的任何潜在问题通常是很重要的:

  1. 绘制一对图:
%matplotlib inline
# this^ is not necessary on Colab
import seaborn as sns
sns.set(style="ticks", color_codes=True)

g = sns.pairplot(iris, hue='species')

这里是(在 seaborn 愉悦的间距和颜色中呈现):

在 seaborn 中绘制一对图可视化数据集中的成对关系。每个子图显示一个散点图中的一个变量与另一个变量。对角线上的子图显示变量的分布。颜色对应于三个类别。

从这个图中,特别是沿着对角线看,我们可以看到弗吉尼亚和变色鸢尾品种并不是(线性)可分的。这是我们将要努力解决的问题,我们将不得不克服它。

  1. 让我们快速查看数据集:
iris.head()

我们只看到 setosa,因为花的种类是按顺序列出的:

  1. 准备好训练的特征和目标如下:
classes = {'setosa': 0, 'versicolor': 1, 'virginica': 2}
X = iris[['sepal_length', 'sepal_width', 'petal_length', 'petal_width']].values
y = iris['species'].apply(lambda x: classes[x]).values

最后一行将三个对应于三个类别的字符串转换为数字 - 这称为序数编码。多类别机器学习算法可以处理这个问题。对于神经网络,我们将使用另一种编码方式,稍后您将看到。

经过这些基本步骤,我们准备开始开发预测模型。这些模型可以根据特征预测花的类别。我们将依次看到 Python 中最重要的三个机器学习库中的每一个。让我们从 scikit-learn 开始。

在 scikit-learn 中建模

在这个示例中,我们将在 scikit-learn 中创建一个分类器,并检查其性能。

Scikit-learn(也称为 sklearn)是自 2007 年以来开发的 Python 机器学习框架。它也是可用的最全面的框架之一,与 pandas、NumPy、SciPy 和 Matplotlib 库兼容。Scikit-learn 的大部分都经过了 Cython、C 和 C++的优化,以提高速度和效率。

请注意,并非所有的 scikit-learn 分类器都能处理多类问题。所有分类器都能进行二元分类,但并非所有都能处理超过两类的情况。幸运的是,随机森林模型能够处理。随机森林模型(有时称为随机决策森林)是一种可以应用于分类和回归任务的算法,是决策树的集成。其主要思想是通过在数据集的自助采样上创建决策树,并对这些树进行平均,以提高精度。

以下几行代码中的一些应该对你来说是样板代码,并且我们会一遍又一遍地使用它们:

  1. 分离训练和验证。

作为良好实践的一部分,我们应该始终在未用于训练的数据样本上测试模型的性能(称为保留集验证集)。我们可以这样做:

from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.33, random_state=0
)
  1. 定义一个模型。

在这里,我们定义我们的模型超参数,并使用这些超参数创建模型实例。在我们的情况下,过程如下:

超参数是不属于学习过程但控制学习的参数。在神经网络的情况下,这包括学习率、模型架构和激活函数。

params = dict(
    max_depth=20,
    random_state=0,
    n_estimators=100,
)
clf = RandomForestClassifier(**params)
  1. 训练模型。

在这里,我们将训练数据集传递给我们的模型。在训练过程中,调整模型的参数以获得更好的结果(其中“更好”由一个称为成本函数损失函数的函数定义)。

对于训练,我们使用fit方法,该方法适用于所有与 sklearn 兼容的模型:

clf.fit(X_train, y_train)
  1. 检查模型的性能。

尽管模型内部有一个度量(成本函数),我们可能希望查看额外的度量。在建模的背景下,这些被称为指标。在 scikit-learn 中,我们可以方便地使用许多度量。对于分类问题,我们通常会查看混淆矩阵,并经常希望将其绘制出来:

from sklearn.metrics import plot_confusion_matrix

plot_confusion_matrix(
    clf, X_test, y_test,
    display_labels=['setosa', 'versicolor', 'virginica'],
    normalize='true'
)

混淆矩阵相对直观,特别是在类似于 sklearn 的plot_confusion_matrix()中呈现得如此清晰的情况下。基本上,我们可以看到我们的类预测与实际类别的对应情况。我们可以看到预测值与实际标签之间的对比,按类别分组,因此每个条目对应于在给定实际类别 B 的情况下预测为类别 A 的次数。在这种情况下,我们对矩阵进行了归一化处理,使每行(实际标签)的总和为一。

这是混淆矩阵:

由于这是一个归一化矩阵,对角线上的数字也被称为命中率真正率。我们可以看到 setosa 被预测为 setosa 的比例为 100%(1)。相比之下,versicolor 被预测为 versicolor 的比例为 95%(0.95),而 5%的时间(0.053)被预测为 virginica。

在命中率方面表现非常好,然而,正如预期的那样,我们在区分变色鸢尾和弗吉尼亚鸢尾之间有一点小问题。

让我们继续使用 Keras。

在 Keras 中建模

在这个示例中,我们将使用 Keras 预测花的种类。

Keras 是一个高级接口,用于(深度)神经网络模型,可以使用 TensorFlow 作为后端,但也可以使用Microsoft Cognitive ToolkitCNTK)、Theano 或 PlaidML。Keras 是开发 AI 模型的接口,而不是一个独立的框架。Keras 已作为 TensorFlow 的一部分进行了集成,因此我们从 TensorFlow 导入 Keras。TensorFlow 和 Keras 都是由 Google 开发的开源工具。

由于 Keras 与 TensorFlow 紧密集成,Keras 模型可以保存为 TensorFlow 模型,然后部署在 Google 的部署系统 TensorFlow Serving 中(参见www.tensorflow.org/tfx/guide/serving),或者从任何编程语言(如 C++或 Java)使用。让我们深入了解一下:

  1. 运行以下代码。如果您熟悉 Keras,您会认识到这是样板代码:
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Dense
import tensorflow as tf

def create_iris_model():
    '''
    Create the iris classification model
    '''
    iris_model = Sequential()
    iris_model.add(Dense(10, activation='selu', input_dim=4))
    iris_model.add(Dense(3, activation='softmax'))
    iris_model.compile(
        optimizer='rmsprop',
        loss='categorical_crossentropy',
        metrics=['accuracy']
    )
    iris_model.summary()
    return iris_model

iris_model = create_iris_model()

这导致以下模型构建:

我们可以用不同的方式可视化这个模型。我们可以使用内置的 Keras 功能如下:

dot = tf.keras.utils.model_to_dot(
    iris_model,
    show_shapes=True,
    show_layer_names=True,
    rankdir="TB",
    expand_nested=True,
    dpi=96,
    subgraph=False,
)
dot.write_png('iris_model_keras.png')

这将把网络的可视化写入一个名为iris_model_keras.png的文件中。生成的图像如下所示:

这显示我们有 4 个输入神经元,10 个隐藏神经元和 3 个输出神经元,完全连接以前馈方式。这意味着输入中的所有神经元都馈送到隐藏层中的所有神经元,然后馈送到输出层中的所有神经元。

我们使用顺序模型构建(与图形相对)。顺序模型类型比图形类型更容易构建。层的构建方式相同;然而,对于顺序模型,您必须定义输入维度,input_dim

我们使用两个密集层,中间层使用 SELU 激活函数,最后一层使用 softmax 激活函数。我们将在工作原理……部分解释这两者。至于SELU 激活函数,现在可以说它提供了必要的非线性,使得神经网络能够处理更多线性不可分的变量,正如我们的情况。实际上,在隐藏层中很少使用线性(恒等函数)激活。

最终层中的每个单元(或神经元)对应于三个类别中的一个。softmax 函数将输出层归一化,使其神经激活总和为 1。我们使用分类交叉熵作为损失函数进行训练。交叉熵通常用于神经网络的分类问题。二元交叉熵损失用于两类问题,而分类交叉熵损失用于两类或更多类问题(交叉熵将在 工作原理... 部分详细解释)。

  1. 接下来,对特征进行独热编码。

这意味着我们有三列,每一列代表一个物种,其中一个将被设为 1 对应于相应的类别:

y_categorical = tf.keras.utils.to_categorical(y, 3)

因此,我们的 y_categorical 的形状为 (150, 3)。这意味着,为了表示类别 0 作为标签,而不是使用 0(有时称为标签编码整数编码),我们使用了一个向量 [1.0, 0.0, 0.0]。这被称为独热编码。每行的总和等于 1。

  1. 对特征进行归一化。

对于神经网络,我们的特征应该以一种激活函数可以处理整个输入范围的方式进行归一化 —— 通常是到标准分布,其平均值为 0.0,标准差为 1.0:

X = (X - X.mean(axis=0)) / X.std(axis=0)
X.mean(axis=0)

此单元格的输出如下:

array([-4.73695157e-16, -7.81597009e-16, -4.26325641e-16, -4.73695157e-16])

我们看到每列的均值非常接近零。我们还可以通过以下命令查看标准差:

X.std(axis=0)

输出如下:

array([1., 1., 1., 1.])

标准差正好为 1,与预期一致。

  1. 在 TensorBoard 中显示我们的训练进度。

TensorBoard 是一种神经网络学习的可视化工具,用于跟踪和可视化指标、模型图、特征直方图、投影嵌入等等:

%load_ext tensorboard
import os

logs_base_dir = "./logs"
os.makedirs(logs_base_dir, exist_ok=True)
%tensorboard --logdir {logs_base_dir}

此时,应在笔记本中弹出一个 TensorBoard 小部件。我们只需确保它获得所需的信息:

  1. 将 TensorBoard 的详细信息作为回调插入 Keras 训练函数,以便 TensorBoard 获取训练信息:
import datetime

logdir = os.path.join(
  logs_base_dir,
  datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
)
tensorboard_callback = tf.keras.callbacks.TensorBoard(
  logdir, histogram_freq=1
)
X_train, X_test, y_train, y_test = train_test_split(
    X, y_categorical, test_size=0.33, random_state=0
)
iris_model.fit(
  x=X_train,
  y=y_train,
  epochs=150,
  callbacks=[tensorboard_callback]
)

这会运行我们的训练。一个 epoch 是整个数据集通过神经网络的一次完整遍历。我们在这里使用了 150,这有点任意。我们可以使用停止准则,在验证和训练误差开始发散,或者换句话说,过拟合发生时自动停止训练。

为了像以前一样使用 plot_confusion_matrix() 进行比较,我们必须将模型包装在一个实现 predict() 方法的类中,并具有 classes_ 列表和一个等于分类器的 _estimator_type 属性。我们将在在线资料中展示这一点。

  1. 绘制混淆矩阵。

在这里,使用 scikitplot 函数会更容易:

import scikitplot as skplt

y_pred = iris_model.predict(X_test).argmax(axis=1)
skplt.metrics.plot_confusion_matrix(
    y_test.argmax(axis=1),
    y_pred,
    normalize=True
)

同样地,我们归一化矩阵,以便得到分数。输出应该类似于以下内容:

这比我们在 scikit-learn 中之前的尝试稍逊一筹,但通过一些调整,我们可以达到一个可比较的水平,甚至可能有更好的表现。调整的例子包括改变模型的任何超参数,比如隐藏层中的神经元数,对网络架构的任何更改(添加新层),或者改变隐藏层的激活函数。

  1. 查看来自 TensorBoard 的图表:训练进度和模型图。这里它们:

这些图显示了整个训练过程中的准确率和损失。我们还可以在 TensorBoard 中获得另一种对网络的可视化:

此处显示了所有网络层,损失和指标,优化器(RMSprop),以及训练过程,以及它们之间的关系。关于网络架构,我们可以看到四个密集层(所呈现的输入和目标不被视为网络的适当部分,因此呈白色)。网络由一个密集隐藏层(由输入提供数据),和一个密集输出层(由隐藏层提供数据)组成。损失函数在输出层激活和目标之间进行计算。优化器根据损失与所有层一起工作。您可以在 TensorBoard 的教程中找到更多信息:www.tensorflow.org/tensorboard/get_started。TensorBoard 的文档解释了更多的配置和选项。

因此,分类准确性随着训练周期的增加而提高,损失逐渐减少。最终的图表显示了网络和训练架构,包括两个密集层、损失和指标,以及优化器。

在 PyTorch 中建模

在这个示例中,我们将描述一个与之前在 Keras 中展示的网络等效的网络,训练它,并绘制其性能。

PyTorch 是一个基于 Torch 库开发的深度学习框架,主要由 Facebook 开发。一段时间以来,Facebook 一直在开发另一个深度学习框架,名为 Caffe2;然而,它在 2018 年 3 月并入了 PyTorch。PyTorch 在图像和语言处理应用中具有一些优势。除了 Python 外,Torch 还提供了一个 C++ 接口,用于学习和模型部署:

  1. 让我们首先定义模型架构。这看起来与 Keras 非常相似:
import torch
from torch import nn

iris_model = nn.Sequential(
  torch.nn.Linear(4, 10), # equivalent to Dense in keras
  torch.nn.SELU(),
  torch.nn.Linear(10, 3),
  torch.nn.Softmax(dim=1)
)
print(iris_model)

这与我们之前在 Keras 中定义的架构相同:这是一个前馈式、两层神经网络,隐藏层采用 SELU 激活函数,第一层有 10 个神经元,第二层有 3 个神经元。

如果您喜欢类似于 Keras 中 summary() 函数的输出,可以使用 torchsummary 包(github.com/sksq96/pytorch-summary)。

  1. 我们需要将 NumPy 数组转换为 Torch 张量:
from torch.autograd import Variable

X_train = Variable(
    torch.Tensor(X_train).float()
)
y_train = Variable(torch.Tensor(
   y_train.argmax(axis=1)).long()
)
X_test = Variable(
    torch.Tensor(X_test).float()
)
y_test = Variable(
    torch.Tensor(y_test.argmax(axis=1)).long()
)

y_train 是我们之前创建的 one-hot 编码的目标矩阵。由于 PyTorch 交叉熵损失函数期望这样,我们将其转换回整数编码。

  1. 现在我们可以进行训练,如下所示:
criterion = torch.nn.CrossEntropyLoss()  # cross entropy loss
optimizer = torch.optim.RMSprop(
    iris_model.parameters(), lr=0.01
)
for epoch in range(1000):
  optimizer.zero_grad()
  out = iris_model(X_train)
  loss = criterion(out, y_train)
  loss.backward()
  optimizer.step()
  if epoch % 10 == 0:
    print('number of epoch', epoch, 'loss', loss)
  1. 然后我们将使用scikitplot来可视化我们的结果,与之前类似:
import scikitplot as skplt

y_pred = iris_model(X_test).detach().numpy()
skplt.metrics.plot_confusion_matrix(
    y_test,
    y_pred.argmax(axis=1),
    normalize=True
)
labels = ['setosa', 'versicolor', 'virginica']
ax.set_xticklabels(labels)
ax.set_yticklabels(labels)

这是我们得到的图表:

你的绘图可能有所不同。神经网络学习不是确定性的,因此你可能得到更好或更差的数字,或者完全不同的数字。

如果我们让这个过程运行更长时间,我们可以获得更好的性能。这留给你作为一个练习。

工作原理...

我们首先看看神经网络训练背后的直觉,然后稍微深入探讨一些我们将在 PyTorch 和 Keras 配方中使用的技术细节。

神经网络训练

机器学习的基本思想是通过改变模型的参数来尽量减少误差。这种参数的调整被称为学习。在监督学习中,误差由模型预测与目标之间的损失函数计算得出。这种误差在每一步计算,并且相应地调整模型参数。

神经网络是由可调的仿射变换(f)和激活函数(sigma)组成的可组合函数逼近器:

简而言之,在具有线性激活函数的单层前馈神经网络中,模型预测由系数与所有维度输入的乘积之和给出:

这被称为感知器,它是一个线性二元分类器。下图展示了具有四个输入的简单示例:

一维输入的预测器可以分解为二维中的斜率-截距形式的线,如图所示:。这里,m 是斜率,b 是 y-截距。对于更高维度的输入,我们可以写成(改变符号并向量化),具有偏置项 和权重。这仍然是一条线,只不过在与输入维度相同的空间中。请注意, 表示我们对于 的模型预测,对于我们知道的例子,我们可以计算两者之间的差异作为我们的预测误差。

我们也可以使用非常简单的线性代数来定义二元分类器,如下所示:

这仍然是非常简单的线性代数。这个只有一个层的线性模型,称为感知器,很难预测更复杂的关系。这导致了对神经网络限制的深刻担忧,这些担忧在 1969 年由 Minsky 和 Papert 的一篇有影响力的论文后变得流行。然而,自 1990 年以来,神经网络在支持向量机(SVMs)和多层感知器(MLP)的形式下经历了复苏。MLP 是一个前馈神经网络,其输入和输出之间至少有一层(隐藏层)。由于具有许多层线性激活的多层感知器可以被非平凡地减少为仅一个层,我们将引用具有隐藏层和非线性激活函数的神经网络。这些类型的模型可以近似任意函数,并执行非线性分类(根据通用逼近定理)。任何层上的激活函数都可以是任何可微的非线性函数;传统上,sigmoid 函数,,在这个目的上经常被使用。

为了说明,让我们用jax来写下这些内容:

import jax.numpy as np
from jax import grad, jit
import numpy.random as npr

def predict(params, inputs):
    for W, b in params:
        outputs = np.dot(inputs, W) + b
        inputs = np.tanh(outputs)
    return outputs

def construct_network(layer_sizes=[10, 5, 1]):
    '''Please make sure your final layer corresponds to
    the target dimensionality.
    '''
    def init_layer(n_in, n_out):
        W = npr.randn(n_in, n_out)
        b = npr.randn(n_out,)
        return W, b
    return list(
        map(init_layer, layer_sizes[:-1], layer_sizes[1:])
    )

params = construct_network()

如果您看这段代码,您会发现我们可以同样地在 NumPy、TensorFlow 或 PyTorch 中进行操作。您还会注意到construct_network()函数接受一个layer_sizes参数。这是网络的超参数之一,在学习之前需要决定的内容。我们可以选择仅输出[1]来得到感知器,或者[10, 1]来得到一个两层感知器。这展示了如何将网络作为一组参数以及如何从该网络获得预测。我们还没有讨论如何学习这些参数,这将引出我们的错误。

有一句古语说,“所有的模型都是错误的,但有些是有用的”。我们可以测量我们模型的误差,这可以帮助我们计算我们可以对参数进行的变化的大小和方向,以减少误差。

给定一个(可微分的)损失函数(也称为成本函数),,例如均方误差MSE),我们可以计算我们的误差。在 MSE 的情况下,损失函数如下:

然后为了得到我们权重的变化,我们将使用训练中点上损失的导数:

这意味着我们正在应用梯度下降,这意味着随着时间的推移,我们的误差将按梯度(乘以学习率)比例减少。让我们继续我们的代码:

def mse(preds, targets):
    return np.sum((preds - targets)**2)

def propagate_and_error(loss_fun):
    def error(params, inputs, targets):
        preds = predict(params, inputs)
        return loss_fun(preds, targets)
    return error

error_grads = jit(grad(propagate_and_error(mse)))

PyTorch 和 JAX 都具有autograd功能,这意味着我们可以自动获取各种函数的导数(梯度)。

我们将在本书中遇到许多不同的激活和损失函数。在本章中,我们使用 SELU 激活函数。

SELU 激活函数

缩放指数线性单元SELU)激活函数是由 Klambauer 等人于 2017 年最近发布的(papers.nips.cc/paper/6698-self-normalizing-neural-networks.pdf):

SELU 函数对于正值x是线性的,对于负值是缩放指数,x为 0 时为 0。在原始论文中可以找到详细信息。SELU 函数显示出比其他函数更好的收敛性能。您可以在 Padamonti(2018)的比较激活函数中找到更多信息,网址为arxiv.org/pdf/1804.02763.pdf

Softmax 激活

作为我们神经网络输出层的激活函数,我们使用 softmax 函数。这作为输出层神经激活的归一化,总和为 1.0 的类激活。因此,输出可以解释为类概率。softmax 激活函数定义如下:

交叉熵

在使用神经网络进行多类训练时,通常会训练交叉熵。多类别情况下的二元交叉熵如下所示:

在这里,M是类的数量(setosa、versicolor 和 virginica),y是类标签c是否正确的 0 或 1,p是观测值o属于类c的预测概率。您可以在 ml-cheatsheet 网站上了解更多关于不同损失函数和度量的信息,网址为ml-cheatsheet.readthedocs.io/en/latest/loss_functions.html

另请参见

您可以在每个库的网站上找到本教程中使用的更多详细信息:

TensorboardX 是 TensorFlow 之外的其他深度学习框架(如 PyTorch、Chainer、MXNet 等)的 TensorBoard 接口,网址为github.com/lanpa/tensorboardX

值得注意的是,scikit-plot 现在已不再维护。对于机器学习指标和图表的绘制,mlxtend 是一个很好的选择,网址是:rasbt.github.io/mlxtend/

我们在这里使用了一些其他库,而且在本书中我们将会遇到以下内容:

在接下来的示例中,我们将通过 Keras 了解一个更实际的例子。

使用 Keras 进行建模

在这个示例中,我们将加载一个数据集,然后进行探索性数据分析EDA),如可视化分布。

我们将执行典型的预处理任务,如编码分类变量,并进行归一化和缩放以用于神经网络训练。然后我们将在 Keras 中创建一个简单的神经网络模型,使用生成器进行训练,绘制训练和验证性能。我们将查看一个仍然相当简单的数据集:来自 UCI 机器学习库的成人收入数据集(也称为人口普查收入数据集)。在这个数据集中,目标是根据人口普查数据预测某人是否年收入超过 5 万美元。

由于我们有一些分类变量,我们还将处理分类变量的编码。

由于这仍然是一个入门的示例,我们将详细介绍这个问题。我们将包括以下部分:

  • 数据加载和预处理:

    1. 加载数据集

    2. 检查数据

    3. 分类编码

    4. 绘制变量和分布图

    5. 绘制相关性

    6. 标签编码

    7. 规范化和缩放

    8. 保存预处理后的数据

  • 模型训练:

    1. 创建模型

    2. 编写数据生成器

    3. 训练模型

    4. 绘制性能

    5. 提取性能指标

    6. 计算特征重要性

准备工作

除了之前安装的库外,这个示例还需要一些其他库:

  • category_encoders 用于编码分类变量

  • minepy 用于基于信息的相关性测量

  • eli5 用于检查黑盒模型

我们之前用过 Seaborn 进行可视化。

我们可以按以下方式安装这些库:

!pip install category_encoders minepy eli5 seaborn

作为给您读者的一条注:如果您同时使用pipconda,可能会导致一些库不兼容,造成环境混乱。我们建议在有conda版本的情况下使用conda,尽管通常使用pip更快。

这个数据集已经分成了训练集和测试集。让我们从 UCI 下载数据集,如下所示:

!wget http://archive.ics.uci.edu/ml/machine-learning-databases/adult/adult.data
!wget http://archive.ics.uci.edu/ml/machine-learning-databases/adult/adult.test

wget在 macOS 上默认未包含;我们建议使用brew安装wgetformulae.brew.sh/formula/wget)。在 Windows 上,您可以访问前述两个 URL,并通过文件菜单下载这两个文件。确保您记住保存文件的目录,以便稍后找到它们。然而,也有一些其他替代方法:

  • 您可以使用我们在第二章中提供的下载脚本,监督机器学习的高级主题在 PyTorch 中预测房价 配方。

  • 您可以安装wget库并运行import wget; wget.download(URL, filepath)

我们从 UCI 数据集描述页面获得以下信息:

  • age: 连续。

  • workclass: 私人企业,自雇-无薪,自雇-有薪,联邦政府,地方政府,州政府,无报酬,从未工作过。

  • fnlwgt: 连续。

  • education: 学士,一些大学,11 年级,高中毕业,专业学校,副学士学位,副职业学位,9 年级,7 年级,12 年级,硕士,1-4 年级,10 年级,博士,5-6 年级,学前教育。

  • education-num: 连续。

  • marital-status: 已婚-市民配偶,离婚,从未结过婚,分居,丧偶,已婚-配偶缺席,已婚-空军。

  • occupation: 技术支持,手工修理,其他服务,销售,执行管理,专业特长,处理器清洁工,机器操作检查,行政文员,农业捕捞,运输搬运,私人家务,保护服务,武装部队。

  • relationship: 妻子,自己的孩子,丈夫,非家庭成员,其他亲戚,未婚。

  • race: 白人,亚裔太平洋岛民,美印-爱斯基摩人,其他,黑人。

  • sex: 女性,男性。

  • capital-gain: 连续。

  • capital-loss: 连续。

  • hours-per-week: 连续。

  • native-country: 美国,等等。

fnlwgt实际上代表最终权重;换句话说,构成条目的总人数。

请记住,这个数据集是一个知名的数据集,已经多次在科学出版物和机器学习教程中使用。我们在这里使用它来回顾一些 Keras 基础知识,而不需要专注于数据集。

如何实现...

正如我们之前提到的,我们首先加载数据集,进行一些探索性数据分析,然后在 Keras 中创建模型,训练它,并查看性能。

我们将这个配方分成数据加载和预处理,以及模型训练两部分。

数据加载和预处理

我们将从加载训练集和测试集开始:

  1. 加载数据集:为了加载数据集,我们将再次使用 pandas。我们像之前一样使用 pandas 的read_csv()命令:
import pandas as pd
cols = [
    'age', 'workclass', 'fnlwgt',
    'education', 'education-num',
    'marital-status', 'occupation',
    'relationship', 'race', 'sex',
    'capital-gain', 'capital-loss',
    'hours-per-week', 'native-country', '50k'
]
train = pd.read_csv(
    'adult.data',
    names=cols
)
test = pd.read_csv(
    'adult.test',
    names=cols
)

现在让我们看看数据!

  1. 检查数据:我们可以用head()方法看到 DataFrame 的开头:
train.head()

这导致以下输出:

接下来,我们将查看测试数据:

test.head()

看起来如下:

第一行有 15 列中的 14 个空值和 1 列不可用列。我们将丢弃这一行:

test.drop(0, axis=0, inplace=True)

然后它消失了。

  1. 分类编码:让我们从分类编码开始。对于探索性数据分析(EDA),最好使用序数编码。这意味着对于分类特征,我们将每个值映射到一个不同的数字:
import category_encoders as ce

X = train.drop('50k', axis=1)
encoder = ce.OrdinalEncoder(cols=list(
    X.select_dtypes(include='object').columns)[:]
)
encoder.fit(X, train['50k'])
X_cleaned = encoder.transform(X)

X_cleaned.head()

我们在这里分离X,即特征,和y,即目标。特征不包含标签;这就是drop()方法的目的——我们也可以使用del train['50k']

这是结果:

在开始新任务时,最好进行探索性数据分析(EDA)。让我们绘制一些这些变量。

  1. 要绘制变量和分布,请使用以下代码块:
from scipy import stats
import seaborn as sns
sns.set(color_codes=True)
sns.set_context(
 'notebook', font_scale=1.5,
 rc={"lines.linewidth": 2.0}
)
sns.distplot(train['age'], bins=20, kde=False, fit=stats.gamma)

我们将得到以下绘图:

接下来,我们再次看一下配对图。我们将所有数值变量相互绘制:

import numpy as np

num_cols = list(
  set(
    train.select_dtypes(
      include='number'
    ).columns
  ) - set(['education-num'])
) + ['50k']]
g = sns.pairplot(
 train[num_cols],
 hue='50k',
 height=2.5,
 aspect=1,
)
for i, j in zip(*np.triu_indices_from(g.axes, 1)):
  g.axes[i, j].set_visible(False)

如前所述,在对角线上的配对图中,显示了单变量的直方图——即变量的分布——其色调由类别定义。这里我们有橙色与蓝色(请参见图右侧的图例)。对角线上的子图显示了两个变量之间的散点图:

如果我们看一下对角线上的年龄变量(第二行),我们会发现两个类别有不同的分布,尽管它们仍然重叠。因此,年龄似乎在我们的目标类别方面具有区分性。

我们可以看到在分类图中也是如此:

sns.catplot(x='50k', y='age', kind='box', data=train)

这是生成的图形:

完成这些后,让我们转向相关性图。

  1. 绘制相关性:为了了解变量之间的冗余程度,我们将基于最大信息系数(MIC),这是一种基于信息熵的相关性度量,绘制相关性矩阵。我们将在本文末尾解释 MIC。

由于计算 MIC 可能需要一些时间,我们将采用之前介绍的并行化模式。请注意线程池的创建和map操作:

import numpy as np
import os
from sklearn.metrics.cluster import adjusted_mutual_info_score
from minepy import MINE
import multiprocessing

def calc_mic(args):
  (a, b, i1, i2) = args
  mine = MINE(alpha=0.6, c=15, est='mic_approx')
  mine.compute_score(a, b)
  return (mine.mic(), i1, i2)

pool = multiprocessing.Pool(os.cpu_count())

corrs = np.zeros((len(X_cleaned.columns), len(X_cleaned.columns)))
queue = []
for i1, col1 in enumerate(X_cleaned.columns):
  if i1 == 1:
    continue
  for i2, col2 in enumerate(X_cleaned.columns):
    if i1 < i2:
      continue
    queue.append((X_cleaned[col1], X_cleaned[col2], i1, i2))

results = pool.map(calc_mic, queue)

for (mic, i1, i2) in results:
  corrs[i1, i2] = mic

corrs = pd.DataFrame(
    corrs,
    columns=list(X_cleaned.columns),
    index=list(X_cleaned.columns)
)

这可能仍然需要一些时间,但应该比按顺序计算要快得多。

让我们将相关性矩阵可视化为热图:由于矩阵是对称的,这里我们只显示下三角部分并应用一些漂亮的样式:

mask = np.zeros_like(corrs, dtype=np.bool)
mask[np.triu_indices_from(mask)] = True
cmap = sns.diverging_palette(
    h_neg=220, h_pos=10, n=50, as_cmap=True
)
sns.set_context(
    'notebook', font_scale=1.1,
    rc={'lines.linewidth': 2.0}
)
sns.heatmap(
    corrs,
    square=True,
    mask=mask,
    cmap=cmap, vmax=1.0, center=0.5,
    linewidths=.5,
    cbar_kws={"shrink": .5}
)

如下所示:

我们可以看到在相关性矩阵热图中,大多数变量对之间的相关性都相当低(大多数相关性都低于 0.4),这意味着大多数特征相对不相关;然而,有一对变量明显突出,即education-numeducation

corrs.loc['education-num', 'education']

输出为 0.9995095286140694

这几乎是完美相关的情况。这两个变量实际上指向相同的信息。

让我们看看education中每个值的education-num的方差:

train.groupby(by='education')['education-num'].std()

我们只看到零。没有变异。换句话说,education中的每个值都恰好对应于education-num中的一个值。这些变量完全相同!我们应该能够删除其中一个,例如通过del train['education'],或者在训练期间忽略其中一个。

UCI 描述页面提到了缺失变量。现在让我们来寻找缺失的变量:

train.isnull().any()

对于每个变量,我们只看到False,因此在这里我们看不到任何缺失值。

在神经网络训练中,对于分类变量,我们可以选择使用嵌入(我们将在第十章,自然语言处理中讨论)或者将它们作为一位热编码来进行输入;这意味着每个因子,每个可能的值,都被编码为一个二进制变量,指示其是否存在。让我们试试一位热编码以简化问题。

因此,首先让我们重新编码变量:

encoder = ce.OneHotEncoder(
    cols=list(X.select_dtypes(include='object').columns)[:]
)
encoder.fit(X, train['50k'])
X_cleaned = encoder.transform(X)
x_cleaned_cols = X_cleaned.columns
x_cleaned_cols

我们的x_cleaned_cols如下所示:

完成后,是时候对我们的标签进行编码了。

  1. 标签编码:我们将在两列中对目标值进行编码,如果存在则为 1,如果不存在则为 0。需要记住,Python 的真值对应于 0 和 1,分别表示假和真。由于我们有一个二元分类任务(即,我们只有两个类别),我们可以在单个输出中使用 0 和 1。如果我们有超过两个类别,我们将不得不对输出进行分类编码,通常意味着我们使用与类别数量相同的输出神经元。通常情况下,我们需要尝试不同的解决方案来找出最佳的方法。

在以下代码块中,我们只是做了一个选择并坚持了下来:

y = np.zeros((len(X_cleaned), 2))
y[:, 0] = train['50k'].apply(lambda x: x == ' <=50K')
y[:, 1] = train['50k'].apply(lambda x: x == ' >50K')
  1. 归一化和缩放:我们必须将所有值转换为 z 值。这是通过减去均值并除以标准差来实现的,以便获得均值为 0.0,标准差为 1.0 的正态分布。对于神经网络输入并不一定需要正态分布。然而,重要的是将数值缩放到神经网络激活函数的敏感部分。将值转换为 z 分数是一种标准方法:
from sklearn.preprocessing import StandardScaler

standard_scaler = StandardScaler()
X_cleaned = standard_scaler.fit_transform(X_cleaned)
X_test = standard_scaler.transform(encoder.transform(test[cols[:-1]]))
  1. 保存我们的预处理:出于良好的实践,我们保存我们的数据集和转换器,以便有审计追踪。这对于更大的项目非常有用:
import joblib
joblib.dump(
    [encoder, standard_scaler, X_cleaned, X_test],
    'adult_encoder.joblib'
)

我们现在准备好进行训练了。

模型训练

我们将创建模型,训练它,绘制性能,然后计算特征重要性。

  1. 要创建模型,我们再次使用Sequential模型类型。这是我们的网络架构:
from tensorflow.keras import Sequential
from tensorflow.keras.layers import Dense

model = Sequential()
model.add(Dense(20, activation='selu', input_dim=108))
model.add(Dense(2, activation='softmax'))
model.compile(
    optimizer='rmsprop',
    loss='categorical_hinge',
    metrics=['accuracy']
)
model.summary()

这是 Keras 模型的摘要:

  1. 现在,让我们编写一个数据生成器。为了让这个过程更加有趣,这次我们将使用一个生成器来批量输入我们的数据。这意味着我们会逐批次地将数据流入,而不是一次性将所有训练数据放入fit()函数中。这对于非常大的数据集非常有用。

我们将使用fit_generator()函数如下:

def adult_feed(X_cleaned, y, batch_size=10, shuffle=True):
  def init_batches():
    return (
        np.zeros((batch_size, X_cleaned.shape[1])),
        np.zeros((batch_size, y.shape[1]))
        )
  batch_x, batch_y = init_batches()
  batch_counter = 0
  while True: # this is for every epoch
    indexes = np.arange(X_cleaned.shape[0])
    if shuffle == True:
      np.random.shuffle(indexes)
    for index in indexes:
      batch_x[batch_counter, :] = X_cleaned[index, :]
      batch_y[batch_counter, :] = y[index, :]
      batch_counter += 1
      if batch_counter >= batch_size:
        yield (batch_x, batch_y)
        batch_counter = 0
        batch_x, batch_y = init_batches()

如果我们还没有进行预处理,我们可以将其放入此函数中。

  1. 现在我们有了数据生成器,我们可以按如下方式训练我们的模型:
history = model.fit_generator(
    adult_feed(X_cleaned, y, 10),
    steps_per_epoch=len(X_cleaned) // 10,
    epochs=50
)

因为这是一个小数据集,所以应该相对快速;然而,如果发现这太耗时,您可以减少数据集大小或训练周期数。

我们有来自训练的输出,如损失和指标,保存在我们的history变量中。

  1. 这次我们将绘制来自 Keras 训练历史的训练进度随时间变化的图表,而不是使用 TensorBoard。由于我们没有验证,所以我们只绘制训练损失和训练准确率:
import matplotlib.pyplot as plt

plt.plot(history.history['accuracy'])
plt.plot(history.history['loss'])
plt.title('Model Training')
plt.ylabel('Accuracy')
plt.xlabel('Epoch')
plt.legend(['Accuracy', 'Loss'], loc='center left')

请注意,在某些 Keras 版本中,准确率存储为accuracy而不是acc在历史记录中。

这是生成的图表:

在训练过程中,准确率在增加而损失在减少,这是一个好兆头。

  1. 由于我们已经对测试数据进行了独热编码和缩放,我们可以直接进行预测并计算我们的性能。我们将使用 sklearn 内置函数计算AUC(曲线下面积)得分。AUC 得分来自接收器操作特性曲线,这是对假阳率(也称为误报率)在x轴上的可视化,与真阳率(也称为命中率)在y轴上的对比。该曲线下的积分,即 AUC 得分,是分类性能的常用指标,有助于理解高命中率和任何假警报之间的平衡:
from sklearn.metrics import roc_auc_score

predictions = model.predict(X_test)
# Please note that the targets have slightly different names in the test set than in the training dataset. We'll need to take care of this here:
target_lookup = {' <=50K.': 0, ' >50K.': 1 }
y_test = test['50k'].apply(
    lambda x: target_lookup[x]
).values
roc_auc_score(y_test, predictions.argmax(axis=1))

我们获得了0.7579310072282265作为 AUC 得分。76%的 AUC 得分可以根据任务的难度而有好坏之分。对于这个数据集来说并不差,但我们可能通过进一步调整模型来提高性能。不过,目前我们会保留它如此。

  1. 最后,我们将检查特征重要性。为此,我们将使用eli5库进行黑盒排列重要性评估。黑盒排列重要性包括一系列与模型无关的技术,大致上来说,它们会对特征进行排列以确定它们的重要性。您可以在它是如何工作...部分阅读更多关于排列重要性的信息。

为了使其工作,我们需要一个评分函数,如下所示:

from eli5.permutation_importance import get_score_importances

def score(data, y=None, weight=None):
  return model.predict(data).argmax(axis=1)

base_score, score_decreases = get_score_importances(score, X_test, y_test)
feature_importances = np.mean(score_decreases, axis=0).mean(axis=1)

现在我们可以按排序顺序打印特征重要性:

import operator

feature_importances_annotated = {col: imp for col, imp in zip(x_cleaned_cols, feature_importances)}
sorted_feature_importances_annotated = sorted(feature_importances_annotated.items(), key=operator.itemgetter(1), reverse=True)

for i, (k, v) in enumerate(sorted_feature_importances_annotated):
  print('{i}: {k}: {v}'.format(i=i, k=k, v=v))
  if i > 9:
        break

我们获得类似以下列表的内容:

您的最终列表可能会与此列表不同。神经网络训练并不确定性,尽管我们可以尝试固定随机生成器种子。在这里,正如我们所预料的,年龄是一个重要因素;然而,在关系状态和婚姻状况的某些类别中,年龄之前的因素也显现出来。

它是如何工作...

我们经历了机器学习中的典型流程:加载数据集,绘制和探索数据,对分类变量进行编码和归一化预处理。然后在 Keras 中创建和训练神经网络模型,并绘制训练和验证性能。让我们更详细地讨论我们所做的事情。

最大信息系数

有许多方法可以计算和绘制相关矩阵,我们将在接下来的示例中看到更多可能性。在这里,我们基于最大信息系数MIC)计算了相关性。 MIC 来自于基于最大信息的非参数探索框架。这篇文章于 2011 年发表在科学杂志上,被誉为 21 世纪的相关度量标准(文章链接在science.sciencemag.org/content/334/6062/1518.full)。

应用于两个变量 XY,它启发性地搜索两个变量中的分箱,使得给定分箱时 XY 之间的互信息最大化。该系数的范围介于 0(无相关性)和 1(完全相关性)之间。与皮尔逊相关系数相比,它有一个优势,首先是找到非线性相关性,其次是可以处理分类变量。

数据生成器

如果你熟悉 Python 生成器,你就不需要解释这是什么,但也许需要一些澄清。使用生成器可以按需或在线加载数据,而不是一次性加载。这意味着你可以处理比可用内存大得多的数据集。

神经网络和 Keras 生成器的一些重要术语如下所示

  • 迭代steps_per_epoch)是完成一个 epoch 所需的批次数。

  • 批量大小是单个批次中的训练示例数量。

有多种方法可以使用 Keras 实现生成器,例如以下方式:

  • 使用任何 Python 生成器

  • 实现 tensorflow.keras.utils.Sequence

对于第一个选项,我们可以使用任何生成器,但这里使用了一个带有 yield 函数。这意味着我们为 Keras 的 fit_generator() 函数提供了 steps_per_epoch 参数。

至于第二个选项,我们编写了一个继承自 tensorflow.keras.utils.Sequence 的类,该类实现了以下方法:

  • len(),以便 fit_generator() 函数知道还有多少数据要加载。这对应于 steps_per_epoch,并且是 

  • __getitem__(),用于 fit_generator 请求下一个批次。

  • on_epoch_end() 在 epoch 结束时执行一些洗牌或其他操作 - 这是可选的。

为简单起见,我们采用了前一种方法。

我们稍后将看到,使用生成器进行批数据加载通常是在线学习的一部分,即我们根据数据增量地训练模型。

排列重要性

eli5库可以计算排列重要性,该重要性衡量了当特征不存在时预测错误的增加。这也称为平均减少精度MDA)。与以一特征为一组的重新训练模型不同,可以将特征替换为随机噪声。该噪声从与特征相同的分布中绘制,以避免扭曲。实际上,最简单的方法是在行之间随机混洗特征值。您可以在 Breiman 的《随机森林》(2001)中找到有关排列重要性的更多细节,网址为www.stat.berkeley.edu/%7Ebreiman/randomforest2001.pdf

另请参阅

关于 Keras、底层 TensorFlow 库、在线学习和生成器,我们将在接下来的示例中详细讨论。我建议您熟悉层类型、数据加载器和预处理器、损失、指标和训练选项。所有这些都可以转移到其他框架,例如 PyTorch,其应用程序编程接口API)不同,但基本原则相同。

以下是 TensorFlow/Keras 文档的链接:

Keras/TensorFlow 组合和 PyTorch 都提供了许多有趣的功能,超出了本示例或本书的范围。举几个例子,PyTorch 具有自动区分功能(以 autograd 形式存在,详细信息请参见pytorch.org/docs/stable/autograd.html),而 TensorFlow 具有估算器 API,这是与 Keras 类似的抽象(有关更多详细信息,请参见www.tensorflow.org/guide/estimator)。

关于eli5的信息,请访问其网站:eli5.readthedocs.io/.

对于更多数据集,以下三个网站是您的好帮手:

第二章:监督机器学习中的高级主题

在前一章节中跟随 scikit-learn、Keras 和 PyTorch 的尝试后,我们将进入更多端到端示例。这些示例更加先进,因为它们包括更复杂的转换和模型类型。

我们将使用 sklearn 预测伴侣选择,在这里我们会实现许多自定义转换步骤和更复杂的机器学习流程。然后,我们将在 PyTorch 中预测房价并可视化特征和神经元的重要性。之后,我们将执行主动学习来共同决定客户价值,并进行 sklearn 中的在线学习。在已知的多次违法者预测案例中,我们将建立一个没有种族偏见的模型。最后但同样重要的是,我们将预测 CO[2] 水平的时间序列。

在线学习 在这个上下文中(与基于互联网的学习相对),指的是一种包含顺序接收的训练数据的模型更新策略。这在数据集非常庞大(通常出现在图像、视频和文本中)或者由于数据变化的性质需要保持模型更新时非常有用。

在许多这些示例中,我们已经缩短了描述以突出特定概念的最显著细节。有关完整详情,请参阅 GitHub 上的笔记本。

在这一章中,我们将涵盖以下几个示例:

  • 在 scikit-learn 中进行数据转换

  • 使用 PyTorch 预测房价

  • 实时决策客户价值

  • 对抗算法偏见

  • 预测 CO[2] 时间序列

技术要求

本章的代码和笔记本在 GitHub 上可供查阅:github.com/PacktPublishing/Artificial-Intelligence-with-Python-Cookbook/tree/master/chapter02

在 scikit-learn 中进行数据转换

在这个示例中,我们将使用混合类型的列式数据构建更复杂的管道。我们将使用由 Fisman 等人于 2006 年发布的速度约会数据集:doi.org/10.1162/qjec.2006.121.2.673

或许这些示例将在多方面为我们提供信息,并且我们将了解到关于人类交配选择机制的一些有用机制。

在 OpenML 网站上的数据集描述如下:

这些数据是从 2002 年至 2004 年实验性速配活动的参与者中收集的。在活动中,与异性的每位参与者进行四分钟的第一次约会。在他们的 4 分钟结束时,参与者被问及是否愿意再见对方。他们还被要求评价约会对象的六个属性:吸引力、诚实、智力、趣味、野心和共同兴趣。数据集还包括在过程不同阶段收集的参与者问卷数据。这些字段包括人口统计信息、约会习惯、跨关键属性的自我感知、对伴侣所认为有价值的东西的信念以及生活方式信息。

问题是根据我们对参与者及其配对的了解来预测伴侣选择。这个数据集呈现了一些可以作为说明用途的挑战:

  • 它包含 123 个不同类型的特征:

    • 分类

    • 数值

    • 范围特征

它还包括以下内容:

  • 一些缺失值

  • 目标不平衡

在解决预测伴侣选择问题的过程中,我们将在 scikit-learn 中构建自定义编码器和包含所有特征及其预处理步骤的管道。

本示例的主要焦点将放在管道和转换器上。特别是,我们将为处理范围特征构建一个自定义转换器,以及为数值特征构建另一个转换器。

准备工作

对于这个示例,我们将需要以下库:

  • OpenML 用于下载数据集

  • openml_speed_dating_pipeline_steps 用于使用我们的自定义转换器。

  • imbalanced-learn 用于处理不平衡的类别

  • shap 用于显示特征的重要性

为了安装它们,我们可以再次使用pip

pip install -q openml openml_speed_dating_pipeline_steps==0.5.5 imbalanced_learn category_encoders shap

OpenML 是一个旨在使数据科学和机器学习可复制的组织,因此更有利于研究。OpenML 网站不仅托管数据集,还允许将机器学习结果上传到公共排行榜,条件是实现必须完全依赖开源。有兴趣的任何人都可以查看这些结果及其详细获取方式。

为了检索数据,我们将使用 OpenML Python API。get_dataset() 方法将下载数据集;使用 get_data(),我们可以获取特征和目标的 pandas DataFrames,并且方便地获取分类和数值特征类型的信息:

import openml
dataset = openml.datasets.get_dataset(40536)
X, y, categorical_indicator, _ = dataset.get_data(
  dataset_format='DataFrame',
  target=dataset.default_target_attribute
)
categorical_features = list(X.columns[categorical_indicator]) numeric_features = list(
  X.columns[[not(i) for i in categorical_indicator]]
)

在原始数据集的版本中,正如论文中所述,有很多工作要做。但是,在 OpenML 版本的数据集中,缺失值已表示为numpy.nan,这让我们可以跳过此转换。如果您感兴趣,可以在 GitHub 上查看此预处理器:github.com/benman1/OpenML-Speed-Dating

或者,您可以使用来自 OpenML 数据集网页的下载链接,网址为www.openml.org/data/get_csv/13153954/speeddating.arff

数据集加载完毕,并安装了库,我们已经准备好开始了。

如何做到…

管道是描述机器学习算法如何按顺序进行转换的一种方式,包括预处理步骤,然后应用最终预测器之前的原始数据集。我们将在本配方和本书中的其他概念示例中看到这些概念。

查看此数据集很快就会发现几个显著的特点。我们有很多分类特征。因此,在建模时,我们需要对它们进行数字编码,如第一章中的Keras 中的建模和预测配方中所述,Python 人工智能入门

编码范围以数字形式显示

其中一些实际上是编码范围。这意味着这些是有序的序数类别;例如,d_interests_correlate特征包含如下字符串:

[[0-0.33], [0.33-1], [-1-0]]

如果我们把这些范围视为分类变量,我们将失去有关顺序的信息,以及有关两个值之间差异的信息。但是,如果我们将它们转换为数字,我们将保留这些信息,并能够在其上应用其他数值转换。

我们将实现一个转换器插入到 sklearn 管道中,以便将这些范围特征转换为数值特征。转换的基本思想是提取这些范围的上限和下限,如下所示:

def encode_ranges(range_str):
  splits = range_str[1:-1].split('-')
  range_max = splits[-1]
  range_min = '-'.join(splits[:-1])
  return range_min, range_max

examples = X['d_interests_correlate'].unique()
[encode_ranges(r) for r in examples]

我们将以示例为例:

[('0', '0.33'), ('0.33', '1'), ('-1', '0')]

为了获得数字特征,我们可以取两个边界的平均值。正如我们之前提到的,在 OpenML 上,不仅显示结果,还透明显示模型。因此,如果我们要提交我们的模型,我们只能使用已发布的模块。我们在pypi Python 包存储库中创建了一个模块,并发布了它,您可以在此处找到完整代码包:pypi.org/project/openml-speed-dating-pipeline-steps/

这里是RangeTransformer的简化代码:

from sklearn.base import BaseEstimator, TransformerMixin
import category_encoders.utils as util

class RangeTransformer(BaseEstimator, TransformerMixin):
  def __init__(self, range_features=None, suffix='_range/mean', n_jobs=-1):
    assert isinstance(range_features, list) or range_features is None
    self.range_features = range_features
    self.suffix = suffix
    self.n_jobs = n_jobs

  def fit(self, X, y=None):
    return self

  def transform(self, X, y=None):
    X = util.convert_input(X)
    if self.range_features is None:
      self.range_features = list(X.columns)

    range_data = pd.DataFrame(index=X.index)
    for col in self.range_features:
      range_data[str(col) + self.suffix] = pd.to_numeric(
        self._vectorize(X[col])
      )
    self.feature_names = list(range_data.columns)
    return range_data

    def _vectorize(self, s):
        return Parallel(n_jobs=self.n_jobs)(
            delayed(self._encode_range)(x) for x in s
        )

    @staticmethod
    @lru_cache(maxsize=32)
    def _encode_range(range_str):
        splits = range_str[1:-1].split('-')
        range_max = float(splits[-1])
        range_min = float('-'.join(splits[:-1]))
        return sum([range_min, range_max]) / 2.0

    def get_feature_names(self):
        return self.feature_names

这是范围的自定义转换器的简短片段。请在 GitHub 上查看完整实现,网址为github.com/benman1/OpenML-Speed-Dating

请注意如何使用 fit()transform() 方法。在 fit() 方法中,我们不需要做任何事情,因为我们总是应用相同的静态规则。transform() 方法应用这个规则。我们之前已经看过例子。在 transform() 方法中,我们会迭代列。这个转换器还展示了典型的 scikit-learn 并行化模式的使用。另外,由于这些范围经常重复,并且数量并不多,我们将使用缓存,以便不必进行昂贵的字符串转换,而是可以在处理完范围后从内存中检索范围值。

在 scikit-learn 中自定义转换器的一个重要事项是,它们应该继承自 BaseEstimatorTransformerMixin,并实现 fit()transform() 方法。稍后,我们将需要 get_feature_names() 方法来获取生成特征的名称。

派生高阶特征

让我们实现另一个转换器。您可能已经注意到,我们有不同类型的特征,看起来涉及相同的个人属性:

  • 个人偏好

  • 自我评估

  • 对另一个人的评估

似乎很明显,任何这些特征之间的差异可能是显著的,比如真诚的重要性与某人评估潜在伴侣的真诚程度之间的差异。因此,我们的下一个转换器将计算数值特征之间的差异,这有助于突出这些差异。

这些特征是从其他特征派生的,并结合了来自两个(或更多特征)的信息。让我们看看 NumericDifferenceTransformer 特征是什么样的:

import operator

class NumericDifferenceTransformer(BaseEstimator, TransformerMixin):
    def __init__(self, features=None,
                 suffix='_numdist', op=operator.sub, n_jobs=-1
                 ):
        assert isinstance(
            features, list
        ) or features is None
        self.features = features
        self.suffix = suffix
        self.op = op
        self.n_jobs = n_jobs

    def fit(self, X, y=None):
        X = util.convert_input(X)
        if self.features is None:
            self.features = list(
                X.select_dtypes(include='number').columns
            )
        return self

    def _col_name(self, col1, col2):
        return str(col1) + '_' + str(col2) + self.suffix

    def _feature_pairs(self):
        feature_pairs = []
        for i, col1 in enumerate(self.features[:-1]):
            for col2 in self.features[i+1:]:
                feature_pairs.append((col1, col2))
        return feature_pairs

    def transform(self, X, y=None):
        X = util.convert_input(X)

        feature_pairs = self._feature_pairs()
        columns = Parallel(n_jobs=self.n_jobs)(
            delayed(self._col_name)(col1, col2)
            for col1, col2 in feature_pairs
        )
        data_cols = Parallel(n_jobs=self.n_jobs)(
            delayed(self.op)(X[col1], X[col2])
            for col1, col2 in feature_pairs
        )
        data = pd.concat(data_cols, axis=1)
        data.rename(
            columns={i: col for i, col in enumerate(columns)},
            inplace=True, copy=False
        )
        data.index = X.index
        return data

    def get_feature_names(self):
        return self.feature_names

这是一个自定义转换器,用于计算数值特征之间的差异。请参考 OpenML-Speed-Dating 库的完整实现,位于 github.com/benman1/OpenML-Speed-Dating

这个转换器与 RangeTransformer 结构非常相似。请注意列之间的并行化。__init__() 方法的一个参数是用于计算差异的函数,默认情况下是 operator.sub()。operator 库是 Python 标准库的一部分,它将基本运算符实现为函数。sub() 函数做的就是它听起来像:

import operator
operator.sub(1, 2) == 1 - 2
# True

这为我们提供了标准操作符的前缀或功能语法。由于我们可以将函数作为参数传递,这使我们能够指定列之间的不同操作符。

这一次的 fit() 方法只是收集数值列的名称,我们将在 transform() 方法中使用这些名称。

结合转换

我们将使用 ColumnTransformer 和管道将这些转换器组合在一起。但是,我们需要将列与它们的转换关联起来。我们将定义不同的列组:

range_cols = [
    col for col in X.select_dtypes(include='category')
    if X[col].apply(lambda x: x.startswith('[')
    if isinstance(x, str) else False).any()
]
cat_columns = list(
  set(X.select_dtypes(include='category').columns) - set(range_cols)
)
num_columns = list(
    X.select_dtypes(include='number').columns
)

现在我们有范围列、分类列和数值列,我们可以为它们分配管道步骤。

在我们的案例中,我们将其组合如下,首先是预处理器:

from imblearn.ensemble import BalancedRandomForestClassifier
from sklearn.feature_selection import SelectKBest, f_classif
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import FunctionTransformer
import category_encoders as ce
import openml_speed_dating_pipeline_steps as pipeline_steps

preprocessor = ColumnTransformer(
 transformers=[
 ('ranges', Pipeline(steps=[
 ('impute', pipeline_steps.SimpleImputerWithFeatureNames(strategy='constant', fill_value=-1)),
 ('encode', pipeline_steps.RangeTransformer())
 ]), range_cols),
 ('cat', Pipeline(steps=[
 ('impute', pipeline_steps.SimpleImputerWithFeatureNames(strategy='constant', fill_value='-1')),
 ('encode', ce.OneHotEncoder(
 cols=None, # all features that it given by ColumnTransformer
 handle_unknown='ignore',
 use_cat_names=True
 )
 )
 ]), cat_columns),
 ('num', pipeline_steps.SimpleImputerWithFeatureNames(strategy='median'), num_columns),
 ],
 remainder='drop', n_jobs=-1
)

然后,我们将预处理放入管道中,与估算器一起:

def create_model(n_estimators=100):
    return Pipeline(
        steps=[('preprocessor', preprocessor),
               ('numeric_differences', pipeline_steps.NumericDifferenceTransformer()),
               ('feature_selection', SelectKBest(f_classif, k=20)),
               ('rf', BalancedRandomForestClassifier(
                  n_estimators=n_estimators,
                  )
               )]
       )

这里是测试集的表现:

from sklearn.metrics import roc_auc_score, confusion_matrix
from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(
  X, y,
  test_size=0.33,
  random_state=42,
  stratify=y
)
clf = create_model(50)
clf.fit(X_train, y_train)
y_predicted = clf.predict(X_test)
auc = roc_auc_score(y_test, y_predicted)
print('auc: {:.3f}'.format(auc))

我们得到以下性能作为输出:

auc: 0.779

这是一个非常好的性能,您可以将其与 OpenML 排行榜进行比较看到。

它是如何工作的…

现在是时候解释与此配方相关的基本 scikit-learn 术语了。这两个概念都不对应现有的机器学习算法,而是可组合的模块:

  • 转换器(在 scikit-learn 中):从 sklearn.base.TransformerMixin 派生的类;它具有 fit()transform() 方法。这些涉及预处理步骤或特征选择。

  • 预测器:从 sklearn.base.ClassifierMixinsklearn.base.RegressorMixin 派生的类;它具有 fit()predict() 方法。这些是机器学习估计器,换句话说,分类器或回归器。

  • 管道:一个接口,将所有步骤包装在一起,并为转换的所有步骤和结果估计器提供单一接口。管道再次具有 fit()predict() 方法。

有几点需要指出关于我们的方法。正如我们之前所说,我们有缺失值,因此必须用其他值填充(意思是替换)缺失值。在这种情况下,我们用 -1 替换缺失值。对于分类变量来说,这将成为一个新类别,而对于数值变量来说,这将成为分类器必须处理的特殊值。

ColumnTransformer 是 scikit-learn 版本 0.20 中引入的一个期待已久的功能。从那时起,ColumnTransformer 经常可以像这样看到,例如:

from sklearn.compose import ColumnTransformer, make_column_transformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder

feature_preprocessing = make_column_transformer(
  (StandardScaler(), ['column1', 'column2']),
  (OneHotEncoder(), ['column3', 'column4', 'column5']) 
)

feature_preprocessing 可以像往常一样使用 fit()transform()fit_transform() 方法:

processed_features = feature_preprocessing.fit_transform(X)

这里,X 意味着我们的特征。

或者,我们可以像这样将 ColumnTransformer 作为管道的一步:

from sklearn.pipeline import make_pipeline
from sklearn.linear_model import LogisticRegression

make_pipeline(
    feature_preprocessing,
    LogisticRegression()
)

我们的分类器是修改版的随机森林分类器。随机森林是一组决策树,每棵树都在训练数据的随机子集上进行训练。平衡的随机森林分类器(Chen 等人statistics.berkeley.edu/sites/default/files/tech-reports/666.pdf)确保每个随机子集在两个类别之间平衡。

由于 NumericDifferenceTransformer 可以提供大量特征,我们将增加一步基于模型的特征选择。

还有更多内容…

您可以在 GitHub 上的openml_speed_dating_pipeline_steps库和笔记本中查看使用速配数据集、几个自定义转换器和扩展填充类的完整示例,链接如下:github.com/PacktPublishing/Artificial-Intelligence-with-Python-Cookbook/blob/master/chapter02/Transforming%20Data%20in%20Scikit-Learn.ipynb

RangeTransformerNumericDifferenceTransformer 也可以使用 scikit-learn 中的 FunctionTransformer 实现。

ColumnTransformer 对于 pandas DataFrames 或 NumPy 数组特别方便,因为它允许为不同特征子集指定不同操作。然而,另一个选项是 FeatureUnion,它允许将来自不同转换的结果连接在一起。要了解另一种方法如何将我们的操作链在一起,请查看我们存储库中的 PandasPicker

另请参阅

在此食谱中,我们使用 ANOVA F 值进行单变量特征选择,这相对简单但有效。单变量特征选择方法通常是简单的过滤器或统计测试,用于衡量特征与目标的相关性。然而,有许多不同的特征选择方法,scikit-learn 实现了很多:scikit-learn.org/stable/modules/feature_selection.html

在 PyTorch 中预测房价

在这个食谱中,问题的目标是预测艾奥瓦州埃姆斯的房价,给定描述房屋、区域、土地、基础设施、公用设施等 81 个特征。埃姆斯数据集具有良好的分类和连续特征组合,适量适度,并且最重要的是,不像其他类似数据集(例如波士顿房价数据集)那样受潜在的红线问题或数据输入问题的困扰。我们将在此处集中讨论 PyTorch 建模的主要方面。我们将进行在线学习,类似于 Keras,在第一章中的Keras 中的建模和预测食谱中。如果您想查看某些步骤的更多详细信息,请查看我们在 GitHub 上的笔记本。

作为一个额外的内容,我们还将演示在 PyTorch 中开发的模型的神经元重要性。您可以在 PyTorch 中尝试不同的网络架构或模型类型。这个食谱的重点是方法论,而不是对最佳解决方案的详尽搜索。

准备工作

为了准备这个食谱,我们需要做一些准备工作。我们将像之前的食谱一样下载数据,在 scikit-learn 中转换数据,并按以下步骤进行一些预处理:

from sklearn.datasets import fetch_openml
data = fetch_openml(data_id=42165, as_frame=True)

您可以在 OpenML 上查看完整的数据集描述:www.openml.org/d/42165

让我们看看这些特征:

import pandas as pd
data_ames = pd.DataFrame(data.data, columns=data.feature_names)
data_ames['SalePrice'] = data.target
data_ames.info()

这是 DataFrame 的信息:

<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1460 entries, 0 to 1459
Data columns (total 81 columns):
Id               1460 non-null float64
MSSubClass       1460 non-null float64
MSZoning         1460 non-null object
LotFrontage      1201 non-null float64
LotArea          1460 non-null float64
Street           1460 non-null object
Alley            91 non-null object
LotShape         1460 non-null object
LandContour      1460 non-null object
Utilities        1460 non-null object
LotConfig        1460 non-null object
LandSlope        1460 non-null object
Neighborhood     1460 non-null object
Condition1       1460 non-null object
Condition2       1460 non-null object
BldgType         1460 non-null object
HouseStyle       1460 non-null object
OverallQual      1460 non-null float64
OverallCond      1460 non-null float64
YearBuilt        1460 non-null float64
YearRemodAdd     1460 non-null float64
RoofStyle        1460 non-null object
RoofMatl         1460 non-null object
Exterior1st      1460 non-null object
Exterior2nd      1460 non-null object
MasVnrType       1452 non-null object
MasVnrArea       1452 non-null float64
ExterQual        1460 non-null object
ExterCond        1460 non-null object
Foundation       1460 non-null object
BsmtQual         1423 non-null object
BsmtCond         1423 non-null object
BsmtExposure     1422 non-null object
BsmtFinType1     1423 non-null object
BsmtFinSF1       1460 non-null float64
BsmtFinType2     1422 non-null object
BsmtFinSF2       1460 non-null float64
BsmtUnfSF        1460 non-null float64
TotalBsmtSF      1460 non-null float64
Heating          1460 non-null object
HeatingQC        1460 non-null object
CentralAir       1460 non-null object
Electrical       1459 non-null object
1stFlrSF         1460 non-null float64
2ndFlrSF         1460 non-null float64
LowQualFinSF     1460 non-null float64
GrLivArea        1460 non-null float64
BsmtFullBath     1460 non-null float64
BsmtHalfBath     1460 non-null float64
FullBath         1460 non-null float64
HalfBath         1460 non-null float64
BedroomAbvGr     1460 non-null float64
KitchenAbvGr     1460 non-null float64
KitchenQual      1460 non-null object
TotRmsAbvGrd     1460 non-null float64
Functional       1460 non-null object
Fireplaces       1460 non-null float64
FireplaceQu      770 non-null object
GarageType       1379 non-null object
GarageYrBlt      1379 non-null float64
GarageFinish     1379 non-null object
GarageCars       1460 non-null float64
GarageArea       1460 non-null float64
GarageQual       1379 non-null object
GarageCond       1379 non-null object
PavedDrive       1460 non-null object
WoodDeckSF       1460 non-null float64
OpenPorchSF      1460 non-null float64
EnclosedPorch    1460 non-null float64
3SsnPorch        1460 non-null float64
ScreenPorch      1460 non-null float64
PoolArea         1460 non-null float64
PoolQC           7 non-null object
Fence            281 non-null object
MiscFeature      54 non-null object
MiscVal          1460 non-null float64
MoSold           1460 non-null float64
YrSold           1460 non-null float64
SaleType         1460 non-null object
SaleCondition    1460 non-null object
SalePrice        1460 non-null float64
dtypes: float64(38), object(43)
memory usage: 924.0+ KB

在 Colab 中,默认安装了 PyTorch 和 seaborn。我们假设,即使您现在使用自己托管的安装工作,也会安装这些库。

不过,我们还会使用一个库,captum,它允许检查 PyTorch 模型的特征和神经元重要性:

!pip install captum

还有一件事。我们假设您的计算机有 GPU。如果您的计算机没有 GPU,我们建议您在 Colab 上尝试此方法。在 Colab 中,您需要选择一个带 GPU 的运行时类型。

在所有这些准备工作之后,让我们看看如何预测房屋价格。

如何做到...

Ames 房屋数据集是一个小到中等规模的数据集(1,460 行),包含 81 个特征,既包括分类特征又包括数值特征。没有缺失值。

在之前的 Keras 配方中,我们已经看到了如何缩放变量。在这里缩放很重要,因为所有变量具有不同的尺度。分类变量需要转换为数值类型,以便将它们输入到我们的模型中。我们可以选择独热编码,其中我们为每个分类因子创建虚拟变量,或者序数编码,其中我们对所有因子进行编号,并用这些编号替换字符串。我们可以像处理任何其他浮点变量一样输入虚拟变量,而序数编码则需要使用嵌入,线性神经网络投影,重新排列多维空间中的类别。

我们选择嵌入路线:

import numpy as np
from category_encoders.ordinal import OrdinalEncoder
from sklearn.preprocessing import StandardScaler

num_cols = list(data_ames.select_dtypes(include='float'))
cat_cols = list(data_ames.select_dtypes(include='object'))

ordinal_encoder = OrdinalEncoder().fit(
    data_ames[cat_cols]
)
standard_scaler = StandardScaler().fit(
    data_ames[num_cols]
)

X = pd.DataFrame(
    data=np.column_stack([
        ordinal_encoder.transform(data_ames[cat_cols]),
        standard_scaler.transform(data_ames[num_cols])
    ]),
    columns=cat_cols + num_cols
)

我们在 GitHub 上的笔记本中更详细地进行数据分析,例如相关性和分布图。

现在,我们可以将数据分割为训练集和测试集,就像我们在之前的示例中所做的那样。在这里,我们还添加了一个数值变量的分层。这确保了不同部分(五个部分)在训练集和测试集中的等量包含:

np.random.seed(12)  
from sklearn.model_selection import train_test_split

bins = 5
sale_price_bins = pd.qcut(
    X['SalePrice'], q=bins, labels=list(range(bins))
)
X_train, X_test, y_train, y_test = train_test_split(
    X.drop(columns='SalePrice'),
    X['SalePrice'],
    random_state=12,
    stratify=sale_price_bins
)

在继续之前,让我们使用一个与模型无关的技术来查看特征的重要性。

然而,在运行任何东西之前,让我们确保我们在 GPU 上运行:

device = torch.device('cuda')
torch.backends.cudnn.benchmark = True

让我们构建我们的 PyTorch 模型,类似于《Python 中的人工智能入门》第一章中的 在 scikit-learn、Keras 和 PyTorch 中分类 的配方。

我们将使用 PyTorch 实现一个带批量输入的神经网络回归。这将涉及以下步骤:

  1. 将数据转换为 torch 张量:

  2. 定义模型架构:

  3. 定义损失标准和优化器:

  4. 创建批量数据加载器:

  5. 运行训练:

没有进一步的序言,让我们开始吧:

  1. 将数据转换为 torch 张量:
from torch.autograd import Variable 

num_features = list(
    set(num_cols) - set(['SalePrice', 'Id'])
)
X_train_num_pt = Variable(
    torch.cuda.FloatTensor(
        X_train[num_features].values
    )
)
X_train_cat_pt = Variable(
    torch.cuda.LongTensor(
        X_train[cat_cols].values
    )
)
y_train_pt = Variable(
    torch.cuda.FloatTensor(y_train.values)
).view(-1, 1)
X_test_num_pt = Variable(
    torch.cuda.FloatTensor(
        X_test[num_features].values
    )
)
X_test_cat_pt = Variable(
   torch.cuda.LongTensor(
        X_test[cat_cols].values
    ).long()
)
y_test_pt = Variable(
    torch.cuda.FloatTensor(y_test.values)
).view(-1, 1)

这确保我们将数值和分类数据加载到不同的变量中,类似于 NumPy。如果在单个变量(数组/矩阵)中混合数据类型,它们将变成对象。我们希望将数值变量作为浮点数加载,并将分类变量作为长整型(或整型)索引类别。我们还要将训练集和测试集分开。

显然,ID 变量在模型中不应该很重要。在最坏的情况下,如果 ID 与目标变量有任何相关性,它可能会引入目标泄漏。因此,我们已经将其从进一步处理中移除。

  1. 定义模型架构:
class RegressionModel(torch.nn.Module): 

    def __init__(self, X, num_cols, cat_cols, device=torch.device('cuda'), embed_dim=2, hidden_layer_dim=2, p=0.5): 
        super(RegressionModel, self).__init__() 
        self.num_cols = num_cols
        self.cat_cols = cat_cols
        self.embed_dim = embed_dim
        self.hidden_layer_dim = hidden_layer_dim

        self.embeddings = [
            torch.nn.Embedding(
                num_embeddings=len(X[col].unique()),
                embedding_dim=embed_dim
            ).to(device)
            for col in cat_cols
        ]
        hidden_dim = len(num_cols) + len(cat_cols) * embed_dim,

        # hidden layer
        self.hidden = torch.nn.Linear(torch.IntTensor(hidden_dim), hidden_layer_dim).to(device)
        self.dropout_layer = torch.nn.Dropout(p=p).to(device)
        self.hidden_act = torch.nn.ReLU().to(device)

        # output layer
        self.output = torch.nn.Linear(hidden_layer_dim, 1).to(device)

    def forward(self, num_inputs, cat_inputs):
        '''Forward method with two input variables -
        numeric and categorical.
        '''
        cat_x = [
            torch.squeeze(embed(cat_inputs[:, i] - 1))
            for i, embed in enumerate(self.embeddings)
        ]
        x = torch.cat(cat_x + [num_inputs], dim=1)
        x = self.hidden(x)
        x = self.dropout_layer(x)
        x = self.hidden_act(x)
        y_pred = self.output(x)
        return y_pred

house_model = RegressionModel(
    data_ames, num_features, cat_cols
)

我们在两个线性层(Keras 术语中的密集层)上使用的激活函数是修正线性单元激活ReLU)函数。请注意,由于在分类和数值类型上发生了不同的操作,我们不能轻松地将相同的架构封装为顺序模型。

  1. 接下来,定义损失标准和优化器。我们以均方误差MSE)作为损失函数,使用随机梯度下降作为优化算法:
criterion = torch.nn.MSELoss().to(device)
optimizer = torch.optim.SGD(house_model.parameters(), lr=0.001)
  1. 现在,创建一个数据加载器以一次输入一个数据批次:
data_batch = torch.utils.data.TensorDataset(
    X_train_num_pt, X_train_cat_pt, y_train_pt
)
dataloader = torch.utils.data.DataLoader(
    data_batch, batch_size=10, shuffle=True
)

我们设置批量大小为 10。现在我们可以进行训练。

  1. 开始训练!

由于这似乎比我们在《Python 人工智能入门第一章中看到的 Keras 中的分类》一书中的示例要冗长得多,我们对此代码进行了详细的注释。基本上,我们必须在每个 epoch 上进行循环,并在每个 epoch 内执行推断、计算误差,并根据误差应用优化器进行调整。

这是没有内部训练循环的 epoch 循环:

from tqdm.notebook import trange

train_losses, test_losses = [], []
n_epochs = 30
for epoch in trange(n_epochs):
    train_loss, test_loss = 0, 0
    # training code will go here:
    # <...>

    # print the errors in training and test:
    if epoch % 10 == 0 :
        print(
            'Epoch: {}/{}\t'.format(epoch, 1000),
            'Training Loss: {:.3f}\t'.format(
                train_loss / len(dataloader)
            ),
            'Test Loss: {:.3f}'.format(
                test_loss / len(dataloader)
            )
        )

训练是在所有训练数据的批次循环内执行的。它看起来如下所示:

    for (x_train_num_batch,
         x_train_cat_batch,
         y_train_batch) in dataloader:
        # predict y by passing x to the model 
        (x_train_num_batch,
         x_train_cat_batch, y_train_batch) = (
                x_train_num_batch.to(device),
                x_train_cat_batch.to(device),
                y_train_batch.to(device)
        )
        pred_ytrain = house_model.forward(
            x_train_num_batch, x_train_cat_batch
        )
        # calculate and print loss:
        loss = torch.sqrt(
            criterion(pred_ytrain, y_train_batch)
        ) 

        # zero gradients, perform a backward pass, 
        # and update the weights. 
        optimizer.zero_grad() 
        loss.backward() 
        optimizer.step()
        train_loss += loss.item()
        with torch.no_grad():
            house_model.eval()
            pred_ytest = house_model.forward(
                X_test_num_pt, X_test_cat_pt
            )
            test_loss += torch.sqrt(
                criterion(pred_ytest, y_test_pt)
            )

        train_losses.append(train_loss / len(dataloader))
        test_losses.append(test_loss / len(dataloader))

这是我们得到的输出。TQDM 为我们提供了一个有用的进度条。在每个第十个 epoch,我们打印一个更新,显示训练和验证性能:

请注意,我们对nn.MSELoss取平方根,因为 PyTorch 中的nn.MSELoss定义如下:

((input-target)**2).mean()

让我们绘制模型在训练和验证数据集上的表现情况:

plt.plot(
    np.array(train_losses).reshape((n_epochs, -1)).mean(axis=1),
    label='Training loss'
)
plt.plot(
    np.array(test_losses).reshape((n_epochs, -1)).mean(axis=1),
    label='Validation loss'
)
plt.legend(frameon=False)
plt.xlabel('epochs')
plt.ylabel('MSE')

以下图表显示了结果绘图:

我们在验证损失停止减少之前及时停止了训练。

我们还可以对目标变量进行排名和分箱,并将预测结果绘制在其上,以便查看模型在整个房价范围内的表现。这是为了避免在回归中出现的情况,特别是当损失函数为 MSE 时,只能在接近均值的中等价值范围内进行良好预测,而对其他任何值都表现不佳。您可以在 GitHub 笔记本中找到此代码。这称为提升图表(这里有 10 个分箱):

我们可以看到,事实上,模型在整个房价范围内都预测得非常接近。事实上,我们得到了约 93% 的斯皮尔曼等级相关性,非常显著,这证实了这个模型具有很高的准确性。

如何运作...

深度学习神经网络框架使用不同的优化算法。其中流行的有随机梯度下降SGD)、均方根传播RMSProp)和自适应矩估计ADAM)。

我们将随机梯度下降定义为我们的优化算法。或者,我们也可以定义其他优化器:

opt_SGD = torch.optim.SGD(net_SGD.parameters(), lr=LR)
opt_Momentum = torch.optim.SGD(net_Momentum.parameters(), lr=LR, momentum=0.6)
opt_RMSprop = torch.optim.RMSprop(net_RMSprop.parameters(), lr=LR, alpha=0.1)
opt_Adam = torch.optim.Adam(net_Adam.parameters(), lr=LR, betas=(0.8, 0.98))

SGD 与梯度下降的工作方式相同,只是每次只对单个示例进行操作。有趣的是,其收敛性类似于梯度下降,并且对计算机内存的要求更低。

RMSProp 的工作原理是根据梯度的符号来调整算法的学习率。最简单的变体检查最后两个梯度的符号,然后根据它们是否相同增加或减少学习率的一小部分。

ADAM 是最流行的优化器之一。它是一种自适应学习算法,根据梯度的一阶和二阶矩来调整学习率。

Captum 是一个工具,可以帮助我们理解在数据集上学习的神经网络模型的细枝末节。它可以协助学习以下内容:

  • 特征重要性

  • 层重要性

  • 神经元重要性

这在学习可解释神经网络中非常重要。在这里,使用了集成梯度来理解特征重要性。后来,还通过使用层导纳方法展示了神经元的重要性。

还有更多...

鉴于我们已经定义并训练了神经网络,让我们使用 captum 库找出重要的特征和神经元:

from captum.attr import (
    IntegratedGradients,
    LayerConductance,
    NeuronConductance
)
house_model.cpu()
for embedding in house_model.embeddings:
    embedding.cpu()

house_model.cpu()
ing_house = IntegratedGradients(forward_func=house_model.forward, )
#X_test_cat_pt.requires_grad_()
X_test_num_pt.requires_grad_()
attr, delta = ing_house.attribute(
 X_test_num_pt.cpu(),
 target=None,
 return_convergence_delta=True,
 additional_forward_args=X_test_cat_pt.cpu()
)
attr = attr.detach().numpy()

现在,我们有一个特征重要性的 NumPy 数组。

也可以使用这个工具获取层和神经元的重要性。让我们看看我们第一层神经元的重要性。我们可以传递 house_model.act1,这是第一线性层上的 ReLU 激活函数:

cond_layer1 = LayerConductance(house_model, house_model.act1)
cond_vals = cond_layer1.attribute(X_test, target=None)
cond_vals = cond_vals.detach().numpy()
df_neuron = pd.DataFrame(data = np.mean(cond_vals, axis=0), columns=['Neuron Importance'])
df_neuron['Neuron'] = range(10)

它看起来是这样的:

图表显示神经元的重要性。显然,有一个神经元并不重要。

我们还可以通过对我们之前获得的 NumPy 数组进行排序来查看最重要的变量:

df_feat = pd.DataFrame(np.mean(attr, axis=0), columns=['feature importance'] )
df_feat['features'] = num_features
df_feat.sort_values(
    by='feature importance', ascending=False
).head(10)

这里是最重要的 10 个变量的列表:

特征重要性通常有助于我们理解模型,并且剪枝模型以使其变得不那么复杂(希望也不那么过拟合)。

另见

PyTorch 文档包含了关于层类型、数据加载、损失、度量和训练的一切信息:pytorch.org/docs/stable/nn.html

有关优化算法的详细讨论可以在以下文章中找到:imaddabbura.github.io/post/gradient-descent-algorithm/。Geoffrey Hinton 和其他人在演示幻灯片中详细解释了小批量梯度下降:www.cs.toronto.edu/~tijmen/csc321/slides/lecture_slides_lec6.pdf。最后,您可以在介绍它的文章中找到有关 ADAM 的所有细节:arxiv.org/abs/1412.6980

Captum 为 PyTorch 模型的可解释性和模型检查提供了丰富的功能。您可以在其详尽的文档中找到更多信息:captum.ai/。详细信息可以在原始论文中找到:arxiv.org/pdf/1703.01365.pdf

实时决策客户价值

假设我们有以下情景:我们有一份顾客名单,需要给他们打电话来销售我们的产品。每次电话都需要支付呼叫中心人员的工资,因此我们希望尽可能地减少这些成本。我们对每个顾客都有一些信息,这些信息可以帮助我们确定他们是否有可能购买。每次通话后,我们可以更新我们的模型。主要目标是只打给最有潜力的顾客,并提高我们对哪些顾客更可能购买我们产品的洞察力。

在这个配方中,我们将采用主动学习的方法,这是一种策略,我们可以主动决定接下来要探索(和学习)什么。我们的模型将帮助决定打给谁。因为我们将在每次查询(电话)后更新我们的模型,所以我们将使用在线学习模型。

准备工作

我们将通过下载数据集和安装几个库来准备我们的配方。

再次,我们将从 OpenML 获取数据:

!pip install -q openml

import openml 
dataset = openml.datasets.get_dataset(1461)
X, y, categorical_indicator, _ = dataset.get_data(
  dataset_format='DataFrame',
  target=dataset.default_target_attribute
)
categorical_features = X.columns[categorical_indicator]
numeric_features = X.columns[
  [not(i) for i in categorical_indicator]
]

这个数据集被称为bank-marketing,您可以在 OpenML 上查看其描述:www.openml.org/d/1461

对于每一行,描述一个人,我们有不同的特征,数值和分类的,告诉我们关于人口统计和顾客历史的信息。

为了模拟顾客签署我们的产品的可能性,我们将使用专门用于在线模型的 scikit-multiflow 包。我们还将再次使用category_encoders包:

!pip install scikit-multiflow category_encoders

有了这两个库,我们可以开始这个配方了。

如何做…

我们需要实现一个探索策略和一个正在不断更新的模型。我们正在使用在线版本的随机森林,Hoeffding Tree,作为我们的模型。我们正在估计每一步的不确定性,并基于此返回下一个要呼叫的候选人。

与往常一样,我们需要定义一些预处理步骤:

from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import FunctionTransformer
import category_encoders as ce

ordinal_encoder = ce.OrdinalEncoder(
  cols=None,  # all features that it encounters
  handle_missing='return_nan',
  handle_unknown='ignore'
).fit(X)

preprocessor = ColumnTransformer(
  transformers=[
    ('cat', ordinal_encoder, categorical_features),
    ('num', FunctionTransformer(validate=False), numeric_features)
])
preprocessor = preprocessor.fit(X)

然后我们来到我们的主动学习方法本身。这受到了modAL.models.ActiveLearner的启发:

import numpy as np
from skmultiflow.trees.hoeffding_tree import HoeffdingTreeClassifier 
from sklearn.metrics import roc_auc_score
import random

class ActivePipeline:
  def __init__(self, model, preprocessor, class_weights):
    self.model = model
    self.preprocessor = preprocessor
    self.class_weights = class_weights

  @staticmethod
  def values(X):
    if isinstance(X, (np.ndarray, np.int64)):
      return X
    else:
      return X.values

  def preprocess(self, X):
    X_ = pd.DataFrame(
      data=self.values(X),
      columns=[
        'V1', 'V2', 'V3', 'V4',
        'V5', 'V6', 'V7', 'V8',
        'V9', 'V10', 'V11', 'V12',
        'V13', 'V14', 'V15', 'V16'
      ])
    return self.preprocessor.transform(X_)

  def fit(self, X, ys):
    weights = [self.class_weights[y] for y in ys]
    self.model.fit(self.preprocess(X), self.values(ys))

  def update(self, X, ys):
    if isinstance(ys, (int, float)):
      weight = self.class_weights[y]
    else:
      weight = [self.class_weights[y] for y in ys]

    self.model.partial_fit(
      self.preprocess(X),
      self.values(ys),
      weight
    )

  def predict(self, X):
    return self.model.predict(
      self.preprocess(X)
    )

  def predict_proba(self, X):
    return self.model.predict_proba(
      self.preprocess(X)
    )

  @staticmethod
  def entropy(preds):
    return -np.sum(
      np.log((preds + 1e-15) * preds)
      / np.log(np.prod(preds.size))
    )

  def max_margin_uncertainty(self, X, method: str='entropy',
      exploitation: float=0.9, favor_class: int=1, k: int=1
  ):
    '''similar to modAL.uncertainty.margin_uncertainty
    ''' 
    probs = self.predict_proba(X)
    if method=='margin':
      uncertainties = np.abs(probs[:,2] - probs[:, 1]) / 2.0
    elif method=='entropy':
      uncertainties = np.apply_along_axis(self.entropy, 1, probs[:, (1,2)])
    else: raise(ValueError('method not implemented!'))

    if favor_class is None:
      weights = uncertainties 
    else: weights = (1.0 - exploitation) * uncertainties + exploitation * probs[:, favor_class]

    if self.sampling:
      ind = random.choices(
        range(len(uncertainties)), weights, k=k
      )
    else:
      ind = np.argsort(weights, axis=0)[::-1][:k]
    return ind, np.mean(uncertainties[ind])

  def score(self, X, y, scale=True):
    probs = self.predict_proba(X, probability=2)
    if scale:
      probs = np.clip(probs - np.mean(probs) + 0.5, 0, 1)
    return roc_auc_score(y, probs)

同样地,我们创建了一个与 scikit-learn 兼容的类。它基本上保存了一个机器学习模型和一个数据预处理器。我们实现了fit()predict(),还有score()来获取模型的性能。我们还实现了一个update()方法,调用机器学习模型的partial_fit()。调用partial_fit()而不是fit()大大加快了计算速度,因为我们不必每次获取新数据时都从头开始。

创建主动学习管道的方法如下:

active_pipeline = ActivePipeline(
  HoeffdingTreeClassifier(),
  preprocessor,
  class_weights.to_dict()
)
active_pipeline.model.classes = [0, 1, 2]

我们可以使用这个设置在我们的数据集上运行不同的模拟。例如,我们可以比较大量实验(0.5 开发利用)与仅开发利用(1.0),或者在第一批之后根本不学习。基本上我们通过一个循环进行:

  • 通过active_pipeline.max_margin_uncertainty(),我们向主动管道呈现数据,并根据我们集成方法的偏好获取不确定性和目标预测的数据点数量。

  • 一旦我们获得这些数据点的实际结果,我们就可以更新我们的模型:active_pipeline.update()

您可以在 GitHub 的笔记本中看到一个例子。

我们可以看到,在几个示例之后,好奇心获胜。实际上,开发利用是最不成功的方案。通过不更新模型,性能随时间而恶化:

这是主动学习或强化学习的理想场景,因为,与强化学习类似,不确定性可以成为除了客户正面期望外的附加标准。随着模型对客户理解的提升,随时间减少这种熵寻求行为。

工作原理如下…

值得深入探讨这个配方中使用的一些概念和策略。

主动学习

主动学习意味着我们可以积极查询更多信息;换句话说,探索是我们策略的一部分。在我们必须主动决定学习什么以及我们学到了什么不仅影响我们的模型学习量和质量,还影响我们可以获得的投资回报的场景中,这是非常有用的。

霍夫丁树

霍夫丁树(也称为非常快速决策树,简称VFDT)由 Geoff Hulten 等人于 2001 年引入(挖掘时间变化数据流)。它是一个增量增长的用于数据流的决策树。树节点基于霍夫丁边界(或加法切尔诺夫边界)进行扩展。理论上已经证明,给定足够的训练数据,由霍夫丁树学习的模型与非增量学习者构建的模型非常接近。

霍夫丁边界定义如下:

需要注意的是,霍夫丁树不处理随时间变化的数据分布。

类加权

由于我们处理的是不平衡数据集,让我们使用类权重。这基本上意味着我们在少数类(注册)上采样,而在多数类(未注册)上进行下采样。

类权重的公式如下:

同样,在 Python 中,我们可以编写以下内容:

class_weights = len(X) / (y.astype(int).value_counts() * 2)

然后我们可以使用这些类权重进行抽样。

我们将以几个额外的指针来结束本文。

另见

只有少数几个 scikit-learn 模型允许增量或在线学习。请参考scikit-learn.org/stable/modules/computing.html上的列表。

几个线性模型包括partial_fit()方法。scikit-multiflow 库专门用于增量和在线/流模型:scikit-multiflow.github.io/

您可以从最近关注生物医学图像处理的综述中找到更多关于主动学习的资源和想法(Samuel Budd 等人,关于医学图像分析的主动学习和人机协同深度学习的调查,2019 年;arxiv.org/abs/1910.02923)。

我们的方法受到 modalAI Python 主动学习包的启发,您可以在modal-python.readthedocs.io/找到它。如果您对主动学习方法感兴趣,我们建议您查看。还有一些其他 Python 包可供选择:

主动学习中的主要决策之一是探索与利用之间的权衡。您可以在一篇名为主动学习中的探索与利用:贝叶斯方法的论文中找到更多信息:www.vincentlemaire-labs.fr/publis/ijcnn_2_2010_camera_ready.pdf

对抗算法偏见

替代制裁的矫正罪犯管理剖析系统COMPAS)是一种商业算法,根据犯罪案件记录为刑事被告分配风险分数。该风险分数对应于再犯(累犯)和犯下暴力犯罪的可能性,并且此分数用于法庭上帮助确定判决。ProPublica 组织在佛罗里达州的一个县获得了约 7,000 人的分数和数据。等待 2 年后,他们审计了 2016 年的 COMPAS 模型,并发现了模型存在非常令人担忧的问题。ProPublica 的发现之一是在性别、种族和族裔方面存在歧视问题,特别是在对少数民族过度预测累犯率的情况下。

歧视对 AI 系统构成重大问题,说明了审核模型及其输入数据的重要性。如果忽视了这种偏见,基于人类决策建立的模型将放大人类偏见。我们不仅从法律角度考虑,而且从道德角度来说,我们希望构建不会给某些群体带来不利的模型。这为模型构建提出了一个有趣的挑战。

通常情况下,我们认为司法应对性别或种族视而不见。这意味着法院的决定不应考虑这些敏感变量如种族或性别。然而,即使我们在模型训练中省略了它们,这些敏感变量可能与其他变量相关,并因此仍然可能影响决策,对受保护群体如少数族裔或妇女造成损害。

在本节中,我们将使用 ProPublica 提供的 COMPAS 建模数据集。我们将检查是否存在种族偏见,并创建一个模型来消除它。您可以在 ProPublica 的原始分析中找到这些信息:github.com/propublica/compas-analysis

准备就绪

在我们开始之前,我们首先会下载数据,提及预处理中的问题,并安装所需的库。

让我们获取数据:

!wget https://raw.githubusercontent.com/propublica/compas-analysis/master/compas-scores-two-years.csv
import pandas as pd
date_cols = [
    'compas_screening_date', 'c_offense_date',
    'c_arrest_date', 'r_offense_date', 
    'vr_offense_date', 'screening_date',
    'v_screening_date', 'c_jail_in',
    'c_jail_out', 'dob', 'in_custody', 
    'out_custody'
]
data = pd.read_csv(
    'compas-scores-two-years.csv',
    parse_dates=date_cols
)

每行代表犯人的暴力风险和累犯风险评分。最后一列two_year_recid表示我们的目标。

ProPublica 从不同来源编制了他们的数据集,他们根据罪犯的名字进行了匹配:

  • 来自布罗沃德县法院书记处网站的刑事记录。

  • 来自佛罗里达监狱部门网站的公共监禁记录。

  • 他们通过公开记录信息请求获取的 COMPAS 评分。

我们可以突出几个数据集中的问题:

  1. 种族一栏是受保护的类别。不应作为模型训练的特征,而应作为控制变量。

  2. 数据集中有全名,这些全名可能没有用处,甚至可能透露出犯人的种族。

  3. 数据集中有案件编号。这些可能不适合用于训练模型,尽管它们可能具有某种目标泄漏,即案件编号的增加可能表明时间,并且在目标上可能存在漂移效应。

  4. 存在缺失值。我们需要进行插补。

  5. 存在日期时间戳。这些可能不会有用,甚至可能会带来相关问题(见第 3 点)。然而,我们可以将这些特征转换为 UNIX 时间戳,即自 1970 年以来经过的秒数,然后计算日期时间戳之间的时间段,例如通过重新使用我们在之前示例中看到的NumericDifferenceTransformer。然后,我们可以将这些时间段用作模型特征,而不是日期时间戳。

  6. 我们有几个分类变量。

  7. 计费描述(c_charge_desc)可能需要清理。

Mathias Barenstein 指出(arxiv.org/abs/1906.04711)ProPublica 在处理数据时出现了一个错误,导致保留了比他们本应保留的再犯者多 40% 的数据。我们将他的修正应用于两年的截止日期:

import datetime
indexes = data.compas_screening_date <= pd.Timestamp(datetime.date(2014, 4, 1))
assert indexes.sum() == 6216
data = data[indexes]

在本教程中我们将使用几个库,可以按以下方式安装:

!pip install category-encoders

category-encoders 是一个提供超出 scikit-learn 所提供的分类编码功能的库。

如何做到……

让我们先了解一些基本术语。我们需要为公平性制定度量标准。但是公平性(或者,如果我们看不公平性,偏见)意味着什么?

公平性可以指两个非常不同的概念:

  • [平等机会]:模型预测与实际情况之间不应有差异;或

  • [等结果]:模型的结果应完全相同。

第一个也被称为等几率,而后者指的是等假阳性率。平等机会意味着每个群体都应该有同样的机会,而平等结果策略则意味着表现不佳的群体应该相对其他群体更加宽容或者有更多机会。

我们将采用假阳性率的概念,这在直觉上具有吸引力,并且在许多司法管辖区的平等就业机会案例中被确立为法律。关于这些术语,我们将在参见部分提供一些资源。

因此,影响计算的逻辑基于混淆矩阵中的数值,最重要的是假阳性,我们刚刚提到的。这些情况被预测为阳性,实际上却是阴性;在我们的情况下,被预测为再犯者的人,实际上不是再犯者。让我们为此编写一个函数:

def confusion_metrics(actual, scores, threshold):
    y_predicted = scores.apply(
        lambda x: x >= threshold
    ).values
    y_true = actual.values
    TP = (
        (y_true==y_predicted) & 
        (y_predicted==1)
    ).astype(int)
    FP = (
        (y_true!=y_predicted) &
        (y_predicted==1)
    ).astype(int)
    TN = (
        (y_true==y_predicted) &
        (y_predicted==0)
    ).astype(int)
    FN = (
        (y_true!=y_predicted) &
        (y_predicted==0)
    ).astype(int)
    return TP, FP, TN, FN

现在我们可以使用这个函数来总结特定群体的影响,代码如下:

def calculate_impacts(data, sensitive_column='race', recid_col='is_recid', score_col='decile_score.1', threshold=5.0):
    if sensitive_column == 'race':
      norm_group = 'Caucasian'
    elif sensitive_column == 'sex':
      norm_group = 'Male'
    else:
      raise ValueError('sensitive column not implemented')
    TP, FP, TN, FN = confusion_metrics(
        actual=data[recid_col],
        scores=data[score_col],
        threshold=threshold
    )
    impact = pd.DataFrame(
        data=np.column_stack([
              FP, TN, FN, TN,
              data[sensitive_column].values, 
              data[recid_col].values,
              data[score_col].values / 10.0
             ]),
        columns=['FP', 'TP', 'FN', 'TN', 'sensitive', 'reoffend', 'score']
    ).groupby(by='sensitive').agg({
        'reoffend': 'sum', 'score': 'sum',
        'sensitive': 'count', 
        'FP': 'sum', 'TP': 'sum', 'FN': 'sum', 'TN': 'sum'
    }).rename(
        columns={'sensitive': 'N'}
    )

    impact['FPR'] = impact['FP'] / (impact['FP'] + impact['TN'])
    impact['FNR'] = impact['FN'] / (impact['FN'] + impact['TP'])
    impact['reoffend'] = impact['reoffend'] / impact['N']
    impact['score'] = impact['score'] / impact['N']
    impact['DFP'] = impact['FPR'] / impact.loc[norm_group, 'FPR']
    impact['DFN'] = impact['FNR'] / impact.loc[norm_group, 'FNR']
    return impact.drop(columns=['FP', 'TP', 'FN', 'TN'])

这首先计算混淆矩阵,其中包括真阳性和假阴性,然后编码不利影响比率AIR),在统计学中也被称为相对风险比率RRR)。鉴于任何性能指标,我们可以写成以下形式:

这表达了一个期望,即受保护群体(非裔美国人)的指标应该与常规群体(白种人)的指标相同。在这种情况下,我们将得到 1.0. 如果受保护群体的指标与常规群体相差超过 20 个百分点(即低于 0.8 或高于 1.2),我们将标记它为显著的歧视。

规范组一个规范组,也称为标准化样本规范组,是代表统计数据意图比较的人群样本。在偏见的语境中,其法律定义是具有最高成功率的群体,但在某些情况下,整个数据集或最频繁的群体被作为基线。实用上,我们选择白人群体,因为他们是最大的群体,也是模型效果最好的群体。

在前述函数中,我们按敏感群体计算假阳性率。然后,我们可以检查非洲裔美国人与白人之间的假阳性率是否不成比例,或者非洲裔美国人的假阳性率是否高得多。这意味着非洲裔美国人被频繁标记为再次犯罪者的情况。我们发现确实如此:

这张表格的简短解释如下:

  • 再犯:再犯频率

  • 分数:该群体的平均分数

  • N:组内的总人数

  • FPR:false positive rates

  • FNR:false negative rates

  • DFP:不成比例的假阳性

  • DFN:不成比例的假阴性

最后的 FPR 和 FNR 列一起可以给出关于模型总体质量的概念。如果两者都很高,那么模型在特定群体中的表现就不好。最后两列分别表达了 FPR 和 FNR 比率的不良影响比例,这是我们主要关注的内容。我们需要通过降低非裔美国人的 FPR 至可接受水平来减少模型中的种族偏见。

让我们进行一些预处理,然后我们将构建模型:

from sklearn.feature_extraction.text import CountVectorizer
from category_encoders.one_hot import OneHotEncoder
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler

charge_desc = data['c_charge_desc'].apply(lambda x: x if isinstance(x, str) else '')
count_vectorizer = CountVectorizer(
    max_df=0.85, stop_words='english',
    max_features=100, decode_error='ignore'
)
charge_desc_features = count_vectorizer.fit_transform(charge_desc)

one_hot_encoder = OneHotEncoder()
charge_degree_features = one_hot_encoder.fit_transform(
    data['c_charge_degree']
)

data['race_black'] = data['race'].apply(lambda x: x == 'African-American').astype(int)
stratification = data['race_black'] + (data['is_recid']).astype(int) * 2

CountVectorizer计算单词的词汇量,指示每个单词的使用频率。这被称为词袋表示法,并且我们将其应用于被指控描述列。我们排除英语停用词,这些词非常常见,例如介词(如onat)和人称代词(例如Ime);我们还将词汇限制为 100 个词以及不出现在超过 85%字段中的词汇。

我们对被指控程度应用虚拟编码(单热编码)。

为什么我们要使用两种不同的转换方式?基本上,描述是关于为什么某人被指控犯罪的文字描述。每个字段都是不同的。如果我们使用单热编码,每个字段将获得自己的虚拟变量,我们就无法看到字段之间的任何共性。

最后,我们创建一个新变量以分层化,以确保在训练和测试数据集中,再犯(我们的目标变量)和某人是否是非洲裔美国人的比例相似。这将帮助我们计算指标以检查歧视性:

y = data['is_recid']
X = pd.DataFrame(
    data=np.column_stack(
        [data[['juv_fel_count', 'juv_misd_count',
 'juv_other_count', 'priors_count', 'days_b_screening_arrest']], 
          charge_degree_features, 
          charge_desc_features.todense()
        ]
    ),
    columns=['juv_fel_count', 'juv_misd_count', 'juv_other_count', 'priors_count', 'days_b_screening_arrest'] \
    + one_hot_encoder.get_feature_names() \
    + count_vectorizer.get_feature_names(),
    index=data.index
)
X['jailed_days'] = (data['c_jail_out'] - data['c_jail_in']).apply(lambda x: abs(x.days))
X['waiting_jail_days'] = (data['c_jail_in'] - data['c_offense_date']).apply(lambda x: abs(x.days))
X['waiting_arrest_days'] = (data['c_arrest_date'] - data['c_offense_date']).apply(lambda x: abs(x.days))
X.fillna(0, inplace=True)

columns = list(X.columns)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.33,
    random_state=42,
    stratify=stratification
)  # we stratify by black and the target

我们进行一些数据工程,导出变量来记录某人在监狱中度过了多少天,等待了多久的审判,或者等待了多久的逮捕。

我们将使用类似于我们在《Python 中的人工智能入门》第一章中遇到的方法来构建一个 jax 神经网络模型。这次,我们将进行一个完整的实现:

import jax.numpy as jnp
from jax import grad, jit, vmap, ops, lax
import numpy.random as npr
import numpy as onp
import random
from tqdm import trange
from sklearn.base import ClassifierMixin
from sklearn.preprocessing import StandardScaler

class JAXLearner(ClassifierMixin):
  def __init__(self, layer_sizes=[10, 5, 1], epochs=20, batch_size=500, lr=1e-2):
    self.params = self.construct_network(layer_sizes)
    self.perex_grads = jit(grad(self.error))
    self.epochs = epochs
    self.batch_size = batch_size
    self.lr = lr

  @staticmethod
  def construct_network(layer_sizes=[10, 5, 1]):
    '''Please make sure your final layer corresponds to targets in dimensions.
    '''
    def init_layer(n_in, n_out):
      W = npr.randn(n_in, n_out)
      b = npr.randn(n_out,)
      return W, b

    return list(map(init_layer, layer_sizes[:-1], layer_sizes[1:]))

  @staticmethod
  def sigmoid(X):  # or tanh
    return 1/(1+jnp.exp(-X))

  def _predict(self, inputs):
    for W, b in self.params:
      outputs = jnp.dot(inputs, W) + b
      inputs = self.sigmoid(outputs)
    return outputs

  def predict(self, inputs):
    inputs = self.standard_scaler.transform(inputs)
    return onp.asarray(self._predict(inputs))

  @staticmethod
  def mse(preds, targets, other=None):
    return jnp.sqrt(jnp.sum((preds - targets)**2))

  @staticmethod
  def penalized_mse(preds, targets, sensitive):
    err = jnp.sum((preds - targets)**2)
    err_s = jnp.sum((preds * sensitive - targets * sensitive)**2)
    penalty = jnp.clip(err_s / err, 1.0, 2.0)
    return err * penalty

  def error(self, params, inputs, targets, sensitive):
      preds = self._predict(inputs)
      return self.penalized_mse(preds, targets, sensitive)

  def fit(self, X, y, sensitive):
    self.standard_scaler = StandardScaler()
    X = self.standard_scaler.fit_transform(X)
    N = X.shape[0]
    indexes = list(range(N))
    steps_per_epoch = N // self.batch_size

    for epoch in trange(self.epochs, desc='training'):
        random.shuffle(indexes)
        index_offset = 0
        for step in trange(steps_per_epoch, desc='iteration'):
            grads = self.perex_grads(
                self.params, 
                X[indexes[index_offset:index_offset+self.batch_size], :], 
                y[indexes[index_offset:index_offset+self.batch_size]],
                sensitive[indexes[index_offset:index_offset+self.batch_size]]
            )
            # print(grads)
            self.params = [(W - self.lr * dW, b - self.lr * db)
                      for (W, b), (dW, db) in zip(self.params, grads)]
            index_offset += self.batch_size

这是一个 JAX 神经网络的 scikit-learn 包装器。为了与 scikit-learn 兼容,我们从ClassifierMixin继承并实现fit()predict()。这里最重要的部分是惩罚 MSE 方法,除了模型预测和目标之外,还考虑了一个敏感变量。

让我们训练它并检查性能。请注意,我们输入Xy敏感训练,它是用于训练数据集的非洲裔美国人的指示变量:

sensitive_train = X_train.join(
    data, rsuffix='_right'
)['race_black']
jax_learner = JAXLearner([X.values.shape[1], 100, 1])
jax_learner.fit(
    X_train.values,
    y_train.values,
    sensitive_train.values
)

我们将统计数据可视化如下:

X_predicted = pd.DataFrame(
    data=jax_learner.predict(
        X_test.values
    ) * 10,
    columns=['score'], 
    index=X_test.index
).join(
    data[['sex', 'race', 'is_recid']], 
    rsuffix='_right'
)
calculate_impacts(X_predicted, score_col='score')

这是我们得到的表格:

我们可以看到非洲裔美国人的不成比例的误报率非常接近(甚至低于)1.0,这正是我们想要的。测试集很小,没有足够的样本来计算亚裔和美洲原住民的有意义统计数据。然而,如果我们希望确保这两个群体的误报率相等,我们可以扩展我们的方法来涵盖这两个群体。

它的工作原理...

这种方法能起作用的关键是自定义的目标函数或损失函数。在 scikit-learn 中,这远非易事,尽管我们将在接下来的部分展示一个实现。

通常,有不同的可能性来实现自己的成本或损失函数。

  • LightGBM、Catboost 和 XGBoost 都提供了许多损失函数的接口和定义自定义损失函数的能力。

  • PyTorch 和 Keras(TensorFlow)提供了一个接口。

  • 你可以从头开始实现你的模型(这就是我们在主食谱中所做的)。

对于 scikit-learn,通常没有公共 API 来定义自己的损失函数。对于许多算法,只有一个选择,有时候还有几个备选项。在使用树的分裂标准时,损失函数必须是高效的,只有 Cython 实现能保证这一点。这仅在非公共 API 中可用,这意味着使用起来更加困难。

最后,当没有(直接)方法来实现自定义损失时,可以将算法包装在如遗传算法等通用优化方案中。

在神经网络中,只要提供一个可微的损失函数,你可以插入任何你想要的东西。

基本上,我们能够将不利影响编码为具有均方误差(MSE)函数的惩罚项。这基于我们之前提到的 MSE,但具有不利影响的惩罚项。让我们再看一下损失函数:

  @staticmethod
  def penalized_mse(preds, targets, sensitive):
    err = jnp.sum((preds - targets)**2)
    err_s = jnp.sum((preds * sensitive - targets * sensitive)**2)
    penalty = jnp.clip(err_s / err, 1.0, 2.0)
    return err * penalty

首先要注意的是,我们传递了三个变量而不是两个变量。sensitive是与不利影响相关的变量,指示我们是否有来自受保护群体的人。

计算方法如下:

  1. 我们从模型预测和目标计算总体均方误差 err。

  2. 我们计算受保护群体的均方误差,err_s

  3. 我们取受保护群体的均方误差与总体均方误差(AIR)的比值,并将其限制在 1.0 到 2.0 之间。我们不希望值低于 1,因为我们只关心 AIR 是否对受保护群体产生负面影响。

  4. 然后我们将 AIR 乘以总体 MSE。

对于 2,MSE 可以简单地通过将预测和目标分别乘以sensitive来计算。这将取消所有sensitive等于 0 的点。

对于 4,看似可以取消总体误差,但实际上我们发现它似乎确实有效。我们可能也可以添加这两项,以便给两种错误赋予类似的重要性。

我们使用 Jax 中的 autograd 功能来进行微分。

还有更多内容……

在接下来的内容中,我们将使用非公开的 scikit-learn API 来为决策树实现自定义分裂标准。我们将使用这个来训练一个带有 COMPAS 数据集的随机森林模型:

这扩展了 Evgeni Dubov 的 Hellinger 准则的实现(github.com/EvgeniDubov/hellinger-distance-criterion)。

%%cython

from sklearn.tree._criterion cimport ClassificationCriterion
from sklearn.tree._criterion cimport SIZE_t

import numpy as np
cdef double INFINITY = np.inf

from libc.math cimport sqrt, pow
from libc.math cimport abs

cdef class PenalizedHellingerDistanceCriterion(ClassificationCriterion):    

    cdef double proxy_impurity_improvement(self) nogil:
        cdef double impurity_left
        cdef double impurity_right

        self.children_impurity(&impurity_left, &impurity_right)

        return impurity_right + impurity_left

    cdef double impurity_improvement(self, double impurity) nogil:
        cdef double impurity_left
        cdef double impurity_right

        self.children_impurity(&impurity_left, &impurity_right)
        return impurity_right + impurity_left

    cdef double node_impurity(self) nogil:
        cdef SIZE_t* n_classes = self.n_classes
        cdef double* sum_total = self.sum_total
        cdef double hellinger = 0.0
        cdef double sq_count
        cdef double count_k
        cdef SIZE_t k
        cdef SIZE_t c

        for k in range(self.n_outputs):
            for c in range(n_classes[k]):
                hellinger += 1.0

        return hellinger / self.n_outputs

    cdef void children_impurity(self, double* impurity_left,
                                double* impurity_right) nogil:
        cdef SIZE_t* n_classes = self.n_classes
        cdef double* sum_left = self.sum_left
        cdef double* sum_right = self.sum_right
        cdef double hellinger_left = 0.0
        cdef double hellinger_right = 0.0
        cdef double count_k1 = 0.0
        cdef double count_k2 = 0.0

        cdef SIZE_t k
        cdef SIZE_t c

        # stop splitting in case reached pure node with 0 samples of second class
        if sum_left[1] + sum_right[1] == 0:
            impurity_left[0] = -INFINITY
            impurity_right[0] = -INFINITY
            return

        for k in range(self.n_outputs):
            if(sum_left[0] + sum_right[0] > 0):
                count_k1 = sqrt(sum_left[0] / (sum_left[0] + sum_right[0]))
            if(sum_left[1] + sum_right[1] > 0):
                count_k2 = sqrt(sum_left[1] / (sum_left[1] + sum_right[1]))

            hellinger_left += pow((count_k1  - count_k2), 2)

            if(sum_left[0] + sum_right[0] > 0):    
                count_k1 = sqrt(sum_right[0] / (sum_left[0] + sum_right[0]))
            if(sum_left[1] + sum_right[1] > 0):
                count_k2 = sqrt(sum_right[1] / (sum_left[1] + sum_right[1]))

            if k==0:
              hellinger_right += pow((count_k1  - count_k2), 2)
            else:
              hellinger_right -= pow((count_k1  - count_k2), 2)

        impurity_left[0]  = hellinger_left  / self.n_outputs
        impurity_right[0] = hellinger_right / self.n_outputs

让我们用这个来训练并测试它:

ensemble = [
    DecisionTreeClassifier(
      criterion=PenalizedHellingerDistanceCriterion(
        2, np.array([2, 2], dtype='int64')
      ),
      max_depth=100
    ) for i in range(100)
]
for model in ensemble:
    model.fit(
        X_train,
        X_train.join(
            data,
            rsuffix='_right'
        )[['is_recid', 'race_black']]
    )
Y_pred = np.array(
    [model.predict(X_test) for model in
     ensemble]
)
predictions2 = Y_pred.mean(axis=0)

这给了我们一个 AUC 值为 0.62:

我们可以看到,尽管我们已经取得了很大进展,但我们并没有完全消除所有偏见。30%(非洲裔美国人的 DFP)仍然被认为是不可接受的。我们可以尝试不同的改进或采样策略来改善结果。不幸的是,我们不能在实践中使用这个模型。

例如,解决此问题的一种方法是在随机森林中进行模型选择。由于每棵树都有其独特的分类方式,我们可以计算每棵单独树或树组合的不利影响统计数据。我们可以删除树,直到剩下一组满足我们不利影响条件的树。这超出了本章的范围。

另请参阅

您可以在不同的地方进一步了解算法公平性。有大量关于公平性的文献可供参考:

有不同的 Python 库可用于解决偏差(或反算法公平性)问题:

最后,Scikit-Lego 中包含公平性的功能:scikit-lego.readthedocs.io/en/latest/fairness.html

虽然你可以通过进行 Google 数据集搜索(toolbox.google.com/datasetsearch)找到许多有关再犯率的数据集,但在其他应用程序和相关数据集中,公平性也很重要,例如信用评分、人脸识别、招聘或预测式执法等等。

有不同的了解自定义损失的方法。文章 Custom loss versus custom scoringkiwidamien.github.io/custom-loss-vs-custom-scoring.html)提供了一个很好的概述。要深入了解梯度提升中的自定义损失函数的实施,可以从 towardsdatascience(towardsdatascience.com/custom-loss-functions-for-gradient-boosting-f79c1b40466d)开始。

预测 CO[2] 的时间序列

在这个示例中,我们将使用 Facebook 的 Prophet 库对时间序列数据进行预测,测试一些着名的模型(ARIMA,SARIMA)和信号分解,以检查它们在预测 CO[2] 值的时间序列的性能。

准备工作

为了准备这个示例,我们将安装库并下载数据集。

我们将使用 statsmodels 库和 Prophet:

pip install statsmodels fbprophet

我们将分析本教程中的 CO[2]浓度数据。您可以在附带本教程的 GitHub 笔记本中看到数据加载,或者在 scikit-learn 关于夏威夷毛纳罗亚气象观测站二氧化碳数据的高斯过程回归GPR)示例中查看:scikit-learn.org/stable/auto_examples/gaussian_process/plot_gpr_co2.html#sphx-glr-auto-examples-gaussian-process-plot-gpr-co2-py

该数据集是关于大气中二氧化碳早期记录之一。正如稍后将观察到的那样,这些数据呈现出正弦模式,冬季二氧化碳浓度上升,夏季由于植物和植被减少而下降:

X,y = load_mauna_loa_atmospheric_co2()

数据集包含从 1958 年到 2001 年在夏威夷毛纳罗亚观测站测量的平均 CO[2]浓度。我们将根据这些数据对 CO[2]浓度进行建模。

怎么做...

现在我们将开始预测二氧化碳时间序列数据。首先我们会探索数据集,然后应用 ARIMA 和 SARIMA 技术。

  1. 让我们来看看时间序列:
df_CO2 = pd.DataFrame(data = X, columns = ['Year'])
df_CO2['CO2 in ppm'] = y
lm = sns.lmplot(x='Year', y='CO2 in ppm', data=df_CO2, height=4, aspect=4)
fig = lm.fig 
fig.suptitle('CO2 conc. mauna_loa 1958-2001', fontsize=12)

这里是图表:

这里的脚本显示了 CO[2]数据的时间序列季节性分解,显示出 CO[2]浓度的明显季节变化,可以追溯到生物学:

import statsmodels.api as stmd
d = stm.datasets.co2.load_pandas()
co2 = d.data
co2.head()
y = co2['co2']
y = y.fillna(
    y.interpolate()
)  # Fill missing values by interpolation

现在我们已经对分解数据进行了预处理,让我们继续进行:

from pylab import rcParams
rcParams['figure.figsize'] = 11, 9
result = stm.tsa.seasonal_decompose(y, model='additive')
pd.plotting.register_matplotlib_converters()
result.plot()
plt.show()

在这里,我们看到了分解结果:观察到的时间序列、其趋势、季节成分以及未解释的残差部分:

现在,让我们分析时间序列。

使用 ARIMA 和 SARIMA 分析时间序列

我们将对数据集拟合 ARIMA 和 SARIMA 模型。

我们将定义我们的两个模型,并将其应用于测试数据集中的每个点。在这里,我们迭代地在所有点上拟合模型并预测下一个点,作为一步预测。

  1. 首先,我们分割数据:
# taking a 90/10 split between training and testing:
future = int(len(y) * 0.9)
print('number of train samples: %d test samples %d' (future, len(y)-future)
)
train, test = y[:future], y[future:]

这使我们得到了 468 个样本用于训练和 53 个用于测试。

  1. 接下来,我们定义模型:
from statsmodels.tsa.arima_model import ARIMA
from statsmodels.tsa.statespace.sarimax import SARIMAX

def get_arima_model(history, order=(5, 1, 0)):
    return ARIMA(history, order=order)

def get_sarima_model(
    history,
    order=(5, 1, 1),
    seasonal_order=(0, 1, 1, 4)
    ):
    return SARIMAX(
        history,
        order=order,
        enforce_stationarity=True,
        enforce_invertibility=False,
        seasonal_order=seasonal_order
    )

  1. 然后我们训练模型:
from sklearn.metrics import mean_squared_error

def apply_model(train, test, model_fun=get_arima_model):
  '''we just roll with the model and apply it to successive
  time steps
  '''
  history = list(train)
  predictions = []
  for t in test:
    model = model_fun(history).fit(disp=0)
    output = model.forecast()
    predictions.append(output[0])
    history.append(t)
  error = mean_squared_error(test, predictions)
  print('Test MSE: %.3f' % error)
  #print(model.summary().tables[1])
  return predictions, error

predictions_arima, error_arima = apply_model(train, test)
predictions_sarima, error_sarima = apply_model(
    train, test, get_sarima_model
)

在测试中,我们得到 ARIMA 和 SARIMA 模型的均方误差分别为 0.554 和 0.405。让我们看看模型在图形上的拟合情况:

我们可以使用阿卡信息准则AIC)进行参数探索,它反映了模型质量相对于模型中参数数量的情况。statsmodels 中拟合函数返回的模型对象包括 AIC 值,因此我们可以在一系列参数上进行网格搜索,选择最小化 AIC 的模型。

它是如何工作的...

时间序列数据是一组观察值x(t),其中每个数据点都在时间t记录。在大多数情况下,时间是离散变量,即。我们正在进行预测,这是根据时间序列中的先前观察来预测未来值的任务。

为了解释我们使用的模型 ARIMA 和 SARIMA,我们将逐步进行,并依次解释每个模型:

  • 自回归 (AR)

  • 移动平均 (MA)

  • 自回归移动平均模型 (ARMA)

  • 自回归积分移动平均 (ARIMA)和

  • 季节性自回归积分移动平均模型 (SARIMA)

ARIMA 和 SARIMA 基于 ARMA 模型,这是一个自回归移动平均模型。让我们简要地了解一些基础知识。

ARMA 是一个线性模型,分为两部分。首先是自回归线性模型:

在这里,是参数,是常数,是白噪声,而是模型的阶数(或线性模型的窗口大小)。ARMA 的第二部分是移动平均,这同样是一个线性回归,但是是关于不可观测的滞后误差项的定义如下:

在这里,是移动平均的阶数,是参数,而是时间序列的期望或均值。ARMA(p, q)模型是这两个模型 AR(p)和 MA(q)的组合:

拟合过程有些复杂,特别是由于 MA 部分。如果您感兴趣,可以在 Wikipedia 上阅读有关 Box-Jenkins 方法的介绍:en.wikipedia.org/wiki/Box%E2%80%93Jenkins_method

然而需要注意的几个限制。时间序列必须是以下的:

  • 平稳性:基本上,观察值的均值和协方差必须随时间保持不变。

  • 非周期性:虽然可以使用较大的 p 和 q 值来建模季节性,但这不是模型的一部分。

  • 线性:每个值可以建模为前值和误差项的线性组合。

不同的 ARMA 扩展用于解决前两个限制,这就是 ARIMA 和 SARIMA 发挥作用的地方。

ARIMA(p, d, q)代表自回归积分移动平均。它带有三个参数:

  • p:自回归项数(自回归)

  • d:需要使时间序列平稳化的非季节性差分数(积分)

  • q:滞后的预测误差数(移动平均)

积分是指差异化。为了稳定均值,我们可以取连续观测之间的差异。这也可以去除趋势或消除季节性。可以写成如下形式:

这可以重复多次,并且这就是参数 d 描述的 ARIMA。请注意,ARIMA 可以处理漂移和非平稳时间序列。但是,它仍然无法处理季节性。

SARIMA 代表季节性 ARIMA,是 ARIMA 的扩展,因为它还考虑了数据的季节性。

SARIMA(p, d, q)(P, D, Q)m包含 ARIMA 的非季节性参数和额外的季节性参数。大写字母 P、D 和 Q 注释了季节性移动平均和自回归分量,其中m是每个季节中的周期数。通常这是一年中的周期数;例如m=4表示季度季节效应,意味着D表示观测XtXt-m之间的季节性差异,PQ表示具有 m 个滞后的线性模型。

在 Python 中,statsmodels 库提供了基于数据季节性的信号分解方法。

还有更多...

Prophet 是 Facebook 提供的用于预测时间序列数据的库。它采用加法模型并拟合非线性模型。当数据具有强烈的季节性效应并且有足够的历史趋势时,该库表现最佳。

让我们看看如何使用它:

from fbprophet import Prophet

train_df = df_CO2_fb['1958':'1997']
test_df = df_CO2_fb['1998':'2001']
train_df = train_df.reset_index()
test_df = test_df.reset_index()Co2_model= Prophet(interval_width=0.95)

Co2_model.fit(train_df)
train_forecast = Co2_model.predict(train_df)
test_forecast = Co2_model.predict(test_df)
fut = Co2_model.make_future_DataFrame(periods=12, freq='M')
forecast_df = Co2_model.predict(fut)
Co2_model.plot(forecast_df)

这里是我们的模型预测:

我们得到与 ARIMA/SARIMA 模型相似的分解,即趋势和季节性组成部分:

每年的变化明显显示了二氧化碳浓度随季节变化的升降。趋势随时间显著上升,这可能在考虑全球变暖时令人担忧。

另请参阅

我们在这个示例中使用了以下库:

还有许多与时间序列相关的有趣库,包括以下内容:

第三章:模式、异常值和推荐

为了从数据中获取知识,理解数据集背后的结构非常重要。我们对数据集的表示方式可以使其更直观地在某种方式下工作,并因此更容易从中获得洞察力。工具的法则指出,当手持锤子时,一切似乎都像是钉子(基于安德鲁·马斯洛的《科学心理学》,1966 年),这是关于适应工具的趋势。然而,并没有银弹,因为所有方法都有它们在特定问题下的缺点。因此,了解可用工具库中的基本方法对于识别应该使用锤子而不是螺丝刀的情况至关重要。

在本章中,我们将探讨不同的数据表示方法,无论是为了可视化客户群体以及找到异常模式,还是投射数据以强调差异,根据客户自己以及其他客户的先前选择向客户推荐产品,并通过相似性识别欺诈者社区。

具体而言,我们将提供以下配方:

  • 聚类市场段

  • 发现异常

  • 表示相似性搜索

  • 推荐产品

  • 发现欺诈者社区

聚类市场段

在这个配方中,我们将应用聚类方法以便为营销目的找到客户群体。我们将查看德国信用风险数据集,并尝试识别不同的客户段。理想情况下,我们希望找到既有利可图又不同的群体,以便进行定向广告。

准备工作

对于这个配方,我们将使用信用风险数据集,通常被完整称为德国信用风险数据集。每行描述了一位借款人的信息,给出了关于这个人的几个属性,并告诉我们这个人是否还了贷款(即信用是否良好或不良风险)。

我们需要按如下步骤下载和加载德国信用数据:

import pandas as pd
!wget http://archive.ics.uci.edu/ml/machine-learning-databases/statlog/german/german.data
names = ['existingchecking', 'duration', 'credithistory',
         'purpose', 'creditamount', 'savings', 
         'employmentsince', 'installmentrate', 
         'statussex', 'otherdebtors', 'residencesince', 
         'property', 'age', 'otherinstallmentplans', 
         'housing', 'existingcredits', 'job', 
         'peopleliable', 'telephone', 'foreignworker', 
         'classification']

customers = pd.read_csv('german.data', names=names, delimiter=' ')

对于可视化,我们将使用dython库。dython库直接处理分类和数值变量,并针对数值-分类或分类-分类比较进行调整。请参阅详细文档,网址为 shakedzy.xyz/dython/。让我们按以下步骤安装该库:

!pip install dython

现在我们可以使用dython库玩弄德国信用数据集,将其可视化,并看看如何将内部的人群聚合到不同的群组中。

如何实现...

我们首先将可视化数据集,进行一些预处理,并应用聚类算法。我们将试图从这些聚类中获得见解,并在新的见解基础上重新进行聚类。

我们将从可视化特征开始:

  1. 可视化相关性:在这个示例中,我们将使用dython库。我们可以使用 dython 的associations函数计算分类变量、数值变量(Pearson 相关系数)和混合分类-数值变量的相关性函数:
from dython.nominal import associations

associations(customers, clustering=True, figsize=(16, 16), cmap='YlOrBr');

此调用不仅计算相关性,还通过将相关的变量聚类在一起来清理相关矩阵。数据可视化如下截图所示:

我们无法真正看到清晰的聚类界限;然而,如果你沿对角线观察,似乎有几个群体。

此外,一些变量如电话和职位与其他变量略有不同。在 GitHub 的笔记本中,我们尝试了降维方法,看看是否能改善我们的聚类。然而,降维并没有效果那么好,而直接聚类效果更佳:github.com/PacktPublishing/Artificial-Intelligence-with-Python-Cookbook/blob/master/chapter03/Clustering%20market%20segments.ipynb

作为聚类的第一步,我们将一些变量转换为虚拟变量;这意味着我们将对分类变量进行独热编码。

  1. 预处理变量:我们对分类变量进行了独热编码(也称为哑变换),如下所示:
catvars = ['existingchecking', 'credithistory', 'purpose', 'savings', 'employmentsince',
 'statussex', 'otherdebtors', 'property', 'otherinstallmentplans', 'housing', 'job', 
 'telephone', 'foreignworker']
numvars = ['creditamount', 'duration', 'installmentrate', 'residencesince', 'age', 
 'existingcredits', 'peopleliable', 'classification']

dummyvars = pd.get_dummies(customers[catvars])
transactions = pd.concat([customers[numvars], dummyvars], axis=1)

不幸的是,当我们将数据集可视化以突出客户差异时,结果并不理想。你可以在线查看笔记本以了解一些尝试。

  1. 聚类的第一次尝试:聚类的典型方法是kmeans。让我们来试一试:
from sklearn.cluster import KMeans
from matplotlib import pyplot as plt

sse = {}
for k in range(1, 15):
    kmeans = KMeans(n_clusters=k).fit(transactions)
    sse[k] = kmeans.inertia_ 
plt.figure()
plt.plot(list(sse.keys()), list(sse.values()))
plt.xlabel("Number of cluster")
plt.ylabel("SSE")
plt.show()

惯性是所有数据点到最近聚类中心的距离总和。在k-means 聚类算法中选择最佳聚类数(超参数k)的一种视觉标准称为肘部准则

让我们可视化不同聚类数下的惯性:

“肘部准则”的基本思想是选择误差或惯性变平的聚类数。根据肘部准则,我们可能会选择4个聚类。让我们重新获取这些聚类:

kmeans = KMeans(n_clusters=4).fit(transactions)
y = kmeans.labels_
  1. 总结聚类:现在我们可以总结这些聚类:
clusters = transactions.join(
    pd.DataFrame(data=y, columns=['cluster'])
).groupby(by='cluster').agg(
    age_mean=pd.NamedAgg(column='age', aggfunc='mean'),
    age_std=pd.NamedAgg(column='age', aggfunc='std'),
    creditamount=pd.NamedAgg(column='creditamount', aggfunc='mean'),
    duration=pd.NamedAgg(column='duration', aggfunc='mean'),
    count=pd.NamedAgg(column='age', aggfunc='count'),
    class_mean=pd.NamedAgg(column='classification', aggfunc='mean'),
    class_std=pd.NamedAgg(column='classification', aggfunc='std'),
).sort_values(by='class_mean')
clusters

这是聚类的汇总表。我们包括了营销特征,如年龄,以及其他让我们了解客户带来的收益的因素。我们展示了一些标准偏差,以了解这些群体的一致性程度:

我们在这个小节中看到,差异主要是由信用金额的差异造成的。这使我们回到了最初的起点,即我们从聚类中获得的主要是我们输入的内容。解决这个问题没有简单的方法,但我们可以选择在我们的聚类中要关注的变量。

  1. 新的聚类尝试:我们可以重新审视我们的目标,简化我们的目标,并从实际上我们想要找到的内容开始:满足两个特征的客户群体,即:

    • 这些聚类应该通过谁为我们赚钱来区分客户:这使我们关注如信用金额、贷款期限以及是否已经还清等变量。

    • 这些聚类应该突出与营销相关的不同特征,如年龄、性别或其他一些特征。

有了这个想法,我们将进行新的尝试:

from scipy.spatial.distance import pdist, squareform
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import AgglomerativeClustering

distances = squareform(pdist(
    StandardScaler().fit_transform(
        transactions[['classification', 'creditamount', 'duration']]
   )
))
clustering = AgglomerativeClustering(
    n_clusters=5, affinity='precomputed', linkage='average'
).fit(distances)
y = clustering.labels_

现在我们可以再次生成概览表,以查看群体统计信息:

clusters = transactions.join(
    pd.DataFrame(data=y, columns=['cluster'])
).groupby(by='cluster').agg(
    age_mean=pd.NamedAgg(column='age', aggfunc='mean'),
    age_std=pd.NamedAgg(column='age', aggfunc='std'),
    creditamount=pd.NamedAgg(column='creditamount', aggfunc='mean'),
    duration=pd.NamedAgg(column='duration', aggfunc='mean'),
    count=pd.NamedAgg(column='age', aggfunc='count'),
    class_mean=pd.NamedAgg(column='classification', aggfunc='mean'),
    class_std=pd.NamedAgg(column='classification', aggfunc='std'),
).sort_values(by='class_mean')
clusters

接下来是新的摘要:

我会认为这比之前的聚类更有用,因为它清楚地显示了哪些客户能给我们带来利润,并突出了其他在营销中相关的差异。

它的工作原理...

在商业智能中,聚类是一种非常常见的可视化技术。在营销中,你会针对不同的人群进行定位,比如青少年与退休人员,某些群体比其他群体更有价值。通常作为第一步,会通过降维方法或特征选择减少维度,然后通过应用聚类算法将群体分离。例如,你可以首先应用主成分分析PCA)来降低维度(特征数量),然后用k-均值找到数据点的群体。

由于视觉化很难客观评判,在前一节中,我们所做的是退后一步,看看实际目的,即我们想要达成的业务目标。我们采取以下步骤来实现这个目标:

  • 我们专注于对我们的目标重要的变量。

  • 我们创建了一个函数,帮助我们确定聚类的质量。

基于这个前提,我们尝试了不同的方法,并根据我们的业务目标对它们进行了评估。

如果你在查看配方时留意到,你可能已经注意到我们不对输出(z-分数)进行标准化。在使用 z-分数进行标准化时,原始分数 x 通过减去均值并除以标准偏差被转换为标准分数,因此每个标准化变量的平均值为 0,标准偏差为 1:

我们不应用标准化,因为已经进行虚拟转换的变量在因素数量上具有更高的重要性。简单来说,z 分数意味着每个变量具有相同的重要性。独热编码为每个可以采用的值提供了一个单独的变量。如果我们在进行虚拟转换后计算和使用 z 分数,一个被转换为许多新(虚拟)变量的变量,因为它有许多值,会比另一个具有较少值和因此较少虚拟列的变量更不重要。我们希望避免这种情况,所以我们不应用 z 分数。

然而,需要记住的重要一点是,我们必须专注于我们能够理解和描述的差异。否则,我们可能会得到用途有限的聚类。

在下一节中,我们将更详细地讨论k-均值算法。

还有更多……

PCA 提出于 1901 年(由卡尔·皮尔逊在《关于空间中一组点的最佳适合线和平面》中提出),k-均值提出于 1967 年(由詹姆斯·麦克昆在《关于多元观测分类和分析的一些方法》中提出)。虽然这两种方法在数据和计算资源稀缺时有其用武之地,但今天存在许多替代方法可以处理数据点和特征之间更复杂的关系。作为本书的作者,我们个人经常感到沮丧,看到依赖正态性或变量之间一种非常有限关系的方法,例如经典的 PCA 或k-均值方法,尤其是在存在更多更好的方法时。

PCA 和k-均值都存在严重缺陷,影响它们在实践中的有效性。由于 PCA 基于相关矩阵运作,它只能找到数据点之间的线性相关性。这意味着如果变量相关但不是线性相关(如在散点图中所见),PCA 将失败。此外,PCA 基于均值和方差,这些是高斯分布的参数。作为基于质心的聚类算法,K-均值只能在欧几里得空间中找到球形群体,因此它无法揭示任何更复杂的结构。有关更多信息,请参阅developers.google.com/machine-learning/clustering/algorithm/advantages-disadvantages

还有其他强健的非线性方法可用,例如亲和传播、模糊c-均值、凝聚聚类等。然而,重要的是要记住,尽管这些方法将数据点分组,以下陈述也是正确的:

  • 这是根据一种启发式方法完成的。

  • 它基于数据集中显而易见的差异以及应用的距离度量。

  • 聚类的目的是为了使人类观察者能够可视化和简化输出。

让我们更详细地看一下k-means 算法。实际上,这很简单,并且可以从头开始用numpyjax编写。该实现基于 NapkinML 中的一个实现(github.com/eriklindernoren/NapkinML):

import jax.numpy as jnp
import numpy as np
from jax import jit, vmap
from sklearn.base import ClassifierMixin
import jax
import random
from scipy.stats import hmean

class KMeans(ClassifierMixin):
    def __init__(self, k, n_iter=100):
      self.k = k
      self.n_iter = n_iter
      self.euclidean = jit(vmap(
          lambda x, y: jnp.linalg.norm(
              x - y, ord=2, axis=-1, keepdims=False
          ), in_axes=(0, None), out_axes=0
      ))

    def adjust_centers(self, X):
        jnp.row_stack([X[self.clusters == c].mean(axis=0)
          for c in self.clusters
        ])

    def initialize_centers(self):
        '''roughly the kmeans++ initialization
        '''
        key = jax.random.PRNGKey(0)
        # jax doesn't have uniform_multivariate
        self.centers = jax.random.multivariate_normal(
            key, jnp.mean(X, axis=0), jnp.cov(X, rowvar=False), shape=(1,)
        )
        for c in range(1, self.k):
            weights = self.euclidean(X, self.centers)
            if c>1:
              weights = hmean(weights ,axis=-1)
              print(weights.shape)

            new_center = jnp.array(
                random.choices(X, weights=weights, k=1)[0],
                ndmin=2
            )
            self.centers = jnp.row_stack(
                (self.centers, new_center)
            )
            print(self.centers.shape)

    def fit(self, X, y=None):
        self.initialize_centers()
        for iter in range(self.n_iter):
            dists = self.euclidean(X, self.centers)
            self.clusters = jnp.argmin(dists, axis=-1)
            self.adjust_centers(X)
        return self.clusters

主要逻辑 - 如预期的那样 - 在fit()方法中。它分为三步,如下迭代:

  1. 计算每个点与簇中心之间的距离。

  2. 每个点被分配到其最近簇中心的簇。

  3. 簇中心被重新计算为算术平均值。

令人惊讶的是,这样一个简单的想法竟然可以得出对人类观察者看起来有意义的结果。这里有一个使用它的例子。让我们试试用我们已经在第一章中Python 中的人工智能入门中知道的鸢尾花数据集来尝试它:

import matplotlib.pyplot as plt
from sklearn.datasets import load_iris
X, y = load_iris(return_X_y=True)

kmeans = KMeans(k=3)
kmeans.fit(X)

最终我们得到可以可视化或检查的簇,类似于之前。

另请参见

为了获得不同聚类方法的概述,请参考一篇调查或评论文章。Saxena 等人在他们的文章聚类技术与发展综述(2017)中涵盖了大部分重要的术语。

我们建议查看与聚类和降维相关的以下方法(我们链接到实现):

利用预先训练的随机森林来提供定制的内核的想法在The Random Forest Kernel and other kernels for big data from random partitions (2014)由 Alex Davies 和 Zoubin Ghahramani 讨论,可在arxiv.org/abs/1402.4293找到。

发现异常

异常是任何偏离预期或正常结果的情况。在工业过程监控(IPM)中,检测异常可以帮助实现更高水平的安全性、效率和质量。

在这个配方中,我们将探讨异常检测的方法。我们将通过使用 Python 异常检测(pyOD),这是一个实现许多最先进方法和可视化的异常检测工具箱,来演示时间序列中的异常检测示例。pyOD 的文档可以在pyod.readthedocs.io/en/latest/找到。

我们将应用基于相似性的自动编码器方法,并使用适用于查找数据流事件的在线学习方法。

准备工作

本文将专注于寻找异常值。我们将演示如何使用 pyOD 库包括自动编码器方法来做到这一点。我们还将概述不同方法的优缺点。

数据流是网站性能的 KPI 的时间序列。这个数据集提供在 DONUT 异常检测器库中,可以在github.com/haowen-xu/donut找到。

让我们按照以下步骤下载并加载它:

import pandas as pd

!wget https://raw.githubusercontent.com/haowen-xu/donut/master/sample_data/cpu4.csv
cpu_data = pd.read_csv('cpu4.csv')

我们将使用来自 pyOD 的方法,因此也要安装它:

!pip install pyOD

请注意,一些 pyOD 方法有依赖关系,如 TensorFlow 和 Keras,因此您可能需要确保这些也已安装。如果您收到No Module named Keras的消息,您可以单独安装 Keras 如下:

!pip install keras

请注意,通常最好使用与 TensorFlow 一起提供的 Keras 版本。

让我们看一下我们的数据集,然后应用不同的异常检测方法。

如何实现...

我们将在本节中涵盖不同的步骤和方法。它们如下:

  1. 可视化

  2. 基准测试

  3. 运行孤立森林

  4. 运行自动编码器

让我们从探索和可视化我们的数据集开始:

  1. 我们可以将我们的数据集随时间作为时间序列进行可视化:

让我们使用以下命令查看我们的数据集:

cpu_data.head()

它看起来是这样的:

这些关键性能指标(KPIs)的时间序列旨在监控 Web 服务的运营和维护。如果服务出现问题,它们会带有一个标签,表示异常,即异常值:

from datetime import datetime
import seaborn as sns

cpu_data['datetime'] = cpu_data.timestamp.astype(int).apply(
    datetime.fromtimestamp
)
# Use seaborn style defaults and set the default figure size
sns.set(rc={'figure.figsize':(11, 4)})

time_data = cpu_data.set_index('datetime')
time_data.loc[time_data['label'] == 1.0, 'value'].plot(linewidth=0.5, marker='o', linestyle='')
time_data.loc[time_data['label'] == 0.0, 'value'].plot(linewidth=0.5)

这是结果图,其中点代表异常值:

或者,我们可以看到异常值在关键绩效指标谱中的位置,以及它们与正常数据的区别有多明显,使用以下代码:

import numpy as np
from matplotlib import pyplot as plt

markers = ['r--', 'b-^']

def hist2d(X, by_col, n_bins=10, title=None):
  bins = np.linspace(X.min(), X.max(), n_bins)

  vals = np.unique(by_col)
  for marker, val in zip(markers, vals):
    n, edges = np.histogram(X[by_col==val], bins=bins)
    n = n / np.linalg.norm(n)
    bin_centers = 0.5 * (edges[1:] + edges[:-1])
    plt.plot(bin_centers, n, marker, alpha=0.8, label=val)

  plt.legend(loc='upper right')
  if title is not None:
    plt.title(title)
  plt.show()

hist2d(cpu_data.value, cpu_data.label, n_bins=50, title='Values by label')

使用上述代码,我们绘制两个直方图的线图对比。或者,我们可以使用hist()函数并设置透明度。

下图显示了异常值分布密度,时间序列值位于x轴上,两条线分别表示被识别为正常和异常的值,0 表示正常数据点,1 表示异常值:

我们将为所有后续方法使用相同的可视化效果,以便进行图形比较。

异常值(用虚线表示)与正常数据点(方块)几乎无法区分,因此我们不会期望完美的表现。

  1. 现在我们将实施基准测试。

在继续测试异常值检测方法之前,让我们制定一个比较它们的过程,这样我们就有了测试方法相对性能的基准。

我们像往常一样将数据分为训练集和测试集:

from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(
    cpu_data[['value']].values, cpu_data.label.values
)

现在让我们编写一个测试函数,可以用不同的异常值检测方法进行测试:

from pyod.utils.data import evaluate_print
from pyod.models.knn import KNN

def test_outlier_detector(X_train, y_train,
                          X_test, y_test, only_neg=True,
                          basemethod=KNN()):
  clf = basemethod
  if only_neg:
    clf.fit(X_train[y_train==0.0], np.zeros(shape=((y_train==0.0).sum(), 1)))
  else:
    clf.fit(X_train, y_train) # most algorithms ignore y

  y_train_pred = clf.predict(X_train) # labels_
  y_train_scores = clf.decision_scores_

  y_test_pred = clf.predict(X_test)
  y_test_scores = clf.decision_function(X_test)

  print("\nOn Test Data:")
  evaluate_print(type(clf).__name__, y_test, y_test_scores)
  hist2d(X_test, y_test_pred, title='Predicted values by label')

此函数在数据集上测试异常值检测方法。它训练一个模型,从模型中获取性能指标,并绘制可视化结果。

它接受这些参数:

  • X_train: 训练特征

  • y_train: 训练标签

  • X_test: 测试特征

  • y_test: 测试标签

  • only_neg: 是否仅使用正常点进行训练

  • basemethod: 要测试的模型

我们可以选择仅在正常点上进行训练(即不包括异常值的所有点),以便学习这些点的分布或一般特征,然后异常值检测方法可以决定新点是否符合这些特征。

现在这一步完成后,让我们测试两种异常值检测方法:孤立森林和自编码器。

孤立森林排名第一。

  1. 我们将讨论孤立森林。

我们运行基准测试方法,并使用孤立森林检测方法:

from pyod.models.iforest import IForest

test_outlier_detector(
    X_train, y_train, X_test, y_test, 
    only_neg=True, basemethod=IForest(contamination=0.01),
)
#On Test Data:
#IForest ROC:0.867, precision @ rank n:0.1

孤立森林预测的接收器操作特征曲线(ROC)性能相对于测试数据约为 0.86,因此表现相当不错。

然而,从下图可以看出,在关键绩效指标谱的较低范围内没有 1(预测的异常值)。该模型错过了较低范围的异常值:

它仅识别具有更高值(>=1.5)的点作为异常值。

  1. 接下来,让我们尝试运行一个自编码器:
from pyod.models.auto_encoder import AutoEncoder

test_outlier_detector(
    X_train, y_train, X_test, y_test, 
    only_neg=False, 
    basemethod=AutoEncoder(hidden_neurons=[1], epochs=10)
)

我们可以看到 Keras 网络结构以及测试函数的输出:

Layer (type)                 Output Shape              Param #   
=================================================================
dense_39 (Dense)             (None, 1)                 2         
_________________________________________________________________
dropout_30 (Dropout)         (None, 1)                 0         
_________________________________________________________________
dense_40 (Dense)             (None, 1)                 2         
_________________________________________________________________
dropout_31 (Dropout)         (None, 1)                 0         
_________________________________________________________________
dense_41 (Dense)             (None, 1)                 2         
_________________________________________________________________
dropout_32 (Dropout)         (None, 1)                 0         
_________________________________________________________________
dense_42 (Dense)             (None, 1)                 2         
=================================================================
Total params: 8
Trainable params: 8
Non-trainable params: 0

... 
On Test Data:
AutoEncoder ROC:0.8174, precision @ rank n:0.1

自编码器的性能与孤立森林非常相似;然而,自编码器在关键绩效指标谱的较低和较高范围都能找到异常值。

此外,我们在只提供正常数据或正常数据和异常值时并未获得明显差异。我们可以通过下图了解自动编码器的工作方式:

实际上这看起来并不太糟糕 —— 中间范围内的值被分类为正常,而在谱外的值则被分类为异常值。

请记住,这些方法是无监督的;当然,如果我们使用自己的数据集使用监督方法,这将需要我们额外的工作来注释异常,而无监督方法则无需如此。

它的工作原理...

异常值是偏离数据中其他观测值的极端值。在许多领域中,包括网络安全、金融、交通、社交媒体、机器学习、机器模型性能监控和监视中,异常值检测都是重要的。已提出了许多领域中的异常检测算法。最突出的算法包括k-最近邻kNN)、局部异常因子LOF)和隔离森林,以及最近的自动编码器、长短期记忆网络LSTM)和生成对抗网络GANs)。我们将在后续的实例中探索其中一些方法。在这个实例中,我们使用了 kNN、自动编码器和隔离森林算法。让我们简要地谈谈这三种方法。

k-最近邻

kNN 分类器是由 Thomas Cover 和 Peter Hart 提出的非参数分类器(见最近邻模式分类,1967)。其主要思想是一个新点很可能属于与其邻居相同的类。超参数 k 是要比较的邻居数。还有基于新点与其邻居相对距离的加权版本。

隔离森林

隔离森林的思想相对简单:创建随机决策树(这意味着每个叶子节点使用随机选择的特征和随机选择的分割值),直到只剩下一个点。穿过树获取到终端节点的路径长度指示出点是否是异常值。

您可以在原创文献中详细了解隔离森林,作者是 Liu 等人,隔离森林,ICDM 2008: 413–422: cs.nju.edu.cn/zhouzh/zhouzh.files/publication/icdm08b.pdf

自动编码器

自动编码器是一种神经网络架构,通过学习数据集的表示来实现。通常通过一个较小的隐藏层(瓶颈)来实现,从而可以恢复原始数据。这与许多其他降维方法类似。

自编码器由两部分组成:编码器和解码器。我们真正想学习的是编码器的转换,它给我们一个我们寻找的数据的代码或表示。

更正式地,我们可以将编码器定义为函数 ,将解码器定义为函数 。我们试图找到  和 ,以使重建误差最小化:

自动编码器在中间网络层中表示数据,它们在基于中间表示的重建越接近时,异常程度越低。

另请参阅

Python 中有许多异常检测的公开实现:

关于异常检测的材料的一个极好资源是 PyOD 作者的专用存储库,位于github.com/yzhao062/anomaly-detection-resources

用于相似性搜索

在这个案例中,我们希望找到一种方法来确定两个字符串是否相似,给定这两个字符串的表示。我们将尝试改进字符串的表示方式,以便在字符串之间进行更有意义的比较。但首先,我们将使用更传统的字符串比较算法来建立基准。

我们将进行以下操作:给定一组成对字符串匹配的数据集,我们将尝试不同的函数来测量字符串的相似性,然后是基于字符 n-gram 频率的字符袋表示,最后是共孪神经网络(也称为双胞胎神经网络)的维度减少字符串表示。我们将设置一个双网络方法,通过字符 n-gram 频率学习字符串的潜在相似空间。

暹罗神经网络,有时也被称为双胞胎神经网络,是以联合双胞胎的类比来命名的。这是一种训练投影或度量空间的方法。两个模型同时训练,比较的是两个模型的输出而不是模型本身的输出。

准备工作

如往常一样,我们需要下载或加载数据集并安装必要的依赖项。

我们将使用一组配对字符串的数据集,根据它们的相似性来确定它们是否匹配:

!wget https://raw.githubusercontent.com/ofrendo/WebDataIntegration/7db877abadd2be94d5373f5f47c8ccd1d179bea6/data/goldstandard/forbes_freebase_goldstandard_train.csv

我们可以按以下方式读取它:

import pandas as pd

data = pd.read_csv(
    'forbes_freebase_goldstandard_train.csv',
    names=['string1', 'string2', 'matched']
)

数据集包含一对相匹配或不匹配的字符串。它的开始如下:

同一 GitHub 仓库还提供了一个测试数据集:

!wget https://raw.githubusercontent.com/ofrendo/WebDataIntegration/7db877abadd2be94d5373f5f47c8ccd1d179bea6/data/goldstandard/forbes_freebase_goldstandard_test.csv

我们可以像以前一样将它读入 pandas DataFrame 中:

test = pd.read_csv(
    'forbes_freebase_goldstandard_test.csv',
    names=['string1', 'string2', 'matched']
)

最后,我们将在本示例中使用一些库,可以像这样安装:

!pip install python-Levenshtein annoy

Levenshtein 距离(有时也称为编辑距离)测量将一个字符串转换为另一个字符串所需的插入、删除和替换次数。它执行搜索以找出执行此转换的最短路径。这里使用的库是该算法的非常快速的实现。你可以在github.com/ztane/python-Levenshtein了解更多关于python-Levenshtein库的信息。

annoy库提供了高度优化的最近邻搜索实现。给定一组点和一个距离,我们可以使用树表示索引所有点,然后对于任何点,我们可以遍历树以找到类似的数据点。你可以在github.com/spotify/annoy了解更多关于 annoy 的信息。

让我们开始吧。

如何做...

如前所述,我们将首先使用标准字符串比较函数计算基线,然后使用字符包法,最后采用暹罗神经网络方法学习投影。你可以在书的 GitHub 仓库中找到相应的笔记本,网址为github.com/PacktPublishing/Artificial-Intelligence-with-Python-Cookbook/blob/master/chapter03/Representing%20for%20similarity%20search.ipynb

Baseline – 字符串比较函数

让我们先实现几个标准字符串比较函数。

我们首先需要确保清理我们的字符串:

def clean_string(string):
    return ''.join(map(lambda x: x.lower() if str.isalnum(x) else ' ', string)).strip()

我们将在接下来的代码中的每个字符串比较函数中使用这个清理函数。我们将使用这个函数在进行任何字符串距离计算之前去除特殊字符。

现在我们可以实现简单的字符串比较函数。首先做Levenshtein距离:

import Levenshtein

def levenstein_distance(s1_, s2_):
    s1, s2 = clean_string(s1_), clean_string(s2_)
    len_s1, len_s2 = len(s1), len(s2)
    return Levenshtein.distance(
        s1, s2
    ) / max([len_s1, len_s2])

现在让我们来计算Jaro-Winkler距离,它是最小单字符转置次数:

def jaro_winkler_distance(s1_, s2_):
    s1, s2 = clean_string(s1_), clean_string(s2_)
    return 1 - Levenshtein.jaro_winkler(s1, s2)

我们还将使用被比较对之间的最长公共子串。我们可以使用SequenceMatcher来完成这一点,它是 Python 标准库的一部分:

from difflib import SequenceMatcher

def common_substring_distance(s1_, s2_):
    s1, s2 = clean_string(s1_), clean_string(s2_)
    len_s1, len_s2 = len(s1), len(s2)
    match = SequenceMatcher(
        None, s1, s2
    ).find_longest_match(0, len_s1, 0, len_s2)
    len_s1, len_s2 = len(s1), len(s2)
    norm = max([len_s1, len_s2])
    return 1 - min([1, match.size / norm])

现在我们可以对所有字符串对进行遍历,并基于每种方法计算字符串距离。对于这三种算法,我们可以计算曲线下面积AUC)分数,以查看它们在分离匹配字符串和非匹配字符串方面的表现如何:

import numpy as np
from sklearn.metrics import roc_auc_score

dists = np.zeros(shape=(len(data), 3))
for algo_i, algo in enumerate(
    [levenstein_distance, jaro_winkler_distance, common_substring_distance]
):
    for i, string_pair in data.iterrows():
        dists[i, algo_i] = algo(string_pair['string1'], string_pair['string2'])

    print('AUC for {}: {}'.format(
        algo.__name__, 
        roc_auc_score(data['matched'].astype(float), 1 - dists[:, algo_i])
    ))
#AUC for levenstein_distance: 0.9508904955034385
#AUC for jaro_winkler_distance: 0.9470992770234525
#AUC for common_substring_distance: 0.9560042320578381

所有算法的 AUC 分数约为 95%,看起来很不错。这三种距离方法表现已经相当不错了。让我们尝试超过这个水平。

字符包方法

现在我们将实现一种基于字符包的字符串相似性方法。

字符包表示意味着我们将创建一个字符的直方图,或者换句话说,我们将计算每个单词中字符的出现次数:

from sklearn.feature_extraction.text import CountVectorizer

# We clean the strings as before and we take ngrams.
ngram_featurizer = CountVectorizer(
    min_df=1,
    analyzer='char',
    ngram_range=(1, 1), # this is the range of ngrams that are to be extracted!
    preprocessor=clean_string
).fit(
    np.concatenate(
        [data['string1'], data['string2']],
        axis=0
    )
)

我们已将ngrams的范围设置为1,这意味着我们只希望单个字符。然而,如果你希望包括字符之间更长范围的依赖关系而不仅仅是字符频率,这个参数可能会有趣。

让我们看看我们可以通过这个方法获得什么样的性能:

string1cv = ngram_featurizer.transform(data['string1'])
string2cv = ngram_featurizer.transform(data['string2'])

def norm(string1cv):
    return string1cv / string1cv.sum(axis=1)

similarities = 1 - np.sum(np.abs(norm(string1cv) - norm(string2cv)), axis=1) / 2
roc_auc_score(data['matched'].astype(float), similarities)
#0.9298183741844471

如你在大约 93%的 AUC 分数中所见,这种方法整体表现还不如上面的方法好,尽管表现并非完全糟糕。因此,让我们尝试调整一下。

Siamese 神经网络方法

现在我们将实现一个 Siamese 网络,学习表示字符串之间相似性(或差异)的投影。

如果你对 Siamese 网络方法感到有些陌生,可能会觉得有点令人畏惧。我们将在工作原理...部分进一步讨论它。

让我们从字符串特征化函数开始:

from tensorflow.keras.models import Sequential, Model
from tensorflow.keras.layers import Dense, Lambda, Input
import tensorflow as tf
from tensorflow.keras import backend as K

def create_string_featurization_model(
    feature_dimensionality, output_dim=50):
    preprocessing_model = Sequential()
    preprocessing_model.add(
        Dense(output_dim, activation='linear', input_dim=feature_dimensionality)
    )
    preprocessing_model.summary()
    return preprocessing_model

create_string_featurization_model函数返回一个字符串特征化模型。特征化模型是字符包输出的非线性投影。

函数有以下参数:

  • feature_dimensionality:来自向量化器的特征数(即字符包输出的维度)

  • output_dim:我们试图创建的嵌入/投影的维度

接下来,我们需要创建这两个模型的联合孪生体。为此,我们需要一个比较函数。我们使用归一化的欧氏距离。这是两个L2归一化投影向量之间的欧氏距离。

向量xL2范数定义如下:

L2归一化是将向量x除以其范数。

我们可以如下定义距离函数:

def euclidean_distance(vects):
    x, y = vects
    x = K.l2_normalize(x, axis=-1)
    y = K.l2_normalize(y, axis=-1)
    sum_square = K.sum(
        K.square(x - y),
        axis=1,
        keepdims=True
    )
    return K.sqrt(K.maximum(
        sum_square,
        K.epsilon()
    ))

现在 Siamese 网络可以通过将其包装为 Lambda 层来使用该函数。让我们定义如何联接孪生体,或者换句话说,我们如何将其包装成一个更大的模型,以便我们可以训练字符串对及其标签(即相似和不相似)。

def create_siamese_model(preprocessing_models, #initial_bias =
                          input_shapes=(10,)):    
    if not isinstance(preprocessing_models, (list, tuple)):
        raise ValueError('preprocessing models needs to be a list or tuple of models')
    print('{} models to be trained against each other'.format(len(preprocessing_models)))
    if not isinstance(input_shapes, list):
        input_shapes = [input_shapes] * len(preprocessing_models)

    inputs = []
    intermediate_layers = []
    for preprocessing_model, input_shape in zip(preprocessing_models, input_shapes):
        inputs.append(Input(shape=input_shape))
        intermediate_layers.append(preprocessing_model(inputs[-1]))

    layer_diffs = []
    for i in range(len(intermediate_layers)-1): 
        layer_diffs.append(
            Lambda(euclidean_distance)([intermediate_layers[i], intermediate_layers[i+1]])
        ) 
    siamese_model = Model(inputs=inputs, outputs=layer_diffs)
    siamese_model.summary()
    return siamese_model

这是一种冗长的说法:取两个网络,计算归一化欧氏距离,并将距离作为输出。

让我们创建双网络并进行训练:

def compile_model(model):
    model.compile(
        optimizer='rmsprop',
        loss='mse',
    )

feature_dims = len(ngram_featurizer.get_feature_names())
string_featurization_model = create_string_featurization_model(feature_dims, output_dim=10)

siamese_model = create_siamese_model(
    preprocessing_models=[string_featurization_model, string_featurization_model],
    input_shapes=[(feature_dims,), (feature_dims,)],
)
compile_model(siamese_model)
siamese_model.fit(
    [string1cv, string2cv],
    1 - data['matched'].astype(float),
    epochs=1000
)

创建一个输出为 10 维的模型;从 n-gram 特征提取器中获得了 41 维,这意味着我们总共有 420 个参数 (41 * 10 + 10)。

正如之前提到的,我们组合网络的输出是两个输出之间的欧氏距离。这意味着我们必须反转我们的目标(匹配)列,以便从相似变为不同,这样 1 对应不同,0 对应相同。我们可以通过简单地从 1 中减去来轻松实现这一点。

现在我们可以获得这种新投影的性能:

from scipy.spatial.distance import euclidean

string_rep1 = string_featurization_model.predict(
    ngram_featurizer.transform(data['string1'])
)
string_rep2 = string_featurization_model.predict(
    ngram_featurizer.transform(data['string2'])
)
dists = np.zeros(shape=(len(data), 1))
for i, (v1, v2) in enumerate(zip(string_rep1, string_rep2)):
    dists[i] = euclidean(v1, v2)

roc_auc_score(data['matched'].astype(float), 1 - dists)
0.9802944806912361

我们已经成功地击败了其他方法。甚至在调整任何超参数之前,我们的投影显然在突出显示对字符串相似性比较重要的差异方面起作用。

工作原理...

scikit-learn 的 CountVectorizer 计算字符串中特征的出现次数。一个常见的用例是计算句子中的单词数 —— 这种表示称为词袋,在这种情况下,特征将是单词。在我们的情况下,我们对基于字符的特征感兴趣,因此我们只计算 a ** 出现的次数、b ** 出现的次数,依此类推。我们可以通过表示连续字符元组如 abba 来使这种表示更加智能化;然而,这超出了我们当前的范围。

Siamese 网络训练是指两个(或更多)神经网络相互训练,通过比较给定一对(或元组)输入的网络输出以及这些输入之间的差异的知识。通常,Siamese 网络由相同的网络组成(即相同的权重)。两个网络输出之间的比较函数可以是诸如欧氏距离或余弦相似度之类的度量。由于我们知道两个输入是否相似,甚至知道它们有多相似,我们可以根据这些知识训练目标。

下图说明了信息流和我们将使用的不同构建块:

给定我们想要比较的两个字符串,我们将使用同一模型从每个字符串创建特征,从而得到两个表示。然后我们可以比较这些表示,希望比较结果与结果相关联,因此如果我们的比较显示很大的差异,那么字符串将是不相似的,如果比较显示很小的差异,那么字符串将是相似的。

我们实际上可以直接训练这个完整的模型,给定一个字符串比较模型和一个由字符串对和目标组成的数据集。这种训练将调整字符串特征化模型,使其表示更加有用。

推荐产品

在这个步骤中,我们将构建一个推荐系统。推荐系统是一个信息过滤系统,通过将内容和社交连接在一起预测排名或相似性。

我们将下载从 Goodreads 网站收集的图书评分数据集,用户在该网站上对阅读过的书籍进行排名和评论。我们将构建不同的推荐模型,并基于已知的评分推荐新书。

准备工作

为了准备我们的步骤,我们将下载数据集并安装所需的依赖项。

让我们获取数据集,并在这里安装我们将使用的两个库 – spotlightlightfm 是推荐系统库:

!pip install git+https://github.com/maciejkula/spotlight.git lightfm

然后我们需要获取图书评分的数据集:

from spotlight.datasets.goodbooks import get_goodbooks_dataset
from spotlight.cross_validation import random_train_test_split 

import numpy as np

interactions = get_goodbooks_dataset()
train, test = random_train_test_split(
 interactions, random_state=np.random.RandomState(42)
)

数据集以交互对象的形式呈现。根据 spotlight 的文档,交互对象可以定义如下:

[它]至少包含一对用户-项目交互,但也可以丰富评分、时间戳和交互权重。

对于隐性反馈场景,只应提供已观察到交互的用户-项目对的用户 ID 和项目 ID。未提供的所有对都被视为缺失观察,并且通常被解释为(隐性)负信号。

对于显性反馈场景,应提供数据集中观察到的所有用户-项目-评分三元组的用户 ID、项目 ID 和评分。

我们有以下训练和测试数据集:

<Interactions dataset (53425 users x 10001 items x 4781183 interactions)>
<Interactions dataset (53425 users x 10001 items x 1195296 interactions)>

要知道项目编号指的是哪些书籍,我们将下载以下 CSV 文件:

!wget https://raw.githubusercontent.com/zygmuntz/goodbooks-10k/master/books.csv

接下来,我们将实现一个根据id获取书名的函数。这对于后续展示我们的推荐非常有用:

import pandas as pd 
books = pd.read_csv('books.csv', index_col=0)

def get_book_titles(book_ids):
    '''Get book titles by book ids 
    ''' 
    if isinstance(book_ids, int): 
        book_ids = [book_ids]
    titles = []
    for book_id in book_ids:
        titles.append(books.loc[book_id, 'title'])
    return titles

book_labels = get_book_titles(list(train.item_ids))

现在我们可以按以下方式使用这个函数:

get_book_titles(1) 
['The Hunger Games (The Hunger Games, #1)'] 

现在我们已经获取了数据集并安装了所需的库,我们可以开始我们的步骤了。

如何做...

我们首先使用矩阵分解模型,然后使用深度学习模型。您可以在 Jupyter 笔记本中找到更多示例,链接如下:github.com/PacktPublishing/Artificial-Intelligence-with-Python-Cookbook/blob/master/chapter03/Recommending_products.ipynb

我们必须设置许多参数,包括潜在维度的数量和周期数:

import torch
from spotlight.factorization.explicit import ExplicitFactorizationModel
from spotlight.evaluation import (
    rmse_score,
    precision_recall_score
)

model = ExplicitFactorizationModel(
    loss='regression',
    embedding_dim=128,
    n_iter=10,
    batch_size=1024,
    l2=1e-9,
    learning_rate=1e-3,
    use_cuda=torch.cuda.is_available()
)
model.fit(train, verbose=True)
train_rmse = rmse_score(model, train)
test_rmse = rmse_score(model, test)
print('Train RMSE {:.3f}, test RMSE {:.3f}'.format(
    train_rmse, test_rmse
))

结果显示在以下截图中:

我们得到以下的推荐:

现在我们将使用lightfm推荐算法:

from lightfm import LightFM
from lightfm.evaluation import precision_at_k

# Instantiate and train the model
model = LightFM(loss='warp')
model.fit(train.tocoo(), epochs=30, num_threads=2)
test_precision = precision_at_k(model, test.tocoo(), k=5)
print(
    'mean test precision at 5: {:.3f}'.format(
        test_precision.mean()
))
mean test precision at 5: 0.114

我们也可以查看推荐内容,如下所示:

两个推荐系统都有它们的应用。基于精度在k(k=5)的基础上,我们可以得出结论,第二个推荐系统lightfm表现更好。

它的工作原理...

推荐系统向用户推荐产品。

他们可以根据不同的原则提出建议,例如以下内容:

  • 他们可以基于这样一个假设进行预测:在以前的购买中表现出类似品味的顾客将来会购买类似的物品(协同过滤)。

  • 基于顾客可能会对与他们过去购买的物品相似的物品感兴趣的理念进行预测(基于内容的过滤)。

  • 基于协同过滤、基于内容的过滤或其他方法的组合预测(混合推荐)。

混合模型可以以不同方式组合方法,例如分别进行基于内容和基于协同的预测,然后将分数相加,或者将这些方法统一到一个单一模型中。

我们尝试的两个模型都基于这样一种想法,即我们可以分离用户和物品的影响。我们将依次解释每个模型及其如何结合方法,但首先让我们解释一下我们正在使用的度量标准:k的精度。

k 处的精度

我们在这里提取的度量标准是k 处的精度。例如,精度在 10 处计算前k个文档中的相关结果数量,通常k=5 或k=10。

精度在k上不考虑在前k个结果中的排序,也不包括我们绝对应该捕捉到的真正好的结果的数量:这将是召回率。尽管如此,精度在k上是一个合理的度量标准,而且很直观。

矩阵分解

spotlight中的显式模型基于 Yehuda Koren 等人在《推荐系统的矩阵分解技术》(2009)中提出的矩阵分解技术。基本思想是将用户-物品(交互)矩阵分解为表示用户潜在因素和物品潜在因素的两个组成部分,以便根据给定物品和用户进行推荐,计算如下:

矩阵分解矩阵因子分解是将矩阵分解为矩阵乘积的过程。存在许多不同的这类分解方法,用途各不相同。

一个相对简单的分解是奇异值分解SVD),但现代推荐系统使用其他分解方法。spotlight矩阵分解和lightfm模型都使用线性整合。

lightfm模型

lightfm模型是由 Kula 在《用户和物品冷启动推荐的元数据嵌入》(2015)中介绍的。更具体地说,我们使用的是 WARP 损失,该损失在 Jason Weston 等人于 2011 年的《WSABIE:大规模词汇图像注释的扩展》中有详细解释。

lightfm 算法中,用于预测的函数如下:

在前述函数中,我们为用户和项目有偏差项, 是 sigmoid 函数。

模型训练最大化数据在给定参数条件下的似然性表达如下:

有多种方法可以衡量推荐系统的表现,而我们选择使用哪种方法取决于我们试图实现的目标。

另请参见

同样,有很多库可以轻松启动和运行。首先,我想强调这两个已经在本配方中使用过的库:

但还有一些其他很有前景的方法:

您可以在以下存储库中找到有关使用数据集进行项目排名的库功能演示:github.com/cheungdaven/DeepRec/blob/master/test/test_item_ranking.py

Microsoft 已经撰写了有关推荐最佳实践的文章:github.com/Microsoft/Recommenders

最后但同样重要的是,您可能会发现以下有关推荐系统的阅读列表有用:github.com/DeepGraphLearning/RecommenderSystems/blob/master/readingList.md

发现欺诈者社区

在这个配方中,我们将尝试使用网络分析方法来检测欺诈社区。这是一个在图分析中经常出现的用例,因为在进行欺诈检测时,我们关注的是人们之间的关系,比如他们是否住在附近,是否通过社交媒体连接,或者是否从事相同的工作。

准备工作

为了准备好制作这个配方,我们将安装所需的库并下载数据集。

我们将使用以下库:

此外,我们将使用 SciPy,但这是在 Anaconda 分发中包含的:

!pip install networkx annoy tqdm python-louvain

我们将使用以下欺诈信用卡交易数据集:www.kaggle.com/mlg-ulb/creditcardfraud

信用卡欺诈数据集包含了 2013 年 9 月欧洲持卡人使用信用卡的交易记录。该数据集包含了 284,807 笔交易,其中 492 笔是欺诈交易。该数据集非常不平衡:正类(欺诈)占所有交易的 0.172%。

让我们导入数据集,然后将其分割为训练集和测试集:

import pandas as pd
from sklearn.datasets import fetch_openml
import random

X, y = fetch_openml(data_id=1597, return_X_y=True)

samples = random.choices(
    list(range(X.shape[0])), k=int(X.shape[0] * 0.33)
)
X_train = X[(samples), :]

我们准备好了!让我们来做这道菜吧!

如何做...

首先我们会创建一个邻接矩阵,然后我们可以对其应用社区检测方法,最后我们将评估生成社区的质量。整个过程由于大数据集而增加了难度,这意味着我们只能应用某些算法。

创建邻接矩阵

首先,我们需要计算所有点的距离。对于这样一个大数据集,这是一个真正的问题。你可以在网上找到几种方法。

我们使用 Spotify 的annoy库进行此目的,这是非常快速和内存高效的:

from annoy import AnnoyIndex
t = AnnoyIndex(X_train.shape[1], 'euclidean') # Length of item vector that will be indexed
for i, v in enumerate(X_train):
    t.add_item(i, v)

t.build(10) # 10 trees

然后我们可以根据索引给出的距离初始化我们的邻接矩阵:

from tqdm import trange
from scipy.sparse import lil_matrix

MAX_NEIGHBORS = 10000 # Careful: this parameter determines the run-time of the loop!
THRESHOLD = 6.0

def get_neighbors(i):
  neighbors, distances = t.get_nns_by_item(i, MAX_NEIGHBORS, include_distances=True)
  return [n for n, d in zip(neighbors, distances) if d < THRESHOLD]

n_rows = X_train.shape[0]
A = lil_matrix((n_rows, n_rows), dtype=np.bool_)
for i in trange(n_rows):
  neighborhood = get_neighbors(i)
  for n in neighborhood:
      A[i, n] = 1
      A[n, i] = 1

现在我们可以应用一些社区检测算法。

社区检测算法

我们矩阵的大小限制了我们的选择。我们将应用以下两个算法:

  • 强连通分量 (SCC)

  • Louvain 算法

我们可以直接在邻接矩阵上应用 SCC 算法,如下所示:

from scipy.sparse.csgraph import connected_components

n_components, labels = connected_components(
    A,
    directed=False,
    return_labels=True
)

对于第二个算法,我们首先需要将邻接矩阵转换为图形;这意味着我们将矩阵中的每个点视为节点之间的边。为了节省空间,我们使用了一个简化的图形类:

import networkx as nx

class ThinGraph(nx.Graph):
    all_edge_dict = {'weight': 1}

    def single_edge_dict(self):
        return self.all_edge_dict

    edge_attr_dict_factory = single_edge_dict

G = ThinGraph(A)

然后我们可以按以下方式应用 Louvain 算法:

import community  # this is the python-louvain package

partition = community.best_partition(G)

现在我们有数据集的两个不同分区。让我们看看它们是否有价值!

评估社区

在理想情况下,我们期望一些社区只有欺诈者,而其他(大多数)社区则完全没有。这种纯度是我们在完美社区中寻找的。然而,由于我们可能也希望得到一些其他可能是欺诈者的建议,我们预计一些点会在大多数非欺诈组中被标记为欺诈者,反之亦然。

我们可以从观察每个社区的欺诈频率直方图开始。Louvain 欺诈者分布如下:

这表明社区中有很多非欺诈者,而其他数值很少。但我们能量化这有多好吗?

我们可以通过计算每个群集中的类熵来描述欺诈者分布。我们将在它的工作原理...部分解释熵。

然后我们可以创建适当选择的随机实验,以查看任何其他社区分配是否会导致更好的类熵。如果我们随机重新排序欺诈者,然后计算跨社区的熵,我们将得到一个熵分布。这将为我们提供 Louvain 社区熵的p 值统计显著性

p 值是我们仅凭偶然性就能获得这种(或更好)分布的概率。

您可以在 GitHub 上的笔记本中找到采样的实现。

我们得到了非常低的显著性,这意味着几乎不可能通过偶然得到类似的结果,这使我们得出结论,我们在识别欺诈者方面找到了有意义的聚类。

工作原理如下...

处理大数据集进行网络分析的最困难部分是构建邻接矩阵。您可以在在线笔记本中找到不同的方法。两个问题是运行时间和内存。这两者都可以随着数据点的数量呈指数增长。

我们的数据集包含 284,807 个点。这意味着在所有点之间建立完全连接的矩阵将占用数百 GB 空间(每个点 4 字节),

我们使用稀疏矩阵,其中大多数邻接都是 0,如果它们不超过给定的阈值。我们将每个点之间的每个连接表示为布尔值(1 位),并且我们仅采用 33%的样本,即 93,986 个点,而不是全部数据集。

图形社区算法

让我们了解两个图形社区算法的工作原理。

Louvain 算法

我们在这个方案中使用了 Louvain 算法。该算法由 Blondel 等人于 2008 年发布(arxiv.org/abs/0803.0476)。由于其时间复杂度为,Louvain 算法已被用于包括 Twitter 在内的大数据集中,该数据集包含 2.4 百万节点和 3800 万链接。

Louvain 算法的主要思想是通过逐步合并社区来增加它们的连接性。连接性通过边模块度来衡量,这是社区内边缘与其他社区顶点相连的密度与其他社区顶点的比例。每个顶点的社区切换都有一个相关的。在将每个顶点分配到它们自己的社区后,启发式操作分为两步:将顶点贪婪地分配到社区,以及粗粒度化。

  • 对于所有顶点 ,分配它们到社区,使得 尽可能高。这一步骤可以重复几次,直到模块度不再改善。

  • 所有社区都被视为顶点。这意味着边也被分组在一起,以便所有属于被分组顶点的边现在成为新创建顶点的边。

直到模块度再无改善为止,这两个步骤将被重复进行。

Girvan-Newman 算法

作为另一种算法的例子,让我们看看 Girvan-Newman 算法。Girvan-Newman 算法(由 Girvan 和 Newman,2002 年提出,相关论文请参见 www.pnas.org/content/99/12/7821)基于节点间的最短路径概念。边介数是边上运行的节点之间最短路径数。

算法工作如下:

  1. 计算所有边的边介数。

  2. 移除具有最高边介数的边。

  3. 重新计算边的边介数。

  4. 重复 步骤 2步骤 3 直到没有边剩余。

结果是一个树状图,显示了算法步骤中聚类的排列方式。

整个算法的时间复杂度为 ,具有边数 m 和顶点数 n

信息熵

给定一个离散随机变量 ,其可能值(或结果)为 ,以概率 发生, 的熵正式定义如下:

这通常被视为随机变量中的意外性、不确定性或混乱程度。

如果变量不是离散的,我们可以应用分箱(例如,通过直方图)或者使用非离散版本的公式。

还有更多...

我们也可以应用其他算法,如 2005 年由 David Pearce 发布的 SCC 算法(在《找到有向图的强连通分量的改进算法》中)。

我们也可以尝试这种方法:

from scipy.sparse.csgraph import connected_components

n_components, labels = connected_components(
    A,
    directed=False,
    return_labels=True
)

SCC 社区欺诈分布如下:

再次得到一个 p 值,显示出非常高的统计显著性。这意味着这不太可能仅仅是偶然发生的,表明我们的方法确实是欺诈的良好分类器。

我们也可以应用更传统的聚类算法。例如,亲和传播算法接受一个邻接矩阵,如下所示:

from sklearn.cluster import AffinityPropagation

ap = AffinityPropagation(
    affinity='precomputed'
).fit(A)

还有许多其他方法可供我们应用。对于其中一些方法,我们需要将邻接矩阵转换为距离矩阵。

参见

您可以在 GitHub 上找到有关图分类和图算法的阅读材料,由 Benedek Rozemberczki 收集,网址为 github.com/benedekrozemberczki/awesome-graph-classification

如果您对图卷积网络或图注意力网络感兴趣,还有一个对您有用的列表在 github.com/Jiakui/awesome-gcn

Python 有一些非常好的图库,具有许多用于社区检测或图分析的实现:

大多数 Python 库适用于小到中型邻接矩阵(大约高达 1,000 条边)。适用于更大数据规模的库包括以下内容:

Cdlib 还包含 BigClam 算法,适用于大图。

一些图数据库如 neo4j,带有 Python 接口,实现社区检测算法:neo4j.com/docs/graph-algorithms/current/

第四章:概率建模

本章讨论不确定性和概率方法。现代机器学习系统存在两个显著的缺点。

首先,它们在预测中可能过于自信(有时过于不自信)。在实践中,鉴于嘈杂的数据,即使我们观察到使用未见数据集进行交叉验证的最佳实践,这种信心也可能不合理。特别是在受监管或敏感环境中,如金融服务、医疗保健、安全和情报领域,我们需要非常谨慎地对待我们的预测及其准确性。

其次,机器学习系统越复杂,我们需要更多的数据来拟合我们的模型,过拟合的风险也越严重。

概率模型是使用随机采样技术产生概率推断的模型。通过参数化分布和固有的不确定性,我们可以克服这些问题,并获得否则需要更多数据才能获得的准确性。

在本章中,我们将使用不同的插件方法建立股票价格预测模型,并进行置信度估计。然后,我们将涵盖估计客户生命周期,这是为服务客户的企业所共有的问题。我们还将研究诊断疾病,并量化信用风险,考虑不同类型的不确定性。

本章涵盖以下配方:

  • 以信心预测股票价格

  • 估算客户生命周期价值

  • 诊断疾病

  • 阻止信用违约

技术要求

在本章中,我们主要使用以下内容:

您可以在 GitHub 上找到本章的代码,链接为 github.com/PacktPublishing/Artificial-Intelligence-with-Python-Cookbook/tree/master/chapter04

以信心预测股票价格

有效市场假说假定在任何给定时间,股票价格集成了关于股票的所有信息,因此市场不能通过优越的策略或更一般地说更好的信息持续超越。然而,可以争论说当前在投资银行实践中,机器学习和统计被构建到算法交易系统中,这与之相矛盾。但这些算法可能会失败,正如 2010 年的闪电崩盘或系统风险被低估时所见,Roger Lowenstein 在他的书 当天才失败时:长期资本管理的兴衰 中讨论过。

在这个示例中,我们将在 scikit-learn 中构建一个简单的股票预测管道,并使用不同的方法生成概率估计。然后,我们将评估我们的不同方法。

准备好

我们将使用yfinance库检索历史股票价格。

这是我们安装它的方式:

pip install yfinance

yfinance 将帮助我们下载历史股票价格。

如何做到...

在实际情况中,我们希望回答以下问题:在价格水平给定的情况下,它们会上涨还是下跌,以及幅度如何?

为了朝着这个目标取得进展,我们将按以下步骤进行:

  1. 下载股票价格。

  2. 创建一个特征化函数。

  3. 编写一个评估函数。

  4. 训练模型来预测股票并比较性能。

特别地,我们将比较以下生成置信度值的方法:

  • Platt 缩放

  • 朴素贝叶斯

  • 保序回归

我们将讨论这些方法及其背景在它是如何工作...部分。

让我们试一下!

  1. 下载股票价格:我们将下载微软的价格:
import yfinance as yf

msft = yf.Ticker('MSFT')
hist = msft.history(period='max')

现在我们有我们的股票价格作为pandas DataFrame hist可用。

  1. 创建一个特征化函数:所以,让我们从一个函数开始,这个函数将为我们提供一个训练和预测的数据集,给定一个窗口大小和一个移动;基本上,我们想要为每个价格获取多少描述符,并且我们要看多远的未来:
from typing import Tuple
import numpy as np
import pandas as pd
import scipy

def generate_data(
    data: pd.DataFrame, window_size: int, shift: int
) -> Tuple[np.array, np.array]:
    y = data.shift(shift + window_size)
    observation_window = []
    for i in range(window_size):
        observation_window.append(
            data.shift(i)
        )
    X = pd.concat(observation_window, axis=1)
    y = (y - X.values[:, -1]) / X.values[:, -1]
    X = X.pct_change(axis=1).values[:, 1:]
    inds = (~np. isnan(X).any(axis=1)) & (~np. isnan(y))
    X, y = X[inds], y[inds]
    return X, y

然后,我们将使用我们的新函数generate_data()生成我们的训练和测试数据集:

from sklearn.model_selection import train_test_split

X, y = generate_data(hist.Close, shift=1, window_size=30)
X_train, X_test, y_train, y_test = train_test_split(X, y)

当然,这是一个常见的模式,我们在前面的示例中已经见过几次了:我们生成我们的数据集,然后将其拆分为训练和验证集,其中训练集用于训练(正如其名称所示),验证集用于检查我们的算法的工作效果(特别是我们是否过度拟合)。

我们的数据集大致符合正态分布。这是我们在训练中目标的样子:

我们可以看到有一个向左的偏斜,即比零更多的值在下方(大约 49%)而不是在上方(大约 43%)。这意味着在训练中,价格会下降而不是上涨。

我们的数据集还没有完成,但是我们需要进行另一次转换。我们的场景是,我们想应用这个模型来帮助我们决定是否购买股票,假设价格会上涨。我们将分离三个不同的类别:

  • 价格上涨了 x

  • 价格保持不变。

  • 价格下降了 x

在下面的代码块中,我们根据threshold参数应用了x给定的截止值:

def threshold_vector(x, threshold=0.02):
    def threshold_scalar(f):
        if f > threshold:
            return 1
        elif f < -threshold:
            return -1
        return 0
    return np.vectorize(threshold_scalar)(x)

y_train_classes, y_test_classes = threshold_vector(y_train), threshold_vector(y_test)

在此之后,我们对训练和测试(验证)的阈值化 y 值进行了处理。

  1. 编写一个评估函数:这是为了衡量我们在使用给定模型预测股票价格时的性能。对于评估,我们需要一个帮助函数,将整数编码转换为一位有效编码。

在评估中,我们计算并打印曲线下面积AUC)作为性能指标。我们创建了一个函数,measure_perf(),用于测量性能并打印相关指标,给定像这样的模型:

from sklearn import metrics

def to_one_hot(a):
    """convert from integer encoding to one-hot"""
    b = np.zeros((
       a.size, 3
    ))
    b[np.arange(a.size), a+1] = 1
    return b

def measure_perf(model, y_test_classes):
  y_pred = model.predict(X_test)
  auc = metrics.roc_auc_score(
    to_one_hot(y_test_classes),
    to_one_hot(y_pred),
    multi_class='ovo'
  )
  print('AUC: {:.3f}'.format(auc))

现在我们可以使用我们的新方法来评估模型训练后的性能。

  1. 训练模型以预测股票并比较性能:我们现在将比较以下方法,以从我们的三个模型中生成概率结果,前两种我们可以快速实现:
from sklearn.ensemble import RandomForestClassifier
from sklearn.calibration import CalibratedClassifierCV

rf = RandomForestClassifier(
    n_estimators=500, n_jobs=-1
).fit(X_train, y_train_classes)
platt = CalibratedClassifierCV(
    rf, method='sigmoid'
).fit(X_train, y_train_classes)
isotonic = CalibratedClassifierCV(
    rf, method='isotonic'
).fit(X_train, y_train_classes)
print('Platt:')
measure_perf(platt, y_test_classes)
print('Isotonic:')
measure_perf(isotonic, y_test_classes)
#Platt:
#AUC: 0.504
#Isotonic:
#AUC: 0.505

对于朴素贝叶斯,我们尝试不同的变体:分类朴素贝叶斯和补集朴素贝叶斯:

from sklearn.ensemble import StackingClassifier
from sklearn.naive_bayes import ComplementNB, CategoricalNB

def create_classifier(final_estimator):
    estimators = [
        ('rf', RandomForestClassifier(
            n_estimators=100,
            n_jobs=-1
        ))
    ] 
    return StackingClassifier(
        estimators=estimators,
        final_estimator=final_estimator,
        stack_method='predict_proba'
    ).fit(X_train, y_train_classes)

measure_perf(create_classifier(CategoricalNB()), y_test_classes)
measure_perf(create_classifier(ComplementNB()), y_test_classes)
#CategoricalNB:
#AUC: 0.500
#ComplementNB: 
#AUC: 0.591

我们发现,无论是 Platt 缩放(逻辑回归)还是等渗回归都无法很好地处理我们的数据集。朴素贝叶斯回归不比随机选择好多少,这让我们不愿意拿来押注,即使稍微比随机选择好一点。然而,补集朴素贝叶斯分类器的表现要好得多,达到了 59%的 AUC。

工作原理...

我们已经看到,我们可以创建一个股票价格预测器。我们将这个过程分解为创建数据、验证和训练模型。最终,我们找到了一种方法,可以让我们对其实际使用抱有希望。

让我们先生成数据,然后再使用我们的不同方法。

特征化

这对于任何人工智能工作都是至关重要的。在处理任何工作或首次查看数据集之前,我们应该问自己我们选择什么作为我们观察单位的单元,并且我们如何以有意义且可以被算法捕捉的方式描述我们的数据点。这是一种随着经验变得自动化的事情。

在我们的generate_data()函数中,我们从股票价格历史数据中提取了用于训练和测试的数据集。我们专注于预测单个价格,因此我们的观察单元是单个股票价格。对于每个价格,我们需要提取特征,即其他价格。我们跨越时间段提取价格,这可以帮助我们预测未来的值。更准确地说,我们不直接使用价格;我们必须先对其进行归一化,因此最好将其称为价格水平而不是价格。

使用我们的方法,我们为预测参数化我们的数据,不同的时间视野和一些点。价格水平是在一个窗口内提取的,一段时间内的天数(特征)。最后,将来的价格水平将被预测(目标)。时间段和偏移是我们的两个额外参数:window_sizeshift。该函数返回x,包含其窗口内的股票价格历史,以及y,未来要预测的股票价格。

还有更多问题需要解决。我们已经看到了在时间序列数据处理中的一些方法,在《预测 CO2 时间序列》这一章节的《高级监督学习主题》中。特别是在这个配方中,平稳性和标准化也是需要考虑的问题(你可能想要翻回去看看那里的解释)。

特征被标准化为平均值为 0,然后作为百分比变化差异(每个窗口中的每个值到前一个值的差异)。差异步骤是为了引入平稳性度量。特别地,目标被表达为相对于窗口中最后一个值的百分比变化,即特征。

接下来我们将看一下 Platt 缩放,这是一种将模型预测缩放为概率输出的最简单方法之一。

Platt 缩放

Platt 缩放(John Platt,1999 年,支持向量机的概率输出及其与正则化似然方法的比较)是我们使用的第一种模型结果缩放方法。简单来说,它是在我们的分类器预测之上应用逻辑回归。逻辑回归可以表达为以下形式(方程式 1):

这里的 AB 是通过最大似然方法学习得到的。

我们正在寻找 AB,如下所示:

这里的 p 指的是前述的方程式 1。

作为梯度下降,我们可以迭代应用以下两个步骤之一:

  1. 计算梯度作为似然函数的微分。

  2. 根据学习速率缩放的梯度来更新参数。

在接下来的小节中,我们将探讨使用保序回归进行概率校准的替代方法。

保序回归

保序回归(Zadrozny 和 Elkan,2001 年,当成本和概率均未知时的学习和决策)是使用保序函数进行回归的方法,即作为函数逼近时,最小化均方误差的同时保持单调递增或非减。

我们可以表达为以下形式:

这里 m 是我们的保序函数,xy 是特征和目标,f 是我们的分类器。

接下来,我们将看一个最简单的概率模型之一,朴素贝叶斯。

朴素贝叶斯

朴素贝叶斯分类器基于贝叶斯定理。

贝叶斯定理是关于条件概率的定理,表示事件 A 发生在 B 给定的条件下:

P(A) 是观察到 A 的概率(A 的边际概率)。考虑到公式中的 P(B) 在分母中,不能为零。这背后的推理值得一读。

朴素贝叶斯分类器涉及到给定特征的类别概率。我们可以将类别 k 和特征 x 插入到贝叶斯定理中,如下所示:

它被称为朴素是因为它假设特征彼此独立,因此提名者可以简化如下:

在接下来的部分中,我们将查看额外的材料。

请参阅

这里有一些资源供您参考:

  • 对于 Platt 缩放,请参阅 John Platt 的支持向量机的概率输出及与正则化似然方法的比较(1999 年)。

  • 对于同位素回归,在我们应用于分类中的概率估计,请参阅 Zadrozny, B.和 Elkan, C.的将分类器分数转换为准确的多类别概率估计(2002 年)。

  • 对于两者的比较,请参阅 A. Niculescu-Mizil & R. Caruana 在 ICML(2005 年)上的使用监督学习预测良好概率。关于朴素贝叶斯算法,请参阅 Rennie, J. D.等人的Tackling the Poor Assumptions of Naive Bayes Text Classifiers(2003 年)。

  • scikit-learn 文档概述了置信度校准(scikit-learn.org/stable/auto_examples/calibration/plot_calibration_curve.html#sphx-glr-auto-examples-calibration-plot-calibration-curve-py)。

  • 对于应用于深度学习模型的方法,请参阅 Lee 等人在 ICLR 2018 年的论文,训练置信度校准的分类器以检测分布外样本arxiv.org/abs/1711.09325)。他们的代码可在 GitHub 上找到:github.com/alinlab/Confident_classifier

您可以在以下链接找到更多关于不同框架下时间序列概率分析的示例:

估算客户生命周期价值

在本示例中,我们将学习如何计算生命周期价值以及顾客为公司提供的价值。这对于营销预算非常重要,例如基于客户细分的潜在获取或广告支出。我们将通过分别对时间内客户购买模式的变化和购买价值进行建模来实现这一点。

准备就绪

我们需要lifetimes包来完成这个配方。让我们按照以下代码安装它:

pip install lifetimes

现在我们可以开始了。

如何做...

用于客户生命周期价值的数据集可以是交易性的,也可以是客户汇总的。

摘要数据应包括以下统计信息:

  • T:交易期间;客户第一次购买后经过的时间

  • 频率:观察期内客户的购买次数

  • 货币价值:购买的平均价值

  • 最近:客户在最后一次购买时的年龄

让我们从第一步开始!

  1. 我们首先将BetaGeoBD)/负二项分布NBD)模型拟合到客户交易摘要数据集上:
from lifetimes.datasets import load_cdnow_summary_data_with_monetary_value
from lifetimes import BetaGeoFitter

bgf = BetaGeoFitter(penalizer_coef=0.0)
bgf.fit(
   data['frequency'],
    data['recency'],
    data['T']
)
  1. Gamma-Gamma 模型用于购买价值不能处理没有重复购买的客户,因此在拟合之前我们将排除这些客户:
from lifetimes import GammaGammaFitter

data_repeat = data[data.frequency>0]
ggf = GammaGammaFitter(penalizer_coef=0.0)
ggf.fit(
    data_repeat.frequency,
    data_repeat.monetary_value
)
  1. 然后,我们可以使用 Lifetimes 库的另一个方法结合预测模型(预测未来交易数量的模型bgf和预测平均购买价值的模型ggf)。它包括一个对未来价值进行折现的参数。我们将包括对应年化 12.7%的折现。我们将打印五个客户的生命周期价值:
print(ggf.customer_lifetime_value(
    bgf,
    data['frequency'],
    data['recency'],
    data['T'],
    data['monetary_value'],
    time=12,
    discount_rate=0.01
).head(5))

输出显示了客户生命周期价值:

customer_id
1      140.096218
2       18.943466
3       38.180575
4       38.180575
5       38.180575

现在我们知道谁是我们最好的客户,因此知道在哪里投资我们的时间和资源!

让我们看看这个配方中的一些方法。

工作原理...

在这个配方中,我们根据他们的购买模式估计了客户的生命周期价值。

每个客户对公司有一个价值。这对于市场预算非常重要 - 例如,基于客户细分的潜在获取或广告支出。实际的客户生命周期价值在客户离开公司后才知道;然而,我们可以为每个客户建立两个不同的概率预测模型:

  • 建模购买更多产品的可能性

  • 建模平均值(收入)的购买

我们使用 BG/NBD 模型建模购买频率 - 或者更精确地说,随时间变化的客户购买模式 - 并使用 Gamma-Gamma 模型建模购买价值。这两个模型都利用了变量之间的非线性关系。

最后,我们可以按照以下公式结合预测以获取生命周期价值:

让我们看看我们在这里使用的两个子模型。

BG/NBD 模型

这考虑了客户的购买频率和客户的退出概率。

它包含以下假设:

  • 每个客户的购买都遵循带有λ参数的泊松分布。

  • 每次交易后,客户的流失概率p遵循贝塔分布。

  • 交易率和客户之间的退出概率是独立的。

可以根据最大似然估计来估计λ和p参数。

伽玛-伽玛模型

此模型用于估计客户终身的平均交易价值,E(M),我们对其有一个不完美的估计,如下所示:

在这里,x是客户终身内(未知的)总购买次数,z是每次购买的价值。

我们假设z是从伽玛分布中抽样的,因此,模型的拟合涉及在个体水平上找到形状和比例参数。

另请参见

这个示例比较短,因为 Lifetimes 库已经做了很多必要功能的插件和播放。关于这种分析的扩展解释可以在 Lifetimes 文档中找到(lifetimes.readthedocs.io/en/latest/Quickstart.html)。

Lifetimes 库提供了一系列模型(称为fitters),您可能想要深入了解。关于本示例中两种方法的更多详细信息,请参阅 Fader 等人的Counting your Customers the Easy Way: An Alternative to the Pareto/NBD Model, 2005 年以及 Batislam 等人的Empirical validation and comparison of models for customer base analysis2007 年。您可以在 Fader 和 Hardi 的报告Gamma-Gamma Model of Monetary Value(2013 年)中找到关于伽玛-伽玛模型的详细信息。

Google Cloud Platform GitHub 仓库展示了一个客户终身价值估算的模型比较(github.com/GoogleCloudPlatform/tensorflow-lifetime-value),包括 Lifetimes,一个 TensorFlow 神经网络和 AutoML。你可以在 UCI 机器学习存档中找到一个非常类似的在线零售数据集(archive.ics.uci.edu/ml/datasets/Online+Retail)。

Lifelines 是一个由 Lifetimes 的作者 Cameron Davidson-Pilon 编写的生存回归库(lifelines.readthedocs.io/en/latest/Survival%20Regression.html)。

诊断疾病

对于概率建模,实验性库不胜枚举。运行概率网络可能比算法(非算法)方法慢得多,直到不久前,它们几乎对除了非常小的数据集外的任何事物都不实用。事实上,大多数教程和示例都是关于玩具数据集的。

然而,由于硬件更快和变分推断的进步,在近年来这种情况发生了变化。利用 TensorFlow Probability,即使进行了概率抽样并且完全支持 GPU,也通常可以简单地定义架构、损失和层,并支持最新的实现来进行快速训练。

在这个示例中,我们将实现一个在医疗保健领域的应用程序 - 我们将诊断一种疾病。

准备工作

我们已经在之前的章节中安装了 scikit-learn 和 TensorFlow。

对于这个示例,我们还需要安装 tensorflow-probability

pip install tensorflow-probability

现在安装了 tensorflow-probability,我们将在下一节中广泛使用它。

怎样做…

我们将把这个过程分解为几个步骤:

  1. 下载并准备数据

  2. 创建神经网络

  3. 模型训练

  4. 验证

我们将从 Python 中获取数据集的步骤开始:

  1. 下载并准备数据:我们将下载在匈牙利心脏病学院由 Andras Janosi 团队收集的症状和心脏病诊断数据集(www.openml.org/d/1565/),然后在 Keras 中构建神经网络,并根据症状进行概率诊断。

如之前一样,我们将从 OpenML 下载数据集。您可以在那里看到完整的描述。原始目标编码了不同的状态,其中 0 表示健康,其他数字表示疾病。因此,我们将在健康和非健康之间分开,并将其视为二元分类问题。我们应用标准缩放器,以便将 z 分数馈送给神经网络。所有这些在 Chapter 1、Getting Started with Artificial Intelligence in Python、Chapter 2、Advanced Topics in Supervised Machine Learning、Chapter 3、Patterns, Outliers, and Recommendations 和 Chapter 4、Probabilistic Modeling 等几个早期的章节中应该是熟悉的:

from sklearn.datasets import fetch_openml
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split

X, y = fetch_openml(data_id=1565, return_X_y=True, as_frame=True)
target = (y.astype(int) > 1).astype(float)
scaler = StandardScaler()
X_t = scaler.fit_transform(X)
Xt_train, Xt_test, y_train, y_test = train_test_split(
    X_t, target, test_size=0.33, random_state=42
)

现在,我们已经预处理并将数据集拆分为训练集和测试集。

  1. 创建神经网络:网络的构建本身很简单,看起来与我们之前见过的任何 Keras 网络非常相似。不同之处在于末端有一个 DistributionLambda 层,我们将在下一节中解释它:
import tensorflow as tf
import tensorflow_probability as tfp
tfd = tfp.distributions
from tensorflow import keras

negloglik = lambda y, p_y: -p_y.log_prob(y)

model = keras.Sequential([
  keras.layers.Dense(12, activation='relu', name='hidden'),
  keras.layers.Dense(1, name='output'),
  tfp.layers.DistributionLambda(
      lambda t: tfd.Bernoulli(logits=t)
  ),
])

model.compile(optimizer=tf.optimizers.Adagrad(learning_rate=0.05), loss=negloglik)

需要注意的是,我们不像在二元分类任务中那样最后以 Dense(2, activation='softmax' 层结束,而是将输出减少到我们概率分布需要的参数数量,对于伯努利分布而言,仅需一个参数,即二进制结果的期望平均值。

我们使用的是一个只有 181 个参数的相对较小的模型。在 How it works... 节中,我们将解释损失函数。

  1. 模型训练:现在,我们可以训练我们的模型。我们将在 tensorboard 中绘制训练损失,并启用早停:
%load_ext tensorboard
callbacks = [
    keras.callbacks.EarlyStopping(patience=10, monitor='loss'),
    keras.callbacks.TensorBoard(log_dir='./logs'),
]
history = model.fit(
    Xt_train,
    y_train.values,
    epochs=2000,
    verbose=False,
    callbacks=callbacks
)

该网络将运行 2,000 个周期,完成可能需要一段时间。从 TensorBoard 中,我们可以看到各周期的训练损失:

  1. 验证模型:现在我们可以从模型中进行抽样。每个网络预测都给出了均值和方差。我们可以查看单个预测。我们任意选择了第 10 个预测:
from scipy.stats import norm
import matplotlib.pyplot as plt

y_pred = model(Xt_test)
a = y_pred.mean().numpy()[10]
b = y_pred.variance().numpy()[10]
fig, ax = plt.subplots(1, 1)
x = np.linspace(
    norm.ppf(0.001, a, b),
    norm.ppf(0.999, a, b),
    100
)
pdf = norm.pdf(x, a, b)
ax.plot(
    x, 
    pdf / np.sum(pdf), 
    'r-', lw=5, alpha=0.6, 
    label='norm pdf'
)
plt.ylabel('probability density')
plt.xlabel('predictions')

此预测如下所示:

因此,每个预测都是来自伯努利过程的样本。我们可以使用累积分布函数将每个预测转换为类别概率:

def to_classprobs(y_pred):
    N = y_pred.mean().numpy().shape[0]
    class_probs = np.zeros(
        shape=(N, 2)
    )
    for i, (a, b) in enumerate(
        zip(
            y_pred.mean().numpy(),
            y_pred.variance().numpy()
        )
    ):
        conf = norm.cdf(0.5, a, b)
        class_probs[i, 0] = conf
        class_probs[i, 1] = 1 - conf
    return class_probs

class_probs = to_classprobs(y_pred)

现在,我们可以计算曲线下面积以及其他与测试目标相关的指标:

import sklearn

def to_one_hot(a):
    """convert from integer encoding to one-hot"""
    b = np.zeros((a.size, 2))
    b[np.arange(a.size), np.rint(a).astype(int)] = 1
    return b

sklearn.metrics.roc_auc_score(
    to_one_hot(y_test),
    class_probs
)
print('{:.3f}'.format(sklearn.metrics.roc_auc_score(to_one_hot(y_test), class_probs)))
0.859

85% 的 AUC 听起来不错。但我们在医疗领域工作,因此我们需要检查召回率(也称为敏感性)和精度;换句话说,我们是否检测到了所有患病的患者,并且如果我们诊断某人,他们是否确实患病?如果我们漏掉某人,他们可能会死于未治疗的情况。如果我们将所有人都诊断为患病,将会给资源带来压力。

在以下代码段中,我们将更详细地查看我们得到的结果:

from sklearn.metrics import plot_precision_recall_curve
import matplotlib.pyplot as plt
from sklearn.metrics import average_precision_score

class ModelWrapper(sklearn.base.ClassifierMixin):
    _estimator_type = 'classifier'
    classes_ = [0, 1]
    def predict_proba(self, X):
        pred = model(X)
        return to_classprobs(pred)

model_wrapper = ModelWrapper()
average_precision = average_precision_score(
    to_one_hot(y_test),
    class_probs
)
fig = plot_precision_recall_curve(
    model_wrapper, Xt_test, y_test
)
fig.ax_.set_title(
    '2-class Precision-Recall curve: '
    'AP={0:0.2f}'.format(average_precision)
)

这通过可视化我们的结果来更好地理解精度和召回率之间的权衡。

我们得到以下图表:

这条曲线展示了我们模型固有的召回率和精度之间的权衡。在我们的置信度(或类别概率)上采用不同的截断时,我们可以判断某人是否患病。如果我们想要找到所有人(召回率=100%),精度会下降到 40%以下。另一方面,如果我们希望在诊断某人患病时始终正确(精度=100%),那么我们将会错过所有人(召回率=0%)。

现在的问题是,分别错过人员或诊断过多的成本,以便对是否某人患病做出决定。考虑到治疗人员的重要性,也许在召回率约为 90%和精度约为 65%左右之间存在一个最佳点。

工作原理是这样的...

我们训练了一个神经网络来进行概率预测,用于诊断疾病。让我们分析一下这个过程,并了解我们在这里使用的内容。

随机不确定性

TensorFlow Probability 提供了多种用于建模不同类型不确定性的层。随机不确定性指的是在给定相同输入的情况下,结果的随机变化性——换句话说,我们可以学习数据中的分布情况。

我们可以通过在 Keras 和 TensorFlow Probability 中参数化描述预测的分布,而不是直接预测输入来实现这一点。基本上,DistributionLambda从分布中抽取样本(在我们的情况下是伯努利分布)。

负对数似然

我们使用负对数似然作为我们的损失函数。这种损失函数通常用于最大似然估计。

我们定义的是这样的:

negloglik = lambda y, p_y: -p_y.log_prob(y)

损失函数接受两个值:y,目标值,以及提供log_prob()方法的概率分布。该方法返回y处概率密度的对数。由于高值代表好结果,我们希望通过取负值来反转该函数。

伯努利分布

伯努利分布(有时称为硬币翻转分布)是一个具有两个结果的离散事件分布,其发生概率为pq = 1 - p。它有一个单一的参数p。我们也可以选择其他建模选项,例如在 softmax 激活层之上的分类分布。

指标

最后,我们涉及了召回率和精确率。

它们的定义如下:

我们之前见过真阳性tp)、假阳性fp)和假阴性fn)。作为提醒,真阳性指的是正确的预测,假阳性指的是错误地预测为正面的值,假阴性指的是错误地预测为负面的值。

另请参阅

在这个配方中,您已经看到如何为健康应用程序使用概率模型。有许多其他数据集和执行概率推断的不同方式。请查看 TensorFlow Probability 作为最具影响力的概率建模框架之一(www.tensorflow.org/probability)。它提供了广泛的教程。

停止信用违约

对于向客户提供信用延伸的公司,为了盈利,批准申请的最重要标准是他们是否能够偿还债务。这由一个称为信用评分的过程决定,它基于客户的财务历史和社会经济信息。传统上,用于信用评分的是评分卡,尽管近年来,这些简单模型已被更复杂的机器学习模型所取代。评分卡基本上是不同信息项目的检查表,每个项目与分数相关联,最终所有分数相加并与及格分数进行比较。

我们将使用一个相对较小的信用卡申请数据集;然而,它仍然可以为我们提供一些关于如何使用神经网络模型进行信用评分的见解。我们将实现一个包含权重分布和输出分布的模型。这被称为认知不确定性和随机不确定性,将为我们提供更可靠的预测信息。

准备工作

我们将使用tensorflow-probability。以防您跳过了前面的配方诊断疾病,这里是如何安装它:

pip install tensorflow-probability

现在,我们应该准备好使用 Keras 和tensorflow-probability了。

如何做...

让我们获取数据集并进行预处理,然后创建模型,训练模型并验证它:

  1. 下载和准备数据集:我们将在这个示例中使用的数据集是在 2009 年发布的(I-Cheng Yeh 和 Che-hui Lien,《信用卡客户违约概率数据挖掘技术的比较》,),最初托管在 UCI 机器学习库上,网址为archive.ics.uci.edu/ml/datasets/default+of+credit+card+clients

我们将使用 scikit-learn 的实用函数通过openml下载数据:

import numpy as np
from sklearn.datasets import fetch_openml

openml_frame = fetch_openml(data_id=42477, as_frame=True)
data = openml_frame['data']

这给我们提供了关于客户人口统计信息及其申请的特征。

我们将使用一个非常标准的预处理过程,这在本书中我们已经看过很多次,并且我们将轻松处理。我们本可以更深入地检查特征,或者在转换和特征工程上做更多工作,但这与本示例无关。

所有特征都注册为数值,因此我们只应用标准缩放。这里是预处理步骤,然后我们将其分为训练和测试数据集:

from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split

scaler = StandardScaler()
X = scaler.fit_transform(
    data
)
target_dict = {val: num for num, val in enumerate(list(openml_frame['target'].unique()))}
y = openml_frame['target'].apply(lambda x: target_dict[x]).astype('float').values
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.33, random_state=42
)

现在,工作完成了,让我们创建模型。

  1. 创建模型:首先,我们需要先验和后验分布。这是通过直接按照在线 TensorFlow 概率教程(www.github.com/tensorflow/probability/blob/master/tensorflow_probability/examples/jupyter_notebooks/Probabilistic_Layers_Regression.ipynb)进行的,适用于正态分布:
import tensorflow as tf
import tensorflow_probability as tfp
import matplotlib.pyplot as plt
tfd = tfp.distributions
%matplotlib inline

negloglik = lambda y, rv_y: -rv_y.log_prob(y)
def prior_trainable(kernel_size, bias_size=0, dtype=None):
    n = kernel_size + bias_size
    return tf.keras.Sequential([
    tfp.layers.VariableLayer(n, dtype=dtype),
    tfp.layers.DistributionLambda(lambda t: tfd.Independent(
        tfd.Normal(loc=t, scale=1),
        reinterpreted_batch_ndims=1)),
    ])
def posterior_mean_field(kernel_size, bias_size=0, dtype=None):
    n = kernel_size + bias_size
    c = np.log(np.expm1(1.))
    return tf.keras.Sequential([
    tfp.layers.VariableLayer(2 * n, dtype=dtype),
    tfp.layers.DistributionLambda(lambda t: tfd.Independent(
        tfd.Normal(
            loc=t[..., :n],
            scale=1e-5 + tf.nn.softplus(c + t[..., n:])
        ),
    reinterpreted_batch_ndims=1)),
    ])

请注意DenseVariational

现在来看主模型,在这里我们将使用先验和后验分布。你会认出DistributionLambda。我们从之前的示例中替换了Binomial诊断疾病,用Normal,这将给我们一个预测方差的估计:

model = tf.keras.Sequential([
    tfp.layers.DenseVariational(2, posterior_mean_field, prior_trainable, kl_weight=1/X.shape[0]),
    tfp.layers.DistributionLambda(
        lambda t: tfd.Normal(
            loc=t[..., :1],
            scale=1e-3 + tf.math.softplus(0.01 * t[...,1:])
        )
    ),
])
model.compile(
    optimizer=tf.optimizers.Adam(learning_rate=0.01),
    loss=negloglik
)
callback = tf.keras.callbacks.EarlyStopping(monitor='val_loss', patience=5)
model.fit(
    X_train,
    y_train,
    validation_data=(X_test, y_test),
    epochs=1000,
    verbose=False,
    callbacks=[callback]
)

拟合后,我们将模型应用于测试数据集并对其进行预测。

  1. 验证:让我们检查一下我们的模型表现如何:
from sklearn.metrics import roc_auc_score
preds = model(X_test)
roc_auc_score(y_test, preds.mean().numpy())

我们得到了约 70%的 AUC。由于这个摘要数字通常不能完全揭示全部情况,让我们也看看混淆矩阵:

from sklearn.metrics import confusion_matrix
import pandas as pd
import seaborn as sns

cm = confusion_matrix(y_test, preds.mean().numpy() >= 0.5)
cm = pd.DataFrame(
 data=cm / cm.sum(axis=0),
 columns=['False', 'True'],
 index=['False', 'True']
)
sns.heatmap(
 cm,
 fmt='.2f',
 cmap='Blues',
 annot=True,
 annot_kws={'fontsize': 18}
)

此代码给出了以下混淆矩阵:

这个混淆矩阵列出了对实际违约的预测。在点对角线上,假-假和真-真是正确的预测(真正例和真反例)。我们可以看到,正确预测的数量比错误预测高,这让人感到安慰。

然而,最有趣的一点是预测的方差与误差相关!

import scipy
scipy.stats.spearmanr(np.abs(y_test - preds.mean().numpy().squeeze()), preds.variance().numpy().squeeze())

我们得到了绝对误差与预测方差之间约 60%的秩相关。

这给了我们一个非常有用的实践信心估计。我们发现,对于测试点,误差较高的方差较大,而在预期误差较低的地方,方差较小。我们可以在散点图中查看绝对误差和方差:

这就结束了我们的配方。这个配方作为一个练习,让你尝试更好的预处理,调整模型,或者切换分布。

它的工作原理...

信用评分模型通常使用 logistic 回归模型,我们在本章中的预测股票价格和置信度配方中遇到过。另外,提升模型或可解释的决策树也在使用中。鉴于在线学习的能力和表示残差不确定性的能力,tensorflow-probability 提供了另一个实用的选择。

在这个配方中,我们创建了一个与认知不确定性一起工作的概率性信用违约预测模型。现在是时候解释这意味着什么了。

认知不确定性

这是与不完整信息相关的不确定性 - 换句话说,模型固有的不确定性。在与嘈杂的现实世界数据集中经常遇到认知不确定性。

在 TensorFlow Probability 中,这可以建模为权重不确定性。在这个配方中,我们使用了贝叶斯神经网络,其中权重是概率分布而不是标量估计。这种权重不确定性转化为预测中的不确定性,这正是我们想要看到的。

作为我们最终的网络层,我们加入了来自正态分布的随机性,以模拟阿莱托里克不确定性。相反,我们也可以假设这种变异性是已知的。

另请参阅

还有其他探索途径,比如库或额外的材料,我们将在这里列出。

在线可以找到类似的问题,比如以下内容:

由于版权限制,我们不能在这里使用这些内容。

至于库,我们建议您看看这些:

至于教程,Open Risk Manual 提供了关于使用 Python 进行信用评分的开放资源:www.openriskmanual.org/wiki/Credit_Scoring_with_Python.

NumPyro 为离婚率提供了贝叶斯回归的教程:pyro.ai/numpyro/bayesian_regression.html#Regression-Model-to-Predict-Divorce-Rate

第五章:启发式搜索技术和逻辑推理

在本章中,我们将介绍一系列问题解决工具。我们将从本体论和基于知识的推理开始,然后转向布尔可满足性 (SAT)和组合优化的优化,其中我们将模拟个体行为和社会协调的结果。最后,我们将实现蒙特卡洛树搜索以找到国际象棋中的最佳着法。

在本章中,我们将涉及各种技术,包括逻辑求解器、图嵌入、遗传算法 (GA)、粒子群优化 (PSO)、SAT 求解器、模拟退火 (SA)、蚁群优化、多主体系统和蒙特卡洛树搜索。

在本章中,我们将涵盖以下配方:

  • 基于知识做出决策

  • 解决 n 皇后问题

  • 查找最短的公交路线

  • 模拟疾病的传播

  • 编写带有蒙特卡洛树搜索的国际象棋引擎

让我们开始吧!

基于知识做出决策

当关于一个主题有大量背景知识可用时,为什么不在做出决策时使用它?这被称为基于知识的系统。专家系统中的推理引擎和逻辑求解器中的统一化就是其中的例子。

在做出决策时,另一种检索知识的方式是基于在图中表示知识。图中的每个节点表示一个概念,而每条边表示一种关系。两者都可以嵌入并表示为表达它们与图中其他元素位置关系的数值特征。

在本配方中,我们将为每种可能性举例两次。

从亚里士多德到林奈再到今天的数学家和物理学家,人们试图通过将对象分类到系统顺序中来对世界进行排序,这被称为分类学。在数学上,分类法被表示为图,它表示信息作为元组(s, o),其中主题 s 与对象 o 相连;或者三元组(s, p, o),其中主题 a 与谓词 p 相关联到 o。经常使用的一种类型是 ISA 分类法,其中关系为 is-a 类型。例如,汽车是车辆,飞机也是车辆。

准备就绪

在这个配方中,我们将使用从 Python 的nltk (自然语言工具包)库接口的逻辑求解器,然后使用被称为networkxkarateclub的图库。

您需要使用的pip命令如下:

pip install nltk karateclub networkx

对于这个配方的第二部分,我们还需要从 Kaggle 下载动物园数据集,可在www.kaggle.com/uciml/zoo-animal-classification获取。

如何做到...

正如我们在这个配方的介绍中解释的那样,我们将从两种不同的方法来看待两个不同的问题。

我们将从使用逻辑求解器开始进行逻辑推理。

逻辑推理

在这个配方的这一部分,我们将使用nltk库捆绑的一些库来简单展示逻辑推理的一个示例。还有许多其他方法可以处理逻辑推理,我们将在配方末尾的参考资料部分中看到一些。

我们将使用一个非常简单的玩具问题,你可以在任何101 – 逻辑入门书籍中找到,尽管解决这类问题的方法可以更复杂。

我们的问题是众所周知的:如果所有人类都是可死的,苏格拉底是一个人类,那么苏格拉底是可死的吗?

我们可以在nltk中非常自然地表达这个过程,如下所示:

from nltk import *
from nltk.sem import Expression

p1 = Expression.fromstring('man(socrates)')
p2 = Expression.fromstring('all x.(man(x) -> mortal(x))')
c = Expression.fromstring('mortal(socrates)')
ResolutionProver().prove(c, [p1, p2], verbose=True)

前面的代码给出了以下输出:

[1] {-mortal(socrates)}     A 
[2] {man(socrates)}         A 
[3] {-man(z2), mortal(z2)}  A 
[4] {-man(socrates)}        (1, 3) 
[5] {mortal(socrates)}      (2, 3) 
[6] {}                      (1, 5) 
True

求解器提供的推理也可以很自然地阅读,所以我们不会在这里解释这个过程。我们将在如何工作...部分中学习其内部工作原理。

接下来,我们将看看知识嵌入。

知识嵌入

在这个配方的这一部分,我们将尝试利用信息如何相互关联,将其嵌入到一个可以作为特征化一部分的多维空间中。

这里,我们将加载数据,预处理数据,嵌入数据,然后通过对其新特征进行分类来测试我们的嵌入效果。让我们开始吧:

  1. 数据集加载和预处理:首先,我们将像之前那样将动物园数据集加载到 pandas 中。然后,我们将确保将二进制列表示为bool而不是int
import pandas as pd
zoo = pd.read_csv('zoo.csv')
binary_cols = zoo.columns[zoo.nunique() == 2]
for col in binary_cols:
    zoo[col] = zoo[col].astype(bool)
labels = [
    'Mammal', 'Bird', 'Reptile',
    'Fish', 'Amphibian', 'Bug',
    'Invertebrate'
]
training_size = int(len(zoo) * 0.8)

动物园数据集包含 101 种动物,每种动物都有描述其是否有毛发或产奶等特征。这里,目标类别是动物的生物学类别。

  1. 图嵌入get_triplet()函数以(s, p, o)的格式返回二进制和整数元素的三元组。注意,我们是从完整数据集中创建三元组,而不仅仅是训练集。但是,为了避免目标泄漏,我们不会从训练集外的数据点创建三元组:
all_labels = { i+1: c for i, c in enumerate(labels) }
cols = list(zoo.columns)

triplets = []
def get_triplet(row, col):
    if col == 'class_type':
        return (
            all_labels[row[col]],
            'is_a',
            row['animal_name'],
        )
    # int properties:
    if col in ['legs']:
        #if row[col] > 0:
        return (
            row['animal_name'],
            'has' + col,
            str(row[col]) + '_legs'
        )
        #else:
        # return ()
    # binary properties:
    if row[col]:
        return (
            row['animal_name'],
            'has',
            str(col)
        )
    else:
        return ()

for i, row in zoo.iterrows():
    for col in cols:
        if col == 'animal_name':
            continue
        if col == 'class_type' and i > training_size:
                continue
        triplet = get_triplet(row, col)
        if triplet:
            triplets.append(triplet)

前面的代码将创建我们的三元组。让我们看一些示例,了解它们的样子。以下是我们得到的前 20 个条目;我们使用triplets[:20]来获取它们:

[('aardvark', 'has', 'hair'),
 ('aardvark', 'has', 'milk'),
 ('aardvark', 'has', 'predator'),
 ('aardvark', 'has', 'toothed'),
 ('aardvark', 'has', 'backbone'),
 ('aardvark', 'has', 'breathes'),
 ('aardvark', 'haslegs', '4_legs'),
 ('aardvark', 'has', 'catsize'),
 ('Mammal', 'is_a', 'aardvark'),
 ('antelope', 'has', 'hair'),
 ('antelope', 'has', 'milk'),
 ('antelope', 'has', 'toothed'),
 ('antelope', 'has', 'backbone'),
 ('antelope', 'has', 'breathes'),
 ('antelope', 'haslegs', '4_legs'),
 ('antelope', 'has', 'tail'),
 ('antelope', 'has', 'catsize'),
 ('Mammal', 'is_a', 'antelope'),
 ('bass', 'has', 'eggs'),
 ('bass', 'has', 'aquatic')]

前面的代码块展示了一些结果三元组的示例。总共,我们从 101 行中得到了 842 个三元组。

现在,我们可以使用networkx API 将这个数据集加载到图中:

import networkx as nx

class Vocabulary:
    label2id = {}
    id2label = {}
    def lookup(self, word):
        """get word id; if not present, insert"""
        if word in self.label2id:
            return self.label2id[word]
        ind = len(self.label2id)
        self.label2id[word] = ind
        return ind

    def inverse_lookup(self, index):
        if len(self.id2label) == 0:
            self.id2label = {
                ind: label
                for label, ind in self.label2id.items()
            }
        return self.id2label.get(index, None)

vocab = Vocabulary()
nx_graph = nx.Graph()
for (a, p, b) in triplets:
    id1, id2 = vocab.lookup(a), vocab.lookup(b)
    nx_graph.add_edge(id1, id2)

Vocabulary类是label2idid2label字典的包装器。我们需要这个类是因为一些图嵌入算法不接受节点或关系的字符串名称。在这里,我们在将概念标签存储到图中之前将其转换为 ID。

现在,我们可以用不同的算法对图进行数值嵌入。这里我们将使用Walklets

from karateclub.node_embedding.neighbourhood import Walklets

model_w = Walklets(dimensions=5)
model_w.fit(nx_graph)
embedding = model_w.get_embedding()

前面的代码显示了图中每个概念将由一个 5 维向量表示。

现在,我们可以测试这些特征是否对预测目标(动物)有用:

trainamals = [
    vocab.label2id[animal]
    for animal in zoo.animal_name.values[:training_size]
]
testimals = [
    vocab.label2id[animal]
    for animal in zoo.animal_name.values[training_size:]
]
clf = SVC(random_state=42)
clf.fit(embedding[trainamals, :], zoo.class_type[:training_size])

test_labels = zoo.class_type[training_size:]
test_embeddings = embedding[testimals, :]
print(end='Support Vector Machine: Accuracy: ')
print('{:.3f}'.format(
  accuracy_score(test_labels, clf.predict(test_embeddings)
))
print(confusion_matrix(test_labels, clf.predict(test_embeddings)))

输出如下所示:

Support Vector Machine: Accuracy = 0.809
[[5 0 0 0 0 0 0]
 [0 4 0 0 0 0 0]
 [2 0 0 1 0 0 0]
 [0 0 0 3 0 0 0]
 [1 0 0 0 0 0 0]
 [0 0 0 0 0 2 0]
 [0 0 0 0 0 0 3]]

看起来很不错,尽管这项技术只有在我们拥有超越训练集的知识库时才会变得真正有趣。在不加载数百万个三元组或庞大图表的情况下,很难展示图嵌入。我们将在接下来的小节中提到一些大型知识库。

工作原理...

在本节中,我们将首先涵盖逻辑推理和逻辑证明器,然后再看知识嵌入和 Walklets 图嵌入的基本概念。

逻辑推理

逻辑推理是一个涵盖逻辑推断技术如演绎、归纳和引导的术语。引导推理,经常在专家系统中使用,是从现有观察中检查并推导可能结论(最佳解释)的过程。

专家系统是一种模拟人类专家决策能力的推理系统。专家系统通过推理处理知识体系,主要以 if-then-else 规则表示(这称为知识库)。

归纳推理 是在遵循初始前提和规则的情况下确定结论。在演绎推理 中,我们从观察中推断出一个规则。

要应用逻辑推理,语言陈述必须编码为良好形式的逻辑公式,以便我们可以应用逻辑演算。良好形式的公式可以包含以下实体:

  • 断言符号如 P

  • 等号符号,

  • 否定,

  • 二元连接词如

  • 量词如 (对于所有)和 (存在)。

例如,推理Socrates 是一个人。人是有限的。因此,苏格拉底是有限的,可以用命题逻辑的逻辑陈述来表达,如下:

接下来,我们将看一下逻辑证明器。

逻辑证明器

自动定理证明是一个广泛的领域,包括基于逻辑定理和数学公式的工作。我们已经看过证明由逻辑等式组成的一阶逻辑方程的问题。搜索算法与逻辑方程结合,以便确定命题公式的可满足性(参见本章中的Solving the n-queens problem配方),以及在给定一组公理的情况下句子的有效性nltk中的解析定理证明器提供了其他功能,如统一化、包含、以及问答QA):www.nltk.org/howto/resolution.html

在下一小节中,我们将看一下知识嵌入。

知识嵌入

知识嵌入KE)指的是从概念关系中导出的分布式表示。这些通常在知识图谱KG)中表示。

知识图谱的一个著名示例是WordNet(G.A. Miller 等人,《WordNet:一个在线词汇数据库》;1990 年),它提供了单词的同义词、上义词和其他扩展,类似于一本词典,并且在所有主要操作系统中都有不同的 GUI 和适当的命令行。WordNet 提供了 200 多种语言的版本,每个词(synset)都通过定向语义关系与其他词相关联,如上义词或下义词、部分或整体关系等。

知识图谱可以在自然语言处理NLP)应用中使用,以支持决策,它们可以有效地作为查找引擎或推理的工具。

知识嵌入是概念关系的低维表示,可以使用嵌入或更通用的降维方法提取。在下一小节中,我们将看看 Walklet 嵌入方法。

用 Walklets 进行图嵌入

Walklet 算法基本上将 Word2Vec skipgram 算法应用于图中的顶点,因此我们将根据它们的连接而不是单词(Word2Vec 的原始应用)获得概念的嵌入。Walklet 算法在图的顶点上对短随机行走进行子采样,作为路径传递给浅层神经网络(见下图),用于 skipgram 训练。

skipgram 算法(Mikolov 等人,2013 年;arxiv.org/abs/1301.3781)根据单词本身预测单词(即顶点)的上下文。每个单词被表示为连续的词袋向量(实际上,每个单词都在我们使用的字典中得到索引),我们预测基于隐藏层投影的周围单词(概念)的索引。该隐藏层投影的维度和上下文的窗口大小是算法的主要参数。训练后,我们使用隐藏层作为嵌入。

下图说明了 skipgram 网络架构,包括输入层、隐藏层和单词预测的输出层:

w(t)指的是当前单词(或概念),而w(t-2)w(t-1)w(t+1)w(t+2)指的是当前单词之前和之后的两个单词。我们根据当前单词预测单词上下文。正如我们已经提到的,上下文的大小(窗口大小)是 skipgram 算法的超参数。

一个相关的算法是连续词袋算法CBOW),其中架构被倒置 - 我们根据上下文预测单个词。两者都基于这样一个假设:共同出现的词具有相关的含义或分布相似性,这意味着它们在意义上是相似的。这被称为分布假设(Harris, 1954, Distributional structure)。

Walklet 算法在大型图上表现良好,并且由于它是神经网络,可以在线训练。关于 Walklets 的更多信息可以在 Brian Perozzi 等人的 2017 年论文Don't Walk, Skip! Online Learning of Multi-scale** Network Embeddings中找到(arxiv.org/abs/1605.02115)。

另见

下面是用于 Python 中逻辑推理的库:

我们一直在nltk的推理指南中遵循推理的指导。你可以在官方nltk网站找到更多工具:www.nltk.org/howto/inference.html

一些用于图嵌入的其他库如下:

KarateClub 由爱丁堡大学的博士生 Benedek Rozemberczki 维护,包含许多无监督图嵌入算法的实现。

一些图库还提供链路预测。这意味着对于给定的节点集合,您可以推断是否存在与其他节点的关系。关于链路预测的评论可以在 Andrea Rossi 等人的论文Knowledge Graph Embedding for Link Prediction: A Comparative Analysis中找到(2020; arxiv.org/abs/2002.00819)。

一些关于推理真实世界和/或常识的资源如下:

Learning on graphs: Open Graph Benchmark: Datasets for Machine Learning on Graphs, Hu and others, 2020 (arxiv.org/pdf/2005.00687.pdf) 是关于使用机器学习进行图形嵌入的另一个参考文献。

还有几个大型的现实世界知识数据库可供使用,例如以下内容:

解决 n-皇后问题

在数理逻辑中,可满足性是关于一个公式在某些解释(参数)下是否有效的问题。如果一个公式在任何解释下都不能成立,我们称其为不可满足。布尔可满足性问题(SAT)是关于一个布尔公式在其参数的任何值下是否有效(可满足)的问题。由于许多问题可以归约为 SAT 问题,并且存在针对它的求解器和优化方法,SAT 问题是一个重要的问题类别。

SAT 问题已被证明是 NP 完全的。NP 完全性(缩写为非确定性多项式时间)意味着问题的解决方案可以在多项式时间内验证。请注意,这并不意味着可以快速找到解决方案,只是可以快速验证解决方案。NP 完全问题通常使用搜索启发式和算法来解决。

在这个配方中,我们将以多种方式解决 SAT 问题。我们将以一个相对简单且深入研究的案例来解释,即 n-皇后问题,其中我们尝试在一个nn的棋盘上放置皇后,以使得任何列、行和对角线最多只能放置一个皇后。

首先,我们将应用遗传算法(GA),然后是粒子群优化(PSO),最后使用专门的 SAT 求解器。

准备工作

我们在这个配方中的一个方法中将使用dd求解器。要安装它,我们还需要omega库。我们可以使用pip命令获取这两个库,如下所示:

pip install dd omega

我们稍后将使用dd SAT 求解器库,但首先我们将研究一些其他的算法方法。

如何做...

我们将从遗传算法(GA)开始。

遗传算法

首先,我们将定义染色体的表示方式和如何进行变异。然后,我们将定义一个反馈循环来测试和改变这些染色体。我们将在最后的工作原理部分详细解释算法本身。让我们开始吧:

  1. 表示解决方案(一个染色体):面向对象的风格适合定义染色体。让我们看看我们的实现。首先,我们需要知道染色体是什么以及它的作用:
import random
from typing import Optional, List, Tuple

class Chromosome:
    def __init__(self, configuration: Optional[List]=None, nq: Optional[int]=None):
        if configuration is None:
            self.nq = nq
            self.max_fitness = np.sum(np.arange(nq))
            self.configuration = [
                random.randint(1, nq) for _ in range(nq)
            ]
        else:
            self.configuration = configuration
            self.nq = len(configuration)
            self.max_fitness = np.sum(np.arange(self.nq))

    def fitness(self):
        return cost_function(self.configuration) / self.max_fitness

    def mutate(self):
        ind = random.randint(0, self.nq-1)
        val = random.randint(1, self.nq)
        self.configuration[ind] = val

上述代码创建了我们的基本数据结构,其中包含一个候选解决方案,可以复制和突变。此代码涉及成本函数。

我们需要一个成本函数,以便知道如何适应我们的基因:

def cost_function(props):
    res = 0
    for i1, q1 in enumerate(props[:-1]):
        for i2, q2 in enumerate(props[i1+1:], i1+1):
            if (q1 != q2) and (abs(i1 - i2) != abs(q1 - q2)):
                res += 1
    return res

我们可以根据这个成本函数(见fitness()方法)选择基因。

  1. 编写主要算法:N 皇后问题的 GA 如下(我们在此省略了可视化):
class GeneticQueen:
    def __init__(self, nq, population_size=20, mutation_prob=0.5):
        self.nq = nq
        self.population_size = population_size
        self.mutation_prob = mutation_prob
        self.population = [Chromosome(nq=nq) for _ in range(population_size)]
        self.solution = None
        self.best_fitness = None

    def iterate(self):
        new_population = []
        best_fitness = -1
        for i in range(len(self.population)):
            p1, p2 = self.get_parents()
            child = Chromosome(self.cross_over(p1, p2))
            if random.random() < self.mutation_prob:
                child.mutate()
            new_population.append(child)
            fit = child.fitness()
            if fit > best_fitness:
                best_fitness = fit
            if fit == 1:
                self.solution = child
                break                
        self.best_fitness = best_fitness
        self.population = new_population

    def cross_over(self, p1, p2):
        return [
            yi
            if random.random() > 0
            else xi
            for xi, yi in zip(
                p1.configuration,
                p2.configuration
            )
        ]

    def get_parents(self) -> Tuple[Chromosome, Chromosome]:
        weights = [chrom.fitness() for chrom in self.population]
        return tuple(
            random.choices(
                self.population, 
                weights=weights,
                k=2
            )
        )

该类包含染色体的种群,并可以对其应用方法(如果您喜欢的话,如get_parents()cross_over())。请注意iterate()方法,在这里实现了主要逻辑。我们将在它的工作原理...部分对我们在这里做出的主要决策进行评论。

  1. 运行算法:我们通过简单地实例化一个GeneticQueen并调用iterate()来执行我们的算法。我们还可以添加几行额外的代码来定期更新并随时间收集适应性数据。然后,我们像这样运行算法:
def ga_solver(nq):
    fitness_trace = []
    gq = GeneticQueen(nq=nq)
    generation = 0
    while not gq.solution:
        gq.iterate()
        if (generation % 100) == 0:
            print('Generation {}'.format(generation))
            print('Maximum Fitness: {:.3f}'.format(gq.best_fitness))
        fitness_trace.append(gq.best_fitness)
        generation += 1

    gq.visualize_solution()
    return fitness_trace

最后,我们可以可视化解决方案。

如果我们运行上述代码,将得到一个看起来像这样的单次运行结果(您的结果可能会有所不同):

Generation 0
Maximum Fitness: 0.857
Generation 100
Maximum Fitness: 0.821
Generation 200
Maximum Fitness: 0.892
Generation 300
Maximum Fitness: 0.892
Generation 400
Maximum Fitness: 0.892

上述代码给出了以下输出:

这个操作接近 8 秒才完成。

下图显示了算法每次迭代中最佳染色体的适应性:

在这里,我们可以看到算法的适应性并不总是改善;它也可能下降。我们本可以选择在此处保留最佳染色体。在这种情况下,我们不会看到任何下降(但潜在的缺点是我们可能会陷入局部最小值)。

现在,让我们继续 PSO!

粒子群优化

在这个配方的这一部分,我们将从头开始实现 N 皇后问题的 PSO 算法。让我们开始吧:

  1. 表示解决方案:与 GA 类似,我们需要定义解决方案的外观。在 PSO 中,这意味着我们定义一个粒子:
class Particle:
    best_fitness: int = 0

    def __init__(
        self, N=None, props=None,
        velocities=None
    ):
        if props is None:
            self.current_particle = np.random.randint(0, N-1, N)
            self.best_state = np.random.randint(0, N-1, N)
            self.velocities = np.random.uniform(-(N-1), N-1, N)
        else:
            self.current_particle = props
            self.best_state = props
            self.velocities = velocities

        self.best_fitness = cost_function(self.best_state)

    def set_new_best(self, props: List[int], new_fitness: int): 
        self.best_state = props
        self.best_fitness = new_fitness

    def __repr__(self):
        return f'{self.__class__.__name__}(\n' +\
            f'\tcurrent_particle={self.current_particle}\n' +\
            f'\best_state={self.best_state}\n' +\
            f'\tvelocities={self.velocities}\n' +\
            f'\best_fitness={self.best_fitness}\n' +\
            ')'

这是我们将要处理的主数据结构。它包含一个候选解决方案。应用 PSO 将涉及更改一堆这些粒子。我们将在它的工作原理...部分详细解释Particle的工作原理。

我们将使用与我们为 GA 定义的相同成本函数。该成本函数告诉我们我们的粒子如何适应给定问题 - 换句话说,一个性质向量有多好。

我们将初始化和主算法封装到一个类中:

class ParticleSwarm:
  def __init__(self, N: int, n_particles: int,
    omega: float, phip: float, phig: float
  ):
    self.particles = [Particle(N=N) for i in range(n_particles)]
    self.omega = omega
    self.phip = phip
    self.phig = phig

  def get_best_particle(self):
    best_particle = 0
    best_score = -1
    score = -1
    for i, particle in enumerate(self.particles):
        score = cost_function(particle.current_particle)
        if score > best_score:
            best_score = score
            best_ind = i
    return self.particles[best_ind].current_particle, best_score

  def iterate(self):
    for particle in self.particles:
      rg = np.random.rand((N))
      rp = np.random.rand((N))
      delta_p = particle.best_state - particle.current_particle
      delta_g = best_particle - particle.current_particle
      update = (rp * self.phip * delta_p +
        \ rg * self.phig * delta_g)  # local vs global
      particle.velocities = self.omega * particle.velocities + update
      particle.current_particle = (np.abs(
        particle.current_particle + particle.velocities
      ) % N ).astype(int)  # update the particle best
      current_fitness = cost_function(particle.current_particle)
      if current_fitness > particle.best_fitness:
        particle.set_new_best(
          particle.current_particle, current_fitness
        )
        particle_candidate, score_candidate = get_best_particle(particles)
        if best_score_cand > best_score:
          best_particle = particle_candidate
          best_score = score_candidate
    return best_particle, best_score

get_best_particle()方法返回最佳配置和最佳分数。请注意iterate()方法,它更新我们的粒子并返回最佳粒子及其分数。关于此更新的详细信息在工作原理...部分提供。优化过程本身使用几个相对简单的公式完成。

我们还想展示我们的解决方案。显示棋盘位置的代码如下:

import chess
import chess.svg
from IPython.display import display

def show_board(queens):
    fen = '/'.join([queen_to_str(q) for q in queens])
    display(chess.svg.board(board=chess.Board(fen), size=300))

下面是 PSO 的主算法:

def particle_swarm_optimization(
    N: int, omega: float, phip: float, phig: float,
    n_particles: int, visualize=False, max_iteration=999999
) -> List[int]:
  def print_best():
    print(f'iteration {iteration} - best particle: {best_particle}, score: {best_score}')
  solved_cost = np.sum(np.arange(N))
  pso = ParticleSwarm(N, n_particles, omega, phip, phig)
  iteration = 0
  best_particle, best_score = get_best_particle(particles)
  scores = [best_score]
  if visualize:
    print('iteration:', iteration)
    show_board(best_particle)
  while best_score < solved_cost and iteration < max_iteration:
    if (iteration % 500) == 0 or iteration == 0:
      print_best()
      best_particle, best_score = pso.iterate()
    if iteration > 0 and visualize:
      print('iteration:', iteration)
      show_board(best_particle)
    scores.append(best_score)
    iteration += 1
  print_best()
  return best_particle, scores

类似于我们在 GA 案例中所做的,我们追踪解决方案在迭代中的表现(通过我们的成本函数)。主函数返回以下内容:

  • best_particle:最佳解决方案

  • scores:我们迭代中的最佳分数

正如我们之前提到的,我们将在工作原理...部分解释所有这些内容的工作方式。

您可以查看使用n = 8运行的算法输出,网址为github.com/PacktPublishing/Artificial-Intelligence-with-Python-Cookbook/blob/master/chapter05/solving-n-queens.md

我们在这里使用棋盘库进行可视化。

在下面的图表中,您可以看到解决方案在迭代中的质量:

由于所有粒子都保持其最佳解的记录,分数永远不会下降。在第 1,323 次迭代时,我们找到了一个解决方案,算法停止了。

SAT 求解器

这主要基于可以在dd库中找到的示例,版权属于加州理工学院,网址为github.com/tulip-control/dd/blob/0f6d16483cc13078edebac9e89d1d4b99d22991e/examples/queens.py

在 Python 中的现代 SAT 求解器中,我们可以将约束定义为简单的函数。

基本上,有一个公式包含所有约束条件。一旦所有约束条件满足(或所有约束条件的合取),就找到了解决方案:

def queens_formula(n):
    present = at_least_one_queen_per_row(n)
    rows = at_most_one_queen_per_line(True, n)
    cols = at_most_one_queen_per_line(False, n)
    slash = at_most_one_queen_per_diagonal(True, n)
    backslash = at_most_one_queen_per_diagonal(False, n)
    s = conj([present, rows, cols, slash, backslash])
    return s

这是at_least_one_queen_per_row的约束条件:

def at_least_one_queen_per_row(n):
    c = list()
    for i in range(n):
        xijs = [_var_str(i, j) for j in range(n)]
        s = disj(xijs)
        c.append(s)
    return conj(c)

在这里,我们对每行上的皇后进行析取。

主运行如下所示:

def benchmark(n):
    t0 = time.time()
    u, bdd = solve_queens(n)
    t1 = time.time()
    dt = t1 - t0

    for i, d in enumerate(bdd.pick_iter(u)):
        if len(d) > 0:
            visualize_solution(d)
            break
    n_solutions = bdd.count(u)

    s = (
        '------\n'
        'queens: {n}\n'
        'time: {dt} (sec)\n'
        'node: {u}\n'
        'total nodes: {k}\n'
        'number solutions: {n_solutions}\n'
        '------\n'
    ).format(
        n=n, dt=dt, u=u, k=len(bdd),
        n_solutions=n_solutions,
    )
    print(s)
    return dt

当我们运行此代码时,应该看到一个示例解决方案。我们还应该得到一些关于找到多少解决方案以及花费多长时间找到它们的统计信息。

下面是八皇后问题的示例解决方案:

文本输出如下所示:

queens: 8
time: 4.775595426559448 (sec)
node: -250797
total nodes: 250797
number solutions: 92

此求解器不仅获得了所有的解决方案(我们只显示了其中一个),而且比遗传算法快大约两倍!

工作原理...

在本节中,我们将解释在此配方中使用的不同方法,从遗传算法开始。

遗传算法

在本质上,遗传算法很简单:我们维护一组候选解决方案(称为染色体),并且我们有两种操作可以用来改变它们:

  • cross-over:两个染色体产生子代(这意味着它们混合)

  • mutation: 染色体随机变化

一个染色体存储在configuration中的候选解决方案。在初始化染色体时,我们必须给它皇后的数量或者初始配置。在本章的前文中,我们已经讨论了染色体的实际含义。如果没有给定配置,则需要使用列表推导创建一个,比如[random.randint(1, nq) for _ in range(nq)]

一个染色体可以计算自己的适应度;在这里,我们使用了先前使用的相同成本函数,但这次我们将其缩放到 0 到 1 之间,其中 1 表示我们找到了一个解决方案,介于其中的任何值显示我们距离解决方案有多接近。染色体也可以对自己进行突变;也就是说,它可以随机改变其值之一。

算法的每一次迭代,我们都通过这两个操作创建新的染色体代。

  1. 首先,我们使用代表解决方案不同参数的不同值初始化我们的第一代染色体。

  2. 然后,我们计算我们的染色体的适应度。这可以通过与环境的交互来完成,或者它可能是解决方案本身固有的,就像我们的九皇后问题的组合问题一样。

  3. 接下来,我们按以下方式创建新的染色体代:

    • 在考虑其适应度的情况下选择父母

    • 根据一定的概率突变几个染色体

  4. 最后,我们从第 2 步开始重复,直到适应度足够高或者我们已经迭代了很多次。

我们在这里非常宽泛地表达了最后一步。基本上,我们可以决定何时适应度足够高以及我们想要迭代多少次。这些是我们的停止标准。

这在我们的GeneticQueen.iterate()的实现中非常清晰,因此为了可视化目的,让我们再看一眼(仅稍微简化):

def iterate(self):
  new_population = []
  for i in range(len(self.population)):
    p1, p2 = self.get_parents()
    child = Chromosome(self.cross_over(p1, p2))
    if random.random() < self.mutation_prob:
      child.mutate()
    new_population.append(child)

关于遗传算法(GA),我们必须做出的一个重要决策是是否保留最佳解决方案,或者是否所有染色体(包括最佳的)都必须死亡(可能在产生后代后)。在这里,每次迭代都会创建一个全新的代。

我们通过按其适应度加权随机选择父母,其中适应度最高的被选择的可能性更大。在我们的实现中,cross-over函数会随机在每个参数中的两个父母之间做出决策。

为 GA 必须做出的主要超参数和主要决策如下:

  • 种群大小(我们有多少染色体?)

  • 变异率(染色体变异时变化的程度是多少?)

  • 多少(以及哪些)染色体产生后代?通常这些是具有最高适应度的染色体。

  • 我们的停止标准是什么?通常,算法的适应度有一个阈值,并且有一个设定的迭代次数。

正如我们所见,遗传算法非常灵活且直观。在接下来的部分,我们将看看 PSO。

粒子群优化(Particle Swarm Optimization,PSO)

我们以Particle数据结构开始我们的实现。要初始化一个粒子,我们传入皇后数量(N)或者我们的速度和参数向量。基本上,一个粒子有一个配置,或者说一组参数 - 在这种情况下是一个向量,它与问题的某个程度匹配(current_particle),以及一个速度(类似于学习率)。每个粒子的属性向量表示皇后的位置。

PSO 然后以特定的方式对粒子应用变化。PSO 结合了局部搜索和全局搜索;也就是说,在每个粒子处,我们试图将搜索引导向全局最佳粒子和过去最佳粒子。一个粒子保持其最佳实例的记录;也就是说,其最佳参数的向量和相应的得分。我们还保持参数的相应速度。这些速度可以根据正在使用的公式而减慢、增加或改变方向。

PSO 需要一些参数,如下所示(大多数这些在我们的实现中已命名;这里省略了那些特定于我们的九皇后问题的参数):

  • omega:衰减参数

  • phip:控制局部搜索的贡献

  • phig:控制全局搜索的贡献

  • n_particles:粒子的数量

  • max_iterations:用于没有解决方案的提前停止

在我们的 PSO 问题中,有两个增量,delta_pdelta_g,其中 pg 分别代表粒子(particle)和全局(global)。这是因为其中一个是根据粒子的历史最佳计算的,另一个是根据粒子的全局最佳计算的。

更新根据以下代码计算:

delta_p = particle.best_state - particle.current_particle
delta_g = best_particle - particle.current_particle
update = (rp * phip * delta_p +\
 rg * phig * delta_g) # local vs global

这里,rprg 是随机数,phipphig 分别是局部和全局因子。它们分别指一个唯一的粒子或所有粒子,如delta_pdelta_g 变量所示。

还有另一个参数 omega,它调节当前速度的衰减。在每次迭代中,根据以下公式计算新的速度:

particle.velocities = omega * particle.velocities + update

接着,根据它们的速度递增粒子参数。

请注意,算法对于phipphigomega的选择非常敏感。

我们的成本函数(或好度函数)根据给定的皇后配置为每个粒子计算分数。这个配置被表示为在范围]0, N-1.中的索引列表对于每对皇后,函数检查它们是否在对角线、垂直或水平方向上重叠。每个不冲突的检查都给予一个点,因此最大的得分是![。这对于 8 皇后问题是 28。

SAT 求解器

有许多不同的专用可满足性SAT)求解器工作方式。Weiwei Gong 和 Xu Zhou(2017)的调查提供了对不同方法的广泛概述:aip.scitation.org/doi/abs/10.1063/1.4981999

我们在配方中使用的 dd 求解器,使用二进制决策图BDD)工作,这些图是由 Randal Bryant(基于图的布尔函数操作算法,1986 年)引入的。二进制决策图(有时称为分支程序)将约束表示为布尔函数,而不是其他编码方式,如否定范式。

在 BDD 中,一个算法或一组约束被表示为在维度为 n 的布尔域上的布尔函数,其评估为真或假:

这意味着我们可以将问题表示为二叉树或等效地表示为真值表。

为了说明这一点,让我们看一个例子。我们可以枚举所有关于我们的二进制变量(x1x2x3)的状态,然后得出一个最终状态,即 f 的结果。以下真值表总结了我们变量的状态,以及我们的函数评估:

x1 x2 x3 f
False False False False
False False True False
False True False False
False True True False
True False False True
True False True False
True True False True
True True True True

这对应于以下二叉树:

二叉树和真值表具有高度优化的库实现,因此它们可以运行非常快。这解释了我们如何如此快速地得到结果。

另见

Python 中还有许多其他 SAT 求解器,其中一些如下所示:

关于 SAT 求解器在解数独中的应用讨论可在此找到:codingnest.com/modern-sat-solvers-fast-neat-underused-part-1-of-n/

这里可以找到解决骑士和卫士问题的 Z3 示例: jamiecollinson.com/blog/solving-knights-and-knaves-with-z3/

寻找最短公交路线

寻找最短公交路线意味着寻找一条连接地图上点(公交车站)的路径。这是旅行推销员问题的一个实例。在本篇文章中,我们将通过不同的算法来解决寻找最短公交路线的问题,包括模拟退火和蚁群优化。

准备工作

除了像scipynumpy这样的标准依赖项外,我们还将使用scikit-opt库,该库实现了许多不同的群体智能算法。

群体智能是分散式、自组织系统的集体行为,这种行为在观察者眼中表现出明显的智能性。这个概念在基于人工智能的工作中被使用。自然系统,如蚂蚁群、鸟群、鹰的捕猎、动物群集和细菌生长,在全局层面展示出一定水平的智能,尽管蚂蚁、鸟类和鹰通常表现出相对简单的行为。受生物学启发的群体算法包括遗传算法、粒子群优化、模拟退火和蚁群优化。

我们可以使用pip安装scikit-opt,如下所示:

pip install scikit-opt

现在,我们准备解决旅行推销员问题。

如何做...

正如我们之前提到的,我们将以两种不同的方式解决最短公交路线问题。

首先,我们需要为公交车站创建一组坐标(经度,纬度)。问题的难度取决于站点的数量(N)。在这里,我们将N设置为15

import numpy as np
N = 15
stops = np.random.randint(0, 100, (N, 2))

我们还可以预先计算站点之间的距离矩阵,如下所示:

from scipy import spatial

distance_matrix = spatial.distance.cdist(stops, stops, metric='euclidean')

我们可以将这个距离矩阵输入到两个算法中以节省时间。

我们将从模拟退火开始。

模拟退火

在这个子节中,我们将编写我们的算法来寻找最短公交路线。这基于 Luke Mile 的 Python 实现的模拟退火,应用于旅行推销员问题:gist.github.com/qpwo/a46274751cc5db2ab1d936980072a134。让我们开始吧:

  1. 实现本身非常简短而简洁:
def find_tour(stops, distance_matrix, iterations=10**5):
    def calc_distance(i, j):
        """sum of distance to and from i and j in tour
        """
        return sum(
            distance_matrix[tour[k], tour[k+1]]
            for k in [j - 1, j, i - 1, i]
        )

    n = len(stops)
    tour = np.random.permutation(n)
    lengths = []
    for temperature in np.logspace(4, 0, num=iterations):
        i = np.random.randint(n - 1) # city 1
        j = np.random.randint(i + 1, n) # city 2
        old_length = calc_distance(i, j)
        # swap i and j:
        tour[[i, j]] = tour[[j, i]]
        new_length = calc_distance(i, j)
        if np.exp((old_length - new_length) / temperature) < np.random.random():  # bad swap
            tour[[i, j]] = tour[[j, i]]  # undo swap
            lengths.append(old_length)
        else:
            lengths.append(new_length) 
    return tour, lengths
  1. 接下来,我们需要调用算法,如下所示:
from scipy.spatial.distance import euclidean
tour, lengths = find_tour(
    stops, distance_matrix, iterations=1000000
)

这是最终的解决方案 – 路径如下所示:

我们还可以绘制算法的内部距离度量。请注意,这个内部成本函数在约 800,000 次迭代之前一直下降:

现在,让我们尝试蚁群优化算法。

蚁群优化

在这里,我们正在从库中加载实现。我们将在它的工作原理...部分解释细节:

from sko.ACA import ACA_TSP

def cal_total_distance(tour):
    return sum([
        distance_matrix[tour[i % N], tour[(i + 1) % N]]
        for i in range(N)
    ])

aca = ACA_TSP(
    func=cal_total_distance,
    n_dim=N,
    size_pop=N,
    max_iter=200,
    distance_matrix=distance_matrix
)
best_x, best_y = aca.run()

我们使用基于我们之前获取的点距离的距离计算(distance_matrix)。

再次,我们可以绘制最佳路径和路径距离随迭代次数的变化情况,如下所示:

再次,我们可以看到最终路径,这是我们优化的结果(左侧子图),以及随着算法迭代距离逐渐减少的路径(右侧子图)。

工作原理...

最短巴士路线问题是旅行商问题(TSP)的一个示例,而 TSP 又是组合优化的一个众所周知的示例。

组合优化是指使用组合技术来解决离散优化问题。换句话说,它是在一组对象中找到解决方案的行为。在这种情况下,“离散”意味着有限数量的选项。组合优化的智能部分在于减少搜索空间或加速搜索。旅行商问题、最小生成树问题、婚姻问题和背包问题都是组合优化的应用。

TSP 可以表述如下:给定要访问的城镇列表,找出遍历所有城镇并回到起点的最短路径是什么?TSP 在规划、物流和微芯片设计等领域有应用。

现在,让我们更详细地看一下模拟退火和蚁群优化。

模拟退火

模拟退火是一种概率优化技术。其名称来源于冶金学,其中加热和冷却用于减少材料中的缺陷。简单来说,在每次迭代中,可以发生状态转换(即变化)。如果变化成功,则系统会降低其温度。这可以重复进行,直到状态足够好或达到一定迭代次数为止。

在这个示例中,我们随机初始化了我们的城市旅游路线,然后进行了模拟退火的迭代。SA 的主要思想是,变化的速率取决于一定的温度。在我们的实现中,我们从 4 逻辑地降低了温度到 0。在每次迭代中,我们尝试交换(也可以尝试其他操作)路径(旅游路线)中两个随机巴士站点的索引 ij,其中 i < j,然后计算从 i-1i、从 ii+1、从 j-1j 和从 jj+1 的距离总和(见 calc_distance)。我们还需要一个距离度量来进行 calc_distance。我们选择了欧几里得距离,在这里,但我们也可以选择其他距离度量。

温度在我们需要决定是否接受交换时发挥作用。我们计算路径长度变化前后的指数差:

然后,我们生成一个随机数。如果这个随机数小于我们的表达式,我们就接受这个变化;否则,我们撤销它。

蚁群优化

正如其名称所示,蚁群优化 受到蚂蚁群体的启发。让我们使用蚂蚁分泌的信息素作为类比:这里,代理人具有候选解决方案,越接近解决方案,越有吸引力。

总体而言,蚂蚁编号 k 从状态 x 转移到状态 y 的概率如下:

Tau 是在 xy 之间沉积的信息素路径。eta 参数控制信息素的影响,其中 etabeta 次幂是状态转换(例如转换成本的倒数)。信息素路径根据包括状态转换在内的整体解决方案的好坏而更新。

在这里,scikit-opt 函数起到了重要作用。我们只需传递几个参数,如距离函数、点数、种群中的蚂蚁数量、迭代次数和距离矩阵,然后调用 run()

另请参阅

您也可以将此问题作为混合整数问题来解决。Python-MIP 库解决混合整数问题,您可以在 python-mip.readthedocs.io/en/latest/examples.html 找到 TSP 的示例。

TSP 也可以用 Hopfield 网络解决,如本教程所述:www.tutorialspoint.com/artificial_neural_network/artificial_neural_network_optimization_using_hopfield.htm。在这里讨论了一种布谷鸟搜索方法:github.com/Ashwin-Surana/cuckoo-search

scikit-opt 是一个强大的启发式算法库。它包括以下算法:

  • 差分进化

  • 遗传算法

  • 粒子群优化

  • 模拟退火

  • 蚁群算法

  • 免疫算法

  • 人工鱼群算法

scikit-opt 文档包含更多解决 TSP(旅行推销员问题)的例子:scikit-opt.github.io/scikit-opt/#/en/README?id=_22-genetic-algorithm-for-tsptravelling-salesman-problem。另一个类似于 scikit-opt 的库是 pyswarms,可以在 pyswarms.readthedocs.io/en/latest/index.html 找到。

正如我们在本文开头提到的,运输物流在 TSP 中有其独特的应用,甚至在其纯粹形式中。墨西哥拥有 30,000 辆公共汽车、小巴和面包车的数据集可以在 thelivinglib.org/mapaton-cdmx/ 找到。

模拟疾病传播

诸如天花、结核病和黑死病等大流行病,长期以来显著影响了人类群体。截至 2020 年,新冠肺炎正在全球范围内传播,关于如何在尽可能少的伤亡情况下控制病毒的政治和经济问题已广泛讨论。

关于新冠肺炎,对于自由主义者来说,瑞典曾一度成为无需封锁的典范,尽管未考虑到诸如高比例的单人户和社会距离的文化倾向等次要因素。最近,瑞典的死亡人数有所上升,其人均发病率是已记录的最高之一(www.worldometers.info/coronavirus/)。

在英国,最初的反应是依赖群体免疫,只有在其他国家已经实施封锁数周后才宣布封锁。由于无法应对,国民健康服务系统(NHS)使用临时床位并租用商业医院的床位。

多代理系统MAS)是由参与者(称为代理)组成的计算机模拟。这些个体代理可以根据启发式或基于强化学习作出响应。此外,这些代理相互响应以及对环境的响应的系统行为可以应用于研究以下主题:

  • 合作与协调

  • 分布式约束优化

  • 交流与协商

  • 分布式问题解决,尤其是分布式约束优化

在这个食谱中,一个相对简单的多代理模拟将展示不同的响应如何导致疫情的致命人数和传播方式上的差异。

准备就绪

我们将使用mesa多代理建模库来实现我们的多代理模拟。

用于此操作的pip命令如下:

pip install mesa

现在,我们已经准备好了!

如何做…

这个模拟基于 Maple Rain Research Co., Ltd.的工作。对于这个食谱,我们已经做了一些更改,引入了因素如医院床位和封锁政策,并且我们也改变了感染和活跃病例的计算方式。你可以在github.com/benman1/covid19-sim-mesa找到完整的代码。

声明:本食谱的目的不是提供医疗建议,我们也不是合格的医疗从业者或专家。

首先,我们将通过Person类来定义我们的代理:

class Person(Agent):
    def __init__(self, unique_id, model):
        super().__init__(unique_id, model)
        self.alive = True
        self.infected = False
        self.hospitalized = False
        self.immune = False
        self.in_quarantine = False  # self-quarantine
        self.time_infected = 0

此定义将代理定义为拥有健康和隔离状态的人。

我们仍然需要一些方法来改变其他属性的变化方式。我们不会详细介绍所有这些方法,只是介绍那些足以让你理解所有内容如何结合在一起的方法。我们需要理解的核心是代理在感染时做什么。基本上,在感染期间,我们需要了解代理是否会传染给其他人,是否会因感染而死亡,或者是否会康复:

    def while_infected(self):
        self.time_infected += 1
        if self.hospitalized:
            # stay in bed, do nothing; maybe die
            if self.random.random() < (
                    self.model.critical_rate *
                    self.model.hospital_factor
            ):
                # die
                self.alive = False
                self.hospitalized = False
                self.infected = False
                return
            self.hospitalized -= 1
            return
        if self.random.random() < (
            self.model.quarantine_rate /
            self.model.recovery_period
        ):
            self.set_quarantine()
        if not self.in_quarantine:
            self.infect_others()  # infect others in same cell
        if self.time_infected < self.model.recovery_period:
            if self.random.random() < self.model.critical_rate:
                if self.model.hospital_takeup:
                    self.hospitalized = self.model.hospital_period
                    self.set_quarantine()
                else:
                    self.alive = False # person died from infection
                    self.infected = False
        else:  # person has passed the recovery period so no longer infected
            self.infected = False
            self.quarantine = False
            if self.random.random() < self.model.immunity_chance:
                self.immune = True

在这里,我们可以看到几个在模型层面上定义的变量,例如self.model.critical_rateself.model.hospital_factorself.model.recovery_period。我们稍后会更详细地查看这些模型变量。

现在,我们需要一种方法让我们的代理记录它们的位置,这在mesa中被称为MultiGrid

    def move_to_next(self):
        possible_steps = self.model.grid.get_neighborhood(
            self.pos,
            moore=True,
            include_center=False
        )
        new_position = self.random.choice(possible_steps)
        self.model.grid.move_agent(self, new_position)

这是相对直接的。如果代理移动,它们只在它们的邻域内移动;也就是说,下一个相邻的单元。

被称为step()方法的入口方法在每个周期(迭代)都会被调用:

    def step(self):
        if self.alive:
            self.move()

如果代理活着,它们在每一步都会移动。这是它们移动时会发生的事情:

    def move(self):
        if self.in_quarantine or self.model.lockdown:
            pass
        else:
            self.move_to_next()
        if self.infected:
            self.while_infected()

这结束了我们的代理,也就是Person的主要逻辑。现在,让我们看看在模型层面上如何将所有内容整合在一起。这可以在model.py中的Simulation类中找到。

让我们看看代理是如何创建的:

    def create_agents(self):
        for i in range(self.num_agents):
            a = Person(i, self)
            if self.random.random() < self.start_infected:
                a.set_infected()
            self.schedule.add(a)
            x = self.random.randrange(self.grid.width)
            y = self.random.randrange(self.grid.height)
            self.grid.place_agent(a, (x, y))

上述代码创建了我们需要的代理数量。其中一些会根据start_infected参数被感染。我们还将这些代理添加到一个以网格形式组织的单元地图中。

我们还需要定义一些数据收集器,如下所示:

        def set_reporters(self):
            self.datacollector = DataCollector(
                model_reporters={
                    'Active Cases': active_cases,
                    'Deaths': total_deaths,
                    'Immune': total_immune,
                    'Hospitalized': total_hospitalized,
                    'Lockdown': get_lockdown,
                })

此字典列表中的变量在每个周期中都会追加,以便我们可以绘图或进行统计评估。例如,让我们看看active_cases函数是如何定义的:

def active_cases(model):
    return sum([
        1
        for agent in model.schedule.agents
        if agent.infected
    ])

当被调用时,该函数会迭代模型中的代理,并计算状态为infected的代理数量。

同样地,就像对Person一样,Simulation的主要逻辑在step()方法中,该方法推进模型一个周期:

    def step(self):
        self.datacollector.collect(self)
        self.hospital_takeup = self.datacollector.model_vars[
            'Hospitalized'
        ][-1] < self.free_beds
        self.schedule.step()
        if self.lockdown:
            self.lockdown -= 1
        else:
            if self.lockdown_policy(
                self.datacollector.model_vars['Active Cases'],
                self.datacollector.model_vars['Deaths'],
                self.num_agents
            ):
                self.lockdown = self.lockdown_period
        self.current_cycle += 1

让我们看看不同的封锁政策如何影响死亡和疾病的传播。

我们将在这些模拟中使用与之前相同的一组变量。我们设置它们以便它们大致对应于英国,按照 1/1,000 的因子:

scale_factor = 0.001
area = 242495  # km2 uk
side = int(math.sqrt(area)) # 492

sim_params = {
    'grid_x': side,
    'grid_y': side,
    'density': 259 * scale_factor, # population density uk,
    'initial_infected': 0.05,
    'infect_rate': 0.1,
    'recovery_period': 14 * 12,
    'critical_rate': 0.05,
    'hospital_capacity_rate': .02,
    'active_ratio': 8 / 24.0,
    'immunity_chance': 1.0,
    'quarantine_rate': 0.6,
    'lockdown_policy': lockdown_policy,
    'cycles': 200 * 12,
    'hospital_period': 21 * 12,
}

我们将在它是如何工作的……部分解释网格的动机。

封锁由lockdown_policy方法声明,该方法被传递给Simulation的构造函数。

首先,让我们看看在没有引入封锁的情况下的数据。如果我们的policy函数始终返回False,我们可以创建这个策略:

def lockdown_policy(infected, deaths, population_size):
    return 0

结果图显示了我们随时间收集的五个变量:

总体而言,我们有 8,774 例死亡。

在这里,我们可以看到随着这一政策早期解除封锁,多次感染的波动:

def lockdown_policy(infected, deaths, population_size):
  if (
    (max(infected[-5 * 10:]) / population_size) > 0.6
    and 
    (len(deaths) > 2 and deaths[-1] > deaths[-2])
 ):
 return 7 * 12
 return 0

当我们运行这个模拟时,我们得到完全不同的结果,如下所示:

在 250 个迭代周围的矩形形状显示了封锁的宣布时间(忽略比例或形状)。总体而言,我们可以看到这导致了 20663 人的死亡。这种极高的死亡率——远高于critical_rate参数的设定——由于免疫前再感染,已经设定为 5%。

让我们将这与一个非常谨慎的政策进行比较,即每次死亡率上升或者感染率在(大致)3 周内超过 20%时宣布封锁:

def lockdown_policy(infected, deaths, population_size):
    if infected[-1] / population_size > 0.2:
        return 21 * 12
    return 0

仅有一次封锁,我们得到了以下的图表,显示了大约 600 人的总死亡人数:

您可以更改这些参数或者调整逻辑,以创建更复杂和/或更真实的模拟。

更多关于原始工作的细节可以在线上找到(teck78.blogspot.com/2020/04/using-mesa-framework-to-simulate-spread.html)。

工作原理...

模拟非常简单:它由代理组成,并且在迭代(称为周期)中进行。每个代理代表人群的一部分。

在这里,某个群体被这种疾病感染。在每个周期(对应 1 小时)内,被感染者可以去医院(如果有空位)、死亡、或者朝着康复迈进。他们还可以进入隔离状态。在尚未康复、未隔离且尚未死亡的情况下,他们可以在与他们空间接近的人群中传播疾病。恢复时,代理可以获得免疫力。

在每个周期内,代理可以移动。如果他们不在隔离中或者国家实施了封锁,他们将会移动到一个新的位置;否则,他们将保持原地。如果一个人被感染,他们可以死亡、去医院、康复、传染给其他人,或者进入隔离状态。

根据死亡和感染率,可以宣布国家封锁,这是我们模拟的主要焦点:国家封锁的引入如何影响死亡人数?

我们需要考虑不同的变量。其中一个是人口密度。我们可以通过将我们的代理放在地图或网格上来引入人口密度,网格大小由grid_xgrid_y定义。infect_rate 参数必须根据网格大小和人口密度进行调整。

我们在这里需要考虑更多的参数,比如以下的参数:

  • initial_infected 是初始感染率。

  • recovery_period 声明了被感染后恢复所需的周期数(大致以小时计),0 表示永不恢复。

  • critical_rate 是在整个恢复期内患病者可能重症的比例,这意味着他们可能会去医院(如果可能的话)或者死亡。

  • hospital_capacity_rate 是全人口每人的医院床位数。我们通过在线搜索找到了这些信息(www.hsj.co.uk/acute-care/nhs-hospitals-have-four-times-more-empty-beds-than-normal/7027392.articlewww.kingsfund.org.uk/publications/nhs-hospital-bed-numbers)。

  • 还有 active_ratio 定义一个人的活跃程度;quarantine_rate 决定一个人不去医院而进行自我隔离的可能性;以及 immunity_chance ,在康复后相关。

  • 模拟将运行一定数量的 cycles,我们的封锁政策在 lockdown_policy 函数中声明。

Simulationstep() 方法中,我们进行了数据收集。然后,根据 free_beds 变量检查医院是否可以接收更多患者。接着,我们运行了代理器 self.schedule.step()。如果我们处于封锁状态,我们开始倒计时。封锁状态由 False 变量到 lockdown_period 变量设置(在 Python 的鸭子类型中有所改动)。

lockdown_policy() 函数确定国家封锁的持续时间,根据感染和死亡的人数随时间变化(列表)。在这里,0 意味着我们不宣布封锁。

还有更多...

由于模拟可能需要很长时间才能运行,尝试参数可能非常缓慢。而不是必须进行完整运行,然后才能看到是否产生预期效果,我们可以使用 matplotlib 的实时绘图功能。

为了获得更快的反馈,让我们实时绘制模拟循环,如下所示:

%matplotlib inline
from collections import defaultdict
from matplotlib import pyplot as plt
from IPython.display import clear_output

def live_plot(data_dict, figsize=(7,5), title=''):
    clear_output(wait=True)
    plt.figure(figsize=figsize)
    for label,data in data_dict.items():
        plt.plot(data, label=label)
    plt.title(title)
    plt.grid(True)
    plt.xlabel('iteration')
    plt.legend(loc='best')
    plt.show()

model = Simulation(sim_params)
cycles_to_run = sim_params.get('cycles')
print(sim_params)
for current_cycle in range(cycles_to_run):
    model.step()
    if (current_cycle % 10) == 0:
        live_plot(model.datacollector.model_vars)

print('Total deaths: {}'.format(
    model.datacollector.model_vars['Deaths'][-1]
))

这将持续(每 10 个周期)更新我们的模拟参数绘图。如果没有达到预期效果,我们可以中止它,而不必等待完整模拟。

另请参见

您可以在 mesa.readthedocs.io/en/master/ 找到有关 mesa 的基于 Python 的多智能体建模的更多信息。以下是一些其他的多智能体库:

其他模拟器也已发布,其中最突出的是 CovidSim 微模型(github.com/mrc-ide/covid-sim),由伦敦帝国学院全球传染病分析中心 MRC 开发。

使用蒙特卡洛树搜索编写国际象棋引擎

国际象棋是一种两人对弈的棋盘游戏,自 15 世纪以来作为智力游戏而广受欢迎。在 20 世纪 50 年代,计算机击败了第一个人类玩家(一个完全的新手),然后在 1997 年击败了人类世界冠军。此后,它们已经发展到拥有超人类的智能。编写国际象棋引擎的主要困难之一是搜索许多变化和组合并选择最佳策略。

在这个示例中,我们将使用蒙特卡洛树搜索来创建一个基本的国际象棋引擎。

准备好了

我们将使用python-chess库进行可视化,获取有效移动,并知道状态是否终止。我们可以使用pip命令安装它,如下所示:

pip install python-chess

我们将使用这个库进行可视化,生成每个位置的有效移动,并检查是否达到了最终位置。

如何实现...

本示例基于 Luke Miles 在gist.github.com/qpwo/c538c6f73727e254fdc7fab81024f6e1上对蒙特卡洛树搜索的最小实现。

首先,我们将查看我们将用来定义我们的树搜索类的代码,然后看看搜索是如何工作的。之后,我们将学习如何将其适应于国际象棋。

树搜索

树搜索是一种利用搜索树作为数据结构的搜索方法。通常情况下,在搜索树中,节点(或叶子)表示一个概念或情况,这些节点通过边(分支)连接。树搜索遍历树以得出最佳解决方案。

让我们首先实现树搜索类:

import random

class MCTS:
    def __init__(self, exploration_weight=1):
        self.Q = defaultdict(int)
        self.N = defaultdict(int)
        self.children = dict()
        self.exploration_weight = exploration_weight

我们将在它的工作原理...部分更详细地讨论这些变量。我们很快将向这个类添加更多方法。

我们的树搜索中的不同步骤在我们的do_rollout方法中执行:

    def do_rollout(self, node):
        path = self._select(node)
        leaf = path[-1]
        self._expand(leaf)
        reward = self._simulate(leaf)
        self._backpropagate(path, reward)

每个rollout()调用都会向我们的树中添加一层。

让我们依次完成四个主要步骤:

  1. select步骤找到一个叶节点,从该节点尚未启动模拟:
    def _select(self, node):
        path = []
        while True:
            path.append(node)
            if node not in self.children or not self.children[node]:
                return path
            unexplored = self.children[node] - self.children.keys()
            if unexplored:
                n = unexplored.pop()
                path.append(n)
                return path
            node = self._select(random.choice(self.children[node]))

这是递归定义的,因此如果我们找不到未探索的节点,我们就会探索当前节点的一个子节点。

  1. 扩展步骤添加子节点——即通过有效移动到达的节点,给定一个棋盘位置:
    def _expand(self, node):
        if node in self.children:
            return
        self.children[node] = node.find_children()

此函数使用后代(或子节点)更新children字典。这些节点是从当前节点通过单个移动可以到达的任何有效棋盘位置。

  1. 模拟步骤运行一系列移动,直到游戏结束:
    def _simulate(self, node):
        invert_reward = True
        while True:
            if node.is_terminal():
                reward = node.reward()
                return 1 - reward if invert_reward else reward
            node = node.find_random_child()
            invert_reward = not invert_reward

此函数执行模拟直到游戏结束。

  1. 反向传播步骤将奖励与路径上的每一步关联起来:
    def _backpropagate(self, path, reward):
        for node in reversed(path):
            self.N[node] += 1
            self.Q[node] += reward
            reward = 1 - reward

最后,我们需要一种选择最佳移动的方法,可以简单地通过查看QN字典并选择具有最大效用(奖励)的后代来实现:

    def choose(self, node):
        if node not in self.children:
            return node.find_random_child()

        def score(n):
            if self.N[n] == 0:
                return float('-inf')
            return self.Q[n] / self.N[n]

        return max(self.children[node], key=score)

我们将看不见的节点的分数设置为-infinity,以避免选择未见过的移动。

实现一个节点

现在,让我们学习如何为我们的国际象棋实现使用一个节点。

因为这基于python-chess库,所以实现起来相对容易:

import hashlib
import copy

class ChessGame:
    def find_children(self):
        if self.is_terminal():
            return set()
        return {
            self.make_move(m) for m in self.board.legal_moves
        }

    def find_random_child(self):
        if self.is_terminal():
            return None
        moves = list(self.board.legal_moves)
        m = choice(moves)
        return self.make_move(m)

    def player_win(self, turn):
        if self.board.result() == '1-0' and turn:
            return True
        if self.board.result() == '0-1' and not turn:
            return True
        return False

    def reward(self):
        if self.board.result() == '1/2-1/2':
            return 0.5
        if self.player_win(not self.board.turn):
            return 0.0

    def make_move(self, move):
        child = self.board.copy()
        child.push(move)
        return ChessGame(child)

    def is_terminal(self):
        return self.board.is_game_over()

我们在这里省略了一些方法,但不要担心 - 我们将在工作原理...部分进行覆盖。

现在一切准备就绪,我们终于可以下棋了。

下国际象棋

让我们下一盘国际象棋!

以下只是一个简单的循环,带有一个图形提示显示棋盘位置:

from IPython.display import display
import chess
import chess.svg

def play_chess():
    tree = MCTS()
    game = ChessGame(chess.Board())
    display(chess.svg.board(board=game.board, size=300))
    while True:
        move_str = input('enter move: ')
        move = chess.Move.from_uci(move_str)
        if move not in list(game.board.legal_moves):
            raise RuntimeError('Invalid move')
        game = game.make_move(move)
        display(chess.svg.board(board=game.board, size=300))
        if game.is_terminal():
            break
        for _ in range(50):
            tree.do_rollout(game)
        game = tree.choose(game)
        print(game)
        if game.is_terminal():
            break

然后,您应该被要求输入一个移动到棋盘上某个位置的移动。每次移动后,将显示一个棋盘,显示当前棋子的位置。这可以在以下截图中看到:

注意移动必须以 UCI 符号输入。如果以“square to square”格式输入移动,例如 a2a4,它应该总是有效。

这里使用的游戏强度并不是非常高,但是在玩弄它时应该仍然很容易看到一些改进。请注意,此实现没有并行化。

工作原理...

蒙特卡洛树搜索MCTS)中,我们应用蒙特卡洛方法 - 基本上是随机抽样 - 以获取关于玩家所做移动强度的概念。对于每个移动,我们随机进行移动直到游戏结束。如果我们做得足够频繁,我们将得到一个很好的估计。

树搜索维护不同的变量:

  • Q是每个节点的总奖励。

  • N是每个节点的总访问次数。

  • children保存每个节点的子节点 - 可以从一个棋盘位置到达的节点。

  • 节点在我们的情况下是一个棋盘状态。

这些字典很重要,因为我们通过奖励对节点(棋盘状态)的效用进行平均,并根据它们被访问的频率(或者更确切地说,它们被访问的次数越少)对节点进行抽样。

搜索的每次迭代包括四个步骤:

  1. 选择

  2. 扩展

  3. 模拟

  4. 回传播

选择步骤,在其最基本的形式下,寻找一个尚未探索过的节点(例如一个棋盘位置)。

扩展步骤将children字典更新为所选节点的子节点。

模拟步骤很简单:我们执行一系列随机移动,直到到达终止位置,并返回奖励。由于这是一个两人零和棋盘游戏,当轮到对手时,我们必须反转奖励。

反向传播步骤按照反向方向的路径将奖励与探索路径中的所有节点关联起来。_backpropagate() 方法沿着一系列移动(路径)回溯所有节点,赋予它们奖励,并更新访问次数。

至于节点的实现,由于我们将它们存储在之前提到的字典中,所以节点必须是可散列且可比较的。因此,在这里,我们需要实现__hash____eq__方法。我们以前没有提到它们,因为我们不需要它们来理解算法本身,所以我们在这里补充了它们以保持完整性:

    def __hash__(self):
        return int(
            hashlib.md5(
                self.board.fen().encode('utf-8')
            ).hexdigest()[:8],
            16
        )

    def __eq__(self, other):
        return self.__hash__() == other.__hash__()

    def __repr__(self):
        return '\n' + str(self.board)

当你在调试时,__repr__()方法可能非常有用。

对于ChessGame类的主要功能,我们还需要以下方法:

  • find_children(): 从当前节点查找所有可能的后继节点

  • find_random_child(): 从当前节点找到一个随机的后继节点

  • is_terminal(): 确定节点是否为终端节点

  • reward(): 为当前节点提供奖励

请再次查看ChessGame的实现,以了解它的运作方式。

这还不算完...

MCTS 的一个重要扩展是上置信树UCTs),用于平衡探索和利用。在 9x9 棋盘上达到段位的第一个围棋程序使用了带有 UCT 的 MCTS。

要实现UCT扩展,我们需要回到我们的MCTS类,并进行一些更改:

    def _uct_select(self, node):
        log_N_vertex = math.log(self.N[node])

        def uct(n):
            return self.Q[n] / self.N[n] + self.exploration_weight * math.sqrt(
                log_N_vertex / self.N[n]
            )

        return max(self.children[node], key=uct)

uct() 函数应用上置信界限UCB)公式,为一个移动提供一个得分。节点n的得分是从节点n开始的所有模拟中赢得的模拟数量的总和,加上一个置信项:

在这里,c 是一个常数。

接下来,我们需要替换代码的最后一行,以便使用_uct_select()代替_select()进行递归。在这里,我们将替换_select()的最后一行,使其陈述如下:

            node = self._uct_select(node)

进行此更改应该会进一步增强代理程序的游戏强度。

另请参见

要了解更多关于 UCT 的信息,请查看 MoGO 关于在 9x9 棋盘上达到段位的第一个计算机围棋程序的文章:hal.inria.fr/file/index/docid/369786/filename/TCIAIG-2008-0010_Accepted_.pdf。它还提供了 MCTS 的伪代码描述。

easyAI 库包含许多不同的搜索算法:zulko.github.io/easyAI/index.html

第六章:深度强化学习

强化学习是指通过优化它们在环境中的行动来自动化问题解决的目标驱动代理的发展。这涉及预测和分类可用数据,并训练代理成功执行任务。通常,代理是一个能够与环境进行交互的实体,学习是通过将来自环境的累积奖励的反馈来指导未来的行动。

可以区分三种不同类型的强化学习:

  • 基于价值——价值函数提供当前环境状态的好坏估计。

  • 基于策略——其中函数根据状态确定行动。

  • 基于模型——包括状态转换、奖励和行动规划在内的环境模型。

在本章中,我们将从多臂赌博机的角度开始介绍强化学习在网站优化中的相对基础的用例,我们将看到一个代理和一个环境以及它们的交互。然后,我们将进入控制的简单演示,这时情况会稍微复杂一些,我们将看到一个代理环境和基于策略的方法 REINFORCE。最后,我们将学习如何玩二十一点,我们将使用深度 Q 网络DQN),这是一个基于价值的算法,2015 年由 DeepMind 创建用于玩 Atari 游戏的 AI。

在本章中,我们将涵盖以下步骤:

  • 优化网站

  • 控制车杆

  • 玩二十一点

技术要求

完整的笔记本可以在线上 GitHub 找到:github.com/PacktPublishing/Artificial-Intelligence-with-Python-Cookbook/tree/master/chapter06

优化网站

在这个步骤中,我们将处理网站优化。通常,需要对网站进行变更(或者更好的是,单一变更)来观察其效果。在所谓的A/B 测试的典型情况下,将系统地比较两个版本的网页。A/B 测试通过向预定数量的用户展示网页版本 A 和版本 B 来进行。之后,计算统计显著性或置信区间,以便量化点击率的差异,目的是决定保留哪种网页变体。

这里,我们将从强化学习的角度来看待网站优化,即对每个访问者(或加载页面的用户),根据加载网站时可用数据选择最佳版本。在每次反馈(点击或未点击)后,我们会更新统计数据。与 A/B 测试相比,这种过程可能会产生更可靠的结果,并且随着时间推移,我们还会更频繁地展示最佳的网页变体。请注意,我们不限于两种变体,而是可以比较许多变体。

这个网站优化的使用案例将帮助我们介绍代理和环境的概念,并展示探索与利用之间的权衡。我们将在工作原理...部分解释这些概念。

怎么做...

为了实施我们的方案,我们需要两个组件:

  • 我们的代理决定向用户展示哪个网页。

  • 环境是一个测试平台,将给我们的代理提供反馈(点击或不点击)。

由于我们仅使用标准的 Python,无需安装任何东西,我们可以直接开始实施我们的方案:

  1. 首先我们将实现我们的环境。我们将考虑这作为一个多臂老丨虎丨机问题,在工作原理...部分中会有详细解释。因此,我们将称我们的环境为Bandit
import random
import numpy as np

class Bandit:
    def __init__(self, K=2, probs=None):
        self.K = K
        if probs is None:
            self.probs = [
                random.random() for _ in range(self.K)
            ]
        else:
            assert len(probs) == K
            self.probs = probs

        self.probs = list(np.array(probs) / np.sum(probs))
        self.best_probs = max(self.probs)

    def play(self, i):
        if random.random() < self.probs[i]:
            return 1
        else:
            return 0

这个老丨虎丨机初始化时有可用选择的数量K。这将为每个选择设置一个点击的概率。在实践中,环境将是真实用户的反馈;在这里,我们模拟用户行为。play()方法会玩第i台机器,并返回10的奖励。

  1. 现在我们需要与这个环境进行交互。这就是我们的代理要发挥作用的地方。代理需要做出决策,我们将为它提供一个决策策略。我们也会包括指标的收集。一个抽象的代理看起来是这样的:
class Agent:
    def __init__(self, env):        
        self.env = env
        self.listeners = {}
        self.metrics = {}
        self.reset()

    def reset(self):
        for k in self.metrics:
            self.metrics[k] = []

    def add_listener(self, name, fun):
        self.listeners[name] = fun
        self.metrics[name] = []

    def run_metrics(self, i):
        for key, fun in self.listeners.items():
            fun(self, i, key)

    def run_one_step(self):
        raise NotImplementedError

    def run(self, n_steps):
        raise NotImplementedError

任何代理都需要一个环境来进行交互。它需要做出单一的决策(run_one_step(self)),为了看到其决策的好坏,我们需要运行一个模拟(run(self, n_steps))。

代理将包含一个指标函数的查找列表,并且还会继承一个指标收集功能。我们可以通过run_metrics(self, i)函数来运行指标收集。

我们在这里使用的策略称为UCB1。我们将在如何做...部分解释这个策略:


class UCB1(Agent):
    def __init__(self, env, alpha=2.):
        self.alpha = alpha
        super(UCB1, self).__init__(env)

    def run_exploration(self):
        for i in range(self.env.K):
            self.estimates[i] = self.env.play(i)
            self.counts[i] += 1
            self.history.append(i)
            self.run_metrics(i) 
            self.t += 1

    def update_estimate(self, i, r):
        self.estimates[i] += (r - self.estimates[i]) / (self.counts[i] + 1)

    def reset(self):
        self.history = []
        self.t = 0
        self.counts = [0] * self.env.K
        self.estimates = [None] * self.env.K
        super(UCB1, self).reset()

    def run(self, n_steps):
        assert self.env is not None
        self.reset()
        if self.estimates[0] is None:
            self.run_exploration()
        for _ in range(n_steps):
            i = self.run_one_step()
            self.counts[i] += 1
            self.history.append(i)
            self.run_metrics(i)

    def upper_bound(self, i):
        return np.sqrt(
            self.alpha * np.log(self.t) / (1 + self.counts[i])
        )

    def run_one_step(self):
        i = max(
            range(self.env.K),
            key=lambda i: self.estimates[i] + self.upper_bound(i)
        )
        r = self.env.play(i)
        self.update_estimate(i, r)
        self.t += 1
        return i

我们的UCB1代理需要一个环境(即老丨虎丨机)进行交互,并且还需要一个单一的参数 alpha,用于权衡探索动作的重要性(与利用已知最佳动作的程度)。代理会随着时间维护其选择的历史记录,以及每个可能选择的估计记录。

我们应该看一下run_one_step(self)方法,它通过选择最佳的乐观选择来做出单一选择。run(self, n_step)方法运行一系列选择,并从环境中获取反馈。

让我们跟踪两个指标:遗憾值,即由于次优选择而导致的预期损失之和,以及作为代理估计值与环境实际配置之间收敛性的衡量标准——斯皮尔曼等级相关系数(stats.spearmanr())。

斯皮尔曼等级相关系数等于排名变量的皮尔逊相关系数(通常简称为相关系数或乘积矩法相关系数)。

两个变量之间的皮尔逊相关性可以表示如下:

其中的协方差,而的标准差。

斯皮尔曼相关性不是基于原始分数,而是基于排名分数计算。排名转换意味着变量按值排序,并为每个条目分配其顺序。给定在X中第i个点的排名,斯皮尔曼秩相关性计算如下:

这评估了两个变量之间关系能否被描述为单调函数的好坏,但不一定是线性的(如皮尔逊相关性的情况)。与皮尔逊相关性类似,斯皮尔曼相关性在完全负相关时为,在完全相关时为表示没有相关性。

我们的跟踪函数是update_regret()update_rank_corr()

from scipy import stats

def update_regret(agent, i, key):
    regret = agent.env.best_probs - agent.env.probs[i]
    if agent.metrics[key]:
        agent.metrics[key].append(
            agent.metrics[key][-1] + regret
        )
    else:
        agent.metrics[key] = [regret]

def update_rank_corr(agent, i, key):
    if agent.t < agent.env.K:
        agent.metrics[key].append(0.0)
    else:
        agent.metrics[key].append(
            stats.spearmanr(agent.env.probs, agent.estimates)[0]
        )

现在,我们可以跟踪这些指标,以便比较alpha参数(更多或更少的探索)的影响。随后,我们可以观察随时间的收敛和累积遗憾:

random.seed(42.0)
bandit = Bandit(20)
agent = UCB1(bandit, alpha=2.0)
agent.add_listener('regret', update_regret)
agent.add_listener('corr', update_rank_corr)
agent.run(5000)

因此,我们有 20 个不同的网页选择,并收集定义的regretcorr,并进行5000次迭代。如果我们绘制这个,我们可以了解这个代理的表现如何:

对于第二次运行,我们将 alpha 更改为0.5,因此我们将进行较少的探索:

我们可以看到,alpha=0.5时的累积遗憾远低于alpha=2.0时;然而,估计值与环境参数的总体相关性较低。

因此,较少的探索使得我们的代理模型对环境的真实参数了解程度较差。这是因为较少的探索使得较低排名特征的排序没有收敛。尽管它们被排名为次优,但它们还没有被选择足够多次来确定它们是最差还是次差,例如。这就是我们在较少探索时看到的情况,这也可能是可以接受的,因为我们可能只关心知道哪种选择是最佳的。

工作原理如下...

在这个示例中,我们处理了网站优化问题。我们模拟用户对不同版本网页的选择,同时实时更新每个变体的统计数据,以及应该显示的频率。此外,我们比较了探索性场景和更加利用性场景的优缺点。

我们将用户对网页的响应框架化为多臂赌博问题。多臂赌博MABP)是一种投币并拉动多个杠杆之一的老丨虎丨机,每个杠杆与不同的奖励分布相关联,而这对投资者来说是未知的。更普遍地说,多臂赌博问题(也称为K 臂赌博问题)是在资源在竞争选择之间分配的情况下,每个选择的结果仅部分已知,但随着时间的推移可能会更好地了解。当在做出决策时考虑世界的观察结果时,这被称为上下文赌博

我们使用了置信上界版本 1UCB1)算法(Auer 等人,有限时间分析多臂赌博问题,2002 年),这个算法易于实现。

运行方式如下:

  • 为了获取平均奖励的初始估计值(探索阶段),每个动作都要执行一次。

  • 对于每一轮 t 更新 Q(a)N(a),并根据以下公式执行动作 a'

其中  是平均奖励的查找表,  是动作  被执行的次数,  是参数。

UCB 算法遵循在不确定性面前保持乐观的原则,通过选择在其置信区间上 UCB 最高的臂而不是估计奖励最高的臂来执行动作。它使用简单的均值估计器来估算动作奖励。

前述方程式中的第二项量化了不确定性。不确定性越低,我们越依赖 Q(a)。不确定性随着动作播放次数的增加而线性减少,并随着轮数的对数增加而对数增加。

多臂赌博在许多领域中非常有用,包括在线广告、临床试验、网络路由或在生产中两个或多个版本的机器学习模型之间的切换。

有许多变体的赌博算法来处理更复杂的场景,例如,选择之间切换的成本,或者具有有限生命周期的选择,例如秘书问题。秘书问题的基本设置是你想从一个有限的申请者池中雇佣一名秘书。每位申请者按随机顺序进行面试,面试后立即做出明确的决定(是否雇佣)。秘书问题也被称为婚姻问题。

另见

Ax 库在 Python 中实现了许多赌徒算法:ax.dev/

Facebook 的 PlanOut 是一个用于大规模在线实验的库:facebook.github.io/planout/index.html

作为阅读材料,我们建议这些书籍:

控制一个倒立摆

倒立摆是 OpenAI Gym 中的一个控制任务,已经研究了多年。虽然与其他任务相比相对简单,但它包含了我们实施强化学习算法所需的一切,我们在这里开发的一切也可以应用于其他更复杂的学习任务。它还可以作为在模拟环境中进行机器人操作的示例。选择一个不那么苛刻的任务的好处在于训练和反馈更快。

OpenAI Gym是一个开源库,可以通过为代理与之交互的广泛环境标准化,帮助开发强化学习算法。OpenAI Gym 提供了数百个环境和集成,从机器人控制和三维行走到电脑游戏和自动驾驶汽车:gym.openai.com/

在 OpenAI Gym 环境的以下截图中展示了倒立摆任务,通过将购物车向左或向右移动来平衡一个立杆:

在这个示例中,我们将使用 PyTorch 实现 REINFORCE 策略梯度方法来解决倒立摆任务。让我们开始吧。

准备工作

有许多库提供了测试问题和环境的集合。其中一个集成最多的库是 OpenAI Gym,我们将在这个示例中使用它:

pip install gym

现在我们可以在我们的示例中使用 OpenAI Gym 了。

实施方法...

OpenAI Gym 为我们节省了工作——我们不必自己定义环境,确定奖励信号,编码环境或说明允许哪些动作。

首先,我们将加载环境,定义一个深度学习策略用于动作选择,定义一个使用此策略来选择执行动作的代理,最后我们将测试代理在我们的任务中的表现:

  1. 首先,我们将加载环境。每次杆子不倒下时,我们都会得到一个奖励。我们有两个可用的移动方式,向左或向右,并且观察空间包括购物车位置和速度的表示以及杆角度和速度,如下表所示:

    编号 观察值 最小值 最大值
    0 购物车位置 -2.4 2.4
    1 购物车速度 -Inf -Inf
    2 杆角度 ~ -41.8° ~ 41.8°
    3 Pole Velocity At Tip -Inf -Inf

您可以在这里了解更多关于此环境的信息:gym.openai.com/envs/CartPole-v1/

我们可以加载环境并打印这些参数如下:

import gym

env = gym.make('CartPole-v1')
print('observation space: {}'.format(
    env.observation_space
))
print('actions: {}'.format(
    env.action_space.n
))
#observation space: Box(4,)
#actions: 2

因此,我们确认我们有四个输入和两个动作,我们的代理将类似于前面的示例优化网站定义,只是这次我们会在代理外部定义我们的神经网络。

代理将创建一个策略网络,并使用它来做出决策,直到达到结束状态;然后将累积奖励馈送到网络中进行学习。让我们从策略网络开始。

  1. 让我们创建一个策略网络。我们将采用一个全连接的前馈神经网络,根据观察空间预测动作。这部分基于 PyTorch 实现,可以在github.com/pytorch/examples/blob/master/reinforcement_learning/reinforce.py找到:
import torch as T
import torch.nn as nn
import torch.nn.functional as F
import numpy as np

class PolicyNetwork(nn.Module):
    def __init__(
        self, lr, n_inputs,
        n_hidden, n_actions
    ):
        super(PolicyNetwork, self).__init__()
        self.lr = lr
        self.fc1 = nn.Linear(n_inputs, n_hidden)
        self.fc2 = nn.Linear(n_hidden, n_actions)
        self.optimizer = optim.Adam(self.parameters(), lr=self.lr)

        self.device = T.device(
            'cuda:0'
            if T.cuda.is_available()
            else 'cpu:0'
        )
        self.to(self.device)

    def forward(self, observation):
        x = T.Tensor(observation.reshape(-1).astype('float32'),
        ).to(self.device)
        x = F.relu(self.fc1(x))
        x = F.softmax(self.fc2(x), dim=0)
        return x

这是一个神经网络模块,用于学习策略,换句话说,从观察到动作的映射。它建立了一个具有一层隐藏层和一层输出层的两层神经网络,其中输出层中的每个神经元对应于一个可能的动作。我们设置了以下参数:

  • lr: 学习率

  • n_inputs: 输入数量

  • n_hidden: 隐藏神经元数量

  • n_actions: 输入维度

  1. 现在我们可以定义我们的代理人了:
class Agent:
    eps = np.finfo(
        np.float32
    ).eps.item()

    def __init__(self, env, lr, params, gamma=0.99):
        self.env = env
        self.gamma = gamma
        self.actions = []
        self.rewards = []
        self.policy = PolicyNetwork(
            lr=lr,
            **params
        )

    def choose_action(self, observation):
        output = self.policy.forward(observation)
        action_probs = T.distributions.Categorical(
            output
        )
        action = action_probs.sample()
        log_probs = action_probs.log_prob(action)
        action = action.item()
        self.actions.append(log_probs)
        return action, log_probs

代理人评估策略以执行动作并获得奖励。 gamma 是折扣因子。

使用 choose_action(self, observation) 方法,我们的代理根据观察选择动作。动作是根据我们网络的分类分布进行抽样。

我们省略了run() 方法,其内容如下:

    def run(self):
        state = self.env.reset()
        probs = []
        rewards = []
        done = False
        observation = self.env.reset()
        t = 0
        while not done:
            action, prob = self.choose_action(observation.reshape(-1))
            probs.append(prob)
            observation, reward, done, _ = self.env.step(action)
            rewards.append(reward)
            t += 1

        policy_loss = []
        returns = []
        R = 0
        for r in rewards[::-1]:
            R = r + self.gamma * R
            returns.insert(0, R)
        returns = T.tensor(returns)
        returns = (returns - returns.mean()) / (returns.std() + self.eps)

        for log_prob, R in zip(probs, returns):
            policy_loss.append(-log_prob * R)

        if(len(policy_loss)) > 0:
            self.policy.optimizer.zero_grad()
            policy_loss = T.stack(policy_loss, 0).sum()
            policy_loss.backward()
            self.policy.optimizer.step()
        return t

run(self) 方法类似于之前的示例,优化网站,在环境中运行完整的模拟直到结束。这是直到杆几乎倒下或达到 500 步(即 env._max_episode_steps 的默认值)为止。

  1. 接下来,我们将测试我们的代理人。我们将在环境中运行我们的代理人,通过模拟与环境的交互来开始。为了获得我们学习率的更干净的曲线,我们将env._max_episode_steps设置为10000。这意味着模拟在 10000 步后停止。如果我们保持默认值500,算法将在达到约 500 步后停滞或性能达到某个水平。相反,我们试图做更多的优化:
env._max_episode_steps = 10000
input_dims = env.observation_space.low.reshape(-1).shape[0]
n_actions = env.action_space.n

agent = Agent(
    env=env,
    lr=0.01,
    params=dict(
        n_inputs=input_dims,
        n_hidden=10,
        n_actions=n_actions
    ),
    gamma=0.99,
)
update_interval = 100
scores = []
score = 0
n_episodes = 25000
stop_criterion = 1000
for i in range(n_episodes):
    mean_score = np.mean(scores[-update_interval:])
    if (i>0) and (i % update_interval) == 0:
        print('Iteration {}, average score: {:.3f}'.format(
            i, mean_score
        ))
        T.save(agent.policy.state_dict(), filename)

    score = agent.run()
    scores.append(score)
    if score >= stop_criterion:
        print('Stopping. Iteration {}, average score: {:.3f}'.format(
            i, mean_score
        ))
        break

我们应该看到以下输出:

Iteration 100, average score: 31.060
Iteration 200, average score: 132.340
Iteration 300, average score: 236.550
Stopping. Iteration 301, average score: 238.350

在进行模拟时,我们每 100 次迭代看到一次更新的平均分数。一旦达到 1000 分,我们就会停止。这是我们的分数随时间的变化情况:

我们可以看到我们的策略正在持续改进——网络正在成功学习如何操作杆车。请注意,您的结果可能会有所不同。网络可能学习得更快或更慢。

在下一节中,我们将深入了解这个算法的实际工作原理。

它是如何工作的...

在这个案例中,我们看了一个杆车控制场景中的基于策略的算法。让我们更详细地看看其中的一些内容。

策略梯度方法通过给定的梯度上升找到一个策略,以最大化相对于策略参数的累积奖励。我们实现了一种无模型的基于策略的方法,即 REINFORCE 算法(R. Williams,《简单的统计梯度跟随算法用于连接主义强化学习》,1992 年)。

在基于策略的方法中,我们有一个策略函数。策略函数定义在一个环境 和一个动作 上,并返回给定环境下执行动作的概率。在离散选择的情况下,我们可以使用 softmax 函数:

这就是我们在策略网络中所做的,这有助于我们做出我们的动作选择。

值函数 (有时用 表示)返回给定环境中任何动作的奖励。策略的更新定义如下:

其中 是学习率。

在参数初始化之后,REINFORCE 算法通过每次执行动作时应用此更新函数来进行。

您应该能够在任何 Gym 环境上运行我们的实现,几乎不需要更改。我们故意做了一些事情(例如,将观测数据重塑为向量),以便更容易重用它;但是,请确保您的网络架构与观测数据的性质相对应。例如,如果您的观测数据是时间序列(例如股票交易或声音),您可能希望使用 1D 卷积网络或递归神经网络,或者如果您的观测数据是图像,则可以使用 2D 卷积。

还有更多内容...

还有一些其他的事情可以让我们玩得更开心。首先,我们想看到代理与杆互动,其次,我们可以使用库来避免从零开始实施代理。

观察我们在环境中的代理

我们可以玩数百场游戏或尝试不同的控制任务。如果我们真的想在 Jupyter 笔记本中观看我们的代理与环境的互动,我们可以做到:

from IPython import display
import matplotlib.pyplot as plt
%matplotlib inline

observation = env.reset()
img = plt.imshow(env.render(mode='rgb_array'))
for _ in range(100):
    img.set_data(env.render(mode='rgb_array'))
    display.display(plt.gcf())
    display.clear_output(wait=True)
    action, prob = agent.choose_action(observation)
    observation, _, done, _ = agent.env.step(action)
    if done:
        break

现在我们应该看到我们的代理与环境互动了。

如果您正在远程连接(例如在 Google Colab 上运行),您可能需要做一些额外的工作:

!sudo apt-get install -y xvfb ffmpeg 
!pip install 'gym==0.10.11'
!pip install 'imageio==2.4.0'
!pip install PILLOW
!pip install 'pyglet==1.3.2'
!pip install pyvirtualdisplay
display = pyvirtualdisplay.Display(
    visible=0, size=(1400, 900)
).start()

在接下来的部分,我们将使用一个实现在库中的强化学习算法,RLlib

使用 RLlib 库

我们可以使用 Python 库和包中的实现,而不是从头开始实现算法。例如,我们可以训练 PPO 算法(Schulman 等人,《Proximal Policy Optimization Algorithms》,2017),该算法包含在RLlib包中。 RLlib 是我们在《Python 人工智能入门》第一章中遇到的Ray库的一部分。 PPO 是一种政策梯度方法,引入了一个替代目标函数,可以通过梯度下降进行优化:

import ray
from ray import tune
from ray.rllib.agents.ppo import PPOTrainer

ray.init()
trainer = PPOTrainer

analysis = tune.run(
    trainer,
    stop={'episode_reward_mean': 100},
    config={'env': 'CartPole-v0'},
    checkpoint_freq=1,
)

这将开始训练。 您的代理将存储在本地目录中,以便稍后加载它们。 RLlib 允许您使用'torch': True选项来使用 PyTorch 和 TensorFlow。

另见

一些强化学习库提供了许多深度强化学习算法的实现:

请注意,安装这些库可能需要一段时间,并可能占用几个 GB 的硬盘空间。

最后,OpenAI 提供了一个与强化学习相关的教育资源库:spinningup.openai.com/

玩 21 点游戏

强化学习中的一个基准是游戏。 研究人员或爱好者设计了许多与游戏相关的不同环境。 有些游戏的里程碑已经在《Python 人工智能入门》第一章中提到。 对许多人来说,游戏的亮点肯定是在国际象棋和围棋两方面击败人类冠军——1997 年国际象棋冠军加里·卡斯帕罗夫和 2016 年围棋冠军李世石——并在 2015 年达到超人类水平的 Atari 游戏中表现出色。

在这个示例中,我们开始使用最简单的游戏环境之一:21 点游戏。 21 点游戏与现实世界有一个有趣的共同点:不确定性。

Blackjack 是一种纸牌游戏,在其最简单的形式中,您将与一名纸牌荷官对战。您面前有一副牌,您可以"hit",意味着您会得到一张额外的牌,或者"stick",这时荷官会继续抽牌。为了赢得比赛,您希望尽可能接近 21 分,但不能超过 21 分。

在这个教程中,我们将使用 Keras 实现一个模型来评估给定环境配置下不同动作的价值函数。我们将实现的变体称为 DQN,它在 2015 年的 Atari 里程碑成就中被使用。让我们开始吧。

准备工作

如果您尚未安装依赖项,则需要进行安装。

我们将使用 OpenAI Gym,并且我们需要安装它:

pip install gym

我们将使用 Gym 环境来进行 21 点游戏。

如何做...

我们需要一个代理人来维护其行为影响的模型。这些行动是从其内存中回放以进行学习的。我们将从一个记录过去经验以供学习的内存开始:

  1. 让我们实现这个记忆。这个记忆本质上是一个 FIFO 队列。在 Python 中,您可以使用一个 deque;然而,我们发现 PyTorch 示例中的回放内存实现非常优雅,因此这是基于 Adam Paszke 的 PyTorch 设计:
#this is based on https://pytorch.org/tutorials/intermediate/reinforcement_q_learning.html
from collections import namedtuple

Transition = namedtuple(
    'Transition',
    ('state', 'action', 'next_state', 'reward')
)

class ReplayMemory:
    def __init__(self, capacity=2000):
        self.capacity = capacity
        self.memory = []
        self.position = 0

    def push(self, *args):
        if len(self.memory) < self.capacity:
            self.memory.append(None)
        self.memory[self.position] = Transition(*args)
        self.position = (self.position + 1) % self.capacity

    def sample(self, batch_size):
        batch = random.sample(self.memory, batch_size)
        batch = Transition(
            *(np.array(el).reshape(batch_size, -1) for el in zip(*batch))
        )
        return batch

    def __len__(self):
        return len(self.memory)

我们实际上只需要两个方法:

  • 如果我们的容量已满,我们需要推入新的记忆,并覆盖旧的记忆。

  • 我们需要为学习抽样记忆。

最后一点值得强调:我们不是使用所有的记忆来进行学习,而是只取其中的一部分。

sample()方法中,我们做了一些修改以使我们的数据符合正确的形状。

  1. 让我们看看我们的代理:
import random
import numpy as np
import numpy.matlib
import tensorflow as tf
from tensorflow.keras import layers
from tensorflow.keras import optimizers
from tensorflow.keras import initializers

class DQNAgent():
    def __init__(self, env, epsilon=1.0, lr=0.5, batch_size=128):
        self.env = env
        self.action_size = self.env.action_space.n
        self.state_size = env.observation_space
        self.memory = ReplayMemory()
        self.epsilon = epsilon
        self.lr = lr
        self.batch_size = batch_size
        self.model = self._build_model()

    def encode(self, state, action=None):
        if action is None:
            action = np.reshape(
                list(range(self.action_size)),
                (self.action_size, 1)
            )
            return np.hstack([
                np.matlib.repmat(state, self.action_size, 1),
                action
            ])
        return np.hstack([state, action])

    def play(self, state):
        state = np.reshape(state, (1, 3)).astype(float)
        if np.random.rand() <= self.epsilon:
            action = np.random.randint(0, self.action_size)
        else:
            action_value = self.model.predict(self.encode(state)).squeeze()
            action = np.argmax(action_value)

        next_state1, reward, done, _ = self.env.step(action)
        next_state = np.reshape(next_state1, (1, 3)).astype(float)
        if done:
            self.memory.push(state, action, next_state, reward)
        return next_state1, reward, done

    def learn(self):
        if len(self.memory) < self.batch_size:
            return
        batch = self.memory.sample(
            self.batch_size
        )
        result = self.model.fit(
            self.encode(batch.state, batch.action),
            batch.reward,
            epochs=1,
            verbose=0
        )

请注意在play()方法开始时的动作选择。我们掷一个骰子来确定我们是要随机选择一个动作,还是要遵循我们模型的判断。这被称为ε-贪心动作选择,它可以促进更多的探索并更好地适应环境。

该代理人带有一些用于配置的超参数:

  • lr: 网络的学习率。

  • batch_size: 从内存和网络训练中进行抽样的批大小。

  • epsilon: 此因子位于01之间,控制我们希望响应中的随机性程度。1表示完全随机的探索,0表示没有探索(完全利用)。

我们发现这三个参数可以显著改变我们学习的轨迹。

我们从列表中省略了一个方法,该方法定义了神经网络模型:

    def _build_model(self):
        model = tf.keras.Sequential([
            layers.Dense(
                100,
                input_shape=(4,),
                kernel_initializer=initializers.RandomNormal(stddev=5.0),
                bias_initializer=initializers.Ones(),
                activation='relu',
                name='state'
            ),
            layers.Dense(
                2,
                activation='relu'
            ),
            layers.Dense(1, name='action', activation='tanh'),
        ])
        model.summary()
        model.compile(
            loss='hinge',
            optimizer=optimizers.RMSprop(lr=self.lr)
        )
        return model

这是一个三层神经网络,有两个隐藏层,一个有 100 个神经元,另一个有 2 个神经元,采用 ReLU 激活函数,并带有一个输出层,有 1 个神经元。

  1. 让我们加载环境并初始化我们的代理。我们初始化我们的代理和环境如下:
import gym
env = gym.make('Blackjack-v0')

agent = DQNAgent(
    env=env, epsilon=0.01, lr=0.1, batch_size=100
)

这加载了Blackjack OpenAI Gym 环境和我们在本教程的第 2 步中实现的DQNAgent

epsilon 参数定义了代理的随机行为。我们不希望将其设置得太低。学习率是我们在实验后选择的值。由于我们在进行随机卡牌游戏,如果设置得太高,算法将会非常快速地遗忘。批处理大小和记忆大小参数分别决定了每一步的训练量以及关于奖励历史的记忆。

我们可以看到这个网络的结构(由 Keras 的summary()方法显示)。

_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
state (Dense)                (None, 100)               500       
_________________________________________________________________
dense_4 (Dense)              (None, 2)                 202       
_________________________________________________________________
action (Dense)               (None, 1)                 3         
=================================================================
Total params: 705
Trainable params: 705
Non-trainable params: 0

对于模拟来说,我们的关键问题之一是epsilon参数的值。如果设置得太低,我们的代理将无法学到任何东西;如果设置得太高,我们将会因为代理做出随机动作而亏钱。

  1. 现在让我们来玩 21 点吧。我们选择以线性方式稳定减少epsilon,然后在一定数量的回合内进行利用。当epsilon达到0时,我们停止学习:
num_rounds = 5000
exploit_runs = num_rounds // 5
best_100 = -1.0

payouts = []
epsilons = np.hstack([
 np.linspace(0.5, 0.01, num=num_rounds - exploit_runs), 
 np.zeros(exploit_runs)
])

这是实际开始玩 21 点的代码:

from tqdm.notebook import trange

for sample in trange(num_rounds):
  epsilon = epsilons[sample]
  agent.epsilon = epsilon
  total_payout = 0
  state = agent.env.reset()
  for _ in range(10):
    state, payout, done = agent.play(state)
    total_payout += payout
    if done:
      break
  if epsilon > 0:
    agent.learn()

  mean_100 = np.mean(payouts[-100:])
    if mean_100 > best_100:
      best_100 = mean_100

  payouts.append(total_payout)
  if (sample % 100) == 0 and sample >= 100:
    print('average payout: {:.3f}'.format(
      mean_100
    ))
    print(agent.losses[-1])

print('best 100 average: {:.3f}'.format(best_100))

在模拟过程中,我们收集了网络训练损失的统计数据,并且收集了连续 100 次游戏中的最大津贴。

在 OpenAI Gym 中,奖励或者如果我们想保持 21 点的术语,就是津贴,可以是-1(我们输了),0(什么也没有),或者 1(我们赢了)。我们使用学习的策略随时间的津贴如下所示:

由于巨大的可变性,我们并未展示原始数据,而是绘制了移动平均线,分别为 100 和 1,000,结果呈现两条线:一条高度变化,另一条平滑,如图所示。

随着时间推移,我们确实看到了津贴的增加;然而,平均来看我们仍然亏损。即使在停止学习的开发阶段也会发生这种情况。

我们的 21 点环境没有奖励阈值,认为达到此阈值即解决问题;但是,一篇报道列出了 100 个最佳剧集,平均为 1.0,这也是我们达到的:gym.openai.com/evaluations/eval_21dT2zxJTbKa1TJg9NB8eg/

它是如何工作的...

在这个案例中,我们看到了强化学习中更高级的算法,更具体地说是一种基于价值的算法。在基于价值的强化学习中,算法构建了价值函数的估计器,进而让我们选择策略。

代理需要一些额外的评论。如果你读过之前的配方,控制倒立摆,你可能会觉得没有太多的事情发生——有一个网络,一个play()方法来决定动作,和一个learn()方法。代码相对较少。一个基本的阈值策略(我的牌加起来是 17 吗?)已经相当成功,但希望本配方展示的内容对于更复杂的用例仍然有教育意义和帮助。与我们之前见过的策略网络不同,这次,网络不是直接建议最佳动作,而是将环境和动作的组合作为输入,并输出预期奖励。我们的模型是一个两层的前馈模型,隐藏层有两个神经元,在由一个单个神经元组成的最终层中求和。代理以ε贪婪的方式进行游戏——它以概率epsilon随机移动;否则,根据其知识选择最佳移动。play函数通过比较所有可用动作的预期结果来建议具有最高效用的动作。

Q 值函数  定义如下:

其中  是时间  时的奖励,状态和动作。  是折扣因子;策略  选择动作。

在最简单的情况下, 可以是一个查找表,每个状态-动作对都有一个条目。

最优 Q 值函数定义如下:

因此,可以根据以下公式确定最佳策略:

神经拟合 Q 学习(NFQ)(Riedmiller,《神经拟合 Q 迭代——数据高效的神经强化学习方法的第一次经验》,2005 年),神经网络对给定状态进行前向传播,输出对应的可用动作。神经 Q 值函数可以通过梯度下降根据平方误差进行更新:

其中  指的是迭代  的参数,而  指的是下一个时间步骤的动作和状态。

DQN(Mnih 等人,《使用深度强化学习玩 Atari 游戏》,2015 年)基于 NFQ 进行了一些改进。这些改进包括仅在几次迭代中的小批量更新参数,基于来自重播记忆的随机样本。由于在原始论文中,该算法从屏幕像素值学习,网络的第一层是卷积层(我们将在第七章,高级图像应用中介绍)。

参见

这是 Sutton 和 Barto 的开创性著作《强化学习导论》的网站:incompleteideas.net/book/the-book-2nd.html

他们在那里描述了一个简单的 21 点游戏代理。如果您正在寻找其他卡牌游戏,可以查看 neuron-poker,这是一个 OpenAI 扑克环境;他们实现了 DQN 和其他算法:github.com/dickreuter/neuron_poker

关于 DQN 及其使用的更多细节,我们建议阅读 Mnih 等人的文章,《使用深度强化学习玩 Atari 游戏》:arxiv.org/abs/1312.5602

最后,DQN 及其后继者,双 DQN 和对决 DQN,构成了 AlphaGo 的基础,该成果发表于《自然》杂志(Silver 等人,2017 年),题为《无需人类知识掌握围棋》:www.nature.com/articles/nature24270

第七章:高级图像应用

计算机视觉中的人工智能应用包括机器人技术、自动驾驶汽车、人脸识别、生物医学图像中的疾病识别以及制造业的质量控制等。

在本章中,我们将从图像识别(或图像分类)开始,我们将探讨基本模型和更高级的模型。然后,我们将使用生成对抗网络GANs)创建图像。

在本章中,我们将涵盖以下内容:

  • 识别服装项目

  • 生成图像

  • 编码图像和样式

技术要求

我们将使用许多标准库,如 NumPy、Keras 和 PyTorch,但我们也会看到一些在每个配方开始时会提到的更多库,因为它们变得相关。

你可以在 GitHub 上找到本章配方的笔记本:github.com/PacktPublishing/Artificial-Intelligence-with-Python-Cookbook/tree/master/chapter07

识别服装项目

图像分类的一个流行例子是 MNIST 数据集,其中包含不同风格的数字从 0 到 9。在这里,我们将使用一种称为 Fashion-MNIST 的可替换数据集,其中包含不同的服装。

Fashion-MNIST 是 Zalando 的服装图片数据集,包括一个由 60,000 个示例组成的训练集和一个由 10,000 个示例组成的测试集:github.com/zalandoresearch/fashion-mnist

这里是数据集中的一些示例:

在这个配方中,我们将使用不同的模型识别服装项目——我们将从通用图像特征(高斯差分DoG)和支持向量机开始;然后我们将转向前馈多层感知器MLP);接着我们将使用卷积神经网络ConvNet);最后,我们将使用 MobileNet 进行迁移学习。

准备工作

在我们开始之前,我们必须安装一个库。在这个配方中,我们将使用scikit-image,这是一个用于图像变换的库,因此我们将快速设置它:

pip install scikit-image

现在我们已经准备好进入配方了!

如何实现...

我们将首先加载和准备数据集,然后我们将使用不同的方法学习 Fashion-MNIST 数据集中服装项目的分类模型。让我们从加载 Fashion-MNIST 时尚数据集开始。

我们可以通过keras工具函数直接获取数据集:

from tensorflow import keras
from matplotlib import pyplot as plt

(train_images, train_labels), (test_images, test_labels) = keras.datasets.fashion_mnist.load_data()
train_images = train_images / 255.0
test_images = test_images / 255.0
plt.imshow(train_images[0])
plt.colorbar()
plt.grid(False)

我们还将图像标准化为 0 到 1 的范围,通过除以最大像素强度(255.0),并且我们可视化第一张图像。

我们应该看到一张运动鞋的图片,这是训练集中的第一张图片:

正如我们在本配方介绍中提到的,我们将在接下来的几节中应用不同的方法:

  • DoG 特征

  • MLP

  • LeNet

  • 使用 MobileNet 进行迁移学习

让我们从 DoG 开始。

高斯差分

在深度学习在图像识别中取得突破之前,图像是使用来自拉普拉斯差分或高斯的滤波器进行特征化的。这些功能在 scikit-image 中实现,因此我们可以采用现成的实现。

让我们编写一个函数,使用高斯金字塔提取图像特征:

import skimage.transform
import numpy as np

def get_pyramid_features(img):
    return np.hstack([
        layer.reshape(-1)
        for layer in skimage.transform.pyramids.pyramid_gaussian(img)
    ])

get_pyramid_features() 函数应用高斯金字塔并将这些特征展平为一个向量返回。我们将在 它是如何工作... 部分解释什么是高斯金字塔。

我们几乎准备好开始学习了。我们只需迭代所有图像并提取我们的高斯金字塔特征。让我们创建另一个执行此操作的函数:

from sklearn.svm import LinearSVC

def featurize(x_train, y_train):
    data = []
    labels = []
    for img, label in zip(x_train, y_train):
        data.append(get_pyramid_features(img))
        labels.append(label)

    data = np.array(data)
    labels = np.array(labels)
    return data, labels

为了训练模型,我们在我们的训练数据集上应用 featurize() 函数。我们将使用线性支持向量机作为我们的模型。然后,我们将此模型应用于从我们的测试数据集中提取的特征 - 请注意,这可能需要一些时间才能运行:

x_train, y_train = featurize(train_images, train_labels)
clf = LinearSVC(C=1, loss='hinge').fit(x_train, y_train)

x_val, y_val = featurize(test_images, test_labels)
print('accuracy: {:.3f}'.format(clf.score(x_val, y_val)))

使用这些特征的线性支持向量机在验证数据集上获得了 84% 的准确率。通过调整滤波器,我们可以达到更高的性能,但这超出了本文的范围。在 2012 年 AlexNet 发布之前,这种方法是图像分类的最先进方法之一。

训练模型的另一种方法是将图像展平,并直接将归一化的像素值输入到分类器中,例如 MLP。这是我们现在要尝试的。

多层感知器

用 MLP 对图像进行分类的一个相对简单的方法是。在这种情况下,使用了一个具有 10 个神经元的两层 MLP,你可以将隐藏层看作是 10 个特征检测器的特征提取层。

在本书中我们已经多次看到 MLP 的示例,因此我们将跳过此处的细节;可能感兴趣的是,我们将图像从 28x28 展平为 784 的向量。至于其余部分,可以说我们训练分类交叉熵,并监视准确性。

你将在以下代码块中看到这一点:

import tensorflow as tf
from tensorflow.keras.losses import SparseCategoricalCrossentropy

def compile_model(model):
  model.summary()
  model.compile(
    optimizer='adam',
    loss=SparseCategoricalCrossentropy(
      from_logits=True
    ),
    metrics=['accuracy']
  ) 

def create_mlp():
  model = tf.keras.Sequential([
    tf.keras.layers.Flatten(input_shape=(28, 28)),
    tf.keras.layers.Dense(128, activation='relu'),
    tf.keras.layers.Dense(10)
  ])
  compile_model(model)
  return model

这个模型在两层及其连接之间有 101,770 个可训练参数。

我们将使用以下函数封装我们的训练集。这应该是相当容易理解的:

def train_model(model, train_images, test_images):
    model.fit(
        train_images,
        train_labels,
        epochs=50,
        verbose=1,
        validation_data=(test_images, test_labels)
    )
    loss, accuracy = model.evaluate(test_images, test_labels, verbose=0)
    print('loss:', loss)
    print('accuracy:', accuracy)

经过 50 个周期,我们在验证集上的准确率为 0.886。

下一个模型是经典的 ConvNet,为 MNIST 提出,使用卷积、池化和全连接层。

LeNet5

LeNet5 是一个前馈神经网络,具有卷积层和最大池化,以及用于导致输出的特征的全连接前馈层。让我们看看它的表现:

from tensorflow.keras.layers import (
  Conv2D, MaxPooling2D, Flatten, Dense
)

def create_lenet():
    model = tf.keras.Sequential([
        Conv2D(
            filters=6,
            kernel_size=(5, 5),
            padding='valid',
            input_shape=(28, 28, 1),
            activation='tanh'
        ),
        MaxPooling2D(pool_size=(2, 2)),
        Conv2D(
            filters=16,
            kernel_size=(5, 5),
            padding='valid',
            activation='tanh'
        ),
        MaxPooling2D(pool_size=(2, 2)),
        Flatten(),
        Dense(120, activation='tanh'),
        Dense(84, activation='tanh'),
        Dense(10, activation='softmax')
    ])
    compile_model(model)
    return model

create_lenet() 函数构建我们的模型。我们只需调用它,并使用它运行我们的 train_model() 函数,以适应训练数据集并查看我们的测试表现:

train_model(
    create_lenet(),
    train_images.reshape(train_images.shape + (1,)),
    test_images.reshape(test_images.shape + (1,)),
)

经过 50 集数,我们的验证准确率为 0.891。

我们还可以查看混淆矩阵,以查看我们如何区分特定的服装类别:

让我们继续进行我们最后一次尝试对服装项进行分类。

MobileNet 迁移学习

MobileNetV2 模型是在 ImageNet 上训练的,这是一个包含 1400 万张图像的数据库,已手动注释为 WordNet 分类系统的类别。

MobileNet 可以下载用于迁移学习的权重。这意味着我们保持大部分或所有 MobileNet 的权重固定。在大多数情况下,我们只需添加一个新的输出投影来区分 MobileNet 表示之上的新类别集:

base_model = tf.keras.applications.MobileNetV2(
    input_shape=(224, 224, 3),
    include_top=False,
    weights='imagenet'
)

MobileNet 包含 2,257,984 个参数。下载 MobileNet 时,我们有方便的选项可以省略输出层(include_top=False),这样可以节省工作量。

对于我们的迁移模型,我们必须附加一个池化层,然后像前两个神经网络一样附加一个输出层:

def create_transfer_model():
    base_model = tf.keras.applications.MobileNetV2(
        input_shape=(224, 224, 3),
        include_top=False,
        weights='imagenet'
    )
    base_model.trainable = False
    model = tf.keras.Sequential([
      base_model,
      tf.keras.layers.GlobalAveragePooling2D(),
      tf.keras.layers.Dense(10)
    ])
    compile_model(model)
    return model

请注意,在 MobileNet 模型中,我们会冻结或固定权重,只学习我们添加在顶部的两个层。

当我们下载 MobileNet 时,您可能已经注意到一个细节:我们指定了 224x224x3 的尺寸。MobileNet 有不同的输入形状,224x224x3 是最小的之一。这意味着我们必须重新缩放我们的图像,并将它们转换为 RGB(通过串联灰度)。您可以在 GitHub 上的在线笔记本中找到详细信息。

MobileNet 迁移学习的验证准确率与 LeNet 和我们的 MLP 非常相似:0.893。

工作原理...

图像分类包括为图像分配标签,这是深度学习革命开始的地方。

从前述 URL 获取的以下图表说明了 ImageNet 图像分类基准随时间的性能提高:

图上的 TOP 1 准确率(也更简单地称为准确率)在y轴上是一个度量,用于衡量所有预测中正确预测的比例,或者换句话说,正确识别对象的频率的比率。随着时间的推移(x轴),图上的最先进线一直在持续改进,直到现在,使用 NoisyStudent 方法达到了 87.4%的准确率(详细信息请见此处:paperswithcode.com/paper/self-training-with-noisy-student-improves)。

在下面的图表中,您可以看到图像识别中深度学习的时间线,可以看到复杂性(层数)的增加以及 ImageNet 大规模视觉识别挑战(ILSVRC)中错误率的降低:

您可以在www.image-net.org/challenges/LSVRC/找到有关挑战的更多详细信息。

高斯差分

高斯金字塔是原始图像的一系列经过递归缩小的版本,其中缩小是使用高斯滤波器完成的。您可以在 Scholarpedia 上详细了解 www.scholarpedia.org/article/Scale_Invariant_Feature_Transform

我们使用 skimage 的实用函数来提取特征,然后在顶部应用线性支持向量机作为分类器。为了提高性能,我们本可以尝试其他分类器,如随机森林或梯度提升。

LeNet5

CNN 或 ConvNet 是至少包含一个卷积层的神经网络。LeNet 是 ConvNet 的经典示例,最初由 Yann LeCun 等人提出,1989 年的原始形式是 (应用于手写邮政编码识别的反向传播),1998 年的修订形式(称为 LeNet5)是 (应用于文档识别的基于梯度的学习)。

您可以在以下图表中看到 LeNet 的架构(使用 NN-SVG 工具在 alexlenail.me/NN-SVG 创建):

卷积在图像识别中是非常重要的转换,也是非常深的神经网络中图像识别的重要组成部分。卷积包括前馈连接,称为过滤器或卷积核,应用于图像的矩形补丁(上一层)。然后,每个生成的映射是核在整个图像上滑动的结果。这些卷积映射通常后面跟随由池化层进行的子采样(在 LeNet 的情况下,是从每个核中提取的最大值)。

MobileNet 迁移学习

MobileNets(Howard 等人,MobileNets: 高效卷积神经网络用于移动视觉应用;2017;arxiv.org/abs/1704.04861)是由 Google 开发的一类模型,专为移动和嵌入式应用而设计,明确权衡延迟与准确性。MobileNet 主要由不同形状的堆叠卷积层组成。所有卷积层后跟批归一化和 ReLU 激活。最后一个卷积层后是一个平均池化层,去除空间维度,以及一个带有 softmax 函数的最终密集输出层。

在 Keras 中,加载模型只需一条命令。tf.keras.applications 包提供了许多模型的架构和权重,例如 DenseNet、EfficientNet、Inception-ResNet V2、Inception V3、MobileNetV1、MobileNetV2、NASNet-A、ResNet、ResNet v2、VGG16、VGG19 和 Xception V1。在我们的案例中,我们有一个预训练模型,这意味着它具有使用 tf.keras.applications.MobileNetV2() 函数的架构和权重。

我们可以重新训练(微调)模型以提高应用性能,或者我们可以使用原始模型,并在其上添加额外的层以对新类进行分类。

我们加载模型的操作是这样的:

    base_model = tf.keras.applications.MobileNetV2(
        input_shape=(224, 224, 3),
        include_top=False,
        weights='imagenet'
    )

正如之前提到的,我们可以从多个选择中指定不同的输入形状。include_top指定是否包含分类层。如果我们想要使用在 ImageNet 数据集上训练过的原始模型输出,我们会将其设置为True。由于我们的数据集中有不同的类别,我们想将其设置为False

如果我们想要微调模型(带或不带顶部),我们将保持基础模型(MobileNetV2)可训练。显然,这种训练方式可能需要更长的时间,因为需要训练更多的层。这就是为什么在训练过程中我们冻结了所有 MobileNetV2 的层,并将其trainable属性设置为False

另请参阅

你可以在*Khan 等人(2020)在 arXiv 上发表的《深度卷积神经网络最近架构的综述》中找到对 ConvNet 的回顾,从 LeNet 到 AlexNet 再到更近期的架构,可在此链接获取:arxiv.org/pdf/1901.06032.pdf

一个更近期的架构是 EfficientNet(Mingxing Tan 和 Quoc V. Le,2019),在 ImageNet 上实现了最先进的性能,同时比最佳 ConvNet 小约一个数量级,并且比最佳 ConvNet 快大约五倍:arxiv.org/abs/1905.11946

生成图像

2014 年由 Ian Goodfellow 等人引入的 GAN 对抗学习,是一种通过两个网络相互对抗的框架来拟合数据集的分布,其中一个模型生成示例,另一个模型区分这些示例是真实的还是虚假的。这可以帮助我们使用新的训练示例扩展我们的数据集。使用 GAN 的半监督训练可以在使用少量标记训练示例的同时,实现更高的监督任务性能。

本篇重点是在 MNIST 数据集上实现深度卷积生成对抗网络DCGAN)和鉴别器,这是最知名的数据集之一,包含 60,000 个 0 到 9 之间的数字。我们将在工作原理...部分解释术语和背景。

准备工作

对于这个步骤,我们不需要任何特殊的库。我们将使用 TensorFlow 与 Keras,NumPy 和 Matplotlib,这些都是我们之前见过的。为了保存图像,我们将使用 Pillow 库,你可以按以下方式安装或升级:

pip install --upgrade Pillow

让我们马上开始吧。

如何操作...

对于我们的 GAN 方法,我们需要一个生成器——一个接受某些输入(可能是噪声)的网络——以及一个鉴别器,一个图像分类器,例如本章中识别服装项目食谱中看到的那样。

生成器和鉴别器都是深度神经网络,并将一起进行训练。训练后,我们将看到训练损失、各个时期的示例图像以及最后时期的复合图像。

首先,我们将设计鉴别器。

这是一个经典的 ConvNet 示例。它是一系列卷积和池化操作(通常是平均或最大池化),接着是平坦化和输出层。更多详情,请参见本章的识别服装项目示例:

def discriminator_model():
    model = Sequential([
        Conv2D(
            64, (5, 5),
            padding='same',
            input_shape=(28, 28, 1),
            activation='tanh'
        ),
        MaxPooling2D(pool_size=(2, 2)),
        Conv2D(128, (5, 5), activation='tanh'),
        MaxPooling2D(pool_size=(2, 2)),
        Flatten(),
        Dense(1024, activation='tanh'),
        Dense(1, activation='sigmoid')
    ])
  return model

这与我们在本章的识别服装项目示例中介绍的 LeNet 卷积块非常相似。

接下来,我们设计生成器。

虽然鉴别器通过卷积和池化操作对其输入进行下采样,生成器则进行上采样。我们的生成器接受一个 100 维的输入向量,并通过执行与 ConvNet 相反方向的操作生成图像。因此,这种类型的网络有时被称为 DeconvNet。

生成器的输出通过 Tanh 激活函数重新标准化到-1 到 1 的范围内。DCGAN 论文(Alec Radford 等人,2015 年,《无监督学习中的深度卷积生成对抗网络》)的一个主要贡献之一是在反卷积操作后引入批标准化。在 Keras 中,有两种实现反卷积的方法:一种是使用UpSampling2D(参见www.tensorflow.org/api_docs/python/tf/keras/layers/UpSampling2D),另一种是使用Conv2DTranspose。这里,我们选择了UpSampling2D

from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import (
    Dense, Reshape, Activation,
    Flatten, BatchNormalization,
    UpSampling2D, Conv2D, MaxPooling2D
)

def create_generator_model():
    model = Sequential([
        Dense(input_dim=100, units=1024, activation='tanh'), Dense(128*7*7),
        BatchNormalization(),
        Activation('tanh'),
        Reshape((7, 7, 128), input_shape=(128*7*7,)),
        UpSampling2D(size=(2, 2)),
        Conv2D(64, (5, 5), padding='same'),
        Activation('tanh'),
        UpSampling2D(size=(2, 2)),
        Conv2D(1, (5, 5), padding='same'),
        Activation('tanh'),
    ])
    model.summary()
    return model

调用此函数,我们将使用summary()获取我们网络架构的输出。我们将看到有 6,751,233 个可训练参数。我们建议在强大的系统上运行此示例,例如 Google Colab。

为了训练网络,我们加载并标准化了 MNIST 数据集:

from tensorflow.keras.datasets import mnist

(X_train, y_train), (X_test, y_test) = mnist.load_data()
X_train = (X_train.astype(np.float32) - 127.5) / 127.5
X_train = X_train[:, :, :, None]
X_test = X_test[:, :, :, None]

图像以灰度形式呈现,像素值范围在 0–255 之间。我们将其标准化到-1 到+1 的范围内,然后重新调整为在末尾具有单例维度。

为了将误差传递给生成器,我们将生成器与鉴别器链在一起,如下所示:

def chain_generator_discriminator(g, d):
    model = Sequential()
    model.add(g)
    model.add(d)
    return model

作为我们的优化器,我们将使用 Keras 的随机梯度下降:

from tensorflow.keras.optimizers import SGD

def optim():
    return SGD(
        lr=0.0005,
        momentum=0.9,
        nesterov=True
    )

现在,让我们创建并初始化我们的模型:

d = discriminator_model()
g = generator_model()
d_on_g = chain_generator_discriminator(g, d)
d_on_g.compile(loss='binary_crossentropy', optimizer=optim())
d.compile(loss='binary_crossentropy', optimizer=optim())

单个训练步骤包括三个步骤:

  • 生成器从噪声中生成图像。

  • 鉴别器学习区分生成的图像和真实的图像。

  • 生成器通过鉴别器的反馈学习创建更好的图像。

让我们依次进行这些步骤。首先是从噪声生成图像:

import numpy as np

def generate_images(g, batch_size):
    noise = np.random.uniform(-1, 1, size=(batch_size, 100))
    image_batch = X_train[index*batch_size:(index+1)*batch_size]
    return g.predict(noise, verbose=0)

然后,鉴别器在给定假和真实图像时进行学习:

def learn_discriminate(d, image_batch, generated_images, batch_size):
    X = np.concatenate(
        (image_batch, generated_images)
    )
    y = np.array(
        [1] * batch_size + [0] * batch_size
    )
    loss = d.train_on_batch(X, y)
    return loss

我们将真实的1和假的0图像串联起来,作为鉴别器的输入。

最后,生成器从鉴别器的反馈中学习:

def learn_generate(d_on_g, d, batch_size):
    noise = np.random.uniform(-1, 1, (batch_size, 100))
    d.trainable = False
    targets = np.array([1] * batch_size)
    loss = d_on_g.train_on_batch(noise, targets)
    d.trainable = True
    return loss

请注意,在这个函数中,鉴别器目标的反转。与以前的假 0 不同,我们现在输入 1。同样重要的是,在生成器学习期间,鉴别器的参数是固定的(否则我们将再次忘记)。

我们可以在训练中加入额外的命令,以保存图像,以便可以通过视觉方式欣赏我们生成器的进展:

from PIL import Image

def save_images(generated_images, epoch, index):
    image = combine_images(generated_images)
    image = image*127.5+127.5
    Image.fromarray(
        image.astype(np.uint8)
    ).save('{}_{}.png'.format(epoch, index))

我们的训练通过交替进行以下步骤来进行:

from tqdm.notebook import trange

batch_size = 1024
generator_losses = []
discriminator_losses = []
for epoch in trange(100):
    for index in trange(nbatches):
        image_batch = X_train[index*batch_size:(index+1)*batch_size]
        generated_images = generate_images(g, batch_size)
        d_loss = learn_discriminate(
            d, image_batch, generated_images, batch_size
        )
        g_loss = learn_generate(d_on_g, d, batch_size)
        discriminator_losses.append(d_loss)
        generator_losses.append(g_loss)
        if (index % 20) == 0:
            save_images(generated_images, epoch, index)

我们让它运行。tqdm 进度条将显示剩余时间。在 Google Colab 上可能需要大约一个小时。

在 100 个 epochs 中,我们的训练错误看起来如下:

我们已经保存了图像,因此我们也可以查看生成器在 epochs 中的输出。以下是每个 100 个 epochs 中单个随机生成的数字的画廊:

我们可以看到图像通常变得越来越清晰。有趣的是,生成器的训练误差似乎在前几个 epochs 之后保持在相同的基线水平。这是一个显示最后一个 epoch 期间生成的 100 个图像的图像:

图像并不完美,但大部分可以识别为数字。

如何运作...

生成模型可以生成具有与训练集相同统计特性的新数据,这对半监督和无监督学习很有用。GAN 由 Ian Goodfellow 等人在 2014 年(Generative Adversarial Nets,NIPS;papers.nips.cc/paper/5423-generative-adversarial-nets)引入,而 DCGAN 由 Alec Radford 等人在 2015 年(Unsupervised Representation Learning with Deep Convolutional Generative Adversarial Networksarxiv.org/abs/1511.06434)引入。自原始论文以来,已提出许多增量改进。

在 GAN 技术中,生成网络学习将一个种子(例如,随机输入)映射到目标数据分布,而鉴别网络评估并区分生成器产生的数据和真实数据分布。

生成网络生成数据,鉴别网络评估数据。这两个神经网络相互竞争,生成网络的训练增加了鉴别网络的错误率,而鉴别器的训练增加了生成器的错误率,从而进行武器竞赛,迫使彼此变得更好。

在训练中,我们将随机噪声输入生成器,然后让鉴别器学习如何对生成器输出与真实图像进行分类。然后,给定鉴别器的输出,或者说其反向的生成器进行训练。鉴别器判断图像为假的可能性越小,对生成器越有利,反之亦然。

另请参见

原始的 GAN 论文,Generative Adversarial Networks(Ian Goodfellow 等人,2014 年),可以在 arXiv 上找到:arxiv.org/abs/1406.2661

DCGAN 论文,Unsupervised Representation Learning with Deep Convolutional Generative Adversarial Networks(Alec Radford 等人,2015 年),也可以在 arXiv 上找到:arxiv.org/abs/1511.06434

你可以在 PyTorch 网站上找到有关 DCGAN 的教程:pytorch.org/tutorials/beginner/dcgan_faces_tutorial.html

还有许多值得探索的 GAN 架构。Erik Linder-Norén 在 PyTorch 和 Keras 中实现了数十种最先进的架构。你可以在他的 GitHub 仓库中找到它们:github.com/eriklindernoren/PyTorch-GANgithub.com/eriklindernoren/Keras-GAN

图像和风格编码

自编码器在有效地表示输入方面非常有用。在 2016 年的一篇论文中,Makhazani 等人展示了对抗自编码器可以比变分自编码器创建更清晰的表示,并且——与我们在前一示例中看到的 DCGAN 类似——我们获得了学习创建新示例的额外好处,这对半监督或监督学习场景有帮助,并且允许使用更少的标记数据进行训练。以压缩方式表示还有助于基于内容的检索。

在这个示例中,我们将在 PyTorch 中实现对抗自编码器。我们将实现监督和无监督两种方法,并展示结果。在无监督方法中,类别之间有很好的聚类效果;在监督方法中,我们的编码器-解码器架构能够识别风格,从而使我们能够进行风格转移。在这个示例中,我们将使用计算机视觉的hello world数据集 MNIST。

准备工作

对于这个示例,我们将需要使用torchvision。这将帮助我们下载我们的数据集。我们将快速安装它:

!pip install torchvision

对于 PyTorch,我们需要先进行一些准备工作,比如启用CUDA并设置tensor类型和device

use_cuda = True
use_cuda = use_cuda and torch.cuda.is_available()
print(use_cuda)
if use_cuda:
    dtype = torch.cuda.FloatTensor
    device = torch.device('cuda:0')
else:
    dtype = torch.FloatTensor
    device = torch.device('cpu')

与其他示例风格不同,我们还会先导入必要的库:

import numpy as np
import torch
from torch import autograd
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader, dataset
from torchvision.datasets import MNIST
import torchvision.transforms as T
from tqdm.notebook import trange

现在,让我们开始吧。

怎么做…

在这个示例中,我们将实现一个对抗自编码器,并将其应用于 MNIST 手写数字数据集。这段代码基于 Maurits Diephuis 和 Shideh Rezaeifar 的实现:github.com/mdiephuis/adversarial-autoencoders

首先我们将导入必要的库。然后,我们将加载我们的数据集,定义模型组件,包括编码器、解码器和判别器,然后进行训练,最后我们将可视化生成的表示。

首先是加载数据集。

我们需要设置一些全局变量,这些变量将定义训练和数据集。然后,我们加载我们的数据集:

EPS = torch.finfo(torch.float32).eps
batch_size = 1024
n_classes = 10
batch_size = 1024
n_classes = 10

train_loader = torch.utils.data.DataLoader(
    MNIST(
        'Data/',
        train=True,
        download=True,
        transform=T.Compose([
                T.transforms.ToTensor(),
                T.Normalize((0.1307,), (0.3081,))
        ])
    ),
    batch_size=batch_size,
    shuffle=True
)

val_loader = torch.utils.data.DataLoader(
    MNIST(
        'Val/',
        train=False,
        download=True,
        transform=T.Compose([
                T.transforms.ToTensor(),
                T.Normalize((0.1307,), (0.3081,))
        ])
    ),
    batch_size=batch_size,
    shuffle=False
)

规范化中的转换对应于 MNIST 数据集的均值和标准差。

接下来是定义自编码器模型。

自编码器由编码器、解码器和判别器组成。如果您熟悉自编码器,这对您来说不是什么新东西。在下一节工作原理……中,我们将对其进行详细解释和分解。

首先,我们将定义我们的编码器和解码器:

dims = 10
class Encoder(nn.Module):
    def __init__(self, dim_input, dim_z):
        super(Encoder, self).__init__()
        self.dim_input = dim_input # image size
        self.dim_z = dim_z
        self.network = []
        self.network.extend([
            nn.Linear(self.dim_input, self.dim_input // 2),
            nn.Dropout(p=0.2),
            nn.ReLU(),
            nn.Linear(self.dim_input // 2, self.dim_input // 2),
            nn.Dropout(p=0.2),
            nn.ReLU(),
            nn.Linear(self.dim_input // 2, self.dim_z),
        ])
        self.network = nn.Sequential(*self.network)
    def forward(self, x):
        z = self.network(x)
        return z

请注意dim参数,它表示表示层的大小。我们选择10作为我们编码层的大小。

然后,我们将定义我们的解码器:

class Decoder(nn.Module):
    def __init__(self, dim_input , dim_z, supervised=False):
        super(Decoder, self).__init__()
        self.dim_input = dim_input
        self.dim_z = dim_z
        self.supervised = supervised
        self.network = []
        self.network.extend([
            nn.Linear(self.dim_z, self.dim_input // 2) if not self.supervised
            else nn.Linear(self.dim_z + n_classes, self.dim_input // 2),
            nn.Dropout(p=0.2),
            nn.ReLU(),
            nn.Linear(self.dim_input // 2, self.dim_input // 2),
            nn.Dropout(p=0.2),
            nn.ReLU(),
            nn.Linear(self.dim_input // 2, self.dim_input),
            nn.Sigmoid(),
        ])
        self.network = nn.Sequential(*self.network)
    def forward(self, z):
        x_recon = self.network(z)
        return x_recon

顺便说一下,我们还可以定义我们的判别器来与我们的编码器竞争:

class Discriminator(nn.Module):
    def __init__(self, dims, dim_h):
        super(Discriminator,self).__init__()
        self.dim_z = dims
        self.dim_h = dim_h
        self.network = []
        self.network.extend([
            nn.Linear(self.dim_z, self.dim_h),
            nn.Dropout(p=0.2), 
            nn.ReLU(),
            nn.Dropout(p=0.2), 
            nn.Linear(self.dim_h, self.dim_h),
            nn.ReLU(),
            nn.Linear(self.dim_h, 1),
            nn.Sigmoid(),
        ])
        self.network = nn.Sequential(*self.network)

    def forward(self, z):
        disc = self.network(z)
        return disc

请注意,为了保持在 0 和 1 的范围内,我们压缩了我们的输出。这对我们的损失函数非常重要。

然后是训练模型。

对抗自编码器可以在有监督的方式下使用,其中标签被输入到解码器中,除了编码输出之外,我们还需要一个实用函数,将变量进行独热编码:

def one_hot_encoding(labels, n_classes=10):
    cat = np.array(labels.data.tolist())
    cat = np.eye(n_classes)[cat].astype('float32')
    cat = torch.from_numpy(cat)
    return autograd.Variable(cat)

我们将展示如何在有标签和无标签情况下使用对抗自编码器:

def train_validate(
        encoder,
        decoder,
        Disc,
        dataloader,
        optim_encoder,
        optim_decoder,
        optim_D,
        train):
    total_rec_loss = 0
    total_disc_loss = 0
    total_gen_loss = 0
    if train:
        encoder.train()
        decoder.train()
        Disc.train()
    else:
        encoder.eval()
        decoder.eval()
        Disc.eval()

    iteration = 0
    for (data, labels) in dataloader:
        # [ training loop here, see next code segment ]

    M = len(dataloader.dataset)
    return total_rec_loss / M, total_disc_loss / M, total_gen_loss / M

如您在评论中所见,我们已经拆分出训练循环。训练循环如下所示:

    for (data, labels) in dataloader:
        # Reconstruction loss:
        for p in Disc.parameters():
            p.requires_grad = False

        real_data_v = autograd.Variable(data).to(device).view(-1, 784)
        encoding = encoder(real_data_v)

        if decoder.supervised:
            categories = one_hot_encoding(labels, n_classes=10).to(device)
            decoded = decoder(torch.cat((categories, encoding), 1))
        else:
            decoded = decoder(encoding)

        reconstruction_loss = F.binary_cross_entropy(decoded, real_data_v)
        total_rec_loss += reconstruction_loss.item()
        if train:
            optim_encoder.zero_grad()
            optim_decoder.zero_grad()
            reconstruction_loss.backward()
            optim_encoder.step()
            optim_decoder.step()

        encoder.eval()
        z_real_gauss = autograd.Variable(
            torch.randn(data.size()[0], dims) * 5.0
        ).to(device)
        z_fake_gauss = encoder(real_data_v)
        D_real_gauss = Disc(z_real_gauss)
        D_fake_gauss = Disc(z_fake_gauss)

        D_loss = -torch.mean(
            torch.log(D_real_gauss + EPS) +
            torch.log(1 - D_fake_gauss + EPS)
        )
        total_disc_loss += D_loss.item()

        if train:
            optim_D.zero_grad()
            D_loss.backward()
            optim_D.step()

        if train:
            encoder.train()
        else:
            encoder.eval()
        z_fake_gauss = encoder(real_data_v)
        D_fake_gauss = Disc(z_fake_gauss)

        G_loss = -torch.mean(torch.log(D_fake_gauss + EPS))
        total_gen_loss += G_loss.item()

        if train:
            optim_encoder_reg.zero_grad()
            G_loss.backward()
            optim_encoder_reg.step()

        if (iteration % 100) == 0:
            print(
                'reconstruction loss: %.4f, discriminator loss: %.4f , generator loss: %.4f' %
                (reconstruction_loss.item(), D_loss.item(), G_loss.item()))

        iteration += 1

对于这段代码,我们将在工作原理……部分讨论如何计算和反向传播三种不同的损失。还请注意监督参数,它定义了我们是要使用监督还是无监督训练。

现在,让我们初始化我们的模型和优化器:

encoder = Encoder(784, dims).to(device)
decoder = Decoder(784, dims, supervised=True).to(device)
Disc = Discriminator(dims, 1500).to(device)

lr = 0.001
optim_encoder = torch.optim.Adam(encoder.parameters(), lr=lr)
optim_decoder = torch.optim.Adam(decoder.parameters(), lr=lr)
optim_D = torch.optim.Adam(Disc.parameters(), lr=lr)
optim_encoder_reg = torch.optim.Adam(encoder.parameters(), lr=lr * 0.1)

现在我们可以开始训练:

train_loss = []
val_loss = []
for epoch in trange(n_epochs):
    l1, l2, l3 = train_validate(
        encoder, decoder, Disc,
        train_loader, optim_encoder, optim_decoder,
        optim_D, train=True
    )
    print('epoch: {} ---- training loss: {:.8f}'.format(epoch, l1))
    train_loss.append(l1)

    if (epoch % 5) == 0:
        l1, l2, l3 = train_validate(
            encoder, decoder, Disc,
            val_loader, optim_encoder,
            optim_decoder, optim_D, False
        )
        print('epoch: {} ---- validation loss: {:.8f}'.format(epoch, l1))
        val_loss.append(l1)

这没什么大不了的,除了之前定义的train_validate()函数的调用,一次是train=True选项,一次是train=False选项。从这两个调用中,我们分别收集用于训练和验证的错误。

训练和验证错误持续下降,正如我们在下面的图表中所见:

如果您运行此代码,比较生成器和判别器的损失——看到生成器和判别器的损失如何相互影响是很有趣的。

下一步是可视化表示:

在有监督条件下,编码器空间的投影与类别关系不大,正如您在下面的tsne图中所见:

这是编码器数字表示空间的二维可视化。颜色(或者如果您在黑白显示器上看的话,阴影)代表不同的数字,它们都被集中在一起,而不是分成群集。编码器根本不区分不同的数字。

编码的东西完全是别的,那就是风格。事实上,我们可以分别在两个维度上变化输入到解码器中,以展示这一点:

前五行对应第一个维度的线性范围,第二个维度保持恒定,然后在接下来的五行中,第一个维度固定,第二个维度变化。我们可以看到第一个维度对应厚度,第二个维度对应倾斜度。这被称为风格转移。

我们还可以尝试无监督训练,通过设置supervised=False。我们应该看到这样的投影,其中类别在tsne投影的 2D 空间中聚类:

这是数字编码器表示空间的二维可视化。每种颜色(或阴影)代表不同的数字。我们可以看到不同的聚类将同一数字的实例组合在一起。编码器区分不同的数字。

在下一节中,我们将讨论其工作原理。

工作原理...

自编码器是一个由两部分组成的网络 - 编码器和解码器 - 其中编码器将输入映射到潜在空间,解码器重建输入。自编码器可以通过重建损失进行训练,该损失通常是原始输入与恢复输入之间的平方误差。

对抗自编码器于 2016 年由 Makhazani 等人引入(对抗自编码器; arxiv.org/pdf/1511.05644.pdf)。该出版物展示了它们如何在聚类和半监督训练中使用,或者它们如何解相关类别表示。对抗自编码器是一种使用 GAN 执行变分推断的概率自编码器。与直接匹配输入和输出不同,自编码器的隐藏编码向量的聚合后验被匹配到任意先验分布。

由于对抗自编码器是 GAN,因此依赖于生成器和鉴别器之间的竞争,训练比普通自编码器更复杂一些。我们计算三种不同类型的错误:

  • 标准重建误差

  • 鉴别器的一个错误,量化了无法区分真实随机数和编码器输出的失败

  • 对编码器错误的惩罚,因为它未能欺骗鉴别器

在我们的情况下,我们强制先验分布和解码器输出在 0 和 1 之间的范围内,并且因此可以使用交叉熵作为重建误差。

可能有助于突出显示负责计算不同类型误差的代码段。

重建误差如下所示:

if decoder.supervised:
    categories = one_hot_encoding(labels, n_classes=10).to(device)
    decoded = decoder(torch.cat((categories, encoding), 1))
else:
    decoded = decoder(encoding)
    reconstruction_loss = F.binary_cross_entropy(decoded, real_data_v)

有一个额外的标志用于将标签作为监督训练输入到解码器中。我们发现在监督设置中,编码器并不表示数字,而是表示风格。我们认为这是因为在监督设置中,重建误差不再依赖于标签。

鉴别器损失计算如下:

        # i) latent representation:
        encoder.eval()
        z_real_gauss = autograd.Variable(
            torch.randn(data.size()[0], dims) * 5.0
        ).to(device)
        z_fake_gauss = encoder(real_data_v)
        # ii) feed into discriminator
        D_real_gauss = Disc(z_real_gauss)
        D_fake_gauss = Disc(z_fake_gauss)

        D_loss = -torch.mean(
            torch.log(D_real_gauss + EPS) +
            torch.log(1 - D_fake_gauss + EPS)
        )

请注意,这是为了训练鉴别器,而不是编码器,因此有encoder.eval()语句。

生成器损失计算如下:

        if train:
            encoder.train()
        else:
            encoder.eval()
        z_fake_gauss = encoder(real_data_v)
        D_fake_gauss = Disc(z_fake_gauss)

        G_loss = -torch.mean(torch.log(D_fake_gauss + EPS))
        total_gen_loss += G_loss.item()

在下一节中,我们将查看更多的材料和背景。

另请参见

对于更更新、更全面的对抗自编码器实现,请参考 Maurits Diephuis 维护的存储库:github.com/mdiephuis/generative-models

关于自编码器和对抗自编码器的更多历史和数学背景,请参阅 Lilian Weng 在她的博客上的优秀概述文章From Autoencoder to Beta-VAElilianweng.github.io/lil-log/2018/08/12/from-autoencoder-to-beta-vae.html

最后,请查看 Maurits Diephuis 等人的Variational Saccading: Efficient Inference for Large Resolution Images(Ramapuram 等人,2019)。他们引入了一个概率算法,用于专注于更大图像中感兴趣的区域,灵感来源于眼睛的扫视运动。你可以在 GitHub 上找到他们的代码:github.com/jramapuram/variational_saccading

第八章:处理动态图像

本章涉及视频应用。虽然应用于图像的方法可以应用于视频的单帧,但通常会导致时间上的一致性损失。我们将尝试在消费者硬件上找到可能性和趣味性之间的平衡,并展示和实施。

谈论视频时应该考虑到很多应用程序,例如目标跟踪、事件检测(监视)、深度伪造、3D 场景重建和导航(自动驾驶汽车)。

很多伪造视频需要耗费数小时或数天的计算时间。我们将尝试在可能性和趣味性之间找到一个合理的平衡。这种平衡可能比其他章节更为明显,因为视频的计算不像单独的图像计算那样耗费资源。作为这种平衡的一部分,我们将逐帧处理视频,而不是跨时间域处理。尽管如此,我们仍将通过提供实际的真实应用示例或至少类似的示例来解决问题。

在本章中,我们将从图像检测开始,其中算法将图像识别模型应用于图像的不同部分以定位对象。然后我们将展示如何将其应用到视频流中。然后我们将使用深度伪造模型创建视频,并参考更多相关的模型,用于创建和检测深度伪造。

在本章中,我们将看到以下示例:

  • 定位对象

  • 视频伪造

技术要求

我们将使用许多标准库,包括kerasopencv,但在每个示例开始之前我们会提到更多的库。

您可以在 GitHub 上找到本章示例的笔记本,链接为github.com/PacktPublishing/Artificial-Intelligence-with-Python-Cookbook/tree/master/chapter08

定位对象

对象检测是指在图像和视频中识别特定类别的对象。例如,在自动驾驶汽车中,必须识别行人和树木以避让。

在这个示例中,我们将在 Keras 中实现一个对象检测算法。我们将首先将其应用于单个图像,然后应用于我们的笔记本摄像头。在工作原理...部分,我们将讨论理论和更多关于对象检测的算法。

准备工作

对于这个示例,我们将需要开放计算机视觉库OpenCV)和scikit-image的 Python 绑定:

!pip install -U opencv-python scikit-image

作为我们的示例图像,我们将从一个对象检测工具箱中下载一张图像:

def download_file(url: str, filename='demo.jpg'):
    import requests
    response = requests.get(url)
    with open(filename, 'wb') as f:
        f.write(response.content)

download_file('https://raw.githubusercontent.com/open-mmlab/mmdetection/master/demo/demo.jpg')

请注意,任何其他图像都可以。

我们将使用基于keras-yolo3库的代码,只需进行少量更改即可快速设置。我们也可以快速下载这个:

download_file('https://gist.githubusercontent.com/benman1/51b2e4b10365333f0af34f4839f86f27/raw/991b41e5d5d83174d3d75b55915033550e16adf8/keras-yolo3.py', 'keras_yolo3.py')

最后,我们还需要YOLOv3网络的权重,可以从 darknet 开源实现中下载:

download_file('https://pjreddie.com/media/files/yolov3.weights', 'yolov3.weights')

现在,你应该在本地目录中拥有示例图像、yolo3-keras Python 脚本以及 YOLOv3 网络权重,从中运行你的笔记本。

如何实现它……

在本节中,我们将使用 Keras 实现一个物体检测算法。

我们将导入 keras-yolo3 库,加载预训练的权重,然后对给定的图像或摄像头视频流进行物体检测:

  1. 由于我们在 keras-yolo3 脚本中已实现了大部分物体检测功能,所以我们只需要导入它:
from keras_yolo3 import load_model, detect
  1. 我们可以按以下方式加载带有预训练权重的网络。请注意,权重文件相当大 - 它们将占用大约 237 MB 的磁盘空间:
yolov3 = load_model('yolov3.weights')

我们的模型现在可作为 Keras 模型使用。

  1. 然后,我们可以对我们的示例图像执行物体检测:
from matplotlib import pyplot as plt

plt.imshow(detect(yolov3, 'demo.jpg'))

我们应该看到我们的示例图像标注了每个边界框的标签,如下截图所示:

我们可以使用 OpenCV 库扩展此功能以处理视频。我们可以逐帧捕获连接到计算机的摄像头的图像,运行物体检测,并显示带标注的图像。

请注意,此实现未经优化,可能运行比较慢。要获取更快的实现,请参考参考资料部分中链接的 darknet 实现。

当你运行以下代码时,请注意你可以按 q 键停止摄像头:

import cv2
from skimage import color

cap = cv2.VideoCapture(0)

while(True):
    ret, frame = cap.read()

    img = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)    
    img = detect(yolov3, img)
    cv2.imshow('frame', img)
    if cv2.waitKey(1) & 0xFF == ord('q'):
        break

cap.release()
cv2.destroyAllWindows()

我们以灰度方式捕获图像,但随后必须使用 scikit-image 将其转换回 RGB,通过堆叠图像来检测对象并显示带标注的帧。

这是我们获得的图像:

在下一节中,我们将讨论这个配方及其背景解释。

它是如何工作的……

我们已经使用 Keras 实现了一个物体检测算法。这是一个标准库的开箱即用功能,但我们将其连接到摄像头,并应用到了一个示例图像上。

图像检测的主要算法如下:

物体检测的主要需求之一是速度 – 你不希望在识别前等待撞上树。

图像检测是基于图像识别的基础上,增加了在图像中搜索候选位置的复杂性。

Fast R-CNN 是 R-CNN 的改进(2014 年同一作者)。每个感兴趣区域,即由边界框定义的矩形图像块,通过图像金字塔进行尺度归一化。卷积网络然后可以通过一次前向传递处理这些对象提议(从几千到成千上万)。作为实现细节,Fast R-CNN 使用奇异值分解压缩完全连接层以提高速度。

YOLO 是一个单一网络,直接从图像中提出边界框和类别。在其实验中,作者以每秒 45 帧和 155 帧的速度运行了不同版本的 YOLO。

SSD 是一种单阶段模型,摒弃了独立对象提议生成的需要,而选择通过网络传递一组离散的边界框。然后在不同分辨率和边界框位置上组合预测结果。

顺便提一下,Joseph Redmon 发表并维护了他的 YOLO 架构的多个增量改进,但他已经离开了学术界。 YOLO 系列的最新实现由 Bochkovskiy 等人在相同的精神中进行,也在 Redmon 的 GitHub 仓库上得到了认可:github.com/AlexeyAB/darknet

YOLOv4 在其 CNN 中引入了几种新的网络特性,展示了快速的处理速度,同时保持了显著优于 YOLOv3 的精度水平(43.5% 平均精度 (AP),在 Tesla V100 GPU 上实时速度约为每秒 65 帧,针对 MS COCO 数据集)。

还有更多...

与网络摄像头交互的方式有多种,并且甚至有一些移动应用程序允许您流式传输摄像头内容,这意味着您可以将其插入在云上运行的应用程序中(例如 Colab 笔记本)或服务器上。

最常见的库之一是matplotlib,也可以从网络摄像头实时更新 matplotlib 图形,如下所示的代码块:

%matplotlib notebook
import cv2
import matplotlib.pyplot as plt

def grab_frame(cap):
    ret, frame = cap.read()
    if not ret:
        print('No image captured!')
        exit()
    return cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)

cap = cv2.VideoCapture(0)
fig, ax = plt.subplots(1, 1)
im = ax.imshow(grab_frame(cap))

plt.tick_params(
    top=False, bottom=False, left=False, right=False,
    labelleft=False, labelbottom=False
)
plt.show()

while True:
    try:
        im.set_data(grab_frame(cap))
        fig.canvas.draw()
    except KeyboardInterrupt:
        cap.release()
        break

这是初始化视频源并在 matplotlib 子图中显示的基本模板。我们可以通过中断内核来停止。

我们将在下一节中提到更多的库以供玩耍。

另请参见

我们建议您查看 YOLOv4 论文,可在 arxiv 上找到:arxiv.org/abs/2004.10934

有关对象检测,有几个库可用:

让我们继续下一个步骤吧!

伪造视频

深度伪造是通过深度学习应用制作的操纵视频。潜在的不道德用途在媒体上已经存在一段时间。您可以想象这可能如何落入试图破坏政府的宣传机制之手中。请注意,我们建议不要出于不正当目的制作深度伪造视频。

深度伪造技术存在一些道德应用,其中一些非常有趣。您是否想过史泰龙可能会在《终结者》中看起来如何?今天您可以实现!

在本教程中,我们将学习如何使用 Python 创建深度伪造。我们将下载两部电影的公共领域视频,并通过替换其中一个人物的脸部来制作深度伪造。《Charade》是一部 1963 年由斯坦利·多南导演的电影,风格类似于希区柯克的影片。影片中 50 多岁的凯瑞·格兰特与 30 多岁的奥黛丽·赫本搭档。我们认为让这对配对年龄更合适。在一番搜索后,我们找到了 1963 年约翰·韦恩主演的《McLintock!》中的莫琳·奥哈拉,以取代奥黛丽·赫本。

准备工作

Faceit是围绕faceswap库的一个包装器,它简化了我们进行深度伪造所需的许多任务。我们已经在github.com/benman1/faceit上分支了faceit仓库。

我们需要做的是下载faceit仓库并安装必需的库。

你可以用git克隆(clone)这个仓库(如果在ipython笔记本中输入此命令,请加上感叹号):

git clone https://github.com/benman1/faceit

我们发现 Docker 容器非常适合安装依赖项(这需要安装 Docker)。我们可以像这样创建一个 Docker 容器:

./dockerbuild.sh

这个过程可能需要一些时间来构建。请注意,Docker 镜像基于 Nvidia 的容器,因此您可以在容器内使用 GPU。

请注意,尽管有一个轻量级模型可供使用,我们强烈建议您在配备 GPU 的机器上运行深度伪造技术。

最后,我们可以这样进入我们的容器:

./dockerrun.sh

在容器内部,我们可以运行 Python 3.6. 以下所有命令都假定我们在容器内并且在 /project 目录下。

如何做...

我们需要将视频和面部定义为深度伪造过程的输入。

  1. 我们可以像这样在 Python(Python 3.6)中定义我们的模型:
from faceit import *

faceit = FaceIt('hepburn_to_ohara', 'hepburn', 'ohara')

这清楚地表明我们想要用ohara替换hepburn(这是我们在进程内部对它们命名的方式)。我们必须将图像放在data/persons目录下分别命名为hepburn.jpgohara.jpg。我们已经在仓库的便利性部分提供了这些图像。

如果我们没有提供图像,faceit将提取所有的面部图像,不论它们显示的是谁。然后我们可以将其中两张图像放在persons目录下,并删除data/processed/目录下面部的目录。

  1. 接下来,我们需要定义我们要使用的视频。我们可以选择使用完整的电影或短片。我们没有找到《McLintock!》电影的好短片,因此我们正在使用整部电影。至于《Charade》,我们专注于单场景的片段。我们将这些片段保存在磁盘上,名称为mclintock.mp4who_trust.mp4

请注意,只能从允许下载或不禁止下载的网站下载视频,即使是公共领域的视频:

faceit.add('ohara', 'mclintock.mp4')
faceit.add('hepburn', 'who_trust.mp4')
FaceIt.add_model(faceit)

这定义了我们的模型使用的数据为一对视频。Faceit 允许一个可选的第三参数,它可以是一个链接到视频的链接,从中可以自动下载。但是,在您从 YouTube 或其他网站下载视频之前,请确保这在其服务条款中允许,并且在您的司法管辖区内合法。

  1. 然后,通过几行代码(和大量的调整和等待)启动深度伪造的创建:
faceit.preprocess()
faceit.train()
faceit.convert('who_trust.mp4', face_filter=True, photos=False)

预处理步骤包括下载视频,提取所有帧作为图像,最后提取面部。我们已经提供了这些面部,因此您不必执行预处理步骤。

下面的图片显示奥黛丽·赫本在左边,莫琳·奥哈拉在右边扮演奥黛丽·赫本:

这些变化可能看起来很微妙。如果你想要更清晰的东西,我们可以使用同样的模型将凯瑞·格兰特替换为莫琳·奥哈拉:

实际上,我们可以通过在转换中禁用面部过滤器来制作一部名为《成为莫琳·奥哈拉》的电影。

我们本可以使用更先进的模型,更多的训练来改进深度伪造,或者我们可以选择一个更简单的场景。然而,有时候结果看起来并不差。我们已将我们的伪造视频上传到 YouTube,您可以在此观看:youtu.be/vDLxg5qXz4k

它的工作原理...

典型的深度伪造流程包括我们在配方中方便地忽略的一些步骤,因为 Faceit 提供了的抽象。这些步骤是以下,给定人物 A 和人物 B,其中 A 将被 B 替换:

  • 选择 A 和 B 的视频,并将视频分割成帧。

  • 从这些帧中使用人脸识别为 A 和 B 提取面部。这些面部,或面部表情和面部姿势,理想情况下应该代表你将制作的视频。确保它们不模糊,不被遮挡,不显示除 A 和 B 之外的任何人,并且它们不要太重复。

  • 你可以训练一个模型处理这些面孔,该模型可以用 B 替换面孔 A。你应该让训练运行一段时间。这可能需要几天,甚至几周或几个月,具体取决于面孔数量和模型复杂性。

  • 现在我们可以通过运行面部识别和模型在其上进行转换视频。

在我们的情况下,面部识别库(face-recognition)在检测和识别方面表现非常出色。然而,它仍然存在高假阳性和假阴性。这可能导致体验不佳,特别是在有几个面部的帧中。

在当前版本的 faceswap 库中,我们将从目标视频中提取帧以获取所有脸部对齐的地标。然后,我们可以使用 GUI 手动检查和清理这些对齐,以确保它们包含正确的脸部。这些对齐将用于转换:forum.faceswap.dev/viewtopic.php?t=27#align.

每个步骤都需要非常注意。整个操作的核心是模型。可以有不同的模型,包括生成对抗自编码器和其他模型。faceswap 中的原始模型是带有变化的自编码器。我们之前在第七章中使用过自编码器,高级图像应用。这个相对传统,我们可以从那里的自编码器实现中获取我们的自编码器实现。但是为了完整起见,我们将展示其基于 keras/tensorflow 的实现(简化):

from keras.models import Model
from keras.layers import Input, Dense, Flatten, Reshape
from keras.layers.advanced_activations import LeakyReLU
from keras.layers.convolutional import Conv2D
from keras.optimizers import Adam
from lib.PixelShuffler import PixelShuffler

IMAGE_SHAPE = (64, 64, 3)
ENCODER_DIM = 1024

def conv(self, filters):
    def block(x):
    x = Conv2D(filters, kernel_size=5, strides=2, padding='same')(x)
    x = LeakyReLU(0.1)(x)
    return x
 return block

def upscale(self, filters):
    def block(x):
    x = Conv2D(filters * 4, kernel_size=3, padding='same')(x)
    x = LeakyReLU(0.1)(x)
    x = PixelShuffler()(x)
    return x
 return block

def Encoder():
    input_ = Input(shape=IMAGE_SHAPE)
    x = input_
    x = conv(128)(x)
    x = conv(256)(x)
    x = conv(512)(x)
    x = conv(1024)(x)
    x = Dense(ENCODER_DIM)(Flatten()(x))
    x = Dense(4 * 4 * 1024)(x)
    x = Reshape((4, 4, 1024))(x)
    x = upscale(512)(x)
    return Model(input_, x)

def Decoder():
    input_ = Input(shape=(8, 8, 512))
    x = input_
    x = upscale(256)(x)
    x = upscale(128)(x)
    x = upscale(64)(x)
    x = Conv2D(3, kernel_size=5, padding='same', activation='sigmoid')(x)
    return Model(input_, x)

这段代码本身并不是非常有趣。我们有两个函数,Decoder()Encoder(),分别返回解码器和编码器模型。这是一个卷积编码器-解码器架构。在解码器的放大操作中,PixelShuffle 层通过排列将数据从深度重新排列为空间数据块。

现在,自编码器更有趣的部分在于训练是如何进行的,作为两个模型:

optimizer = Adam(lr=5e-5, beta_1=0.5, beta_2=0.999)
x = Input(shape=IMAGE_SHAPE)

encoder = Encoder()
decoder_A, decoder_B = Decoder(), Decoder()
autoencoder_A = Model(x, decoder_A(encoder(x)))
autoencoder_B = Model(x, decoder_B(encoder(x)))

autoencoder_A.compile(optimizer=optimizer, loss='mean_absolute_error')
autoencoder_B.compile(optimizer=optimizer, loss='mean_absolute_error')

我们有两个自编码器,一个用于训练 A 脸部,另一个用于训练 B 脸部。两个自编码器都在最小化输出与输入之间的重建误差(以平均绝对误差度量)。如前所述,我们有一个单一编码器,它是两个模型的一部分,并且因此将在 A 脸部和 B 脸部上都进行训练。解码器模型在两个脸部之间是分开的。这种架构确保我们在 A 脸部和 B 脸部之间有一个共同的潜在表示。在转换中,我们可以从 A 中取出一个脸部,表示它,然后应用 B 的解码器以获得对应于潜在表示的 B 脸部。

另请参阅

我们整理了一些关于玩弄视频和深度伪造以及检测深度伪造的进一步参考资料。

深度伪造

我们整理了一些关于深度伪造以及与创建深度伪造过程相关的链接。

在这个示例中,面部识别库已经被用来选择图像区域进行训练和应用变换。该库在 GitHub 上可获得:github.com/ageitgey/face_recognition

这里有一些简单视频伪造应用的不错示例:

对于更复杂的视频操作,如深度伪造,有很多可用的工具,我们将重点介绍两个:

提出并实现了许多不同的模型,包括以下内容:

faceit live 仓库(github.com/alew3/faceit_live)是 faceit 的一个分支,可以在实时视频源上运行,并配有一个反馈视频给恶作剧参与者的 hack。

深度伪造检测

以下链接与检测深度伪造相关:

论文《DeepFakes and Beyond: A Survey of **Face Manipulation and Fake Detection*》(Ruben Tolosana 等人,2020)提供了更多的链接和数据集资源及方法。

第九章:音频和语音中的深度学习

在本章中,我们将处理声音和语音。声音数据以波形的形式出现,因此需要与其他类型的数据进行不同的预处理。

在音频信号的机器学习中,商业应用包括语音增强(例如助听器)、语音到文本和文本到语音、噪声取消(例如耳机)、根据用户喜好推荐音乐(如 Spotify)以及生成音频。在音频中可以遇到许多有趣的问题,包括音乐流派的分类、音乐的转录、生成音乐等等。

我们将在本章中实现几个与声音和语音相关的应用。首先,我们将做一个简单的分类任务的例子,尝试区分不同的词汇。这将是智能家居设备中区分不同命令的典型应用。然后,我们将研究一个文本到语音的架构。您可以应用这个架构从文本创建自己的音频书籍,或者为您自己的智能家居设备的语音输出。最后,我们将结束于生成音乐的配方。从商业角度来看,这可能更多是一个利基应用,但您可以为了乐趣或娱乐您的视频游戏用户而构建自己的音乐。

在本章中,我们将看看以下的配方:

  • 识别语音命令

  • 从文本合成语音

  • 生成旋律

技术要求

您可以在 GitHub 上找到与本章配方相关的笔记本的源代码:github.com/PacktPublishing/Artificial-Intelligence-with-Python-Cookbook/tree/master/chapter09

我们将在本章中使用音频处理库 librosalibrosa.org/doc/latest/index.html)。您可以按如下方式安装它:

!pip install librosa

Librosa 在 Colab 中默认安装。

对于本章中的配方,请确保您有一个可用的 GPU。在 Google Colab 上,请确保您激活了 GPU 运行时。

识别语音命令

在这个配方中,我们将在谷歌的语音命令数据集上解决一个简单的声音识别问题。我们将把声音命令分类到不同的类别中。然后,我们将建立一个深度学习模型并进行训练。

准备工作

对于本配方,我们需要在章节开头提到的 librosa 库。我们还需要下载语音命令数据集,为此我们首先需要安装 wget 库:

!pip install wget

或者,我们可以在 Linux 和 macOS 中使用 !wget 系统命令。我们将创建一个新目录,下载带有数据集的存档文件,并提取 tarfile

import os
import wget
import tarfile

DATA_DIR = 'sound_commands'
DATASET_URL = 'http://download.tensorflow.org/data/speech_commands_v0.01.tar.gz'
ARCHIVE = os.path.basename(DATASET_URL)
os.mkdir(DATA_DIR)
os.chdir(DATA_DIR)
wget.download(DATASET_URL)
with tarfile.open(ARCHIVE, 'r:gz') as tar:
  tar.extractall(path='data/train')
os.remove(ARCHIVE)

这为我们在 data/train 目录下获得了许多文件和目录:

_background_noise_  five     marvin        right             tree
bed                 four     nine       seven             two
bird                go       no         sheila            up
cat                 happy    off        six               validation_list.txt
dog                 house    on         stop              wow
down                left     one        testing_list.txt  yes
eight               LICENSE  README.md  three             zero

大多数指的是语音命令;例如,bed 目录包含了 bed 命令的示例。

有了这些,我们现在准备好开始了。

如何做...

在这个教程中,我们将训练一个神经网络来识别语音命令。这个教程的灵感来自于 TensorFlow 在语音命令上的教程,网址是 www.tensorflow.org/tutorials/audio/simple_audio

首先进行数据探索,然后导入和预处理数据集进行训练,接着创建模型,训练并在验证中检查其性能:

  1. 让我们从一些数据探索开始:我们将听一个命令,查看其波形,然后查看其频谱。librosa 库提供了将声音文件加载到向量中的功能:
import librosa
x, sr = librosa.load('data/train/bed/58df33b5_nohash_0.wav')

我们还可以获得一个 Jupyter 小部件来听声音文件或加载的向量:

import IPython.display as ipd
ipd.Audio(x, rate=sr)

小部件看起来像这样:

按下播放,我们听到声音。请注意,即使在远程连接上(例如使用 Google Colab),这也可以工作。

现在让我们看一下声音波形:

%matplotlib inline
import matplotlib.pyplot as plt
import librosa.display

plt.figure(figsize=(14, 5))
librosa.display.waveplot(x, sr=sr, alpha=0.8)

波形看起来像这样:

这也称为压力-时间图,显示随时间变化的(有符号的)振幅。

我们可以如下绘制频谱:

X = librosa.stft(x)
Xdb = librosa.amplitude_to_db(abs(X))
plt.figure(figsize=(14, 5))
librosa.display.specshow(Xdb, sr=sr, x_axis='time', y_axis='log')
plt.colorbar()

频谱看起来像这样:

请注意,我们在y轴上使用了对数尺度。

  1. 现在,让我们进行数据导入和预处理。我们需要迭代文件,并将它们存储为向量:
from tqdm.notebook import tqdm

def vectorize_directory(dirpath, label=0):
 features = []
  labels = [0]
  files = os.listdir(dirpath)
  for filename in tqdm(files):
    x, _ = librosa.load(
        os.path.join(dirpath, filename)
    )
    if len(x) == 22050:
      features.append(x)
  return features, [label] * len(features)

features, labels = vectorize_directory('data/train/bed/')
f, l = vectorize_directory('data/train/bird/', 1)
features.extend(f)
labels.extend(l)
f, l = vectorize_directory('data/train/tree/', 2)
features.extend(f)
labels.extend(l)

为简单起见,我们这里只使用了三个命令:bedbirdtree。这已足以说明深度神经网络在声音分类中的问题和应用,也足够简单,不会花费太多时间。然而,这个过程仍然可能需要一些时间。在 Google Colab 上大约需要一个小时。

最后,我们需要将 Python 特征列表转换为 NumPy 数组,并且需要分割训练和验证数据:

import numpy as np
from sklearn.model_selection import train_test_split

features = np.concatenate([f.reshape(1, -1) for f in features], axis=0)
labels = np.array(labels)
X_train, X_test, y_train, y_test = train_test_split(
 features, labels, test_size=0.33, random_state=42
)

现在我们需要对我们的训练数据做一些处理。我们需要一个可以训练的模型。

  1. 让我们创建一个深度学习模型,然后进行训练和测试。首先我们需要创建我们的模型和标准化。让我们先进行标准化操作:
import tensorflow.keras as keras
from tensorflow.keras.layers import *
from tensorflow.keras.regularizers import l2
from tensorflow.keras.models import Model
import tensorflow.keras.backend as K

def preprocess(x):
    x = (x + 0.8) / 7.0
    x = K.clip(x, -5, 5)
    return x

Preprocess = Lambda(preprocess)

接下来是以下内容:

def relu6(x):
    return K.relu(x, max_value=6)

def conv_layer(x, num_filters=100, k=3, strides=2):
    x = Conv1D(
          num_filters,
          (k),
          padding='valid',
          use_bias=False,
          kernel_regularizer=l2(1e-6)
        )(x)
    x = BatchNormalization()(x)
    x = Activation(relu6)(x)
    x = MaxPool1D(pool_size=num_filters, strides=None, padding='valid')(x)
    return x

def create_model(classes, nlayers=1, filters=100, k=100):
    input_layer = Input(shape=[features.shape[1]])
    x = Preprocess(input_layer)
    x = Reshape([features.shape[1], 1])(x)
    for _ in range(nlayers):
        x = conv_layer(x, num_filters=filters, k=k)
        x = Reshape([219 * filters])(x)
        x = Dense(
            units=len(classes), activation='softmax',
            kernel_regularizer=l2(1e-2)
        )(x)
    model = Model(input_layer, x, name='conv1d_sound')
    model.compile(
        optimizer=keras.optimizers.Adam(lr=3e-4),
        loss=keras.losses.SparseCategoricalCrossentropy(),
        metrics=[keras.metrics.sparse_categorical_accuracy])
    model.summary()
    return model

model = create_model(classes

请注意 conv_layer() 函数,它提供了网络的核心部分。在视觉中可以使用非常类似的一维卷积模块,这里只是我们在这里使用了一维卷积。

这给了我们一个相对较小的模型,仅约有 75,000 个参数:

Layer (type)                 Output Shape              Param #   
=================================================================
input_46 (InputLayer)        [(None, 22050)]           0         
_________________________________________________________________
lambda_44 (Lambda)           (None, 22050)             0         
_________________________________________________________________
reshape_86 (Reshape)         (None, 22050, 1)          0         
_________________________________________________________________
conv1d_56 (Conv1D)           (None, 21951, 100)        10000     
_________________________________________________________________
batch_normalization_43 (Batc (None, 21951, 100)        400       
_________________________________________________________________
activation_43 (Activation)   (None, 21951, 100)        0         
_________________________________________________________________
max_pooling1d_29 (MaxPooling (None, 219, 100)          0         
_________________________________________________________________
reshape_87 (Reshape)         (None, 21900)             0         
_________________________________________________________________
dense_33 (Dense)             (None, 3)                 65703     
=================================================================
Total params: 76,103
Trainable params: 75,903
Non-trainable params: 200
_________________________________________________________________

请注意,最大的层(按参数计算)是最后的密集层。我们可以通过修改密集层之前的卷积或最大池化操作进一步减少参数数量。

现在我们可以进行训练和验证:

import sklearn

model.fit(X_train, y_train, epochs=30)
predicted = model.predict(X_test)
print('accuracy: {:.3f}'.format(
    sklearn.metrics.accuracy_score(y_test, predicted.argmax(axis=1))
))

在验证集中,模型的准确率输出应该大约为 0.805。

工作原理...

声音与其他领域并无太大不同,除了预处理。了解如何在文件中存储声音至关重要。在基本水平上,声音以振幅随时间和频率存储。声音以离散间隔采样(这是采样率)。48 kHz 是 DVD 的典型录音质量,指的是每秒 48,000 次的采样频率。比特深度(也称为动态范围)是信号振幅的分辨率(例如,16 比特意味着振幅范围为 0-65,535)。

对于机器学习,我们可以从波形中进行特征提取,并在原始波形上使用 1D 卷积,或在声谱图表示(例如,Mel 声谱图 – Davis 和 Mermelstein,连续语音中基于音节的识别实验,1980 年)上使用 2D 卷积。我们之前在第七章中处理过卷积,高级图像应用。简而言之,卷积是对层输入上的矩形补丁应用的前向滤波器。生成的映射通常在池化层之后进行子采样。

卷积层可以非常深度堆叠(例如,Dai 等人,2016:arxiv.org/abs/1610.00087)。我们已经为读者实验堆叠层提供了便利。层数nlayerscreate_model()的参数之一。

有趣的是,许多语音识别模型使用递归神经网络。然而,一些模型,如 Facebook 的 wav2letter (github.com/facebookresearch/wav2letter),例如,使用完全卷积模型,这与本方案中采用的方法并无太大不同。

参见

除了librosa,在 Python 中用于音频处理的有用库还包括pydub (github.com/jiaaro/pydub) 和 scipypyAudioProcessing 库提供了音频的特征提取和分类功能:github.com/jsingh811/pyAudioProcessing

还有一些有趣的库和存储库值得探索:

文本转语音合成

一个文本到语音程序,对人类来说很容易理解,可以让有视觉或阅读障碍的人听到家用电脑上的书写文字,或者在驾车时让您享受阅读一本书。在这个示例中,我们将加载一个文本到语音模型,并让它朗读给我们听。在 它是如何工作的…… 部分,我们将介绍模型实现和模型架构。

准备工作完成

对于这个示例,请确保您有一个可用的 GPU。在 Google Colab 上,请确保您激活了 GPU 运行时。我们还需要安装 wget 库,可以在笔记本中如下安装:

!pip install wget

我们还需要从 GitHub 克隆pytorch-dc-tts存储库并安装其要求。请从笔记本运行此命令(或在终端中运行,不带前导感叹号):

from os.path import exists

if not exists('pytorch-dc-tts'):
 !git clone --quiet https://github.com/tugstugi/pytorch-dc-tts

!pip install --ignore-installed librosa

请注意,您需要安装 Git 才能使其正常工作。如果您没有安装 Git,您可以直接从您的 Web 浏览器中下载存储库。

我们已经准备好处理主要示例了。

如何操作……

我们将下载 Torch 模型文件,加载它们到 Torch 中,然后从句子中合成语音:

  1. 下载模型文件:我们将从dropbox下载数据集:
import wget

if not exists('ljspeech-text2mel.pth'):
    wget.download(      'https://www.dropbox.com/s/4t13ugxzzgnocbj/step-300K.pth',
        'ljspeech-text2mel.pth'
    )

if not exists('ljspeech-ssrn.pth'):
    wget.download(
   'https://www.dropbox.com/s/gw4aqrgcvccmg0g/step-100K.pth',
        'ljspeech-ssrn.pth'
    )

现在我们可以在 torch 中加载模型。

  1. 加载模型:让我们先处理依赖关系:
import sys
sys.path.append('pytorch-dc-tts')
import numpy as np
import torch
import IPython
from IPython.display import Audio
from hparams import HParams as hp
from audio import save_to_wav
from models import Text2Mel, SSRN
from datasets.lj_speech import vocab, idx2char, get_test_data

现在我们可以加载模型:

torch.set_grad_enabled(False)
text2mel = Text2Mel(vocab)
text2mel.load_state_dict(torch.load('ljspeech-text2mel.pth').state_dict())
text2mel = text2mel.eval()
ssrn = SSRN()
ssrn.load_state_dict(torch.load('ljspeech-ssrn.pth').state_dict())
ssrn = ssrn.eval()

最后,我们可以大声朗读这些句子。

  1. 语音合成:我们选择了一些花园路径句子。这些句子在语法上是正确的,但会误导读者关于它们最初的理解。

以下句子是花园路径句子的例子——这些句子会误导听众关于单词如何相互关联的理解。我们选择它们是因为它们既简短又有趣。您可以在学术文献中找到这些及更多花园路径句子,比如在《Up the Garden Path》(Tomáš Gráf;2013 年发表于 Acta Universitatis Carolinae Philologica)中:

SENTENCES = [
 'The horse raced past the barn fell.',
 'The old man the boat.',
 'The florist sent the flowers was pleased.',
 'The cotton clothing is made of grows in Mississippi.',
 'The sour drink from the ocean.',
 'Have the students who failed the exam take the supplementary.',
 'We painted the wall with cracks.',
 'The girl told the story cried.',
 'The raft floated down the river sank.',
 'Fat people eat accumulates.'
]

我们可以按照以下步骤从这些句子生成语音:

for i in range(len(SENTENCES)):    
    sentences = [SENTENCES[i]]
    max_N = len(sentences[0])
    L = torch.from_numpy(get_test_data(sentences, max_N))
    zeros = torch.from_numpy(np.zeros((1, hp.n_mels, 1), np.float32))
    Y = zeros
    A = None

    for t in range(hp.max_T):
      _, Y_t, A = text2mel(L, Y, monotonic_attention=True)
      Y = torch.cat((zeros, Y_t), -1)
      _, attention = torch.max(A[0, :, -1], 0)
      attention = attention.item()
      if L[0, attention] == vocab.index('E'): # EOS
          break

    _, Z = ssrn(Y)
    Z = Z.cpu().detach().numpy()
    save_to_wav(Z[0, :, :].T, '%d.wav' % (i + 1))
    IPython.display.display(Audio('%d.wav' % (i + 1), rate=hp.sr))

还有更多... 部分,我们将看一下如何为不同数据集训练模型。

它是如何工作的……

语音合成是通过程序产生人类语音的过程,称为语音合成器。从自然语言到语音的合成称为文本到语音TTS)。合成的语音可以通过连接来自录制片段的音频生成,这些片段以单位如独特的声音、音素和音素对(双音素)出现。

让我们稍微深入了解两种方法的细节。

基于引导注意力的深度卷积网络

在这个示例中,我们加载了 Hideyuki Tachibana 和其他人发表的模型,《基于深度卷积网络和引导注意力的高效可训练文本到语音系统》(2017 年;arxiv.org/abs/1710.08969)。我们使用了在 github.com/tugstugi/pytorch-dc-tts 上的实现。

发表于 2017 年,这种方法的新颖之处在于在网络中不使用循环,而是依赖于卷积,这一决定导致训练和推断比其他模型快得多。事实上,他们声称在一台配备两个现成的 GPU 的游戏 PC 上训练他们的深度卷积 TTS 网络只需大约 15 小时。在librivox公共领域有声书项目的数据集上进行了 15 小时的训练后,众包的平均意见分数似乎没有增加。作者提供了一个演示页面,展示了训练不同阶段的音频样本,您可以听到说出的句子,例如Wasserstein GAN 的两人零和博弈是通过考虑 Kantorovich-Rubinstein 对偶导出的tachi-hi.github.io/tts_samples/

该架构由两个子网络组成,可以分别训练,一个用于从文本合成频谱图,另一个用于从频谱图创建波形。文本到频谱图部分包括以下模块:

  • 文本编码器

  • 音频编码器

  • 注意力

  • 音频解码器

这个方法的有趣之处在于标题中提到的引导注意力,它负责将字符与时间对齐。他们约束这个注意力矩阵几乎是线性随时间的,而不是随机顺序阅读字符,给定一个引导注意力损失

这偏爱矩阵对角线上的值而不是矩阵外的值。他们认为这种约束有助于显著加快训练时间。

WaveGAN

还有更多...部分,我们将加载一个不同的模型,WaveGAN,由 Chris Donahue 等人发布,标题为WaveGAN: 使用生成对抗网络学习合成原始音频(2018 年;arxiv.org/abs/1802.04208)。

Donahue 等人在无监督环境中训练了一个 GAN 以合成原始音频波形。他们尝试了两种不同的策略:

  • 一种名为Spectrogram-StrategySpecGAN)的方法,他们使用了 DCGAN(请参阅第七章中的生成图像章节,高级图像应用),并将其应用于频谱图(频率随时间变化)

  • 一种名为Waveform-StrategyWaveGAN)的方法,他们将架构展平(1D 卷积)

对于第一种策略,他们必须开发一个能够将频谱图转换回文本的方法。

对于 WaveGAN,他们将 2D 卷积展平为 1D,同时保持大小(例如,5x5 的核变为 25 的 1D 核)。步幅为 2x2 变为 4。他们移除了批标准化层。他们使用了 Wasserstein GAN-GP 策略进行训练(Ishaan Gulrajani 等人,2017 年;Wasserstein GANs 的改进训练arxiv.org/abs/1704.00028)。

他们的 WaveGAN 在人类评判(平均意见分数)方面表现明显不如他们的 SpecGAN。您可以在 chrisdonahue.com/wavegan_examples/ 找到一些生成的声音示例。

还有更多......

我们还可以使用 WaveGAN 模型从文本合成语音。

我们将下载在前一教程中遇到的语音命令上训练的模型检查点。然后我们将运行模型生成语音:

  1. 下载 TensorFlow 模型检查点:我们将按以下方式下载模型数据:
import wget

wget.download(
  'https://s3.amazonaws.com/wavegan-v1/models/timit.ckpt.index',
  'model.ckpt.index'
)
wget.download(
  'https://s3.amazonaws.com/wavegan-v1/models/timit.ckpt.data-00000-of-00001',
  'model.ckpt.data-00000-of-00001')
wget.download(
  'https://s3.amazonaws.com/wavegan-v1/models/timit_infer.meta',
  'infer.meta'
);

现在我们可以将计算图加载到内存中:

import tensorflow as tf

tf.reset_default_graph()
saver = tf.train.import_meta_graph('infer.meta')
graph = tf.get_default_graph()
sess = tf.InteractiveSession()
saver.restore(sess, 'model.ckpt');

现在我们可以生成语音。

  1. 生成语音:模型架构涉及字母的潜在表示。我们可以根据潜在表示的随机初始化来听模型构建的内容:
import numpy as np
import PIL.Image
from IPython.display import display, Audio
import time as time

_z = (np.random.rand(2, 100) * 2.) - 1.
z = graph.get_tensor_by_name('z:0')
G_z = graph.get_tensor_by_name('G_z:0')[:, :, 0]
G_z_spec = graph.get_tensor_by_name('G_z_spec:0')

start = time.time()
_G_z, _G_z_spec = sess.run([G_z, G_z_spec], {z: _z})
print('Finished! (Took {} seconds)'.format(time.time() - start))

for i in range(2):
    display(Audio(_G_z[i], rate=16000))

这应该展示了两个使用 Jupyter 小部件的生成声音示例:

如果这些听起来并不特别自然,不要担心。毕竟,我们使用了潜在空间的随机初始化。

另请参阅

基于深度卷积网络和引导注意力的高效可训练文本到语音系统 (arxiv.org/abs/1710.08969)。在 Erdene-Ochir Tuguldur 的 GitHub 仓库中,您可以找到该论文的 PyTorch 实现。蒙古文文本到语音是在《蒙古圣经》的 5 小时音频上训练的:github.com/tugstugi/pytorch-dc-tts

在 Chris Donahue 的 WaveGAN GitHub 仓库中,您可以看到 WaveGAN 的实现以及从 MP3、WAV、OGG 等格式的音频文件中进行训练的示例,而无需预处理 (github.com/chrisdonahue/wavegan)。

Mozilla 开源了他们的 TensorFlow 实现 Baidu 的 Deep Speech 架构(2014 年),您可以在这里找到:github.com/mozilla/DeepSpeech

生成旋律

人工智能 (AI) 在音乐中是一个迷人的话题。如果您最喜欢的 70 年代乐队正在推出新歌,但可能更现代化会很酷吧?索尼与披头士合作做到了这一点,您可以在 YouTube 上听到一首歌,完整地包含了自动生成的歌词,名为 Daddy's carwww.youtube.com/watch?v=LSHZ_b05W7o

在这个教程中,我们将生成一个旋律。更具体地说,我们将使用 Magenta Python 库中的功能继续一首歌曲。

准备工作

我们需要安装 Magenta 库以及一些系统库作为依赖。请注意,为了安装系统依赖项,您需要管理员权限。如果您不是在 Linux(或 *nix)上,您将需要找到与您的系统对应的依赖项。

在 macOS 上,这应该相对简单。否则,在 Colab 环境中运行可能更容易:

!apt-get update -qq && apt-get install -qq libfluidsynth1 fluid-soundfont-gm build-essential libasound2-dev libjack-dev
!pip install -qU pyfluidsynth pretty_midi
!pip install -qU magenta

如果您在 Colab 上,您需要另一种调整以允许 Python 找到您的系统库:

import ctypes.util
orig_ctypes_util_find_library = ctypes.util.find_library
def proxy_find_library(lib):
  if lib == 'fluidsynth':
    return 'libfluidsynth.so.1'
  else:
    return orig_ctypes_util_find_library(lib)
ctypes.util.find_library = proxy_find_library

这是 Python 外部库导入系统的一个聪明的解决方法,取自原始的 Magenta 教程,位于colab.research.google.com/notebooks/magenta/hello_magenta/hello_magenta.ipynb

是时候发挥创造力了!

如何做...

我们首先组合一段旋律的开头,然后从 Magenta 加载MelodyRNN模型让它继续旋律:

  1. 让我们一起编曲。我们将使用小星星。Magenta 项目使用一种名为NoteSequence的音符序列表示,附带许多实用工具,包括与 MIDI 之间的转换。我们可以像这样向序列添加音符:
from note_seq.protobuf import music_pb2

twinkle_twinkle = music_pb2.NoteSequence()
twinkle_twinkle.notes.add(pitch=60, start_time=0.0, end_time=0.5, velocity=80)
twinkle_twinkle.notes.add(pitch=60, start_time=0.5, end_time=1.0, velocity=80)
twinkle_twinkle.notes.add(pitch=67, start_time=1.0, end_time=1.5, velocity=80)
twinkle_twinkle.notes.add(pitch=67, start_time=1.5, end_time=2.0, velocity=80)
twinkle_twinkle.notes.add(pitch=69, start_time=2.0, end_time=2.5, velocity=80)
twinkle_twinkle.notes.add(pitch=69, start_time=2.5, end_time=3.0, velocity=80)
twinkle_twinkle.notes.add(pitch=67, start_time=3.0, end_time=4.0, velocity=80)
twinkle_twinkle.notes.add(pitch=65, start_time=4.0, end_time=4.5, velocity=80)
twinkle_twinkle.notes.add(pitch=65, start_time=4.5, end_time=5.0, velocity=80)
twinkle_twinkle.notes.add(pitch=64, start_time=5.0, end_time=5.5, velocity=80)
twinkle_twinkle.notes.add(pitch=64, start_time=5.5, end_time=6.0, velocity=80)
twinkle_twinkle.notes.add(pitch=62, start_time=6.0, end_time=6.5, velocity=80)
twinkle_twinkle.notes.add(pitch=62, start_time=6.5, end_time=7.0, velocity=80)
twinkle_twinkle.notes.add(pitch=60, start_time=7.0, end_time=8.0, velocity=80) 
twinkle_twinkle.total_time = 8
twinkle_twinkle.tempos.add(qpm=60);

我们可以使用 Bokeh 可视化序列,然后播放音符序列:

import note_seq

note_seq.plot_sequence(twinkle_twinkle)
note_seq.play_sequence(twinkle_twinkle,synth=note_seq.fluidsynth)

看起来如下:

我们可以听歌的前 9 秒。

  1. 让我们从magenta加载MelodyRNN模型:
from magenta.models.melody_rnn import melody_rnn_sequence_generator
from magenta.models.shared import sequence_generator_bundle
from note_seq.protobuf import generator_pb2
from note_seq.protobuf import music_pb2

note_seq.notebook_utils.download_bundle('attention_rnn.mag', '/content/')
bundle = sequence_generator_bundle.read_bundle_file('/content/basic_rnn.mag')
generator_map = melody_rnn_sequence_generator.get_generator_map()
melody_rnn = generator_map'basic_rnn'
melody_rnn.initialize()

这应该只需几秒钟。与本书中遇到的其他模型相比,Magenta 模型非常小。

现在,我们可以将之前的旋律和一些参数一起输入,以继续这首歌曲:

def get_options(input_sequence, num_steps=128, temperature=1.0):
    last_end_time = (max(n.end_time for n in input_sequence.notes)
                      if input_sequence.notes else 0)
    qpm = input_sequence.tempos[0].qpm 
    seconds_per_step = 60.0 / qpm / melody_rnn.steps_per_quarter
    total_seconds = num_steps * seconds_per_step

    generator_options = generator_pb2.GeneratorOptions()
    generator_options.args['temperature'].float_value = temperature
    generate_section = generator_options.generate_sections.add(
      start_time=last_end_time + seconds_per_step,
      end_time=total_seconds)
    return generator_options

sequence = melody_rnn.generate(input_sequence, get_options(twinkle_twinkle))

现在我们可以绘制和播放新音乐:

note_seq.plot_sequence(sequence)
note_seq.play_sequence(sequence, synth=note_seq.fluidsynth)

再次,我们得到了 Bokeh 库的绘图和播放小部件:

我们可以像这样从我们的音符序列创建一个 MIDI 文件:

note_seq.sequence_proto_to_midi_file(sequence, 'twinkle_continued.mid')

这将在磁盘上创建一个新的 MIDI 文件。

在 Google Colab 上,我们可以这样下载 MIDI 文件:

from google.colab import files
files.download('twinkle_continued.mid')

我们可以通过 MIDI 文件将不同的旋律输入模型,或者我们可以尝试其他参数;我们可以增加或减少随机性(temperature参数),或让序列继续较长时间(num_steps参数)。

工作原理...

MelodyRNN 是一种基于 LSTM 的音符语言模型。为了理解 MelodyRNN,我们首先需要了解长短期记忆LSTM)的工作原理。1997 年由 Sepp Hochreiter 和 Jürgen Schmidhuber 发布(长短期记忆doi.org/10.1162%2Fneco.1997.9.8.1735),LSTM 是循环神经网络RNN)的最著名例子,代表了处理图像识别、语音识别、自然语言处理和时间序列等任务的最先进模型。LSTM 曾或正在背后支持 Google、Amazon、Microsoft 和 Facebook 的流行工具,用于语音识别和语言翻译。

LSTM 层的基本单元是 LSTM 单元,由几个调节器组成,我们可以在下面的示意图中看到:

本图表基于 Alex Graves 及其它人的深度递归神经网络语音识别(2013),摘自维基百科关于 LSTMs 的英语条目,网址为en.wikipedia.org/wiki/Long_short-term_memory

监管机构包括以下内容:

  • 一个输入门

  • 一个输出门

  • 一个遗忘门

我们可以解释这些门背后的直觉,而不陷入方程的细节。一个输入门调节输入对单元的影响力,一个输出门减少传出单元的激活,而遗忘门则衰减单元的活动。

LSTMs 能够处理不同长度的序列,这是它们的优点。然而,随着序列长度的增加,它们的性能会下降。为了学习更长的序列,Magenta 库提供了包含注意力机制的模型(Dzmitry Bahdanau 及其它人,2014 年,通过联合学习对齐和翻译来进行神经机器翻译arxiv.org/abs/1409.0473)。Bahdanau 及其它人表明,他们的注意力机制显著改善了处理长序列的性能。

在 MelodyRNN 中,注意力掩码a的应用如下:

您可以在 Magenta 文档中找到更多细节:magenta.tensorflow.org/2016/07/15/lookback-rnn-attention-rnn/

另见

请注意,Magenta 有不同版本的 MelodyRNN 模型可用(github.com/magenta/magenta/tree/master/magenta/models/melody_rnn)。除了 MelodyRNN 外,Magenta 还提供了其他模型,包括用于生成音乐的变分自编码器,以及许多基于浏览器的工具用于探索和生成音乐:github.com/magenta/magenta

DeepBeat 是一个用于嘻哈节拍生成的项目:github.com/nicholaschiang/deepbeat

Jukebox 是基于 Dhariwal 及其它人的论文Jukebox: A Generative Model for Music(2020 年;arxiv.org/abs/2005.00341)的开源项目。您可以在openai.com/blog/jukebox/找到许多音频样本。

您可以在github.com/pkmital/time-domain-neural-audio-style-transfer找到 Parag K. Mital 的 NIPS 论文时域神经音频风格转换(2017 年)的原始实现。

第十章:自然语言处理

自然语言处理 (NLP) 是关于分析文本并设计处理文本的算法,从文本中进行预测或生成更多文本。NLP 涵盖了与语言相关的任何内容,通常包括类似于我们在第九章中看到的识别语音命令配方,深度学习中的音频和语音。您可能还想参考第二章中对抗算法偏差配方或第三章中用于相似性搜索的表示配方,以了解更传统的方法。本章大部分内容将涉及近年来突破性进展背后的深度学习模型。

语言通常被视为与人类智能密切相关,并且机器掌握沟通能力长期以来一直被认为与实现人工通用智能 (AGI) 的目标密切相关。艾伦·图灵在他 1950 年的文章计算机与智能中建议了一个测试,后来称为图灵测试,在这个测试中,询问者必须找出他们的交流对象(在另一个房间里)是计算机还是人类。然而,有人认为,成功地欺骗询问者以为他们在与人类交流并不是真正理解(或智能)的证据,而是符号操纵的结果(中文房间论证;约翰·西尔,思想、大脑和程序,1980 年)。不管怎样,在最近几年里,随着像 GPU 这样的并行计算设备的可用性,NLP 在许多基准测试中取得了令人瞩目的进展,例如在文本分类方面:nlpprogress.com/english/text_classification.html

我们首先将完成一个简单的监督任务,确定段落的情感,然后我们将设置一个响应命令的 Alexa 风格聊天机器人。接下来,我们将使用序列到序列模型翻译文本。最后,我们将尝试使用最先进的文本生成模型写一本流行小说。

在本章中,我们将进行以下配方:

  • 对新闻组进行分类

  • 与用户聊天

  • 将文本从英语翻译成德语

  • 写一本流行小说

技术要求

与迄今为止大多数章节一样,我们将尝试基于 PyTorch 和 TensorFlow 的模型。我们将在每个配方中应用不同的更专业的库。

如往常一样,您可以在 GitHub 上找到配方笔记本:github.com/PacktPublishing/Artificial-Intelligence-with-Python-Cookbook/tree/master/chapter10

对新闻组进行分类

在这个配方中,我们将做一个相对简单的监督任务:基于文本,我们将训练一个模型来确定文章的主题,从一系列话题中选择。这是一个在 NLP 中相对常见的任务;我们将试图概述不同的解决方法。

您可能还想比较在《第二章》中 算法偏差对抗 的配方中,使用词袋法(在 scikit-learn 中的 CountVectorizer)来解决这个问题的方法。在这个配方中,我们将使用词嵌入和使用词嵌入的深度学习模型。

准备工作

在这个配方中,我们将使用 scikit-learn 和 TensorFlow(Keras),正如本书的许多其他配方一样。此外,我们将使用需要下载的词嵌入,并且我们将使用 Gensim 库的实用函数在我们的机器学习管道中应用它们:

!pip install gensim

我们将使用来自 scikit-learn 的数据集,但我们仍然需要下载词嵌入。我们将使用 Facebook 的 fastText 词嵌入,该词嵌入是在 Wikipedia 上训练的:

!pip install wget
import wget
wget.download(
    'https://dl.fbaipublicfiles.com/fasttext/vectors-wiki/wiki.en.vec',
    'wiki.en.vec'
)

请注意,下载可能需要一些时间,并且需要大约 6 GB 的磁盘空间。如果您在 Colab 上运行,请将嵌入文件放入 Google Drive 的目录中,这样当您重新启动笔记本时就不需要重新下载。

如何做...

新闻组数据集是大约 20,000 个新闻组文档的集合,分为 20 个不同的组。20 个新闻组集合是在 NLP 中测试机器学习技术(如文本分类和文本聚类)的流行数据集。

我们将把一组新闻组分类为三个不同的主题,并且我们将使用三种不同的技术来解决这个任务,以便进行比较。首先获取数据集,然后应用词袋法技术,使用词嵌入,在深度学习模型中训练定制的词嵌入。

首先,我们将使用 scikit-learn 的功能下载数据集。我们将新闻组数据集分两批下载,分别用于训练和测试。

from sklearn.datasets import fetch_20newsgroups

categories = ['alt.atheism', 'soc.religion.christian', 'comp.graphics', 'sci.med']
twenty_train = fetch_20newsgroups(
    subset='train',
    categories=categories,
    shuffle=True,
    random_state=42
  )
twenty_test = fetch_20newsgroups(
    subset='test',
    categories=categories,
    shuffle=True,
    random_state=42
)

这方便地为我们提供了训练和测试数据集,我们可以在这三种方法中使用。

让我们开始覆盖第一个方法,使用词袋法。

词袋法

我们将构建一个单词计数和根据它们的频率重新加权的管道。最终的分类器是一个随机森林。我们在训练数据集上训练这个模型:

import numpy as np
from sklearn.pipeline import Pipeline
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.feature_extraction.text import TfidfTransformer
from sklearn.ensemble import RandomForestClassifier

text_clf = Pipeline([
  ('vect', CountVectorizer()),
  ('tfidf', TfidfTransformer()),
  ('clf', RandomForestClassifier()),
])
text_clf.fit(twenty_train.data, twenty_train.target)

CountVectorizer 计算文本中的标记,而 tfidfTransformer 重新加权这些计数。我们将在 工作原理... 部分讨论词项频率-逆文档频率TFIDF)重新加权。

训练后,我们可以在测试数据集上测试准确率:

predicted = text_clf.predict(twenty_test.data)
np.mean(predicted == twenty_test.target)

我们的准确率约为 0.805。让我们看看我们的另外两种方法表现如何。下一步是使用词嵌入。

词嵌入

我们将加载我们之前下载的词嵌入:

from gensim.models import KeyedVectors

model = KeyedVectors.load_word2vec_format(
    'wiki.en.vec',
    binary=False, encoding='utf8'
)

将文本的最简单策略向量化是对单词嵌入进行平均。对于短文本,这通常至少效果还不错:

import numpy as np
from tensorflow.keras.preprocessing.text import text_to_word_sequence

def embed_text(text: str):
  vector_list = [
    model.wv[w].reshape(-1, 1) for w in text_to_word_sequence(text)
    if w in model.wv
  ]
  if len(vector_list) > 0:
    return np.mean(
        np.concatenate(vector_list, axis=1),
        axis=1
    ).reshape(1, 300)
  else:
   return np.zeros(shape=(1, 300))

assert embed_text('training run').shape == (1, 300)

然后我们将这种向量化应用于我们的数据集,然后在这些向量的基础上训练一个随机森林分类器:

train_transformed = np.concatenate(
    [embed_text(t) for t in twenty_train.data]
)
rf = RandomForestClassifier().fit(train_transformed, twenty_train.target)

然后我们可以测试我们方法的性能:

test_transformed = np.concatenate(
    [embed_text(t) for t in twenty_test.data]
)
predicted = rf.predict(test_transformed)
np.mean(predicted == twenty_test.target)

我们的准确率约为 0.862。

让我们看看我们的最后一种方法是否比这个更好。我们将使用 Keras 的嵌入层构建定制的词嵌入。

自定义词嵌入

嵌入层是在神经网络中即时创建自定义词嵌入的一种方式:

from tensorflow.keras import layers

embedding = layers.Embedding(
    input_dim=5000, 
    output_dim=50, 
    input_length=500
)

我们必须告诉嵌入层希望存储多少单词,词嵌入应该具有多少维度,以及每个文本中有多少单词。我们将整数数组输入嵌入层,每个数组引用字典中的单词。我们可以将创建嵌入层输入的任务委托给 TensorFlow 实用函数:

from tensorflow.keras.preprocessing.text import Tokenizer

tokenizer = Tokenizer(num_words=5000)
tokenizer.fit_on_texts(twenty_train.data)

这创建了字典。现在我们需要对文本进行分词并将序列填充到适当的长度:

from tensorflow.keras.preprocessing.sequence import pad_sequences

X_train = tokenizer.texts_to_sequences(twenty_train.data)
X_test = tokenizer.texts_to_sequences(twenty_test.data)
X_train = pad_sequences(X_train, padding='post', maxlen=500)
X_test = pad_sequences(X_test, padding='post', maxlen=500)

现在我们准备构建我们的神经网络:

from tensorflow.keras.models import Sequential
from tensorflow.keras.losses import SparseCategoricalCrossentropy
from tensorflow.keras import regularizers

model = Sequential()
model.add(embedding)
model.add(layers.Flatten())
model.add(layers.Dense(
    10,
    activation='relu',
    kernel_regularizer=regularizers.l1_l2(l1=1e-5, l2=1e-4)
))
model.add(layers.Dense(len(categories), activation='softmax'))
model.compile(optimizer='adam',
              loss=SparseCategoricalCrossentropy(),
              metrics=['accuracy'])
model.summary()

我们的模型包含 50 万个参数。大约一半位于嵌入层,另一半位于前馈全连接层。

我们对网络进行了几个 epoch 的拟合,然后可以在测试数据上测试我们的准确性:

model.fit(X_train, twenty_train.target, epochs=10)
predicted = model.predict(X_test).argmax(axis=1)
np.mean(predicted == twenty_test.target)

我们获得约为 0.902 的准确率。我们还没有调整模型架构。

这完成了我们使用词袋模型、预训练词嵌入和自定义词嵌入进行新闻组分类的工作。现在我们来探讨一些背景。

工作原理...

我们已经根据三种不同的特征化方法对文本进行了分类:词袋模型、预训练词嵌入和自定义词嵌入。让我们简要地深入研究词嵌入和 TFIDF。

在第五章的基于知识做决策配方中,我们已经讨论了SkipgramContinuous Bag of WordsCBOW)算法,在启发式搜索技术与逻辑推理(在使用 Walklets 进行图嵌入子节中)。

简而言之,词向量是一个简单的机器学习模型,可以根据上下文(CBOW 算法)预测下一个词,或者可以根据一个单词预测上下文(Skipgram 算法)。让我们快速看一下 CBOW 神经网络。

CBOW 算法

CBOW 算法是一个两层前馈神经网络,用于从上下文中预测单词(更确切地说是稀疏索引向量):

这幅插图展示了在 CBOW 模型中,基于周围上下文预测单词的方式。在这里,单词被表示为词袋向量。隐藏层由上下文的加权平均值组成(线性投影)。输出单词是基于隐藏层的预测。这是根据法语维基百科关于词嵌入的页面上的一幅图片进行调整的:fr.wikipedia.org/wiki/Word_embedding

我们还没有讨论这些词嵌入的含义,这些词嵌入在它们出现时引起了轰动。这些嵌入是单词的网络激活,并具有组合性质,这为许多演讲和少数论文赋予了标题。我们可以结合向量进行语义代数或进行类比。其中最著名的例子是以下内容:

直观地说,国王和王后是相似的社会职位,只是一个由男人担任,另一个由女人担任。这在数十亿个单词学习的嵌入空间中得到了反映。从国王的向量开始,减去男人的向量,最后加上女人的向量,我们最终到达的最接近的单词是王后。

嵌入空间可以告诉我们关于我们如何使用语言的很多信息,其中一些信息有些令人担忧,比如当单词向量展现出性别刻板印象时。

这可以通过仿射变换在一定程度上进行校正,如 Tolga Bolukbasi 等人所示(Man is to Computer Programmer as Woman is to Homemaker? Debiasing Word Embeddings, 2016; arxiv.org/abs/1607.06520)。

让我们快速看看在这个方法的词袋法中采用的重新加权。

TFIDF

词袋模型部分,我们使用CountVectorizer来计数单词。这给了我们一个形状为的向量,其中是词汇表中的单词数量。词汇表必须在fit()阶段之前创建,然后transform()可以基于词汇表中标记(单词)的位置创建(稀疏)向量。

通过在多篇文档上应用CountVectorizer,我们可以得到一个形状为的稀疏矩阵,其中是语料库(文档集合),而是文档的数量。这个矩阵中的每个位置表示某个标记在文档中出现的次数。在这个方法中,一个标记对应一个单词,但它同样可以是字符或任何一组字符。

有些词可能出现在每个文档中;其他词可能只出现在文档的一个小子集中,这表明它们更为特定和精确。这就是 TFIDF 的直觉,即如果一个词在语料库(文档集合)中的频率低,则提升计数(矩阵中的列)的重要性。

给定一组文档  的反向术语  的定义如下:

这里  是术语的计数  在文档  中,以及  是术语  出现的文档数。您应该看到 TFIDF 值随  的增加而减少。随着术语出现在更多文档中,对数和 TFIDF 值接近 0。

在本章的下一个示例中,我们将超越单词的编码,研究更复杂的语言模型。

还有更多...

我们将简要介绍如何使用 Gensim 学习自己的词嵌入,构建更复杂的深度学习模型,并在 Keras 中使用预训练的词嵌入:

  1. 我们可以在 Gensim 上轻松地训练自己的词嵌入。

让我们读入一个文本文件,以便将其作为 fastText 的训练数据集:

from gensim.utils import tokenize
from gensim.test.utils import datapath

class FileIter(object):
  def __init(self, filepath: str):
    self.path = datapath(filepath)

  def __iter__(self):
    with utils.open(self.path, 'r', encoding='utf-8') as fin:
      for line in fin:
        yield list(tokenize(line))

这对于迁移学习、搜索应用或在学习嵌入过程中花费过长时间的情况非常有用。在 Gensim 中,这只需几行代码(改编自 Gensim 文档)。

训练本身很简单,并且由于我们的文本文件很小,所以相对快速:

from gensim.models import FastText

model = FastText(size=4, window=3, min_count=1)
model.build_vocab(
  sentences=FileIter(
    'crime-and-punishment.txt'
))
model.train(sentences=common_texts, total_examples=len(common_texts), epochs=10)

您可以在 Project Gutenberg 找到《罪与罚》等经典小说:www.gutenberg.org/ebooks/2554

您可以像这样从训练好的模型中检索向量:

model.wv['axe']

Gensim 具有丰富的功能,我们建议您阅读一些其文档。

  1. 构建更复杂的深度学习模型:对于更困难的问题,我们可以在嵌入层之上使用堆叠的conv1d层,如下所示:
x = Conv1D(128, 5, activation='relu')(embedded_sequences)
x = MaxPooling1D(5)(x)

卷积层具有非常少的参数,这是使用它们的另一个优点。

  1. 在 Keras 模型中使用预训练的词嵌入:如果我们想要使用已下载的(或之前定制的)词嵌入,我们也可以这样做。首先,我们需要创建一个字典,在加载它们后我们可以很容易地自己完成,例如,使用 Gensim:
word_index = {i: w for i, w in enumerate(model.wv.vocab.keys())}

然后我们可以将这些向量馈送到嵌入层中:

from tensorflow.keras.layers import Embedding

embedding_layer = Embedding(
    len(word_index) + 1,
    300,
    weights=[list(model.wv.vectors)],
    input_length=500,
    trainable=False
)

为了训练和测试,您必须通过在我们的新词典中查找它们来提供单词索引,并像之前一样将它们填充到相同的长度。

这就结束了我们关于新闻组分类的配方。我们应用了三种不同的特征化方法:词袋模型、预训练词嵌入以及简单神经网络中的自定义词嵌入。

另请参阅

在这个配方中,我们使用了词嵌入。已经介绍了许多不同的嵌入方法,并且已经发布了许多训练自数百亿字词和数百万文档的词嵌入矩阵。如果在租用的硬件上进行大规模训练,这可能会耗费数十万美元。最流行的词嵌入包括以下内容:

处理词嵌入的流行库包括以下内容:

Kashgari 是建立在 Keras 之上用于文本标注和文本分类的库,包括 Word2vec 和更高级的模型,如 BERT 和 GPT2 语言嵌入:github.com/BrikerMan/Kashgari

Hugging Face 变压器库(github.com/huggingface/transformers)包含许多先进的架构和许多变压器模型的预训练权重,可用于文本嵌入。这些模型可以在许多自然语言处理任务中实现最先进的性能。例如,谷歌等公司已将许多语言应用转移到 BERT 架构上。我们将在本章的将英语翻译为德语的文本配方中学习更多有关变压器架构的信息。

fast.ai 提供了关于使用 PyTorch 进行深度学习的许多教程和课程的综合信息;它还包含许多有关自然语言处理的资源:nlp.fast.ai/

最后,在自然语言处理中,分类任务中常常涉及成千上万甚至数百万个不同的标签。这种情况被称为eXtreme MultiLabelXML)场景。你可以在这里找到关于 XML 的笔记本教程:github.com/ppontisso/Extreme-Multi-Label-Classification

与用户聊天

1966 年,约瑟夫·韦伊岑鲍姆发表了一篇关于他的聊天机器人 ELIZA 的文章,名为ELIZA - 人与机器之间自然语言交流研究的计算机程序。以幽默的方式展示技术的局限性,该聊天机器人采用简单的规则和模糊的开放性问题,以表现出对话中的移情理解,并以具有讽刺意味的方式经常被视为人工智能的里程碑。该领域已经发展,今天,AI 助手就在我们身边:您可能有 Alexa、Google Echo 或市场上其他任何商业家庭助手。

在这个教程中,我们将构建一个 AI 助手。这其中的困难在于,人们表达自己的方式有无数种,而且根本不可能预料到用户可能说的一切。在这个教程中,我们将训练一个模型来推断他们想要什么,并且我们会相应地做出回应。

准备工作

对于这个教程,我们将使用 Fariz Rahman 开发的名为Eywa的框架。我们将从 GitHub 使用pip安装它:

!pip install git+https://www.github.com/farizrahman4u/eywa.git

Eywa 具有从对话代理中预期的主要功能,我们可以查看其代码,了解支撑其功能的建模。

我们还将通过pyOWM库使用 OpenWeatherMap Web API,因此我们也将安装这个库:

!pip install pyOWM

使用这个库,我们可以作为我们聊天机器人功能的一部分响应用户请求并请求天气数据。如果您想在自己的聊天机器人中使用此功能,您应该在OpenWeatherMap.org注册一个免费用户账户并获取您的 API 密钥,每天最多可请求 1,000 次。

让我们看看我们如何实现这一点。

如何做…

我们的代理将处理用户输入的句子,解释并相应地回答。它将首先预测用户查询的意图,然后提取实体,以更准确地了解查询的内容,然后返回答案:

  1. 让我们从意图类开始 - 基于一些短语示例,我们将定义诸如greetingstaxiweatherdatetimemusic等意图:
from eywa.nlu import Classifier

CONV_SAMPLES = {
    'greetings' : ['Hi', 'hello', 'How are you', 'hey there', 'hey'],
    'taxi' : ['book a cab', 'need a ride', 'find me a cab'],
    'weather' : ['what is the weather in tokyo', 'weather germany',
                   'what is the weather like in kochi',
                   'what is the weather like', 'is it hot outside'],
    'datetime' : ['what day is today', 'todays date', 'what time is it now',
                   'time now', 'what is the time'],
    'music' : ['play the Beatles', 'shuffle songs', 'make a sound']
}

CLF = Classifier()
for key in CONV_SAMPLES:
    CLF.fit(CONV_SAMPLES[key], key)

我们已经创建了基于对话样本的分类器。我们可以使用以下代码块快速测试其工作原理:

print(CLF.predict('will it rain today')) # >>> 'weather'
print(CLF.predict('play playlist rock n\'roll')) # >>> 'music'
print(CLF.predict('what\'s the hour?')) # >>> 'datetime'

我们可以成功预测所需操作是否涉及天气、酒店预订、音乐或时间。

  1. 作为下一步,我们需要了解意图是否有更具体的内容,例如伦敦的天气与纽约的天气,或者播放披头士与坎耶·韦斯特。我们可以使用eywa实体提取来实现此目的:
from eywa.nlu import EntityExtractor

X_WEATHER = [
  'what is the weather in tokyo',
  'weather germany',
  'what is the weather like in kochi'
]
Y_WEATHER = [
  {'intent': 'weather', 'place': 'tokyo'},
  {'intent': 'weather', 'place': 'germany'},
  {'intent': 'weather', 'place': 'kochi'}
]

EX_WEATHER = EntityExtractor()
EX_WEATHER.fit(X_WEATHER, Y_WEATHER)

这是为了检查天气预测的特定位置。我们也可以测试天气的实体提取:

EX_WEATHER.predict('what is the weather in London')

我们询问伦敦的天气,并且我们的实体提取成功地返回了地点名称:

{'intent': 'weather', 'place': 'London'}
  1. ELIZA
from pyowm import OWM

mgr = OWM('YOUR-API-KEY').weather_manager()

def get_weather_forecast(place):
    observation = mgr.weather_at_place(place)
    return observation.get_weather().get_detailed_status()

print(get_weather_forecast('London'))

介绍中提到的原始 ELIZA 有许多语句-响应对,例如以下内容:

overcast clouds

没有问候和日期,没有一个聊天机器人是完整的:

在匹配正则表达式的情况下,会随机选择一种可能的响应,如果需要,动词会进行转换,包括使用如下逻辑进行缩写:

X_GREETING = ['Hii', 'helllo', 'Howdy', 'hey there', 'hey', 'Hi']
Y_GREETING = [{'greet': 'Hii'}, {'greet': 'helllo'}, {'greet': 'Howdy'},
              {'greet': 'hey'}, {'greet': 'hey'}, {'greet': 'Hi'}]
EX_GREETING = EntityExtractor()
EX_GREETING.fit(X_GREETING, Y_GREETING)

X_DATETIME = ['what day is today', 'date today', 'what time is it now', 'time now']
Y_DATETIME = [{'intent' : 'day', 'target': 'today'}, {'intent' : 'date', 'target': 'today'},
              {'intent' : 'time', 'target': 'now'}, {'intent' : 'time', 'target': 'now'}]

EX_DATETIME = EntityExtractor()
EX_DATETIME.fit(X_DATETIME, Y_DATETIME)
  1. “您今天还有其他问题或疑虑需要我帮助您吗?”
_EXTRACTORS = {
  'taxi': None,
  'weather': EX_WEATHER,
  'greetings': EX_GREETING,
  'datetime': EX_DATETIME,
  'music': None
}

Eywa,一个用于对话代理的框架,具有三个主要功能:

import datetime

_EXTRACTORS = {
  'taxi': None,
  'weather': EX_WEATHER,
  'greetings': EX_GREETING,
  'datetime': EX_DATETIME,
  'music': None
}

def question_and_answer(u_query: str):
    q_class = CLF.predict(u_query)
    print(q_class)
    if _EXTRACTORS[q_class] is None:
      return 'Sorry, you have to upgrade your software!'

    q_entities = _EXTRACTORS[q_class].predict(u_query)
    print(q_entities)
    if q_class == 'greetings':
      return q_entities.get('greet', 'hello')

    if q_class == 'weather':
      place = q_entities.get('place', 'London').replace('_', ' ')
      return 'The forecast for {} is {}'.format(
          place,
          get_weather_forecast(place)
      )

    if q_class == 'datetime':
      return 'Today\'s date is {}'.format(
          datetime.datetime.today().strftime('%B %d, %Y')
      )

    return 'I couldn\'t understand what you said. I am sorry.'

在我们详细讨论这些内容之前,看一下介绍中提到的 ELIZA 聊天机器人可能会很有趣。这将希望我们了解需要理解更广泛语言集的改进。

这些是 Jez Higgins 在 GitHub 上的 ELIZA 仿制品的摘录:github.com/jezhiggins/eliza.py

while True:
    query = input('\nHow can I help you?')
    print(question_and_answer(query))

实体提取器 - 从句子中提取命名实体

你应该能够询问不同地方的日期和天气情况,但如果你询问出租车或音乐,它会告诉你需要升级你的软件。

<问候>

对于机器而言,在开始阶段,硬编码一些规则会更容易,但如果您想处理更多复杂性,您将构建解释意图和位置等参考的模型。

...

这总结了我们的配方。我们实现了一个简单的聊天机器人,首先预测意图,然后基于规则提取实体回答用户查询。

我们省略了呼叫出租车或播放音乐的功能:

“感谢您的来电,我的名字是 _。今天我能为您做什么?”

[r'Is there (.*)', [
    "Do you think there is %1?",
    "It's likely that there is %1.",
    "Would you like there to be %1?"
]],

我们可以使用 Python OpenWeatherMap 库(pyOWM)请求给定位置的天气预报。在撰写本文时,调用新功能get_weather_forecast(),将London作为参数传入,结果如下:

 gReflections = {
  #...
  "i'd" : "you would",
}

ELIZA 是如何工作的?

如果你有兴趣,你应该能够自己实现和扩展这个功能。

让我们基于分类器和实体提取创建一些互动。我们将编写一个响应函数,可以问候,告知日期和提供天气预报:

Eywa

请注意,如果您想执行此操作,您需要使用您自己的(免费)OpenWeatherMap API 密钥。

我们已经为基本任务实现了一个非常简单但有效的聊天机器人。很明显,这可以扩展和定制以处理更多或其他任务。

question_and_answer()函数回答用户查询。

不幸的是,可能与呼叫中心的经历看起来很相似。它们通常也使用脚本,例如以下内容:

这是如何运作的…

  • 如果我们问它问题,我们现在可以与我们的代理进行有限的对话:

  • 我们还需要编码我们对话代理的功能,例如查找天气预报。让我们首先进行天气请求:

  • 模式匹配 – 基于词性和语义意义进行变量匹配

这三者使用起来非常简单,但功能强大。我们在“如何做...”部分看到了前两者的功能。让我们看看基于语义上下文的食品类型模式匹配:

from eywa.nlu import Pattern

p = Pattern('I want to eat [food: pizza, banana, yogurt, kebab]')
p('i\'d like to eat sushi')

我们创建一个名为 food 的变量,并赋予样本值:pizzabananayogurt 和 kebab。在类似上下文中使用食品术语将匹配我们的变量。这个表达式应该返回这个:

{'food' : 'sushi'}

使用看起来与正则表达式非常相似,但正则表达式基于单词及其形态学,eywa.nlu.Pattern 则在语义上锚定在词嵌入中工作。

正则表达式(简称:regex)是定义搜索模式的字符序列。它由 Steven Kleene 首次形式化,并由 Ken Thompson 和其他人在 Unix 工具(如 QED、ed、grep 和 sed)中实现于 1960 年代。这种语法已进入 POSIX 标准,因此有时也称为POSIX 正则表达式。在 1990 年代末,随着 Perl 编程语言的出现,出现了另一种标准,称为Perl 兼容正则表达式PCRE),已在包括 Python 在内的不同编程语言中得到采用。

这些模型如何工作?

首先,eywa 库依赖于来自 explosion.ai 的 sense2vec 词嵌入。sense2vec 词嵌入由 Andrew Trask 和其他人引入(sense2vec – A Fast and Accurate Method for Word Sense Disambiguation In Neural Word Embeddings, 2015)。这个想法被 explosion.ai 接受,他们在 Reddit 讨论中训练了词性消歧的词嵌入。您可以在 explosion.ai 的网站上阅读更多信息:explosion.ai/blog/sense2vec-reloaded

分类器通过存储的对话项目并根据这些嵌入选择具有最高相似度分数的匹配项。请注意,eywa 还有另一个基于递归神经网络的模型实现。

另请参阅

创建聊天机器人的库和框架非常丰富,包括不同的想法和集成方式:

将文本从英语翻译成德语

在这个食谱中,我们将从头开始实现一个 Transformer 网络,并将其用于从英语到德语的翻译任务。在它是如何工作的...部分,我们将详细介绍很多细节。

准备就绪

我们建议使用带有GPU的机器。强烈推荐使用 Colab 环境,但请确保您正在使用启用了 GPU 的运行时。如果您想检查是否可以访问 GPU,可以调用 NVIDIA 系统管理接口:

!nvidia-smi

你应该看到类似这样的东西:

Tesla T4: 0MiB / 15079MiB

这告诉您正在使用 NVIDIA Tesla T4,已使用 1.5GB 的 0MB(1MiB 大约相当于 1.049MB)。

我们需要一个相对较新版本的torchtext,这是一个用于pytorch的文本数据集和实用工具库。

!pip install torchtext==0.7.0

对于还有更多...部分,您可能需要安装额外的依赖项:

!pip install hydra-core

我们正在使用 spaCy 进行标记化。这在 Colab 中预先安装。在其他环境中,您可能需要pip-install它。我们确实需要安装德语核心功能,例如spacy的标记化,在这个食谱中我们将依赖它:

!python -m spacy download de

我们将在食谱的主要部分加载此功能。

如何做...

在这个食谱中,我们将从头开始实现一个 Transformer 模型,并且将其用于翻译任务的训练。我们从 Ben Trevett 关于使用 PyTorch 和 TorchText 实现 Transformer 序列到序列模型的优秀教程中适应了这个笔记本:github.com/bentrevett/pytorch-seq2seq

我们将首先准备数据集,然后实现 Transformer 架构,接着进行训练,最后进行测试:

  1. 准备数据集 - 让我们预先导入所有必需的模块:
import torch
import torch.nn as nn
import torch.optim as optim

import torchtext
from torchtext.datasets import Multi30k
from torchtext.data import Field, BucketIterator

import matplotlib.pyplot as plt
import matplotlib.ticker as ticker

import spacy
import numpy as np

import math

我们将要训练的数据集是 Multi30k 数据集。这是一个包含约 30,000 个平行英语、德语和法语短句子的数据集。

我们将加载spacy功能,实现函数来标记化德语和英语文本:

spacy_de = spacy.load('de')
spacy_en = spacy.load('en')

def tokenize_de(text):
    return [tok.text for tok in spacy_de.tokenizer(text)]

def tokenize_en(text):
    return [tok.text for tok in spacy_en.tokenizer(text)]

这些函数将德语和英语文本从字符串标记化为字符串列表。

Field定义了将文本转换为张量的操作。它提供了常见文本处理工具的接口,并包含一个Vocab,将标记或单词映射到数值表示。我们正在传递我们的前面的标记化方法:

SRC = Field(
    tokenize=tokenize_en, 
    init_token='<sos>', 
    eos_token='<eos>', 
    lower=True, 
    batch_first=True
)

TRG = Field(
    tokenize=tokenize_de, 
    init_token='<sos>', 
    eos_token='<eos>', 
    lower=True, 
    batch_first=True
)

我们将从数据集中创建一个训练-测试-验证拆分。exts参数指定要用作源和目标的语言,fields指定要提供的字段。之后,我们从训练数据集构建词汇表:

train_data, valid_data, test_data = Multi30k.splits(
    exts=('.en', '.de'), 
    fields=(SRC, TRG)
)
SRC.build_vocab(train_data, min_freq=2)
TRG.build_vocab(train_data, min_freq=2)

然后我们可以定义我们的数据迭代器,覆盖训练、验证和测试数据集:

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
BATCH_SIZE = 128

train_iterator, valid_iterator, test_iterator = BucketIterator.splits(
    (train_data, valid_data, test_data), 
     batch_size=BATCH_SIZE,
     device=device
)

现在我们可以在训练此数据集之前构建我们的变压器架构。

  1. 在实施变压器架构时,重要部分是多头注意力和前馈连接。让我们先定义它们,首先从注意力开始:
class MultiHeadAttentionLayer(nn.Module):
    def __init__(self, hid_dim, n_heads, dropout, device):
        super().__init__()
        assert hid_dim % n_heads == 0
        self.hid_dim = hid_dim
        self.n_heads = n_heads
        self.head_dim = hid_dim // n_heads
        self.fc_q = nn.Linear(hid_dim, hid_dim)
        self.fc_k = nn.Linear(hid_dim, hid_dim)
        self.fc_v = nn.Linear(hid_dim, hid_dim)
        self.fc_o = nn.Linear(hid_dim, hid_dim)
        self.dropout = nn.Dropout(dropout)
        self.scale = torch.sqrt(torch.FloatTensor([self.head_dim])).to(device)

    def forward(self, query, key, value, mask = None):
        batch_size = query.shape[0]
        Q = self.fc_q(query)
        K = self.fc_k(key)
        V = self.fc_v(value)
        Q = Q.view(batch_size, -1, self.n_heads, self.head_dim).permute(0, 2, 1, 3)
        K = K.view(batch_size, -1, self.n_heads, self.head_dim).permute(0, 2, 1, 3)
        V = V.view(batch_size, -1, self.n_heads, self.head_dim).permute(0, 2, 1, 3)
        energy = torch.matmul(Q, K.permute(0, 1, 3, 2)) / self.scale
        if mask is not None:
            energy = energy.masked_fill(mask == 0, -1e10)
        attention = torch.softmax(energy, dim = -1)
        x = torch.matmul(self.dropout(attention), V)
        x = x.permute(0, 2, 1, 3).contiguous()
        x = x.view(batch_size, -1, self.hid_dim)
        x = self.fc_o(x)
        return x, attention

前馈层只是一个带有非线性激活、dropout 和线性读出的单向传递。第一个投影比原始隐藏维度大得多。在我们的情况下,我们使用隐藏维度为 512 和pf维度为 2048:

class PositionwiseFeedforwardLayer(nn.Module):
    def __init__(self, hid_dim, pf_dim, dropout):
        super().__init__()
        self.fc_1 = nn.Linear(hid_dim, pf_dim)
        self.fc_2 = nn.Linear(pf_dim, hid_dim)
        self.dropout = nn.Dropout(dropout)

    def forward(self, x):
        x = self.dropout(torch.relu(self.fc_1(x)))
        x = self.fc_2(x)
        return x

我们需要EncoderDecoder部分,每个部分都有自己的层。然后我们将这两者连接成Seq2Seq模型。

这是编码器的外观:

class Encoder(nn.Module):
    def __init__(self, input_dim, hid_dim,
                 n_layers, n_heads, pf_dim,
                 dropout, device,
                 max_length=100):
        super().__init__()
        self.device = device
        self.tok_embedding = nn.Embedding(input_dim, hid_dim)
        self.pos_embedding = nn.Embedding(max_length, hid_dim)
        self.layers = nn.ModuleList(
            [EncoderLayer(
                hid_dim,
                n_heads,
                pf_dim,
                dropout,
                device
            ) for _ in range(n_layers)])
        self.dropout = nn.Dropout(dropout)
        self.scale = torch.sqrt(torch.FloatTensor([hid_dim])).to(device)

    def forward(self, src, src_mask):
        batch_size = src.shape[0]
        src_len = src.shape[1]
        pos = torch.arange(
            0, src_len
        ).unsqueeze(0).repeat(
            batch_size, 1
        ).to(self.device)
        src = self.dropout(
            (self.tok_embedding(src) * self.scale) +
            self.pos_embedding(pos)
        )
        for layer in self.layers:
            src = layer(src, src_mask)
        return src

它由多个编码器层组成。它们看起来如下所示:

class EncoderLayer(nn.Module):
    def __init__(self, hid_dim, n_heads,
                 pf_dim, dropout, device):
        super().__init__()
        self.self_attn_layer_norm = nn.LayerNorm(hid_dim)
        self.ff_layer_norm = nn.LayerNorm(hid_dim)
        self.self_attention = MultiHeadAttentionLayer(hid_dim, n_heads, dropout, device)
        self.positionwise_feedforward = PositionwiseFeedforwardLayer(
            hid_dim, pf_dim, dropout
        )
        self.dropout = nn.Dropout(dropout)

    def forward(self, src, src_mask):
        _src, _ = self.self_attention(src, src, src, src_mask)
        src = self.self_attn_layer_norm(src + self.dropout(_src))
        _src = self.positionwise_feedforward(src)
        src = self.ff_layer_norm(src + self.dropout(_src))
        return src

解码器与编码器并没有太大的不同,但是它附带了两个多头注意力层。解码器看起来像这样:

class Decoder(nn.Module):
    def __init__(self, output_dim, hid_dim,
                 n_layers, n_heads, pf_dim,
                 dropout, device, max_length=100):
        super().__init__()
        self.device = device
        self.tok_embedding = nn.Embedding(output_dim, hid_dim)
        self.pos_embedding = nn.Embedding(max_length, hid_dim)
        self.layers = nn.ModuleList(
            [DecoderLayer(
                hid_dim, n_heads,
                pf_dim, dropout,
                device
            ) for _ in range(n_layers)]
        )
        self.fc_out = nn.Linear(hid_dim, output_dim)
        self.dropout = nn.Dropout(dropout)
        self.scale = torch.sqrt(torch.FloatTensor([hid_dim])).to(device)

    def forward(self, trg, enc_src, trg_mask, src_mask):
        batch_size = trg.shape[0]
        trg_len = trg.shape[1]
        pos = torch.arange(0, trg_len).unsqueeze(0).repeat(
            batch_size, 1
        ).to(self.device)
        trg = self.dropout(
            (self.tok_embedding(trg) * self.scale) +
            self.pos_embedding(pos)
        )
        for layer in self.layers:
            trg, attention = layer(trg, enc_src, trg_mask, src_mask)
        output = self.fc_out(trg)
        return output, attention

在序列中,解码器层执行以下任务:

  • 带掩码的自注意力

  • 前馈

  • 退出率

  • 残差连接

  • 标准化

自注意力层中的掩码是为了避免模型在预测中包含下一个标记(这将是作弊)。

让我们实现解码器层:

class DecoderLayer(nn.Module):
    def __init__(self, hid_dim, n_heads,
                 pf_dim, dropout, device):
        super().__init__()
        self.self_attn_layer_norm = nn.LayerNorm(hid_dim)
        self.enc_attn_layer_norm = nn.LayerNorm(hid_dim)
        self.ff_layer_norm = nn.LayerNorm(hid_dim)
        self.self_attention = MultiHeadAttentionLayer(hid_dim, n_heads, dropout, device)
        self.encoder_attention = MultiHeadAttentionLayer(hid_dim, n_heads, dropout, device)
        self.positionwise_feedforward = PositionwiseFeedforwardLayer(
            hid_dim, pf_dim, dropout
        )
        self.dropout = nn.Dropout(dropout)

    def forward(self, trg, enc_src, trg_mask, src_mask):
        _trg, _ = self.self_attention(trg, trg, trg, trg_mask)
        trg = self.self_attn_layer_norm(trg + self.dropout(_trg))
        _trg, attention = self.encoder_attention(trg, enc_src, enc_src, src_mask)
        trg = self.enc_attn_layer_norm(trg + self.dropout(_trg))
        _trg = self.positionwise_feedforward(trg)
        trg = self.ff_layer_norm(trg + self.dropout(_trg))
        return trg, attention

最后,在Seq2Seq模型中一切都汇聚在一起:

class Seq2Seq(nn.Module):
    def __init__(self, encoder, decoder,
                 src_pad_idx, trg_pad_idx, device):
        super().__init__()
        self.encoder = encoder
        self.decoder = decoder
        self.src_pad_idx = src_pad_idx
        self.trg_pad_idx = trg_pad_idx
        self.device = device

    def make_src_mask(self, src):
        src_mask = (src != self.src_pad_idx).unsqueeze(1).unsqueeze(2)
        return src_mask

    def make_trg_mask(self, trg):
        trg_pad_mask = (trg != self.trg_pad_idx).unsqueeze(1).unsqueeze(2)
        trg_len = trg.shape[1]
        trg_sub_mask = torch.tril(torch.ones((trg_len, trg_len), device=self.device)).bool()
        trg_mask = trg_pad_mask & trg_sub_mask
        return trg_mask

    def forward(self, src, trg):
        src_mask = self.make_src_mask(src)
        trg_mask = self.make_trg_mask(trg)
        enc_src = self.encoder(src, src_mask)
        output, attention = self.decoder(trg, enc_src, trg_mask, src_mask)
        return output, attention

现在我们可以用实际参数实例化我们的模型:

INPUT_DIM = len(SRC.vocab)
OUTPUT_DIM = len(TRG.vocab)
HID_DIM = 256
ENC_LAYERS = 3
DEC_LAYERS = 3
ENC_HEADS = 8
DEC_HEADS = 8
ENC_PF_DIM = 512
DEC_PF_DIM = 512
ENC_DROPOUT = 0.1
DEC_DROPOUT = 0.1

enc = Encoder(INPUT_DIM, 
    HID_DIM, ENC_LAYERS, 
    ENC_HEADS, ENC_PF_DIM, 
    ENC_DROPOUT, device
)

dec = Decoder(OUTPUT_DIM, 
    HID_DIM, DEC_LAYERS, 
    DEC_HEADS, DEC_PF_DIM, 
    DEC_DROPOUT, device
)
SRC_PAD_IDX = SRC.vocab.stoi[SRC.pad_token]
TRG_PAD_IDX = TRG.vocab.stoi[TRG.pad_token]

model = Seq2Seq(enc, dec, SRC_PAD_IDX, TRG_PAD_IDX, device).to(device)

整个模型共有 9,543,087 个可训练参数。

  1. 训练翻译模型时,我们可以使用 Xavier 均匀归一化来初始化参数:
def initialize_weights(m):
    if hasattr(m, 'weight') and m.weight.dim() > 1:
        nn.init.xavier_uniform_(m.weight.data)

model.apply(initialize_weights);

我们需要将学习率设置得比默认值低得多:

LEARNING_RATE = 0.0005

optimizer = torch.optim.Adam(model.parameters(), lr=LEARNING_RATE)

在我们的损失函数CrossEntropyLoss中,我们必须确保忽略填充的标记:

criterion = nn.CrossEntropyLoss(ignore_index=TRG_PAD_IDX)

我们的训练函数如下所示:

def train(model, iterator, optimizer, criterion, clip):
    model.train()
    epoch_loss = 0
    for i, batch in enumerate(iterator):
        src = batch.src
        trg = batch.trg
        optimizer.zero_grad()
        output, _ = model(src, trg[:, :-1])
        output_dim = output.shape[-1]
        output = output.contiguous().view(-1, output_dim)
        trg = trg[:,1:].contiguous().view(-1)
        loss = criterion(output, trg)
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), clip)
        optimizer.step()
        epoch_loss += loss.item()
    return epoch_loss / len(iterator)

然后在循环中执行训练:

N_EPOCHS = 10
CLIP = 1
best_valid_loss = float('inf')

for epoch in range(N_EPOCHS):    
    train_loss = train(model, train_iterator, optimizer, criterion, CLIP)

    print(f'\tTrain Loss: {train_loss:.3f} | Train PPL: {math.exp(train_loss):7.3f}')

我们在这里略微简化了事情。您可以在 GitHub 上找到完整的笔记本。

这个模型训练了 10 个时期。

  1. 测试模型时,我们首先必须编写函数来为模型编码一个句子,并将模型输出解码回句子。然后我们可以运行一些句子并查看翻译。最后,我们可以计算测试集上的翻译性能指标。

为了翻译一个句子,我们必须使用之前创建的源词汇表将其数值化编码,并在将其馈送到我们的模型之前附加停止标记。然后,必须从目标词汇表中解码模型输出:

def translate_sentence(sentence, src_field, trg_field, model, device, max_len=50):
    model.eval()
    if isinstance(sentence, str):
        nlp = spacy.load('en')
        tokens = [token.text.lower() for token in nlp(sentence)]
    else:
        tokens = [token.lower() for token in sentence]
    tokens = [src_field.init_token] + tokens + [src_field.eos_token]
    src_indexes = [src_field.vocab.stoi[token] for token in tokens]
    src_tensor = torch.LongTensor(src_indexes).unsqueeze(0).to(device)
    src_mask = model.make_src_mask(src_tensor)
    with torch.no_grad():
        enc_src = model.encoder(src_tensor, src_mask)
    trg_indexes = [trg_field.vocab.stoi[trg_field.init_token]]
    for i in range(max_len):
        trg_tensor = torch.LongTensor(trg_indexes).unsqueeze(0).to(device)
        trg_mask = model.make_trg_mask(trg_tensor)
        with torch.no_grad():
            output, attention = model.decoder(trg_tensor, enc_src, trg_mask, src_mask)
        pred_token = output.argmax(2)[:, -1].item()
        trg_indexes.append(pred_token)
        if pred_token == trg_field.vocab.stoi[trg_field.eos_token]:
            break
    trg_tokens = [trg_field.vocab.itos[i] for i in trg_indexes]
    return trg_tokens[1:], attention

我们可以看一个示例对并检查翻译:

example_idx = 8

src = vars(train_data.examples[example_idx])['src']
trg = vars(train_data.examples[example_idx])['trg']

print(f'src = {src}')
print(f'trg = {trg}')

我们得到了以下对:

src = ['a', 'woman', 'with', 'a', 'large', 'purse', 'is', 'walking', 'by', 'a', 'gate', '.']
trg = ['eine', 'frau', 'mit', 'einer', 'großen', 'geldbörse', 'geht', 'an', 'einem', 'tor', 'vorbei', '.']

我们可以将其与我们模型获得的翻译进行比较:

translation, attention = translate_sentence(src, SRC, TRG, model, device)
print(f'predicted trg = {translation}')

这是我们的翻译句子:

predicted trg = ['eine', 'frau', 'mit', 'einer', 'großen', 'handtasche', 'geht', 'an', 'einem', 'tor', 'vorbei', '.', '<eos>']

我们的翻译实际上比原始翻译好看。钱包(geldbörse)不是真正的钱包,而是一个小包(handtasche)。

然后,我们可以计算我们模型与黄金标准的 BLEU 分数的指标:

from torchtext.data.metrics import bleu_score

def calculate_bleu(data, src_field, trg_field, model, device, max_len=50):
    trgs = []
    pred_trgs = []

    for datum in data:
        src = vars(datum)['src']
        trg = vars(datum)['trg']
        pred_trg, _ = translate_sentence(src, src_field, trg_field, model, device, max_len)

        #cut off <eos> token
        pred_trg = pred_trg[:-1]

        pred_trgs.append(pred_trg)
        trgs.append([trg])

    return bleu_score(pred_trgs, trgs)

bleu_score = calculate_bleu(test_data, SRC, TRG, model, device)

print(f'BLEU score = {bleu_score*100:.2f}')

我们得到了 33.57 的 BLEU 分数,这个分数还不错,同时训练参数更少,训练时间只需几分钟。

在翻译中,一个有用的度量标准是双语评估助手BLEU)分数,其中 1 是最佳值。它是候选翻译部分与参考翻译(黄金标准)部分的比率,其中部分可以是单个词或一个词序列(n-grams)。

这就是我们的翻译模型了。我们可以看到实际上创建一个翻译模型并不是那么困难。然而,其中有很多理论知识,我们将在下一节中进行介绍。

它的工作原理...

在这个示例中,我们为英语到德语的翻译任务从头开始训练了一个变压器模型。让我们稍微了解一下变压器是什么,以及它是如何工作的。

不久之前,长短期记忆网络LSTMs)一直是深度学习模型的主要选择,然而,由于单词是按顺序处理的,训练可能需要很长时间才能收敛。在前面的示例中,我们已经看到递归神经网络如何用于序列处理(请与第九章中的生成旋律食谱进行比较,音频和语音中的深度学习)。在其他示例中,例如在第九章中的识别语音命令食谱中,我们讨论了卷积模型如何替代这些递归网络,以提高速度和预测性能。在自然语言处理中,卷积网络也已经尝试过(例如,Jonas Gehring 等人的卷积序列到序列学习,2017 年),相较于递归模型,速度和预测性能有所改善,然而,变压器架构证明更加强大且更快。

变压器架构最初是为机器翻译而创建的(Ashish Vaswani 等人的注意力机制就是你所需要的,2017 年)。变压器网络摒弃了递归和卷积,训练和预测速度大大加快,因为单词可以并行处理。变压器架构提供了通用的语言模型,在许多任务中推动了技术发展,如神经机器翻译NMT),问答系统QA),命名实体识别NER),文本蕴涵TE),抽象文本摘要等。变压器模型通常被拿来即插即用,并针对特定任务进行微调,以便从长期和昂贵的训练过程中获得的通用语言理解中受益。

Transformers 由两部分组成,类似于自动编码器:

  • 一个编码器 – 它将输入编码为一系列上下文向量(也称为隐藏状态)。

  • 一个解码器 – 它接收上下文向量并将其解码为目标表示。

我们的示例中实现与原始变压器实现(Ashish Vaswani 等人的注意力机制就是你所需要的,2017 年)之间的差异如下:

  • 我们使用了学习的位置编码而不是静态的编码。

  • 我们使用固定的学习率(没有预热和冷却步骤)。

  • 我们不使用标签平滑处理。

这些变化与现代转换器(如 BERT)保持同步。

首先,输入通过嵌入层和位置嵌入层传递,以编码序列中令牌的位置。令牌嵌入通过缩放为(隐藏层大小的平方根),并添加到位置嵌入中。最后,应用 dropout 进行正则化。

然后编码器通过堆叠模块传递,每个模块包括注意力、前馈全连接层和归一化。注意力层是缩放的乘法(点积)注意力层的线性组合(多头注意力)。

一些转换器架构只包含其中的一部分。例如,OpenAI GPT 转换器架构(Alec Radfor 等人,《通过生成预训练改进语言理解》,2018 年),生成了非常连贯的文本,由堆叠的解码器组成,而 Google 的 BERT 架构(Jacob Devlin 等人,《BERT: 深度双向转换器的预训练用于语言理解》,2019 年),也由堆叠的编码器组成。

还有更多...

Torch 和 TensorFlow 都有预训练模型的存储库。我们可以从 Torch Hub 下载一个翻译模型并立即使用它。这就是我们将快速展示的内容。对于pytorch模型,我们首先需要安装一些依赖项:

!pip install fairseq fastBPE sacremoses

完成之后,我们可以下载模型。它非常大,这意味着它会占用大量磁盘空间:

import torch

en2de = torch.hub.load(
    'pytorch/fairseq',
    'transformer.wmt19.en-de',
    checkpoint_file='model1.pt:model2.pt:model3.pt:model4.pt',
    tokenizer='moses',
    bpe='fastbpe'
)
en2de.translate('Machine learning is great!')

我们应该得到这样的输出:

Maschinelles Lernen ist großartig!

这个模型(Nathan Ng 等人,《Facebook FAIR 的 WMT19 新闻翻译任务提交》,2019 年)在翻译方面处于技术领先地位。它甚至在精度(BLEU 分数)上超越了人类翻译。fairseq附带了用于在您自己的数据集上训练翻译模型的教程。

Torch Hub 提供了许多不同的翻译模型,还有通用语言模型。

另请参阅

您可以在哈佛大学自然语言处理组的网站上找到关于转换器架构的指南,包括 PyTorch 代码(以及关于位置编码的解释),它还可以在 Google Colab 上运行:nlp.seas.harvard.edu/2018/04/03/attention.html

OpenAI 的 Lilian Weng 已经写过关于语言建模和转换器模型的文章,并提供了简明清晰的概述:

至于支持翻译任务的库,pytorchtensorflow 都提供预训练模型,并支持在翻译中有用的架构:

最后,OpenNMT 是一个基于 PyTorch 和 TensorFlow 的框架,用于翻译任务,拥有许多教程和预训练模型:opennmt.net/

撰写一本流行小说

我们之前提到过图灵测试,用于判断计算机是否足够智能以欺骗审问者认为它是人类。一些文本生成工具生成的文章可能在外表上看起来有意义,但在科学语言背后缺乏智力价值。

一些人类的文章和言论也可能如此。纳西姆·塔勒布在他的书《随机漫步的傻子》中认为,如果一个人的写作无法与人工生成的文章区分开来(一种逆图灵测试),则可以称其为不聪明。类似地,艾伦·索卡尔在 1996 年的恶作剧文章《超越边界:走向量子引力的转变诠释学》被一位物理学教授故意编造,以揭露缺乏思维严谨和对科学术语的误用。一个可能的结论是,模仿人类可能不是智力进步的正确方向。

OpenAI GPT-3 拥有 1750 亿个参数,显著推动了语言模型领域的发展,学习了物理学的事实,能够基于描述生成编程代码,并能够撰写娱乐性和幽默性的散文。

数百万全球粉丝已经等待 200 多年,想知道伊丽莎白和达西先生的《傲慢与偏见》故事如何继续。在这个配方中,我们将使用基于变压器的模型生成《傲慢与偏见 2》。

准备就绪

Project Gutenberg 是一个数字图书馆(大部分为公有领域电子书),拥有超过 60,000 本书籍,以纯文本、HTML、PDF、EPUB、MOBI 和 Plucker 等格式提供。Project Gutenberg 还列出了最受欢迎的下载:www.gutenberg.org/browse/scores/top

在撰写本文时,简·奥斯汀的浪漫 19 世纪初的小说傲慢与偏见在过去 30 天内下载量最高(超过 47,000 次)。我们将以纯文本格式下载这本书:

!wget -O pride_and_prejudice.txt http://www.gutenberg.org/files/1342/1342-0.txt

我们将文本文件保存为pride_and_prejudice.txt

我们将在 Colab 中工作,您将可以访问 Nvidia T4 或 Nvidia K80 GPU。但是,您也可以使用自己的计算机,使用 GPU 甚至 CPU。

如果您在 Colab 中工作,您需要将您的文本文件上传到您的 Google Drive (drive.google.com),这样您可以从 Colab 访问它。

我们将使用一个称为gpt-2-simple的 OpenAI GPT-2 的包装库,由 BuzzFeed 的数据科学家 Max Woolf 创建和维护:

%tensorflow_version 1.x
!pip install -q gpt-2-simple

此库将使模型对新文本进行微调并在训练过程中显示文本样本变得更加容易。

然后我们可以选择 GPT-2 模型的大小。OpenAI 已经发布了四种大小的预训练模型:

  • (124 百万参数;占用 500 MB)

  • 中等(355 百万参数;1.5 GB)

  • (774 百万)

  • 超大(1,558 百万)

大模型目前无法在 Colab 中进行微调,但可以从预训练模型生成文本。超大模型太大以至于无法加载到 Colab 的内存中,因此既不能进行微调也不能生成文本。尽管较大的模型会取得更好的性能并且具有更多知识,但它们需要更长时间来训练和生成文本。

我们将选择小模型:

import gpt_2_simple as gpt2
gpt2.download_gpt2(model_name='124M')

让我们开始吧!

如何做...

我们已经下载了一本流行小说傲慢与偏见的文本,并将首先对模型进行微调,然后生成类似傲慢与偏见的文本:

  1. 微调模型:我们将加载一个预训练模型,并对我们的文本进行微调。

我们将挂载 Google Drive。gpt-2-simple库提供了一个实用函数:

gpt2.mount_gdrive()

此时,您需要授权 Colab 笔记本访问您的 Google Drive。我们将使用之前上传到 Google Drive 的傲慢与偏见文本文件:

gpt2.copy_file_from_gdrive(file_name)

然后我们可以基于我们下载的文本开始微调:

sess = gpt2.start_tf_sess()

gpt2.finetune(
    sess,
    dataset=file_name,
    model_name='124M',
    steps=1000,
    restore_from='fresh',
    run_name='run1',
    print_every=10,
    sample_every=200,
    save_every=500
)

我们应该看到训练损失在至少几个小时内下降。我们在训练过程中会看到生成文本的样本,例如这个:

she will now make her opinions known to the whole of the family, and
 to all their friends.

 “At a time when so many middle-aged people are moving into
 town, when many couples are making fortunes off each other, when
 many professions of taste are forming in the society, I am
 really anxious to find some time here in the course of the next three
 months to write to my dear Elizabeth, to seek her informed opinion
 on this happy event, and to recommend it to her husband’s conduct as well
 as her own. I often tell people to be cautious when they see
 connections of importance here. What is to be done after my death?
 To go through the world in such a way as to be forgotten?”

 Mr. Bennet replied that he would write again at length to write
 very near to the last lines of his letter. Elizabeth cried
 out in alarm, and while she remained, a sense of shame had
 entered her of her being the less attentive companion she had been
 when she first distinguished her acquaintance. Anxiety increased
 every moment for the other to return to her senses, and
 every opportunity for Mr. Bennet to shine any brighter was given
 by the very first letter.

gpt-2-simple库确实使得训练和继续训练变得非常容易。所有模型检查点都可以存储在 Google Drive 上,因此在运行时超时时它们不会丢失。我们可能需要多次重启,因此始终在 Google Drive 上备份是个好习惯:

gpt2.copy_checkpoint_to_gdrive(run_name='run1')

如果我们希望在 Colab 重新启动后继续训练,我们也可以这样做:

# 1\. copy checkpoint from google drive:
gpt2.copy_checkpoint_from_gdrive(run_name='run1')

# 2\. continue training:
sess = gpt2.start_tf_sess()
gpt2.finetune(
    sess,
    dataset=file_name,
    model_name='124M',
    steps=500,
    restore_from='latest',
    run_name='run1',
    print_every=10,
    overwrite=True,
    sample_every=200,
    save_every=100
)
# 3\. let's backup the model again:
gpt2.copy_checkpoint_to_gdrive(run_name='run1')

现在我们可以生成我们的新小说了。

  1. 写我们的新畅销书:我们可能需要从 Google Drive 获取模型并将其加载到 GPU 中:
gpt2.copy_checkpoint_from_gdrive(run_name='run1')
sess = gpt2.start_tf_sess()
gpt2.load_gpt2(sess, run_name='run1')

请注意,您可能需要再次重启笔记本(Colab),以避免 TensorFlow 变量冲突。

  1. 现在我们可以调用gpt-2-simple中的一个实用函数将文本生成到文件中。最后,我们可以下载该文件:
gen_file = 'gpt2_gentext_{:%Y%m%d_%H%M%S}.txt'.format(datetime.utcnow())

gpt2.generate_to_file(
  sess,
  destination_path=gen_file,
  temperature=0.7,
  nsamples=100,
  batch_size=20
)
files.download(gen_file)

gpt_2_simple.generate() 函数接受一个可选的prefix参数,这是要继续的文本。

傲慢与偏见——传奇继续;阅读文本时,有时可以看到一些明显的连续性缺陷,然而,有些段落令人着迷。我们总是可以生成几个样本,这样我们就可以选择我们小说的继续方式。

工作原理...

在这个示例中,我们使用了 GPT-2 模型来生成文本。这被称为神经故事生成,是神经文本生成的一个子集。简而言之,神经文本生成是构建文本或语言的统计模型,并应用该模型生成更多文本的过程。

XLNet、OpenAI 的 GPT 和 GPT-2、Google 的 Reformer、OpenAI 的 Sparse Transformers 以及其他基于变换器的模型有一个共同点:它们是由于建模选择而具有生成性——它们是自回归而不是自编码的。这种自回归语言生成基于这样的假设:在给定长度为的上下文序列时,可以预测令牌的概率:

可以通过最小化预测令牌与实际令牌的交叉熵来近似这一过程。例如,LSTM、生成对抗网络GANs)或自回归变换器架构已经用于此目的。

在文本生成中,我们需要做出的一个主要选择是如何抽样,我们有几种选择:

  • 贪婪搜索

  • 束搜索

  • Top-k 抽样

  • Top-p(核心)抽样

在贪婪搜索中,每次选择评分最高的选择,忽略其他选择。相比之下,束搜索(beam search)并行跟踪几个选择的分数,以选择最高分序列,而不是选择高分令牌。Top-k 抽样由 Angela Fan 等人提出(Hierarchical Neural Story Generation, 2018)。在 top-k 抽样中,除了最可能的k个词语外,其他词语都被丢弃。相反,在 top-p(也称为核心抽样)中,选择高分词汇超过概率阈值p,而其他词语则被丢弃。可以结合使用 top-k 和 top-p 以避免低排名词汇。

尽管huggingface transformers库为我们提供了所有这些选择,但是使用gpt-2-simple时,我们可以选择使用 top-k 抽样和 top-p 抽样。

参见

有许多出色的库可以使模型训练或应用现成模型变得更加容易。首先,也许是Hugging Face transformers,这是一个语言理解和生成库,支持 BERT、GPT-2、RoBERTa、XLM、DistilBert、XLNet、T5 和 CTRL 等架构和预训练模型:github.com/huggingface/transformers

Hugging Face transformers 库提供了一些预训练的变换器模型,包括精简版的 GPT-2 模型,该模型在性能上接近 GPT-2,但参数数量减少了约 30%,带来更高的速度和更低的内存及处理需求。你可以从 Hugging Face 的 GitHub 仓库中找到几篇链接的笔记本,描述了文本生成和变换器模型的微调:github.com/huggingface/transformers/tree/master/notebooks#community-notebooks.

此外,Hugging Face 还提供了一个名为Write with Transformers的网站,根据他们的口号,该网站可以自动补全你的思路transformer.huggingface.co/.

在 TensorFlow 文档中,你可以找到关于循环神经网络文本生成的教程:www.tensorflow.org/tutorials/text/text_generation.

这些模型还预装在诸如 textgenrnn 这样的库中:github.com/minimaxir/textgenrnn.

更复杂的基于变换器的模型也可以从 TensorFlow Hub 获取,正如另一个教程所示:www.tensorflow.org/hub/tutorials/wiki40b_lm.

第十一章:生产中的人工智能

在涉及人工智能AI)的系统创建中,实际上 AI 通常只占总工作量的一小部分,而实施的主要部分涉及周围基础设施,从数据收集和验证开始,特征提取,分析,资源管理,到服务和监控(David Sculley 等人,《机器学习系统中隐藏的技术债务》,2015 年)。

在本章中,我们将处理监控和模型版本控制、作为仪表板的可视化以及保护模型免受可能泄露用户数据的恶意黑客攻击。

在本章中,我们将介绍以下配方:

  • 可视化模型结果

  • 为实时决策服务模型

  • 保护模型免受攻击

技术要求

对于 Python 库,我们将使用在 TensorFlow 和 PyTorch 中开发的模型,并在每个配方中应用不同的、更专业的库。

你可以在 GitHub 上找到配方笔记本,网址是github.com/PacktPublishing/Artificial-Intelligence-with-Python-Cookbook/tree/master/chapter11

可视化模型结果

与业务股东的频繁沟通是获取部署 AI 解决方案的关键,并应从思路和前提到决策和发现开始。结果如何传达可以决定在商业环境中成功或失败的关键因素。在这个配方中,我们将为一个机器学习ML)模型构建一个可视化解决方案。

准备工作

我们将在streamlit (www.streamlit.io/)中构建我们的解决方案,并使用来自altair的可视化工具,这是streamlit集成的众多 Python 绘图库之一(还包括matplotlibplotly)。让我们安装streamlitaltair

pip install streamlit altair

在这个配方中,我们不会使用笔记本。因此,在这个代码块中,我们已经省略了感叹号。我们将从终端运行所有内容。

Altair 有一种非常愉快的声明性方法来绘制图形,我们将在配方中看到。Streamlit 是一个创建数据应用程序的框架 - 在浏览器中具有可视化功能的交互式应用程序。

让我们继续构建一个交互式数据应用程序。

如何做…

我们将构建一个简单的应用程序用于模型构建。这旨在展示如何轻松创建一个面向浏览器的视觉交互式应用程序,以向非技术或技术观众展示发现。

作为对streamlit的快速实用介绍,让我们看看如何在 Python 脚本中的几行代码可以提供服务。

Streamlit hello-world

我们将以 Python 脚本形式编写我们的 streamlit 应用程序,而不是笔记本,并且我们将使用 streamlit 执行这些脚本以进行部署。

我们将在我们喜爱的编辑器(例如 vim)中创建一个新的 Python 文件,假设名为streamlit_test.py,并编写以下代码行:

import streamlit as st

chosen_option = st.sidebar.selectbox('Hello', ['A', 'B', 'C'])
st.write(chosen_option)

这将显示一个选择框或下拉菜单,标题为Hello,并在选项ABC之间进行选择。这个选择将存储在变量chosen_option中,在浏览器中可以输出。

我们可以从终端运行我们的简介应用程序如下:

streamlit run streamlit_test.py --server.port=80

服务器端口选项是可选的。

这应该在新标签页或窗口中打开我们的浏览器,显示带有三个选项的下拉菜单。我们可以更改选项,新值将显示出来。

这应该足够作为介绍。现在我们将进入实际的步骤。

创建我们的数据应用

我们数据应用的主要思想是将建模选择等决策纳入我们的应用程序,并观察其后果,无论是用数字总结还是在图表中直观显示。

我们将从实现核心功能开始,如建模和数据集加载,然后创建其界面,首先是侧边栏,然后是主页面。我们将所有代码写入一个单独的 Python 脚本中,可以称之为visualizing_model_results.py

  1. 加载数据集 – 我们将实现数据集加载器:

让我们从一些预备工作开始,如导入:

import numpy as np
import pandas as pd
import altair as alt
import streamlit as st

from sklearn.datasets import (
    load_iris,
    load_wine,
    fetch_covtype
)
from sklearn.model_selection import train_test_split
from sklearn.ensemble import (
    RandomForestClassifier,
    ExtraTreesClassifier,
)
from sklearn.tree import DecisionTreeClassifier
from sklearn.metrics import roc_auc_score
from sklearn.metrics import classification_report

如果您注意到了 hello-world 介绍,您可能会想知道界面如何与 Python 通信。这由 streamlit 处理,每次用户点击某处或输入字段时重新运行您的脚本。

我们需要将数据集加载到内存中。这可能包括下载步骤,对于更大的数据集,下载可能需要很长时间。因此,我们将缓存此步骤到磁盘上,所以不是每次点击按钮时都要下载,我们将从磁盘缓存中检索它:

dataset_lookup = {
    'Iris': load_iris,
    'Wine': load_wine,
    'Covertype': fetch_covtype,
}

@st.cache
def load_data(name):
    iris = dataset_lookup[name]()
    X_train, X_test, y_train, y_test = train_test_split(
        iris.data, iris.target, test_size=0.33, random_state=42
    )
    feature_names = getattr(
        iris, 'feature_names',
        [str(i) for i in range(X_train.shape[1])]
    )
    target_names = getattr(
        iris, 'target_names',
        [str(i) for i in np.unique(iris.target)]
    )
    return (
        X_train, X_test, y_train, y_test,
        target_names, feature_names
    )

这实现了建模和数据集加载器的功能。

请注意使用 streamlit 缓存装饰器,@st.cache。它处理装饰函数(在这种情况下是load_data())的缓存,使得传递给函数的任意数量的参数都将与相关输出一起存储。

这里,数据集加载可能需要一些时间。但是,缓存意味着我们只需加载每个数据集一次,因为随后的数据集将从缓存中检索,因此加载速度会更快。这种缓存功能,可以应用于长时间运行的函数,是使 streamlit 响应更快的核心。

我们正在使用 scikit-learn 数据集 API 下载数据集。由于 scikit-learn 的load_x()类型函数(如load_iris(),主要用于玩具数据集)包括target_namesfeature_names等属性,但是 scikit-learn 的fetch_x()函数(如fetch_covtype())用于更大、更严肃的数据集,我们将为这些分别生成特征和目标名称。

训练过程同样被装饰成可以缓存。但请注意,为了确保缓存与模型类型、数据集以及所有超参数都是唯一的,我们必须包含我们的超参数:

@st.cache
def train_model(dataset_name, model_name, n_estimators, max_depth):
    model = [m for m in models if m.__class__.__name__ == model_name][0]
    with st.spinner('Building a {} model for {} ...'.format(
            model_name, dataset_name
    )):
        return model.fit(X_train, y_train)

建模函数接受模型列表,我们将根据超参数的选择进行更新。我们现在将实施这个选择。

  1. 在侧边栏中呈现关键决策:

在侧边栏中,我们将呈现数据集、模型类型和超参数的选择。让我们首先选择数据集:

st.sidebar.title('Model and dataset selection')
dataset_name = st.sidebar.selectbox(
    'Dataset',
    list(dataset_lookup.keys())
)
(X_train, X_test, y_train, y_test,
 target_names, feature_names) = load_data(dataset_name)

这将在我们在 iris、wine 和 cover type 之间做出选择后加载数据集。

对于模型的超参数,我们将提供滑动条:

n_estimators = st.sidebar.slider(
    'n_estimators',
    1, 100, 25
)
max_depth = st.sidebar.slider(
    'max_depth',
    1, 150, 10
)

最后,我们将再次将模型类型暴露为一个下拉菜单:

models = [
    DecisionTreeClassifier(max_depth=max_depth),
    RandomForestClassifier(
        n_estimators=n_estimators,
        max_depth=max_depth
    ),
    ExtraTreesClassifier(
        n_estimators=n_estimators,
        max_depth=max_depth
    ),
]
model_name = st.sidebar.selectbox(
    'Model',
    [m.__class__.__name__ for m in models]
)
model = train_model(dataset_name, model_name, n_estimators, max_depth)

最后,在选择后,我们将调用train_model()函数,参数为数据集、模型类型和超参数。

此截图显示了侧边栏的外观:

这显示了浏览器中的菜单选项。我们将在浏览器页面的主要部分展示这些选择的结果。

  1. 在主面板上报告分类结果:

在主面板上,我们将展示重要的统计数据,包括分类报告、几个图表,应该能够揭示模型的优势和劣势,以及数据本身的视图,在这里模型错误的决策将被突出显示。

我们首先需要一个标题:

st.title('{model} on {dataset}'.format(
    model=model_name,
    dataset=dataset_name
))

然后我们将展示与我们的建模结果相关的基本统计数据,例如曲线下面积、精度等等:

predictions = model.predict(X_test)
probs = model.predict_proba(X_test)
st.subheader('Model performance in test')
st.write('AUC: {:.2f}'.format(
    roc_auc_score(
        y_test, probs,
        multi_class='ovo' if len(target_names) > 2 else 'raise',
        average='macro' if len(target_names) > 2 else None
    )
))
st.write(
    pd.DataFrame(
        classification_report(
            y_test, predictions,
            target_names=target_names,
            output_dict=True
        )
    )
) 

然后,我们将展示一个混淆矩阵,表格化每个类别的实际和预测标签:

test_df = pd.DataFrame(
    data=np.concatenate([
        X_test,
        y_test.reshape(-1, 1),
        predictions.reshape(-1, 1)
    ], axis=1),
    columns=feature_names + [
        'target', 'predicted'
    ]
)
target_map = {i: n for i, n in enumerate(target_names)}
test_df.target = test_df.target.map(target_map)
test_df.predicted = test_df.predicted.map(target_map)
confusion_matrix = pd.crosstab(
    test_df['target'],
    test_df['predicted'],
    rownames=['Actual'],
    colnames=['Predicted']
)
st.subheader('Confusion Matrix')
st.write(confusion_matrix)

我们还希望能够滚动查看被错误分类的测试数据样本:

def highlight_error(s):
    if s.predicted == s.target:
        return ['background-color: None'] * len(s)
    return ['background-color: red'] * len(s)

if st.checkbox('Show test data'):
    st.subheader('Test data')
    st.write(test_df.style.apply(highlight_error, axis=1))

错误分类的样本将以红色背景突出显示。我们将这种原始数据探索设为可选项,需要通过点击复选框激活。

最后,我们将展示变量在散点图中相互绘制的面板图。这部分将使用altair库:

if st.checkbox('Show test distributions'):
    st.subheader('Distributions')
    row_features = feature_names[:len(feature_names)//2]
    col_features = feature_names[len(row_features):]
    test_df_with_error = test_df.copy()
    test_df_with_error['error'] = test_df.predicted == test_df.target
    chart = alt.Chart(test_df_with_error).mark_circle().encode(
            alt.X(alt.repeat("column"), type='quantitative'),
            alt.Y(alt.repeat("row"), type='quantitative'),
            color='error:N'
    ).properties(
            width=250,
            height=250
    ).repeat(
        row=row_features,
        column=col_features
    ).interactive()
    st.write(chart)

这些图中突出显示了错误分类的例子。同样,我们将这部分设为可选项,通过标记复选框激活。

主页上部分用于Covetype数据集的样式如下:

您可以看到分类报告和混淆矩阵。在这些内容之下(不在截图范围内),将是数据探索和数据图表。

这结束了我们的演示应用程序。我们的应用程序相对简单,但希望这个方法能作为构建这些应用程序以进行清晰沟通的指南。

工作原理如下...

本书关注于实践学习,我们也推荐这种方式用于 streamlit。在使用 streamlit 时,您可以快速实施更改并查看结果,直到您对所见到的内容满意为止。

如果你愿意,Streamlit 提供了一个本地服务器,可以通过浏览器远程访问。因此,你可以在 Azure、Google Cloud、AWS 或你公司的云上运行你的 Streamlit 应用服务器,并在本地浏览器中查看你的结果。

重要的是理解 Streamlit 的工作流程。小部件的值由 Streamlit 存储。其他值在用户与小部件交互时每次都会根据 Python 脚本从头到尾重新计算。为了避免昂贵的计算,可以使用@st.cache装饰器进行缓存,就像我们在这个示例中看到的那样。

Streamlit 的 API 集成了许多绘图和图表库。这些包括 Matplotlib、Seaborn、Plotly、Bokeh,以及 Altair、Vega Lite、用于地图和 3D 图表的 deck.gl 等交互式绘图库,以及 graphviz 图形。其他集成包括 Keras 模型、SymPy 表达式、pandas DataFrames、图像、音频等。

Streamlit 还配备了多种类型的小部件,如滑块、按钮和下拉菜单。Streamlit 还包括一个可扩展的组件系统,每个组件由 HTML 和 JavaScript 构成的浏览器前端以及 Python 后端组成,能够双向发送和接收信息。现有组件接口进一步与 HiPlot、Echarts、Spacy 和 D3 等库进行集成:www.streamlit.io/components.

你可以玩转不同的输入和输出,可以从头开始,也可以改进这个食谱中的代码。我们可以扩展它以显示不同的结果,构建仪表板,连接到数据库进行实时更新,或者为专业主题专家建立用户反馈表单,例如注释或批准。

参见

AI 和统计可视化是一个广泛的领域。Fernanda Viégas 和 Martin Wattenberg 在 NIPS 2018 上进行了一场名为机器学习可视化的概述演讲,并且你可以找到他们的幻灯片和演讲录像。

这是一些有趣的 Streamlit 演示列表:

除了 Streamlit,还有其他可以帮助创建交互式仪表板、演示和报告的库和框架,比如 Bokeh、Jupyter Voilà、Panel 和 Plotly Dash。

如果您正在寻找具有数据库集成的仪表板和实时图表,诸如 Apache Superset 这样的工具非常方便:superset.apache.org/

为实时决策提供模型服务

AI 专家经常被要求建模、呈现或提出模型。但是,即使解决方案可能在商业上具有影响力,实际上,将概念验证(POC)生产化以进行实时决策实施,往往比最初提出模型更具挑战性。一旦我们基于训练数据创建了模型,并对其进行分析以验证其按预期标准运行,并与利益相关者进行沟通,我们希望使该模型可用,以便为新决策的数据提供预测。这可能意味着满足特定要求,例如延迟(用于实时应用程序)和带宽(用于为大量客户提供服务)。通常,模型部署为诸如推断服务器之类的微服务的一部分。

在这个示例中,我们将从头开始构建一个小型推断服务器,并专注于将人工智能引入生产环境的技术挑战。我们将展示如何通过稳健性、按需扩展、及时响应的软件解决方案将 POC 开发成适合生产的解决方案,并且可以根据需要快速更新。

准备工作

在这个示例中,我们将在终端和 Jupyter 环境之间切换。我们将在 Jupyter 环境中创建和记录模型。我们将从终端控制mlflow服务器。我们会注意哪个代码块适合哪个环境。

我们将在这个示例中使用mlflow。让我们从终端安装它:

pip install mlflow

我们假设您已经安装了 conda。如果没有,请参考《Python 人工智能入门》中的设置 Jupyter 环境一章,以获取详细说明。

我们可以像这样从终端启动我们的本地mlflow服务器,使用sqlite数据库作为后端存储:

mlflow server --backend-store-uri sqlite:///mlflow.db --host 0.0.0.0 --default-artifact-root file://$PWD/mlruns

我们应该看到一条消息,指出我们的服务器正在监听0.0.0.0:5000

这是我们可以从浏览器访问此服务器的地方。在浏览器中,我们可以比较和检查不同的实验,并查看我们模型的指标。

更多内容...部分,我们将快速演示如何使用FastAPI库设置自定义 API。我们也将快速安装此库:

!pip install fastapi

有了这个,我们就准备好了!

如何实现...

我们将从一个逗号分隔值CSV)文件中构建一个简单的模型。我们将尝试不同的建模选项,并进行比较。然后我们将部署这个模型:

  1. 下载和准备数据集:

我们将下载一个数据集作为 CSV 文件并准备进行训练。在这个示例中选择的数据集是葡萄酒数据集,描述了葡萄酒样本的质量。我们将从 UCI ML 存档中下载并读取葡萄酒质量的 CSV 文件:

import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split

csv_url =\
    'http://archive.ics.uci.edu/ml/machine-' \
    'learning-databases/wine-quality/winequality-red.csv'
data = pd.read_csv(csv_url, sep=';')

我们将数据分割为训练集和测试集。预测列是quality 列

train_x, test_x, train_y, test_y = train_test_split(
    data.drop(['quality'], axis=1),
    data['quality']
)
  1. 使用不同的超参数进行训练:

我们可以在mlflow服务器中跟踪我们喜欢的任何东西。我们可以创建一个用于性能指标报告的函数,如下所示:

from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score

def eval_metrics(actual, pred):
    rmse = np.sqrt(mean_squared_error(actual, pred))
    mae = mean_absolute_error(actual, pred)
    r2 = r2_score(actual, pred)
    return rmse, mae, r2

在运行训练之前,我们需要将mlflow库注册到服务器上:

import mlflow

mlflow.set_tracking_uri('http://0.0.0.0:5000')
mlflow.set_experiment('/wine')

我们设置了我们的服务器 URI。我们还可以给我们的实验起个名字。

每次我们使用不同选项运行训练集时,MLflow 都可以记录结果,包括指标、超参数、被 pickled 的模型,以及作为 MLModel 捕获库版本和创建时间的定义。

在我们的训练函数中,我们在训练数据上训练,在测试数据上提取我们的模型指标。我们需要选择适合比较的适当超参数和指标:

from sklearn.linear_model import ElasticNet
import mlflow.sklearn

np.random.seed(40)

def train(alpha=0.5, l1_ratio=0.5):
 with mlflow.start_run():
 lr = ElasticNet(alpha=alpha, l1_ratio=l1_ratio, random_state=42)
 lr.fit(train_x, train_y)
 predicted = lr.predict(test_x)
 rmse, mae, r2 = eval_metrics(test_y, predicted)

        model_name = lr.__class__.__name__
        print('{} (alpha={}, l1_ratio={}):'.format(
            model_name, alpha, l1_ratio
        ))
        print(' RMSE: %s' % rmse)
        print(' MAE: %s' % mae)
        print(' R2: %s' % r2)

        mlflow.log_params({key: value for key, value in lr.get_params().items()})
        mlflow.log_metric('rmse', rmse)
        mlflow.log_metric('r2', r2)
        mlflow.log_metric('mae', mae)
        mlflow.sklearn.log_model(lr, model_name)

我们拟合模型,提取我们的指标,将它们打印到屏幕上,在mlflow服务器上记录它们,并将模型工件存储在服务器上。

为了方便起见,我们在train()函数中暴露了我们的模型超参数。我们选择使用mlflow记录所有超参数。或者,我们也可以仅记录像这样与存储相关的参数:mlflow.log_param('alpha', alpha)

我们还可以计算更多伴随模型的工件,例如变量重要性。

我们还可以尝试使用不同的超参数,例如以下方式:

train(0.5, 0.5)

我们应该得到性能指标作为输出:

ElasticNet (alpha=0.5, l1_ratio=0.5):
  RMSE: 0.7325693777577805
  MAE: 0.5895721434715478
  R2: 0.12163690293641838

在我们使用不同参数多次运行之后,我们可以转到我们的服务器,比较模型运行,并选择一个模型进行部署。

  1. 将模型部署为本地服务器。我们可以在浏览器中比较我们的模型。我们应该能够在服务器的实验选项卡下找到我们的葡萄酒实验。

然后我们可以在概述表中比较不同模型运行,或者获取不同超参数的概述图,例如这样:

这个等高线图向我们展示了我们针对平均绝对误差MAE)改变的两个超参数。

我们可以选择一个模型进行部署。我们可以看到我们最佳模型的运行 ID。可以通过命令行将模型部署到服务器,例如像这样:

mlflow models serve -m /Users/ben/mlflow/examples/sklearn_elasticnet_wine/mlruns/1/208e2f5250814335977b265b328c5c49
/artifacts/ElasticNet/

我们可以将数据作为 JSON 传递,例如使用 curl,同样是从终端。这可能看起来像这样:

curl -X POST -H "Content-Type:application/json; format=pandas-split" --data '{"columns":["alcohol", "chlorides", "citric acid", "density", "fixed acidity", "free sulfur dioxide", "pH", "residual sugar", "sulphates", "total sulfur dioxide", "volatile acidity"],"data":[[1.2, 0.231, 0.28, 0.61, 4.5, 13, 2.88, 2.1, 0.26, 63, 0.51]]}' http://127.0.0.1:1234/invocations

有了这个,我们完成了使用mlflow进行模型部署的演示。

工作原理是这样的...

将模型产品化的基本工作流程如下:

  • 在数据上训练模型。

  • 将模型本身打包为可重复使用和可再现的模型格式,以及提取模型预测的胶水代码。

  • 将模型部署在启用您进行预测评分的 HTTP 服务器中。

这通常导致一个通过 JSON 通信的微服务(通常这被称为 RESTful 服务)或 GRPC(通过 Google 的协议缓冲区进行远程过程调用)。这具有将决策智能从后端分离出来,并让 ML 专家完全负责他们的解决方案的优势。

微服务 是一个可以独立部署、维护和测试的单个服务。将应用程序结构化为一组松散耦合的微服务称为微服务架构

另一种方法是将您的模型和粘合代码打包部署到公司现有企业后端中。此集成有几种替代方案:

  • 在诸如预测模型标记语言PMML)等模型交换格式中,这是由数据挖掘组织联合开发的一种格式。

  • 一些软件实现,如 LightGBM、XGBoost 或 TensorFlow,具有多种编程语言的 API,因此可以在 Python 中开发模型,并从诸如 C、C++或 Java 等语言中加载。

  • 重新工程化您的模型:

    • 一些工具可以帮助将决策树等模型转换为 C 或其他语言。

    • 这也可以手动完成。

MLflow 具有命令行、Python、R、Java 和 REST API 接口,用于将模型上传到模型库,记录模型结果(实验),再次下载以便在本地使用,控制服务器等等。它提供了一个服务器,还允许部署到 Azure ML、Amazon Sagemaker、Apache Spark UDF 和 RedisAI。

如果您希望能够远程访问您的mlflow服务器,例如通常在将模型服务器作为独立服务(微服务)时的情况,我们希望将根设置为0.0.0.0,就像我们在示例中所做的那样。默认情况下,本地服务器将在http://127.0.0.1:5000上启动。

如果我们想要访问模型,我们需要从默认的后端存储(这是存储指标的地方)切换到数据库后端,并且我们必须使用 URI 中的协议定义我们的工件存储,例如对于本地mlruns/目录,使用file://$PWD/mlruns。我们已经启用了后端的 SQLite 数据库,这是最简单的方式(但可能不适合生产环境)。我们也可以选择 MySQL、Postgres 或其他数据库后端。

然而,这只是挑战的一部分,因为模型可能变得陈旧或不适用,这些事实只有在我们具备监视部署中模型和服务器性能的能力时才能确认。因此,需要关注监控问题。

监控

在监控 AI 解决方案时,我们特别关注的是操作性或与适当决策相关的指标。前一种类型的指标如下:

  • 延迟 – 在数据上执行预测需要多长时间?

  • 吞吐量 – 我们能在任何时间段内处理多少数据点?

  • 资源使用率 – 在完成推理时,我们分配了多少 CPU、内存和磁盘空间?

以下指标可以作为监控决策过程的一部分:

  • 统计指标,例如在一定时间内预测的平均值和方差

  • 异常值和漂移检测

  • 决策的业务影响

要了解检测异常值的方法,请参阅第三章中的发现异常配方,模式、异常值和推荐

可以从头开始构建独立的监控,类似于本章中可视化模型结果的模板,或者与更专业的监控解决方案集成,如 Prometheus、Grafana 或 Kibana(用于日志监控)。

另请参阅

这是一个非常广泛的话题,在本文档的工作原理……部分中提到了许多生产化方面。在 ML 和深度学习DL)模型中有许多竞争激烈的工业级解决方案,考虑到空间限制,我们只能尝试给出一个概述。像往常一样,在本书中,我们主要集中于避免供应商锁定的开源解决方案:

  • MLflow 致力于管理整个 ML 生命周期,包括实验、可复现性、部署和中心化模型注册:mlflow.org/

  • BentoML 创建一个高性能 API 端点,用于提供训练好的模型:github.com/bentoml/bentoml

虽然某些工具只支持一个或少数几个建模框架,但其他工具,特别是 BentoML 和 MLflow,支持部署在所有主要 ML 训练框架下训练的模型。这两者提供了在 Python 中创建的任何东西的最大灵活性,并且它们都具有用于监控的跟踪功能。

我们的配方是从 mlflow 教程示例中调整的。MLflow 在 GitHub 上有更多不同建模框架集成的示例:github.com/mlflow/mlflow/

其他工具包括以下内容:

此外,还有许多库可用于创建自定义微服务。其中最受欢迎的两个库是:

使用这些,您可以创建端点,该端点可以接收像图像或文本这样的数据,并返回预测结果。

保护模型免受攻击

对抗攻击在机器学习中指的是通过输入欺骗模型的行为。这种攻击的示例包括通过改变几个像素向图像添加扰动,从而导致分类器误分类样本,或者携带特定图案的 T 恤以逃避人物检测器(对抗 T 恤)。一种特定的对抗攻击是隐私攻击,其中黑客可以通过成员推理攻击和模型反演攻击获取模型的训练数据集知识,从而可能暴露个人或敏感信息。

在医疗或金融等领域,隐私攻击尤为危险,因为训练数据可能涉及敏感信息(例如健康状态),并且可能可以追溯到个人身份。在本配方中,我们将构建一个免受隐私攻击的模型,因此无法被黑客攻击。

准备工作

我们将实现一个 PyTorch 模型,但我们将依赖由 Nicolas Papernot 和其他人创建和维护的 TensorFlow/privacy 存储库中的脚本。我们将按以下步骤克隆存储库:

!git clone https://github.com/tensorflow/privacy

配方后期,我们将使用分析脚本计算我们模型的隐私边界。

如何操作...

我们必须为教师模型和学生模型定义数据加载器。在我们的情况下,教师和学生架构相同。我们将训练教师,然后从教师响应的聚合训练学生。我们将最终进行隐私分析,执行来自隐私存储库的脚本。

这是从 Diego Muñoz 的 GitHub 笔记本调整而来的:github.com/dimun/pate_torch

  1. 让我们从加载数据开始。我们将使用torch实用程序函数下载数据:
from torchvision import datasets
import torchvision.transforms as transforms

batch_size = 32

transform = transforms.Compose(
    [transforms.ToTensor(),
     transforms.Normalize((0.1307,), (0.3081,))]
)

train_data = datasets.MNIST(
    root='data', train=True,
    download=True,
    transform=transform
)
test_data = datasets.MNIST(
    root='data', train=False,
    download=True,
    transform=transform
)

这将加载 MNIST 数据集,可能需要一段时间。转换将数据转换为torch.FloatTensortrain_datatest_data分别定义了训练和测试数据的加载器。

请参阅第七章中的识别服装项目配方,简要讨论 MNIST 数据集,以及同一章节中的生成图像配方,用于使用该数据集的另一个模型。

请注意,我们将在整个配方中以临时方式定义一些参数。其中包括num_teachersstandard_deviation。您将在工作原理...部分看到算法的解释,希望那时这些参数会变得合理。

另一个参数,num_workers,定义了用于数据加载的子进程数。batch_size定义了每批加载的样本数。

我们将为教师定义数据加载器:

num_teachers = 100

def get_data_loaders(train_data, num_teachers=10):
    teacher_loaders = []
    data_size = len(train_data) // num_teachers

    for i in range(num_teachers):
        indices = list(range(i * data_size, (i+1) * data_size))
        subset_data = Subset(train_data, indices)
        loader = torch.utils.data.DataLoader(
            subset_data,
            batch_size=batch_size,
            num_workers=num_workers
        )
        teacher_loaders.append(loader)

    return teacher_loaders

teacher_loaders = get_data_loaders(train_data, num_teachers)

get_data_loaders()函数实现了一个简单的分区算法,返回给定教师所需的数据部分。每个教师模型将获取训练数据的不相交子集。

我们为学生定义一个训练集,包括 9000 个训练样本和 1000 个测试样本。这两个集合都来自教师的测试数据集,作为未标记的训练点 - 将使用教师的预测进行标记:

import torch
from torch.utils.data import Subset

student_train_data = Subset(test_data, list(range(9000)))
student_test_data = Subset(test_data, list(range(9000, 10000)))

student_train_loader = torch.utils.data.DataLoader(
    student_train_data, batch_size=batch_size, 
    num_workers=num_workers
)
student_test_loader = torch.utils.data.DataLoader(
    student_test_data, batch_size=batch_size, 
    num_workers=num_workers
)
  1. 定义模型:我们将为所有教师定义一个单一模型:
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim

class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.conv1 = nn.Conv2d(1, 10, kernel_size=5)
        self.conv2 = nn.Conv2d(10, 20, kernel_size=5)
        self.conv2_drop = nn.Dropout2d()
        self.fc1 = nn.Linear(320, 50)
        self.fc2 = nn.Linear(50, 10)

    def forward(self, x):
        x = F.relu(F.max_pool2d(self.conv1(x), 2))
        x = F.relu(F.max_pool2d(self.conv2_drop(self.conv2(x)), 2))
        x = x.view(-1, 320)
        x = F.relu(self.fc1(x))
        x = F.dropout(x, training=self.training)
        x = self.fc2(x)
        return F.log_softmax(x)

这是用于图像处理的卷积神经网络。请参阅第七章,高级图像应用,了解更多图像处理模型。

让我们为预测创建另一个工具函数,给定一个dataloader

def predict(model, dataloader):
    outputs = torch.zeros(0, dtype=torch.long).to(device)
    model.to(device)
    model.eval()
    for images, labels in dataloader:
        images, labels = images.to(device), labels.to(device)
        output = model.forward(images)
        ps = torch.argmax(torch.exp(output), dim=1)
        outputs = torch.cat((outputs, ps))

    return outputs

现在我们可以开始训练教师。

  1. 训练教师模型:

首先,我们将实现一个训练模型的函数:

device = torch.device('cuda:0' if torch.cuda.is_available() else 'cpu')

def train(model, trainloader, criterion, optimizer, epochs=10, print_every=120):
    model.to(device)
    steps = 0
    running_loss = 0
    for e in range(epochs):
        model.train()
        for images, labels in trainloader:
            images, labels = images.to(device), labels.to(device)
            steps += 1         
            optimizer.zero_grad()     
            output = model.forward(images)
            loss = criterion(output, labels)
            loss.backward()
            optimizer.step()
            running_loss += loss.item()

现在我们准备训练我们的教师:

from tqdm.notebook import trange

def train_models(num_teachers):
    models = []
    for t in trange(num_teachers):
        model = Net()
        criterion = nn.NLLLoss()
        optimizer = optim.Adam(model.parameters(), lr=0.003)
        train(model, teacher_loaders[t], criterion, optimizer)
        models.append(model)
    return models

models = train_models(num_teachers) 

这将实例化并训练每个教师的模型。

  1. 训练学生:

对于学生,我们需要一个聚合函数。您可以在工作原理...部分看到聚合函数的解释:

import numpy as np

def aggregated_teacher(models, data_loader, standard_deviation=1.0):
    preds = torch.torch.zeros((len(models), 9000), dtype=torch.long)
    print('Running teacher predictions...')
    for i, model in enumerate(models):
        results = predict(model, data_loader)
        preds[i] = results

    print('Calculating aggregates...')
    labels = np.zeros(preds.shape[1]).astype(int)
    for i, image_preds in enumerate(np.transpose(preds)):
        label_counts = np.bincount(image_preds, minlength=10).astype(float)
        label_counts += np.random.normal(0, standard_deviation, len(label_counts))
        labels[i] = np.argmax(label_counts)

    return preds.numpy(), np.array(labels)

standard_deviation = 5.0
teacher_models = models
preds, student_labels = aggregated_teacher(
    teacher_models,
    student_train_loader,
    standard_deviation
)

aggregated_teacher()函数为所有教师做出预测,计数投票,并添加噪声。最后,它通过argmax返回投票和结果的聚合。

standard_deviation定义了噪声的标准差。这对隐私保证至关重要。

学生首先需要一个数据加载器:

def student_loader(student_train_loader, labels):
    for i, (data, _) in enumerate(iter(student_train_loader)):
        yield data, torch.from_numpy(labels[i*len(data):(i+1)*len(data)])

这个学生数据加载器将被提供聚合的教师标签:

student_model = Net()
criterion = nn.NLLLoss()
optimizer = optim.Adam(student_model.parameters(), lr=0.001)
epochs = 10
student_model.to(device)
steps = 0
running_loss = 0
for e in range(epochs):
    student_model.train()
    train_loader = student_loader(student_train_loader, student_labels)
    for images, labels in train_loader:
        images, labels = images.to(device), labels.to(device)
        steps += 1
        optimizer.zero_grad()
        output = student_model.forward(images)
        loss = criterion(output, labels)
        loss.backward()
        optimizer.step()
        running_loss += loss.item()
        # <validation code omitted>

这将运行学生训练。

由于简洁起见,本代码中的某些部分已从训练循环中省略。验证如下所示:

        if steps % 50 == 0:
            test_loss = 0
            accuracy = 0
            student_model.eval()
            with torch.no_grad():
                for images, labels in student_test_loader:
                    images, labels = images.to(device), labels.to(device)
                    log_ps = student_model(images)
                    test_loss += criterion(log_ps, labels).item()

                    ps = torch.exp(log_ps)
                    top_p, top_class = ps.topk(1, dim=1)
                    equals = top_class == labels.view(*top_class.shape)
                    accuracy += torch.mean(equals.type(torch.FloatTensor))
            student_model.train()
            print('Training Loss: {:.3f}.. '.format(running_loss/len(student_train_loader)),
                  'Test Loss: {:.3f}.. '.format(test_loss/len(student_test_loader)),
                  'Test Accuracy: {:.3f}'.format(accuracy/len(student_test_loader)))
            running_loss = 0

最终的训练更新如下:

Epoch: 10/10..  Training Loss: 0.026..  Test Loss: 0.190..  Test Accuracy: 0.952

我们看到这是一个好模型:在测试数据集上准确率为 95.2%。

  1. 分析隐私:

在 Papernot 等人(2018)中,他们详细介绍了如何计算数据相关的差分隐私界限,以估算训练学生的成本。

他们提供了一个基于投票计数和噪声标准差的分析脚本。我们之前克隆了这个仓库,因此可以切换到其中一个目录,并执行分析脚本:

%cd privacy/research/pate_2018/ICLR2018

我们需要将聚合后的教师计数保存为一个 NumPy 文件。然后可以通过分析脚本加载它:

clean_votes = []
for image_preds in np.transpose(preds):
    label_counts = np.bincount(image_preds, minlength=10).astype(float)
    clean_votes.append(label_counts)

clean_votes = np.array(counts)
with open('clean_votes.npy', 'wb') as file_obj:
  np.save(file_obj, clean_votes)

这将counts矩阵放在一起,并将其保存为文件。

最后,我们调用隐私分析脚本:

!python smooth_sensitivity_table.py  --sigma2=5.0 --counts_file=clean_votes.npy --delta=1e-5

隐私保证的ε估计为 34.226(数据独立)和 6.998(数据相关)。ε值本身并不直观,但需要在数据集及其维度的背景下进行解释。

工作原理...

我们从数据集中创建了一组教师模型,然后从这些教师模型中引导出了一个能提供隐私保证的学生模型。在本节中,我们将讨论机器学习中隐私问题的一些背景,差分隐私,以及 PATE 的工作原理。

泄露客户数据可能会给公司带来严重的声誉损失,更不用说因违反数据保护和隐私法(如 GDPR)而遭到监管机构处罚的费用。因此,在数据集的创建和机器学习中考虑隐私问题至关重要。作为一个例子,来自著名的 Netflix 奖数据集的 50 万用户的数据通过与公开可用的亚马逊评论的关联而被重新匿名化。

尽管几列的组合可能泄露特定个体的太多信息,例如,地址或邮政编码再加上年龄,对于试图追踪数据的人来说是一个线索,但是建立在这些数据集之上的机器学习模型也可能不安全。当遭受成员推断攻击和模型反演攻击等攻击时,机器学习模型可能会泄漏敏感信息。

成员攻击 大致上是识别目标模型在训练输入上的预测与在未经训练的输入上的预测之间的差异。您可以从论文针对机器学习模型的成员推断攻击(Reza Shokri 等人,2016)了解更多信息。他们表明,Google 等公司提供的现成模型可能容易受到这些攻击的威胁。

反演攻击中,通过 API 或黑盒访问模型以及一些人口统计信息,可以重建用于训练模型的样本。在一个特别引人注目的例子中,用于训练人脸识别模型的面部被重建了。更令人关注的是,Matthew Fredrikson 等人表明,个性化药物基因组学模型可以暴露个体的敏感基因信息(个性化华法林剂量定制的隐私个案研究;2014)。

差分隐私 (DP) 机制可以防止模型反演和成员攻击。接下来的部分,我们将讨论差分隐私,然后是关于 PATE 的内容。

差分隐私

差分隐私的概念,最初由 Cynthia Dwork 等人在 2006 年提出(在私有数据分析中校准噪声和灵敏度),是机器学习中隐私的金标准。它集中在个体数据点对算法决策的影响上。大致而言,这意味着,模型的任何输出都不会泄露是否包含了个人信息。在差分隐私中,数据会被某种分布的噪声干扰。这不仅可以提供防止隐私攻击的安全性,还可以减少过拟合的可能性。

为了正确解释差分隐私,我们需要介绍邻近数据库(类似数据集)的概念,这是一个只在单个行或者说单个个体上有所不同的数据库。两个数据集,,仅在事实上的不同之处。

关键是设定一个上限要求映射(或机制)在相邻数据库上的行为几乎完全相同!

这被称为算法的 epsilon-delta 参数化差分隐私,,在任何邻近数据库和任何结果子集上。

在这个表述中,epsilon 参数是乘法保证,delta 参数是概率几乎完全准确结果的加法保证。这意味着个人由于其数据被使用而产生的差异隐私成本是最小的。Delta 隐私可以被视为 epsilon 为0的子集或特殊情况,而 epsilon 隐私则是 delta 为0的情况。

这些保证是通过掩盖输入数据中的微小变化来实现的。例如,斯坦利·L·沃纳在 1965 年描述了这种掩盖的简单程序(随机化响应:消除逃避性答案偏差的调查技术)。调查中的受访者对敏感问题如你有过堕胎吗?以真实方式或根据硬币翻转决定:

  1. 抛一枚硬币。

  2. 如果反面,真实回答:没有。

  3. 如果正面,再翻一枚硬币,如果正面回答,如果反面回答

这提供了合理的否认能力。

教师集合的私有聚合

基于 Nicolas Papernot 等人(2017)的论文来自私有训练数据的半监督知识转移教师集合的私有聚合PATE)技术依赖于教师的嘈杂聚合。2018 年,Nicolas Papernot 等人(具有 PATE 的可扩展私有学习)改进了 2017 年的框架,提高了组合模型的准确性和隐私性。他们进一步展示了 PATE 框架在大规模、真实世界数据集中的适用性。

PATE 训练遵循这个过程:

  1. 基于不共享训练示例的不同数据集创建了模型集合(教师模型)。

  2. 学生模型是基于查询教师模型的嘈杂聚合决策进行训练。

  3. 只能发布学生模型,而不能发布教师模型。

正如提到的,每个教师都在数据集的不相交子集上训练。直觉上,如果教师们就如何对新的输入样本进行分类达成一致意见,那么集体决策不会透露任何单个训练样本的信息。

查询中的聚合机制包括Gaussian NoisyMaxGNMax),具有高斯噪声的 argmax,如下所定义:

这有一个数据点 ,类别 ,以及教师 在 x 上的投票

表示类别 的投票计数:

直观上,准确性随噪声的方差增加而降低,因此方差必须选择足够紧密以提供良好的性能,但又足够宽以保护隐私。

ε值取决于聚合,特别是噪声水平,还取决于数据集及其维度的上下文。请参阅Jaewoo Lee 和 Chris Clifton 的《多少是足够的?选择差分隐私的敏感性参数》(2011 年),以进行讨论。

另见

关于 DP 概念的详细概述可以在Cynthia Dwork 和 Aaron Roth 的《差分隐私的算法基础》中找到。请参阅我们为此配方采用的第二篇 PATE 论文(Nicolas Papernot 等人 2018 年;arxiv.org/pdf/1802.08908.pdf)。

至于与 DP 相关的 Python 库,有许多选择:

TensorFlow 的隐私库包含与 DP 相关的优化器和模型。它还包含使用不同机制的教程,例如 DP 随机梯度下降,DP Adam 优化器或 PATE,适用于成人,IMDB 或其他数据集:github.com/tensorflow/privacy.

有关 TensorFlow 和 PyTorch 的加密 ML 框架:

posted @ 2024-07-23 14:53  绝不原创的飞龙  阅读(0)  评论(0编辑  收藏  举报