Python-机器学习第三版-全-

Python 机器学习第三版(全)

原文:annas-archive.org/md5/29a89b76b2a66ec19795b0aa29fcb926

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

通过新闻和社交媒体的曝光,你可能已经非常熟悉机器学习成为我们时代最令人兴奋的技术之一这一事实。大公司,如谷歌、脸书、苹果、亚马逊和 IBM,之所以大量投资机器学习研究和应用,是有充分理由的。虽然看起来机器学习已经成为我们时代的流行词汇,但它绝不是空洞的炒作。这个令人兴奋的领域打开了新的可能性,已经成为我们日常生活中不可或缺的一部分。想一想我们与智能手机语音助手的对话、为客户推荐合适的产品、预防信用卡欺诈、从电子邮件收件箱中过滤垃圾邮件,以及检测和诊断医疗疾病;这只是其中的一部分。

开始机器学习之旅

如果你想成为一名机器学习从业者,或者成为一个更优秀的解决问题者,或者也许你正在考虑从事机器学习研究的职业,那么这本书就是为你准备的!对于初学者来说,机器学习背后的理论概念可能会让人感到有些不知所措,但近年来出版的许多实践性书籍将帮助你通过实现强大的学习算法来入门机器学习。

实践与理论

通过接触实际的代码示例并处理机器学习的应用实例,是进入这一领域的绝佳方式。此外,具体示例通过将学到的材料付诸实践,帮助阐明更广泛的概念。然而,请记住,强大的能力伴随着巨大的责任!

本书除了提供使用 Python 编程语言和基于 Python 的机器学习库进行机器学习的实践经验外,还介绍了机器学习算法背后的数学概念,这些概念对于成功使用机器学习至关重要。因此,这本书不同于纯粹的实践书籍;这是一本讨论机器学习概念的必要细节,并提供直观、且信息丰富的解释,讲解机器学习算法如何工作,如何使用它们,最重要的是,如何避免最常见的陷阱。

为什么选择 Python?

在我们深入探讨机器学习领域之前,让我们先回答你最重要的问题:“为什么选择 Python?”答案很简单:它既强大又易于上手。Python 已经成为数据科学领域最流行的编程语言,因为它让我们可以忽略编程中的繁琐部分,提供了一个快速记录想法并将概念直接付诸实践的环境。

探索机器学习领域

如果你在Google Scholar中输入“机器学习”作为搜索词,它将返回一个压倒性的大数字——3,250,000篇文献。当然,我们无法讨论过去60年里所有不同算法和应用的细节。但是,在本书中,我们将踏上一段激动人心的旅程,涵盖所有必要的主题和概念,帮助你在这一领域获得领先地位。如果你发现自己的求知欲尚未得到满足,可以利用本书中提到的许多有用资源,进一步了解这一领域的重大突破。

我们,作为作者,真的可以说,学习机器学习使我们成为了更好的科学家、更好的思考者和更好的问题解决者。在这本书中,我们希望与您分享这些知识。知识通过学习获得,关键在于热情,而真正的技能掌握只能通过实践来实现。

前路可能会有些崎岖,一些话题可能比其他话题更具挑战性,但我们希望你能够接受这一挑战,并专注于最终的回报。记住,我们在这段旅程中一起同行,在本书的过程中,我们将为你的工具库添加许多强大的技术,帮助你以数据驱动的方式解决即使是最棘手的问题。

本书的读者对象

如果你已经详细学习过机器学习理论,本书将向你展示如何将你的知识付诸实践。如果你之前使用过机器学习技术,并希望深入了解机器学习如何真正工作的,这本书也适合你。

如果你是机器学习领域的完全新手,别担心,你更有理由感到兴奋!本书承诺,机器学习将改变你对待你想解决问题的思维方式,并通过解锁数据的力量,向你展示如何应对这些问题。如果你想了解如何使用Python来开始回答数据中的关键问题,那就拿起Python机器学习这本书吧。无论你是从零开始,还是想扩展你的数据科学知识,这是一本必不可少且不容错过的资源。

本书内容概览

第1章让计算机通过数据学习,介绍了用于解决各种问题任务的机器学习的主要子领域。此外,它还讨论了创建典型机器学习模型构建管道的基本步骤,这将引导我们进入后续章节。

第2章训练简单的机器学习分类算法,回顾了机器学习的起源,并介绍了二元感知器分类器和自适应线性神经元。本章是对模式分类基本原理的温和介绍,重点讨论优化算法与机器学习之间的相互作用。

第3章使用 scikit-learn 探索机器学习分类器,介绍了分类的基本机器学习算法,并通过一个非常流行和全面的开源机器学习库——scikit-learn,提供了实际案例。

第4章构建良好的训练数据集 – 数据预处理,讨论了如何处理未处理数据集中的常见问题,如缺失数据。它还讨论了几种方法,用于识别数据集中的最具信息量的特征,并如何将不同类型的变量准备好作为机器学习算法的输入。

第5章通过降维压缩数据,描述了减少数据集特征数目至较小集的基本技术,同时保留大部分有用的和具有辨别力的信息。它还讨论了通过主成分分析进行降维的标准方法,并将其与监督式和非线性变换技术进行比较。

第6章模型评估和超参数调优的最佳实践学习,讨论了预测模型性能评估的注意事项和禁忌。此外,还讨论了用于衡量模型性能的不同指标,以及微调机器学习算法的技术。

第7章将不同模型结合进行集成学习,介绍了有效地组合多个学习算法的不同概念。它探讨了如何建立专家集成,以克服单个学习者的弱点,从而实现更准确、更可靠的预测。

第8章将机器学习应用于情感分析,讨论了将文本数据转换为机器学习算法可以预测人们基于其写作所表达意见的有意义表示的基本步骤。

第9章将机器学习模型嵌入到 Web 应用程序中,延续上一章的预测模型,详细讲解了将嵌入机器学习模型的 Web 应用程序开发的必要步骤。

第10章使用回归分析预测连续目标变量,讨论了建模目标与响应变量之间线性关系的基本技术,以便在连续尺度上进行预测。在介绍不同的线性模型后,还讨论了多项式回归和基于树的方法。

第11章处理无标签数据 – 聚类分析,将重点转向机器学习的另一个子领域——无监督学习。它介绍了来自三大基本聚类算法家族的算法,这些算法用于寻找具有某种相似度的对象组。

第12章从零开始实现多层人工神经网络,扩展了我们在第2章训练简单的机器学习分类算法中首次介绍的基于梯度的优化概念。本章将使用流行的反向传播算法,在Python中构建强大的多层神经网络NNs)。

第13章使用TensorFlow并行化神经网络训练,在上一章的基础上,提供了一个实践指南,帮助我们更高效地训练神经网络。本章的重点是TensorFlow 2.0,这是一个开源Python库,允许我们利用现代图形处理单元(GPU)的多个核心,并通过用户友好的Keras API构建深度神经网络。

第14章更深层次探讨——TensorFlow的机制,接续上一章,介绍了TensorFlow 2.0的更高级概念和功能。TensorFlow是一个极为庞大且复杂的库,本章将讲解如何将代码编译为静态图以加速执行,以及如何定义可训练的模型参数。此外,本章还提供了更多关于使用TensorFlow的Keras API训练深度神经网络的实践经验,以及TensorFlow预先构建的估计器。

第15章使用深度卷积神经网络进行图像分类,介绍了卷积神经网络CNNs)。卷积神经网络是深度神经网络架构中的一种,特别适合处理图像数据集。由于相较于传统方法的卓越表现,CNN现在广泛应用于计算机视觉领域,在各种图像识别任务中取得了最先进的成果。在本章中,您将学习如何将卷积层作为强大的特征提取器,应用于图像分类。

第16章使用循环神经网络建模序列数据,介绍了另一种深度学习中流行的神经网络架构,这种架构特别适合处理文本及其他类型的序列数据和时间序列数据。作为热身练习,本章介绍了使用循环神经网络预测电影评论情感的方法。然后,本章讲解了如何教会循环神经网络从书籍中提取信息,从而生成全新的文本。

第 17 章生成对抗网络用于合成新数据,介绍了一种流行的神经网络对抗训练方法,可以用于生成新的、逼真的图像。本章首先简要介绍了自动编码器,一种可以用于数据压缩的特殊神经网络架构。然后,本章展示了如何将自动编码器的解码器部分与第二个神经网络结合,该神经网络可以区分真实图像和合成图像。通过让两个神经网络在对抗训练方法中相互竞争,您将实现一个生成对抗网络,生成新的手写数字。最后,在介绍了生成对抗网络的基本概念之后,本章还介绍了一些改进方法,如使用 Wasserstein 距离度量,以稳定对抗训练。

第 18 章强化学习在复杂环境中的决策应用,涵盖了机器学习的一个子类别,通常用于训练机器人和其他自主系统。本章首先介绍了强化学习RL)的基础知识,使您熟悉智能体/环境交互、RL 系统的奖励过程以及从经验中学习的概念。章节讨论了 RL 的两大类:基于模型的 RL 和无模型 RL。在了解了基本的算法方法,如蒙特卡罗方法和基于时间差的学习后,您将实现并训练一个能够使用 Q 学习算法在网格世界环境中导航的智能体。

最后,本章介绍了深度 Q 学习算法,它是 Q 学习的一个变种,使用深度神经网络(NN)。

本书所需的内容

执行本书中的代码示例需要在 macOS、Linux 或 Microsoft Windows 上安装 Python 3.7.0 或更高版本。在本书中,我们将频繁使用 Python 的基本科学计算库,包括 SciPy、NumPy、scikit-learn、Matplotlib 和 pandas。

第一章将提供设置 Python 环境和这些核心库的说明和有用提示。我们将在后续章节中添加其他库,并提供相应的安装说明,例如,在第 8 章应用机器学习进行情感分析中使用的 NLTK 库,第 9 章将机器学习模型嵌入到 Web 应用程序中中使用的 Flask Web 框架,以及在第 13 章第 18 章中用于高效神经网络训练的 TensorFlow。

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

现在,您已经是一本 Packt 图书的骄傲拥有者,我们有许多内容帮助您从购买中获得最大收益。

下载示例代码文件

您可以从您的账户下载示例代码文件,地址为 http://www.packtpub.com,适用于您购买的所有Packt Publishing书籍。如果您是在其他地方购买了本书,可以访问 http://www.packtpub.com/support 注册,并将文件直接通过邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. 登录或注册 http://www.packt.com

  2. 选择支持标签。

  3. 点击代码下载

  4. 搜索框中输入书名并按照屏幕上的指示操作。

文件下载后,请确保使用最新版本的工具解压或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

或者,如果您从其他地方获得了本书副本,或不愿在Packt创建账户,所有代码示例也可以通过GitHub在https://github.com/rasbt/python-machine-learning-book-3rd-edition下载。

本书中的所有代码也以Jupyter笔记本的形式提供,简短的介绍可以在第1章:让计算机从数据中学习的代码目录中找到,地址为 https://github.com/rasbt/python-machine-learning-book-3rd-edition/tree/master/ch01#pythonjupyter-notebook。有关Jupyter Notebook图形界面的更多信息,请参阅官方文档:https://jupyter-notebook.readthedocs.io/en/stable/

虽然我们推荐使用Jupyter Notebook进行交互式代码执行,但所有代码示例也以Python脚本(例如,ch02/ch02.py)和Jupyter Notebook格式(例如,ch02/ch02.ipynb)提供。此外,我们建议您查看每章附带的README.md文件,以获取更多信息和更新(例如,https://github.com/rasbt/python-machine-learning-book-3rd-edition/blob/master/ch01/README.md)。

我们还提供了其他代码包,来自我们丰富的书籍和视频目录,您可以在https://github.com/PacktPublishing/查看。快去看看吧!

下载彩色图片

我们还提供了一个PDF文件,包含了本书中截图/图表的彩色图片。这些彩色图片将帮助您更好地理解输出的变化。您可以从https://static.packt-cdn.com/downloads/9781789955750_ColorImages.pdf下载此文件。此外,较低分辨率的彩色图片已嵌入到本书中的代码笔记本内,这些笔记本随示例代码文件一同提供。

使用的约定

在本书中,您将找到多种文本样式,用以区分不同种类的信息。以下是一些样式示例及其含义说明。

文本中的代码词汇显示如下:“已经安装的包可以通过--upgrade标志进行更新。”

一段代码如下所示:

>>> import matplotlib.pyplot as plt
>>> import numpy as np
>>> y = df.iloc[0:100, 4].values
>>> y = np.where(y == 'Iris-setosa', -1, 1)
>>> X = df.iloc[0:100, [0, 2]].values
>>> plt.scatter(X[:50, 0], X[:50, 1],
...             color='red', marker='x', label='setosa')
>>> plt.scatter(X[50:100, 0], X[50:100, 1],
...             color='blue', marker='o', label='versicolor')
>>> plt.xlabel('sepal length')
>>> plt.ylabel('petal length')
>>> plt.legend(loc='upper left')
>>> plt.show() 

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

> dot -Tpng tree.dot -o tree.png 

新术语重要词汇 以粗体显示。你在屏幕上看到的词语,例如菜单或对话框中的内容,出现在文本中如下所示:“点击下一步按钮将带你进入下一个屏幕。”

警告或重要说明会以框的形式出现,如下所示。

提示和技巧如下所示。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果你对本书的任何方面有疑问,请在邮件主题中提及书名,并通过 customercare@packtpub.com 向我们发送邮件。

勘误表:尽管我们已尽最大努力确保内容的准确性,但错误仍然可能发生。如果你在我们的书籍中发现错误——无论是文本错误还是代码错误——我们将感激你能报告给我们。通过这样做,你可以避免其他读者的困扰,并帮助我们改进后续版本。如果你发现任何勘误,请访问 www.packtpub.com/support/errata,选择你的书籍,点击勘误提交表单链接,并输入勘误的详细信息。经过验证后,你的勘误将被接受,并会上传到我们的网站,或添加到该书的勘误表部分。

要查看之前提交的勘误表,请访问 https://www.packtpub.com/books/content/support 并在搜索框中输入书名。所需信息将在勘误表部分显示。

盗版:如果你在互联网上发现我们作品的任何非法副本,我们将感激不尽,如果你能提供该位置地址或网站名称,请通过 copyright@packt.com 联系我们,并提供该材料的链接。

如果你有兴趣成为作者:如果你在某个领域有专长,并且有意写作或为一本书做贡献,请访问 authors.packtpub.com

评论

请留下评论。读完并使用此书后,为什么不在你购买该书的网站上留下评论呢?潜在的读者可以看到并利用你的公正意见来做出购买决策,我们 Packt 可以了解你对我们产品的看法,而我们的作者也可以看到你对他们书籍的反馈。谢谢!

有关 Packt 的更多信息,请访问 packt.com

第一章:赋予计算机从数据中学习的能力

在我看来,机器学习,作为一种理解数据的算法应用与科学,是所有计算机科学领域中最令人兴奋的!我们正生活在一个数据充盈的时代;利用机器学习领域中的自我学习算法,我们可以将这些数据转化为知识。得益于近年来开发的许多强大的开源库,可能没有比现在更好的时机来进入机器学习领域,并学习如何利用强大的算法从数据中识别模式,并预测未来事件。

在本章中,你将学习机器学习的主要概念和不同类型的机器学习。结合对相关术语的基本介绍,我们将为成功使用机器学习技术解决实际问题奠定基础。

在本章中,我们将涵盖以下主题:

  • 机器学习的通用概念

  • 三种学习类型及基本术语

  • 成功设计机器学习系统的构建模块

  • 安装和设置Python用于数据分析和机器学习

构建智能机器,将数据转化为知识

在这个现代技术的时代,我们拥有一种丰富的资源:大量的结构化和非结构化数据。在20世纪下半叶,机器学习作为人工智能AI)的一个子领域发展起来,涉及自我学习的算法,这些算法从数据中提取知识并进行预测。

机器学习提供了一种比要求人类手动从大量数据中推导规则和建立模型更高效的替代方案,通过捕获数据中的知识,逐步提高预测模型的性能,并做出基于数据的决策。

机器学习不仅在计算机科学研究中变得越来越重要,而且在我们日常生活中也扮演着越来越大的角色。得益于机器学习,我们享受着强大的电子邮件垃圾邮件过滤器、便捷的文本和语音识别软件、可靠的网页搜索引擎和具有挑战性的国际象棋程序。希望不久后,我们能将安全高效的自动驾驶汽车加入这个列表。此外,在医学应用方面也取得了显著进展;例如,研究人员证明深度学习模型能够以接近人类的准确性检测皮肤癌(https://www.nature.com/articles/nature21056)。最近,DeepMind的研究人员通过深度学习预测3D蛋白质结构,首次超越了基于物理的传统方法(https://deepmind.com/blog/alphafold/)。

机器学习的三种不同类型

在本节中,我们将介绍三种类型的机器学习:监督学习无监督学习强化学习。我们将了解这三种不同学习类型之间的基本区别,并通过概念性示例,我们将理解它们可以应用于的实际问题领域:

用监督学习对未来进行预测

监督学习的主要目标是从标注好的训练数据中学习一个模型,使我们能够对未见过或未来的数据进行预测。在这里,“监督”一词指的是一组训练样本(数据输入),这些训练样本的目标输出信号(标签)已经知道。下图总结了一个典型的监督学习工作流程,其中标注好的训练数据被传递给机器学习算法,用于拟合一个预测模型,从而可以对新的、未标记的数据输入进行预测:

以电子邮件垃圾邮件过滤为例,我们可以使用监督学习算法在标注好的电子邮件语料库上训练一个模型,这些电子邮件已经正确地标记为垃圾邮件或非垃圾邮件,从而预测一封新邮件是否属于这两个类别之一。具有离散类别标签的监督学习任务,如之前的电子邮件垃圾邮件过滤示例,也被称为分类任务。监督学习的另一个子类别是回归,其中输出信号是一个连续值。

用于预测类别标签的分类

分类是监督学习的一个子类别,其目标是根据过去的观察来预测新实例的类别标签。这些类别标签是离散的、无序的值,可以理解为实例的组成员身份。之前提到的电子邮件垃圾邮件检测示例代表了一个典型的二分类任务,在这个任务中,机器学习算法学习一组规则,以区分两种可能的类别:垃圾邮件和非垃圾邮件。

以下图示说明了二分类任务的概念,给定30个训练样本;其中15个训练样本被标记为负类(减号),另外15个训练样本被标记为正类(加号)。在这种情况下,我们的数据集是二维的,这意味着每个样本有两个值与之相关:x[1]x[2]。现在,我们可以使用监督学习算法来学习一个规则——决策边界,表示为虚线——它能够将这两个类别分开,并根据样本的 x[1]x[2] 值将新数据分类到这两个类别中:

然而,类别标签集不必是二元的。监督学习算法学习到的预测模型可以将训练数据集中呈现的任何类别标签分配给新的、未标记的实例。

多类分类任务的一个典型例子是手写字符识别。我们可以收集一个训练数据集,其中包含每个字母的多个手写示例。字母(“A”,“B”,“C”等)将代表我们想要预测的不同无序类别或标签。现在,如果用户通过输入设备提供一个新的手写字符,我们的预测模型将能够以一定的准确度预测字母表中的正确字母。然而,如果这些数字(例如0到9之间的数字)没有出现在训练数据集中,我们的机器学习系统将无法正确识别它们。

用于预测连续结果的回归

我们在上一节中学到,分类任务是将类别化、无序的标签分配给实例。第二种类型的监督学习是预测连续的结果,也叫做回归分析。在回归分析中,我们给定若干个预测变量(解释性变量)和一个连续的响应变量(结果),然后尝试找到这些变量之间的关系,从而预测结果。

请注意,在机器学习领域,预测变量通常称为“特征”,响应变量通常被称为“目标变量”。本书中我们将采用这些约定。

例如,假设我们有兴趣预测学生的数学SAT成绩。如果学习考试的时间与最终成绩之间存在某种关系,我们可以利用这段时间作为训练数据,学习一个模型,该模型使用学习时间来预测未来计划参加此考试的学生的成绩。

回归向均值

“回归”这个术语是由弗朗西斯·高尔顿在他1886年的文章《回归到平庸的遗传身高》中提出的。高尔顿描述了这样一个生物现象:一个种群的身高方差随着时间的推移不会增加。

他观察到,父母的身高并没有遗传给孩子,而是孩子的身高会回归到种群的平均身高。

下图说明了线性回归的概念。给定一个特征变量 x 和目标变量 y,我们对这些数据拟合一条直线,使得数据点和拟合直线之间的距离——通常是平均平方距离——最小化。现在,我们可以利用从这些数据中学习到的截距和斜率来预测新数据的目标变量:

使用强化学习解决互动问题

另一种机器学习类型是强化学习。在强化学习中,目标是开发一个系统(智能体),该系统基于与环境的互动不断提升其表现。由于关于环境当前状态的信息通常也包括所谓的奖励信号,我们可以将强化学习视为与监督学习相关的领域。然而,在强化学习中,这种反馈不是正确的真实标签或值,而是通过奖励函数衡量行动效果的指标。通过与环境的互动,智能体可以使用强化学习来学习一系列行动,这些行动通过探索性的试错法或深思熟虑的规划来最大化奖励。

强化学习的一个常见例子是国际象棋引擎。在这个例子中,智能体根据棋盘的状态(即环境)决定一系列的棋步,而奖励可以定义为游戏结束时的胜利失败

强化学习有许多不同的子类型。然而,一般的框架是,强化学习中的智能体试图通过与环境的一系列互动来最大化奖励。每个状态都可以与正向或负向奖励相关联,奖励可以定义为实现一个整体目标,比如赢得或输掉一局国际象棋比赛。例如,在国际象棋中,每一步的结果可以看作是环境的一个不同状态。

进一步探讨国际象棋的例子,我们可以将访问棋盘上某些配置视为与更可能导致胜利的状态相关联——例如,移除对方的棋子或威胁对方的皇后。然而,其他位置则与更可能导致失败的状态相关联,比如在接下来的回合中失去棋子。现在,在国际象棋中,奖励(无论是赢得比赛的正奖励还是输掉比赛的负奖励)直到游戏结束时才会给出。此外,最终的奖励还将取决于对手的表现。例如,对手可能会牺牲皇后,但最终赢得比赛。

强化学习关注的是学习选择一系列能够最大化总奖励的行动,这些奖励可能在采取行动后立即获得,或通过延迟反馈获得。

使用无监督学习发现隐藏的结构

在监督学习中,我们在训练模型时已经知道正确的答案;在强化学习中,我们为代理执行的特定动作定义了奖励度量。然而,在无监督学习中,我们处理的是无标签数据或结构未知的数据。通过使用无监督学习技术,我们能够探索数据的结构,从中提取有意义的信息,而不依赖于已知的结果变量或奖励函数。

使用聚类找到子组

聚类是一种探索性数据分析技术,它允许我们将一堆信息组织成有意义的子组(),而不需要事先了解它们的组别。分析过程中产生的每个簇都定义了一组共享某种程度相似性的对象,但与其他簇中的对象更为不同,这也是为什么聚类有时被称为无监督分类的原因。聚类是一种很好的结构化信息的技术,可以从数据中推导出有意义的关系。例如,它可以帮助市场营销人员根据客户的兴趣发现客户群体,从而制定不同的营销方案。

下图展示了如何将聚类应用于将无标签数据根据其特征的相似性(x[1]x[2])组织成三个不同的组:

数据压缩的降维

无监督学习的另一个子领域是降维。我们常常处理的是高维数据——每个观察值都包含大量的测量值——这可能对有限的存储空间和机器学习算法的计算性能带来挑战。无监督降维是特征预处理中常用的一种方法,可以去除数据中的噪音,这些噪音可能会降低某些算法的预测性能,同时将数据压缩到一个较小的维度子空间,同时保留大部分相关信息。

有时候,降维也可以用于数据可视化;例如,某些高维特征集可以被投影到一维、二维或三维特征空间中,以便通过二维或三维散点图或直方图进行可视化。下图展示了一个例子,其中应用了非线性降维技术,将一个三维的瑞士卷数据压缩到新的二维特征子空间:

基本术语和符号介绍

现在我们已经讨论了机器学习的三大类别——监督学习、无监督学习和强化学习——接下来让我们看一下本书中将使用的基本术语。以下小节介绍了我们在谈到数据集的不同方面时常用的术语,以及用于更精确和高效沟通的数学符号。

由于机器学习是一个广泛且跨学科的领域,您很快就会遇到许多不同的术语,它们实际上指的是相同的概念。第二小节收集了机器学习文献中最常用的术语,这些术语在您阅读更多不同的机器学习文献时可能会作为参考部分对您有所帮助。

本书中使用的符号和约定

以下表格展示了Iris数据集的一个摘录,这是机器学习领域的经典例子。Iris数据集包含了来自三种不同物种——Setosa、Versicolor和Virginica的150朵鸢尾花的测量数据。在这里,每个花卉示例代表数据集中的一行,花卉的测量值(以厘米为单位)存储为列,我们也称这些为数据集的特征

为了保持符号简洁而高效,我们将利用一些线性代数的基础知识。在接下来的章节中,我们将使用矩阵和向量的符号表示我们的数据。我们将遵循常用的约定,将每个示例表示为特征矩阵X中的一行,其中每个特征存储为一个单独的列。

由150个示例和四个特征组成的Iris数据集可以写成一个!矩阵,

符号约定

在本书的其余部分,除非另有说明,我们将使用上标i来表示第i个训练示例,使用下标j来表示训练数据集的第j维度。

我们将使用小写粗体字母来表示向量!,大写粗体字母来表示矩阵!。若要表示向量或矩阵中的单个元素,我们将使用斜体字母(,分别表示)。

例如,指的是花卉示例150的第一个维度,即萼片长度。因此,该特征矩阵中的每一行代表一个花卉实例,可以写成一个四维行向量,

每个特征维度是一个150维的列向量,。例如:

类似地,我们将目标变量(在这里是类标签)存储为一个150维的列向量:

机器学习术语

机器学习是一个广阔的领域,也非常跨学科,因为它将许多来自其他研究领域的科学家聚集在一起。事实上,许多术语和概念已经被重新发现或重新定义,可能对你来说已经很熟悉,但以不同的名称出现。为了方便你,在以下列表中,你可以找到一些常见术语及其同义词,在阅读本书和一般机器学习文献时可能会派上用场:

  • 训练示例:表示数据集的表中的一行,与观察值、记录、实例或样本同义(在大多数情况下,样本指的是一组训练示例)。

  • 训练:模型拟合,对于类似于参数估计的参数化模型。

  • 特征,缩写为 x:数据表或数据(设计)矩阵中的一列。与预测变量、输入、属性或协变量同义。

  • 目标,缩写为 y:与结果、输出、响应变量、因变量、(类别)标签和真实标签同义。

  • 损失函数:通常与 成本 函数同义。有时损失函数也称为 错误 函数。在某些文献中,“损失”一词指的是对单个数据点的损失,而成本是一个度量,它计算整个数据集上的损失(平均值或总和)。

构建机器学习系统的路线图

在前面的部分中,我们讨论了机器学习的基本概念和三种不同的学习类型。在这一部分,我们将讨论机器学习系统中伴随学习算法的其他重要部分。

下图展示了在预测建模中使用机器学习的典型工作流程,我们将在接下来的子章节中讨论:

B07030_01_09

数据预处理 – 使数据适配

让我们从讨论构建机器学习系统的路线图开始。原始数据很少以适合学习算法最佳性能的形式和结构出现。因此,数据预处理是任何机器学习应用中最关键的步骤之一。

如果我们以前面部分的鸢尾花数据集为例,我们可以将原始数据看作是一系列花卉图像,从中我们想要提取有意义的特征。有效的特征可能是花朵的颜色、色调和强度,或者花朵的高度、长度和宽度。

许多机器学习算法还要求所选特征在同一尺度上以实现最佳性能,这通常通过将特征转换到[0, 1]范围内或标准正态分布(均值为零,方差为单位)的方式来实现,正如我们将在后续章节中看到的那样。

一些选择的特征可能高度相关,因此在某种程度上是冗余的。在这种情况下,降维技术对于将特征压缩到较低维度的子空间是有用的。减少特征空间的维度具有以下优点:所需的存储空间较少,且学习算法可以运行得更快。在某些情况下,如果数据集包含大量无关特征(或噪声),降维还可以改善模型的预测性能;也就是说,如果数据集的信噪比低。

为了确定我们的机器学习算法不仅在训练数据集上表现良好,而且能够很好地泛化到新数据,我们还需要随机地将数据集划分为单独的训练集和测试集。我们使用训练集来训练和优化我们的机器学习模型,而在最后,我们保留测试集以评估最终的模型。

训练和选择预测模型

正如你将在后续章节中看到的那样,已经开发了许多不同的机器学习算法来解决不同的问题任务。从David Wolpert著名的无免费午餐定理中可以总结出一个重要的观点,那就是我们不能“免费”地进行学习(学习算法之间缺乏先验区别,D.H. Wolpert,1996;优化的无免费午餐定理,D.H. Wolpert和W.G. Macready,1997)。我们可以将这个概念与流行的说法相联系,"我想,如果你只有一把锤子,所有的东西都会看起来像钉子"(Abraham Maslow,1966)。例如,每个分类算法都有其固有的偏见,如果我们不对任务做任何假设,则没有一个单一的分类模型能在所有情况下占据优势。因此,在实践中,比较至少几个不同的算法以训练并选择表现最佳的模型是至关重要的。但在我们能够比较不同模型之前,首先必须决定一个衡量性能的标准。一种常用的衡量标准是分类准确率,它被定义为正确分类实例的比例。

一个合理的问题是:如果我们不使用测试数据集来选择模型,而是将其保留用于最终的模型评估,那么我们如何知道哪个模型在最终测试数据集和真实世界数据上表现良好? 为了解决这个问题,可以使用被总结为“交叉验证”的不同技术。在交叉验证中,我们将数据集进一步划分为训练集和验证集,以便估计模型的泛化性能。最后,我们也不能期望软件库提供的不同学习算法的默认参数对我们的特定问题任务是最优的。因此,我们将在后续章节中频繁使用超参数优化技术,以帮助我们微调模型的性能。

我们可以把这些超参数看作是模型的参数,它们不是从数据中学习到的,而是代表模型的控制按钮,我们可以调节它们来提高模型的性能。在后面的章节中,我们会通过实际的例子使这一点变得更加清晰。

评估模型并预测未见过的数据实例

在我们选择了一个已经在训练数据集上拟合的模型后,可以使用测试数据集来估计它在这些未见过的数据上的表现,从而估算所谓的泛化误差。如果我们对它的表现感到满意,我们就可以使用这个模型来预测新的未来数据。需要注意的是,之前提到的程序步骤中的参数(如特征缩放和降维)完全来源于训练数据集,并且这些相同的参数稍后会被重新应用于转换测试数据集以及任何新的数据实例——否则,在测试数据上的表现可能会过于乐观。

使用 Python 进行机器学习

Python 是数据科学中最受欢迎的编程语言之一,得益于其非常活跃的开发者和开源社区,已经开发出大量用于科学计算和机器学习的有用库。

尽管解释型语言(如 Python)在计算密集型任务中的性能不如低级编程语言,但已经开发出如 NumPy 和 SciPy 等扩展库,这些库建立在低层次的 Fortran 和 C 实现之上,以便在多维数组上进行快速的向量化操作。

对于机器学习编程任务,我们主要会参考 scikit-learn 库,这是目前最流行和易于访问的开源机器学习库之一。在后面的章节中,当我们专注于机器学习的一个子领域——深度学习时,我们将使用最新版本的 TensorFlow 库,它通过利用图形处理单元(GPU)非常高效地训练所谓的深度神经网络模型。

从 Python 包索引安装 Python 和软件包

Python 可用于所有三大主流操作系统——Microsoft Windows、macOS 和 Linux——安装程序及文档可以从官方 Python 网站下载:https://www.python.org

本书适用于 Python 3.7 或更高版本,建议使用当前可用的最新 Python 3 版本。部分代码也可能与 Python 2.7 兼容,但由于 Python 2.7 的官方支持已于 2019 年结束,并且大多数开源库已停止对 Python 2.7 的支持(https://python3statement.org),我们强烈建议使用 Python 3.7 或更新版本。

本书中使用的额外包可以通过 pip 安装程序进行安装,pip 自 Python 3.3 起成为 Python 标准库的一部分。更多关于 pip 的信息可以在 https://docs.python.org/3/installing/index.html 找到。

成功安装 Python 后,我们可以从终端执行 pip 来安装额外的 Python 包:

pip install SomePackage 

已安装的包可以通过 --upgrade 标志进行更新:

pip install SomePackage --upgrade 

使用 Anaconda Python 发行版和包管理器

推荐的科学计算 Python 发行版是 Continuum Analytics 提供的 Anaconda。Anaconda 是一个免费(包括商业用途)且企业级的 Python 发行版,集合了数据科学、数学和工程所需的所有核心 Python 包,且跨平台用户友好。Anaconda 安装程序可以从 https://docs.anaconda.com/anaconda/install/ 下载,快速入门指南可以在 https://docs.anaconda.com/anaconda/user-guide/getting-started/ 获取。

成功安装 Anaconda 后,我们可以使用以下命令安装新的 Python 包:

conda install SomePackage 

可以使用以下命令更新现有的包:

conda update SomePackage 

科学计算、数据科学和机器学习的相关包

在本书中,我们主要使用 NumPy 的多维数组来存储和处理数据。偶尔,我们会使用 pandas,这是一个基于 NumPy 构建的库,提供了更多高级的数据处理工具,使得处理表格数据变得更加方便。为了增强学习体验并可视化定量数据,我们将使用高度可定制的 Matplotlib 库,这对于理解数据非常有帮助。

本书中使用的主要 Python 包的版本号列在下方。请确保安装的包的版本号与这些版本号相等或更高,以确保代码示例能够正确运行:

  • NumPy 1.17.4

  • SciPy 1.3.1

  • scikit-learn 0.22.0

  • Matplotlib 3.1.0

  • pandas 0.25.3

总结

在本章中,我们以非常高的层次探讨了机器学习,并熟悉了我们将在接下来的章节中更详细探索的大致框架和主要概念。我们了解到,监督学习由两个重要的子领域组成:分类和回归。分类模型使我们能够将对象分类到已知类别中,而回归分析则帮助我们预测目标变量的连续结果。无监督学习不仅提供了有助于发现无标签数据结构的技术,还可以在特征预处理步骤中用于数据压缩。

我们简要地回顾了将机器学习应用于问题任务的典型路线图,接下来我们将以此为基础,在后续章节中进行更深入的讨论和实践示例。最后,我们设置了Python环境,并安装和更新了所需的包,为实际操作机器学习做好准备。

在本书的后续内容中,除了机器学习本身,我们还将介绍不同的数据集预处理技术,这将帮助你从不同的机器学习算法中获得最佳性能。尽管我们将贯穿全书详细讲解分类算法,我们也会探讨回归分析和聚类的不同技术。

我们即将开始一段令人兴奋的旅程,涵盖机器学习广阔领域中的许多强大技术。然而,我们将一步步地接触机器学习,在本书的各个章节中逐步构建我们的知识。在接下来的章节中,我们将通过实现最早的分类机器学习算法之一,开启这段旅程,为第3章使用scikit-learn探索机器学习分类器做准备,届时我们将使用scikit-learn开源机器学习库,介绍更多高级的机器学习算法。

第二章:训练简单的机器学习分类算法

在本章中,我们将使用两种最早算法描述的机器学习分类算法:感知机和自适应线性神经元。我们将从头开始一步步实现感知机,并训练其在鸢尾花数据集上分类不同的花卉种类。这将帮助我们理解机器学习分类算法的概念,以及如何高效地在Python中实现它们。

通过使用自适应线性神经元讨论优化基础,将为在第3章《使用scikit-learn的机器学习分类器之旅》中使用更复杂的分类器奠定基础。

本章我们将涵盖的主题如下:

  • 建立对机器学习算法的理解

  • 使用pandas、NumPy和Matplotlib读取、处理和可视化数据

  • 在Python中实现线性分类算法

人工神经元——机器学习早期历史的简要回顾

在我们更详细地讨论感知机及相关算法之前,让我们简要回顾一下机器学习的起源。为了理解生物大脑的工作原理,以设计人工智能(AI),沃伦·麦卡洛克和沃尔特·皮茨于1943年首次提出了简化版脑细胞的概念,即所谓的麦卡洛克-皮茨MCP)神经元(《神经活动中固有思想的逻辑演算》,W. S. McCullochW. Pitts数学生物物理学公报,5(4):115-133,1943)。生物神经元是大脑中互相连接的神经细胞,参与处理和传递化学和电信号,如下图所示:

麦卡洛克和皮茨将这种神经细胞描述为一个具有二进制输出的简单逻辑门;多个信号通过树突到达细胞体,然后它们被整合,如果累积的信号超过某个阈值,则会生成一个输出信号,并通过轴突传递出去。

几年后,弗兰克·罗森布拉特基于MCP神经元模型提出了感知机学习规则的第一个概念(《感知机:一个感知和识别的自动机》,F. Rosenblatt康奈尔航空实验室1957)。通过他的感知机规则,罗森布拉特提出了一种算法,能够自动学习最优的权重系数,这些权重系数随后会与输入特征相乘,以决定神经元是否发火(传递信号)。在监督学习和分类的背景下,这样的算法可以用来预测一个新的数据点是否属于某一类别。

人工神经元的正式定义

更正式地说,我们可以将人工神经元的概念置于二分类任务的背景下,简化为我们称之为1(正类)和–1(负类)的两个类别。然后我们可以定义一个决策函数(!),它取某些输入值x和相应的权重向量w的线性组合,其中z是所谓的净输入!

现在,如果某个特定示例的净输入!大于定义的阈值!,我们预测类别1,否则预测类别–1。在感知机算法中,决策函数!是一个单位阶跃函数的变体:

为了简化,我们可以将阈值!移到方程的左侧,并定义一个权重零为!和!,这样我们可以用更简洁的形式表示z

以及:

在机器学习文献中,负阈值或权重!通常称为偏置单元

线性代数基础:点积和矩阵转置

在接下来的部分中,我们将经常使用线性代数中的基本符号。例如,我们将通过向量点积来简化xw值的乘积之和,而上标T表示转置,这是一个将列向量转换为行向量,反之亦然的操作:

例如:

此外,转置操作也可以应用于矩阵,以沿其对角线进行反射,例如:

请注意,转置操作严格来说只对矩阵定义;然而,在机器学习的上下文中,当我们使用“向量”一词时,我们指的是!或!矩阵。

在本书中,我们将只使用线性代数中的非常基础的概念;但是,如果你需要快速回顾,请查看Zico Kolter的优秀作品《线性代数复习与参考》,该书可以在http://www.cs.cmu.edu/~zkolter/course/linalg/linalg_notes.pdf免费获取。

下图展示了净输入!是如何通过感知机的决策函数(左图)被压缩成二进制输出(–1 或 1),以及它如何用于区分两个线性可分的类别(右图):

感知机学习规则

MCP 神经元和 Rosenblatt 的 阈值 感知机模型的核心思想是采用还原主义的方法来模拟大脑中单个神经元的工作方式:它要么 发放 信号,要么不发放。因此,Rosenblatt 最初的感知机规则相当简单,感知机算法可以通过以下步骤总结:

  1. 初始化权重为 0 或小的随机数。

  2. 对于每个训练样本,

    1. 计算输出值,

    2. 更新权重。

在这里,输出值是由我们之前定义的单位阶跃函数预测的类别标签,同时,权重向量中的每个权重()也会更新,w 的更新可以更正式地写作:

(或 的变化)的更新值,我们称之为 ,是通过感知机学习规则计算的,如下所示:

其中,学习率(通常是介于 0.0 和 1.0 之间的常数), 是第 i 个训练样本的 真实类别标签,而 预测的类别标签。需要注意的是,权重向量中的所有权重是同时更新的,这意味着在通过各自的更新值()更新所有权重之前,我们不会重新计算预测标签()。具体来说,对于一个二维数据集,我们将这样写出更新公式:

在我们实现感知机规则之前,让我们通过一个简单的思想实验,来阐明这个学习规则的简洁性。在感知机正确预测类别标签的两个场景中,由于更新值为 0,权重保持不变:

(1)

(2)

然而,在预测错误的情况下,权重会朝着正类或负类目标的方向调整:

(3)

(4)

为了更好地理解乘法因子,,让我们通过另一个简单的例子来分析,其中:

假设 ,并且我们错误地将该样本分类为 –1。在这种情况下,我们会将相应的权重增加 1,以便下次遇到该样本时,净输入()会更为正向,从而更有可能超过单位阶跃函数的阈值,预测该样本为 +1

权重更新与的值成正比。例如,如果我们有另一个示例,它被错误地分类为 –1,我们将会推动决策边界进一步调整,以便下次正确地分类此示例:

需要注意的是,感知机的收敛性只有在两个类别线性可分并且学习率足够小的情况下才能得到保证(感兴趣的读者可以在我的讲义中找到数学证明:https://sebastianraschka.com/pdf/lecture-notes/stat479ss19/L03_perceptron_slides.pdf)。如果两个类别不能通过线性决策边界分开,我们可以设置一个最大遍历次数(epochs)和/或一个容忍误分类的阈值——否则,感知机将永远不会停止更新权重:

下载示例代码

如果你是直接从Packt购买这本书,你可以从你的账户下载示例代码文件,访问http://www.packtpub.com。如果你是从其他地方购买的这本书,你可以直接从https://github.com/rasbt/python-machine-learning-book-3rd-edition下载所有代码示例和数据集。

现在,在我们进入下一节的实现之前,你刚刚学到的内容可以通过一个简单的图示来总结,这个图示展示了感知机的基本概念:

上面的图示说明了感知机如何接收一个示例的输入x,并将其与权重w结合来计算净输入。净输入然后传递给阈值函数,该函数生成一个二进制输出——–1 或 +1——即该示例的预测类别标签。在学习阶段,这个输出用于计算预测的误差,并更新权重。

在Python中实现感知机学习算法

在上一节中,我们学习了Rosenblatt感知机规则的工作原理;现在让我们在Python中实现它,并将其应用于我们在第1章《让计算机从数据中学习》介绍的Iris数据集。

面向对象的感知机API

我们将采用面向对象的方法来定义感知机接口作为Python类,这将允许我们初始化新的Perceptron对象,这些对象可以通过fit方法从数据中学习,并通过单独的predict方法进行预测。作为一种约定,我们将下划线(_)附加到那些在对象初始化时没有创建但通过调用对象的其他方法来创建的属性上,例如self.w_

Python科学计算栈的额外资源

如果你还不熟悉 Python 的科学库,或者需要复习一下,请参考以下资源:

以下是一个感知机在 Python 中的实现:

import numpy as np
class Perceptron(object):
    """Perceptron classifier.

    Parameters
    ------------
    eta : float
      Learning rate (between 0.0 and 1.0)
    n_iter : int
      Passes over the training dataset.
    random_state : int
      Random number generator seed for random weight 
      initialization.

    Attributes
    -----------
    w_ : 1d-array
      Weights after fitting.
    errors_ : list
      Number of misclassifications (updates) in each epoch.

    """
    def __init__(self, eta=0.01, n_iter=50, random_state=1):
        self.eta = eta
        self.n_iter = n_iter
        self.random_state = random_state

    def fit(self, X, y):
        """Fit training data.

        Parameters
        ----------
        X : {array-like}, shape = [n_examples, n_features]
          Training vectors, where n_examples is the number of 
          examples and n_features is the number of features.
        y : array-like, shape = [n_examples]
          Target values.

        Returns
        -------
        self : object

        """
        rgen = np.random.RandomState(self.random_state)
        self.w_ = rgen.normal(loc=0.0, scale=0.01,
                              size=1 + X.shape[1])
        self.errors_ = []

        for _ in range(self.n_iter):
            errors = 0
            for xi, target in zip(X, y):
                update = self.eta * (target - self.predict(xi))
                self.w_[1:] += update * xi
                self.w_[0] += update
                errors += int(update != 0.0)
            self.errors_.append(errors)
        return self

    def net_input(self, X):
        """Calculate net input"""
        return np.dot(X, self.w_[1:]) + self.w_[0]

    def predict(self, X):
        """Return class label after unit step"""
        return np.where(self.net_input(X) >= 0.0, 1, -1) 

使用这个感知机实现,我们现在可以初始化具有给定学习率 eta 和迭代次数 n_iter(训练数据集的遍历次数)的新 Perceptron 对象。

通过 fit 方法,我们将 self.w_ 中的权重初始化为一个向量,,其中 m 代表数据集中的维度(特征)数量,我们为该向量中的第一个元素添加 1,表示偏置单元。记住,这个向量中的第一个元素 self.w_[0] 代表我们之前讨论过的所谓偏置单元。

另外请注意,这个向量包含从正态分布中抽取的小随机数,标准差为 0.01,通过rgen.normal(loc=0.0, scale=0.01, size=1 + X.shape[1])生成,其中 rgen 是一个 NumPy 随机数生成器,我们为其指定了一个用户定义的随机种子,以便在需要时能够重现之前的结果。

需要记住的是,我们不将权重初始化为零,因为只有当权重初始化为非零值时,学习率 (eta) 才会影响分类结果。如果所有权重都初始化为零,那么学习率参数 eta 只会影响权重向量的规模,而不是方向。如果你熟悉三角学,考虑一个向量 ,其中 和一个向量 之间的夹角将恰好为零,以下代码片段演示了这一点:

>>> v1 = np.array([1, 2, 3])
>>> v2 = 0.5 * v1
>>> np.arccos(v1.dot(v2) / (np.linalg.norm(v1) *
...           np.linalg.norm(v2)))
0.0 

在这里,np.arccos 是三角函数的反余弦,np.linalg.norm 是一个计算向量长度的函数(我们选择从正态分布中抽取随机数,而不是从均匀分布中抽取,且标准差为 0.01 是任意的;记住,我们只是希望得到小的随机值,以避免前面讨论的全零向量的特性)。

NumPy 数组索引

NumPy 对一维数组的索引方式类似于 Python 列表,使用方括号 ([]) 表示法。对于二维数组,第一个索引表示行号,第二个索引表示列号。例如,我们可以使用 X[2, 3] 来选择二维数组 X 的第三行和第四列。

在初始化权重之后,fit方法会遍历训练数据集中的所有单个样本,并根据我们在上一节中讨论的感知机学习规则更新权重。

类别标签由predict方法预测,该方法在训练过程中通过fit方法调用,以获取权重更新的类别标签;但是,predict也可以在我们拟合模型后用来预测新数据的类别标签。此外,我们还在self.errors_列表中收集每个时代的误分类数,以便稍后分析我们的感知机在训练过程中的表现。net_input方法中使用的np.dot函数简单地计算向量点积,

我们可以用纯Python通过sum([i * j for i, j in zip(a, b)])来计算两个数组ab的点积,而不是使用NumPy的a.dot(b)np.dot(a, b)。然而,使用NumPy而不是经典的Python for循环结构的优势在于它的算术运算是矢量化的。矢量化意味着一个元素级别的算术运算会自动应用到数组中的所有元素。通过将我们的算术运算表示为数组上的一系列指令,而不是对每个元素执行一组操作,我们可以更好地利用现代中央处理单元(CPU)架构,尤其是支持单指令、多数据SIMD)的架构。此外,NumPy还使用了高度优化的线性代数库,如基本线性代数子程序BLAS)和线性代数包LAPACK),这些库是用C或Fortran编写的。最后,NumPy还允许我们通过使用线性代数的基础知识,如向量和矩阵点积,编写更紧凑和直观的代码。

在鸢尾花数据集上训练感知机模型

为了测试我们的感知机实现,接下来的分析和示例将仅限于使用两个特征变量(维度)。尽管感知机规则不限于二维,考虑仅使用花萼长度和花瓣长度这两个特征将帮助我们在学习过程中通过散点图可视化训练模型的决策区域。

请注意,出于实际考虑,我们将只考虑鸢尾花数据集中的两类花卉——Setosa和Versicolor——记住,感知机是一个二分类器。然而,感知机算法可以扩展到多分类问题——例如,一对多OvA)技术。

多分类的OvA方法

OvA,有时也被称为一对多OvR),是一种技术,允许我们将任何二分类器扩展到多类问题。使用OvA,我们可以为每个类训练一个分类器,其中特定类被视为正类,所有其他类的样本被视为负类。如果我们要对一个新的、未标记的数据实例进行分类,我们将使用我们的n个分类器,其中n是类标签的数量,并将具有最高置信度的类标签分配给我们要分类的特定实例。在感知机的情况下,我们将使用OvA选择与最大绝对净输入值关联的类标签。

首先,我们将使用pandas库直接从UCI机器学习库加载Iris数据集到一个DataFrame对象中,并通过tail方法打印最后五行,以检查数据是否正确加载:

>>> import os
>>> import pandas as pd
>>> s = os.path.join('https://archive.ics.uci.edu', 'ml',
...                  'machine-learning-databases',
...                  'iris','iris.data')
>>> print('URL:', s)
URL: https://archive.ics.uci.edu/ml/machine-learning-databases/iris/iris.data
>>> df = pd.read_csv(s,
...                  header=None,
...                  encoding='utf-8')
>>> df.tail() 

加载Iris数据集

你可以在本书的代码包中找到一份Iris数据集的副本(以及本书中使用的所有其他数据集),如果你在离线工作或者UCI服务器(https://archive.ics.uci.edu/ml/machine-learning-databases/iris/iris.data)暂时无法访问时,可以使用该副本。例如,要从本地目录加载Iris数据集,你可以将这一行替换为:

df = pd.read_csv(
  'https://archive.ics.uci.edu/ml/'
  'machine-learning-databases/iris/iris.data',
  header=None, encoding='utf-8') 

使用以下代码:

df = pd.read_csv(
  'your/local/path/to/iris.data',
  header=None, encoding='utf-8') 

接下来,我们提取前100个类标签,这些标签对应50个Iris-setosa花和50个Iris-versicolor花,并将类标签转换为两个整数类标签1(versicolor)和-1(setosa),我们将它们赋值给向量y,其中pandas DataFramevalues方法返回相应的NumPy表示。

同样,我们提取这100个训练样本的第一个特征列(花萼长度)和第三个特征列(花瓣长度),并将它们赋值给特征矩阵X,我们可以通过二维散点图进行可视化:

>>> import matplotlib.pyplot as plt
>>> import numpy as np
>>> # select setosa and versicolor
>>> y = df.iloc[0:100, 4].values
>>> y = np.where(y == 'Iris-setosa', -1, 1)
>>> # extract sepal length and petal length
>>> X = df.iloc[0:100, [0, 2]].values
>>> # plot data
>>> plt.scatter(X[:50, 0], X[:50, 1],
...             color='red', marker='o', label='setosa')
>>> plt.scatter(X[50:100, 0], X[50:100, 1],
...             color='blue', marker='x', label='versicolor')
>>> plt.xlabel('sepal length [cm]')
>>> plt.ylabel('petal length [cm]')
>>> plt.legend(loc='upper left')
>>> plt.show() 

执行前面的代码示例后,我们现在应该看到以下散点图:

上面的散点图显示了Iris数据集中花卉样本沿两个特征轴的分布:花瓣长度和花萼长度(以厘米为单位)。在这个二维特征子空间中,我们可以看到,线性决策边界应该足以将Setosa花与Versicolor花分开。

因此,像感知机这样的线性分类器应该能够完美地对该数据集中的花进行分类。

现在,到了训练我们的感知机算法的时候了,我们将使用刚刚提取的Iris数据子集。此外,我们还将绘制每个epoch的误分类错误,以检查算法是否收敛,并找到分离两个Iris花类的决策边界:

>>> ppn = Perceptron(eta=0.1, n_iter=10)
>>> ppn.fit(X, y)
>>> plt.plot(range(1, len(ppn.errors_) + 1),
...          ppn.errors_, marker='o')
>>> plt.xlabel('Epochs')
>>> plt.ylabel('Number of updates')
>>> plt.show() 

执行前述代码后,我们应该能看到误分类错误与迭代次数的关系图,如下图所示:

如我们在前面的图中所见,我们的感知机在第六次迭代后收敛,现在应该能够完美地分类训练样本。让我们实现一个小的便捷函数,来可视化二维数据集的决策边界:

from matplotlib.colors import ListedColormap
def plot_decision_regions(X, y, classifier, resolution=0.02):
    # setup marker generator and color map
    markers = ('s', 'x', 'o', '^', 'v')
    colors = ('red', 'blue', 'lightgreen', 'gray', 'cyan')
    cmap = ListedColormap(colors[:len(np.unique(y))])

    # plot the decision surface
    x1_min, x1_max = X[:, 0].min() - 1, X[:, 0].max() + 1
    x2_min, x2_max = X[:, 1].min() - 1, X[:, 1].max() + 1
    xx1, xx2 = np.meshgrid(np.arange(x1_min, x1_max, resolution),
                           np.arange(x2_min, x2_max, resolution))
    Z = classifier.predict(np.array([xx1.ravel(), xx2.ravel()]).T)
    Z = Z.reshape(xx1.shape)
    plt.contourf(xx1, xx2, Z, alpha=0.3, cmap=cmap)
    plt.xlim(xx1.min(), xx1.max())
    plt.ylim(xx2.min(), xx2.max())

    # plot class examples
    for idx, cl in enumerate(np.unique(y)):
         plt.scatter(x=X[y == cl, 0],
                     y=X[y == cl, 1],
                     alpha=0.8,
                     c=colors[idx],
                     marker=markers[idx],
                     label=cl,
                     edgecolor='black') 

首先,我们定义一些colorsmarkers,并通过ListedColormap从颜色列表中创建一个颜色映射。然后,我们确定两个特征的最小值和最大值,并利用这些特征向量通过NumPy的meshgrid函数创建一对网格数组xx1xx2。由于我们在两个特征维度上训练了感知机分类器,我们需要将网格数组展平,并创建一个具有与鸢尾花训练子集相同列数的矩阵,以便我们可以使用predict方法预测相应网格点的类别标签Z

在将预测的类别标签Z重新塑形为与xx1xx2相同维度的网格后,我们可以通过Matplotlib的contourf函数绘制一个等高线图,将不同的决策区域映射到网格数组中每个预测类别的不同颜色:

>>> plot_decision_regions(X, y, classifier=ppn)
>>> plt.xlabel('sepal length [cm]')
>>> plt.ylabel('petal length [cm]')
>>> plt.legend(loc='upper left')
>>> plt.show() 

执行上述代码示例后,我们现在应该能够看到决策区域的图示,如下图所示:

如我们在图中所见,感知机学到了一个决策边界,能够完美地分类所有鸢尾花训练子集中的样本。

感知机收敛性

尽管感知机能够完美地分类两个鸢尾花类别,但收敛性是感知机面临的最大问题之一。Rosenblatt通过数学证明,如果两个类别可以通过线性超平面分隔,感知机学习规则是会收敛的。然而,如果这些类别无法通过这样的线性决策边界完美分隔,权重将永远不会停止更新,除非我们设置最大迭代次数。感兴趣的读者可以在我的讲义中找到该证明的摘要,地址是https://sebastianraschka.com/pdf/lecture-notes/stat479ss19/L03_perceptron_slides.pdf

自适应线性神经元和学习的收敛性

在这一部分,我们将了解另一种类型的单层神经网络(NN):自适应线性神经元Adaline)。Adaline是由Bernard Widrow和他的博士生Tedd Hoff在Rosenblatt的感知机算法发布几年后提出的,可以看作是对感知机的改进(An Adaptive "Adaline" Neuron Using Chemical "Memistors", 技术报告编号1553-2, B. Widrow及其他人, 斯坦福电子实验室, 斯坦福,加利福尼亚州,1960年10月)。

Adaline 算法特别有趣,因为它展示了定义和最小化连续成本函数的关键概念。这为理解更高级的机器学习分类算法奠定了基础,如逻辑回归、支持向量机和回归模型,我们将在未来的章节中讨论这些内容。

Adaline 规则(也称为Widrow-Hoff 规则)与 Rosenblatt 的感知机之间的关键区别在于,权重是基于线性激活函数更新的,而不是像感知机那样基于单位阶跃函数。在 Adaline 中,这个线性激活函数,,仅仅是净输入的恒等函数,因此:

虽然线性激活函数用于学习权重,但我们仍然使用阈值函数进行最终预测,这与我们之前讨论的单位阶跃函数相似。

感知机与 Adaline 算法之间的主要区别在下图中得到了突出显示:

如图所示,Adaline 算法将真实类别标签与线性激活函数的连续值输出进行比较,以计算模型误差并更新权重。相比之下,感知机将真实类别标签与预测的类别标签进行比较。

使用梯度下降最小化成本函数

监督式机器学习算法的关键组成部分之一是一个定义好的目标函数,该函数将在学习过程中进行优化。这个目标函数通常是我们希望最小化的成本函数。在 Adaline 的情况下,我们可以定义成本函数 J,以学习权重为目标,作为计算结果与真实类别标签之间的平方误差之和SSE):

术语 仅为我们的方便而加上,它将使我们更容易推导出成本或损失函数关于权重参数的梯度,正如我们将在下面的段落中看到的那样。与单位阶跃函数相比,这个连续的线性激活函数的主要优点是成本函数变得可微分。这个成本函数的另一个优良性质是它是凸的;因此,我们可以使用一种非常简单却强大的优化算法,称为梯度下降,来找到最小化我们成本函数的权重,从而对 Iris 数据集中的样本进行分类。

如下图所示,我们可以将梯度下降的主要思想描述为爬山,直到达到局部或全局成本最小值。在每次迭代中,我们都会朝着梯度的相反方向迈出一步,其中步长由学习率的值以及梯度的坡度决定:

使用梯度下降法,我们现在可以通过在成本函数的梯度!的相反方向上采取一步来更新权重!

权重变化!定义为负梯度乘以学习率!

为了计算成本函数的梯度,我们需要计算成本函数相对于每个权重的偏导数!

所以我们可以写出权重!的更新公式:

由于我们同时更新所有权重,我们的Adaline学习规则变成了:

平方误差导数

如果你熟悉微积分,SSE成本函数相对于第j个权重的偏导数可以通过以下方式得到:

尽管Adaline学习规则看起来与感知机规则相同,我们应该注意到!与!是实数而非整数类别标签。此外,权重更新是基于训练数据集中的所有样本计算的(而不是在每个训练样本后增量更新权重),这就是为什么这种方法也被称为批量梯度下降

在Python中实现Adaline

由于感知机规则和Adaline非常相似,我们将采用之前定义的感知机实现,并修改fit方法,使得权重通过梯度下降法最小化成本函数来更新:

class AdalineGD(object):
    """ADAptive LInear NEuron classifier.

    Parameters
    ------------
    eta : float
        Learning rate (between 0.0 and 1.0)
    n_iter : int
        Passes over the training dataset.
    random_state : int
        Random number generator seed for random weight initialization.

    Attributes
    -----------
    w_ : 1d-array
        Weights after fitting.
    cost_ : list
        Sum-of-squares cost function value in each epoch.

    """
    def __init__(self, eta=0.01, n_iter=50, random_state=1):
        self.eta = eta
        self.n_iter = n_iter
        self.random_state = random_state

    def fit(self, X, y):
        """ Fit training data.

        Parameters
        ----------
        X : {array-like}, shape = [n_examples, n_features]
            Training vectors, where n_examples
            is the number of examples and
            n_features is the number of features.
        y : array-like, shape = [n_examples]
            Target values.

        Returns
        -------
        self : object

        """
        rgen = np.random.RandomState(self.random_state)
        self.w_ = rgen.normal(loc=0.0, scale=0.01,
                              size=1 + X.shape[1])
        self.cost_ = []

        for i in range(self.n_iter):
            net_input = self.net_input(X)
            output = self.activation(net_input)
            errors = (y - output)
            self.w_[1:] += self.eta * X.T.dot(errors)
            self.w_[0] += self.eta * errors.sum()
            cost = (errors**2).sum() / 2.0
            self.cost_.append(cost)
        return self

    def net_input(self, X):
        """Calculate net input"""
        return np.dot(X, self.w_[1:]) + self.w_[0]

    def activation(self, X):
        """Compute linear activation"""
        return X

    def predict(self, X):
        """Return class label after unit step"""
        return np.where(self.activation(self.net_input(X))
                        >= 0.0, 1, -1) 

不像感知机在评估每个训练样本后更新权重,我们通过self.eta * errors.sum()来计算偏置单元(零权重)的梯度,使用self.eta * X.T.dot(errors)计算权重1到m的梯度,其中X.T.dot(errors)是特征矩阵与误差向量的矩阵-向量乘法。

请注意,activation方法在代码中没有效果,因为它只是一个恒等函数。在这里,我们添加了激活函数(通过activation方法计算)以说明单层神经网络中信息流动的一般概念:从输入数据的特征、净输入、激活到输出。

在下一章中,我们将学习使用非恒等、非线性激活函数的逻辑回归分类器。我们将看到逻辑回归模型与Adaline密切相关,唯一的区别是它的激活函数和成本函数。

现在,与之前的感知机实现类似,我们将成本值收集到 self.cost_ 列表中,以检查算法在训练后是否已经收敛。

矩阵乘法

执行矩阵乘法类似于计算向量点积,其中矩阵中的每一行都被视为一个单独的行向量。这种向量化的方法代表了更紧凑的符号,并且使用 NumPy 可以实现更高效的计算。例如:

请注意,在前面的方程中,我们正在将矩阵与向量相乘,这在数学上是未定义的。然而,请记住,我们使用的约定是,将前面的向量视为一个 矩阵。

在实际应用中,通常需要一些实验来找到一个合适的学习率,,以实现最佳的收敛。因此,我们先选择两个不同的学习率,,并绘制成本函数与迭代次数的关系图,以观察 Adaline 实现从训练数据中学习的效果。

感知机超参数

学习率 eta),以及迭代次数(n_iter)是感知机和 Adaline 学习算法的超参数(或调节参数)。在 第六章模型评估和超参数调优的最佳实践 中,我们将探讨不同的技术,以自动找到能够实现分类模型最佳性能的超参数值。

现在,让我们绘制两个不同学习率的成本与迭代次数的关系图:

>>> fig, ax = plt.subplots(nrows=1, ncols=2, figsize=(10, 4))
>>> ada1 = AdalineGD(n_iter=10, eta=0.01).fit(X, y)
>>> ax[0].plot(range(1, len(ada1.cost_) + 1),
...            np.log10(ada1.cost_), marker='o')
>>> ax[0].set_xlabel('Epochs')
>>> ax[0].set_ylabel('log(Sum-squared-error)')
>>> ax[0].set_title('Adaline - Learning rate 0.01')
>>> ada2 = AdalineGD(n_iter=10, eta=0.0001).fit(X, y)
>>> ax[1].plot(range(1, len(ada2.cost_) + 1),
...            ada2.cost_, marker='o')
>>> ax[1].set_xlabel('Epochs')
>>> ax[1].set_ylabel('Sum-squared-error')
>>> ax[1].set_title('Adaline - Learning rate 0.0001')
>>> plt.show() 

正如我们在成本函数图中看到的那样,我们遇到了两种不同类型的问题。左侧图表显示了如果我们选择一个过大的学习率可能会发生的情况。由于我们 越过 了全局最小值,每个迭代中的误差变得更大,而不是最小化成本函数。另一方面,我们可以看到右侧图表中的成本在下降,但所选择的学习率 太小,以至于算法需要大量的迭代才能收敛到全局成本最小值。

下图说明了如果我们改变某个特定权重参数的值来最小化成本函数 J 可能发生的情况。左侧子图展示了选择合适学习率的情况,其中成本逐渐下降,朝着全局最小值的方向移动。

然而,右侧子图说明了如果我们选择一个过大的学习率会发生什么——我们会越过全局最小值:

通过特征缩放来改进梯度下降

本书中我们将遇到的许多机器学习算法需要某种特征缩放来实现最佳性能,关于这一点我们将在第3章《使用scikit-learn的机器学习分类器巡礼》和第4章《构建良好的训练数据集——数据预处理》中详细讨论。

梯度下降是许多从特征缩放中受益的算法之一。在本节中,我们将使用一种名为标准化的特征缩放方法,它赋予我们的数据标准正态分布的特性:零均值和单位方差。这个归一化过程有助于梯度下降学习更快地收敛;然而,它并不会使原始数据集变为正态分布。标准化将每个特征的均值平移,使其居中于零,并且每个特征的标准差为1(单位方差)。例如,为了标准化第j个特征,我们可以简单地从每个训练样本中减去样本均值,并将其除以标准差

这里,是一个由所有训练示例的第j个特征值构成的向量,n,该标准化技术应用于数据集中的每个特征j

标准化有助于梯度下降学习的原因之一是,优化器需要经过更少的步骤来找到一个好的或最优的解决方案(全局成本最小值),如下图所示,其中子图表示在一个二维分类问题中,成本面作为两个模型权重的函数:

标准化可以通过使用内置的NumPy方法meanstd轻松实现:

>>> X_std = np.copy(X)
>>> X_std[:,0] = (X[:,0] - X[:,0].mean()) / X[:,0].std()
>>> X_std[:,1] = (X[:,1] - X[:,1].mean()) / X[:,1].std() 

标准化之后,我们将再次训练Adaline,并会看到它在少数几个epoch后使用学习率就收敛了:

>>> ada_gd = AdalineGD(n_iter=15, eta=0.01)
>>> ada_gd.fit(X_std, y)
>>> plot_decision_regions(X_std, y, classifier=ada_gd)
>>> plt.title('Adaline - Gradient Descent')
>>> plt.xlabel('sepal length [standardized]')
>>> plt.ylabel('petal length [standardized]')
>>> plt.legend(loc='upper left')
>>> plt.tight_layout()
>>> plt.show()
>>> plt.plot(range(1, len(ada_gd.cost_) + 1),
...          ada_gd.cost_, marker='o')
>>> plt.xlabel('Epochs')
>>> plt.ylabel('Sum-squared-error')
>>> plt.tight_layout()
>>> plt.show() 

执行此代码后,我们应该看到决策区域的图形,以及下降成本的图表,如下图所示:

正如我们在图表中看到的,Adaline在使用学习率对标准化特征进行训练后已收敛。然而,注意尽管所有的花卉示例都已正确分类,SSE仍然不为零。

大规模机器学习和随机梯度下降

在前一部分中,我们学习了如何通过朝着代价梯度的相反方向迈出一步来最小化代价函数,该代价梯度是从整个训练数据集中计算得出的;因此,这种方法有时也被称为批量梯度下降。现在假设我们有一个非常大的数据集,其中包含数百万个数据点,这在许多机器学习应用中并不罕见。在这种情况下,运行批量梯度下降的计算成本可能会非常高,因为每次向全局最小值迈出一步时,我们都需要重新评估整个训练数据集。

批量梯度下降算法的一个流行替代方法是随机梯度下降(SGD),有时也被称为迭代或在线梯度下降。它不是基于所有训练样本的累积误差总和来更新权重,而是基于每个训练样本的误差来更新权重!

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

尽管SGD可以看作是梯度下降的一种近似方法,但由于更频繁的权重更新,它通常能更快地达到收敛。由于每个梯度是基于单个训练样本计算的,因此误差面比梯度下降更加嘈杂,这也带来了一个优势,即如果我们处理的是非线性代价函数,SGD可以更容易地逃脱浅层局部最小值,正如我们将在第12章中看到的,从头开始实现多层人工神经网络。为了通过SGD获得令人满意的结果,重要的是将训练数据按随机顺序呈现;此外,我们还希望在每个周期后对训练数据集进行洗牌,以防止出现周期性问题。

在训练过程中调整学习率

在SGD的实现中,固定的学习率,,通常会被一个随着时间减少的自适应学习率替代,例如:

其中是常数。请注意,SGD并未达到全局最小值,而是达到了一个非常接近全局最小值的区域。通过使用自适应学习率,我们可以进一步进行退火,以便更好地接近代价最小值。

随机梯度下降(SGD)的另一个优点是,我们可以用它进行在线学习。在在线学习中,随着新训练数据的到来,我们的模型会实时进行训练。这在我们积累大量数据时尤其有用,例如,Web应用中的客户数据。使用在线学习,系统可以立即适应变化,并且如果存储空间有限,更新模型后可以丢弃训练数据。

小批量梯度下降

批量梯度下降和 SGD 之间的折衷方法是所谓的 小批量学习。小批量学习可以理解为将批量梯度下降应用于较小的训练数据子集,例如一次处理 32 个训练样本。与批量梯度下降相比,小批量学习的优势在于,通过更频繁的权重更新,小批量方法能够更快地收敛。此外,小批量学习还允许我们将 SGD 中对训练样本的 for 循环替换为利用线性代数概念(例如,通过点积实现加权和)的向量化操作,从而进一步提高学习算法的计算效率。

由于我们已经使用梯度下降实现了 Adaline 学习规则,我们只需要做一些调整,就可以修改学习算法通过 SGD 更新权重。在 fit 方法中,我们现在将在每个训练样本之后更新权重。此外,我们将实现一个额外的 partial_fit 方法,用于在线学习,该方法不会重新初始化权重。为了检查我们的算法在训练后是否已收敛,我们将计算成本,即每个周期中训练样本的平均成本。此外,我们将添加一个选项,在每个周期之前打乱训练数据,以避免在优化成本函数时出现重复的周期;通过 random_state 参数,我们允许指定随机种子以确保可复现性:

class AdalineSGD(object):
    """ADAptive LInear NEuron classifier.

    Parameters
    ------------
    eta : float
        Learning rate (between 0.0 and 1.0)
    n_iter : int
        Passes over the training dataset.
    shuffle : bool (default: True)
        Shuffles training data every epoch if True to prevent 
        cycles.
    random_state : int
        Random number generator seed for random weight 
        initialization.

    Attributes
    -----------
    w_ : 1d-array
        Weights after fitting.
    cost_ : list
        Sum-of-squares cost function value averaged over all
        training examples in each epoch.

    """
    def __init__(self, eta=0.01, n_iter=10,
              shuffle=True, random_state=None):
        self.eta = eta
        self.n_iter = n_iter
        self.w_initialized = False
        self.shuffle = shuffle
        self.random_state = random_state

    def fit(self, X, y):
        """ Fit training data.

        Parameters
        ----------
        X : {array-like}, shape = [n_examples, n_features]
            Training vectors, where n_examples is the number of 
            examples and n_features is the number of features.
        y : array-like, shape = [n_examples]
            Target values.

        Returns
        -------
        self : object

        """
        self._initialize_weights(X.shape[1])
        self.cost_ = []
        for i in range(self.n_iter):
            if self.shuffle:
                X, y = self._shuffle(X, y)
            cost = []
            for xi, target in zip(X, y):
                cost.append(self._update_weights(xi, target))
            avg_cost = sum(cost) / len(y)
            self.cost_.append(avg_cost)
        return self

    def partial_fit(self, X, y):
        """Fit training data without reinitializing the weights"""
        if not self.w_initialized:
            self._initialize_weights(X.shape[1])
        if y.ravel().shape[0] > 1:
            for xi, target in zip(X, y):
                self._update_weights(xi, target)
        else:
            self._update_weights(X, y)
        return self

    def _shuffle(self, X, y):
        """Shuffle training data"""
        r = self.rgen.permutation(len(y))
        return X[r], y[r]

    def _initialize_weights(self, m):
        """Initialize weights to small random numbers"""
        self.rgen = np.random.RandomState(self.random_state)
        self.w_ = self.rgen.normal(loc=0.0, scale=0.01,
                                   size=1 + m)
        self.w_initialized = True

    def _update_weights(self, xi, target):
        """Apply Adaline learning rule to update the weights"""
        output = self.activation(self.net_input(xi))
        error = (target - output)
        self.w_[1:] += self.eta * xi.dot(error)
        self.w_[0] += self.eta * error
        cost = 0.5 * error**2
        return cost

    def net_input(self, X):
        """Calculate net input"""
        return np.dot(X, self.w_[1:]) + self.w_[0]

    def activation(self, X):
        """Compute linear activation"""
        return X

    def predict(self, X):
        """Return class label after unit step"""
        return np.where(self.activation(self.net_input(X))
                        >= 0.0, 1, -1) 

我们现在在 AdalineSGD 分类器中使用的 _shuffle 方法如下工作:通过 np.random 中的 permutation 函数,我们生成一个从 0 到 100 的唯一数字的随机序列。然后可以将这些数字用作索引来打乱我们的特征矩阵和类别标签向量。

然后,我们可以使用 fit 方法训练 AdalineSGD 分类器,并使用我们的 plot_decision_regions 绘制训练结果:

>>> ada_sgd = AdalineSGD(n_iter=15, eta=0.01, random_state=1)
>>> ada_sgd.fit(X_std, y)
>>> plot_decision_regions(X_std, y, classifier=ada_sgd)
>>> plt.title('Adaline - Stochastic Gradient Descent')
>>> plt.xlabel('sepal length [standardized]')
>>> plt.ylabel('petal length [standardized]')
>>> plt.legend(loc='upper left')
>>> plt.tight_layout()
>>> plt.show()
>>> plt.plot(range(1, len(ada_sgd.cost_) + 1), ada_sgd.cost_,
...          marker='o')
>>> plt.xlabel('Epochs')
>>> plt.ylabel('Average Cost')
>>> plt.tight_layout()
>>> plt.show() 

执行前面的代码示例后,我们获得的两个图表如下所示:

正如你所看到的,平均成本下降得非常快,经过 15 个周期后的最终决策边界与批量梯度下降的 Adaline 相似。如果我们想要更新模型,例如在流式数据的在线学习场景中,我们可以简单地对单个训练样本调用 partial_fit 方法——例如 ada_sgd.partial_fit(X_std[0, :], y[0])

总结

在本章中,我们对监督学习中的线性分类器的基本概念有了较为深入的了解。在实现感知器之后,我们看到了如何通过梯度下降的向量化实现和通过 SGD 的在线学习高效训练自适应线性神经元。

现在我们已经了解了如何在Python中实现简单的分类器,接下来我们准备进入下一章,在那里我们将使用Python的scikit-learn机器学习库,获取更高级和更强大的机器学习分类器,这些分类器在学术界和工业界都有广泛应用。

我们用于实现感知机和Adaline算法的面向对象方法将有助于理解scikit-learn API,它基于我们在本章中使用的相同核心概念实现:fitpredict方法。基于这些核心概念,我们将学习用于建模类别概率的逻辑回归以及用于处理非线性决策边界的支持向量机。此外,我们还将介绍一种不同类别的监督学习算法——基于树的算法,这些算法通常会结合成强大的集成分类器。

第三章:使用scikit-learn的机器学习分类器之旅

本章将介绍一些常用且强大的机器学习算法,这些算法在学术界和工业界都有广泛应用。在学习几种用于分类的监督学习算法的区别时,我们还将深入了解它们各自的优缺点。此外,我们还将迈出使用scikit-learn库的第一步,scikit-learn提供了一个用户友好且一致的接口,用于高效和富有成效地使用这些算法。

本章将涵盖的主题如下:

  • 介绍一些稳健且流行的分类算法,如逻辑回归、支持向量机和决策树

  • 使用scikit-learn机器学习库的示例和解释,scikit-learn提供了通过用户友好的Python API访问各种机器学习算法的功能

  • 讨论具有线性和非线性决策边界的分类器的优缺点

选择分类算法

为特定问题选择合适的分类算法需要实践和经验;每种算法都有其独特之处,并且基于某些假设。重新表述大卫·沃尔珀特(David H. Wolpert)提出的无免费午餐定理,没有一种分类器在所有可能的情境中表现最佳(《学习算法之间缺乏先验区分》,沃尔珀特,大卫·H神经计算 8.7(1996年):1341-1390)。实际上,建议始终比较至少几种不同学习算法的性能,以选择最适合特定问题的模型;这些算法可能在特征或样本数量、数据集中的噪声量以及类别是否线性可分等方面有所不同。

最终,分类器的性能——包括计算性能和预测能力——在很大程度上依赖于可用于学习的基础数据。训练一个监督机器学习算法所涉及的五个主要步骤可以总结如下:

  1. 选择特征并收集标注的训练样本。

  2. 选择性能度量标准。

  3. 选择分类器和优化算法。

  4. 评估模型的性能。

  5. 调整算法。

由于本书的教学方法是一步步构建机器学习知识,我们将在本章中主要关注不同算法的基本概念,并将在本书后续章节中重新探讨特征选择与预处理、性能度量和超参数调整等主题,进行更为详细的讨论。

使用scikit-learn的第一步——训练感知机

第二章训练简单的机器学习分类算法中,你学习了两种相关的分类学习算法:感知机规则和Adaline,我们通过 Python 和 NumPy 实现了这两种算法。现在我们将看一下 scikit-learn API,正如前面提到的,它结合了一个用户友好且一致的接口,以及几种分类算法的高度优化实现。scikit-learn 库不仅提供了大量的学习算法,还提供了许多便捷的函数,用于预处理数据以及微调和评估我们的模型。我们将在第四章构建良好的训练数据集 – 数据预处理第五章通过降维压缩数据中进一步讨论这些内容,并讲解其背后的基本概念。

为了开始使用 scikit-learn 库,我们将训练一个类似于第二章中实现的感知机模型。为简便起见,接下来的部分我们将继续使用已经熟悉的鸢尾花数据集。方便的是,鸢尾花数据集已经可以通过 scikit-learn 获取,因为它是一个简单但广泛使用的数据集,经常用于算法测试和实验。与前一章类似,我们将在本章中仅使用鸢尾花数据集的两个特征来进行可视化展示。

我们将把 150 个花卉样本的花瓣长度和花瓣宽度分配给特征矩阵 X,并将对应的花卉物种的类别标签分配给向量数组 y

>>> from sklearn import datasets
>>> import numpy as np
>>> iris = datasets.load_iris()
>>> X = iris.data[:, [2, 3]]
>>> y = iris.target
>>> print('Class labels:', np.unique(y))
Class labels: [0 1 2] 

np.unique(y) 函数返回了存储在 iris.target 中的三个唯一类别标签,正如我们所见,鸢尾花的类别名称 Iris-setosaIris-versicolorIris-virginica 已经以整数形式存储(此处为:012)。尽管许多 scikit-learn 函数和类方法也可以处理字符串格式的类别标签,但使用整数标签是一种推荐的方法,能避免技术故障并提高计算性能,因为它占用更少的内存;此外,将类别标签编码为整数是大多数机器学习库的常见约定。

为了评估训练好的模型在未见过的数据上的表现,我们将进一步将数据集拆分为单独的训练集和测试集。在第六章模型评估与超参数调整的最佳实践中,我们将更详细地讨论有关模型评估的最佳实践:

>>> from sklearn.model_selection import train_test_split
>>> X_train, X_test, y_train, y_test = train_test_split(
...     X, y, test_size=0.3, random_state=1, stratify=y) 

使用 train_test_split 函数来自 scikit-learn 的 model_selection 模块,我们将 Xy 数组随机分割为 30% 的测试数据(45 个样本)和 70% 的训练数据(105 个样本)。

请注意,train_test_split 函数在拆分数据之前已经在内部对训练数据集进行了洗牌;否则,所有来自类别 0 和类别 1 的示例将会被分配到训练数据集中,而测试数据集将包含来自类别 2 的 45 个示例。通过 random_state 参数,我们为内部用于数据集洗牌的伪随机数生成器提供了一个固定的随机种子(random_state=1)。使用这样的固定 random_state 确保了我们的结果是可重复的。

最后,我们利用了 stratify=y 提供的内建分层支持。在此上下文中,分层意味着 train_test_split 方法返回的训练集和测试集子集与输入数据集具有相同的类别标签比例。我们可以使用 NumPy 的 bincount 函数,它用于计算数组中每个值的出现次数,以验证这一点是否成立:

>>> print('Labels counts in y:', np.bincount(y))
Labels counts in y: [50 50 50]
>>> print('Labels counts in y_train:', np.bincount(y_train))
Labels counts in y_train: [35 35 35]
>>> print('Labels counts in y_test:', np.bincount(y_test))
Labels counts in y_test: [15 15 15] 

如我们在第2章训练简单机器学习算法进行分类中的梯度下降示例所看到的,许多机器学习和优化算法也需要特征缩放才能获得最佳性能。在这里,我们将使用 scikit-learn 的 preprocessing 模块中的 StandardScaler 类对特征进行标准化:

>>> from sklearn.preprocessing import StandardScaler
>>> sc = StandardScaler()
>>> sc.fit(X_train)
>>> X_train_std = sc.transform(X_train)
>>> X_test_std = sc.transform(X_test) 

使用前面的代码,我们从 preprocessing 模块加载了 StandardScaler 类,并初始化了一个新的 StandardScaler 对象,将其分配给 sc 变量。通过调用 fit 方法,StandardScaler 从训练数据中估算了每个特征维度的参数,(样本均值)和 (标准差)。接着,我们通过调用 transform 方法,利用这些估算的参数对训练数据进行了标准化。请注意,我们使用相同的缩放参数对测试数据集进行了标准化,这样训练数据集和测试数据集中的数值可以互相比较。

标准化了训练数据后,我们现在可以训练一个感知机模型。scikit-learn 中的大多数算法默认通过一对多OvR)方法支持多类别分类,这使得我们可以一次性将三个花卉类别输入到感知机模型中。代码如下:

>>> from sklearn.linear_model import Perceptron
>>> ppn = Perceptron(eta0=0.1, random_state=1)
>>> ppn.fit(X_train_std, y_train) 

scikit-learn 接口让你想起我们在第2章训练简单机器学习算法进行分类中实现的感知机。在从 linear_model 模块加载 Perceptron 类之后,我们初始化了一个新的 Perceptron 对象,并通过 fit 方法训练了该模型。在这里,模型参数 eta0 相当于我们在自己实现的感知机中的学习率 eta,而 n_iter 参数定义了迭代次数(对训练数据集的遍历次数)。

正如你在第2章中所记得的,找到合适的学习率需要进行一些实验。如果学习率过大,算法会超过全局代价最小值。如果学习率太小,算法会需要更多的epochs直到收敛,这会使得学习变慢 —— 特别是对于大数据集。此外,我们使用了random_state参数来确保每个epoch后对训练数据集进行的初始洗牌是可重现的。

在scikit-learn中训练完模型后,我们可以通过predict方法进行预测,就像在第2章中我们自己的感知器实现中一样。代码如下:

>>> y_pred = ppn.predict(X_test_std)
>>> print('Misclassified examples: %d' % (y_test != y_pred).sum())
Misclassified examples: 1 

执行代码后,我们可以看到感知器在45个花的示例中误分类了1个。因此,测试数据集上的误分类错误大约为0.022或2.2% ()。

分类错误与准确率

许多机器学习实践者报告模型的分类准确率而不是误分类错误,这简单地计算如下:

1 - 错误 = 0.978 或 97.8%

使用分类错误还是准确率仅仅是个人偏好的问题。

注意,scikit-learn还实现了许多不同的性能度量,通过metrics模块可用。例如,我们可以计算感知器在测试数据集上的分类准确率如下:

>>> from sklearn.metrics import accuracy_score
>>> print('Accuracy: %.3f' % accuracy_score(y_test, y_pred))
Accuracy: 0.978 

在这里,y_test是真实的类标签,y_pred是我们之前预测的类标签。另外,scikit-learn中每个分类器都有一个score方法,通过将predict调用与accuracy_score结合起来计算分类器的预测准确率,如下所示:

>>> print('Accuracy: %.3f' % ppn.score(X_test_std, y_test))
Accuracy: 0.978 

过拟合

注意,在本章中,我们将基于测试数据集评估模型的性能。在第6章学习模型评估和超参数调优的最佳实践,你将学习到有用的技术,包括图形分析,如学习曲线,来检测和预防过拟合。过拟合,我们稍后将在本章返回讨论,意味着模型很好地捕捉了训练数据的模式,但对未见过的数据泛化能力不佳。

最后,我们可以使用我们从第2章简单机器学习算法的分类训练中的plot_decision_regions函数来绘制我们新训练的感知器模型的决策区域,并可视化它如何将不同的花示例分离。然而,让我们通过小圆圈来突出显示来自测试数据集的数据实例:

from matplotlib.colors import ListedColormap
import matplotlib.pyplot as plt
def plot_decision_regions(X, y, classifier, test_idx=None,
                          resolution=0.02):
    # setup marker generator and color map
    markers = ('s', 'x', 'o', '^', 'v')
    colors = ('red', 'blue', 'lightgreen', 'gray', 'cyan')
    cmap = ListedColormap(colors[:len(np.unique(y))])

    # plot the decision surface
    x1_min, x1_max = X[:, 0].min() - 1, X[:, 0].max() + 1
    x2_min, x2_max = X[:, 1].min() - 1, X[:, 1].max() + 1
    xx1, xx2 = np.meshgrid(np.arange(x1_min, x1_max, resolution),
                           np.arange(x2_min, x2_max, resolution))
    Z = classifier.predict(np.array([xx1.ravel(), xx2.ravel()]).T)
    Z = Z.reshape(xx1.shape)
    plt.contourf(xx1, xx2, Z, alpha=0.3, cmap=cmap)
    plt.xlim(xx1.min(), xx1.max())
    plt.ylim(xx2.min(), xx2.max())

    for idx, cl in enumerate(np.unique(y)):
        plt.scatter(x=X[y == cl, 0], y=X[y == cl, 1],
                    alpha=0.8, c=colors[idx],
                    marker=markers[idx], label=cl,
                    edgecolor='black')

    # highlight test examples
    if test_idx:
        # plot all examples
        X_test, y_test = X[test_idx, :], y[test_idx]

        plt.scatter(X_test[:, 0], X_test[:, 1],
                    c='', edgecolor='black', alpha=1.0,
                    linewidth=1, marker='o',
                    s=100, label='test set') 

我们稍微修改了plot_decision_regions函数,现在我们可以指定要在生成的图中标记的示例的索引。代码如下:

>>> X_combined_std = np.vstack((X_train_std, X_test_std))
>>> y_combined = np.hstack((y_train, y_test))
>>> plot_decision_regions(X=X_combined_std,
...                       y=y_combined,
...                       classifier=ppn,
...                       test_idx=range(105, 150))
>>> plt.xlabel('petal length [standardized]')
>>> plt.ylabel('petal width [standardized]')
>>> plt.legend(loc='upper left')
>>> plt.tight_layout()
>>> plt.show() 

正如我们在生成的图中看到的,这三类花不能完美地通过线性决策边界分开:

记住在第二章《训练简单的机器学习分类算法》中提到的内容,感知器算法在数据集不是完全线性可分时永远不会收敛,这也是为什么在实际应用中通常不推荐使用感知器算法的原因。在接下来的章节中,我们将探讨一些更强大的线性分类器,即使类不是完全线性可分,它们也能收敛到一个最小的代价。

附加的感知器设置

感知器(Perceptron)以及其他 scikit-learn 函数和类,通常有一些额外的参数,为了简洁起见我们在这里省略了这些参数。你可以通过 Python 中的 help 函数(例如,help(Perceptron))或通过浏览出色的 scikit-learn 在线文档,了解更多关于这些参数的内容:http://scikit-learn.org/stable/

通过逻辑回归建模类概率

尽管感知器规则为机器学习分类算法提供了一个简单且易于理解的入门,但其最大缺点是,如果类不是完全线性可分的,它永远不会收敛。上一节中的分类任务就是一个这样的例子。其原因在于,权重会持续更新,因为每个周期内总会至少存在一个被错误分类的训练样本。当然,你可以调整学习率并增加周期数,但请注意,在此数据集上感知器永远不会收敛。

为了更好地利用时间,我们现在来看另一种简单但更强大的线性和二分类问题算法:逻辑回归。需要注意的是,尽管它的名字里有“回归”二字,逻辑回归实际上是一种分类模型,而非回归模型。

逻辑回归与条件概率

逻辑回归是一种分类模型,容易实现,并且在类是线性可分的情况下表现非常好。它是工业界中应用最广泛的分类算法之一。与感知器和 Adaline 类似,本章中的逻辑回归模型也是一种用于二分类的线性模型。

多类逻辑回归

请注意,逻辑回归可以很容易地推广到多类设置,这被称为多项式逻辑回归或软最大回归(softmax regression)。关于多项式逻辑回归的详细内容超出了本书的范围,但感兴趣的读者可以在我的讲义中找到更多信息:https://sebastianraschka.com/pdf/lecture-notes/stat479ss19/L05_gradient-descent_slides.pdfhttp://rasbt.github.io/mlxtend/user_guide/classifier/SoftmaxRegression/

在多类设置中使用逻辑回归的另一种方式是通过OvR技术,这也是我们之前讨论过的。

为了将逻辑回归作为二元分类的概率模型来解释,我们首先引入赔率:某个特定事件的赔率。赔率可以写成,其中p代表正事件的概率。这里的“正事件”不一定意味着“好”,而是指我们想要预测的事件,例如,某个患者患有某种疾病的概率;我们可以将正事件视为类别标签y = 1。接着,我们可以进一步定义logit函数,它仅仅是赔率(log-odds)的对数:

请注意,log指的是自然对数,因为在计算机科学中这是常见的约定。logit函数接受0到1范围内的输入值,并将其转换为整个实数范围内的值,我们可以用它来表达特征值与log-odds之间的线性关系:

这里,是给定特征x的条件概率,即某个特定示例属于类别1的概率。

现在,我们实际上关心的是预测某个示例属于某个特定类别的概率,这就是logit函数的逆函数形式。

它也被称为逻辑sigmoid函数,有时由于其S形特征,简写为sigmoid函数

这里,z是净输入,即权重和输入的线性组合(即与训练示例相关的特征):

请注意,类似于我们在第2章中使用的约定,训练简单机器学习算法进行分类表示偏置单元,是我们提供给的附加输入值,且其值设置为1。

现在,让我们绘制sigmoid函数在-7到7范围内的一些值,看看它的形态:

>>> import matplotlib.pyplot as plt
>>> import numpy as np
>>> def sigmoid(z):
...     return 1.0 / (1.0 + np.exp(-z))
>>> z = np.arange(-7, 7, 0.1)
>>> phi_z = sigmoid(z)
>>> plt.plot(z, phi_z)
>>> plt.axvline(0.0, color='k')
>>> plt.ylim(-0.1, 1.1)
>>> plt.xlabel('z')
>>> plt.ylabel('$\phi (z)$')
>>> # y axis ticks and gridline
>>> plt.yticks([0.0, 0.5, 1.0])
>>> ax = plt.gca()
>>> ax.yaxis.grid(True)
>>> plt.tight_layout()
>>> plt.show() 

执行前面的代码示例后,我们现在应该能看到S形(sigmoidal)曲线:

我们可以看到,如果z趋向于无穷大(),则趋近于1,因为当z的值很大时,变得非常小。类似地,会趋向于0,原因是分母越来越大。由此,我们可以得出结论,这个sigmoid函数接收实数值作为输入,并将其转换为[0, 1]范围内的值,截距为

为了更好地理解逻辑回归模型,我们可以将其与 第 2 章 关联。在 Adaline 中,我们使用恒等函数 作为激活函数。在逻辑回归中,这个激活函数则变成了我们之前定义的 Sigmoid 函数。

Adaline 和逻辑回归之间的差异如下面的图示所示:

Sigmoid 函数的输出被解释为给定特征 x 和由权重 w 参数化的情况下,一个特定样本属于类别 1 的概率,如 。例如,如果我们为某个特定的花朵样本计算 ,这意味着该样本是 Iris-versicolor 花朵的概率是 80%。因此,这朵花是 Iris-setosa 花朵的概率可以通过 计算,结果是 20%。接下来,预测的概率可以通过阈值函数简化为二元结果:

如果我们查看之前的 Sigmoid 函数图像,这就相当于以下内容:

事实上,在许多应用中,我们不仅仅关心预测的类别标签,还特别关心类别成员概率的估算(即在应用阈值函数之前的 Sigmoid 函数输出)。例如,逻辑回归被广泛应用于天气预报,不仅用来预测某天是否会下雨,还用来报告降雨的概率。类似地,逻辑回归还可以用来预测给定某些症状时患者患有特定疾病的几率,这也是逻辑回归在医学领域广受欢迎的原因。

学习逻辑回归代价函数的权重

你已经了解了如何使用逻辑回归模型来预测概率和类别标签;现在,让我们简要讨论如何拟合模型的参数,例如权重 w。在上一章中,我们将平方和误差代价函数定义为如下形式:

我们通过最小化这个函数来学习 Adaline 分类模型的权重 w。为了说明我们如何推导逻辑回归的代价函数,首先让我们定义我们希望最大化的似然 L,假设数据集中的每个样本是相互独立的。公式如下:

实际上,最大化该方程的(自然)对数会更容易,这个过程被称为 对数似然 函数:

首先,应用对数函数减少了数值下溢的可能性,若似然值非常小时可能会发生这种情况。其次,我们可以将因子的乘积转换为因子的求和,这使得通过加法技巧(你可能还记得微积分中的这一技巧)更容易得到该函数的导数。

现在,我们可以使用一种优化算法,例如梯度上升法,来最大化这个对数似然函数。或者,我们可以将对数似然函数重写为一个成本函数 J,并通过梯度下降法最小化,正如在第2章《训练简单的机器学习分类算法》中所示:

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

看这个方程,我们可以看到,当 y = 0 时,第一个项为零;当 y = 1 时,第二个项为零:

:
>>> def cost_1(z):
...     return - np.log(sigmoid(z))
>>> def cost_0(z):
...     return - np.log(1 - sigmoid(z))
>>> z = np.arange(-10, 10, 0.1)
>>> phi_z = sigmoid(z)
>>> c1 = [cost_1(x) for x in z]
>>> plt.plot(phi_z, c1, label='J(w) if y=1')
>>> c0 = [cost_0(x) for x in z]
>>> plt.plot(phi_z, c0, linestyle='--', label='J(w) if y=0')
>>> plt.ylim(0.0, 5.1)
>>> plt.xlim([0, 1])
>>> plt.xlabel('$\phi$(z)')
>>> plt.ylabel('J(w)')
>>> plt.legend(loc='best')
>>> plt.tight_layout()
>>> plt.show() 

结果图显示了在 x 轴上从 0 到 1 范围内的 sigmoid 激活(sigmoid 函数的输入值是 z,范围为 -10 到 10),以及在 y 轴上相应的逻辑回归成本:

我们可以看到,如果我们正确预测一个示例属于类别 1,成本会接近 0(连续线)。同样,我们也可以在 y 轴上看到,如果我们正确预测 y = 0,成本也会接近 0(虚线)。然而,如果预测错误,成本将趋向无穷大。关键点是,我们会用越来越大的成本惩罚错误的预测。

将 Adaline 实现转换为逻辑回归算法

如果我们自己实现逻辑回归,可以简单地将成本函数 J第2章《训练简单的机器学习分类算法》中的 Adaline 实现中替换为新的成本函数:

我们使用它来计算每个训练样本在每个周期的分类成本。此外,我们需要将线性激活函数替换为 sigmoid 激活,并将阈值函数更改为返回类别标签 0 和 1,而不是 -1 和 1。如果我们对 Adaline 代码进行这些更改,我们将得到一个可工作的逻辑回归实现,如下所示:

class LogisticRegressionGD(object):
    """Logistic Regression Classifier using gradient descent.

    Parameters
    ------------
    eta : float
        Learning rate (between 0.0 and 1.0)
    n_iter : int
        Passes over the training dataset.
    random_state : int
        Random number generator seed for random weight
        initialization.

    Attributes
    -----------
    w_ : 1d-array
        Weights after fitting.
    cost_ : list
        Logistic cost function value in each epoch.

    """
    def __init__(self, eta=0.05, n_iter=100, random_state=1):
        self.eta = eta
        self.n_iter = n_iter
        self.random_state = random_state

    def fit(self, X, y):
        """ Fit training data.

        Parameters
        ----------
        X : {array-like}, shape = [n_examples, n_features]
            Training vectors, where n_examples is the number of
            examples and n_features is the number of features.
        y : array-like, shape = [n_examples]
            Target values.

        Returns
        -------
        self : object

        """
        rgen = np.random.RandomState(self.random_state)
        self.w_ = rgen.normal(loc=0.0, scale=0.01,
                              size=1 + X.shape[1])
        self.cost_ = []

        for i in range(self.n_iter):
            net_input = self.net_input(X)
            output = self.activation(net_input)
            errors = (y - output)
            self.w_[1:] += self.eta * X.T.dot(errors)
            self.w_[0] += self.eta * errors.sum()

            # note that we compute the logistic `cost` now
            # instead of the sum of squared errors cost
            cost = (-y.dot(np.log(output)) -
                        ((1 - y).dot(np.log(1 - output))))
            self.cost_.append(cost)
        return self

    def net_input(self, X):
        """Calculate net input"""
        return np.dot(X, self.w_[1:]) + self.w_[0]

    def activation(self, z):
        """Compute logistic sigmoid activation"""
        return 1\. / (1\. + np.exp(-np.clip(z, -250, 250)))

    def predict(self, X):
        """Return class label after unit step"""
        return np.where(self.net_input(X) >= 0.0, 1, 0)
        # equivalent to:
        # return np.where(self.activation(self.net_input(X))
        #                 >= 0.5, 1, 0) 

当我们拟合逻辑回归模型时,必须记住它仅适用于二分类任务。

因此,让我们只考虑 Iris-setosaIris-versicolor 花(类别 01),并检查我们实现的逻辑回归是否有效:

>>> X_train_01_subset = X_train[(y_train == 0) | (y_train == 1)]
>>> y_train_01_subset = y_train[(y_train == 0) | (y_train == 1)]
>>> lrgd = LogisticRegressionGD(eta=0.05,
...                             n_iter=1000,
...                             random_state=1)
>>> lrgd.fit(X_train_01_subset,
...          y_train_01_subset)
>>> plot_decision_regions(X=X_train_01_subset,
...                       y=y_train_01_subset,
...                       classifier=lrgd)
>>> plt.xlabel('petal length [standardized]')
>>> plt.ylabel('petal width [standardized]')
>>> plt.legend(loc='upper left')
>>> plt.tight_layout()
>>> plt.show() 

结果的决策区域图如下所示:

逻辑回归的梯度下降学习算法

通过微积分,我们可以证明,通过梯度下降在逻辑回归中进行的权重更新等同于我们在第2章训练简单的机器学习算法进行分类》中使用的Adaline方程。然而,请注意,下面的梯度下降学习规则的推导是为那些对逻辑回归梯度下降学习规则背后的数学概念感兴趣的读者准备的。对于本章余下内容的理解,并非必须。

我们从计算对数似然函数相对于j权重的偏导数开始:

在我们继续之前,先来计算一下sigmoid函数的偏导数:

现在,我们可以在我们的第一个方程中重新代入,得到如下结果:

记住,目标是找到使对数似然最大化的权重,从而对每个权重执行如下更新:

由于我们同时更新所有权重,因此我们可以将一般的更新规则写成如下形式:

我们将定义如下:

由于最大化对数似然等同于最小化之前定义的代价函数J,我们可以将梯度下降更新规则写成如下形式:

这与第2章训练简单的机器学习算法进行分类》中Adaline的梯度下降规则相等。

使用scikit-learn训练一个逻辑回归模型

我们在上一小节中进行了一些有用的编码和数学练习,帮助说明了Adaline与逻辑回归的概念性差异。现在,让我们学习如何使用scikit-learn中更优化的逻辑回归实现,该实现也原生支持多类设置。需要注意的是,在最近版本的scikit-learn中,用于多类分类的技术(多项式或OvR)会自动选择。在接下来的代码示例中,我们将使用sklearn.linear_model.LogisticRegression类和熟悉的fit方法,使用标准化的花卉训练数据集训练模型,同时设置multi_class='ovr'作为示例。作为读者的练习,您可能会想比较一下使用multi_class='multinomial'的结果。请注意,multinomial设置通常在实际应用中推荐用于互斥类,比如鸢尾花数据集中的类。这里,"互斥"意味着每个训练样本只能属于一个类(与多标签分类不同,后者一个训练样本可以属于多个类)。

现在,让我们看看代码示例:

>>> from sklearn.linear_model import LogisticRegression
>>> lr = LogisticRegression(C=100.0, random_state=1,
...                         solver='lbfgs', multi_class='ovr')
>>> lr.fit(X_train_std, y_train)
>>> plot_decision_regions(X_combined_std,
...                       y_combined,
...                       classifier=lr,
...                       test_idx=range(105, 150))
>>> plt.xlabel('petal length [standardized]')
>>> plt.ylabel('petal width [standardized]')
>>> plt.legend(loc='upper left')
>>> plt.tight_layout()
>>> plt.show() 

在训练数据上拟合模型后,我们绘制了决策区域、训练样本和测试样本,如下图所示:

请注意,解决优化问题的方法有很多种。对于最小化凸损失函数(如逻辑回归损失),建议使用比常规随机梯度下降(SGD)更先进的方法。事实上,scikit-learn实现了一整套此类优化算法,可以通过solver参数指定,分别是'newton-cg''lbfgs''liblinear''sag''saga'

虽然逻辑回归损失是凸的,但大多数优化算法都应该轻松收敛到全局最小损失。然而,某些算法相比其他算法有一定的优势。例如,在当前版本(v 0.21)中,scikit-learn默认使用'liblinear',它无法处理多项式损失,并且在多类分类中仅限于OvR方案。然而,在scikit-learn的未来版本(即v 0.22)中,默认的求解器将更改为'lbfgs',即有限内存Broyden-Fletcher-Goldfarb-ShannoBFGS)算法(https://en.wikipedia.org/wiki/Limited-memory_BFGS),并且在这方面更加灵活。为了采用这一新的默认选择,在本书中使用逻辑回归时我们将明确指定solver='lbfgs'

看一下我们用于训练LogisticRegression模型的前面的代码,你可能会想,“这个神秘的参数C到底是什么?”我们将在下一小节中讨论这个参数,并介绍过拟合和正则化的概念。不过,在我们继续讨论这些主题之前,让我们先完成关于类别归属概率的讨论。

可以使用predict_proba方法计算训练样本属于某一类的概率。例如,我们可以预测测试数据集中前三个样本的概率,如下所示:

>>> lr.predict_proba(X_test_std[:3, :]) 

这段代码返回如下数组:

array([[3.81527885e-09, 1.44792866e-01, 8.55207131e-01],
       [8.34020679e-01, 1.65979321e-01, 3.25737138e-13],
       [8.48831425e-01, 1.51168575e-01, 2.62277619e-14]]) 

第一行对应于第一个花朵的类别归属概率,第二行对应于第二个花朵的类别归属概率,以此类推。请注意,各列的和都为1,正如预期的那样。(你可以通过执行lr.predict_proba(X_test_std[:3, :]).sum(axis=1)来确认这一点。)

第一行中的最大值约为0.85,这意味着第一个样本属于类别三(Iris-virginica)的预测概率为85%。所以,正如你可能已经注意到的那样,我们可以通过识别每一行中最大的一列来获得预测的类别标签,例如,使用NumPy的argmax函数:

>>> lr.predict_proba(X_test_std[:3, :]).argmax(axis=1) 

返回的类别索引如下所示(它们对应于Iris-virginicaIris-setosaIris-setosa):

array([2, 0, 0]) 

在前面的代码示例中,我们计算了条件概率并通过使用NumPy的argmax函数手动将其转换为类别标签。在实际操作中,使用scikit-learn时,更便捷的获取类别标签的方式是直接调用predict方法:

>>> lr.predict(X_test_std[:3, :])
array([2, 0, 0]) 

最后,如果你想预测单一花卉示例的类别标签,需要注意:scikit-learn期望输入数据为二维数组;因此,我们必须先将单行切片转换成这种格式。将单行数据转换为二维数据数组的一种方式是使用NumPy的reshape方法添加一个新维度,如下所示:

>>> lr.predict(X_test_std[0, :].reshape(1, -1))
array([2]) 

通过正则化来应对过拟合

过拟合是机器学习中的一个常见问题,指的是模型在训练数据上表现良好,但在未见过的数据(测试数据)上泛化能力较差。如果模型出现过拟合,我们也可以说模型具有较高的方差,这可能是由参数过多造成的,从而导致模型过于复杂,不符合数据的真实结构。同样地,我们的模型也可能会遭遇欠拟合(高偏差),这意味着模型不足够复杂,无法很好地捕捉训练数据中的模式,因此在未见过的数据上表现不佳。

虽然到目前为止我们只遇到了用于分类的线性模型,但过拟合和欠拟合问题可以通过将线性决策边界与更复杂的非线性决策边界进行比较来最清楚地说明,如下图所示:

偏差-方差权衡

通常,研究人员使用“偏差”和“方差”或“偏差-方差权衡”这两个术语来描述模型的表现——也就是说,你可能会在讲座、书籍或文章中看到人们说某个模型具有“高方差”或“高偏差”。那么,这是什么意思呢?一般来说,我们可以说“高方差”与过拟合成正比,“高偏差”与欠拟合成正比。

在机器学习模型的背景下,方差衡量的是如果我们多次重新训练模型,例如,在不同的训练数据子集上进行训练时,模型预测某个特定示例的一致性(或变异性)。我们可以说,模型对训练数据中的随机性比较敏感。相对地,偏差衡量的是如果我们在不同的训练数据集上多次重建模型时,预测值与正确值之间的偏差;偏差是衡量由系统性误差引起的偏差,而这种误差与随机性无关。

如果你对“偏差”和“方差”术语的技术规格及推导感兴趣,我在我的讲义中有写过相关内容,详见:https://sebastianraschka.com/pdf/lecture-notes/stat479fs18/08_eval-intro_notes.pdf

寻找良好的偏差-方差平衡的一种方法是通过正则化来调整模型的复杂性。正则化是处理共线性(特征之间的高相关性)、过滤数据噪声并最终防止过拟合的非常有用的方法。

正则化背后的概念是引入额外的信息(偏置),以惩罚极端的参数(权重)值。最常见的正则化形式是所谓的 L2正则化(有时也称为L2收缩或权重衰减),可以写作如下:

这里, 就是所谓的 正则化参数

正则化和特征归一化

正则化是特征缩放(如标准化)重要性的另一个原因。为了使正则化正常工作,我们需要确保所有特征都在可比的尺度上。

逻辑回归的代价函数可以通过添加一个简单的正则化项来进行正则化,这将在模型训练过程中收缩权重:

通过正则化参数!,我们可以控制拟合训练数据的程度,同时保持权重较小。通过增加!的值,我们增加了正则化强度。

在scikit-learn中,LogisticRegression类中实现的参数C来自支持向量机中的一种约定,这将是下一节的主题。术语C与正则化参数!直接相关,它们是倒数。因此,减小逆正则化参数C的值意味着我们增加了正则化强度,我们可以通过绘制两个权重系数的L2正则化路径来可视化这一点:

>>> weights, params = [], []
>>> for c in np.arange(-5, 5):
...     lr = LogisticRegression(C=10.**c, random_state=1,
...                             solver='lbfgs', multi_class='ovr')
...     lr.fit(X_train_std, y_train)
...     weights.append(lr.coef_[1])
...     params.append(10.**c)
>>> weights = np.array(weights)
>>> plt.plot(params, weights[:, 0],
...          label='petal length')
>>> plt.plot(params, weights[:, 1], linestyle='--',
...          label='petal width')
>>> plt.ylabel('weight coefficient')
>>> plt.xlabel('C')
>>> plt.legend(loc='upper left')
>>> plt.xscale('log')
>>> plt.show() 

通过执行前面的代码,我们拟合了10个逻辑回归模型,每个模型使用不同的逆正则化参数C值。为了方便说明,我们只收集了类别1(在数据集中是第二类:Iris-versicolor)与所有分类器的权重系数——请记住,我们使用的是一对多(OvR)技术进行多分类。

从结果图中可以看出,当我们减小参数C时,权重系数会收缩,也就是说,当我们增加正则化强度时:

逻辑回归的额外资源

由于对各个分类算法的深入讨论超出了本书的范围,Logistic Regression: From Introductory to Advanced Concepts and ApplicationsDr. Scott MenardSage Publications2009,推荐给那些想要深入了解逻辑回归的读者。

支持向量机的最大间隔分类

另一个强大且广泛使用的学习算法是支持向量机SVM),它可以看作是感知机的扩展。通过使用感知机算法,我们最小化了误分类错误。然而,在SVM中,我们的优化目标是最大化间隔。间隔定义为分隔超平面(决策边界)与离该超平面最近的训练示例之间的距离,这些训练示例被称为支持向量。这在下图中得到了说明:

最大间隔直觉

设置具有大间隔的决策边界的原理是,它们通常具有较低的泛化误差,而具有小间隔的模型更容易出现过拟合。为了更好地理解间隔最大化的概念,我们来看一下那些与决策边界平行的正负超平面,它们可以表示为以下形式:

(1)(2)

如果我们将这两个线性方程(1)和(2)相互减去,我们得到:

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

因此,我们得到如下方程:

前面方程的左侧可以解释为正负超平面之间的距离,这就是我们希望最大化的所谓间隔

现在,SVM的目标函数变成了通过最大化!来实现间隔最大化,同时约束条件是正确分类所有示例,可以写作:

这里,N是我们数据集中示例的数量。

这两个方程基本上表示,所有负类示例应位于负超平面的一侧,而所有正类示例应位于正超平面的另一侧,这也可以更紧凑地写成如下形式:

然而,实际上,更容易最小化倒数项!,它可以通过二次规划求解。然而,关于二次规划的详细讨论超出了本书的范围。您可以在The Nature of Statistical Learning TheorySpringer Science+Business MediaVladimir Vapnik,2000年)中深入了解SVM,或者阅读Chris J.C. Burges在《A Tutorial on Support Vector Machines for Pattern Recognition》Data Mining and Knowledge Discovery2(2):121-167,1998年)中的精彩讲解。

使用松弛变量处理非线性可分情况

虽然我们不想深入探讨最大间隔分类背后更复杂的数学概念,但简要提一下由 Vladimir Vapnik 在 1995 年引入的松弛变量 ,它促成了所谓的 软间隔分类。引入松弛变量的动机是,线性约束需要被放宽,以便对非线性可分数据进行优化,从而在误分类的情况下仍能让优化过程收敛,并进行适当的成本惩罚。

正值松弛变量简单地被添加到线性约束中:

这里,N 是我们数据集中样本的数量。所以,新的目标函数(在约束条件下最小化)变成了:

通过变量 C,我们可以控制误分类的惩罚。较大的 C 值对应较大的误差惩罚,而选择较小的 C 值时,我们对误分类的错误惩罚要求就会降低。然后我们可以利用 C 参数来控制间隔的宽度,从而调节偏差-方差的权衡,具体如下图所示:

这个概念与正则化相关,我们在上一节中讨论过正则化回归,其中减小 C 的值可以增加模型的偏差并降低方差。

现在我们已经了解了线性 SVM 背后的基本概念,接下来让我们训练一个 SVM 模型来分类 Iris 数据集中的不同花卉:

>>> from sklearn.svm import SVC
>>> svm = SVC(kernel='linear', C=1.0, random_state=1)
>>> svm.fit(X_train_std, y_train)
>>> plot_decision_regions(X_combined_std,
...                       y_combined,
...                       classifier=svm,
...                       test_idx=range(105, 150))
>>> plt.xlabel('petal length [standardized]')
>>> plt.ylabel('petal width [standardized]')
>>> plt.legend(loc='upper left')
>>> plt.tight_layout()
>>> plt.show() 

在训练分类器时,使用 Iris 数据集执行上述代码示例后,SVM 的三个决策区域如以下图所示:

逻辑回归与支持向量机(SVM)

在实际的分类任务中,线性逻辑回归和线性 SVM 通常会产生非常相似的结果。逻辑回归试图最大化训练数据的条件似然,这使得它比 SVM 更容易受到异常值的影响,而 SVM 则更关注距离决策边界最近的点(支持向量)。另一方面,逻辑回归的优势在于它是一个更简单的模型,且实现起来更容易。此外,逻辑回归模型可以很容易地更新,这对于处理流数据时尤其具有吸引力。

scikit-learn 中的替代实现

scikit-learn 库中的 LogisticRegression 类,我们在前面的章节中使用过,利用了 LIBLINEAR 库,这是一款由台湾大学开发的高度优化的 C/C++ 库 (http://www.csie.ntu.edu.tw/~cjlin/liblinear/)。

同样,我们用来训练 SVM 的 SVC 类利用了 LIBSVM,这是一个专门为 SVM 提供支持的 C/C++ 库(http://www.csie.ntu.edu.tw/~cjlin/libsvm/)。

使用 LIBLINEAR 和 LIBSVM 的优势在于,它们能极其快速地训练大量线性分类器。而有时我们的数据集太大,无法完全加载到计算机内存中。因此,scikit-learn 还通过 SGDClassifier 类提供了替代实现,该类也支持通过 partial_fit 方法进行在线学习。SGDClassifier 类的概念类似于我们在第二章《训练简单的机器学习分类算法》中为 Adaline 实现的随机梯度算法。我们可以使用默认参数初始化感知机、逻辑回归和 SVM 的 SGD 版本,如下所示:

>>> from sklearn.linear_model import SGDClassifier
>>> ppn = SGDClassifier(loss='perceptron')
>>> lr = SGDClassifier(loss='log')
>>> svm = SGDClassifier(loss='hinge') 

使用核 SVM 解决非线性问题

SVM 在机器学习实践者中广受欢迎的另一个原因是它们可以很容易地核化,用于解决非线性分类问题。在我们讨论所谓的核 SVM这一最常见的 SVM 变体的主要概念之前,让我们首先创建一个合成数据集,看看这种非线性分类问题是什么样的。

针对线性不可分数据的核方法

使用以下代码,我们将创建一个简单的数据集,该数据集通过 NumPy 的 logical_or 函数生成,形态类似于 XOR 门,其中 100 个示例被分配标签 1,另 100 个示例被分配标签 -1

>>> import matplotlib.pyplot as plt
>>> import numpy as np
>>> np.random.seed(1)
>>> X_xor = np.random.randn(200, 2)
>>> y_xor = np.logical_xor(X_xor[:, 0] > 0,
...                        X_xor[:, 1] > 0)
>>> y_xor = np.where(y_xor, 1, -1)
>>> plt.scatter(X_xor[y_xor == 1, 0],
...             X_xor[y_xor == 1, 1],
...             c='b', marker='x',
...             label='1')
>>> plt.scatter(X_xor[y_xor == -1, 0],
...             X_xor[y_xor == -1, 1],
...             c='r',
...             marker='s',
...             label='-1')
>>> plt.xlim([-3, 3])
>>> plt.ylim([-3, 3])
>>> plt.legend(loc='best')
>>> plt.tight_layout()
>>> plt.show() 

执行代码后,我们将得到一个带有随机噪声的 XOR 数据集,如下图所示:

显然,我们无法使用线性超平面作为决策边界很好地分离正负类别的示例,使用我们在前面章节中讨论的线性逻辑回归或线性支持向量机(SVM)模型。

核方法处理这类线性不可分数据的基本思想是,利用原始特征的非线性组合,通过映射函数将数据投影到更高维空间,,使得数据变得线性可分。如以下图所示,我们可以将二维数据集转换为新的三维特征空间,在这个空间中,类别可以通过以下投影分离:

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

使用核技巧在高维空间中寻找分隔超平面

为了解决一个非线性问题,我们可以通过映射函数将训练数据转换到更高维的特征空间,并训练一个线性SVM模型,在这个新的特征空间中对数据进行分类。然后,我们可以使用相同的映射函数将新的、未见过的数据转换,并使用线性SVM模型对其进行分类。

然而,这种映射方法的一个问题是,构建新特征的计算开销非常大,特别是当我们处理高维数据时。这就是所谓的核技巧发挥作用的地方。

尽管我们没有深入讨论如何解决二次规划任务来训练SVM,但在实际操作中,我们只需要将点积替换为。为了节省显式计算两点之间点积的昂贵步骤,我们定义了一个所谓的核函数

最广泛使用的核函数之一是径向基函数RBF)核,也可以简单地称为高斯核

这通常简化为:

这里,是一个需要优化的自由参数。

粗略来说,“核函数”这一术语可以解释为一对示例之间的相似性函数。负号将距离度量反转为相似性分数,而由于指数项的存在,最终的相似性分数将落在1(完全相似的示例)和0(非常不相似的示例)之间。

现在我们已经了解了核技巧背后的大致原理,接下来让我们看看能否训练一个能够画出非线性决策边界的核SVM,从而很好地分离XOR数据。这里,我们只需使用之前导入的scikit-learn中的SVC类,并将kernel='linear'参数替换为kernel='rbf'

>>> svm = SVC(kernel='rbf', random_state=1, gamma=0.10, C=10.0)
>>> svm.fit(X_xor, y_xor)
>>> plot_decision_regions(X_xor, y_xor, classifier=svm)
>>> plt.legend(loc='upper left')
>>> plt.tight_layout()
>>> plt.show() 

正如我们在结果图中看到的,核SVM相对较好地分离了XOR数据:

参数,我们将其设置为gamma=0.1,可以理解为高斯球的截断参数。如果我们增加的值,我们会增加训练样本的影响力或覆盖范围,从而导致更紧密且更崎岖的决策边界。为了更好地理解,我们来对鸢尾花数据集应用RBF核SVM:

>>> svm = SVC(kernel='rbf', random_state=1, gamma=0.2, C=1.0)
>>> svm.fit(X_train_std, y_train)
>>> plot_decision_regions(X_combined_std,
...                       y_combined, classifier=svm,
...                       test_idx=range(105,150))
>>> plt.xlabel('petal length [standardized]')
>>> plt.ylabel('petal width [standardized]')
>>> plt.legend(loc='upper left')
>>> plt.tight_layout()
>>> plt.show() 

由于我们选择了相对较小的值,RBF核SVM模型的决策边界将相对平缓,如下图所示:

现在,让我们增加的值,并观察其对决策边界的影响:

>>> svm = SVC(kernel='rbf', random_state=1, gamma=100.0, C=1.0)
>>> svm.fit(X_train_std, y_train)
>>> plot_decision_regions(X_combined_std,
...                       y_combined, classifier=svm,
...                       test_idx=range(105,150))
>>> plt.xlabel('petal length [standardized]')
>>> plt.ylabel('petal width [standardized]')
>>> plt.legend(loc='upper left')
>>> plt.tight_layout()
>>> plt.show() 

在生成的图中,我们现在可以看到,在使用相对较大值的条件下,类01周围的决策边界要紧密得多:

尽管模型非常适合训练数据集,这样的分类器在未见数据上可能会有很高的泛化误差。这说明当算法对训练数据集中的波动过于敏感时,参数也在控制过拟合或方差方面起着重要作用。

决策树学习

决策树分类器是具有吸引力的模型,如果我们关心其可解释性的话。正如名称“决策树”所示,我们可以将这个模型视为通过询问一系列问题来将我们的数据进行分解的模型。

让我们考虑以下示例,在这个示例中,我们使用决策树来决定特定日期的活动:

基于我们训练数据集中的特征,决策树模型学习一系列问题来推断示例的类标签。尽管前面的图示了基于分类变量的决策树概念,但是如果我们的特征是实数,比如在鸢尾花数据集中,同样的概念也适用。例如,我们可以简单地沿着花萼宽度特征轴定义一个截断值,并提出一个二元问题:“花萼宽度是否≥ 2.8 厘米?”

使用决策算法,我们从树根开始,并在导致最大信息增益IG)的特征上分割数据,这将在以下部分详细解释。在迭代过程中,我们可以在每个子节点上重复这个分割过程,直到叶子节点变得纯净。这意味着每个节点上的训练示例都属于同一类。在实践中,这可能导致一个非常深的树,具有许多节点,这很容易导致过拟合。因此,我们通常希望通过设置树的最大深度限制来修剪树。

最大化信息增益 - 获取最大的回报

为了在最具信息特征处分割节点,我们需要定义一个通过树学习算法优化的目标函数。在这里,我们的目标函数是在每个分割点最大化信息增益(IG),定义如下:

这里,f 是执行分割的特征; 是父节点和第 j 个子节点的数据集;I 是我们的不纯度度量; 是父节点的训练示例总数; 是第 j 个子节点的示例数。正如我们所看到的,信息增益简单地是父节点的不纯度与子节点不纯度之和的差异——子节点的不纯度越低,信息增益越大。然而,为了简化和减少组合搜索空间,大多数库(包括 scikit-learn)实现了二叉决策树。这意味着每个父节点被分割成两个子节点,

在二叉决策树中常用的三个不纯度度量或分割标准是基尼不纯度)、)和分类错误率)。让我们从所有非空类别()的熵定义开始:

这里, 是属于特定节点 t 的类别 i 的示例比例。因此,如果一个节点上的所有示例都属于同一类,则熵为 0,如果我们具有均匀的类分布,则熵最大。例如,在二元类设置中,如果 ,则熵为 0。如果类别均匀分布为 ,则熵为 1。因此,我们可以说熵标准试图在树中最大化互信息。

基尼不纯度可以理解为最小化误分类的概率:

类似于熵,基尼不纯度在类别完全混合时最大,例如,在二元类设置中(c = 2):

然而,在实践中,基尼不纯度和熵通常产生非常相似的结果,通常不值得花费太多时间评估使用不同不纯度标准的树,而不是尝试不同的修剪截止值。

另一个不纯度度量是分类错误率:

这是修剪决策树的一个有用标准,但不建议用于生长决策树,因为它对节点类别概率的变化不太敏感。我们可以通过查看以下图示的两种可能的分割场景来说明这一点:

我们从一个数据集开始,,这是父节点处的 40 个类别 1 的示例和 40 个类别 2 的示例,我们将其拆分为两个数据集,。使用分类误差作为分割标准时,信息增益在场景 A 和场景 B 中是相同的():

然而,基尼不纯度会偏向场景 B()中的分割,而不是场景 A(),因为 B 的不纯度确实更低。

同样,熵准则也会偏向场景 B()而非场景 A():

为了更直观地比较我们之前讨论的三种不同的不纯度准则,让我们绘制类别 1 的概率范围 [0, 1] 对应的不纯度指数。请注意,我们还将添加一个缩放版本的熵(熵 / 2),以观察基尼不纯度是熵和分类误差之间的中介度量。代码如下:

>>> import matplotlib.pyplot as plt
>>> import numpy as np
>>> def gini(p):
...     return (p)*(1 - (p)) + (1 - p)*(1 - (1-p))
>>> def entropy(p):
...     return - p*np.log2(p) - (1 - p)*np.log2((1 - p))
>>> def error(p):
...     return 1 - np.max([p, 1 - p])
>>> x = np.arange(0.0, 1.0, 0.01)
>>> ent = [entropy(p) if p != 0 else None for p in x]
>>> sc_ent = [e*0.5 if e else None for e in ent]
>>> err = [error(i) for i in x]
>>> fig = plt.figure()
>>> ax = plt.subplot(111)
>>> for i, lab, ls, c, in zip([ent, sc_ent, gini(x), err],
...                           ['Entropy', 'Entropy (scaled)',
...                            'Gini impurity',
...                            'Misclassification error'],
...                           ['-', '-', '--', '-.'],
...                           ['black', 'lightgray',
...                            'red', 'green', 'cyan']):
...     line = ax.plot(x, i, label=lab,
...                   linestyle=ls, lw=2, color=c)
>>> ax.legend(loc='upper center', bbox_to_anchor=(0.5, 1.15),
...           ncol=5, fancybox=True, shadow=False)
>>> ax.axhline(y=0.5, linewidth=1, color='k', linestyle='--')
>>> ax.axhline(y=1.0, linewidth=1, color='k', linestyle='--')
>>> plt.ylim([0, 1.1])
>>> plt.xlabel('p(i=1)')
>>> plt.ylabel('impurity index') 

前述代码示例生成的图如下:

构建决策树

决策树可以通过将特征空间划分为矩形来构建复杂的决策边界。然而,我们必须小心,因为决策树越深,决策边界就越复杂,这很容易导致过拟合。使用 scikit-learn,我们将训练一个最大深度为 4 的决策树,使用基尼不纯度作为不纯度标准。虽然为了可视化目的可能需要特征缩放,但请注意,特征缩放并不是决策树算法的要求。代码如下:

>>> from sklearn.tree import DecisionTreeClassifier
>>> tree_model = DecisionTreeClassifier(criterion='gini',
...                                     max_depth=4,
...                                     random_state=1)
>>> tree_model.fit(X_train, y_train)
>>> X_combined = np.vstack((X_train, X_test))
>>> y_combined = np.hstack((y_train, y_test))
>>> plot_decision_regions(X_combined,
...                       y_combined,
...                       classifier=tree_model,
...                       test_idx=range(105, 150))
>>> plt.xlabel('petal length [cm]')
>>> plt.ylabel('petal width [cm]')
>>> plt.legend(loc='upper left')
>>> plt.tight_layout()
>>> plt.show() 

执行代码示例后,我们得到了决策树的典型轴平行决策边界:

scikit-learn 的一个很好的功能是,它允许我们在训练后通过以下代码直观地可视化决策树模型:

>>> from sklearn import tree
>>> tree.plot_tree(tree_model)
>>> plt.show() 

然而,通过使用 Graphviz 程序作为绘制 scikit-learn 决策树的后端,可以获得更漂亮的可视化效果。这个程序可以从http://www.graphviz.org免费下载,并且支持 Linux、Windows 和 macOS。除了 Graphviz,我们还将使用一个名为 PyDotPlus 的 Python 库,它具有类似 Graphviz 的功能,并允许我们将 .dot 数据文件转换为决策树图像文件。在你安装了 Graphviz(按照http://www.graphviz.org/download上的说明进行安装)后,你可以通过 pip 安装 PyDotPlus,例如在命令行终端中执行以下命令:

> pip3 install pydotplus 

安装 PyDotPlus 先决条件

请注意,在某些系统上,你可能需要手动安装 PyDotPlus 的先决条件,可以通过执行以下命令来安装:

pip3 install graphviz
pip3 install pyparsing 

以下代码将在本地目录中创建我们决策树的 PNG 格式图像:

>>> from pydotplus import graph_from_dot_data
>>> from sklearn.tree import export_graphviz
>>> dot_data = export_graphviz(tree_model,
...                            filled=True,
...                            rounded=True,
...                            class_names=['Setosa',
...                                         'Versicolor',
...                                         'Virginica'],
...                            feature_names=['petal length',
...                                           'petal width'],
...                            out_file=None)
>>> graph = graph_from_dot_data(dot_data)
>>> graph.write_png('tree.png') 

通过使用 out_file=None 设置,我们直接将 DOT 数据分配给 dot_data 变量,而不是将中间的 tree.dot 文件写入磁盘。filledroundedclass_namesfeature_names 的参数是可选的,但通过添加颜色、圆角框边缘、在每个节点显示主要类标签的名称以及在每个分裂标准中显示特征名称,使得最终的图像文件在视觉上更具吸引力。这些设置产生了以下的决策树图像:

看着决策树图,我们现在可以清晰地追踪决策树从我们的训练数据集确定的分裂。我们从根节点的 105 个示例开始,并使用花瓣宽度 ≤ 0.75 cm 将它们分成了两个子节点,分别包含 35 个和 70 个示例。在第一次分裂后,我们可以看到左子节点已经纯净,且仅包含 Iris-setosa 类的示例(基尼不纯度 = 0)。接下来的分裂则用于将 Iris-versicolorIris-virginica 类的示例分开。

从这棵树以及树的决策区域图中,我们可以看到决策树在分离花卉类别方面做得非常好。不幸的是,当前的 scikit-learn 并没有实现手动后剪枝决策树的功能。不过,我们可以回到之前的代码示例,将决策树的max_depth改为3,并将其与当前模型进行比较,但我们将这个留给有兴趣的读者作为练习。

通过随机森林结合多个决策树

由于集成方法在过去十年里因其出色的分类性能和对过拟合的鲁棒性,已经在机器学习应用中获得了巨大的普及。虽然我们将在第7章《集成学习中不同模型的结合》中详细介绍不同的集成方法,包括袋装法提升法,但在此我们先讨论基于决策树的随机森林算法,它因良好的可扩展性和易用性而著称。随机森林可以看作是决策树的集成。随机森林的基本思想是通过平均多个(深度)决策树的结果来降低每棵树的高方差,从而构建出一个更为稳健的模型,具有更好的泛化性能,并且更不容易过拟合。随机森林算法可以用四个简单步骤来总结:

  1. 从训练数据集中随机抽取一个大小为n自助法样本(带放回地随机选择n个样本)。

  2. 从自助法样本中生成一棵决策树。在每个节点:

    1. 随机选择d个特征进行无放回抽样。

    2. 使用根据目标函数提供最佳切分的特征来分割节点,例如,最大化信息增益。

  3. 重复执行步骤 1-2,共k次。

  4. 通过每棵树的预测结果进行汇总,采用多数投票的方式来确定类别标签。多数投票将在第7章《集成学习中不同模型的结合》中进行更详细的讨论。

在训练单独的决策树时,我们应注意第2步的一个小修改:不是评估所有特征来确定每个节点的最佳切分,而是只考虑这些特征的一个随机子集。

有放回和无放回抽样

如果你不熟悉“有放回”和“无放回”抽样的术语,让我们通过一个简单的思维实验来讲解。假设我们正在玩一个彩票游戏,在这个游戏中,我们从一个抽签盒中随机抽取数字。我们从一个包含五个唯一数字的抽签盒开始,数字分别是 0、1、2、3 和 4,并且每次抽取一个数字。在第一轮中,从抽签盒中抽取特定数字的概率为 1/5。现在,在无放回抽样中,我们每次抽取后不会将数字放回抽签盒中。因此,在下一轮中,从剩余数字集合中抽取特定数字的概率会受到前一轮的影响。例如,如果剩余的数字集合是 0、1、2 和 4,那么下一轮抽取数字 0 的概率将变为 1/4。

然而,在有放回的随机抽样中,我们每次抽取后都会将数字放回抽签盒中,因此每次抽取特定数字的概率不会发生变化;同一个数字可能会被多次抽取。换句话说,在有放回抽样中,样本(数字)是独立的,并且它们的协方差为零。例如,五轮抽取随机数字的结果可能如下所示:

  • 无放回的随机采样:2, 1, 3, 4, 0

  • 有放回的随机采样:1, 3, 3, 4, 1

尽管随机森林不像决策树那样提供相同级别的可解释性,但随机森林的一个大优势是我们不必太过担心选择合适的超参数值。通常,我们不需要修剪随机森林,因为集成模型对单个决策树的噪声相当鲁棒。在实践中,我们真正需要关心的唯一参数是我们为随机森林选择的树的数量,k(步骤3)。通常,树的数量越大,随机森林分类器的性能越好,但代价是增加了计算成本。

尽管在实际操作中不太常见,但可以优化的随机森林分类器的其他超参数——使用我们将在第6章模型评估与超参数调优的最佳实践学习》中讨论的技巧——分别是自助采样的大小,n(步骤1),以及为每个分裂随机选择的特征数量,d(步骤2.a)。通过自助采样的大小,n,我们可以控制随机森林的偏差-方差权衡。

减小自助采样的大小会增加个体树之间的多样性,因为某个特定训练样本被包含在自助采样中的概率较低。因此,缩小自助采样的大小可能会增加随机森林的随机性,并有助于减少过拟合的影响。然而,更小的自助采样通常会导致随机森林的整体性能较低,并且训练和测试性能之间差距较小,但测试性能整体较差。相反,增大自助采样的大小可能会增加过拟合的程度。由于自助采样,从而个体决策树变得彼此更相似,它们学会更紧密地拟合原始训练数据集。

在大多数实现中,包括scikit-learn中的RandomForestClassifier实现,自助采样的大小被选择为等于原始训练数据集中训练样本的数量,这通常提供一个良好的偏差-方差权衡。对于每个分裂时的特征数量,d,我们希望选择一个比训练数据集中总特征数小的值。在scikit-learn和其他实现中使用的合理默认值是,其中m是训练数据集中的特征数量。

方便的是,我们不需要自己从单独的决策树构建随机森林分类器,因为scikit-learn中已经有现成的实现可以使用:

>>> from sklearn.ensemble import RandomForestClassifier
>>> forest = RandomForestClassifier(criterion='gini',
...                                 n_estimators=25,
...                                 random_state=1,
...                                 n_jobs=2)
>>> forest.fit(X_train, y_train)
>>> plot_decision_regions(X_combined, y_combined,
...                       classifier=forest, test_idx=range(105,150))
>>> plt.xlabel('petal length [cm]')
>>> plt.ylabel('petal width [cm]')
>>> plt.legend(loc='upper left')
>>> plt.tight_layout()
>>> plt.show() 

执行上述代码后,我们应该会看到由随机森林中树的集成所形成的决策区域,如下图所示:

使用上述代码,我们通过n_estimators参数从25棵决策树训练了一个随机森林,并使用Gini不纯度作为标准来划分节点。尽管我们从一个非常小的训练数据集生长了一个非常小的随机森林,但为了演示的目的,我们使用了n_jobs参数,这允许我们使用计算机的多个核心(这里是两个核心)来并行化模型训练。

K近邻 – 一种惰性学习算法

本章中我们想讨论的最后一个监督学习算法是k近邻KNN)分类器,它尤其有趣,因为它在本质上与我们迄今为止讨论的学习算法有所不同。

KNN是惰性学习者的典型例子。它之所以被称为“惰性”,并不是因为它显得简单,而是因为它并不从训练数据中学习一个区分性函数,而是记住了训练数据集。

参数模型与非参数模型

机器学习算法可以分为参数模型非参数模型。使用参数模型时,我们通过训练数据集来估计参数,从而学习一个能够分类新数据点的函数,且不再需要原始的训练数据集。参数模型的典型例子有感知机、逻辑回归和线性支持向量机(SVM)。与之相对,非参数模型无法通过一组固定的参数来描述,且参数的数量会随着训练数据的增加而增长。到目前为止,我们所见的两个非参数模型的例子是决策树分类器/随机森林和核SVM。

KNN属于非参数模型中的一个子类,称为基于实例的学习。基于实例的学习模型的特点是记住训练数据集,而惰性学习则是基于实例学习的一个特殊情况,它在学习过程中没有(零)成本。

KNN算法本身相对简单,可以通过以下步骤总结:

  1. 选择* k *的值和一个距离度量。

  2. 找到我们想要分类的数据记录的* k *个最近邻。

  3. 通过多数投票分配类别标签。

以下图示说明了如何通过多数投票的方式,根据其五个最近邻的类别,将一个新的数据点(?)分配到三角形类别标签。

基于所选的距离度量,KNN算法会找到训练数据集中与我们要分类的点最接近(最相似)的* k 个样本。然后,数据点的类别标签是通过它的 k *个最近邻的多数投票来确定的。

这种基于记忆的方法的主要优点是,当我们收集到新的训练数据时,分类器能够立即适应。然而,缺点是,在最坏的情况下,分类新示例的计算复杂度随着训练数据集中示例数量的增加而线性增长——除非数据集的维度(特征)非常少,且算法已使用高效的数据结构如k-d树(An Algorithm for Finding Best Matches in Logarithmic Expected TimeJ. H. FriedmanJ. L. Bentley,和R.A. FinkelACM transactions on mathematical software (TOMS),3(3): 209–2261977)来实现。此外,由于不涉及训练步骤,我们无法丢弃训练示例。因此,如果我们处理的是大数据集,存储空间可能会成为一个挑战。

通过执行以下代码,我们将使用欧几里得距离度量在scikit-learn中实现KNN模型:

>>> from sklearn.neighbors import KNeighborsClassifier
>>> knn = KNeighborsClassifier(n_neighbors=5, p=2,
...                            metric='minkowski')
>>> knn.fit(X_train_std, y_train)
>>> plot_decision_regions(X_combined_std, y_combined,
...                       classifier=knn, test_idx=range(105,150))
>>> plt.xlabel('petal length [standardized]')
>>> plt.ylabel('petal width [standardized]')
>>> plt.legend(loc='upper left')
>>> plt.tight_layout()
>>> plt.show() 

在这个数据集的KNN模型中,我们通过指定五个邻居,获得了一个相对平滑的决策边界,如下图所示:

解决平局

在平局情况下,scikit-learn实现的KNN算法会优先选择距离数据记录较近的邻居进行分类。如果邻居之间的距离相似,算法将选择训练数据集中出现的第一个类别标签。

选择正确的 k值对于找到过拟合和欠拟合之间的良好平衡至关重要。我们还必须确保选择适合数据集特征的距离度量。通常,对于实值示例(例如,我们的Iris数据集中的花卉,它们的特征是以厘米为单位测量的),会使用简单的欧几里得距离度量。然而,如果我们使用欧几里得距离度量,那么标准化数据也非常重要,这样每个特征对距离的贡献才能平等。我们在之前的代码中使用的minkowski距离就是欧几里得和曼哈顿距离的一个推广,可以写作如下:

如果我们将参数p=2,则变为欧几里得距离,p=1时则是曼哈顿距离。scikit-learn中还有许多其他的距离度量,可以提供给metric参数使用。它们的详细列表可以在http://scikit-learn.org/stable/modules/generated/sklearn.neighbors.DistanceMetric.html查看。

维度灾难

值得一提的是,KNN非常容易受到过拟合的影响,这是由于维度灾难所致。维度灾难描述的是在固定大小的训练数据集维度数增加时,特征空间变得越来越稀疏的现象。我们甚至可以认为在高维空间中,最接近的邻居也会因距离过远而无法提供好的估计。

我们在关于逻辑回归的章节中讨论了正则化的概念,作为避免过拟合的一种方法。然而,在正则化无法应用的模型中,如决策树和KNN,我们可以使用特征选择和降维技术来帮助我们避免维度灾难。这个内容将在下一章中详细讨论。

总结

在本章中,你学习了许多不同的机器学习算法,这些算法用于解决线性和非线性问题。你已经看到,决策树特别适合我们关注可解释性的情况。逻辑回归不仅是通过SGD进行在线学习的有用模型,而且还允许我们预测某个特定事件的概率。

尽管SVM是强大的线性模型,可以通过核技巧扩展到非线性问题,但它们有许多参数需要调优才能做出良好的预测。相比之下,集成方法如随机森林不需要太多的参数调优,且不像决策树那样容易过拟合,这使得它们在许多实际问题领域中成为有吸引力的模型。KNN分类器提供了一种通过惰性学习进行分类的替代方法,允许我们在没有任何模型训练的情况下进行预测,但预测步骤更具计算开销。

然而,比选择合适的学习算法更重要的是我们训练数据集中的可用数据。没有具有信息性和区分性的特征,任何算法都无法做出良好的预测。

在下一章中,我们将讨论数据预处理、特征选择和降维的相关重要话题,这意味着我们将需要构建强大的机器学习模型。稍后,在第6章模型评估与超参数调优的最佳实践中,我们将了解如何评估和比较模型的性能,并学习一些有用的技巧来微调不同的算法。

第四章:构建良好的训练数据集—数据预处理

数据的质量以及它所包含的有用信息的量是决定机器学习算法学习效果的关键因素。因此,在将数据集输入学习算法之前,确保我们检查并预处理数据集是至关重要的。在本章中,我们将讨论一些基本的数据预处理技术,它们将帮助我们构建良好的机器学习模型。

本章我们将讨论的主题如下:

  • 从数据集中删除和填补缺失值

  • 将分类数据整理成适合机器学习算法的格式

  • 为模型构建选择相关特征

处理缺失数据

在实际应用中,我们的训练样本因各种原因可能缺少一个或多个值,这并不罕见。数据收集过程中可能发生了错误,某些测量可能不适用,或者某些字段可能在调查中被留空。例如,我们通常会在数据表中看到缺失值以空白的形式呈现,或以占位符字符串如NaN(表示“不是数字”)或NULL(在关系数据库中常用的未知值指示符)出现。不幸的是,大多数计算工具无法处理这些缺失值,或者如果我们简单地忽略它们,可能会产生不可预测的结果。因此,在进行进一步分析之前,务必处理这些缺失值。

在本节中,我们将通过几种实际技术来处理缺失值,这些技术包括从数据集中删除条目或从其他训练样本和特征中填补缺失值。

识别表格数据中的缺失值

在讨论处理缺失值的几种技术之前,让我们从一个逗号分隔值CSV)文件中创建一个简单的示例数据框,以便更好地理解这个问题:

>>> import pandas as pd
>>> from io import StringIO
>>> csv_data = \
... '''A,B,C,D
... 1.0,2.0,3.0,4.0
... 5.0,6.0,,8.0
... 10.0,11.0,12.0,'''
>>> # If you are using Python 2.7, you need
>>> # to convert the string to unicode:
>>> # csv_data = unicode(csv_data)
>>> df = pd.read_csv(StringIO(csv_data))
>>> df
        A        B        C        D
0     1.0      2.0      3.0      4.0
1     5.0      6.0      NaN      8.0
2    10.0     11.0     12.0      NaN 

使用前面的代码,我们通过read_csv函数将CSV格式的数据读入pandas的DataFrame,并注意到那两个缺失的单元格被NaN替换了。前面代码示例中的StringIO函数仅用于演示目的。它让我们能够像读取硬盘上的常规CSV文件一样,将赋值给csv_data的字符串读取到pandas的DataFrame中。

对于一个较大的DataFrame,手动查找缺失值可能非常繁琐;在这种情况下,我们可以使用isnull方法返回一个布尔值的DataFrame,指示某个单元格是否包含数值(False)或者数据是否缺失(True)。接着,使用sum方法,我们可以返回每列缺失值的数量,如下所示:

>>> df.isnull().sum()
A      0
B      0
C      1
D      1
dtype: int64 

通过这种方式,我们可以统计每列缺失值的数量;在接下来的小节中,我们将探讨不同的策略来处理这些缺失数据。

使用pandas的DataFrame便捷地处理数据

尽管 scikit-learn 最初是为了仅处理 NumPy 数组而开发的,但有时使用 pandas 的 DataFrame 进行数据预处理会更为方便。如今,大多数 scikit-learn 函数支持将 DataFrame 对象作为输入,但由于 NumPy 数组的处理在 scikit-learn API 中更为成熟,因此建议在可能的情况下使用 NumPy 数组。请注意,在将 DataFrame 输入到 scikit-learn 估算器之前,您始终可以通过 values 属性访问 DataFrame 的底层 NumPy 数组:

>>> df.values
array([[  1.,   2.,   3.,   4.],
       [  5.,   6.,  nan,   8.],
       [ 10.,  11.,  12.,  nan]]) 

删除包含缺失值的训练示例或特征

处理缺失数据最简单的方法之一就是完全删除相应的特征(列)或训练示例(行);可以通过 dropna 方法轻松删除包含缺失值的行:

>>> df.dropna(axis=0)
      A    B    C    D
0   1.0  2.0  3.0  4.0 

同样,我们也可以通过将 axis 参数设置为 1,删除任何行中至少包含一个 NaN 的列:

>>> df.dropna(axis=1)
      A      B
0   1.0    2.0
1   5.0    6.0
2  10.0   11.0 

dropna 方法支持多个额外的参数,这些参数可能会很有用:

# only drop rows where all columns are NaN
# (returns the whole array here since we don't
# have a row with all values NaN)
>>> df.dropna(how='all')
      A      B      C      D
0   1.0    2.0    3.0    4.0
1   5.0    6.0    NaN    8.0
2  10.0   11.0   12.0    NaN
# drop rows that have fewer than 4 real values
>>> df.dropna(thresh=4)
      A      B      C      D
0   1.0    2.0    3.0    4.0
# only drop rows where NaN appear in specific columns (here: 'C')
>>> df.dropna(subset=['C'])
      A      B      C      D
0   1.0    2.0    3.0    4.0
2  10.0   11.0   12.0    NaN 

尽管删除缺失数据似乎是一个方便的方法,但它也有一定的缺点;例如,我们可能会删除过多的样本,从而使得可靠的分析变得不可能。或者,如果我们删除了过多的特征列,就可能会失去分类器区分不同类别所需的重要信息。在下一节中,我们将介绍处理缺失值时最常用的替代方法之一:插值技术。

填充缺失值

通常,删除训练示例或整个特征列是不可行的,因为我们可能会丧失太多宝贵的数据。在这种情况下,我们可以使用不同的插值技术来根据数据集中其他训练示例估算缺失值。最常见的插值技术之一是 均值填充,即我们简单地用整个特征列的均值替换缺失值。实现这一点的一种方便方法是使用 scikit-learn 中的 SimpleImputer 类,如以下代码所示:

>>> from sklearn.impute import SimpleImputer
>>> import numpy as np
>>> imr = SimpleImputer(missing_values=np.nan, strategy='mean')
>>> imr = imr.fit(df.values)
>>> imputed_data = imr.transform(df.values)
>>> imputed_data
array([[  1.,   2.,   3.,   4.],
       [  5.,   6.,  7.5,   8.],
       [ 10.,  11.,  12.,   6.]]) 

在这里,我们将每个 NaN 值替换为相应的均值,该均值是单独为每个特征列计算的。strategy 参数的其他选项包括 medianmost_frequent,其中后者将缺失值替换为最频繁的值。这对于填补类别特征值很有用,例如存储颜色名称编码的特征列,如红色、绿色和蓝色。我们将在本章后面遇到这类数据的例子。

另一种更方便的填充缺失值的方法是使用 pandas 的 fillna 方法,并提供一个填充方法作为参数。例如,使用 pandas,我们可以通过以下命令直接在 DataFrame 对象中实现相同的均值填充:

>>> df.fillna(df.mean()) 

理解 scikit-learn 估算器 API

在上一节中,我们使用了scikit-learn中的SimpleImputer类来填补数据集中的缺失值。SimpleImputer类属于scikit-learn中所谓的变换器类,用于数据转换。此类估算器的两个基本方法是fittransformfit方法用于从训练数据中学习参数,而transform方法则使用这些参数来转换数据。任何需要转换的数据数组必须与用于拟合模型的数据数组具有相同的特征数量。

下图展示了如何在训练数据上安装变换器,并将其用于转换训练数据集和新的测试数据集:

我们在第3章《使用scikit-learn的机器学习分类器巡礼》中使用的分类器属于scikit-learn中所谓的估算器,其API在概念上与变换器类非常相似。估算器具有predict方法,但也可以具有transform方法,正如你将在本章后面看到的那样。正如你可能记得的,我们在训练这些估算器进行分类时,也使用了fit方法来学习模型的参数。然而,在监督学习任务中,我们还提供了类标签来拟合模型,然后可以通过predict方法对新的未标记数据进行预测,如下图所示:

处理类别数据

到目前为止,我们只处理了数值型数据。然而,现实世界中的数据集常常包含一个或多个类别特征列。在本节中,我们将使用简单而有效的示例,看看如何在数值计算库中处理这类数据。

在讨论类别数据时,我们需要进一步区分有序无序特征。可以理解为,有序特征是可以排序或排列的类别值。例如,T恤的尺寸是一个有序特征,因为我们可以定义顺序:XL > L > M。相比之下,无序特征没有任何排序含义,继续以T恤颜色为例,T恤颜色是无序特征,因为通常没有意义说红色比蓝色大。

使用pandas进行类别数据编码

在探讨处理此类类别数据的不同技术之前,让我们创建一个新的DataFrame来说明问题:

>>> import pandas as pd
>>> df = pd.DataFrame([
...            ['green', 'M', 10.1, 'class2'],
...            ['red', 'L', 13.5, 'class1'],
...            ['blue', 'XL', 15.3, 'class2']])
>>> df.columns = ['color', 'size', 'price', 'classlabel']
>>> df
    color  size  price  classlabel
0   green     M   10.1      class2
1     red     L   13.5      class1
2    blue    XL   15.3      class2 

正如我们在前面的输出中所看到的,新创建的 DataFrame 包含一个名义特征(color)、一个顺序特征(size)和一个数值特征(price)列。类别标签(假设我们为监督学习任务创建了一个数据集)存储在最后一列。在本书中讨论的分类学习算法并不使用类别标签中的顺序信息。

映射顺序特征

为了确保学习算法正确解读顺序特征,我们需要将类别字符串值转换为整数。遗憾的是,没有方便的函数可以自动推导出我们 size 特征标签的正确顺序,因此我们必须手动定义映射。在以下简单示例中,假设我们知道特征之间的数值差异,例如,XL = L + 1 = M + 2:

>>> size_mapping = {'XL': 3,
...                 'L': 2,
...                 'M': 1}
>>> df['size'] = df['size'].map(size_mapping)
>>> df
    color  size  price  classlabel
0   green     1   10.1      class2
1     red     2   13.5      class1
2    blue     3   15.3      class2 

如果我们希望在稍后的阶段将整数值转换回原始的字符串表示,我们可以简单地定义一个反向映射字典,inv_size_mapping = {v: k for k, v in size_mapping.items()},然后通过 pandas 的 map 方法将其应用于转换后的特征列,它与我们之前使用的 size_mapping 字典类似。我们可以按如下方式使用它:

>>> inv_size_mapping = {v: k for k, v in size_mapping.items()}
>>> df['size'].map(inv_size_mapping)
0   M
1   L
2   XL
Name: size, dtype: object 

编码类别标签

许多机器学习库要求类别标签以整数值进行编码。虽然 scikit-learn 中的大多数分类估计器会内部将类别标签转换为整数,但提供整数数组作为类别标签是良好的实践,避免了技术问题。为了编码类别标签,我们可以使用与之前讨论的顺序特征映射类似的方法。我们需要记住,类别标签是非顺序的,并且我们分配给特定字符串标签的整数值无关紧要。因此,我们可以简单地枚举类别标签,从 0 开始:

>>> import numpy as np
>>> class_mapping = {label: idx for idx, label in
...                  enumerate(np.unique(df['classlabel']))}
>>> class_mapping
{'class1': 0, 'class2': 1} 

接下来,我们可以使用映射字典将类别标签转换为整数:

>>> df['classlabel'] = df['classlabel'].map(class_mapping)
>>> df
    color  size  price  classlabel
0   green     1   10.1           1
1     red     2   13.5           0
2    blue     3   15.3           1 

我们可以通过如下方式反转映射字典中的键值对,将转换后的类别标签映射回原始的字符串表示:

>>> inv_class_mapping = {v: k for k, v in class_mapping.items()}
>>> df['classlabel'] = df['classlabel'].map(inv_class_mapping)
>>> df
    color  size  price  classlabel
0   green     1   10.1      class2
1     red     2   13.5      class1
2    blue     3   15.3      class2 

或者,我们可以使用 scikit-learn 中直接实现的 LabelEncoder 类来方便地完成这个任务:

>>> from sklearn.preprocessing import LabelEncoder
>>> class_le = LabelEncoder()
>>> y = class_le.fit_transform(df['classlabel'].values)
>>> y
array([1, 0, 1]) 

请注意,fit_transform 方法实际上是 fittransform 方法的快捷方式,我们可以使用 inverse_transform 方法将整数类别标签转换回原始的字符串表示:

>>> class_le.inverse_transform(y)
array(['class2', 'class1', 'class2'], dtype=object) 

对名义特征进行独热编码

在前面映射顺序特征的部分,我们使用了一个简单的字典映射方法,将有序的 size 特征转换为整数。由于 scikit-learn 的分类估算器将类标签视为没有任何顺序的类别数据(名义型数据),我们使用了方便的 LabelEncoder 将字符串标签编码为整数。看起来我们也可以使用类似的方法,将数据集中的名义型 color 列转换为整数,如下所示:

>>> X = df[['color', 'size', 'price']].values
>>> color_le = LabelEncoder()
>>> X[:, 0] = color_le.fit_transform(X[:, 0])
>>> X
array([[1, 1, 10.1],
       [2, 2, 13.5],
       [0, 3, 15.3]], dtype=object) 

执行前述代码后,NumPy 数组 X 的第一列现在保存了新的 color 值,这些值被编码如下:

  • blue = 0

  • green = 1

  • red = 2

如果我们在此停止,并将数组输入到分类器中,我们将犯下处理类别数据时最常见的错误之一。你能发现问题吗?尽管颜色值没有任何特定顺序,但学习算法现在会假设 green 大于 blue,并且 red 大于 green。尽管这个假设不正确,算法仍然可能产生有用的结果。然而,这些结果并不会是最优的。

解决这个问题的常见方法是使用一种称为独热编码的技术。这种方法的思路是为名义型特征列中的每个唯一值创建一个新的虚拟特征。在这里,我们将把 color 特征转换为三个新特征:bluegreenred。然后可以使用二进制值来表示一个示例的具体 color;例如,一个 blue 示例可以被编码为 blue=1green=0red=0。为了执行这一转换,我们可以使用 scikit-learnpreprocessing 模块中实现的 OneHotEncoder

>>> from sklearn.preprocessing import OneHotEncoder
>>> X = df[['color', 'size', 'price']].values
>>> color_ohe = OneHotEncoder()
>>> color_ohe.fit_transform(X[:, 0].reshape(-1, 1)).toarray()
    array([[0., 1., 0.],
           [0., 0., 1.],
           [1., 0., 0.]]) 

注意,我们只对单列 (X[:, 0].reshape(-1, 1)) 应用了 OneHotEncoder,以避免修改数组中的其他两列。如果我们希望在多特征数组中有选择地转换列,可以使用 ColumnTransformer,它接受一组 (name, transformer, column(s)) 元组,如下所示:

>>> from sklearn.compose import ColumnTransformer
>>> X = df[['color', 'size', 'price']].values
>>> c_transf = ColumnTransformer([ 
...     ('onehot', OneHotEncoder(), [0]),
...     ('nothing', 'passthrough', [1, 2])
... ])
>>> c_transf.fit_transform(X).astype(float)
    array([[0.0, 1.0, 0.0, 1, 10.1],
           [0.0, 0.0, 1.0, 2, 13.5],
           [1.0, 0.0, 0.0, 3, 15.3]]) 

在前面的代码示例中,我们通过'passthrough'参数指定只修改第一列,保持其他两列不变。

使用 pandas 实现的 get_dummies 方法,是通过独热编码创建虚拟特征的一个更便捷的方式。应用于 DataFrame 时,get_dummies 方法只会转换字符串类型的列,而保持其他列不变:

>>> pd.get_dummies(df[['price', 'color', 'size']])
    price  size  color_blue  color_green  color_red
0    10.1     1           0            1          0
1    13.5     2           0            0          1
2    15.3     3           1            0          0 

当我们使用独热编码数据集时,我们需要注意,这会引入多重共线性问题,这对某些方法(例如需要矩阵求逆的方法)可能是个问题。如果特征高度相关,则矩阵求逆在计算上会变得困难,这可能导致数值不稳定的估计。为了减少变量之间的相关性,我们可以简单地从独热编码数组中删除一个特征列。请注意,删除特征列并不会丢失任何重要信息;例如,如果我们删除了 color_blue 列,特征信息仍然得到保留,因为如果我们观察到 color_green=0color_red=0,则意味着观察结果必须是 blue

如果我们使用 get_dummies 函数,我们可以通过将 drop_first 参数设置为 True 来删除第一列,如以下代码示例所示:

>>> pd.get_dummies(df[['price', 'color', 'size']],
...                drop_first=True)
    price  size  color_green  color_red
0    10.1     1            1          0
1    13.5     2            0          1
2    15.3     3            0          0 

为了通过 OneHotEncoder 删除冗余列,我们需要设置 drop='first',并将 categories='auto' 设置如下:

>>> color_ohe = OneHotEncoder(categories='auto', drop='first')
>>> c_transf = ColumnTransformer([
...            ('onehot', color_ohe, [0]),
...            ('nothing', 'passthrough', [1, 2])
... ])
>>> c_transf.fit_transform(X).astype(float)
array([[  1\. ,  0\. ,  1\. ,  10.1],
       [  0\. ,  1\. ,  2\. ,  13.5],
       [  0\. ,  0\. ,  3\. ,  15.3]]) 

可选:编码有序特征

如果我们不确定有序特征类别之间的数值差异,或者两个有序值之间的差异没有定义,我们也可以使用阈值编码来对它们进行编码,使用 0/1 值。例如,我们可以将特征 size 的值 M、L 和 XL 拆分为两个新特征,“x > M”和“x > L”。让我们来看一下原始 DataFrame:

>>> df = pd.DataFrame([['green', 'M', 10.1,
...                     'class2'],
...                    ['red', 'L', 13.5,
...                     'class1'],
...                    ['blue', 'XL', 15.3,
...                     'class2']])
>>> df.columns = ['color', 'size', 'price',
...               'classlabel']
>>> df 

我们可以使用 pandas DataFrame 的 apply 方法编写自定义的 lambda 表达式,采用值阈值方法对这些变量进行编码:

>>> df['x > M'] = df['size'].apply(
...     lambda x: 1 if x in {'L', 'XL'} else 0)
>>> df['x > L'] = df['size'].apply(
...     lambda x: 1 if x == 'XL' else 0)
>>> del df['size']
>>> df 

将数据集划分为独立的训练集和测试集

我们在 第 1 章《让计算机从数据中学习》和 第 3 章《使用 scikit-learn 进行机器学习分类器的概览》中简要介绍了将数据集划分为独立的训练集和测试集的概念。请记住,在测试集上比较预测结果与真实标签,可以理解为我们对模型在将其应用于真实世界之前进行的无偏性能评估。在本节中,我们将准备一个新的数据集——Wine 数据集。预处理完数据集后,我们将探索不同的特征选择技术,以减少数据集的维度。

Wine 数据集是另一个可以从 UCI 机器学习库获取的开源数据集(https://archive.ics.uci.edu/ml/datasets/Wine);它包含 178 个葡萄酒样本,具有描述不同化学属性的 13 个特征。

获取 Wine 数据集

你可以在本书的代码包中找到 Wine 数据集(以及本书中使用的所有其他数据集)的副本,如果你在离线工作或 UCI 服务器上的 https://archive.ics.uci.edu/ml/machine-learning-databases/wine/wine.data 临时不可用,可以使用该副本。例如,要从本地目录加载 Wine 数据集,你可以替换这一行:

df = pd.read_csv(
    'https://archive.ics.uci.edu/ml/'
    'machine-learning-databases/wine/wine.data',
    header=None) 

采用如下方式:

df = pd.read_csv(
    'your/local/path/to/wine.data', header=None) 

使用 pandas 库,我们将直接从 UCI 机器学习库中读取开源的 Wine 数据集:

>>> df_wine = pd.read_csv('https://archive.ics.uci.edu/'
...                       'ml/machine-learning-databases/'
...                       'wine/wine.data', header=None)
>>> df_wine.columns = ['Class label', 'Alcohol',
...                    'Malic acid', 'Ash',
...                    'Alcalinity of ash', 'Magnesium',
...                    'Total phenols', 'Flavanoids',
...                    'Nonflavanoid phenols',
...                    'Proanthocyanins',
...                    'Color intensity', 'Hue',
...                    'OD280/OD315 of diluted wines',
...                    'Proline']
>>> print('Class labels', np.unique(df_wine['Class label']))
Class labels [1 2 3]
>>> df_wine.head() 

Wine 数据集中的 13 个不同特征,描述了 178 个葡萄酒样本的化学属性,列在下表中:

这些样本属于三种不同的类别,123,分别表示意大利同一地区种植的三种不同类型的葡萄,但它们来自不同的葡萄酒品种,具体见数据集概述(https://archive.ics.uci.edu/ml/machine-learning-databases/wine/wine.names)。

一种方便的方式是使用 scikit-learn 中 model_selection 子模块的 train_test_split 函数,随机将该数据集分割成测试集和训练集:

>>> from sklearn.model_selection import train_test_split
>>> X, y = df_wine.iloc[:, 1:].values, df_wine.iloc[:, 0].values
>>> X_train, X_test, y_train, y_test =\
...     train_test_split(X, y,
...                      test_size=0.3,
...                      random_state=0,
...                      stratify=y) 

首先,我们将特征列 1-13 的 NumPy 数组表示赋值给变量 X,将第一列的类别标签赋值给变量 y。然后,我们使用 train_test_split 函数将 Xy 随机分割为训练集和测试集。通过设置 test_size=0.3,我们将 30% 的葡萄酒样本分配给 X_testy_test,其余 70% 的样本分别分配给 X_trainy_train。将类别标签数组 y 作为 stratify 的参数,确保训练集和测试集的类别比例与原始数据集一致。

选择适当的比例将数据集分割为训练集和测试集

如果我们将数据集划分为训练集和测试集,我们必须记住,我们是在扣留学习算法可能受益的有价值信息。因此,我们不希望将过多信息分配给测试集。然而,测试集越小,泛化误差的估计就会越不准确。将数据集划分为训练集和测试集的关键在于平衡这种权衡。在实践中,最常用的划分比例是60:40、70:30或80:20,具体取决于初始数据集的大小。然而,对于大型数据集,90:10或99:1的划分也很常见且适用。例如,如果数据集包含超过100,000个训练样本,可能只需保留10,000个样本用于测试,就能很好地估算泛化性能。更多信息和示例可以在我的文章《模型评估、模型选择和算法选择》中找到,该文章在https://arxiv.org/pdf/1811.12808.pdf上可以免费获取。

此外,模型训练和评估后,不是丢弃分配的测试数据,而是通常会在整个数据集上重新训练分类器,因为这样可以提高模型的预测性能。虽然这种方法通常被推荐,但如果数据集较小且测试数据集中包含异常值,比如说,可能会导致更差的泛化性能。另外,在对整个数据集重新拟合模型后,我们就没有任何独立的数据来评估其性能了。

将特征映射到相同的尺度

特征缩放是我们预处理管道中的一个关键步骤,然而它很容易被遗忘。决策树随机森林是少数几个我们不需要担心特征缩放的机器学习算法。这些算法对尺度具有不变性。然而,大多数机器学习和优化算法在特征处于相同尺度时表现得更好,正如我们在《第2章:训练简单机器学习算法进行分类》中看到的,当我们实现梯度下降优化算法时就是这样。

特征缩放的重要性可以通过一个简单的例子来说明。假设我们有两个特征,其中一个特征的量表范围是1到10,另一个特征的量表范围是1到100,000。

当我们回想起《第2章:训练简单机器学习算法进行分类》中的Adaline的平方误差函数时,我们可以理解为什么说该算法主要会根据第二个特征中的较大误差来优化权重。另一个例子是k近邻KNN)算法,它使用欧几里得距离度量:计算出的样本之间的距离将主要受第二个特征轴的影响。

现在,有两种常见的方法将不同的特征带到相同的尺度:归一化标准化。这些术语在不同领域中经常被使用得比较随意,其含义需要从上下文中推断出来。通常,归一化指的是将特征重缩放到[0, 1]的范围内,这实际上是最小-最大缩放的一种特殊情况。为了归一化我们的数据,我们可以简单地对每个特征列应用最小-最大缩放,其中某个示例的新的值,可以按如下方式计算:

在这里,是一个特定的例子,是特征列中的最小值,而是最大值。

最小-最大缩放过程已经在scikit-learn中实现,可以如下使用:

>>> from sklearn.preprocessing import MinMaxScaler
>>> mms = MinMaxScaler()
>>> X_train_norm = mms.fit_transform(X_train)
>>> X_test_norm = mms.transform(X_test) 

尽管通过最小-最大缩放进行归一化是一种常用的技术,适用于我们需要将值限定在一个有界区间内的情况,但标准化对于许多机器学习算法来说可能更为实用,特别是对于像梯度下降这样的优化算法。原因是许多线性模型,如第3章中提到的逻辑回归和SVM(使用scikit-learn进行机器学习分类器巡礼),通常将权重初始化为0或接近0的小随机值。通过标准化,我们将特征列的均值置为0,标准差为1,这样特征列具有与标准正态分布(零均值和单位方差)相同的参数,从而更容易学习权重。此外,标准化保持了关于离群值的有用信息,并且使得算法对离群值不那么敏感,而与最小-最大缩放不同,后者将数据缩放到有限的值范围内。

标准化的过程可以通过以下方程式表示:

在这里,是特定特征列的样本均值,而是对应的标准差。

以下表格展示了标准化和归一化两种常用特征缩放技术在一个简单示例数据集(包含0到5的数字)中的区别:

输入 标准化 最小-最大归一化
0.0 -1.46385 0.0
1.0 -0.87831 0.2
2.0 -0.29277 0.4
3.0 0.29277 0.6
4.0 0.87831 0.8
5.0 1.46385 1.0

你可以通过执行以下代码示例手动进行标准化和归一化,如表所示:

>>> ex = np.array([0, 1, 2, 3, 4, 5])
>>> print('standardized:', (ex - ex.mean()) / ex.std())
standardized: [-1.46385011  -0.87831007  -0.29277002  0.29277002
0.87831007  1.46385011]
>>> print('normalized:', (ex - ex.min()) / (ex.max() - ex.min()))
normalized: [ 0\.  0.2  0.4  0.6  0.8  1\. ] 

MinMaxScaler类类似,scikit-learn还实现了标准化的类:

>>> from sklearn.preprocessing import StandardScaler
>>> stdsc = StandardScaler()
>>> X_train_std = stdsc.fit_transform(X_train)
>>> X_test_std = stdsc.transform(X_test) 

再次强调,我们仅在训练数据上拟合一次StandardScaler类,并使用这些参数来转换测试数据集或任何新的数据点。

scikit-learn 提供了其他更先进的特征缩放方法,如 RobustScaler。如果我们处理的是包含许多异常值的小型数据集,RobustScaler尤其有帮助并且推荐使用。类似地,如果应用于该数据集的机器学习算法容易过拟合,那么 RobustScaler 是一个不错的选择。RobustScaler 独立地操作每个特征列,去除中位数值,并根据数据集的第1四分位数和第3四分位数(即第25和第75百分位数)对数据集进行缩放,从而使极端值和异常值不那么显著。感兴趣的读者可以在官方的 scikit-learn 文档中找到有关 RobustScaler 的更多信息,链接:https://scikit-learn.org/stable/modules/generated/sklearn.preprocessing.RobustScaler.html

选择有意义的特征

如果我们注意到模型在训练数据集上的表现远远优于在测试数据集上的表现,这一观察结果是过拟合的强烈指示。正如我们在第三章使用 scikit-learn 进行机器学习分类器巡礼中所讨论的,过拟合意味着模型对训练数据集中的特定观察结果拟合得过于紧密,但对新数据的泛化能力较差;我们称模型具有高方差。过拟合的原因是我们的模型对于给定的训练数据过于复杂。减少泛化误差的常见解决方案如下:

  • 收集更多的训练数据

  • 通过正则化引入复杂度的惩罚

  • 选择一个更简单的模型,减少参数数量

  • 降低数据的维度

收集更多训练数据通常不可行。在第六章模型评估和超参数调优的最佳实践学习中,我们将学习一种有用的技术来检查更多的训练数据是否有效。在接下来的章节中,我们将探讨通过正则化和特征选择进行降维的常见方法,从而通过减少需要拟合到数据的参数数量来简化模型。

L1 和 L2 正则化作为针对模型复杂度的惩罚

你还记得在第三章使用 scikit-learn 进行机器学习分类器巡礼中,L2 正则化是通过惩罚较大的单个权重来减少模型复杂度的一种方法。我们将权重向量 w 的平方 L2 范数定义如下:

另一种减少模型复杂度的方法是相关的L1 正则化

在这里,我们只是将权重的平方替换为权重绝对值的和。与L2正则化不同,L1正则化通常会产生稀疏的特征向量,大多数特征权重会为零。如果我们有一个高维数据集,且许多特征是无关的,稀疏性在实践中会非常有用,特别是在特征维度比训练样本还多的情况下。从这个角度看,L1正则化可以理解为一种特征选择技术。

L2正则化的几何解释

如前所述,L2正则化向成本函数添加一个惩罚项,这使得相比于使用未正则化成本函数训练的模型,得到的权重值更加温和。

为了更好地理解L1正则化如何鼓励稀疏性,让我们退一步,看看正则化的几何解释。我们将绘制两个权重系数的凸成本函数的等高线,

在这里,我们将考虑平方误差和SSE)成本函数,这是我们在第二章《训练简单的机器学习分类算法》中为Adaline使用的,因为它是球形的,比逻辑回归的成本函数更容易绘制;但是,相同的概念适用。记住,我们的目标是找到最小化训练数据成本函数的权重系数组合,如下图所示(椭圆中心的点):

我们可以把正则化看作是向成本函数中添加一个惩罚项,以鼓励较小的权重;换句话说,我们对大权重进行惩罚。因此,通过通过正则化参数增加正则化强度,,我们将权重收缩至零,并减少模型对训练数据的依赖。让我们通过以下图示来说明L2惩罚项的概念:

二次 L2 正则化项由阴影球体表示。在这里,我们的权重系数不能超过我们的正则化预算——权重系数的组合不能超出阴影区域。另一方面,我们仍然希望最小化成本函数。在惩罚约束下,我们的最佳努力是选择 L2 球体与未加惩罚的成本函数等高线交点的位置。正则化参数的值越大,惩罚成本增长得越快,从而导致 L2 球体变得更窄。例如,如果我们将正则化参数增大至无穷大,权重系数将有效地变为零,表示为 L2 球体的中心。总结这个例子的主要信息,我们的目标是最小化未加惩罚的成本与惩罚项的和,可以理解为增加偏置并倾向于选择更简单的模型,以减少在缺乏足够训练数据的情况下对模型的方差拟合。

L1 正则化下的稀疏解

现在,让我们讨论一下 L1 正则化与稀疏性。L1 正则化背后的主要概念与我们在前一节讨论的相似。然而,由于 L1 惩罚是绝对权重系数的和(记住 L2 项是二次的),我们可以将其表示为一个菱形预算,如下图所示:

在前面的图中,我们可以看到成本函数的等高线在 处触及 L1 菱形。由于 L1 正则化系统的等高线较为尖锐,因此最优解——即成本函数的椭圆与 L1 菱形边界的交点——更可能位于坐标轴上,这有助于稀疏性。

L1 正则化与稀疏性

L1 正则化能够导致稀疏解的数学细节超出了本书的范围。如果你有兴趣,可以参考《统计学习的元素》The Elements of Statistical Learning第三章 3.4 节,其中对 L2 与 L1 正则化的解释非常出色,作者为Trevor HastieRobert TibshiraniJerome Friedman,出版于Springer Science+Business Media2009年。

对于支持 L1 正则化的 scikit-learn 正则化模型,我们只需将 penalty 参数设置为 'l1',即可获得稀疏解:

>>> from sklearn.linear_model import LogisticRegression
>>> LogisticRegression(penalty='l1',
...                    solver='liblinear',
...                    multi_class='ovr') 

请注意,我们还需要选择一个不同的优化算法(例如,solver='liblinear'),因为 'lbfgs' 当前不支持 L1 正则化的损失优化。应用于标准化后的葡萄酒数据,L1 正则化的逻辑回归将得到如下稀疏解:

>>> lr = LogisticRegression(penalty='l1',
...                         C=1.0,
...                         solver='liblinear',
...                         multi_class='ovr')
# Note that C=1.0 is the default. You can increase
# or decrease it to make the regularization effect
# stronger or weaker, respectively.
>>> lr.fit(X_train_std, y_train)
>>> print('Training accuracy:', lr.score(X_train_std, y_train))
Training accuracy: 1.0
>>> print('Test accuracy:', lr.score(X_test_std, y_test))
Test accuracy: 1.0 

训练和测试的准确率(均为 100%)表明我们的模型在两个数据集上都表现得非常完美。当我们通过 lr.intercept_ 属性访问截距项时,可以看到返回的数组包含三个值:

>>> lr.intercept_
array([-1.26346036, -1.21584018, -2.3697841 ]) 

由于我们通过一对其余OvR)方法在多类数据集上拟合了LogisticRegression对象,第一个截距属于拟合类1与类2、类3之间的模型,第二个值是拟合类2与类1、类3之间的模型的截距,第三个值是拟合类3与类1、类2之间的模型的截距:

>>> lr.coef_
array([[ 1.24590762,  0.18070219,  0.74375939, -1.16141503,
         0\.        ,  0\.        ,  1.16926815,  0\.        ,
         0\.        ,  0\.        ,  0\.        ,  0.54784923,
         2.51028042],
       [-1.53680415, -0.38795309, -0.99494046,  0.36508729,
        -0.05981561,  0\.        ,  0.6681573 ,  0\.        ,
         0\.        , -1.93426485,  1.23265994,  0\.        ,
        -2.23137595],
       [ 0.13547047,  0.16873019,  0.35728003,  0\.        ,
         0\.        ,  0\.        , -2.43713947,  0\.        ,
         0\.        ,  1.56351492, -0.81894749, -0.49308407,
         0\.        ]]) 

我们通过lr.coef_属性访问到的权重数组包含三行权重系数,每行对应一个类别的权重向量。每一行包含13个权重,每个权重与13维的Wine数据集中的相应特征相乘,以计算净输入:

访问scikit-learn估计器的偏置和权重参数

在scikit-learn中,intercept_对应于 coef_对应于 j > 0时的值

由于L1正则化的结果,正如前文所述,L1正则化作为特征选择的一种方法,我们刚刚训练了一个对数据集中可能不相关特征具有鲁棒性的模型。然而,严格来说,前一个示例中的权重向量不一定是稀疏的,因为它们包含的非零项多于零项。然而,我们可以通过进一步增加正则化强度来强制执行稀疏性(即选择较低的C参数值)。

在本章最后一个关于正则化的示例中,我们将调整正则化强度并绘制正则化路径——不同正则化强度下不同特征的权重系数:

>>> import matplotlib.pyplot as plt
>>> fig = plt.figure()
>>> ax = plt.subplot(111)
>>> colors = ['blue', 'green', 'red', 'cyan',
...           'magenta', 'yellow', 'black',
...           'pink', 'lightgreen', 'lightblue',
...           'gray', 'indigo', 'orange']
>>> weights, params = [], []
>>> for c in np.arange(-4., 6.):
...     lr = LogisticRegression(penalty='l1', C=10.**c,
...                             solver='liblinear',
...                             multi_class='ovr', random_state=0)
...     lr.fit(X_train_std, y_train)
...     weights.append(lr.coef_[1])
...     params.append(10**c)
>>> weights = np.array(weights)
>>> for column, color in zip(range(weights.shape[1]), colors):
...     plt.plot(params, weights[:, column],
...              label=df_wine.columns[column + 1],
...              color=color)
>>> plt.axhline(0, color='black', linestyle='--', linewidth=3)
>>> plt.xlim([10**(-5), 10**5])
>>> plt.ylabel('weight coefficient')
>>> plt.xlabel('C')
>>> plt.xscale('log')
>>> plt.legend(loc='upper left')
>>> ax.legend(loc='upper center',
...           bbox_to_anchor=(1.38, 1.03),
...           ncol=1, fancybox=True)
>>> plt.show() 

生成的图表为我们提供了进一步的见解,帮助我们了解L1正则化的行为。如我们所见,如果我们用强正则化参数惩罚模型(C < 0.01),所有特征权重将为零;C是正则化参数的倒数,!

顺序特征选择算法

减少模型复杂度并避免过拟合的另一种方法是通过特征选择进行降维,这对于未经正则化的模型尤其有用。降维技术主要分为两类:特征选择特征提取。通过特征选择,我们选择原始特征的子集,而在特征提取中,我们从特征集合中提取信息来构造一个新的特征子空间。

在本节中,我们将介绍一类经典的特征选择算法。在下一章,第5章通过降维压缩数据,我们将学习不同的特征提取技术,将数据集压缩到低维特征子空间。

顺序特征选择算法是一类贪心搜索算法,旨在将初始的 d 维特征空间减少到一个 k 维的特征子空间,其中 k<d。特征选择算法的动机是自动选择与问题最相关的特征子集,以提高计算效率,或者通过移除无关特征或噪声来减少模型的泛化误差,这对于不支持正则化的算法尤为有用。

一种经典的顺序特征选择算法是 顺序后向选择SBS),其目标是在尽量减少分类器性能下降的情况下,降低初始特征子空间的维度,从而提高计算效率。在某些情况下,如果模型存在过拟合,SBS甚至可以提高模型的预测能力。

贪心搜索算法

贪心算法在组合搜索问题的每个阶段做出局部最优选择,通常会导致次优解,而与之相对的是 穷举搜索算法,它评估所有可能的组合,并保证找到最优解。然而,在实际应用中,穷举搜索通常计算上不可行,而贪心算法则提供了一种更简单、计算上更高效的解决方案。

SBS算法背后的思想相当简单:SBS顺序地从完整特征子集中移除特征,直到新的特征子空间包含所需的特征数量。为了确定每个阶段要移除哪个特征,我们需要定义准则函数 J,以便最小化它。

由准则函数计算出的准则可以简单地是分类器在移除特定特征前后的性能差异。然后,在每个阶段要移除的特征可以简单地定义为最大化该准则的特征;或者用更简单的说法,每个阶段我们移除那个在移除后造成性能损失最小的特征。根据前述的SBS定义,我们可以将算法概括为四个简单的步骤:

  1. 初始化算法时,设定 k = d,其中 d 是完整特征空间的维度,

  2. 确定最大化准则的特征,,其准则为:,其中!

  3. 从特征集 中移除特征

  4. 如果 k 等于所需的特征数,则终止;否则,转到步骤2。

顺序特征算法的资源

你可以在 《大规模特征选择技术的比较研究》 中找到对几种顺序特征算法的详细评估,F. FerriP. PudilM. HatefJ. Kittler,第403-413页,1994

不幸的是,SBS算法还未在scikit-learn中实现。但由于它非常简单,我们就来手动实现它,使用Python从头开始编写:

from sklearn.base import clone
from itertools import combinations
import numpy as np
from sklearn.metrics import accuracy_score
from sklearn.model_selection import train_test_split
class SBS():
    def __init__(self, estimator, k_features,
                 scoring=accuracy_score,
                 test_size=0.25, random_state=1):
        self.scoring = scoring
        self.estimator = clone(estimator)
        self.k_features = k_features
        self.test_size = test_size
        self.random_state = random_state
    def fit(self, X, y):
        X_train, X_test, y_train, y_test = \
            train_test_split(X, y, test_size=self.test_size,
                             random_state=self.random_state)

        dim = X_train.shape[1]
        self.indices_ = tuple(range(dim))
        self.subsets_ = [self.indices_]
        score = self._calc_score(X_train, y_train,
                                 X_test, y_test, self.indices_)
        self.scores_ = [score]
        while dim > self.k_features:
            scores = []
            subsets = []

            for p in combinations(self.indices_, r=dim - 1):
                score = self._calc_score(X_train, y_train,
                                         X_test, y_test, p)
                scores.append(score)
                subsets.append(p)

            best = np.argmax(scores)
            self.indices_ = subsets[best]
            self.subsets_.append(self.indices_)
            dim -= 1

            self.scores_.append(scores[best])
        self.k_score_ = self.scores_[-1]

        return self

    def transform(self, X):
        return X[:, self.indices_]

    def _calc_score(self, X_train, y_train, X_test, y_test, indices):
        self.estimator.fit(X_train[:, indices], y_train)
        y_pred = self.estimator.predict(X_test[:, indices])
        score = self.scoring(y_test, y_pred)
        return score 

在之前的实现中,我们定义了k_features参数来指定我们希望返回的特征数量。默认情况下,我们使用来自scikit-learn的accuracy_score来评估模型(分类估计器)在特征子集上的表现。

fit方法的while循环内部,itertools.combination函数创建的特征子集被评估并逐步减少,直到特征子集具有所需的维度。在每次迭代中,基于内部创建的测试数据集X_test,最佳子集的准确度得分会被收集到一个列表self.scores_中。我们稍后会使用这些得分来评估结果。最终特征子集的列索引被分配给self.indices_,我们可以通过transform方法使用它,返回一个包含所选特征列的新数据数组。请注意,fit方法内部并没有显式地计算标准,而是通过直接移除不在最佳表现特征子集中的特征来实现。

现在,让我们看看使用scikit-learn中的KNN分类器的SBS实现效果:

>>> import matplotlib.pyplot as plt
>>> from sklearn.neighbors import KNeighborsClassifier
>>> knn = KNeighborsClassifier(n_neighbors=5)
>>> sbs = SBS(knn, k_features=1)
>>> sbs.fit(X_train_std, y_train) 

尽管我们的SBS实现已经在fit函数内部将数据集拆分为测试集和训练集,但我们仍然将训练数据集X_train输入给算法。SBS的fit方法会为测试(验证)和训练创建新的训练子集,这就是为什么这个测试集也被称为验证数据集。这种方法是必要的,以防我们的原始测试集成为训练数据的一部分。

记住,我们的SBS算法会在每个阶段收集最佳特征子集的得分,因此让我们进入实现中更激动人心的部分,绘制基于验证数据集计算的KNN分类器的分类准确率。代码如下:

>>> k_feat = [len(k) for k in sbs.subsets_]
>>> plt.plot(k_feat, sbs.scores_, marker='o')
>>> plt.ylim([0.7, 1.02])
>>> plt.ylabel('Accuracy')
>>> plt.xlabel('Number of features')
>>> plt.grid()
>>> plt.tight_layout()
>>> plt.show() 

如下图所示,我们可以看到,当我们减少特征数量时,KNN分类器在验证数据集上的准确度得到了提升,这很可能是由于我们在第3章《使用scikit-learn进行机器学习分类器之旅》中讨论的维度灾难的减少。同时,我们也可以在下图中看到,分类器在k={3, 7, 8, 9, 10, 11, 12}时,达到了100%的准确率:

为了满足我们的好奇心,让我们看看最小的特征子集(k=3),它在验证数据集上表现得如此出色:

>>> k3 = list(sbs.subsets_[10])
>>> print(df_wine.columns[1:][k3])
Index(['Alcohol', 'Malic acid', 'OD280/OD315 of diluted wines'], dtype='object') 

使用前面的代码,我们从11号位置的sbs.subsets_属性中获取了三特征子集的列索引,并从pandas Wine DataFrame的列索引中返回了相应的特征名称。

接下来,让我们评估KNN分类器在原始测试数据集上的表现:

>>> knn.fit(X_train_std, y_train)
>>> print('Training accuracy:', knn.score(X_train_std, y_train))
Training accuracy: 0.967741935484
>>> print('Test accuracy:', knn.score(X_test_std, y_test))
Test accuracy: 0.962962962963 

在前面的代码部分,我们使用了完整的特征集,并在训练数据集上获得了约97%的准确率,在测试数据集上获得了约96%的准确率,这表明我们的模型已经能够很好地泛化到新数据上。现在,让我们使用选定的三个特征子集,看看KNN的表现如何:

>>> knn.fit(X_train_std[:, k3], y_train)
>>> print('Training accuracy:',
...       knn.score(X_train_std[:, k3], y_train))
Training accuracy: 0.951612903226
>>> print('Test accuracy:',
...       knn.score(X_test_std[:, k3], y_test))
Test accuracy: 0.925925925926 

当在Wine数据集中使用不到原始特征的四分之一时,测试数据集上的预测准确性略有下降。这可能表明这三个特征提供的信息并不比原始数据集少。然而,我们也必须记住,Wine数据集是一个小数据集,非常容易受到随机性的影响——也就是说,我们如何将数据集划分为训练集和测试集,以及如何进一步将训练集划分为训练集和验证集。

尽管通过减少特征数量并没有提高KNN模型的性能,但我们缩小了数据集的大小,这在实际应用中可能很有用,尤其是当数据收集步骤非常昂贵时。另外,通过大幅减少特征数量,我们获得了更简洁的模型,更易于解释

scikit-learn中的特征选择算法

通过scikit-learn,提供了更多的特征选择算法。这些算法包括基于特征权重的递归后向消除、通过重要性选择特征的基于树的方法,以及单变量统计检验。对不同特征选择方法的全面讨论超出了本书的范围,但可以在http://scikit-learn.org/stable/modules/feature_selection.html找到一个很好的总结,并附有示例。你还可以在Python包mlxtend中找到与我们之前实现的简单SBS相关的几种不同类型的顺序特征选择的实现,地址是http://rasbt.github.io/mlxtend/user_guide/feature_selection/SequentialFeatureSelector/

使用随机森林评估特征重要性

在之前的章节中,你学习了如何通过逻辑回归使用 L1 正则化来将无关特征的系数归零,以及如何使用 SBS 算法进行特征选择并将其应用于 KNN 算法。另一种选择数据集中相关特征的有用方法是使用随机森林,这是一种集成技术,在第 3 章《使用 scikit-learn 进行机器学习分类器之旅》中有介绍。通过使用随机森林,我们可以通过计算森林中所有决策树的平均不纯度减少来衡量特征的重要性,而无需对数据是否线性可分作出任何假设。方便的是,scikit-learn 中的随机森林实现已经为我们收集了特征重要性值,因此在拟合 RandomForestClassifier 后,我们可以通过 feature_importances_ 属性访问这些值。通过执行以下代码,我们将训练一个包含 500 棵树的森林,并根据它们各自的重要性度量对葡萄酒数据集中的 13 个特征进行排名——请记住,在第 3 章《使用 scikit-learn 进行机器学习分类器之旅》中我们讨论过,树模型不需要使用标准化或归一化的特征:

>>> from sklearn.ensemble import RandomForestClassifier
>>> feat_labels = df_wine.columns[1:]
>>> forest = RandomForestClassifier(n_estimators=500,
...                                 random_state=1)
>>> forest.fit(X_train, y_train)
>>> importances = forest.feature_importances_
>>> indices = np.argsort(importances)[::-1]
>>> for f in range(X_train.shape[1]):
...     print("%2d) %-*s %f" % (f + 1, 30,
...                             feat_labels[indices[f]],
...                             importances[indices[f]]))
>>> plt.title('Feature Importance')
>>> plt.bar(range(X_train.shape[1]),
...         importances[indices],
...         align='center')
>>> plt.xticks(range(X_train.shape[1]),
...            feat_labels[indices] rotation=90)
>>> plt.xlim([-1, X_train.shape[1]])
>>> plt.tight_layout()
>>> plt.show()
 1) Proline                         0.185453
 2) Flavanoids                      0.174751
 3) Color intensity                 0.143920
 4) OD280/OD315 of diluted wines    0.136162
 5) Alcohol                         0.118529
 6) Hue                             0.058739
 7) Total phenols                   0.050872
 8) Magnesium                       0.031357
 9) Malic acid                      0.025648
 10) Proanthocyanins                0.025570
 11) Alcalinity of ash              0.022366
 12) Nonflavanoid phenols           0.013354
 13) Ash                            0.013279 

执行代码后,我们创建了一个图表,将葡萄酒数据集中不同特征按其相对重要性进行排名;请注意,特征重要性值已归一化,以便它们的总和为 1.0:

我们可以得出结论,葡萄酒的脯氨酸和类黄酮水平、颜色强度、OD280/OD315 衍射以及酒精浓度是数据集中最具辨别力的特征,这一结论基于 500 棵决策树中计算的平均不纯度减少。有趣的是,图表中排名靠前的两个特征也出现在我们在上一节中实现的 SBS 算法的三特征子集选择中(酒精浓度和稀释酒的 OD280/OD315)。

然而,就可解释性而言,随机森林技术有一个值得注意的重要陷阱。如果两个或多个特征高度相关,一个特征可能会被排名非常高,而另一个特征(或多个特征)的信息可能没有完全捕获。另一方面,如果我们仅对模型的预测性能感兴趣,而不是对特征重要性值的解释,则不需要担心这个问题。

总结这一节关于特征重要性值和随机森林的内容时,值得一提的是,scikit-learn 还实现了一个SelectFromModel对象,该对象在模型拟合后根据用户指定的阈值选择特征。如果我们想将RandomForestClassifier作为特征选择器,并作为scikit-learn Pipeline对象中的一个中间步骤,这非常有用,Pipeline允许我们将不同的预处理步骤与估计器连接起来,正如你将在第六章模型评估与超参数调优的最佳实践中看到的那样。例如,我们可以将threshold设置为0.1,使用以下代码将数据集缩减为五个最重要的特征:

>>> from sklearn.feature_selection import SelectFromModel
>>> sfm = SelectFromModel(forest, threshold=0.1, prefit=True)
>>> X_selected = sfm.transform(X_train)
>>> print('Number of features that meet this threshold', 
...       'criterion:', X_selected.shape[1])
Number of features that meet this threshold criterion: 5
>>> for f in range(X_selected.shape[1]):
...     print("%2d) %-*s %f" % (f + 1, 30,
...                             feat_labels[indices[f]],
...                             importances[indices[f]]))
 1) Proline                         0.185453
 2) Flavanoids                      0.174751
 3) Color intensity                 0.143920
 4) OD280/OD315 of diluted wines    0.136162
 5) Alcohol                         0.118529 

总结

我们在本章开始时探讨了确保正确处理缺失数据的有用技巧。在将数据输入到机器学习算法之前,我们还必须确保正确地编码分类变量,在这一章中,我们展示了如何将有序和名义特征值映射到整数表示。

此外,我们简要讨论了L1正则化,它通过减少模型的复杂性来帮助我们避免过拟合。作为移除无关特征的另一种方法,我们使用了一个序列特征选择算法,从数据集中选择有意义的特征。

在下一章中,你将学习到另一种有用的降维方法:特征提取。它允许我们将特征压缩到一个较低维度的子空间,而不是像特征选择那样完全去除特征。

第五章:通过维度减少压缩数据

第4章构建良好的训练数据集——数据预处理中,您了解了使用不同特征选择技术来减少数据集维度的不同方法。另一种用于维度减少的特征选择方法是特征提取。在本章中,您将学习三种基本技术,它们将帮助您通过将数据转换到一个比原始数据低维的新特征子空间,从而总结数据集的信息内容。数据压缩是机器学习中的一个重要话题,它帮助我们存储和分析现代技术时代产生和收集的大量数据。

本章将涵盖以下主题:

  • 主成分分析PCA)用于无监督数据压缩

  • 线性判别分析LDA)作为一种监督式维度减少技术,用于最大化类分离度

  • 通过核主成分分析KPCA)的非线性维度减少

通过主成分分析进行无监督的维度减少

类似于特征选择,我们可以使用不同的特征提取技术来减少数据集中的特征数量。特征选择和特征提取的区别在于,使用特征选择算法(如顺序后向选择)时,我们保留原始特征,而使用特征提取时,我们将数据转换或投影到一个新的特征空间。

在维度减少的背景下,特征提取可以理解为一种数据压缩方法,目的是保持大部分相关信息。实际上,特征提取不仅用于提高存储空间或学习算法的计算效率,还可以通过减少维度诅咒来提高预测性能——特别是当我们使用非正则化模型时。

主成分分析的主要步骤

在本节中,我们将讨论主成分分析(PCA),这是一种无监督的线性变换技术,广泛应用于不同领域,最显著的应用包括特征提取和维度减少。PCA的其他常见应用包括探索性数据分析、股市交易中的信号去噪、以及生物信息学领域中基因组数据和基因表达水平的分析。

PCA帮助我们根据特征之间的相关性识别数据中的模式。简而言之,PCA旨在找到高维数据中最大方差的方向,并将数据投影到一个新的子空间,该子空间的维度与原始子空间相等或更少。新子空间的正交轴(主成分)可以解释为在新特征轴互相正交的约束下,最大方差的方向,如下图所示:

在前面的图中,是原始特征轴,PC1PC2是主成分。

如果我们使用PCA进行降维,我们构造一个-维的变换矩阵,W,它使我们能够将一个向量,x,即训练样本的特征,映射到一个新的k-维特征子空间,该子空间的维度比原始的d-维特征空间要低。例如,过程如下。假设我们有一个特征向量,x

然后通过变换矩阵进行转换,

从而得到输出向量:

由于将原始的d-维数据转换到这个新的k-维子空间(通常k << d)的结果,第一个主成分将具有最大的方差。所有后续的主成分将具有最大方差,前提是这些成分与其他主成分不相关(正交)——即使输入特征是相关的,结果主成分也会是互相正交的(不相关)。请注意,PCA的方向对数据缩放非常敏感,如果特征是以不同的尺度度量的,而我们希望为所有特征分配相等的重要性,那么我们需要在执行PCA之前对特征进行标准化。

在更详细地了解PCA降维算法之前,让我们用几个简单的步骤总结一下该方法:

  1. 标准化d-维数据集。

  2. 构建协方差矩阵。

  3. 将协方差矩阵分解为其特征向量和特征值。

  4. 按特征值的降序排列,以对相应的特征向量进行排序。

  5. 选择与k个最大特征值相对应的k个特征向量,其中k是新特征子空间的维度()。

  6. 从“前面”的k个特征向量构建投影矩阵W

  7. 使用投影矩阵Wd-维输入数据集X进行变换,以获得新的k-维特征子空间。

在接下来的部分中,我们将逐步执行PCA,使用Python作为学习练习。然后,我们将看到如何使用scikit-learn更方便地执行PCA。

步骤提取主成分

在本小节中,我们将处理PCA的前四个步骤:

  1. 标准化数据。

  2. 构建协方差矩阵。

  3. 获取协方差矩阵的特征值和特征向量。

  4. 按照降序排列特征值,以对特征向量进行排序。

首先,我们将从 第4章 中使用过的Wine数据集开始加载:构建良好的训练数据集——数据预处理

>>> import pandas as pd
>>> df_wine = pd.read_csv('https://archive.ics.uci.edu/ml/'
...           'machine-learning-databases/wine/wine.data',
...           header=None) 

获取Wine数据集

你可以在本书的代码包中找到一份Wine数据集的副本(以及本书中使用的所有其他数据集),如果你在离线工作或者UCI服务器(https://archive.ics.uci.edu/ml/machine-learning-databases/wine/wine.data)暂时无法访问时,可以使用这份副本。例如,要从本地目录加载Wine数据集,你可以替换以下代码行:

df = pd.read_csv(
    'https://archive.ics.uci.edu/ml/'
    'machine-learning-databases/wine/wine.data',
    header=None) 

以及以下条件:

df = pd.read_csv(
    'your/local/path/to/wine.data',
    header=None) 

接下来,我们将Wine数据分为训练集和测试集,分别使用数据的70%和30%,并将其标准化为单位方差:

>>> from sklearn.model_selection import train_test_split
>>> X, y = df_wine.iloc[:, 1:].values, df_wine.iloc[:, 0].values
>>> X_train, X_test, y_train, y_test = \
...     train_test_split(X, y, test_size=0.3,
...                      stratify=y,
...                      random_state=0)
>>> # standardize the features
>>> from sklearn.preprocessing import StandardScaler
>>> sc = StandardScaler()
>>> X_train_std = sc.fit_transform(X_train)
>>> X_test_std = sc.transform(X_test) 

完成前面的必要预处理代码后,接下来我们进入第二步:构建协方差矩阵。对称的 维协方差矩阵,其中 d 是数据集中的维度数,存储不同特征之间的成对协方差。例如,两个特征 在总体水平上的协方差可以通过以下公式计算:

这里, 分别是特征 jk 的样本均值。请注意,如果我们对数据集进行了标准化,样本均值将为零。两个特征之间的正协方差表示这两个特征一起增加或减少,而负协方差则表示它们以相反的方向变化。例如,三个特征的协方差矩阵可以写成如下形式(注意, 代表希腊大写字母 sigma,与求和符号区分开):

协方差矩阵的特征向量代表主成分(最大方差的方向),而相应的特征值则定义了它们的大小。在Wine数据集的情况下,我们将从该 维的协方差矩阵中获得13个特征向量和特征值。

现在,进入第三步,让我们获得协方差矩阵的特征对。如你从我们的线性代数入门课程中记得,特征向量 v 满足以下条件:

这里,是一个标量:特征值。由于手动计算特征向量和特征值是一个相当繁琐和复杂的任务,我们将使用NumPy的linalg.eig函数来获取Wine协方差矩阵的特征对:

>>> import numpy as np
>>> cov_mat = np.cov(X_train_std.T)
>>> eigen_vals, eigen_vecs = np.linalg.eig(cov_mat)
>>> print('\nEigenvalues \n%s' % eigen_vals)
Eigenvalues
[ 4.84274532  2.41602459  1.54845825  0.96120438  0.84166161
  0.6620634   0.51828472  0.34650377  0.3131368   0.10754642
  0.21357215  0.15362835  0.1808613 ] 

使用numpy.cov函数,我们计算了标准化训练数据集的协方差矩阵。通过使用linalg.eig函数,我们进行了特征分解,得到一个向量(eigen_vals),其中包含13个特征值,以及存储为列的对应特征向量,保存在一个维度的矩阵(eigen_vecs)中。

NumPy中的特征分解

numpy.linalg.eig函数被设计为可以处理对称矩阵和非对称矩阵。然而,你可能会发现它在某些情况下返回复特征值。

一个相关的函数numpy.linalg.eigh已经实现,它用于分解厄米矩阵,这是处理对称矩阵(如协方差矩阵)的数值上更稳定的方法;numpy.linalg.eigh总是返回实特征值。

总方差和解释方差

由于我们希望通过将数据集压缩到一个新的特征子空间来减少数据的维度,因此我们只选择包含大部分信息(方差)的特征向量(主成分)的子集。特征值定义了特征向量的大小,因此我们需要按特征值的大小降序排序;我们关注的是基于其对应特征值的前k个特征向量。但在收集这k个最具信息量的特征向量之前,我们先绘制方差解释比例图。特征值的方差解释比例,,只是一个特征值,,与所有特征值总和的比值:

使用NumPy的cumsum函数,我们可以计算解释方差的累积和,然后通过Matplotlib的step函数绘制:

>>> tot = sum(eigen_vals)
>>> var_exp = [(i / tot) for i in
...            sorted(eigen_vals, reverse=True)]
>>> cum_var_exp = np.cumsum(var_exp)
>>> import matplotlib.pyplot as plt
>>> plt.bar(range(1,14), var_exp, alpha=0.5, align='center',
...         label='Individual explained variance')
>>> plt.step(range(1,14), cum_var_exp, where='mid',
...          label='Cumulative explained variance')
>>> plt.ylabel('Explained variance ratio')
>>> plt.xlabel('Principal component index')
>>> plt.legend(loc='best')
>>> plt.tight_layout()
>>> plt.show() 

结果图表显示,单独的第一个主成分约占总方差的40%。

同时,我们还可以看到,前两个主成分组合解释了数据集近60%的方差:

尽管解释方差图让我们想起了我们在第4章构建良好的训练数据集—数据预处理中通过随机森林计算的特征重要性值,但我们应该提醒自己,PCA是一种无监督方法,这意味着它忽略了类别标签的信息。而随机森林则使用类别成员信息来计算节点的杂质,方差则衡量了特征轴上值的分布。

特征转换

现在我们已经成功地将协方差矩阵分解为特征对,让我们继续进行最后三步,将Wine数据集变换到新的主成分轴上。接下来我们将处理的步骤如下:

  1. 选择 k 个特征向量,这些特征向量对应于 k 个最大特征值,其中 k 是新特征子空间的维度()。

  2. 从“前 k ”个特征向量构造投影矩阵 W

  3. 使用投影矩阵 Wd 维输入数据集 X 进行变换,以获得新的 k 维特征子空间。

或者,用更通俗的说法,我们将按特征值的降序排序特征对,从选定的特征向量构建投影矩阵,并使用投影矩阵将数据变换到低维子空间。

我们首先按特征值降序排列特征对:

>>> # Make a list of (eigenvalue, eigenvector) tuples
>>> eigen_pairs = [(np.abs(eigen_vals[i]), eigen_vecs[:, i])
...                for i in range(len(eigen_vals))]
>>> # Sort the (eigenvalue, eigenvector) tuples from high to low
>>> eigen_pairs.sort(key=lambda k: k[0], reverse=True) 

接下来,我们收集对应于两个最大特征值的两个特征向量,以捕捉数据集中约60%的方差。注意,选择了两个特征向量用于说明,因为稍后我们将在本小节中通过二维散点图绘制数据。实际上,主成分的数量需要通过计算效率与分类器性能之间的权衡来确定:

>>> w = np.hstack((eigen_pairs[0][1][:, np.newaxis],
...                eigen_pairs[1][1][:, np.newaxis]))
>>> print('Matrix W:\n', w)
Matrix W:
[[-0.13724218   0.50303478]
 [ 0.24724326   0.16487119]
 [-0.02545159   0.24456476]
 [ 0.20694508  -0.11352904]
 [-0.15436582   0.28974518]
 [-0.39376952   0.05080104]
 [-0.41735106  -0.02287338]
 [ 0.30572896   0.09048885]
 [-0.30668347   0.00835233]
 [ 0.07554066   0.54977581]
 [-0.32613263  -0.20716433]
 [-0.36861022  -0.24902536]
 [-0.29669651   0.38022942]] 

执行前面的代码后,我们已从前两个特征向量创建了一个 维的投影矩阵 W

镜像投影

根据你使用的NumPy和LAPACK版本,可能会得到带符号反转的矩阵 W。请注意,这不是问题;如果 v 是矩阵的特征向量 ,那么我们有:

这里,v 是特征向量,而 –v 也是特征向量,我们可以如下证明这一点。使用基本的代数,我们可以将方程两边同时乘以一个标量,

由于矩阵乘法对于标量乘法是结合律的,我们可以将其重新排列为以下形式:

现在,我们可以看到 是一个特征向量,具有相同的特征值 ,适用于 。因此,v–v 都是特征向量。

使用投影矩阵,我们现在可以将一个示例 x(表示为13维行向量)变换到PCA子空间(主成分一和二),得到 ,现在这是一个二维示例向量,由两个新特征组成:

>>> X_train_std[0].dot(w)
array([ 2.38299011,  0.45458499]) 

类似地,我们可以通过计算矩阵点积,将整个 维训练数据集变换到两个主成分上:

>>> X_train_pca = X_train_std.dot(w) 

最后,让我们将经过转换的 Wine 训练数据集(现在作为 维矩阵存储)在二维散点图中进行可视化:

>>> colors = ['r', 'b', 'g']
>>> markers = ['s', 'x', 'o']
>>> for l, c, m in zip(np.unique(y_train), colors, markers):
...     plt.scatter(X_train_pca[y_train==l, 0],
...                 X_train_pca[y_train==l, 1],
...                 c=c, label=l, marker=m)
>>> plt.xlabel('PC 1')
>>> plt.ylabel('PC 2')
>>> plt.legend(loc='lower left')
>>> plt.tight_layout()
>>> plt.show() 

如我们在结果图中所见,数据沿着 x 轴——第一主成分——比第二主成分(y 轴)分布得更广,这与我们在前一小节中创建的解释方差比率图一致。然而,我们可以看出,线性分类器很可能能够很好地分离这些类别:

尽管我们在前述散点图中对类别标签信息进行了编码以便于说明,但我们必须牢记,PCA 是一种无监督技术,不使用任何类别标签信息。

scikit-learn 中的主成分分析(PCA)

尽管前一小节中的详细方法帮助我们理解了 PCA 的内部工作原理,但现在我们将讨论如何使用 scikit-learn 中实现的 PCA 类。

PCA 类是 scikit-learn 中的另一个变换器类,我们首先使用训练数据拟合模型,然后使用相同的模型参数对训练数据和测试数据集进行变换。现在,让我们在 Wine 训练数据集上使用 scikit-learn 的 PCA 类,通过逻辑回归对转换后的样本进行分类,并通过我们在第2章中定义的 plot_decision_regions 函数可视化决策区域,训练简单的机器学习分类算法

from matplotlib.colors import ListedColormap
def plot_decision_regions(X, y, classifier, resolution=0.02):

    # setup marker generator and color map
    markers = ('s', 'x', 'o', '^', 'v')
    colors = ('red', 'blue', 'lightgreen', 'gray', 'cyan')
    cmap = ListedColormap(colors[:len(np.unique(y))])

    # plot the decision surface
    x1_min, x1_max = X[:, 0].min() - 1, X[:, 0].max() + 1
    x2_min, x2_max = X[:, 1].min() - 1, X[:, 1].max() + 1
    xx1, xx2 = np.meshgrid(np.arange(x1_min, x1_max, resolution),
                           np.arange(x2_min, x2_max, resolution))
    Z = classifier.predict(np.array([xx1.ravel(), xx2.ravel()]).T)
    Z = Z.reshape(xx1.shape)
    plt.contourf(xx1, xx2, Z, alpha=0.4, cmap=cmap)
    plt.xlim(xx1.min(), xx1.max())
    plt.ylim(xx2.min(), xx2.max())

    # plot examples by class
    for idx, cl in enumerate(np.unique(y)):
        plt.scatter(x=X[y == cl, 0],
                    y=X[y == cl, 1],
                    alpha=0.6,
                    color=cmap(idx),
                    edgecolor='black',
                    marker=markers[idx],
                    label=cl) 

为了方便起见,您可以将上述的 plot_decision_regions 代码放入当前工作目录中的单独代码文件中,例如 plot_decision_regions_script.py,并将其导入到当前的 Python 会话中。

>>> from sklearn.linear_model import LogisticRegression
>>> from sklearn.decomposition import PCA
>>> # initializing the PCA transformer and
>>> # logistic regression estimator:
>>> pca = PCA(n_components=2)
>>> lr = LogisticRegression(multi_class='ovr',
...                         random_state=1,
...                         solver='lbfgs')
>>> # dimensionality reduction:
>>> X_train_pca = pca.fit_transform(X_train_std)
>>> X_test_pca = pca.transform(X_test_std)
>>> # fitting the logistic regression model on the reduced dataset:
>>> lr.fit(X_train_pca, y_train)
>>> plot_decision_regions(X_train_pca, y_train, classifier=lr)
>>> plt.xlabel('PC 1')
>>> plt.ylabel('PC 2')
>>> plt.legend(loc='lower left')
>>> plt.tight_layout()
>>> plt.show() 

通过执行前述代码,我们现在应该能看到训练数据在两个主成分轴上的决策区域:

当我们将通过 scikit-learn 进行的 PCA 投影与我们自己实现的 PCA 进行比较时,结果图可能是彼此的镜像。请注意,这并不是由于这两种实现中的任何错误;这种差异的原因是,取决于特征求解器,特征向量可能具有负号或正号。

虽然这不影响结果,但如果我们希望,可以通过将数据乘以 –1 来简单地反转镜像;请注意,特征向量通常会被缩放到单位长度 1。为了完整性,让我们绘制经过转换的测试数据集上逻辑回归的决策区域,看看它是否能够很好地分离类别:

>>> plot_decision_regions(X_test_pca, y_test, classifier=lr)
>>> plt.xlabel('PC1')
>>> plt.ylabel('PC2')
>>> plt.legend(loc='lower left')
>>> plt.tight_layout()
>>> plt.show() 

在执行前述代码绘制测试数据集的决策区域后,我们可以看到,逻辑回归在这个小的二维特征子空间中表现得相当好,仅仅将测试数据集中的少数几个样本分类错误:

如果我们对不同主成分的解释方差比感兴趣,可以简单地将 PCA 类初始化,并将 n_components 参数设置为 None,这样所有主成分都会被保留,然后可以通过 explained_variance_ratio_ 属性访问解释方差比:

>>> pca = PCA(n_components=None)
>>> X_train_pca = pca.fit_transform(X_train_std)
>>> pca.explained_variance_ratio_
array([ 0.36951469, 0.18434927, 0.11815159, 0.07334252,
        0.06422108, 0.05051724, 0.03954654, 0.02643918,
        0.02389319, 0.01629614, 0.01380021, 0.01172226,
        0.00820609]) 

请注意,当我们初始化 PCA 类时,我们将 n_components=None,这样它将按排序顺序返回所有主成分,而不是进行降维。

通过线性判别分析进行的监督式数据压缩

LDA 可以作为一种特征提取技术,用于提高计算效率并减少由于高维数据导致的过拟合程度,尤其是在非正则化模型中。LDA 的基本概念与 PCA 非常相似,但 PCA 旨在寻找数据集中最大方差的正交分量轴,而 LDA 的目标是找到优化类间可分性的特征子空间。在接下来的部分中,我们将更详细地讨论 LDA 和 PCA 之间的相似性,并逐步讲解 LDA 方法。

主成分分析与线性判别分析

PCA 和 LDA 都是可以用于减少数据集维度的线性变换技术;前者是无监督算法,而后者是监督算法。因此,我们可能认为 LDA 在分类任务中是优于 PCA 的特征提取技术。然而,A.M. Martinez 报告称,在某些情况下,通过 PCA 进行预处理会导致更好的分类结果,尤其是在图像识别任务中,例如当每个类仅包含少量示例时(PCA 与 LDAA. M. MartinezA. C. KakIEEE Transactions on Pattern Analysis and Machine Intelligence,23(2): 228-233,2001)。

Fisher LDA

LDA 有时也被称为 Fisher 的 LDA。Ronald A. Fisher 于 1936 年首次为二类分类问题提出了 Fisher 线性判别The Use of Multiple Measurements in Taxonomic ProblemsR. A. FisherAnnals of Eugenics,7(2): 179-188,1936)。Fisher 的线性判别后来由 C. Radhakrishna Rao 在 1948 年针对多类问题进行了推广,假设类内协方差相等且各类呈正态分布,这一方法我们现在称为 LDA(The Utilization of Multiple Measurements in Problems of Biological ClassificationC. R. RaoJournal of the Royal Statistical Society,Series B(Methodological),10(2): 159-203,1948)。

下图总结了用于二类问题的 LDA 概念。类 1 的示例用圆圈表示,类 2 的示例用叉号表示:

x轴所示的线性判别(LD 1),能够很好地分开两个正态分布的类别。尽管如y轴所示的示例线性判别(LD 2)捕获了数据集中的大量方差,但由于它没有捕捉到任何类别区分信息,因此不能作为有效的线性判别。

LDA的一个假设是数据呈正态分布。此外,我们假设类别具有相同的协方差矩阵,并且训练样本彼此统计独立。然而,即使这些假设中的一个或多个被(稍微)违反,LDA作为降维方法仍然可以合理有效地工作(Pattern Classification 第二版R. O. DudaP. E. Hart,和D. G. Stork纽约2001)。

线性判别分析的内部工作原理

在深入代码实现之前,让我们简要总结一下执行LDA所需的主要步骤:

  1. 标准化d维数据集(d是特征的数量)。

  2. 对于每个类别,计算d维均值向量。

  3. 构建类间散布矩阵和类内散布矩阵

  4. 计算矩阵的特征向量及其对应的特征值。

  5. 按降序排列特征值,以对应的特征向量进行排名。

  6. 选择与前k个最大特征值对应的k个特征向量,以构建一个维的转换矩阵W;特征向量是该矩阵的列。

  7. 使用转换矩阵W将样本投影到新的特征子空间。

如我们所见,LDA与PCA非常相似,因为我们将矩阵分解为特征值和特征向量,这些将形成新的低维特征空间。然而,正如前面提到的,LDA考虑了类别标签信息,这些信息以第2步中计算的均值向量的形式表示。在接下来的章节中,我们将更详细地讨论这七个步骤,并提供示例代码实现。

计算散布矩阵

由于我们在本章开头的PCA部分已经对Wine数据集的特征进行了标准化,因此我们可以跳过第一步,直接计算均值向量,然后使用这些均值向量分别构建类内散布矩阵和类间散布矩阵。每个均值向量存储了类别i样本的均值特征值

这将产生三个均值向量:

>>> np.set_printoptions(precision=4)
>>> mean_vecs = []
>>> for label in range(1,4):
...     mean_vecs.append(np.mean(
...                X_train_std[y_train==label], axis=0))
...     print('MV %s: %s\n' %(label, mean_vecs[label-1]))
MV 1: [ 0.9066  -0.3497  0.3201  -0.7189  0.5056  0.8807  0.9589  -0.5516
0.5416  0.2338  0.5897  0.6563  1.2075]
MV 2: [-0.8749  -0.2848  -0.3735  0.3157  -0.3848  -0.0433  0.0635  -0.0946
0.0703  -0.8286  0.3144  0.3608  -0.7253]
MV 3: [ 0.1992  0.866  0.1682  0.4148  -0.0451  -1.0286  -1.2876  0.8287
-0.7795  0.9649  -1.209  -1.3622  -0.4013] 

使用均值向量,我们现在可以计算类内散布矩阵

这是通过对每个类i的个别散布矩阵求和来计算的:

>>> d = 13 # number of features
>>> S_W = np.zeros((d, d))
>>> for label, mv in zip(range(1, 4), mean_vecs):
...     class_scatter = np.zeros((d, d))
>>> for row in X_train_std[y_train == label]:
...     row, mv = row.reshape(d, 1), mv.reshape(d, 1)
...     class_scatter += (row - mv).dot((row - mv).T)
...     S_W += class_scatter
>>> print('Within-class scatter matrix: %sx%s' % (
...       S_W.shape[0], S_W.shape[1]))
Within-class scatter matrix: 13x13 

我们在计算散布矩阵时所做的假设是训练数据集中的类别标签是均匀分布的。然而,如果我们打印类别标签的数量,会发现这一假设被违背了:

>>> print('Class label distribution: %s'
...       % np.bincount(y_train)[1:])
Class label distribution: [41 50 33] 

因此,我们希望在将个别散布矩阵相加形成散布矩阵之前,先对其进行缩放。当我们通过类别样本数量来除以散布矩阵时,我们可以看到,计算散布矩阵实际上与计算协方差矩阵是一样的—协方差矩阵是散布矩阵的标准化版本:

计算缩放后的类内散布矩阵的代码如下:

>>> d = 13 # number of features
>>> S_W = np.zeros((d, d))
>>> for label,mv in zip(range(1, 4), mean_vecs):
...     class_scatter = np.cov(X_train_std[y_train==label].T)
...     S_W += class_scatter
>>> print('Scaled within-class scatter matrix: %sx%s'
...      % (S_W.shape[0], S_W.shape[1]))
Scaled within-class scatter matrix: 13x13 

在计算完缩放后的类内散布矩阵(或协方差矩阵)后,我们可以进入下一步,计算类间散布矩阵

这里,m是计算得到的总体均值,包含来自所有c类别的样本:

>>> mean_overall = np.mean(X_train_std, axis=0)
>>> d = 13 # number of features
>>> S_B = np.zeros((d, d))
>>> for i, mean_vec in enumerate(mean_vecs):
...     n = X_train_std[y_train == i + 1, :].shape[0]
...     mean_vec = mean_vec.reshape(d, 1) # make column vector
...     mean_overall = mean_overall.reshape(d, 1)
...     S_B += n * (mean_vec - mean_overall).dot(
...     (mean_vec - mean_overall).T)
>>> print('Between-class scatter matrix: %sx%s' % (
...                S_B.shape[0], S_B.shape[1]))
Between-class scatter matrix: 13x13 

为新的特征子空间选择线性判别

LDA的其余步骤与PCA的步骤类似。然而,代替对协方差矩阵进行特征分解,我们需要解矩阵的广义特征值问题:

>>> eigen_vals, eigen_vecs =\
...     np.linalg.eig(np.linalg.inv(S_W).dot(S_B)) 

在计算特征对后,我们可以按降序排列特征值:

>>> eigen_pairs = [(np.abs(eigen_vals[i]), eigen_vecs[:,i])
...                for i in range(len(eigen_vals))]
>>> eigen_pairs = sorted(eigen_pairs,
...               key=lambda k: k[0], reverse=True)
>>> print('Eigenvalues in descending order:\n')
>>> for eigen_val in eigen_pairs:
...     print(eigen_val[0])
Eigenvalues in descending order:
349.617808906
172.76152219
3.78531345125e-14
2.11739844822e-14
1.51646188942e-14
1.51646188942e-14
1.35795671405e-14
1.35795671405e-14
7.58776037165e-15
5.90603998447e-15
5.90603998447e-15
2.25644197857e-15
0.0 

在LDA中,线性判别数最多为c−1,其中c是类别标签的数量,因为类间散布矩阵c个秩为1或更小的矩阵的总和。我们确实可以看到,只有两个非零特征值(特征值3-13并不完全为零,但这是由于NumPy中的浮点运算造成的)。

共线性

请注意,在完美共线性(所有对齐的样本点都位于一条直线上)的稀有情况下,协方差矩阵的秩为1,这将导致只有一个特征向量具有非零特征值。

为了衡量线性判别特征向量(特征向量)捕捉到的类别区分信息的多少,让我们绘制按特征值降序排列的线性判别特征向量图,类似于我们在PCA部分创建的解释方差图。为了简化,我们将类别区分信息的内容称为判别度

>>> tot = sum(eigen_vals.real)
>>> discr = [(i / tot) for i in sorted(eigen_vals.real, reverse=True)]
>>> cum_discr = np.cumsum(discr)
>>> plt.bar(range(1, 14), discr, alpha=0.5, align='center',
...         label='Individual "discriminability"')
>>> plt.step(range(1, 14), cum_discr, where='mid',
...          label='Cumulative "discriminability"')
>>> plt.ylabel('"Discriminability" ratio')
>>> plt.xlabel('Linear Discriminants')
>>> plt.ylim([-0.1, 1.1])
>>> plt.legend(loc='best')
>>> plt.tight_layout()
>>> plt.show() 

正如我们在结果图中看到的,前两个线性判别特征向量就能捕捉到Wine训练数据集中的100%有用信息:

现在,让我们将两个最具判别性的特征向量列堆叠起来,形成变换矩阵W

>>> w = np.hstack((eigen_pairs[0][1][:, np.newaxis].real,
...                eigen_pairs[1][1][:, np.newaxis].real))
>>> print('Matrix W:\n', w)
Matrix W:
 [[-0.1481  -0.4092]
  [ 0.0908  -0.1577]
  [-0.0168  -0.3537]
  [ 0.1484   0.3223]
  [-0.0163  -0.0817]
  [ 0.1913   0.0842]
  [-0.7338   0.2823]
  [-0.075   -0.0102]
  [ 0.0018   0.0907]
  [ 0.294   -0.2152]
  [-0.0328   0.2747]
  [-0.3547  -0.0124]
  [-0.3915  -0.5958]] 

将样本投影到新的特征空间

使用我们在前一小节中创建的变换矩阵 W,我们现在可以通过矩阵相乘来转换训练数据集:

>>> X_train_lda = X_train_std.dot(w)
>>> colors = ['r', 'b', 'g']
>>> markers = ['s', 'x', 'o']
>>> for l, c, m in zip(np.unique(y_train), colors, markers):
...     plt.scatter(X_train_lda[y_train==l, 0],
...                 X_train_lda[y_train==l, 1] * (-1),
...                 c=c, label=l, marker=m)
>>> plt.xlabel('LD 1')
>>> plt.ylabel('LD 2')
>>> plt.legend(loc='lower right')
>>> plt.tight_layout()
>>> plt.show() 

从结果图中我们可以看到,三类Wine数据现在在新的特征子空间中变得完全线性可分:

通过scikit-learn实现LDA

这一步步的实现是理解LDA内部工作原理的好练习,并且有助于理解LDA与PCA之间的区别。现在,让我们来看看在scikit-learn中实现的LDA类:

>>> # the following import statement is one line
>>> from sklearn.discriminant_analysis import LinearDiscriminantAnalysis as LDA
>>> lda = LDA(n_components=2)
>>> X_train_lda = lda.fit_transform(X_train_std, y_train) 

接下来,让我们看看逻辑回归分类器在经过LDA变换后的低维训练数据集上的表现:

>>> lr = LogisticRegression(multi_class='ovr', random_state=1,
...                         solver='lbfgs')
>>> lr = lr.fit(X_train_lda, y_train)
>>> plot_decision_regions(X_train_lda, y_train, classifier=lr)
>>> plt.xlabel('LD 1')
>>> plt.ylabel('LD 2')
>>> plt.legend(loc='lower left')
>>> plt.tight_layout()
>>> plt.show() 

从结果图中我们可以看到,逻辑回归模型错误地分类了来自类别2的一个样本:

通过降低正则化强度,我们可能会将决策边界调整到一个位置,使得逻辑回归模型能够正确分类训练数据集中的所有样本。然而,更重要的是,让我们来看看测试数据集上的结果:

>>> X_test_lda = lda.transform(X_test_std)
>>> plot_decision_regions(X_test_lda, y_test, classifier=lr)
>>> plt.xlabel('LD 1')
>>> plt.ylabel('LD 2')
>>> plt.legend(loc='lower left')
>>> plt.tight_layout()
>>> plt.show() 

如下图所示,逻辑回归分类器仅使用二维特征子空间,而不是原始的13个Wine特征,便能够完美地分类测试数据集中的样本,获得了完美的准确率:

使用核主成分分析进行非线性映射

许多机器学习算法都假设输入数据是线性可分的。你已经学习过感知机甚至需要完全线性可分的训练数据才能收敛。我们迄今为止介绍的其他算法假设缺乏完美的线性可分性是由噪声造成的:例如Adaline、逻辑回归和(标准)SVM等。

然而,如果我们处理的是非线性问题——这类问题在实际应用中可能非常常见——那么像PCA和LDA这样的线性降维技术可能不是最佳选择。

在这一部分,我们将介绍一个核化版本的主成分分析(PCA),或者称为KPCA,它与第3章中你将会记得的核支持向量机(SVM)概念相关。通过使用KPCA,我们将学习如何将非线性可分的数据转换到一个新的低维子空间,从而使得线性分类器适用。

核函数和核技巧

正如你会记得我们在第3章中讨论的关于核支持向量机(SVM)的内容,我们可以通过将非线性问题投影到一个新的高维特征空间来解决此类问题,在这个空间中,类别变得线性可分。为了将样本 转换到这个更高的 k 维子空间,我们定义了一个非线性映射函数,

我们可以将 看作一个函数,用来创建原始特征的非线性组合,将原始的 d 维数据集映射到更大的 k 维特征空间。

例如,如果我们有一个特征向量 x 是一个由 d 个特征组成的列向量),其维度为 2(d = 2),那么映射到 3D 空间的可能形式为:

换句话说,我们通过KPCA执行非线性映射,将数据转换到更高维空间。然后,我们在这个高维空间中使用标准PCA,将数据投影回低维空间,在该空间中,样本可以通过线性分类器分离(前提是样本在输入空间中可以通过密度分离)。然而,这种方法的一个缺点是计算开销非常大,这就是我们使用核技巧的地方。通过使用核技巧,我们可以在原始特征空间中计算两个高维特征向量之间的相似度。

在我们继续深入讨论用于解决计算密集型问题的核技巧之前,先回顾一下本章开始时实现的标准PCA方法。我们计算了两个特征,kj,之间的协方差,如下所示:

由于特征的标准化使它们在均值为零的地方居中,例如,,我们可以将此方程简化如下:

请注意,前述方程指的是两个特征之间的协方差;现在,我们来写出计算协方差矩阵的通用方程,

Bernhard Scholkopf 将这一方法进行了推广(核主成分分析B. ScholkopfA. Smola,和 K.R. Muller,第583-588页,1997),使得我们可以通过 用非线性特征组合替代原始特征空间中样本之间的点积:

为了从这个协方差矩阵中获得特征向量——主成分,我们必须解以下方程:

在这里,v 是协方差矩阵的特征值和特征向量,a 可以通过提取核(相似性)矩阵 K 的特征向量得到,正如你将在接下来的段落中看到的。

推导核矩阵

核矩阵的推导可以如下所示。首先,我们将协方差矩阵写成矩阵表示形式,其中 是一个 维的矩阵:

现在,我们可以将特征向量方程写成如下形式:

由于 ,我们得到:

将其左右两边同时乘以得到以下结果:

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

正如你可能记得的那样,在第3章《使用scikit-learn进行机器学习分类器巡礼》的使用核SVM解决非线性问题一节中,我们通过使用核函数来避免显式计算示例x之间的成对点积,x*在下的核技巧,免去了显式计算特征向量:

换句话说,经过KPCA处理后,我们得到的是已经投影到相应成分上的示例,而不是像标准PCA方法那样构造一个变换矩阵。基本上,核函数(或简称核)可以理解为一个计算两个向量点积的函数——即相似度的度量。

最常用的核函数如下:

  • 多项式核函数:

    这里,是阈值,p是需要用户指定的幂值。

  • 双曲正切(Sigmoid)核函数:

  • 径向基函数RBF)或高斯核,我们将在接下来的小节中使用该核函数的示例:

    它通常写成以下形式,引入变量

为了总结我们到目前为止的学习内容,我们可以定义以下三个步骤来实现RBF KPCA:

  1. 我们计算核(相似度)矩阵,K,需要计算以下内容:

    我们对每一对示例都这样做:

    例如,如果我们的数据集包含100个训练示例,那么成对相似度的对称核矩阵将是维的。

  2. 我们使用以下公式对核矩阵进行中心化,K

    这里,是一个维的矩阵(与核矩阵的维度相同),其中所有值都等于

  3. 我们根据对应的特征值收集中心化核矩阵的前k个特征向量,这些特征值按降序排列。与标准PCA不同,特征向量不是主成分轴,而是已经投影到这些轴上的示例。

此时,您可能会想知道为什么在第二步中我们需要对核矩阵进行中心化。我们之前假设我们正在处理标准化数据,在我们构造协方差矩阵并通过用非线性特征组合替代点积时,所有特征的均值为零。因此,在第二步中对核矩阵进行中心化是必要的,因为我们并未显式计算新的特征空间,因此无法保证新的特征空间也以零为中心。

在下一节中,我们将通过在Python中实现KPCA来将这三步付诸实践。

在Python中实现核主成分分析

在上一小节中,我们讨论了KPCA背后的核心概念。现在,我们将按照总结KPCA方法的三个步骤,在Python中实现一个RBF KPCA。通过使用一些SciPy和NumPy辅助函数,我们会看到实现一个KPCA实际上非常简单:

from scipy.spatial.distance import pdist, squareform
from scipy import exp
from scipy.linalg import eigh
import numpy as np
def rbf_kernel_pca(X, gamma, n_components):
    """
    RBF kernel PCA implementation.

    Parameters
    ------------
    X: {NumPy ndarray}, shape = [n_examples, n_features]

    gamma: float
        Tuning parameter of the RBF kernel

    n_components: int
        Number of principal components to return

    Returns
    ------------
    X_pc: {NumPy ndarray}, shape = [n_examples, k_features]
        Projected dataset

    """
    # Calculate pairwise squared Euclidean distances
    # in the MxN dimensional dataset.
    sq_dists = pdist(X, 'sqeuclidean')

    # Convert pairwise distances into a square matrix.
    mat_sq_dists = squareform(sq_dists)

    # Compute the symmetric kernel matrix.
    K = exp(-gamma * mat_sq_dists)

    # Center the kernel matrix.
    N = K.shape[0]
    one_n = np.ones((N,N)) / N
    K = K - one_n.dot(K) - K.dot(one_n) + one_n.dot(K).dot(one_n)

    # Obtaining eigenpairs from the centered kernel matrix
    # scipy.linalg.eigh returns them in ascending order
    eigvals, eigvecs = eigh(K)
    eigvals, eigvecs = eigvals[::-1], eigvecs[:, ::-1]

    # Collect the top k eigenvectors (projected examples)
    X_pc = np.column_stack([eigvecs[:, i]
                           for i in range(n_components)])

    return X_pc 

使用RBF KPCA进行降维的一个缺点是我们必须事先指定参数。找到适当的值需要实验,最好使用参数调优算法来完成,例如执行网格搜索,我们将在第6章模型评估与超参数调优的最佳实践中更详细地讨论这个问题。

示例1 – 分离半月形状

现在,让我们在一些非线性示例数据集上应用我们的rbf_kernel_pca。我们将首先创建一个包含100个示例点的二维数据集,表示两个半月形状:

>>> from sklearn.datasets import make_moons
>>> X, y = make_moons(n_samples=100, random_state=123)
>>> plt.scatter(X[y==0, 0], X[y==0, 1],
...             color='red', marker='^', alpha=0.5)
>>> plt.scatter(X[y==1, 0], X[y==1, 1],
...             color='blue', marker='o', alpha=0.5)
>>> plt.tight_layout()
>>> plt.show() 

为了便于说明,三角符号表示一个类的半月形状,而圆形符号表示另一个类的示例:

很明显,这两个半月形状是不可线性分隔的,我们的目标是通过KPCA 展开半月形状,使得数据集可以作为线性分类器的合适输入。但首先,让我们看看如果我们通过标准PCA将数据投影到主成分上,数据集会是什么样子:

>>> from sklearn.decomposition import PCA
>>> scikit_pca = PCA(n_components=2)
>>> X_spca = scikit_pca.fit_transform(X)
>>> fig, ax = plt.subplots(nrows=1, ncols=2, figsize=(7,3))
>>> ax[0].scatter(X_spca[y==0, 0], X_spca[y==0, 1],
...               color='red', marker='^', alpha=0.5)
>>> ax[0].scatter(X_spca[y==1, 0], X_spca[y==1, 1],
...               color='blue', marker='o', alpha=0.5)
>>> ax[1].scatter(X_spca[y==0, 0], np.zeros((50,1))+0.02,
...               color='red', marker='^', alpha=0.5)
>>> ax[1].scatter(X_spca[y==1, 0], np.zeros((50,1))-0.02,
...               color='blue', marker='o', alpha=0.5)
>>> ax[0].set_xlabel('PC1')
>>> ax[0].set_ylabel('PC2')
>>> ax[1].set_ylim([-1, 1])
>>> ax[1].set_yticks([])
>>> ax[1].set_xlabel('PC1')
>>> plt.tight_layout()
>>> plt.show() 

很明显,在得到的图形中,我们可以看到线性分类器无法在通过标准PCA转换后的数据集上表现良好:

请注意,当我们仅绘制第一个主成分时(右侧子图),我们稍微将三角形示例向上移动,将圆形示例向下移动,以更好地可视化类别重叠。正如左侧子图所示,原始的半月形状仅略微发生了剪切,并且沿垂直中心翻转——这种转换对于线性分类器在区分圆形和三角形之间并无帮助。类似地,如果我们将数据集投影到一维特征轴上,正如右侧子图所示,代表两种半月形状的圆形和三角形也不能线性分开。

PCA 与 LDA

请记住,PCA 是一种无监督方法,不使用类别标签信息来最大化方差,而与 LDA 相反。这里,三角形和圆形符号仅用于可视化目的,以表示分离程度。

现在,让我们尝试一下我们在上一小节中实现的内核 PCA 函数rbf_kernel_pca

>>> X_kpca = rbf_kernel_pca(X, gamma=15, n_components=2)
>>> fig, ax = plt.subplots(nrows=1, ncols=2, figsize=(7, 3))
>>> ax[0].scatter(X_kpca[y==0, 0], X_kpca[y==0, 1],
...               color='red', marker='^', alpha=0.5)
>>> ax[0].scatter(X_kpca[y==1, 0], X_kpca[y==1, 1],
...               color='blue', marker='o', alpha=0.5)
>>> ax[1].scatter(X_kpca[y==0, 0], np.zeros((50,1))+0.02,
...               color='red', marker='^', alpha=0.5)
>>> ax[1].scatter(X_kpca[y==1, 0], np.zeros((50,1))-0.02,
...               color='blue', marker='o', alpha=0.5)
>>> ax[0].set_xlabel('PC1')
>>> ax[0].set_ylabel('PC2')
>>> ax[1].set_ylim([-1, 1])
>>> ax[1].set_yticks([])
>>> ax[1].set_xlabel('PC1')
>>> plt.tight_layout()
>>> plt.show() 

我们现在可以看到,这两个类别(圆形和三角形)已经线性分开,因此我们得到了一个适合线性分类器的训练数据集:

不幸的是,对于不同的数据集,调节参数!并没有一个通用的最佳值。找到一个适合特定问题的!值需要进行实验。在第六章模型评估与超参数调优的最佳实践中,我们将讨论一些有助于自动化优化这些调节参数的技术。这里,我们将使用已知能产生良好结果的!值。

示例 2 – 分离同心圆

在上一小节中,我们已经看到如何通过 KPCA 分离半月形状。既然我们在理解 KPCA 的概念上付出了很多努力,让我们来看看另一个有趣的非线性问题示例,同心圆:

>>> from sklearn.datasets import make_circles
>>> X, y = make_circles(n_samples=1000,
...                     random_state=123, noise=0.1,
...                     factor=0.2)
>>> plt.scatter(X[y == 0, 0], X[y == 0, 1],
...             color='red', marker='^', alpha=0.5)
>>> plt.scatter(X[y == 1, 0], X[y == 1, 1],
...             color='blue', marker='o', alpha=0.5)
>>> plt.tight_layout()
>>> plt.show() 

再次,我们假设这是一个二分类问题,其中三角形代表一个类别,圆形代表另一个类别:

我们从标准 PCA 方法开始,将其与 RBF 内核 PCA 的结果进行比较:

>>> scikit_pca = PCA(n_components=2)
>>> X_spca = scikit_pca.fit_transform(X)
>>> fig, ax = plt.subplots(nrows=1, ncols=2, figsize=(7,3))
>>> ax[0].scatter(X_spca[y==0, 0], X_spca[y==0, 1],
...               color='red', marker='^', alpha=0.5)
>>> ax[0].scatter(X_spca[y==1, 0], X_spca[y==1, 1],
...               color='blue', marker='o', alpha=0.5)
>>> ax[1].scatter(X_spca[y==0, 0], np.zeros((500,1))+0.02,
...               color='red', marker='^', alpha=0.5)
>>> ax[1].scatter(X_spca[y==1, 0], np.zeros((500,1))-0.02,
...               color='blue', marker='o', alpha=0.5)
>>> ax[0].set_xlabel('PC1')
>>> ax[0].set_ylabel('PC2')
>>> ax[1].set_ylim([-1, 1])
>>> ax[1].set_yticks([])
>>> ax[1].set_xlabel('PC1')
>>> plt.tight_layout()
>>> plt.show() 

再次,我们可以看到标准 PCA 无法产生适合训练线性分类器的结果:

给定一个合适的!值,让我们看看使用 RBF KPCA 实现是否更有运气:

>>> X_kpca = rbf_kernel_pca(X, gamma=15, n_components=2)
>>> fig, ax = plt.subplots(nrows=1,ncols=2, figsize=(7,3))
>>> ax[0].scatter(X_kpca[y==0, 0], X_kpca[y==0, 1],
...               color='red', marker='^', alpha=0.5)
>>> ax[0].scatter(X_kpca[y==1, 0], X_kpca[y==1, 1],
...               color='blue', marker='o', alpha=0.5)
>>> ax[1].scatter(X_kpca[y==0, 0], np.zeros((500,1))+0.02,
...               color='red', marker='^', alpha=0.5)
>>> ax[1].scatter(X_kpca[y==1, 0], np.zeros((500,1))-0.02,
...               color='blue', marker='o', alpha=0.5)
>>> ax[0].set_xlabel('PC1')
>>> ax[0].set_ylabel('PC2')
>>> ax[1].set_ylim([-1, 1])
>>> ax[1].set_yticks([])
>>> ax[1].set_xlabel('PC1')
>>> plt.tight_layout()
>>> plt.show() 

再次,RBF KPCA 将数据投影到一个新的子空间中,在该空间中,两个类别变得线性可分:

投影新的数据点

在之前两个应用KPCA的示例中,半月形和同心圆形,我们将单一数据集投影到一个新的特征上。然而,在实际应用中,我们可能会有多个数据集需要转换,例如训练数据和测试数据,通常还包括我们在模型构建和评估后收集的新示例。在本节中,你将学习如何投影那些不属于训练数据集的数据点。

正如你从本章开始的标准PCA方法中所记得的那样,我们通过计算变换矩阵与输入示例之间的点积来进行数据投影;投影矩阵的列是我们从协方差矩阵中获得的前k个特征向量(v)。

现在,问题是我们如何将这一概念转移到KPCA中。如果回想一下KPCA背后的理念,我们会记得我们得到的是中心化核矩阵的特征向量(a),而不是协方差矩阵的特征向量,这意味着这些已经被投影到主成分轴上的示例是* v *。因此,如果我们想要将新的示例,,投影到这个主成分轴上,我们需要计算以下内容:

幸运的是,我们可以使用核技巧,这样就不需要显式地计算投影,。然而,值得注意的是,KPCA与标准PCA不同,它是一种基于记忆的方法,这意味着我们必须每次都重用原始训练数据集来投影新的示例

我们必须计算训练数据集中每个第i个示例与新示例之间的成对RBF核(相似性),

在这里,核矩阵的特征向量,a,和特征值,,满足方程中的以下条件:

在计算新示例与训练数据集中示例之间的相似性之后,我们需要通过其特征值来归一化特征向量,a。因此,让我们修改之前实现的rbf_kernel_pca函数,使其还返回核矩阵的特征值:

from scipy.spatial.distance import pdist, squareform
from scipy import exp
from scipy.linalg import eigh
import numpy as np
def rbf_kernel_pca(X, gamma, n_components):
    """
    RBF kernel PCA implementation.

    Parameters
    ------------
    X: {NumPy ndarray}, shape = [n_examples, n_features]

    gamma: float
        Tuning parameter of the RBF kernel

    n_components: int
        Number of principal components to return

    Returns
    ------------
    alphas {NumPy ndarray}, shape = [n_examples, k_features]
        Projected dataset

    lambdas: list
        Eigenvalues

    """
    # Calculate pairwise squared Euclidean distances
    # in the MxN dimensional dataset.
    sq_dists = pdist(X, 'sqeuclidean')

    # Convert pairwise distances into a square matrix.
    mat_sq_dists = squareform(sq_dists)

    # Compute the symmetric kernel matrix.
    K = exp(-gamma * mat_sq_dists)

    # Center the kernel matrix.
    N = K.shape[0]
    one_n = np.ones((N,N)) / N
    K = K - one_n.dot(K) - K.dot(one_n) + one_n.dot(K).dot(one_n)

    # Obtaining eigenpairs from the centered kernel matrix
    # scipy.linalg.eigh returns them in ascending order
    eigvals, eigvecs = eigh(K)
    eigvals, eigvecs = eigvals[::-1], eigvecs[:, ::-1]

    # Collect the top k eigenvectors (projected examples)
    alphas = np.column_stack([eigvecs[:, i]
                             for i in range(n_components)])

    # Collect the corresponding eigenvalues
    lambdas = [eigvals[i] for i in range(n_components)]
    return alphas, lambdas 

现在,让我们创建一个新的半月形数据集,并使用更新后的RBF KPCA实现将其投影到一维子空间中:

>>> X, y = make_moons(n_samples=100, random_state=123)
>>> alphas, lambdas = rbf_kernel_pca(X, gamma=15, n_components=1) 

为了确保我们已经实现了投影新示例的代码,假设半月形数据集中的第26个点是一个新的数据点,,我们的任务是将其投影到这个新的子空间中:

>>> x_new = X[25]
>>> x_new
array([ 1.8713187 ,  0.00928245])
>>> x_proj = alphas[25] # original projection
>>> x_proj
array([ 0.07877284])
>>> def project_x(x_new, X, gamma, alphas, lambdas):
...     pair_dist = np.array([np.sum(
...                 (x_new-row)**2) for row in X])
...     k = np.exp(-gamma * pair_dist)
...     return k.dot(alphas / lambdas) 

通过执行以下代码,我们可以重现原始的投影。使用project_x函数,我们也能够投影任何新的数据示例。代码如下:

>>> x_reproj = project_x(x_new, X,
...            gamma=15, alphas=alphas,
...            lambdas=lambdas)
>>> x_reproj
array([ 0.07877284]) 

最后,让我们可视化在第一个主成分上的投影:

>>> plt.scatter(alphas[y==0, 0], np.zeros((50)),
...             color='red', marker='^',alpha=0.5)
>>> plt.scatter(alphas[y==1, 0], np.zeros((50)),
...             color='blue', marker='o', alpha=0.5)
>>> plt.scatter(x_proj, 0, color='black',
...             label='Original projection of point X[25]',
...             marker='^', s=100)
>>> plt.scatter(x_reproj, 0, color='green',
...             label='Remapped point X[25]',
...             marker='x', s=500)
>>> plt.yticks([], [])
>>> plt.legend(scatterpoints=1)
>>> plt.tight_layout()
>>> plt.show() 

正如我们在下面的散点图中看到的那样,我们将示例数据, ,正确地映射到了第一个主成分上:

scikit-learn中的核主成分分析

为了方便,我们可以使用scikit-learn在sklearn.decomposition子模块中实现的KPCA类。它的使用方法类似于标准的PCA类,我们可以通过kernel参数指定核函数:

>>> from sklearn.decomposition import KernelPCA
>>> X, y = make_moons(n_samples=100, random_state=123)
>>> scikit_kpca = KernelPCA(n_components=2,
...               kernel='rbf', gamma=15)
>>> X_skernpca = scikit_kpca.fit_transform(X) 

为了检查我们的结果是否与自己的KPCA实现一致,让我们将变换后的半月形数据绘制到前两个主成分上:

>>> plt.scatter(X_skernpca[y==0, 0], X_skernpca[y==0, 1],
...             color='red', marker='^', alpha=0.5)
>>> plt.scatter(X_skernpca[y==1, 0], X_skernpca[y==1, 1],
...             color='blue', marker='o', alpha=0.5)
>>> plt.xlabel('PC1')
>>> plt.ylabel('PC2')
>>> plt.tight_layout()
>>> plt.show() 

正如我们所看到的,scikit-learn的KernelPCA结果与我们自己的实现一致:

流形学习

scikit-learn库还实现了超出本书范围的非线性降维技术。感兴趣的读者可以在http://scikit-learn.org/stable/modules/manifold.html上找到一个有关当前scikit-learn实现的良好概述,并附有示例。

总结

在这一章节中,你学习了三种不同的、基础的特征提取降维技术:标准PCA、LDA和KPCA。通过PCA,我们将数据投影到低维子空间中,以最大化沿正交特征轴的方差,同时忽略类标签。与PCA不同,LDA是一种监督学习的降维技术,它考虑了训练数据集中的类别信息,旨在尽可能最大化类在特征空间中的可分性。

最后,你了解了一个非线性特征提取器——KPCA。通过核技巧和临时投影到更高维的特征空间,你最终能够将包含非线性特征的数据集压缩到一个低维子空间,在这个子空间中,类变得线性可分。

凭借这些基本的预处理技术,你现在已经为接下来章节中学习如何高效地整合不同的预处理技术,并评估不同模型的性能做好了充分准备。

第六章:学习模型评估和超参数调优的最佳实践

在前几章中,你学习了用于分类的基本机器学习算法,并且了解了在将数据输入算法之前,如何将数据整理成适合的格式。现在,是时候学习通过微调算法和评估模型性能来构建优秀机器学习模型的最佳实践了。在本章中,我们将学习如何完成以下任务:

  • 评估机器学习模型的性能

  • 诊断机器学习算法中的常见问题

  • 微调机器学习模型

  • 使用不同的性能指标评估预测模型

使用管道简化工作流

当我们在前几章应用不同的预处理技术时,如在第4章构建良好的训练数据集 – 数据预处理中进行的特征缩放标准化,或在第5章通过降维压缩数据中进行的主成分分析(PCA),你已经学到,我们必须重用在训练数据拟合过程中获得的参数来缩放和压缩任何新的数据,例如在单独的测试数据集中出现的示例。在本节中,你将学习一个非常实用的工具,scikit-learn中的Pipeline类。它允许我们拟合一个模型,其中包括任意数量的转换步骤,并将其应用于对新数据进行预测。

加载乳腺癌威斯康星数据集

在本章中,我们将使用乳腺癌威斯康星数据集,该数据集包含569个恶性和良性肿瘤细胞的示例。数据集中的前两列分别存储示例的唯一ID号和相应的诊断(M = 恶性,B = 良性)。第3至第32列包含30个实值特征,这些特征是通过数字化细胞核图像计算得到的,可以用来构建模型预测肿瘤是良性还是恶性。乳腺癌威斯康星数据集已经被存储在UCI机器学习库中,关于该数据集的更多详细信息可以在https://archive.ics.uci.edu/ml/datasets/Breast+Cancer+Wisconsin+(Diagnostic)找到。

获取乳腺癌威斯康星数据集

你可以在本书的代码包中找到数据集的副本(以及本书中使用的所有其他数据集),如果你在离线工作,或者UCI服务器https://archive.ics.uci.edu/ml/machine-learning-databases/breast-cancer-wisconsin/wdbc.data暂时无法访问,可以使用这些副本。例如,要从本地目录加载数据集,你可以替换以下几行:

df = pd.read_csv(
     'https://archive.ics.uci.edu/ml/'
     'machine-learning-databases'
     '/breast-cancer-wisconsin/wdbc.data',
     header=None) 

与以下内容一起使用:

df = pd.read_csv(
     'your/local/path/to/wdbc.data',
     header=None) 

在本节中,我们将读取数据集并通过三个简单的步骤将其拆分为训练数据集和测试数据集:

  1. 我们将首先通过pandas直接从UCI网站读取数据集:

    >>> import pandas as pd
    >>> df = pd.read_csv('https://archive.ics.uci.edu/ml/'
    ...                  'machine-learning-databases'
    ...                  '/breast-cancer-wisconsin/wdbc.data',
    ...                  header=None) 
    
  2. 接下来,我们将把30个特征赋值给一个NumPy数组X。通过使用LabelEncoder对象,我们将把类别标签从原始字符串表示('M''B')转换为整数:

    >>> from sklearn.preprocessing import LabelEncoder
    >>> X = df.loc[:, 2:].values
    >>> y = df.loc[:, 1].values
    >>> le = LabelEncoder()
    >>> y = le.fit_transform(y)
    >>> le.classes_
    array(['B', 'M'], dtype=object) 
    

    在将类别标签(诊断结果)编码到数组y后,恶性肿瘤现在表示为类别1,良性肿瘤表示为类别0。我们可以通过调用拟合后的LabelEncodertransform方法,使用两个虚拟类别标签来再次检查这个映射:

    >>> le.transform(['M', 'B'])
    array([1, 0]) 
    
  3. 在我们在下一小节中构建第一个模型管道之前,先将数据集划分为单独的训练数据集(占数据的80%)和单独的测试数据集(占数据的20%):

    >>> from sklearn.model_selection import train_test_split
    >>> X_train, X_test, y_train, y_test = \
    ...     train_test_split(X, y,
    ...                      test_size=0.20,
    ...                      stratify=y,
    ...                      random_state=1) 
    

在管道中组合转换器和估算器

在上一章中,你学习到许多学习算法需要输入特征具有相同的尺度才能获得最佳性能。由于乳腺癌威斯康星数据集中的特征是以不同的尺度测量的,我们将在将其输入线性分类器(如逻辑回归)之前,对乳腺癌威斯康星数据集的列进行标准化。此外,假设我们想通过主成分分析PCA)将数据从初始的30个维度压缩到一个较低的二维子空间,PCA是一种用于降维的特征提取技术,已经在第5章《通过降维压缩数据》中介绍过。

我们可以通过将StandardScalerPCALogisticRegression对象连接在一个管道中,而不是分别为训练数据集和测试数据集进行模型拟合和数据转换步骤:

>>> from sklearn.preprocessing import StandardScaler
>>> from sklearn.decomposition import PCA
>>> from sklearn.linear_model import LogisticRegression
>>> from sklearn.pipeline import make_pipeline
>>> pipe_lr = make_pipeline(StandardScaler(),
...                         PCA(n_components=2),
...                         LogisticRegression(random_state=1,
...                                            solver='lbfgs'))
>>> pipe_lr.fit(X_train, y_train)
>>> y_pred = pipe_lr.predict(X_test)
>>> print('Test Accuracy: %.3f' % pipe_lr.score(X_test, y_test))
Test Accuracy: 0.956 

make_pipeline函数接受任意数量的scikit-learn转换器(支持fittransform方法的对象)作为输入,后面跟着一个实现fitpredict方法的scikit-learn估算器。在我们之前的代码示例中,我们提供了两个转换器,StandardScalerPCA,以及一个LogisticRegression估算器作为输入传递给make_pipeline函数,这会从这些对象构建一个scikit-learn的Pipeline对象。

我们可以将scikit-learn的Pipeline视为这些独立转换器和估算器的元估算器或包装器。如果我们调用Pipelinefit方法,数据将通过这些中间步骤中的fittransform调用传递到转换器,直到它到达估算器对象(管道中的最后一个元素)。然后,估算器将被拟合到转换后的训练数据上。

当我们在前面的代码示例中对pipe_lr管道执行fit方法时,StandardScaler首先对训练数据执行fittransform调用。然后,转换后的训练数据被传递给管道中的下一个对象PCA。与前一步类似,PCA也对经过缩放的输入数据执行fittransform,并将其传递给管道的最后一个元素——估计器。

最终,LogisticRegression估计器在经过StandardScalerPCA的转换后,拟合了训练数据。再次提醒,我们应注意,管道中间步骤的数量没有限制;但是,最后一个管道元素必须是一个估计器。

类似于在管道上调用fit,管道还实现了predict方法。如果我们将数据集传递给Pipeline对象实例的predict调用,数据将通过transform调用经过中间步骤。在最后一步,估计器对象将对转换后的数据进行预测并返回结果。

scikit-learn库的管道是非常有用的封装工具,我们将在本书的剩余部分频繁使用它们。为了确保你充分理解Pipeline对象的工作原理,请仔细查看以下插图,它总结了我们前述段落的讨论:

使用k折交叉验证评估模型性能

构建机器学习模型的关键步骤之一是估计模型在未见过数据上的表现。假设我们在训练数据集上拟合了模型,并使用相同的数据估算它在新数据上的表现。我们从第3章,使用scikit-learn的机器学习分类器概览中的通过正则化解决过拟合问题部分中记得,如果模型过于简单,可能会出现欠拟合(高偏差);如果模型对于训练数据过于复杂,则可能会对训练数据过拟合(高方差)。

为了找到一个可接受的偏差-方差权衡,我们需要仔细评估我们的模型。在本节中,你将学习常见的交叉验证技术保留法交叉验证k折交叉验证,它们可以帮助我们可靠地估计模型的泛化性能,即模型在未见过的数据上的表现。

保留法

一种经典且广泛使用的机器学习模型泛化性能估计方法是留出交叉验证。使用留出法时,我们将初始数据集划分为训练集和测试集——前者用于模型训练,后者用于估计模型的泛化性能。然而,在典型的机器学习应用中,我们还需要调整和比较不同的参数设置,以进一步提升模型在未见数据上的预测性能。这个过程称为模型选择,其名称指的是我们希望为给定的分类问题选择最优调参参数(也称为超参数)。然而,如果在模型选择过程中不断重复使用相同的测试数据集,它将成为训练数据的一部分,从而使模型更容易出现过拟合。尽管存在这个问题,许多人仍然使用测试数据集进行模型选择,这并不是一种好的机器学习实践。

使用留出法进行模型选择的更好方法是将数据分为三部分:训练数据集、验证数据集和测试数据集。训练数据集用于拟合不同的模型,随后使用验证数据集的性能进行模型选择。拥有一个在训练和模型选择过程中未曾见过的测试数据集的优点在于,我们可以更少偏见地估计模型对新数据的泛化能力。下图展示了留出交叉验证的概念,我们使用验证数据集反复评估不同超参数值训练后的模型性能。一旦对超参数值的调整满意后,我们会在测试数据集上估计模型的泛化性能:

留出法的一个缺点是,性能估计可能会非常敏感于我们如何将训练数据集划分为训练子集和验证子集;对于不同的数据示例,估计会有所不同。在下一小节中,我们将探讨一种更稳健的性能估计技术——k折交叉验证,在这种方法中,我们会在训练数据的k个子集上重复留出法进行k次。

K折交叉验证

在k折交叉验证中,我们将训练数据集随机划分为k个折叠,且不放回,其中k - 1个折叠用于模型训练,1个折叠用于性能评估。该过程会重复进行k次,以便获得k个模型和性能估计。

有放回和无放回的抽样

我们在第3章使用scikit-learn的机器学习分类器概览中查看了一个例子来说明有放回和无放回采样。如果你没有阅读过那一章,或者想要复习一下,请参考通过随机森林结合多个决策树部分中的信息框,标题为有放回与无放回采样

然后,我们根据不同的独立测试折叠计算模型的平均性能,以获得一个相比于保留法更加不敏感于训练数据子划分的性能估计。通常,我们使用k折交叉验证来进行模型调优,也就是寻找能够获得令人满意的泛化性能的最佳超参数值,这些性能是通过在测试折叠上评估模型性能得到的。

一旦我们找到了令人满意的超参数值,我们可以在完整的训练数据集上重新训练模型,并使用独立的测试数据集来获得最终的性能估计。对模型进行k折交叉验证后再用整个训练数据集进行训练的理论依据是,给学习算法提供更多的训练样本通常会产生一个更加准确且鲁棒的模型。

由于k折交叉验证是一种无放回的重采样技术,这种方法的优点是每个样本都会被用于训练和验证(作为测试折叠的一部分)一次,这相比于保留法能够提供一个更低方差的模型性能估计。下图总结了k折交叉验证的概念,其中k = 10。训练数据集被划分为10个折叠,在10次迭代过程中,九个折叠用于训练,剩下一个折叠用于模型评估的测试数据集。

此外,每个折叠的估计性能,(例如,分类准确率或错误率),然后被用来计算模型的估计平均性能,E

在k折交叉验证中,k的一个好的标准值是10,正如经验研究所表明的。例如,Ron Kohavi对多个现实世界数据集的实验表明,10折交叉验证在偏差和方差之间提供了最佳的折中(A Study of Cross-Validation and Bootstrap for Accuracy Estimation and Model SelectionKohavi, RonInternational Joint Conference on Artificial Intelligence (IJCAI),14 (12): 1137-43,1995)。

然而,如果我们处理的是相对较小的训练集,增加折数可能会很有用。如果我们增加k的值,那么每次迭代中将使用更多的训练数据,这会导致通过平均单个模型估计来估算泛化性能时产生较低的悲观偏差。然而,较大的k值也会增加交叉验证算法的运行时间,并且由于训练折更相似,这会导致估计的方差增大。另一方面,如果我们处理的是大规模数据集,可以选择较小的k值,例如k = 5,依然能够准确估算模型的平均性能,同时减少重新拟合和评估模型在不同折上的计算成本。

留一交叉验证

k折交叉验证的一个特例是留一交叉验证LOOCV)方法。在LOOCV中,我们将折数设置为训练示例的数量(k = n),这样在每次迭代时,只使用一个训练示例进行测试,这是一种处理非常小的数据集时推荐的方法。

相对于标准的k折交叉验证方法,分层k折交叉验证稍作改进,尤其在类别不均衡的情况下,可以提供更好的偏差和方差估计,这一点也在本节中前面提到的Ron Kohavi的同一研究中有所展示。在分层交叉验证中,每个折中的类别标签比例得以保持,确保每个折都能代表训练数据集中类别的比例,我们将通过在scikit-learn中使用StratifiedKFold迭代器来进行说明:

>>> import numpy as np
>>> from sklearn.model_selection import StratifiedKFold
>>> kfold = StratifiedKFold(n_splits=10).split(X_train, y_train)
>>> scores = []
>>> for k, (train, test) in enumerate(kfold):
...     pipe_lr.fit(X_train[train], y_train[train])
...     score = pipe_lr.score(X_train[test], y_train[test])
...     scores.append(score)
...     print('Fold: %2d, Class dist.: %s, Acc: %.3f' % (k+1,
...           np.bincount(y_train[train]), score))
Fold:  1, Class dist.: [256 153], Acc: 0.935
Fold:  2, Class dist.: [256 153], Acc: 0.935
Fold:  3, Class dist.: [256 153], Acc: 0.957
Fold:  4, Class dist.: [256 153], Acc: 0.957
Fold:  5, Class dist.: [256 153], Acc: 0.935
Fold:  6, Class dist.: [257 153], Acc: 0.956
Fold:  7, Class dist.: [257 153], Acc: 0.978
Fold:  8, Class dist.: [257 153], Acc: 0.933
Fold:  9, Class dist.: [257 153], Acc: 0.956
Fold: 10, Class dist.: [257 153], Acc: 0.956
>>> print('\nCV accuracy: %.3f +/- %.3f' %
...      (np.mean(scores), np.std(scores)))
CV accuracy: 0.950 +/- 0.014 

首先,我们从sklearn.model_selection模块初始化了StratifiedKFold迭代器,并使用训练数据集中的y_train类别标签,此外通过n_splits参数指定了折数。当我们使用kfold迭代器遍历k折时,我们使用返回的train索引来拟合我们在本章开始时设置的逻辑回归管道。通过使用pipe_lr管道,我们确保在每次迭代中,示例都被正确地缩放(例如标准化)。然后,我们使用test索引计算模型的准确性分数,并将其收集到scores列表中,用于计算平均准确率和估计的标准差。

尽管之前的代码示例有助于说明k折交叉验证的工作原理,scikit-learn还实现了一个k折交叉验证评分器,允许我们以更简洁的方式使用分层k折交叉验证来评估模型:

>>> from sklearn.model_selection import cross_val_score
>>> scores = cross_val_score(estimator=pipe_lr,
...                          X=X_train,
...                          y=y_train,
...                          cv=10,
...                          n_jobs=1)
>>> print('CV accuracy scores: %s' % scores)
CV accuracy scores: [ 0.93478261  0.93478261  0.95652174
                      0.95652174  0.93478261  0.95555556
                      0.97777778  0.93333333  0.95555556
                      0.95555556]
>>> print('CV accuracy: %.3f +/- %.3f' % (np.mean(scores),
...       np.std(scores)))
CV accuracy: 0.950 +/- 0.014 

cross_val_score 方法的一个极其有用的功能是,我们可以将不同折叠的评估分配到机器上多个中央处理单元(CPU)上。如果我们将 n_jobs 参数设置为 1,则只会使用一个 CPU 来评估性能,就像我们之前的 StratifiedKFold 示例一样。然而,通过将 n_jobs=2,我们可以将 10 轮交叉验证分配到两个 CPU(如果机器上有的话),而将 n_jobs=-1,我们可以使用机器上所有可用的 CPU 来并行计算。

估计泛化性能

请注意,关于如何在交叉验证中估计泛化性能方差的详细讨论超出了本书的范围,但你可以参考一篇关于模型评估和交叉验证的综合文章(《机器学习中的模型评估、模型选择与算法选择》Raschka S,arXiv 预印本 arXiv:1811.12808,2018),该文章更深入地讨论了这些主题。文章可以免费从https://arxiv.org/abs/1811.12808获取。

此外,你还可以在 M. Markatou 等人写的这篇优秀文章中找到详细讨论(《交叉验证估计器的方差与泛化误差分析》M. MarkatouH. TianS. BiswasG. M. Hripcsak机器学习研究期刊,6:1127-1168,2005)。

你还可以阅读关于替代交叉验证技术的相关资料,比如 .632 Bootstrap 交叉验证方法(《交叉验证的改进:.632+ Bootstrap 方法》B. EfronR. Tibshirani美国统计学会期刊,92(438):548-560,1997)。

使用学习曲线和验证曲线调试算法

在本节中,我们将介绍两个非常简单但又强大的诊断工具,帮助我们提升学习算法的表现:学习曲线验证曲线。在接下来的小节中,我们将讨论如何使用学习曲线诊断学习算法是否存在过拟合(高方差)或欠拟合(高偏差)的问题。此外,我们还将介绍验证曲线,这能帮助我们解决学习算法中的常见问题。

使用学习曲线诊断偏差和方差问题

如果一个模型对于给定的训练数据集过于复杂——即模型中有过多的自由度或参数——模型往往会出现过拟合,并且在面对未见过的数据时,表现不佳。通常,收集更多的训练样本有助于减少过拟合的程度。

然而,在实际应用中,收集更多数据通常非常昂贵或根本不可行。通过将模型的训练准确率和验证准确率绘制成训练数据集大小的函数,我们可以轻松检测模型是否存在高方差或高偏差问题,以及收集更多数据是否能帮助解决这个问题。在我们讨论如何在 scikit-learn 中绘制学习曲线之前,让我们通过以下插图来讨论这两种常见的模型问题:

左上角的图显示了一个具有高偏差的模型。该模型的训练准确率和交叉验证准确率都很低,表明它在训练数据上存在欠拟合问题。解决这个问题的常见方法是增加模型的参数数量,例如,通过收集或构造额外的特征,或通过减少正则化的程度,例如,在支持向量机SVM)或逻辑回归分类器中。

右上角的图显示了一个存在高方差的模型,这从训练准确率和交叉验证准确率之间的巨大差距可以看出。为了应对这个过拟合问题,我们可以收集更多的训练数据,减少模型的复杂度,或增加正则化参数等。

对于无正则化的模型,通过特征选择(第 4 章构建良好的训练数据集 – 数据预处理)或特征提取(第 5 章通过降维压缩数据)减少特征数量,也有助于降低过拟合的程度。虽然收集更多的训练数据通常有助于减少过拟合的机会,但它并不总是有效,例如,当训练数据噪声极大或模型已经非常接近最优时。

在下一小节中,我们将看到如何使用验证曲线来解决这些模型问题,但首先让我们看看如何使用 scikit-learn 的学习曲线函数来评估模型:

>>> import matplotlib.pyplot as plt
>>> from sklearn.model_selection import learning_curve
>>> pipe_lr = make_pipeline(StandardScaler(),
...                         LogisticRegression(penalty='l2',
...                                            random_state=1,
...                                            solver='lbfgs',
...                                            max_iter=10000))
>>> train_sizes, train_scores, test_scores =\
...                 learning_curve(estimator=pipe_lr,
...                                X=X_train,
...                                y=y_train,
...                                train_sizes=np.linspace(
...                                            0.1, 1.0, 10),
...                                cv=10,
...                                n_jobs=1)
>>> train_mean = np.mean(train_scores, axis=1)
>>> train_std = np.std(train_scores, axis=1)
>>> test_mean = np.mean(test_scores, axis=1)
>>> test_std = np.std(test_scores, axis=1)
>>> plt.plot(train_sizes, train_mean,
...          color='blue', marker='o',
...          markersize=5, label='Training accuracy')
>>> plt.fill_between(train_sizes,
...                  train_mean + train_std,
...                  train_mean - train_std,
...                  alpha=0.15, color='blue')
>>> plt.plot(train_sizes, test_mean,
...          color='green', linestyle='--',
...          marker='s', markersize=5,
...          label='Validation accuracy')
>>> plt.fill_between(train_sizes,
...                  test_mean + test_std,
...                  test_mean - test_std,
...                  alpha=0.15, color='green')
>>> plt.grid()
>>> plt.xlabel('Number of training examples')
>>> plt.ylabel('Accuracy')
>>> plt.legend(loc='lower right')
>>> plt.ylim([0.8, 1.03])
>>> plt.show() 

请注意,当实例化 LogisticRegression 对象时,我们传递了 max_iter=10000 作为附加参数(默认使用 1,000 次迭代),以避免在较小数据集大小或极端正则化参数值下出现收敛问题(将在下一节中讨论)。在成功执行前面的代码后,我们将获得以下学习曲线图:

通过 learning_curve 函数中的 train_sizes 参数,我们可以控制用于生成学习曲线的训练样本的绝对数量或相对数量。在这里,我们将 train_sizes=np.linspace(0.1, 1.0, 10) 设置为使用 10 个均匀间隔的相对训练数据集大小。默认情况下,learning_curve 函数使用分层 k 折交叉验证来计算分类器的交叉验证准确率,并通过 cv 参数设置 k=10 进行 10 折分层交叉验证。

然后,我们简单地计算了不同训练数据集大小的交叉验证训练和测试得分的平均准确率,并使用 Matplotlib 的plot函数将其绘制出来。此外,我们还通过fill_between函数将平均准确率的标准差添加到图表中,以表示估计的方差。

正如我们在前面的学习曲线图中看到的,如果模型在训练过程中见过超过250个示例,它在训练和验证数据集上的表现都非常好。我们还可以看到,对于少于250个示例的训练数据集,训练准确率有所提高,同时验证和训练准确率之间的差距加大——这是过拟合程度增加的一个指示。

通过验证曲线解决过拟合和欠拟合问题

验证曲线是通过解决过拟合或欠拟合等问题来提高模型性能的有用工具。验证曲线与学习曲线相关,但不是将训练和测试的准确率作为样本大小的函数进行绘制,而是通过调整模型参数的值,例如逻辑回归中的逆正则化参数C。让我们继续看看如何通过 scikit-learn 创建验证曲线:

>>> from sklearn.model_selection import validation_curve
>>> param_range = [0.001, 0.01, 0.1, 1.0, 10.0, 100.0]
>>> train_scores, test_scores = validation_curve(
...                             estimator=pipe_lr,
...                             X=X_train,
...                             y=y_train,
...                             param_name='logisticregression__C',
...                             param_range=param_range,
...                             cv=10)
>>> train_mean = np.mean(train_scores, axis=1)
>>> train_std = np.std(train_scores, axis=1)
>>> test_mean = np.mean(test_scores, axis=1)
>>> test_std = np.std(test_scores, axis=1)
>>> plt.plot(param_range, train_mean,
...          color='blue', marker='o',
...          markersize=5, label='Training accuracy')
>>> plt.fill_between(param_range, train_mean + train_std,
...                  train_mean - train_std, alpha=0.15,
...                  color='blue')
>>> plt.plot(param_range, test_mean,
...          color='green', linestyle='--',
...          marker='s', markersize=5,
...          label='Validation accuracy')
>>> plt.fill_between(param_range,
...                  test_mean + test_std,
...                  test_mean - test_std,
...                  alpha=0.15, color='green')
>>> plt.grid()
>>> plt.xscale('log')
>>> plt.legend(loc='lower right')
>>> plt.xlabel('Parameter C')
>>> plt.ylabel('Accuracy')
>>> plt.ylim([0.8, 1.0])
>>> plt.show() 

使用前面的代码,我们获得了参数C的验证曲线图:

类似于learning_curve函数,validation_curve函数默认使用分层k折交叉验证来估计分类器的性能。在validation_curve函数内部,我们指定了要评估的参数。在这种情况下,是C,即LogisticRegression分类器的逆正则化参数,我们将其写为'logisticregression__C',以访问 scikit-learn 管道中LogisticRegression对象,并通过param_range参数设置了指定值范围。与前一部分中的学习曲线示例类似,我们绘制了平均训练和交叉验证准确率及其相应的标准差。

尽管C值不同所导致的准确率差异较小,但我们可以看到,当增加正则化强度(即C的较小值)时,模型略微欠拟合数据。然而,对于较大的C值,这意味着减弱正则化强度,因此模型倾向于轻微地过拟合数据。在这种情况下,最佳的C值似乎介于0.01到0.1之间。

通过网格搜索微调机器学习模型

在机器学习中,我们有两种类型的参数:一种是从训练数据中学习到的参数,例如逻辑回归中的权重,另一种是独立优化的学习算法参数。后者是模型的调参参数(或超参数),例如逻辑回归中的正则化参数或决策树的深度参数。

在前一节中,我们通过调节模型的一个超参数来使用验证曲线提高模型的表现。在本节中,我们将介绍一种流行的超参数优化技术——网格搜索,它通过找到超参数值的最佳组合,进一步帮助提升模型的性能。

通过网格搜索调优超参数

网格搜索方法非常简单:它是一种暴力穷举搜索的范式,我们指定不同超参数的值列表,计算机会评估每种组合下的模型性能,以获得这个集合中值的最佳组合:

>>> from sklearn.model_selection import GridSearchCV
>>> from sklearn.svm import SVC
>>> pipe_svc = make_pipeline(StandardScaler(),
...                          SVC(random_state=1))
>>> param_range = [0.0001, 0.001, 0.01, 0.1,
...                1.0, 10.0, 100.0, 1000.0]
>>> param_grid = [{'svc__C': param_range,
...                'svc__kernel': ['linear']},
...               {'svc__C': param_range,
...                'svc__gamma': param_range,
...                'svc__kernel': ['rbf']}]
>>> gs = GridSearchCV(estimator=pipe_svc,
...                   param_grid=param_grid,
...                   scoring='accuracy',
...                   cv=10,
...                   refit=True,
...                   n_jobs=-1)
>>> gs = gs.fit(X_train, y_train)
>>> print(gs.best_score_)
0.9846153846153847
>>> print(gs.best_params_)
{'svc__C': 100.0, 'svc__gamma': 0.001, 'svc__kernel': 'rbf'} 

使用前面的代码,我们从sklearn.model_selection模块初始化了一个GridSearchCV对象来训练和调优SVM管道。我们将GridSearchCVparam_grid参数设置为字典列表,以指定我们希望调优的参数。对于线性SVM,我们只评估了逆正则化参数C;对于RBF核SVM,我们调优了svc__Csvc__gamma两个参数。请注意,svc__gamma参数是专门针对核SVM的。

在我们使用训练数据进行网格搜索后,我们通过best_score_属性获得了最佳表现模型的得分,并查看了其参数,这些参数可以通过best_params_属性进行访问。在这个特定的例子中,使用svc__C = 100.0的RBF核SVM模型获得了最佳的k折交叉验证准确率:98.5%。

最后,我们使用独立的测试数据集来估计最佳选定模型的性能,模型可以通过GridSearchCV对象的best_estimator_属性获得:

>>> clf = gs.best_estimator_
>>> clf.fit(X_train, y_train)
>>> print('Test accuracy: %.3f' % clf.score(X_test, y_test))
Test accuracy: 0.974 

请注意,在完成网格搜索后,手动使用clf.fit(X_train, y_train)在训练集上拟合具有最佳设置的模型(gs.best_estimator_)并非必要。GridSearchCV类有一个refit参数,如果我们设置refit=True(默认为True),它会自动将gs.best_estimator_重新拟合到整个训练集。

随机超参数搜索

尽管网格搜索是寻找最优参数集的强大方法,但评估所有可能的参数组合在计算上是非常昂贵的。使用 scikit-learn 进行不同参数组合采样的替代方法是随机搜索。随机搜索通常与网格搜索表现相当,但在成本和时间上更加高效。特别是,如果我们通过随机搜索仅采样 60 个参数组合,我们就已经有 95% 的概率在最优性能的 5% 范围内获得解(用于超参数优化的随机搜索Bergstra J, Bengio Y. 机器学习研究期刊。第281-305页,2012年)。

使用 scikit-learn 中的 RandomizedSearchCV 类,我们可以在指定的预算范围内从采样分布中随机选择参数组合。更多详细信息和使用示例可以在 http://scikit-learn.org/stable/modules/grid_search.html#randomized-parameter-optimization 找到。

带嵌套交叉验证的算法选择

将 k 折交叉验证与网格搜索结合使用是通过改变超参数值来微调机器学习模型性能的有用方法,正如我们在前一小节中所见。如果我们想在不同的机器学习算法之间进行选择,另一种推荐的方法是嵌套交叉验证。在一项关于误差估计偏差的研究中,Sudhir Varma 和 Richard Simon 得出结论,使用嵌套交叉验证时,估计的真实误差相对于测试数据集几乎没有偏差(使用交叉验证进行模型选择时的误差估计偏差BMC 生物信息学S. VarmaR. Simon,7(1): 91,2006)。

在嵌套交叉验证中,我们有一个外部的 k 折交叉验证循环,用于将数据拆分为训练集和测试集,内部循环则使用 k 折交叉验证在训练集上选择模型。模型选择完成后,测试集用于评估模型性能。下图解释了仅有五个外部折叠和两个内部折叠的嵌套交叉验证概念,这对于计算性能要求较高的大型数据集非常有用;这种特定类型的嵌套交叉验证也被称为5x2交叉验证

在 scikit-learn 中,我们可以按如下方式执行嵌套交叉验证:

>>> gs = GridSearchCV(estimator=pipe_svc,
...                   param_grid=param_grid,
...                   scoring='accuracy',
...                   cv=2)
>>> scores = cross_val_score(gs, X_train, y_train,
...                          scoring='accuracy', cv=5)
>>> print('CV accuracy: %.3f +/- %.3f' % (np.mean(scores),
...                                       np.std(scores)))
CV accuracy: 0.974 +/- 0.015 

返回的平均交叉验证准确率可以很好地估计,如果我们调整模型的超参数并将其应用于未见过的数据时,期望的结果是什么。

例如,我们可以使用嵌套交叉验证方法将 SVM 模型与简单的决策树分类器进行比较;为了简化,我们将只调整它的深度参数:

>>> from sklearn.tree import DecisionTreeClassifier
>>> gs = GridSearchCV(estimator=DecisionTreeClassifier(
...                       random_state=0),
...                   param_grid=[{'max_depth': [1, 2, 3,
...                                              4, 5, 6,
...                                              7, None]}],
...                   scoring='accuracy',
...                   cv=2)
>>> scores = cross_val_score(gs, X_train, y_train,
...                          scoring='accuracy', cv=5)
>>> print('CV accuracy: %.3f +/- %.3f' % (np.mean(scores),
...                                       np.std(scores)))
CV accuracy: 0.934 +/- 0.016 

如我们所见,SVM 模型的嵌套交叉验证性能(97.4%)明显优于决策树的性能(93.4%),因此,我们可以预期它可能是分类来自与此数据集相同人群的新数据的更好选择。

查看不同的性能评估指标

在之前的章节中,我们使用预测准确度评估了不同的机器学习模型,这个指标是量化模型表现的一个有用标准。然而,还有一些其他的性能指标可以用来衡量模型的相关性,如精确度、召回率和F1 分数

解读混淆矩阵

在深入讨论不同的评分指标之前,让我们先看看一个混淆矩阵,这是一个展示学习算法表现的矩阵。

混淆矩阵只是一个方阵,报告分类器的真正例TP)、真负例TN)、假正例FP)和假负例FN)的预测计数,如下图所示:

尽管通过比较真实和预测的类别标签,可以轻松手动计算这些指标,scikit-learn 提供了一个方便的 confusion_matrix 函数,我们可以使用它,如下所示:

>>> from sklearn.metrics import confusion_matrix
>>> pipe_svc.fit(X_train, y_train)
>>> y_pred = pipe_svc.predict(X_test)
>>> confmat = confusion_matrix(y_true=y_test, y_pred=y_pred)
>>> print(confmat)
[[71  1]
[ 2 40]] 

执行代码后返回的数组为我们提供了有关分类器在测试数据集上所犯的不同类型错误的信息。我们可以使用 Matplotlib 的 matshow 函数将这些信息映射到前面图示中的混淆矩阵:

>>> fig, ax = plt.subplots(figsize=(2.5, 2.5))
>>> ax.matshow(confmat, cmap=plt.cm.Blues, alpha=0.3)
>>> for i in range(confmat.shape[0]):
...     for j in range(confmat.shape[1]):
...         ax.text(x=j, y=i,
...                 s=confmat[i, j],
...                 va='center', ha='center')
>>> plt.xlabel('Predicted label')
>>> plt.ylabel('True label')
>>> plt.show() 

现在,下面的混淆矩阵图表,添加了标签后,应该使结果更容易理解:

假设在本例中类 1(恶性)是正类,我们的模型正确地将属于类 0(TN)的71个示例分类为负类,将属于类 1(TP)的40个示例分类为正类。然而,我们的模型也错误地将来自类 1 的两个示例误分类为类 0(FN),并且它错误地预测了一个示例是恶性肿瘤,尽管它实际上是良性肿瘤(FP)。在下一小节中,我们将学习如何利用这些信息来计算各种错误指标。

优化分类模型的精确度和召回率

预测误差ERR)和准确度ACC)都提供了关于多少示例被误分类的总体信息。误差可以理解为所有假预测的总和除以总预测数量,而准确度则是正确预测的总和除以总预测数量:

然后,预测准确度可以直接从错误中计算得出:

真正例率 (TPR) 和 假正例率 (FPR) 是特别适用于类别不平衡问题的性能指标:

以肿瘤诊断为例,我们更关注的是恶性肿瘤的检测,以帮助患者获得适当的治疗。然而,同样重要的是减少将良性肿瘤错误分类为恶性肿瘤(假正例,FP)的数量,以免不必要地让患者担忧。与假正例率(FPR)相比,真正例率(TPR)提供了有关在所有正例(P)中,正确识别出的正例(或相关例子)所占的比例的有用信息。

性能指标 精确度 (PRE) 和 召回率 (REC) 与真正例和真负例的比率有关,实际上,REC 与 TPR 同义:

重新审视恶性肿瘤检测的例子,优化召回率有助于最小化漏掉恶性肿瘤的可能性。然而,这会导致将健康患者误判为患有恶性肿瘤(较高的假正例数量)。另一方面,如果我们优化精确度,则强调预测患者是否患有恶性肿瘤的正确性,但这会以更频繁地漏掉恶性肿瘤(较高的假负例数量)为代价。

为了平衡优化精确度(PRE)和召回率(REC)的优缺点,通常使用精确度和召回率的组合,即所谓的 F1 分数:

进一步阅读精确度和召回率

如果你对精确度和召回率等不同性能指标有更深入的讨论兴趣,可以阅读 David M. W. Powers 的技术报告 Evaluation: From Precision, Recall and F-Factor to ROC, Informedness, Markedness & Correlation,该报告可在 http://www.flinders.edu.au/science_engineering/fms/School-CSEM/publications/tech_reps-research_artfcts/TRRA_2007.pdf 免费获取。

这些评分指标都已在 scikit-learn 中实现,可以从 sklearn.metrics 模块导入,以下代码片段展示了如何操作:

>>> from sklearn.metrics import precision_score
>>> from sklearn.metrics import recall_score, f1_score
>>> print('Precision: %.3f' % precision_score(
...           y_true=y_test, y_pred=y_pred))
Precision: 0.976
>>> print('Recall: %.3f' % recall_score(
...           y_true=y_test, y_pred=y_pred))
Recall: 0.952
>>> print('F1: %.3f' % f1_score(
...           y_true=y_test, y_pred=y_pred))
F1: 0.964 

此外,我们还可以通过 GridSearchCV 中的 scoring 参数使用不同于准确率的评分指标。scoring 参数接受的不同值的完整列表可以在 http://scikit-learn.org/stable/modules/model_evaluation.html 查找到。

记住,在 scikit-learn 中,正类是标记为类 1 的类别。如果我们希望指定不同的 正类标签,可以通过 make_scorer 函数构造自己的评分器,然后将其直接作为 GridSearchCVscoring 参数的参数(在这个例子中,使用 f1_score 作为度量标准):

>>> from sklearn.metrics import make_scorer, f1_score
>>> c_gamma_range = [0.01, 0.1, 1.0, 10.0]
>>> param_grid = [{'svc__C': c_gamma_range,
...                'svc__kernel': ['linear']},
...               {'svc__C': c_gamma_range,
...                'svc__gamma': c_gamma_range,
...                'svc__kernel': ['rbf']}]
>>> scorer = make_scorer(f1_score, pos_label=0)
>>> gs = GridSearchCV(estimator=pipe_svc,
...                   param_grid=param_grid,
...                   scoring=scorer,
...                   cv=10)
>>> gs = gs.fit(X_train, y_train)
>>> print(gs.best_score_)
0.986202145696
>>> print(gs.best_params_)
{'svc__C': 10.0, 'svc__gamma': 0.01, 'svc__kernel': 'rbf'} 

绘制接收器操作特性曲线

接收器操作特征ROC)图是用于根据分类器在FPR和TPR上的表现选择分类模型的有用工具,FPR和TPR是通过调整分类器的决策阈值计算的。ROC图的对角线可以解释为随机猜测,而低于对角线的分类模型被认为比随机猜测差。一个完美的分类器将在图的左上角,具有TPR为1和FPR为0。基于ROC曲线,我们可以进一步计算所谓的ROC曲线下面积ROC AUC),以表征分类模型的性能。

类似于ROC曲线,我们也可以计算精确率-召回率曲线,用于分类器在不同概率阈值下的表现。scikit-learn中也实现了绘制这些精确率-召回率曲线的函数,相关文档请见http://scikit-learn.org/stable/modules/generated/sklearn.metrics.precision_recall_curve.html

执行以下代码示例,我们将绘制一个ROC曲线,该曲线来自一个分类器,该分类器只使用来自乳腺癌威斯康星数据集的两个特征来预测肿瘤是良性还是恶性。尽管我们将使用之前定义的相同的逻辑回归管道,但这次我们只使用两个特征。这是为了使分类任务对分类器更具挑战性,因通过省略其他特征中包含的有用信息,使得生成的ROC曲线在视觉上更具趣味性。出于类似的原因,我们还将StratifiedKFold验证器中的折数减少到三折。代码如下:

>>> from sklearn.metrics import roc_curve, auc
>>> from scipy import interp
>>> pipe_lr = make_pipeline(StandardScaler(),
...                         PCA(n_components=2),
...                         LogisticRegression(penalty='l2',
...                                            random_state=1,
...                                            solver='lbfgs',
...                                            C=100.0))
>>> X_train2 = X_train[:, [4, 14]]
>>> cv = list(StratifiedKFold(n_splits=3,
...                           random_state=1).split(X_train,
...                                                 y_train))
>>> fig = plt.figure(figsize=(7, 5))
>>> mean_tpr = 0.0
>>> mean_fpr = np.linspace(0, 1, 100)
>>> all_tpr = []
>>> for i, (train, test) in enumerate(cv):
...     probas = pipe_lr.fit(
...         X_train2[train],
...         y_train[train]).predict_proba(X_train2[test])
...     fpr, tpr, thresholds = roc_curve(y_train[test],
...                                      probas[:, 1],
...                                      pos_label=1)
...     mean_tpr += interp(mean_fpr, fpr, tpr)
...     mean_tpr[0] = 0.0
...     roc_auc = auc(fpr, tpr)
...     plt.plot(fpr,
...              tpr,
...              label='ROC fold %d (area = %0.2f)'
...              % (i+1, roc_auc))
>>> plt.plot([0, 1],
...          [0, 1],
...          linestyle='--',
...          color=(0.6, 0.6, 0.6),
...          label='Random guessing')
>>> mean_tpr /= len(cv)
>>> mean_tpr[-1] = 1.0
>>> mean_auc = auc(mean_fpr, mean_tpr)
>>> plt.plot(mean_fpr, mean_tpr, 'k--',
...          label='Mean ROC (area = %0.2f)' % mean_auc, lw=2)
>>> plt.plot([0, 0, 1],
...          [0, 1, 1],
...          linestyle=':',
...          color='black',
...          label='Perfect performance')
>>> plt.xlim([-0.05, 1.05])
>>> plt.ylim([-0.05, 1.05])
>>> plt.xlabel('False positive rate')
>>> plt.ylabel('True positive rate')
>>> plt.legend(loc="lower right")
>>> plt.show() 

在前面的代码示例中,我们使用了scikit-learn中已经熟悉的StratifiedKFold类,并使用来自sklearn.metrics模块的roc_curve函数单独计算了我们pipe_lr管道中LogisticRegression分类器的ROC性能,每次迭代都如此。此外,我们通过从SciPy导入的interp函数对三折的平均ROC曲线进行了插值,并通过auc函数计算了曲线下面积。结果显示,ROC曲线表明不同折之间存在一定的方差,且平均ROC AUC(0.76)介于完美得分(1.0)与随机猜测(0.5)之间:

请注意,如果我们只对ROC AUC分数感兴趣,我们也可以直接从sklearn.metrics子模块导入roc_auc_score函数,该函数的使用方式与前面介绍的其他评分函数(例如precision_score)相似。

通过报告分类器的 ROC AUC 性能,可以进一步了解分类器在处理不平衡样本时的表现。然而,尽管准确率得分可以解释为 ROC 曲线上的一个单一截断点,A. P. Bradley 表明 ROC AUC 和准确率指标通常是相一致的:《在机器学习算法评估中使用 ROC 曲线下的面积》A. P. Bradley模式识别,30(7): 1145-1159,1997

多类别分类的评分指标

目前我们讨论的评分指标是特定于二分类系统的。然而,scikit-learn 还实现了宏平均和微平均方法,通过 一对多OvA)分类将这些评分指标扩展到多类别问题。微平均是通过系统的单个真正例(TP)、真负例(TN)、假正例(FP)和假负例(FN)来计算的。例如,k 类系统中精确度得分的微平均可以按如下方式计算:

宏平均值是通过不同系统的平均得分来简单计算的:

微平均适用于当我们想平等地对每个实例或预测加权时,而宏平均则是将所有类别平等加权,以评估分类器在处理最常见类别标签时的整体表现。

如果我们使用二分类性能指标来评估 scikit-learn 中的多类别分类模型,默认情况下会使用宏平均的归一化或加权变体。加权宏平均是通过在计算平均值时根据每个类别标签的真实实例数量加权每个类别的得分来计算的。加权宏平均在处理类别不平衡时非常有用,也就是说,当每个标签的实例数量不同。

虽然加权宏平均是 scikit-learn 中多类别问题的默认设置,但我们可以通过在从 sklearn.metrics 模块导入的不同评分函数中使用 average 参数来指定平均方法,例如 precision_scoremake_scorer 函数:

>>> pre_scorer = make_scorer(score_func=precision_score,
...                          pos_label=1,
...                          greater_is_better=True,
...                          average='micro') 

处理类别不平衡

我们在本章中已经多次提到类别不平衡的问题,但实际上我们还没有讨论如何在发生这种情况时适当地处理。类别不平衡是处理实际数据时一个非常常见的问题——数据集中某一类别或多个类别的样本被过度表示。我们可以想到一些可能出现这种情况的领域,例如垃圾邮件过滤、欺诈检测或疾病筛查。

假设我们在本章中使用的乳腺癌威斯康星数据集由90%的健康患者组成。在这种情况下,通过仅对所有示例预测多数类(良性肿瘤),我们就能在测试数据集上达到90%的准确率,而无需借助监督学习算法。因此,在这样的数据集上训练一个大约达到90%测试准确率的模型,意味着我们的模型没有从数据集中提供的特征中学到任何有用的东西。

在本节中,我们将简要介绍一些可以帮助处理不平衡数据集的技术。但在讨论解决这一问题的不同方法之前,让我们先从原本包含357个良性肿瘤(类别0)和212个恶性肿瘤(类别1)的数据集中创建一个不平衡数据集:

>>> X_imb = np.vstack((X[y == 0], X[y == 1][:40]))
>>> y_imb = np.hstack((y[y == 0], y[y == 1][:40])) 
0), we would achieve a prediction accuracy of approximately 90 percent:
>>> y_pred = np.zeros(y_imb.shape[0])
>>> np.mean(y_pred == y_imb) * 100
89.92443324937027 

因此,当我们在这类数据集上训练分类器时,比较不同模型时,除了准确率外,关注其他指标是有意义的,比如精确度、召回率、ROC曲线——根据我们的应用场景,选择我们最关心的指标。例如,我们的优先目标可能是识别大部分患有恶性癌症的患者,以推荐额外的筛查,因此召回率应该是我们选择的指标。在垃圾邮件过滤中,如果系统不太确定,我们不希望将邮件标记为垃圾邮件,此时精确度可能是更合适的指标。

除了评估机器学习模型之外,类别不平衡还会在模型拟合过程中影响学习算法。由于机器学习算法通常优化的是一个奖励或成本函数,该函数是根据它在拟合过程中看到的训练样本总和来计算的,因此决策规则很可能会偏向多数类。

换句话说,算法隐式地学习一个模型,该模型根据数据集中最丰富的类别优化预测,以最小化成本或最大化训练过程中的奖励。

处理类别不平衡问题的一种方法是在模型拟合过程中对少数类的错误预测赋予更大的惩罚。通过scikit-learn,调整这种惩罚与将class_weight参数设置为class_weight='balanced'一样方便,这对于大多数分类器都已经实现。

处理类别不平衡的其他流行策略包括对少数类进行上采样、对多数类进行下采样以及生成合成训练样本。不幸的是,没有一种通用的最佳解决方案或技术能够在不同问题领域中表现最好。因此,实际上,建议在给定问题上尝试不同的策略,评估结果,并选择看似最合适的技术。

scikit-learn库实现了一个简单的resample函数,可以通过从数据集中有放回地抽取新样本来帮助上采样少数类。以下代码将从我们不平衡的乳腺癌威斯康星数据集中提取少数类(这里是类别1),并不断从中抽取新样本,直到它包含与类别标签0相同数量的样本:

>>> from sklearn.utils import resample
>>> print('Number of class 1 examples before:',
...       X_imb[y_imb == 1].shape[0])
Number of class 1 examples before: 40
>>> X_upsampled, y_upsampled = resample(
...         X_imb[y_imb == 1],
...         y_imb[y_imb == 1],
...         replace=True,
...         n_samples=X_imb[y_imb == 0].shape[0],
...         random_state=123)
>>> print('Number of class 1 examples after:',
...       X_upsampled.shape[0])
Number of class 1 examples after: 357 

在重新采样后,我们可以将原始类别0的样本与上采样的类别1子集堆叠在一起,以获得一个平衡的数据集,如下所示:

>>> X_bal = np.vstack((X[y == 0], X_upsampled))
>>> y_bal = np.hstack((y[y == 0], y_upsampled)) 

因此,多数投票预测规则将仅达到50%的准确率:

>>> y_pred = np.zeros(y_bal.shape[0])
>>> np.mean(y_pred == y_bal) * 100
50 

类似地,我们可以通过从数据集中删除训练样本来对多数类进行下采样。要使用resample函数执行下采样,我们可以简单地在之前的代码示例中交换类别1标签和类别0标签,反之亦然。

生成新的训练数据以解决类别不平衡问题

另一种处理类别不平衡的技术是生成合成训练样本,这超出了本书的范围。可能最广泛使用的合成训练数据生成算法是合成少数类过采样技术SMOTE),您可以通过Nitesh Chawla等人发表的原始研究文章了解更多关于此技术的信息:《SMOTE: Synthetic Minority Over-sampling Technique》,《人工智能研究期刊》,16: 321-357,2002。同时,我们强烈推荐您查看imbalanced-learn,这是一个专注于不平衡数据集的Python库,其中包括SMOTE的实现。您可以在https://github.com/scikit-learn-contrib/imbalanced-learn了解更多关于imbalanced-learn的信息。

总结

在本章的开始,我们讨论了如何在便捷的模型管道中链式地组合不同的转换技术和分类器,这些管道帮助我们更高效地训练和评估机器学习模型。然后,我们使用这些管道进行了k折交叉验证,这是一种模型选择和评估的关键技术。通过k折交叉验证,我们绘制了学习曲线和验证曲线,以诊断学习算法中常见的问题,如过拟合和欠拟合。

通过网格搜索,我们进一步对模型进行了微调。然后,我们使用混淆矩阵和各种性能指标来评估和优化模型在特定问题任务中的表现。最后,我们通过讨论处理不平衡数据的不同方法来结束本章,这在许多现实世界应用中是一个常见问题。现在,您应该已经掌握了构建监督式机器学习分类模型的基本技术。

在下一章,我们将探讨集成方法:这些方法允许我们结合多个模型和分类算法,以进一步提升机器学习系统的预测性能。

第七章:组合不同的模型进行集成学习

在前一章中,我们专注于调整和评估不同分类模型的最佳实践。在本章中,我们将进一步探讨这些技术,并探索构建一组分类器的不同方法,这些方法通常比其各个成员单独具有更好的预测性能。我们将学习如何执行以下操作:

  • 基于多数投票进行预测

  • 使用装袋减少过拟合,通过重复绘制训练数据集的随机组合

  • 将提升应用于从其错误中学习的弱学习器构建强大的模型

用集成学习

集成方法的目标是将不同的分类器组合成一个元分类器,其泛化性能比单个分类器更好。例如,假设我们从10个专家的预测中收集了预测结果,集成方法允许我们通过这10个专家的预测策略性地组合这些预测结果,以得出比每个单独专家预测更准确和更稳健的预测结果。正如本章后面将看到的那样,有几种不同的方法可以创建一组分类器的集成。本节将介绍集成如何工作的基本解释,以及为什么它们通常因产生良好的泛化性能而受到认可。

在本章中,我们将重点介绍使用多数投票原则的最流行的集成方法。多数投票简单地意味着我们选择由大多数分类器预测的类标签,即获得超过50%选票的类标签。严格来说,“多数投票”一词仅适用于二元分类设置。然而,很容易将多数投票原则推广到多类设置,这称为多数投票。在这里,我们选择获得最多票数(众数)的类标签。下图说明了对10个分类器的集成进行多数投票和多数投票的概念,其中每个唯一符号(三角形、正方形和圆形)表示唯一的类标签:

使用训练数据集,我们首先通过训练m不同的分类器()来开始。根据技术的不同,集成可以由不同的分类算法构建,例如决策树、支持向量机、逻辑回归分类器等。或者,我们也可以使用相同的基础分类算法,适合训练数据集的不同子集。这种方法的一个显著例子是随机森林算法,它结合了不同的决策树分类器。下图说明了使用多数投票的一般集成方法的概念:

为了通过简单的多数或选举投票来预测类别标签,我们可以将每个单独分类器预测的类别标签结合起来,,并选择获得最多票数的类别标签,

(在统计学中,众数是一个集合中最频繁出现的事件或结果。例如,mode{1, 2, 1, 1, 2, 4, 5, 4} = 1。)

例如,在一个二分类任务中,其中class1 = –1,class2 = +1,我们可以将多数投票预测写作:

为了说明为什么集成方法比单一分类器更有效,我们可以应用组合学的简单概念。在以下示例中,我们假设所有用于二分类任务的n基分类器具有相同的错误率,。此外,我们假设分类器是独立的,且错误率之间没有相关性。在这些假设下,我们可以简单地将基分类器集成的错误概率表示为二项分布的概率质量函数:

这里, 是二项系数 n 选择 k。换句话说,我们计算集成的预测错误的概率。现在,让我们看看一个更具体的例子,假设有11个基分类器(n = 11),每个分类器的错误率为0.25():

二项系数

二项系数指的是从大小为 n 的集合中选择 k 个无序元素的方式数量;因此,它通常被称为“n 选择 k”。由于这里顺序无关,二项系数有时也被称为组合组合数,在其完全形式下,它写作:

在这里,符号 (!) 代表阶乘,例如

如你所见,如果所有假设都成立,集成的错误率(0.034)明显低于每个单独分类器的错误率(0.25)。请注意,在这个简化的示例中,基分类器通过偶数个分类器进行50-50的划分时被视为错误,而实际上这种情况只在一半的时间内成立。为了将这样一个理想化的集成分类器与基分类器进行比较,我们将在不同的基错误率范围内实现概率质量函数,使用Python进行如下操作:

>>> from scipy.special import comb
>>> import math
>>> def ensemble_error(n_classifier, error):
...     k_start = int(math.ceil(n_classifier / 2.))
...     probs = [comb(n_classifier, k) *
...              error**k *
...              (1-error)**(n_classifier - k)
...              for k in range(k_start, n_classifier + 1)]
...     return sum(probs)
>>> ensemble_error(n_classifier=11, error=0.25)
0.03432750701904297 

在实现了 ensemble_error 函数之后,我们可以计算出一系列不同基分类器错误率从0.0到1.0的集成错误率,并使用折线图可视化集成错误与基分类器错误之间的关系:

>>> import numpy as np
>>> import matplotlib.pyplot as plt
>>> error_range = np.arange(0.0, 1.01, 0.01)
>>> ens_errors = [ensemble_error(n_classifier=11, error=error)
...               for error in error_range]
>>> plt.plot(error_range, ens_errors,
...          label='Ensemble error',
...          linewidth=2)
>>> plt.plot(error_range, error_range,
...          linestyle='--', label='Base error',
...          linewidth=2)
>>> plt.xlabel('Base error')
>>> plt.ylabel('Base/Ensemble error')
>>> plt.legend(loc='upper left')
>>> plt.grid(alpha=0.5)
>>> plt.show() 

如结果图所示,只要基分类器的表现优于随机猜测(),集成的错误概率总是比单个基分类器的错误率更好。注意,y 轴表示基误差(虚线)和集成误差(实线):

通过多数投票组合分类器

在上一节简要介绍了集成学习后,让我们通过一个热身练习开始,使用 Python 实现一个简单的集成分类器进行多数投票。

多数投票

尽管我们将在本节讨论的多数投票算法也可以通过多数投票推广到多类问题,但为了简化起见,文献中通常使用“多数投票”这个术语。

实现一个简单的多数投票分类器

我们将在本节实现的算法将允许我们结合不同的分类算法,并为每个算法分配与置信度相关的权重。我们的目标是构建一个更强大的元分类器,平衡单个分类器在特定数据集上的弱点。用更精确的数学术语来说,我们可以将加权多数投票表示为:

这里, 是与基分类器 相关的权重; 是集成预测的类别标签;A 是唯一类别标签的集合;(希腊字母 chi)是特征函数或指示函数,如果第 j 个分类器的预测类别与 i)匹配,则返回 1。对于相等的权重,我们可以简化这个方程并表示为:

为了更好地理解 加权 的概念,我们现在来看一个更具体的例子。假设我们有一个由三个基分类器组成的集成!,并且我们想要预测给定样本 x 的类别标签!。三个基分类器中有两个预测类别为 0,一个预测类别为 1。如果我们对每个基分类器的预测给予相同的权重,那么多数投票将预测该样本属于类别 0:

现在,让我们给 赋予 0.6 的权重,并将 的权重设置为 0.2:

更简单地说,由于 ,我们可以说,由 做出的预测比 的预测重三倍,表示为:

要将加权多数投票的概念转化为 Python 代码,我们可以使用 NumPy 中方便的 argmaxbincount 函数:

>>> import numpy as np
>>> np.argmax(np.bincount([0, 0, 1],
...           weights=[0.2, 0.2, 0.6]))
1 

正如你从 第 3 章 中的逻辑回归讨论中记得的那样,《使用 scikit-learn 的机器学习分类器之旅》,scikit-learn 中某些分类器也可以通过 predict_proba 方法返回预测的类标签的概率。使用预测的类概率而不是类标签进行多数投票,如果我们的集成中的分类器经过良好校准,这种做法是非常有用的。根据概率预测类标签的修改版多数投票可以写成如下:

在这里, jth 分类器对于类标签 i 的预测概率。

继续我们之前的例子,假设我们有一个二分类问题,类标签是 ,并且有三个分类器的集成 。假设分类器 为某个特定示例 x 返回以下的类成员概率:

使用之前相同的权重(0.2、0.2 和 0.6),然后可以计算各个类的概率,如下所示:

要实现基于类概率的加权多数投票,我们可以再次使用 NumPy,利用 np.averagenp.argmax

>>> ex = np.array([[0.9, 0.1],
...                [0.8, 0.2],
...                [0.4, 0.6]])
>>> p = np.average(ex, axis=0, weights=[0.2, 0.2, 0.6])
>>> p
array([0.58, 0.42])
>>> np.argmax(p)
0 

把所有的内容组合起来,现在我们来实现 Python 中的 MajorityVoteClassifier

from sklearn.base import BaseEstimator
from sklearn.base import ClassifierMixin
from sklearn.preprocessing import LabelEncoder
from sklearn.base import clone
from sklearn.pipeline import _name_estimators
import numpy as np
import operator
class MajorityVoteClassifier(BaseEstimator,
                             ClassifierMixin):
    """ A majority vote ensemble classifier

    Parameters
    ----------
    classifiers : array-like, shape = [n_classifiers]
      Different classifiers for the ensemble

    vote : str, {'classlabel', 'probability'}
      Default: 'classlabel'
      If 'classlabel' the prediction is based on
      the argmax of class labels. Else if
      'probability', the argmax of the sum of
      probabilities is used to predict the class label
      (recommended for calibrated classifiers).

    weights : array-like, shape = [n_classifiers]
      Optional, default: None
      If a list of `int` or `float` values are
      provided, the classifiers are weighted by
      importance; Uses uniform weights if `weights=None`.

    """
    def __init__(self, classifiers,
                 vote='classlabel', weights=None):

        self.classifiers = classifiers
        self.named_classifiers = {key: value for
                                  key, value in
                                  _name_estimators(classifiers)}
        self.vote = vote
        self.weights = weights

    def fit(self, X, y):
        """ Fit classifiers.

        Parameters
        ----------
        X : {array-like, sparse matrix},
            shape = [n_examples, n_features]
            Matrix of training examples.

        y : array-like, shape = [n_examples]
            Vector of target class labels.

        Returns
        -------
        self : object

        """
        if self.vote not in ('probability', 'classlabel'):
            raise ValueError("vote must be 'probability'"
                             "or 'classlabel'; got (vote=%r)"
                             % self.vote)
        if self.weights and
        len(self.weights) != len(self.classifiers):
            raise ValueError("Number of classifiers and weights"
                             "must be equal; got %d weights,"
                             "%d classifiers"
                             % (len(self.weights),
                             len(self.classifiers)))
        # Use LabelEncoder to ensure class labels start
        # with 0, which is important for np.argmax
        # call in self.predict
        self.lablenc_ = LabelEncoder()
        self.lablenc_.fit(y)
        self.classes_ = self.lablenc_.classes_
        self.classifiers_ = []
        for clf in self.classifiers:
            fitted_clf = clone(clf).fit(X,
                               self.lablenc_.transform(y))
            self.classifiers_.append(fitted_clf)
        return self 

我在代码中添加了大量注释,来解释各个部分。然而,在我们实现剩下的方法之前,先休息一下,讨论一些可能一开始看起来有点困惑的代码。我们使用了 BaseEstimatorClassifierMixin 父类来获取一些基本功能,无需额外编写代码,包括 get_paramsset_params 方法来设置和返回分类器的参数,以及 score 方法来计算预测准确性。

接下来,我们将添加 predict 方法,通过基于类标签的多数投票来预测类标签,如果我们用 vote='classlabel' 初始化新的 MajorityVoteClassifier 对象。或者,我们也可以用 vote='probability' 来初始化集成分类器,通过类成员概率来预测类标签。此外,我们还会添加一个 predict_proba 方法,用来返回平均后的概率,这在计算接收者操作特征曲线下面积(ROC AUC)时非常有用:

 def predict(self, X):
        """ Predict class labels for X.

        Parameters
        ----------
        X : {array-like, sparse matrix},
            Shape = [n_examples, n_features]
            Matrix of training examples.

        Returns
        ----------
        maj_vote : array-like, shape = [n_examples]
            Predicted class labels.

        """
        if self.vote == 'probability':
            maj_vote = np.argmax(self.predict_proba(X), axis=1)
        else: # 'classlabel' vote

            # Collect results from clf.predict calls
            predictions = np.asarray([clf.predict(X)
                                      for clf in
                                      self.classifiers_]).T

            maj_vote = np.apply_along_axis(lambda x: np.argmax(
                                           np.bincount(x,
                                           weights=self.weights)),
                                           axis=1,
                                           arr=predictions)
        maj_vote = self.lablenc_.inverse_transform(maj_vote)
        return maj_vote

    def predict_proba(self, X):
        """ Predict class probabilities for X.

        Parameters
        ----------
        X : {array-like, sparse matrix}, 
            shape = [n_examples, n_features]
            Training vectors, where
            n_examples is the number of examples and
            n_features is the number of features.

        Returns
        ----------
        avg_proba : array-like,
            shape = [n_examples, n_classes]
            Weighted average probability for
            each class per example.

        """
        probas = np.asarray([clf.predict_proba(X)
                             for clf in self.classifiers_])
        avg_proba = np.average(probas, axis=0,
                               weights=self.weights)
        return avg_proba

    def get_params(self, deep=True):
        """ Get classifier parameter names for GridSearch"""
        if not deep:
            return super(MajorityVoteClassifier, 
                           self).get_params(deep=False)
        else:
            out = self.named_classifiers.copy()
            for name, step in self.named_classifiers.items():
                for key, value in step.get_params(
                        deep=True).items():
                    out['%s__%s' % (name, key)] = value
            return out 

另外,请注意,我们定义了自己修改版的 get_params 方法,通过使用 _name_estimators 函数来访问集成中各个分类器的参数;这可能一开始看起来有点复杂,但当我们在后续章节使用网格搜索进行超参数调优时,这一做法就会变得非常有意义。

scikit-learn 中的 VotingClassifier

虽然MajorityVoteClassifier的实现非常适用于演示目的,但我们基于本书第一版中的实现在scikit-learn中实现了一个更复杂的多数投票分类器版本。该集成分类器在scikit-learn版本0.17及更高版本中作为sklearn.ensemble.VotingClassifier可用。

使用多数投票原则进行预测

现在是时候让我们在上一节中实现的MajorityVoteClassifier发挥作用了。但首先,让我们准备一个可以测试的数据集。由于我们已经熟悉从CSV文件加载数据集的技巧,我们将采取捷径并从scikit-learn的datasets模块加载鸢尾花数据集。此外,我们将仅选择两个特征,即萼片宽度花瓣长度,以便更具挑战性地进行分类任务以进行说明。尽管我们的MajorityVoteClassifier适用于多类问题,但我们只会对来自Iris-versicolorIris-virginica类别的花卉示例进行分类,稍后我们将计算ROC AUC。代码如下:

>>> from sklearn import datasets
>>> from sklearn.model_selection import train_test_split
>>> from sklearn.preprocessing import StandardScaler
>>> from sklearn.preprocessing import LabelEncoder
>>> iris = datasets.load_iris()
>>> X, y = iris.data[50:, [1, 2]], iris.target[50:]
>>> le = LabelEncoder()
>>> y = le.fit_transform(y) 

决策树中的类成员概率

请注意,scikit-learn使用predict_proba方法(如果适用)来计算ROC AUC分数。在第3章使用scikit-learn进行机器学习分类器之旅中,我们看到了如何计算逻辑回归模型中的类概率。在决策树中,概率是从在训练时为每个节点创建的频率向量计算出来的。该向量收集从该节点的类标签分布中计算出的每个类标签的频率值。然后,将频率归一化,使它们总和为1。类似地,k最近邻算法中的k个最近邻居的类标签被聚合以返回归一化的类标签频率。尽管从决策树和k最近邻分类器返回的归一化概率看起来类似于从逻辑回归模型获得的概率,但我们必须意识到,这些实际上并非来自概率质量函数。

接下来,我们将把鸢尾花示例分成50%的训练数据和50%的测试数据:

>>> X_train, X_test, y_train, y_test =\
...     train_test_split(X, y,
...                      test_size=0.5,
...                      random_state=1,
...                      stratify=y) 

使用训练数据集,我们现在将训练三种不同的分类器:

  • 逻辑回归分类器

  • 决策树分类器

  • k最近邻分类器

然后,我们将通过训练数据集上的10折交叉验证评估每个分类器的模型性能,然后将它们组合成一个集成分类器:

>>> from sklearn.model_selection import cross_val_score
>>> from sklearn.linear_model import LogisticRegression
>>> from sklearn.tree import DecisionTreeClassifier
>>> from sklearn.neighbors import KNeighborsClassifier
>>> from sklearn.pipeline import Pipeline
>>> import numpy as np
>>> clf1 = LogisticRegression(penalty='l2',
...                           C=0.001,
...                           solver='lbfgs',
...                           random_state=1)
>>> clf2 = DecisionTreeClassifier(max_depth=1,
...                               criterion='entropy',
...                               random_state=0)
>>> clf3 = KNeighborsClassifier(n_neighbors=1,
...                             p=2,
...                             metric='minkowski')
>>> pipe1 = Pipeline([['sc', StandardScaler()],
...                   ['clf', clf1]])
>>> pipe3 = Pipeline([['sc', StandardScaler()],
...                   ['clf', clf3]])
>>> clf_labels = ['Logistic regression', 'Decision tree', 'KNN']
>>> print('10-fold cross validation:\n')
>>> for clf, label in zip([pipe1, clf2, pipe3], clf_labels):
...     scores = cross_val_score(estimator=clf,
...                              X=X_train,
...                              y=y_train,
...                              cv=10,
...                              scoring='roc_auc')
...     print("ROC AUC: %0.2f (+/- %0.2f) [%s]"
...           % (scores.mean(), scores.std(), label)) 

如下片段所示,我们收到的输出显示各个分类器的预测性能几乎相等:

10-fold cross validation:
ROC AUC: 0.92 (+/- 0.15) [Logistic regression]
ROC AUC: 0.87 (+/- 0.18) [Decision tree]
ROC AUC: 0.85 (+/- 0.13) [KNN] 

你可能会想,为什么我们在一个管道中训练了逻辑回归和k近邻分类器。其背后的原因是,正如在第3章中讨论的,《使用 scikit-learn 进行机器学习分类器巡礼》,逻辑回归和k近邻算法(使用欧几里得距离度量)并不具备尺度不变性,这与决策树不同。尽管鸢尾花的特征都是以相同的尺度(厘米)测量的,但使用标准化特征是一个良好的习惯。

现在,让我们进入更有趣的部分,将各个分类器组合在一起,以便在我们的MajorityVoteClassifier中进行多数投票:

>>> mv_clf = MajorityVoteClassifier(
...                  classifiers=[pipe1, clf2, pipe3])
>>> clf_labels += ['Majority voting']
>>> all_clf = [pipe1, clf2, pipe3, mv_clf]
>>> for clf, label in zip(all_clf, clf_labels):
...     scores = cross_val_score(estimator=clf,
...                              X=X_train,
...                              y=y_train,
...                              cv=10,
...                              scoring='roc_auc')
...     print("ROC AUC: %0.2f (+/- %0.2f) [%s]"
...           % (scores.mean(), scores.std(), label))
ROC AUC: 0.92 (+/- 0.15) [Logistic regression]
ROC AUC: 0.87 (+/- 0.18) [Decision tree]
ROC AUC: 0.85 (+/- 0.13) [KNN]
ROC AUC: 0.98 (+/- 0.05) [Majority voting] 

如你所见,MajorityVotingClassifier在 10 次交叉验证评估中相较于单一分类器的表现有所提高。

评估和调整集成分类器

在本节中,我们将从测试数据集中计算 ROC 曲线,以检查MajorityVoteClassifier在处理未见数据时是否能够很好地泛化。我们必须记住,测试数据集不应被用于模型选择;它的目的仅仅是提供分类器系统泛化性能的无偏估计:

>>> from sklearn.metrics import roc_curve
>>> from sklearn.metrics import auc
>>> colors = ['black', 'orange', 'blue', 'green']
>>> linestyles = [':', '--', '-.', '-']
>>> for clf, label, clr, ls \
...     in zip(all_clf, clf_labels, colors, linestyles):
...     # assuming the label of the positive class is 1
...     y_pred = clf.fit(X_train,
...                      y_train).predict_proba(X_test)[:, 1]
...     fpr, tpr, thresholds = roc_curve(y_true=y_test,
...                                      y_score=y_pred)
...     roc_auc = auc(x=fpr, y=tpr)
...     plt.plot(fpr, tpr,
...              color=clr,
...              linestyle=ls,
...              label='%s (auc = %0.2f)' % (label, roc_auc))
>>> plt.legend(loc='lower right')
>>> plt.plot([0, 1], [0, 1],
...          linestyle='--',
...          color='gray',
...          linewidth=2)
>>> plt.xlim([-0.1, 1.1])
>>> plt.ylim([-0.1, 1.1])
>>> plt.grid(alpha=0.5)
>>> plt.xlabel('False positive rate (FPR)')
>>> plt.ylabel('True positive rate (TPR)')
>>> plt.show() 

如你在结果 ROC 曲线中看到的,集成分类器在测试数据集上表现也很出色(ROC AUC = 0.95)。然而,你可以看到逻辑回归分类器在相同数据集上的表现也很相似,这可能是由于数据集较小,导致高方差(在此情况下,是我们划分数据集时的敏感性)所致:

由于我们只选择了两个特征进行分类示例,因此查看集成分类器的决策区域究竟是什么样子会很有趣。

尽管在模型拟合之前标准化训练特征并非必要,因为我们的逻辑回归和k近邻管道会自动处理此操作,但为了视觉目的,我们将标准化训练数据集,以便决策树的决策区域能够在相同的尺度上呈现。代码如下:

>>> sc = StandardScaler()
>>> X_train_std = sc.fit_transform(X_train)
>>> from itertools import product
>>> x_min = X_train_std[:, 0].min() - 1
>>> x_max = X_train_std[:, 0].max() + 1
>>> y_min = X_train_std[:, 1].min() - 1
>>>
>>> y_max = X_train_std[:, 1].max() + 1
>>> xx, yy = np.meshgrid(np.arange(x_min, x_max, 0.1),
...                      np.arange(y_min, y_max, 0.1))
>>> f, axarr = plt.subplots(nrows=2, ncols=2,
...                         sharex='col',
...                         sharey='row',
...                         figsize=(7, 5))
>>> for idx, clf, tt in zip(product([0, 1], [0, 1]),
...                         all_clf, clf_labels):
...     clf.fit(X_train_std, y_train)
...     Z = clf.predict(np.c_[xx.ravel(), yy.ravel()])
...     Z = Z.reshape(xx.shape)
...     axarr[idx[0], idx[1]].contourf(xx, yy, Z, alpha=0.3)
...     axarr[idx[0], idx[1]].scatter(X_train_std[y_train==0, 0],
...                                   X_train_std[y_train==0, 1],
...                                   c='blue',
...                                   marker='^',
...                                   s=50)
...     axarr[idx[0], idx[1]].scatter(X_train_std[y_train==1, 0],
...                                   X_train_std[y_train==1, 1],
...                                   c='green',
...                                   marker='o',
...                                   s=50)
...     axarr[idx[0], idx[1]].set_title(tt)
>>> plt.text(-3.5, -5.,
...          s='Sepal width [standardized]',	
...          ha='center', va='center', fontsize=12)
>>> plt.text(-12.5, 4.5,
...          s='Petal length [standardized]',
...          ha='center', va='center',
...          fontsize=12, rotation=90)
>>> plt.show() 

有趣的是,尽管也是预期中的,集成分类器的决策区域似乎是单个分类器决策区域的混合体。乍一看,多数投票决策边界看起来与决策树树桩的决策非常相似,对于萼片宽度≥1的情况,它与y轴正交。然而,你也可以注意到,k近邻分类器的非线性特征也被混合在其中:

在调整单个分类器的参数以进行集成分类之前,让我们调用get_params方法,初步了解如何在GridSearchCV对象中访问单个参数:

>>> mv_clf.get_params()
{'decisiontreeclassifier':
 DecisionTreeClassifier(class_weight=None, criterion='entropy',
                        max_depth=1, max_features=None,
                        max_leaf_nodes=None, min_samples_leaf=1,
                        min_samples_split=2,
                        min_weight_fraction_leaf=0.0,
                        random_state=0, splitter='best'),
 'decisiontreeclassifier__class_weight': None,
 'decisiontreeclassifier__criterion': 'entropy',
 [...]
 'decisiontreeclassifier__random_state': 0,
 'decisiontreeclassifier__splitter': 'best',
 'pipeline-1':
 Pipeline(steps=[('sc', StandardScaler(copy=True, with_mean=True,
                                       with_std=True)),
                 ('clf', LogisticRegression(C=0.001,
                                            class_weight=None,
                                            dual=False,
                                            fit_intercept=True,
                                            intercept_scaling=1,
                                            max_iter=100,
                                            multi_class='ovr',
                                            penalty='l2',
                                            random_state=0,
                                            solver='liblinear',
                                            tol=0.0001,
                                            verbose=0))]),
 'pipeline-1__clf':
 LogisticRegression(C=0.001, class_weight=None, dual=False,
                    fit_intercept=True, intercept_scaling=1,
                    max_iter=100, multi_class='ovr',
                    penalty='l2', random_state=0,
                    solver='liblinear', tol=0.0001, verbose=0),
 'pipeline-1__clf__C': 0.001,
 'pipeline-1__clf__class_weight': None,
 'pipeline-1__clf__dual': False,
 [...]
 'pipeline-1__sc__with_std': True,
 'pipeline-2':
 Pipeline(steps=[('sc', StandardScaler(copy=True, with_mean=True,
                                       with_std=True)),
                 ('clf', KNeighborsClassifier(algorithm='auto',
                                            leaf_size=30,
                                            metric='minkowski',
                                            metric_params=None,
                                            n_neighbors=1,
                                            p=2,
                                            weights='uniform'))]),
 'pipeline-2__clf':
 KNeighborsClassifier(algorithm='auto', leaf_size=30,
                      metric='minkowski', metric_params=None,
                      n_neighbors=1, p=2, weights='uniform'),
 'pipeline-2__clf__algorithm': 'auto',
 [...]
 'pipeline-2__sc__with_std': True} 

根据 get_params 方法返回的值,我们现在知道如何访问单个分类器的属性。接下来,为了演示,我们将通过网格搜索调整逻辑回归分类器的逆正则化参数 C 和决策树的深度:

>>> from sklearn.model_selection import GridSearchCV
>>> params = {'decisiontreeclassifier__max_depth': [1, 2],
...           'pipeline-1__clf__C': [0.001, 0.1, 100.0]}
>>> grid = GridSearchCV(estimator=mv_clf,
...                     param_grid=params,
...                     cv=10,
...                     iid=False,
...                     scoring='roc_auc')
>>> grid.fit(X_train, y_train) 

在网格搜索完成后,我们可以打印不同的超参数值组合以及通过 10 折交叉验证计算出的平均 ROC AUC 分数,具体如下:

>>> for r, _ in enumerate(grid.cv_results_['mean_test_score']):
...     print("%0.3f +/- %0.2f %r"
...           % (grid.cv_results_['mean_test_score'][r],
...              grid.cv_results_['std_test_score'][r] / 2.0,
...              grid.cv_results_['params'][r]))
0.944 +/- 0.07 {'decisiontreeclassifier__max_depth': 1,
                'pipeline-1__clf__C': 0.001}
0.956 +/- 0.07 {'decisiontreeclassifier__max_depth': 1,
                'pipeline-1__clf__C': 0.1}
0.978 +/- 0.03 {'decisiontreeclassifier__max_depth': 1,
                'pipeline-1__clf__C': 100.0}
0.956 +/- 0.07 {'decisiontreeclassifier__max_depth': 2,
                'pipeline-1__clf__C': 0.001}
0.956 +/- 0.07 {'decisiontreeclassifier__max_depth': 2,
                'pipeline-1__clf__C': 0.1}
0.978 +/- 0.03 {'decisiontreeclassifier__max_depth': 2,
                'pipeline-1__clf__C': 100.0}
>>> print('Best parameters: %s' % grid.best_params_)
Best parameters: {'decisiontreeclassifier__max_depth': 1,
                  'pipeline-1__clf__C': 0.001}
>>> print('Accuracy: %.2f' % grid.best_score_)
Accuracy: 0.98 

正如你所看到的,当我们选择较低的正则化强度(C=0.001)时,我们得到最佳的交叉验证结果,而树的深度似乎对性能没有任何影响,这表明决策树桩足以分离数据。为了提醒自己,使用测试数据集多次进行模型评估是一个不好的做法,我们在本节中不会估计调整超参数的泛化性能。我们将迅速转向集成学习的另一种方法:自助聚合(bagging)

使用堆叠构建集成

我们在本节中实现的多数投票方法不应与堆叠(stacking)混淆。堆叠算法可以理解为一个两层集成方法,其中第一层由独立的分类器组成,这些分类器将它们的预测结果传递给第二层,在第二层中,另一个分类器(通常是逻辑回归)对第一层分类器的预测进行拟合,从而做出最终预测。David H. Wolpert在《堆叠泛化》(Stacked generalization)一文中对此算法进行了更详细的描述,该文章发表在Neural Networks期刊,第5卷第2期,241-259页,1992年。不幸的是,在撰写本文时,scikit-learn 尚未实现该算法的实现;然而,这个功能正在开发中。与此同时,你可以在http://rasbt.github.io/mlxtend/user_guide/classifier/StackingClassifier/http://rasbt.github.io/mlxtend/user_guide/classifier/StackingCVClassifier/ 找到与 scikit-learn 兼容的堆叠实现。

自助聚合 — 从自助抽样中构建分类器集成

自助聚合是一种集成学习技术,与我们在上一节中实现的 MajorityVoteClassifier 密切相关。然而,和使用相同的训练数据集来拟合集成中的单个分类器不同,我们从初始训练数据集中抽取自助样本(带放回的随机样本),这也是为什么自助聚合又被称为 自助抽样聚合(bootstrap aggregating)。

自助聚合的概念在下图中总结:

在接下来的子节中,我们将通过手动方式进行一个简单的自助聚合示例,并使用 scikit-learn 对葡萄酒样本进行分类。

自助聚合概述

为了更具体地展示bagging分类器的自助聚合是如何工作的,我们考虑以下图示的例子。在这里,我们有七个不同的训练实例(表示为索引1-7),在每一轮bagging中都被随机地抽取且允许重复。每个自助样本随后用来拟合一个分类器,,它通常是一个未剪枝的决策树:

从之前的插图中可以看出,每个分类器都接收来自训练数据集的随机子集。我们将通过bagging获得的这些随机样本称为Bagging轮次1Bagging轮次2,依此类推。每个子集包含一定比例的重复样本,且由于有放回抽样,一些原始样本在重新采样的数据集中根本不会出现。一旦个别分类器拟合了这些自助样本,预测结果便通过多数投票结合起来。

请注意,bagging(自助聚合法)与我们在第3章《使用scikit-learn的机器学习分类器概览》中介绍的随机森林分类器也有关。事实上,随机森林是bagging的一个特例,在拟合单个决策树时,我们还会使用随机的特征子集。

使用bagging的模型集成

Bagging首次由Leo Breiman在1994年的一份技术报告中提出;他还展示了bagging可以提高不稳定模型的准确性,并减少过拟合的程度。我强烈推荐你阅读他在Bagging predictors, L. Breiman, Machine Learning, 24(2):123–140, 1996中的研究,文献可以在网上免费获取,以了解更多关于bagging的细节。

应用bagging来对葡萄酒数据集中的样本进行分类

为了展示bagging的实际应用,我们将使用第4章《构建优质训练数据集——数据预处理》中介绍的葡萄酒数据集,创建一个更复杂的分类问题。在这里,我们将只考虑葡萄酒类别2和3,并选择两个特征——酒精稀释酒的OD280/OD315

>>> import pandas as pd
>>> df_wine = pd.read_csv('https://archive.ics.uci.edu/ml/'
...               'machine-learning-databases/wine/wine.data',
...               header=None)
>>> df_wine.columns = ['Class label', 'Alcohol',
...                    'Malic acid', 'Ash',
...                    'Alcalinity of ash',
...                    'Magnesium', 'Total phenols',
...                    'Flavanoids', 'Nonflavanoid phenols',
...                    'Proanthocyanins',
...                    'Color intensity', 'Hue',
...                    'OD280/OD315 of diluted wines',
...                    'Proline']
>>> # drop 1 class
>>> df_wine = df_wine[df_wine['Class label'] != 1]
>>> y = df_wine['Class label'].values
>>> X = df_wine[['Alcohol',
...              'OD280/OD315 of diluted wines']].values 

接下来,我们将把类别标签编码成二进制格式,并将数据集分别分割为80%的训练集和20%的测试集:

>>> from sklearn.preprocessing import LabelEncoder
>>> from sklearn.model_selection import train_test_split
>>> le = LabelEncoder()
>>> y = le.fit_transform(y)
>>> X_train, X_test, y_train, y_test =\
...            train_test_split(X, y,
...                             test_size=0.2,
...                             random_state=1,
...                             stratify=y) 

获取葡萄酒数据集

你可以在本书的代码包中找到葡萄酒数据集的副本(以及本书中使用的所有其他数据集),如果你离线工作或UCI服务器https://archive.ics.uci.edu/ml/machine-learning-databases/wine/wine.data暂时不可用,你可以使用它。例如,要从本地目录加载葡萄酒数据集,可以执行以下代码:

df = pd.read_csv(
         'https://archive.ics.uci.edu/ml/'
         'machine-learning-databases'
         '/wine/wine.data', header=None) 

并将其替换为以下内容:

df = pd.read_csv(
         'your/local/path/to/wine.data',
         header=None) 

BaggingClassifier算法已经在scikit-learn中实现,我们可以从ensemble子模块中导入。这里,我们将使用未修剪的决策树作为基础分类器,并创建一个由500棵决策树组成的集成,这些决策树在训练数据集的不同自助抽样样本上进行拟合:

>>> from sklearn.ensemble import BaggingClassifier
>>> tree = DecisionTreeClassifier(criterion='entropy',
...                               random_state=1,
...                               max_depth=None)
>>> bag = BaggingClassifier(base_estimator=tree,
...                         n_estimators=500,
...                         max_samples=1.0,
...                         max_features=1.0,
...                         bootstrap=True,
...                         bootstrap_features=False,
...                         n_jobs=1,
...                         random_state=1) 

接下来,我们将计算训练数据集和测试数据集上的预测准确度,以便将袋装分类器的表现与单个未修剪决策树的表现进行比较:

>>> from sklearn.metrics import accuracy_score
>>> tree = tree.fit(X_train, y_train)
>>> y_train_pred = tree.predict(X_train)
>>> y_test_pred = tree.predict(X_test)
>>> tree_train = accuracy_score(y_train, y_train_pred)
>>> tree_test = accuracy_score(y_test, y_test_pred)
>>> print('Decision tree train/test accuracies %.3f/%.3f'
...       % (tree_train, tree_test))
Decision tree train/test accuracies 1.000/0.833 

根据我们在这里打印的准确度值,未修剪的决策树正确地预测了所有训练示例的类别标签;然而,明显较低的测试准确度表明模型的方差较高(过拟合):

>>> bag = bag.fit(X_train, y_train)
>>> y_train_pred = bag.predict(X_train)
>>> y_test_pred = bag.predict(X_test)
>>> bag_train = accuracy_score(y_train, y_train_pred)
>>> bag_test = accuracy_score(y_test, y_test_pred)
>>> print('Bagging train/test accuracies %.3f/%.3f'
...       % (bag_train, bag_test))
Bagging train/test accuracies 1.000/0.917 

尽管决策树和袋装分类器在训练数据集上的训练准确度相似(都是100%),但我们可以看到,袋装分类器在估计的测试数据集上的泛化性能稍好。接下来,让我们比较决策树和袋装分类器的决策区域:

>>> x_min = X_train[:, 0].min() - 1
>>> x_max = X_train[:, 0].max() + 1
>>> y_min = X_train[:, 1].min() - 1
>>> y_max = X_train[:, 1].max() + 1
>>> xx, yy = np.meshgrid(np.arange(x_min, x_max, 0.1),
...                      np.arange(y_min, y_max, 0.1))
>>> f, axarr = plt.subplots(nrows=1, ncols=2,
...                         sharex='col',
...                         sharey='row',
...                         figsize=(8, 3))
>>> for idx, clf, tt in zip([0, 1],
...                         [tree, bag],
...                         ['Decision tree', 'Bagging']):
...     clf.fit(X_train, y_train)
...
...     Z = clf.predict(np.c_[xx.ravel(), yy.ravel()])
...     Z = Z.reshape(xx.shape)
...     axarr[idx].contourf(xx, yy, Z, alpha=0.3)
...     axarr[idx].scatter(X_train[y_train==0, 0],
...                        X_train[y_train==0, 1],
...                        c='blue', marker='^')
...     axarr[idx].scatter(X_train[y_train==1, 0],
...                        X_train[y_train==1, 1],
...                        c='green', marker='o')
...     axarr[idx].set_title(tt)
>>> axarr[0].set_ylabel('Alcohol', fontsize=12)
>>> plt.tight_layout()
>>> plt.text(0, -0.2,
...          s='OD280/OD315 of diluted wines',
...          ha='center',
...          va='center',
...          fontsize=12,
...          transform=axarr[1].transAxes)
>>> plt.show() 

从结果图中可以看到,三节点深度决策树的分段线性决策边界在袋装集成中看起来更平滑:

我们在这一节中只看了一个非常简单的袋装示例。在实际应用中,更多复杂的分类任务和数据集的高维度很容易导致单个决策树出现过拟合,而此时袋装算法正好能够发挥其优势。最后,我们必须注意,袋装算法在降低模型方差方面是一种有效的方法。然而,袋装并不能有效降低模型的偏差,也就是说,对于那些过于简单,无法很好地捕捉数据趋势的模型,袋装并不起作用。这就是为什么我们希望对低偏差的分类器集成进行袋装,比如未修剪的决策树。

通过自适应提升(AdaBoost)利用弱学习器

在这一节关于集成方法的最后,我们将讨论提升,特别关注其最常见的实现:自适应提升AdaBoost)。

AdaBoost识别

AdaBoost的最初理念是由Robert E. Schapire于1990年提出的。弱学习能力的强度R. E. Schapire机器学习,5(2):197-227,1990。在Robert Schapire和Yoav Freund于第十三届国际会议论文集(ICML 1996)上提出AdaBoost算法后,AdaBoost成为随后几年最广泛使用的集成方法之一(Y. FreundR. E. Schapire等人的新提升算法实验ICML,第96卷,148-156,1996)。2003年,Freund和Schapire因其开创性的工作获得了Gödel奖,这是计算机科学领域最具声望的奖项,授予最杰出的出版物。

在提升算法中,集成模型由非常简单的基本分类器组成,这些分类器通常被称为弱学习器,它们在性能上通常仅稍微优于随机猜测——一个典型的弱学习器例子是决策树桩。提升的关键概念是聚焦于那些难以分类的训练示例,即让弱学习器从错误分类的训练示例中学习,以提升集成模型的性能。

以下小节将介绍提升和AdaBoost背后的算法过程。最后,我们将使用scikit-learn进行一个实际的分类示例。

提升算法如何工作

与袋装法(bagging)不同,提升算法的初始形式使用的是从训练数据集中随机抽取的训练示例子集,且不进行替换;原始的提升过程可以总结为以下四个关键步骤:

  1. 从训练数据集中抽取一个随机子集(样本),,并且不进行替换,从数据集D中训练一个弱学习器,

  2. 从训练数据集中抽取第二个随机训练子集,,并且不进行替换,同时将之前被错误分类的50%的示例加入其中,来训练一个弱学习器,

  3. 在训练数据集D中找到那些与存在分歧的训练示例,,来训练第三个弱学习器,

  4. 通过多数投票将弱学习器结合起来。

正如Leo Breiman(偏差、方差与弧形分类器L. Breiman1996)所讨论的,提升方法相比袋装法可以减少偏差和方差。然而,实际上,像AdaBoost这样的提升算法也因其较高的方差而著名,即它们有过拟合训练数据的倾向(AdaBoost的改进以避免过拟合G. RaetschT. OnodaK. R. Mueller神经信息处理国际会议论文集CiteSeer1998)。

与此处描述的原始提升过程不同,AdaBoost使用完整的训练数据集来训练弱学习器,其中在每次迭代中重新加权训练示例,以构建一个强分类器,使其能够从之前弱学习器的错误中学习。

在深入探讨AdaBoost算法的具体细节之前,我们先看一下下面的图,以便更好地理解AdaBoost背后的基本概念:

为了逐步演示 AdaBoost,我们从子图 1 开始,该子图表示一个用于二分类的训练数据集,所有训练示例都被分配了相等的权重。基于该训练数据集,我们训练了一个决策桩(以虚线表示),它试图对两类(三角形和圆形)的示例进行分类,同时尽可能地最小化代价函数(或者在决策树集成的特殊情况下最小化杂质分数)。

对于下一轮(子图 2),我们为之前被误分类的两个示例(圆形)分配更大的权重。同时,我们降低了正确分类的示例的权重。下一次决策桩将更加关注权重较大的训练示例——这些训练示例通常是难以分类的。在子图 2 中显示的弱学习器将三个来自圆形类的不同示例误分类,这些示例随后被赋予更大的权重,如子图 3 所示。

假设我们的 AdaBoost 集成仅由三轮提升组成,我们通过加权多数投票将训练在不同重加权训练子集上的三个弱学习器组合起来,如子图 4 所示。

现在我们对 AdaBoost 的基本概念有了更清楚的了解,接下来我们将使用伪代码更加详细地了解该算法。为了清晰起见,我们将用叉号()表示逐元素乘法,用点号()表示两个向量的点积:

  1. 将权重向量 w 设置为均匀权重,其中

  2. 对于 m 次提升轮中的 j,执行以下操作:

    1. 训练一个加权的弱学习器:

    2. 预测类别标签:

    3. 计算加权错误率:

    4. 计算系数:

    5. 更新权重:

    6. 将权重归一化使其总和为 1:

  3. 计算最终预测:

注意,步骤 2c 中的表达式 表示一个由 1 和 0 组成的二进制向量,其中如果预测错误则赋值为 1,否则赋值为 0。

尽管 AdaBoost 算法看起来非常简单,但我们可以通过一个包含 10 个训练示例的训练数据集来详细演示,具体如以下表格所示:

表的第一列显示了训练样本1到10的索引。在第二列中,您可以看到单个样本的特征值,假设这是一个一维数据集。第三列显示了每个训练样本 的真实类标签,其中,其中。初始权重显示在第四列中;我们均匀初始化权重(分配相同的常量值),并将它们归一化为总和为1。对于10个样本的训练数据集,我们因此将0.1分配给权重向量w中的每个权重。假设我们的分割标准是,则第五列显示了预测的类标签。然后,表的最后一列根据我们在伪代码中定义的更新规则显示了更新后的权重。

由于权重更新的计算可能一开始看起来有点复杂,我们现在将逐步跟随计算。我们将从计算加权错误率 开始,如第2c步所述:

接下来,我们将计算系数,—如第2d步所示—这将在第2e步中用于更新权重,以及在多数投票预测(第3步)中使用权重:

在我们计算系数 后,现在可以使用以下方程更新权重向量:

这里, 是预测和真实类标签向量的逐元素乘积。因此,如果预测 正确, 将具有正号,因此我们减少第i个权重,因为 也是一个正数:

类似地,如果 预测的标签是错误的,我们将增加第i个权重:

或者说,它就像这样:

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

这里,

因此,对于每个正确分类的样本对应的权重,其将从初始值0.1减少到,用于下一轮增强。类似地,错误分类样本的权重将从0.1增加到

使用scikit-learn应用AdaBoost

前一小节简要介绍了AdaBoost。跳到更实际的部分,我们现在通过scikit-learn来训练一个AdaBoost集成分类器。我们将使用前一节中用于训练bagging元分类器的相同Wine子集。通过base_estimator属性,我们将在500个决策树桩上训练AdaBoostClassifier

>>> from sklearn.ensemble import AdaBoostClassifier
>>> tree = DecisionTreeClassifier(criterion='entropy',
...                               random_state=1,
...                               max_depth=1)
>>> ada = AdaBoostClassifier(base_estimator=tree,
...                          n_estimators=500,
...                          learning_rate=0.1,
...                          random_state=1)
>>> tree = tree.fit(X_train, y_train)
>>> y_train_pred = tree.predict(X_train)
>>> y_test_pred = tree.predict(X_test)
>>> tree_train = accuracy_score(y_train, y_train_pred)
>>> tree_test = accuracy_score(y_test, y_test_pred)
>>> print('Decision tree train/test accuracies %.3f/%.3f'
...       % (tree_train, tree_test))
Decision tree train/test accuracies 0.916/0.875 

如你所见,决策树桩似乎相较于我们在前一节看到的未经剪枝的决策树,对训练数据进行了欠拟合:

>>> ada = ada.fit(X_train, y_train)
>>> y_train_pred = ada.predict(X_train)
>>> y_test_pred = ada.predict(X_test)
>>> ada_train = accuracy_score(y_train, y_train_pred)
>>> ada_test = accuracy_score(y_test, y_test_pred)
>>> print('AdaBoost train/test accuracies %.3f/%.3f'
...       % (ada_train, ada_test))
AdaBoost train/test accuracies 1.000/0.917 

在这里,你可以看到AdaBoost模型正确预测了训练数据集的所有类别标签,并且与决策树桩相比,测试数据集的性能也有所提高。然而,你也可以看到,由于我们试图减少模型偏差,导致了额外的方差——即训练和测试性能之间的差距更大。

虽然我们使用了另一个简单的示例来演示,但我们可以看到,AdaBoost分类器的性能相比决策树桩有所改善,并且与我们在前一节中训练的bagging分类器达到了非常相似的准确率。然而,我们必须注意,基于反复使用测试数据集来选择模型被认为是不良做法。对泛化性能的估计可能过于乐观,关于这一点我们在第六章中有更详细的讨论,模型评估与超参数调优的最佳实践学习

最后,让我们检查一下决策区域的情况:

>>> x_min = X_train[:, 0].min() - 1
>>> x_max = X_train[:, 0].max() + 1
>>> y_min = X_train[:, 1].min() - 1
>>> y_max = X_train[:, 1].max() + 1
>>> xx, yy = np.meshgrid(np.arange(x_min, x_max, 0.1),
...                      np.arange(y_min, y_max, 0.1))
>>> f, axarr = plt.subplots(1, 2,
...                         sharex='col',
...                         sharey='row',
...                         figsize=(8, 3))
>>> for idx, clf, tt in zip([0, 1],
...                         [tree, ada],
...                         ['Decision Tree', 'AdaBoost']):
...     clf.fit(X_train, y_train)
...     Z = clf.predict(np.c_[xx.ravel(), yy.ravel()])
...     Z = Z.reshape(xx.shape)
...     axarr[idx].contourf(xx, yy, Z, alpha=0.3)
...     axarr[idx].scatter(X_train[y_train==0, 0],
...                        X_train[y_train==0, 1],
...                        c='blue',
...                        marker='^')
...     axarr[idx].scatter(X_train[y_train==1, 0],
...                        X_train[y_train==1, 1],
...                        c='green',
...                        marker='o')
...     axarr[idx].set_title(tt)
...     axarr[0].set_ylabel('Alcohol', fontsize=12)
>>> plt.tight_layout()
>>> plt.text(0, -0.2,
...          s='OD280/OD315 of diluted wines',
...          ha='center',
...          va='center',
...          fontsize=12,
...          transform=axarr[1].transAxes)
>>> plt.show() 

通过查看决策区域,可以看到AdaBoost模型的决策边界比决策树桩的决策边界复杂得多。此外,注意到AdaBoost模型的特征空间划分与我们在前一节中训练的bagging分类器非常相似:

作为对集成技术的总结,值得注意的是,集成学习相比单个分类器增加了计算复杂度。在实践中,我们需要仔细考虑是否愿意为相对适度的预测性能提升支付增加的计算成本。

一个常被引用的关于这种权衡的例子是著名的100万美元Netflix大奖,该大奖通过集成技术获得。关于该算法的细节已发布在A. ToescherM. JahrerR. M. Bell的《Netflix大奖的BigChaos解决方案》中,Netflix Prize文档2009年,可以在http://www.stat.osu.edu/~dmsl/GrandPrize2009_BPC_BigChaos.pdf查看。获胜团队获得了100万美元的大奖奖金;然而,由于模型的复杂性,Netflix从未实现他们的模型,因为它对于实际应用来说不可行:

“我们离线评估了一些新的方法,但我们测量的额外准确性提升似乎并不足以证明将它们引入生产环境所需的工程工作量是值得的。”

http://techblog.netflix.com/2012/04/netflix-recommendations-beyond-5-stars.html

梯度提升

另一种流行的提升方法是梯度提升。AdaBoost和梯度提升共享一个主要的整体概念:将弱学习器(例如决策树桩)提升为强学习器。这两种方法——自适应提升和梯度提升——主要的区别在于权重的更新方式以及如何结合(弱)分类器。如果你熟悉基于梯度的优化并对梯度提升感兴趣,我推荐阅读Jerome Friedman的工作(贪婪函数近似:一种梯度提升机Jerome FriedmanAnnals of Statistics 2001,第1189-1232页)以及关于XGBoost的最新论文,XGBoost本质上是原始梯度提升算法的一种计算高效实现(XGBoost:一种可扩展的树提升系统Tianqi ChenCarlos GuestrinProceeding of the 22nd ACM SIGKDD International Conference on Knowledge Discovery and Data MiningACM 2016,第785-794页)。请注意,除了scikit-learn中的GradientBoostingClassifier实现,scikit-learn现在还在0.21版本中包含了一种显著更快的梯度提升版本——HistGradientBoostingClassifier,它比XGBoost还要快。有关scikit-learn中GradientBoostingClassifierHistGradientBoostingClassifier的更多信息,你可以阅读文档:https://scikit-learn.org/stable/modules/ensemble.html#gradient-tree-boosting。此外,梯度提升的简短通用解释可以在以下讲义中找到:https://sebastianraschka.com/pdf/lecture-notes/stat479fs19/07-ensembles__notes.pdf

概述

在本章中,我们讨论了一些最受欢迎和广泛使用的集成学习技术。集成方法将不同的分类模型结合起来,消除它们各自的弱点,这通常会导致稳定且表现优异的模型,这些模型对工业应用以及机器学习竞赛都非常具有吸引力。

在本章开始时,我们在 Python 中实现了 MajorityVoteClassifier,它允许我们将不同的分类算法结合起来。接着我们了解了袋装法(bagging),这是一种通过从训练数据集中随机抽取自助样本,并通过多数投票结合单独训练的分类器来降低模型方差的有用技术。最后,我们学习了 AdaBoost,它是一种基于弱学习者的算法,弱学习者通过从错误中学习来改进。

在前几章中,我们学到了许多关于不同学习算法、调优和评估技术的内容。在下一章中,我们将探讨机器学习的一个特定应用——情感分析,它已成为互联网和社交媒体时代的一个有趣话题。

第八章:将机器学习应用于情感分析

在现代互联网和社交媒体时代,人们的意见、评论和推荐已经成为政治学和商业领域的宝贵资源。得益于现代技术,我们现在能够最有效地收集和分析这些数据。在本章中,我们将深入探讨自然语言处理(NLP)的一个子领域——情感分析,并学习如何使用机器学习算法根据文档的极性(作者的态度)来分类文档。特别地,我们将使用来自互联网电影数据库IMDb)的50,000条电影评论数据集,构建一个预测模型,能够区分正面评论和负面评论。

我们将在接下来的章节中讨论以下主题:

  • 清理和准备文本数据

  • 从文本文档中构建特征向量

  • 训练机器学习模型以分类正面和负面电影评论

  • 使用外部学习处理大型文本数据集

  • 从文档集合中推断主题进行分类

准备IMDb电影评论数据以进行文本处理

如前所述,情感分析,有时也叫做意见挖掘,是自然语言处理(NLP)这一广泛领域中的一个热门子学科;它关注的是分析文档的极性。情感分析中的一个常见任务是基于作者对某一特定话题表达的意见或情感对文档进行分类。

在本章中,我们将使用来自互联网电影数据库(IMDb)的一个大型电影评论数据集,该数据集由Andrew Maas等人收集(Learning Word Vectors for Sentiment Analysis, A. L. Maas, R. E. Daly, P. T. Pham, D. Huang, A. Y. Ng, and C. Potts, Proceedings of the 49th Annual Meeting of the Association for Computational Linguistics: Human Language Technologies, pages 142–150, Portland, Oregon, USA, Association for Computational Linguistics, June 2011)。该电影评论数据集包含50,000条极性电影评论,每条评论被标记为正面或负面;其中,正面表示电影在IMDb上的评分超过六星,负面表示电影在IMDb上的评分低于五星。在接下来的章节中,我们将下载数据集,进行预处理,将其转换为适用于机器学习工具的格式,并从这些电影评论的子集提取有意义的信息,构建一个机器学习模型,预测某个评论者是否喜欢或不喜欢一部电影。

获取电影评论数据集

可以从http://ai.stanford.edu/~amaas/data/sentiment/下载电影评论数据集的压缩档案(84.1 MB),该档案为gzip压缩的tarball格式:

  • 如果你使用的是 Linux 或 macOS,可以打开一个新的终端窗口,进入下载目录并执行 tar -zxf aclImdb_v1.tar.gz 来解压数据集。

  • 如果你使用的是 Windows,可以下载一个免费的压缩工具,如 7-Zip (http://www.7-zip.org),用来从下载档案中提取文件。

  • 或者,你也可以直接在 Python 中解压 gzip 压缩的 tarball 文件,方法如下:

    >>> import tarfile
    >>> with tarfile.open('aclImdb_v1.tar.gz', 'r:gz') as tar:
    ...     tar.extractall() 
    

将电影数据集预处理为更方便的格式

在成功解压数据集后,我们将把解压后的下载档案中的各个文本文件合并成一个单一的 CSV 文件。在接下来的代码段中,我们将把电影评论读取到一个 pandas DataFrame 对象中,这个过程在标准桌面计算机上可能需要最多 10 分钟。

为了可视化进度和预计完成时间,我们将使用 Python 进度指示器 (PyPrind, https://pypi.python.org/pypi/PyPrind/) 包,该包是几年前为此类目的开发的。你可以通过执行 pip install pyprind 命令来安装 PyPrind:

>>> import pyprind
>>> import pandas as pd
>>> import os
>>> # change the 'basepath' to the directory of the
>>> # unzipped movie dataset
>>> basepath = 'aclImdb'
>>>
>>> labels = {'pos': 1, 'neg': 0}
>>> pbar = pyprind.ProgBar(50000)
>>> df = pd.DataFrame()
>>> for s in ('test', 'train'):
...     for l in ('pos', 'neg'):
...         path = os.path.join(basepath, s, l)
...         for file in sorted(os.listdir(path)):
...             with open(os.path.join(path, file),
...                       'r', encoding='utf-8') as infile:
...                 txt = infile.read()
...             df = df.append([[txt, labels[l]]],
...                            ignore_index=True)
...             pbar.update()
>>> df.columns = ['review', 'sentiment']
0%                          100%
[##############################] | ETA: 00:00:00
Total time elapsed: 00:02:05 

在前面的代码中,我们首先初始化了一个新的进度条对象 pbar,设置为 50,000 次迭代,这是我们要读取的文档数量。通过嵌套的 for 循环,我们遍历了主 aclImdb 目录中的 traintest 子目录,并从 posneg 子目录中读取了各个文本文件,最终将它们与整数分类标签(1 = 正面,0 = 负面)一起添加到 df pandas DataFrame 中。

由于合并后的数据集中的类标签是排序的,我们现在将使用 np.random 子模块中的 permutation 函数打乱 DataFrame——这将在后续部分中非常有用,当我们直接从本地驱动器流式传输数据时,用于将数据集分为训练集和测试集。

为了方便起见,我们还将把合并并打乱的电影评论数据集存储为 CSV 文件:

>>> import numpy as np
>>> np.random.seed(0)
>>> df = df.reindex(np.random.permutation(df.index))
>>> df.to_csv('movie_data.csv', index=False, encoding='utf-8') 

由于我们将在本章后续部分使用这个数据集,让我们快速确认一下我们是否成功地将数据保存为正确的格式,通过读取 CSV 文件并打印前三个示例的部分内容:

>>> df = pd.read_csv('movie_data.csv', encoding='utf-8')
>>> df.head(3) 

如果你在 Jupyter Notebook 中运行代码示例,你现在应该能看到数据集的前三个示例,如下表所示:

作为一个合理性检查,在我们继续下一节之前,让我们确认一下 DataFrame 中包含了所有 50,000 行数据:

>>> df.shape
(50000, 2) 

介绍词袋模型

你可能还记得在第4章构建良好的训练数据集—数据预处理中,我们需要将分类数据(如文本或单词)转换为数值形式,才能将其传递给机器学习算法。在本节中,我们将介绍词袋模型,它允许我们将文本表示为数值特征向量。词袋模型的核心思想非常简单,可以总结如下:

  1. 我们从整个文档集合中创建唯一标记的词汇表——例如,单词。

  2. 我们从每个文档中构建特征向量,该向量包含每个单词在特定文档中出现的次数。

由于每个文档中的唯一单词仅代表词袋词汇表中所有单词的一个小子集,因此特征向量大部分将由零组成,这就是为什么我们称它们为稀疏的原因。如果这听起来太抽象,不用担心;在接下来的小节中,我们将一步步演示如何创建一个简单的词袋模型。

将单词转换为特征向量

为了基于各个文档中的单词计数构建一个词袋模型,我们可以使用scikit-learn中实现的CountVectorizer类。如你将在接下来的代码部分看到的那样,CountVectorizer接受一个文本数据数组,这些数据可以是文档或句子,并为我们构建词袋模型:

>>> import numpy as np
>>> from sklearn.feature_extraction.text import CountVectorizer
>>> count = CountVectorizer()
>>> docs = np.array(['The sun is shining',
...                  'The weather is sweet',
...                  'The sun is shining, the weather is sweet,'
...                  'and one and one is two'])
>>> bag = count.fit_transform(docs) 

通过在CountVectorizer上调用fit_transform方法,我们构建了词袋模型的词汇表,并将以下三个句子转换为稀疏特征向量:

  • '太阳在照耀'

  • '天气很甜美'

  • '太阳在照耀,天气很甜美,一加一等于二'

现在,让我们打印出词汇表的内容,以更好地理解其中的基本概念:

>>> print(count.vocabulary_)
{'and': 0,
'two': 7,
'shining': 3,
'one': 2,
'sun': 4,
'weather': 8,
'the': 6,
'sweet': 5,
'is': 1} 

如你从执行前面的命令中看到的那样,词汇表存储在一个Python字典中,该字典将唯一的单词映射到整数索引。接下来,让我们打印出我们刚刚创建的特征向量:

>>> print(bag.toarray())
[[0 1 0 1 1 0 1 0 0]
 [0 1 0 0 0 1 1 0 1]
 [2 3 2 1 1 1 2 1 1]] 

此处显示的特征向量中的每个索引位置对应于存储为字典项的CountVectorizer词汇表中的整数值。例如,索引位置0的第一个特征表示单词'and'的出现次数,这个单词只出现在最后一个文档中,而索引位置1的单词'is'(文档向量中的第二个特征)在所有三个句子中都出现。这些特征向量中的值也称为原始词频tf(t, d)——词汇t在文档d中出现的次数。需要注意的是,在词袋模型中,句子或文档中单词或词语的顺序并不重要。词频在特征向量中出现的顺序是根据词汇表中的索引派生的,这些索引通常是按字母顺序分配的。

N-gram模型

我们刚刚创建的词袋模型中的项目序列也被称为 1-gramunigram 模型——词汇表中的每个项目或标记代表一个单词。更一般地说,NLP 中的连续项序列——词、字母或符号——也被称为 n-grams。n-gram 模型中的数字 n 选择取决于具体应用;例如,Ioannis Kanaris 等人进行的研究表明,大小为 3 和 4 的 n-gram 在反垃圾邮件邮件过滤中表现良好(Words versus character n-grams for anti-spam filtering, Ioannis Kanaris, Konstantinos Kanaris, Ioannis Houvardas, 和 Efstathios Stamatatos, International Journal on Artificial Intelligence Tools, World Scientific Publishing Company, 16(06): 1047-1067, 2007)。

为了总结 n-gram 表示法的概念,我们将构建我们第一个文档 "the sun is shining" 的 1-gram 和 2-gram 表示法如下:

  • 1-gram: "the", "sun", "is", "shining"

  • 2-gram: "the sun", "sun is", "is shining"

scikit-learn 中的 CountVectorizer 类允许我们通过其 ngram_range 参数使用不同的 n-gram 模型。默认情况下使用的是 1-gram 表示法,我们也可以通过用 ngram_range=(2,2) 初始化新的 CountVectorizer 实例来切换到 2-gram 表示法。

通过词频-逆文档频率评估词语相关性

在分析文本数据时,我们常常遇到一些在多个文档中都出现的词语。这些高频词通常不包含有用的或区分性的的信息。在本小节中,您将学习一种名为 词频-逆文档频率tf-idf)的有用技术,它可以用于降低这些高频词在特征向量中的权重。tf-idf 可以定义为词频和逆文档频率的乘积:

这里,tf(t, d) 是我们在上一节中介绍的词频,idf(t, d) 是逆文档频率,可以通过以下方式计算:

这里, 是文档总数,df(d, t) 是包含词项 t 的文档数 d。注意,给分母加上常数 1 是可选的,它的目的是为那些在任何训练样本中都没有出现的词项分配一个非零值;使用 log 是为了确保低文档频率不会被赋予过多的权重。

scikit-learn 库还实现了另一个变换器——TfidfTransformer 类,它将 CountVectorizer 类的原始词频作为输入,并将其转换为 tf-idf:

>>> from sklearn.feature_extraction.text import TfidfTransformer
>>> tfidf = TfidfTransformer(use_idf=True,
...                          norm='l2',
...                          smooth_idf=True)
>>> np.set_printoptions(precision=2)
>>> print(tfidf.fit_transform(count.fit_transform(docs))
...       .toarray())
[[ 0\.    0.43  0\.    0.56  0.56  0\.    0.43  0\.    0\.  ]
 [ 0\.    0.43  0\.    0\.    0\.    0.56  0.43  0\.    0.56]
 [ 0.5   0.45  0.5   0.19  0.19  0.19  0.3   0.25  0.19]] 

正如你在前一个小节中看到的,单词'is'在第三个文档中的词频最高,是最频繁出现的单词。然而,在将相同的特征向量转化为tf-idf后,单词'is'现在在第三个文档中的tf-idf值相对较小(0.45),因为它也出现在第一和第二个文档中,因此不太可能包含任何有用的区分信息。

然而,如果我们手动计算了特征向量中各个词的tf-idf值,我们会注意到TfidfTransformer计算tf-idf的方式与我们之前定义的标准教科书公式略有不同。scikit-learn中实现的逆文档频率公式计算如下:

类似地,scikit-learn中计算的tf-idf与我们之前定义的标准公式略有不同:

请注意,前面公式中的“+1”是由于在之前的代码示例中设置了smooth_idf=True,这有助于对出现在所有文档中的词汇赋予零权重(即,idftd)= log(1) = 0)。

虽然在计算tf-idf之前对原始词频进行归一化更为常见,但TfidfTransformer类直接对tf-idf进行归一化。默认情况下(norm='l2'),scikit-learn的TfidfTransformer应用L2归一化,这通过将未归一化的特征向量v除以其L2范数,返回一个长度为1的向量:

为了确保我们理解TfidfTransformer的工作原理,让我们通过一个示例来计算单词'is'在第三个文档中的tf-idf值。单词'is'在第三个文档中的词频是3(tf = 3),并且该词的文档频率为3,因为单词'is'出现在所有三个文档中(df = 3)。因此,我们可以按如下方式计算逆文档频率:

现在,为了计算tf-idf,我们只需要将1加到逆文档频率上,然后将其与词频相乘:

如果我们对第三个文档中的所有词语重复此计算,我们将得到以下tf-idf向量:[3.39, 3.0, 3.39, 1.29, 1.29, 1.29, 2.0, 1.69, 1.29]。然而,注意到这个特征向量中的数值与我们之前使用的TfidfTransformer得到的值不同。我们在这个tf-idf计算中缺少的最后一步是L2归一化,可以按如下方式应用:

如你所见,结果现在与scikit-learn的TfidfTransformer返回的结果一致,既然你现在理解了tf-idf是如何计算的,让我们继续进入下一部分,并将这些概念应用到电影评论数据集上。

清理文本数据

在前面的子章节中,我们了解了词袋模型、词频和TF-IDF。然而,首先重要的一步——在构建我们的词袋模型之前——是通过去除所有不需要的字符来清理文本数据。

为了说明这一点的重要性,让我们展示重排后的电影评论数据集中第一篇文档的最后50个字符:

>>> df.loc[0, 'review'][-50:]
'is seven.<br /><br />Title (Brazil): Not Available' 

正如你在这里看到的,文本中包含了HTML标记、标点符号和其他非字母字符。虽然HTML标记并不包含太多有用的语义,但在某些自然语言处理(NLP)上下文中,标点符号可以表示有用的附加信息。不过,为了简化,我们现在将移除所有标点符号,除了表情符号字符,如:😃 ,因为这些显然对于情感分析是有用的。为了完成这个任务,我们将使用Python的正则表达式regex)库re,如下面所示:

>>> import re
>>> def preprocessor(text):
...     text = re.sub('<[^>]*>', '', text)
...     emoticons = re.findall('(?::|;|=)(?:-)?(?:\)|\(|D|P)',
...                            text)
...     text = (re.sub('[\W]+', ' ', text.lower()) +
...             ' '.join(emoticons).replace('-', ''))
...     return text 

通过前面代码段中的第一个正则表达式<[^>]*>,我们尝试从电影评论中移除所有的HTML标记。虽然许多程序员一般不建议使用正则表达式来解析HTML,但这个正则表达式应该足以清理这个特定的数据集。由于我们只关心移除HTML标记,并且不打算进一步使用HTML标记,因此使用正则表达式来完成这项工作是可以接受的。不过,如果你更倾向于使用更复杂的工具来从文本中移除HTML标记,可以查看Python的HTML解析器模块,相关内容描述在https://docs.python.org/3/library/html.parser.html中。在我们移除HTML标记之后,我们使用了一个稍微复杂一些的正则表达式来查找表情符号,并将其临时存储为表情符号。接着,我们通过正则表达式[\W]+移除文本中的所有非单词字符,并将文本转换为小写字母。

处理单词大小写

在本次分析的背景下,我们假设一个单词的大小写——例如,单词是否出现在句子的开头——不包含有意义的语义信息。然而,需要注意的是,也有例外;例如,我们会去除专有名词的标记。但在本次分析的背景下,我们简化假设字母的大小写并不包含对于情感分析相关的信息。

最终,我们将临时存储的表情符号添加到处理后文档字符串的末尾。此外,我们还为了保持一致性,去除了表情符号中的鼻子字符(:-)中的-)。

正则表达式

虽然正则表达式提供了一种高效且方便的方式来搜索字符串中的字符,但它们也有陡峭的学习曲线。不幸的是,关于正则表达式的深入讨论超出了本书的范围。然而,你可以在Google开发者门户网站找到一个很好的教程,地址是https://developers.google.com/edu/python/regular-expressions,或者你也可以查看Python的re模块的官方文档,网址是https://docs.python.org/3.7/library/re.html

尽管将表情符号字符添加到清理后的文档字符串末尾可能看起来不是最优雅的方法,但我们必须注意,如果我们的词汇表仅包含单词级别的标记,那么在我们的词袋模型中,单词的顺序并不重要。但在我们进一步讨论如何将文档拆分为单独的术语、单词或标记之前,让我们先确认我们的preprocessor函数是否正常工作:

>>> preprocessor(df.loc[0, 'review'][-50:])
'is seven title brazil not available'
>>> preprocessor("</a>This :) is :( a test :-)!")
'this is a test :) :( :)' 

最后,由于我们将在接下来的章节中反复使用清理后的文本数据,让我们现在将我们的preprocessor函数应用到DataFrame中的所有电影评论:

>>> df['review'] = df['review'].apply(preprocessor) 

将文档处理成标记

成功准备好电影评论数据集后,我们现在需要考虑如何将文本语料库拆分成单独的元素。分词文档的一种方法是通过在清理后的文档中按照空白字符拆分它们,将文档分割成单个单词:

>>> def tokenizer(text):
...     return text.split()
>>> tokenizer('runners like running and thus they run')
['runners', 'like', 'running', 'and', 'thus', 'they', 'run'] 

在分词的背景下,另一个有用的技术是词干提取,即将一个词转化为其词根形式的过程。它允许我们将相关的词映射到同一个词干。最初的词干提取算法是由Martin F. Porter于1979年开发的,因此被称为Porter词干提取算法一个后缀剥离算法Martin F. Porter程序:电子图书与信息系统,14(3):130–137,1980)。Python的自然语言工具包NLTKhttp://www.nltk.org)实现了Porter词干提取算法,我们将在以下代码部分中使用它。为了安装NLTK,你可以简单地执行conda install nltkpip install nltk

NLTK在线书籍

尽管NLTK不是本章的重点,但如果你对NLP中的更高级应用感兴趣,我强烈推荐你访问NLTK网站,并阅读官方的NLTK书籍,该书籍可以在http://www.nltk.org/book/免费获得。

以下代码展示了如何使用Porter词干提取算法:

>>> from nltk.stem.porter import PorterStemmer
>>> porter = PorterStemmer()
>>> def tokenizer_porter(text):
...     return [porter.stem(word) for word in text.split()]
>>> tokenizer_porter('runners like running and thus they run')
['runner', 'like', 'run', 'and', 'thu', 'they', 'run'] 

使用nltk包中的PorterStemmer,我们修改了我们的tokenizer函数,将单词还原为其词根形式,这在前面的简单示例中有所展示,其中单词'running'词干提取为词根形式'run'

词干提取算法

Porter词干提取算法可能是最古老且最简单的词干提取算法。其他流行的词干提取算法包括更新版的Snowball词干提取器(Porter2或英语词干提取器)和Lancaster词干提取器(Paice/Husk词干提取器)。尽管Snowball和Lancaster词干提取器比原始的Porter词干提取器更快,但Lancaster词干提取器因比Porter词干提取器更具侵略性而臭名昭著。这些替代的词干提取算法也可以通过NLTK包使用(http://www.nltk.org/api/nltk.stem.html)。

虽然词干提取可能会产生非真实单词,例如从thus提取的'thu',正如前面的例子所示,但一种叫做词形还原的技术旨在获得单个单词的标准(语法正确)形式——即所谓的词根。然而,与词干提取相比,词形还原在计算上更为复杂且成本更高,而且在实践中,已观察到词干提取和词形还原对文本分类的性能几乎没有影响(单词规范化对文本分类的影响Michal TomanRoman Tesar,和Karel JezekInSciT会议录,第354-358页,2006)。

在进入下一节之前,我们将训练一个机器学习模型,使用词袋模型,先简要谈谈另一个有用的主题——停用词移除。停用词是指在各种文本中非常常见的单词,这些单词可能没有(或仅有少量)可用来区分不同文档类别的有用信息。停用词的例子有isandhaslike。如果我们正在处理原始或规范化的词频,而不是已经对高频词进行降权的tf-idf,移除停用词可能会有所帮助。

为了从电影评论中移除停用词,我们将使用来自NLTK库的127个英语停用词集合,您可以通过调用nltk.download函数来获取该集合:

>>> import nltk
>>> nltk.download('stopwords')
After we download the stop-words set, we can load and apply the English stop-word set as follows:
>>> from nltk.corpus import stopwords
>>> stop = stopwords.words('english')
>>> [w for w in tokenizer_porter('a runner likes'
...  ' running and runs a lot')[-10:]
...  if w not in stop]
['runner', 'like', 'run', 'run', 'lot'] 

训练一个逻辑回归模型进行文档分类

在本节中,我们将训练一个逻辑回归模型,根据词袋模型将电影评论分类为正面负面评论。首先,我们将把清理后的文本文档的DataFrame分成25,000篇训练文档和25,000篇测试文档:

>>> X_train = df.loc[:25000, 'review'].values
>>> y_train = df.loc[:25000, 'sentiment'].values
>>> X_test = df.loc[25000:, 'review'].values
>>> y_test = df.loc[25000:, 'sentiment'].values 

接下来,我们将使用GridSearchCV对象,通过5折分层交叉验证,找到逻辑回归模型的最佳参数组合:

>>> from sklearn.model_selection import GridSearchCV
>>> from sklearn.pipeline import Pipeline
>>> from sklearn.linear_model import LogisticRegression
>>> from sklearn.feature_extraction.text import TfidfVectorizer
>>> tfidf = TfidfVectorizer(strip_accents=None,
...                         lowercase=False,
...                         preprocessor=None)
>>> param_grid = [{'vect__ngram_range': [(1,1)],
...                'vect__stop_words': [stop, None],
...                'vect__tokenizer': [tokenizer,
...                                    tokenizer_porter],
...                'clf__penalty': ['l1', 'l2'],
...                'clf__C': [1.0, 10.0, 100.0]},
...               {'vect__ngram_range': [(1,1)],
...                'vect__stop_words': [stop, None],
...                'vect__tokenizer': [tokenizer,
...                                    tokenizer_porter],
...                'vect__use_idf':[False],
...                'vect__norm':[None],
...                'clf__penalty': ['l1', 'l2'],
...                'clf__C': [1.0, 10.0, 100.0]}
...              ]
>>> lr_tfidf = Pipeline([('vect', tfidf),
...                      ('clf',
...                       LogisticRegression(random_state=0,
...                                          solver='liblinear'))])
>>> gs_lr_tfidf = GridSearchCV(lr_tfidf, param_grid,
...                            scoring='accuracy',
...                            cv=5, verbose=2,
...                            n_jobs=1)
>>> gs_lr_tfidf.fit(X_train, y_train) 

通过n_jobs参数进行多进程处理

请注意,强烈建议在前面的代码示例中将n_jobs=-1(而不是n_jobs=1)设置为利用机器上的所有可用核心,从而加速网格搜索。然而,一些Windows用户在使用n_jobs=-1设置时遇到了与Windows上的tokenizertokenizer_porter函数的多进程序列化相关的问题。另一种解决方法是将这两个函数[tokenizer, tokenizer_porter]替换为[str.split]。但是,请注意,使用简单的str.split替换后,将不支持词干化。

当我们使用前面的代码初始化GridSearchCV对象及其参数网格时,我们将参数组合的数量限制在一定范围内,因为特征向量的数量以及大量的词汇量可能会使网格搜索的计算开销非常大。在标准台式计算机上,网格搜索可能需要最多40分钟才能完成。

在前面的代码示例中,我们将上一节中的CountVectorizerTfidfTransformer替换为TfidfVectorizer,它将CountVectorizerTfidfTransformer结合起来。我们的param_grid包含了两个参数字典。在第一个字典中,我们使用TfidfVectorizer的默认设置(use_idf=Truesmooth_idf=Truenorm='l2')来计算tf-idfs;在第二个字典中,我们将这些参数设置为use_idf=Falsesmooth_idf=Falsenorm=None,以便基于原始词频训练模型。此外,对于逻辑回归分类器,我们通过惩罚参数训练了使用L2和L1正则化的模型,并通过定义反向正则化参数C的值范围来比较不同的正则化强度。

网格搜索完成后,我们可以打印出最佳的参数集:

>>> print('Best parameter set: %s ' % gs_lr_tfidf.best_params_)
Best parameter set: {'clf__C': 10.0, 'vect__stop_words': None, 'clf__penalty': 'l2', 'vect__tokenizer': <function tokenizer at 0x7f6c704948c8>, 'vect__ngram_range': (1, 1)} 

如前面的输出所示,我们使用常规的tokenizer(不使用Porter词干化、没有停用词库,并且结合tf-idfs)与逻辑回归分类器(使用L2正则化,并设置正则化强度C10.0)获得了最佳的网格搜索结果。

使用此网格搜索得到的最佳模型,我们可以打印出训练数据集的平均5折交叉验证准确率和测试数据集的分类准确率:

>>> print('CV Accuracy: %.3f'
...       % gs_lr_tfidf.best_score_)
CV Accuracy: 0.897
>>> clf = gs_lr_tfidf.best_estimator_
>>> print('Test Accuracy: %.3f'
...       % clf.score(X_test, y_test))
Test Accuracy: 0.899 

结果表明,我们的机器学习模型能够以90%的准确率预测电影评论是正面还是负面。

朴素贝叶斯分类器

一种仍然非常流行的文本分类器是朴素贝叶斯分类器,它在电子邮件垃圾邮件过滤应用中获得了广泛使用。朴素贝叶斯分类器实现简单、计算高效,且在相对较小的数据集上通常表现特别好,优于其他算法。尽管本书中没有讨论朴素贝叶斯分类器,但有兴趣的读者可以在arXiv上找到一篇关于朴素贝叶斯文本分类的文章(Naive Bayes and Text Classification I – Introduction and TheoryS. RaschkaComputing Research Repository (CoRR),abs/1410.5329,2014http://arxiv.org/pdf/1410.5329v3.pdf)。

处理更大的数据 – 在线算法与外部核心学习

如果你执行了上一节的代码示例,你可能已经注意到,在网格搜索过程中,为50,000条电影评论数据集构建特征向量可能非常耗费计算资源。在许多现实世界的应用中,处理更大的数据集也并不罕见,这些数据集可能超过我们计算机的内存容量。由于并非每个人都有超级计算机设施的使用权限,我们将应用一种叫做外部核心学习(out-of-core learning)的技术,它可以通过在数据集的小批次上逐步训练分类器,从而使我们能够处理这些大规模数据集。

使用递归神经网络进行文本分类

第16章使用递归神经网络建模序列数据中,我们将重新访问这个数据集,并训练一个基于深度学习的分类器(递归神经网络),以对IMDb电影评论数据集中的评论进行分类。这个基于神经网络的分类器采用与外部核心学习相同的原则,使用随机梯度下降优化算法,但不需要构建词袋模型。

第2章训练简单的机器学习算法进行分类中,介绍了随机梯度下降的概念;它是一种优化算法,通过每次使用一个示例来更新模型的权重。在本节中,我们将利用scikit-learn中SGDClassifierpartial_fit函数,直接从本地驱动器流式读取文档,并使用小批量文档训练一个逻辑回归模型。

首先,我们将定义一个tokenizer函数,用于清理我们在本章开头构建的movie_data.csv文件中的未处理文本数据,并将其拆分为词语标记,同时去除停用词:

>>> import numpy as np
>>> import re
>>> from nltk.corpus import stopwords
>>> stop = stopwords.words('english')
>>> def tokenizer(text):
...     text = re.sub('<[^>]*>', '', text)
...     emoticons = re.findall('(?::|;|=)(?:-)?(?:\)|\(|D|P)',
...                            text.lower())
...     text = re.sub('[\W]+', ' ', text.lower()) \
...                   + ' '.join(emoticons).replace('-', '')
...     tokenized = [w for w in text.split() if w not in stop]
...     return tokenized 

接下来,我们将定义一个生成器函数stream_docs,该函数一次读取并返回一份文档:

>>> def stream_docs(path):
...     with open(path, 'r', encoding='utf-8') as csv:
...         next(csv) # skip header
...         for line in csv:
...             text, label = line[:-3], int(line[-2])
...             yield text, label 

为了验证我们的stream_docs函数是否正常工作,让我们从movie_data.csv文件中读取第一份文档,该函数应返回一个元组,其中包括评论文本及其对应的类标签:

>>> next(stream_docs(path='movie_data.csv'))
('"In 1974, the teenager Martha Moxley ... ',1) 

现在,我们将定义一个函数get_minibatch,该函数将从stream_docs函数中获取文档流,并返回由size参数指定的特定数量的文档:

>>> def get_minibatch(doc_stream, size):
...     docs, y = [], []
...     try:
...         for _ in range(size):
...             text, label = next(doc_stream)
...             docs.append(text)
...             y.append(label)
...     except StopIteration:
...         return None, None
...     return docs, y 

不幸的是,我们无法使用CountVectorizer进行外部核心学习,因为它要求将完整的词汇表保存在内存中。另外,TfidfVectorizer需要将训练数据集的所有特征向量保存在内存中以计算逆文档频率。然而,scikit-learn中另一个有用的文本处理向量化工具是HashingVectorizerHashingVectorizer与数据无关,并通过Austin Appleby的32位MurmurHash3哈希函数使用哈希技巧(https://sites.google.com/site/murmurhash/):

>>> from sklearn.feature_extraction.text import HashingVectorizer
>>> from sklearn.linear_model import SGDClassifier
>>> vect = HashingVectorizer(decode_error='ignore',
...                          n_features=2**21,
...                          preprocessor=None,
...                          tokenizer=tokenizer)
>>> clf = SGDClassifier(loss='log', random_state=1)
>>> doc_stream = stream_docs(path='movie_data.csv') 

使用上述代码,我们通过tokenizer函数初始化了HashingVectorizer,并将特征数量设置为2**21。此外,我们通过将SGDClassifierloss参数设置为'log'重新初始化了逻辑回归分类器。请注意,通过选择HashingVectorizer中的大量特征,我们减少了哈希碰撞的可能性,但也增加了逻辑回归模型中的系数数量。

现在到了真正有趣的部分——在设置好所有的互补功能后,我们可以使用以下代码开始进行外部核心学习:

>>> import pyprind
>>> pbar = pyprind.ProgBar(45)
>>> classes = np.array([0, 1])
>>> for _ in range(45):
...     X_train, y_train = get_minibatch(doc_stream, size=1000)
...     if not X_train:
...         break
...     X_train = vect.transform(X_train)
...     clf.partial_fit(X_train, y_train, classes=classes)
...     pbar.update()
0%                          100%
[##############################] | ETA: 00:00:00
Total time elapsed: 00:00:21 

再次,我们利用了PyPrind包来估计学习算法的进度。我们将进度条对象初始化为45次迭代,在接下来的for循环中,我们遍历了45个小批次的文档,每个小批次由1,000个文档组成。在完成增量学习过程后,我们将使用最后5,000个文档来评估模型的性能:

>>> X_test, y_test = get_minibatch(doc_stream, size=5000)
>>> X_test = vect.transform(X_test)
>>> print('Accuracy: %.3f' % clf.score(X_test, y_test))
Accuracy: 0.868 

如你所见,该模型的准确率约为87%,略低于我们在上一节中通过网格搜索超参数调优所得到的准确率。然而,外部核心学习非常节省内存,完成的时间不到一分钟。最后,我们可以使用最后5,000个文档来更新我们的模型:

>>> clf = clf.partial_fit(X_test, y_test) 

word2vec模型

一个比词袋模型更现代的替代方案是word2vec,这是Google在2013年发布的一种算法(高效估计词向量表示T. MikolovK. ChenG. CorradoJ. Dean,arXiv预印本arXiv:1301.3781,2013)。

word2vec算法是一种基于神经网络的无监督学习算法,旨在自动学习单词之间的关系。word2vec的思想是将意义相似的单词聚集在相似的簇中,通过巧妙的向量间距,模型可以使用简单的向量数学重新生成某些单词,例如,kingman + woman = queen

原始的C语言实现,以及相关论文和其他实现的有用链接,可以在https://code.google.com/p/word2vec/找到。

使用潜在狄利克雷分配进行主题建模

主题建模描述了将主题分配给无标签文本文档的广泛任务。例如,一个典型的应用场景是将大量报纸文章文本集合中的文档进行分类。在主题建模的应用中,我们的目标是将这些文章分配到不同的类别标签,如体育、财经、世界新闻、政治、地方新闻等。因此,在我们在第1章《赋予计算机从数据中学习的能力》中讨论的机器学习广泛类别的背景下,我们可以将主题建模视为一种聚类任务,它是无监督学习的一个子类别。

在本节中,我们将讨论一种流行的主题建模技术,称为潜在狄利克雷分配LDA)。然而,请注意,尽管潜在狄利克雷分配通常缩写为LDA,但它不能与线性判别分析混淆,后者是一种监督式的降维技术,在第5章《通过降维压缩数据》中进行了介绍。

将电影评论分类器嵌入到Web应用程序中

LDA不同于我们在本章中采用的监督学习方法,通过该方法我们将电影评论分类为正面和负面。因此,如果你有兴趣通过Flask框架将scikit-learn模型嵌入到Web应用程序中,并以电影评论为例,请随时跳到下一章,并在稍后的时间回到这一独立的主题建模部分。

使用LDA分解文本文档

由于LDA背后的数学内容较为复杂,并且需要一定的贝叶斯推断知识,我们将从实践者的角度来探讨这个主题,并用通俗的语言解释LDA。然而,感兴趣的读者可以通过以下研究论文了解更多关于LDA的内容:《潜在狄利克雷分配》(Latent Dirichlet Allocation),David M. BleiAndrew Y. Ng,和Michael I. Jordan机器学习研究期刊 3,第993-1022页,2003年1月

LDA是一种生成式概率模型,它试图找到在不同文档中频繁同时出现的词组。这些频繁出现的词代表我们的主题,假设每个文档都是不同词的混合体。LDA的输入是我们在本章早些时候讨论的词袋模型。给定一个词袋矩阵作为输入,LDA将其分解为两个新的矩阵:

  • 文档到主题矩阵

  • 词到主题矩阵

LDA 将词袋矩阵分解成两部分,若我们将这两个矩阵相乘,就能够以最低的误差重建输入,即词袋矩阵。实际上,我们关注的是 LDA 在词袋矩阵中找到的那些主题。唯一的缺点可能是我们必须预先定义主题的数量——主题的数量是 LDA 的一个超参数,必须手动指定。

使用 scikit-learn 进行 LDA

在本小节中,我们将使用 scikit-learn 实现的 LatentDirichletAllocation 类,来分解电影评论数据集,并将其分类为不同的主题。在以下示例中,我们将分析限制为 10 个不同的主题,但鼓励读者尝试调整算法的超参数,以进一步探索该数据集中可以找到的主题。

首先,我们将使用本章开头创建的本地 movie_data.csv 文件,将数据集加载到 pandas DataFrame 中:

>>> import pandas as pd
>>> df = pd.read_csv('movie_data.csv', encoding='utf-8') 

接下来,我们将使用已经熟悉的 CountVectorizer 来创建词袋矩阵,作为 LDA 的输入。

为了方便,我们将通过 stop_words='english' 使用 scikit-learn 内置的英语停用词库:

>>> from sklearn.feature_extraction.text import CountVectorizer
>>> count = CountVectorizer(stop_words='english',
...                         max_df=.1,
...                         max_features=5000)
>>> X = count.fit_transform(df['review'].values) 

请注意,我们将考虑的词的最大文档频率设置为 10% (max_df=.1),以排除在文档中出现频率过高的词。移除频繁出现的词的理由是,这些词可能是所有文档中常见的词,因此不太可能与给定文档的特定主题类别相关联。同时,我们将考虑的词的数量限制为最常出现的 5000 个词 (max_features=5000),以限制数据集的维度,从而提高 LDA 推理的效果。然而,max_df=.1max_features=5000 都是任意选择的超参数值,鼓励读者在比较结果时进行调优。

以下代码示例演示了如何将 LatentDirichletAllocation 估算器拟合到词袋矩阵上,并从文档中推断出 10 个不同的主题(请注意,模型拟合可能需要 5 分钟或更长时间,具体取决于笔记本电脑或标准桌面电脑的性能):

>>> from sklearn.decomposition import LatentDirichletAllocation
>>> lda = LatentDirichletAllocation(n_components=10,
...                                 random_state=123,
...                                 learning_method='batch')
>>> X_topics = lda.fit_transform(X) 

通过设置 learning_method='batch',我们让 lda 估算器基于所有可用的训练数据(词袋矩阵)进行一次性估算,这比替代的 'online' 学习方法要慢,但可能会导致更准确的结果(设置 learning_method='online' 类似于在线或小批量学习,我们在 第 2 章 训练简单的机器学习分类算法 和本章中有讨论)。

期望最大化

scikit-learn 库中 LDA 的实现使用了 期望最大化EM)算法,通过迭代更新其参数估计。我们在本章中没有讨论 EM 算法,但如果你有兴趣了解更多内容,请参考 Wikipedia 上的精彩概述(https://en.wikipedia.org/wiki/Expectation–maximization_algorithm)以及 Colorado Reed 的教程《Latent Dirichlet Allocation: Towards a Deeper Understanding》,该教程详细介绍了如何在 LDA 中使用 EM 算法,且可在 http://obphio.us/pdfs/lda_tutorial.pdf 免费阅读。

在拟合 LDA 模型后,我们现在可以访问 lda 实例的 components_ 属性,该属性存储了一个矩阵,包含了每个主题的单词重要性(此处为 5000),并按递增顺序排列:

>>> lda.components_.shape
(10, 5000) 

为了分析结果,我们首先打印每个主题的五个最重要的单词。请注意,单词的重要性值是按递增顺序排名的。因此,为了打印出前五个单词,我们需要将主题数组按逆序排序:

>>> n_top_words = 5
>>> feature_names = count.get_feature_names()
>>> for topic_idx, topic in enumerate(lda.components_):
...     print("Topic %d:" % (topic_idx + 1))
...     print(" ".join([feature_names[i]
...                     for i in topic.argsort()\
...                     [:-n_top_words - 1:-1]]))
Topic 1:
worst minutes awful script stupid
Topic 2:
family mother father children girl
Topic 3:
american war dvd music tv
Topic 4:
human audience cinema art sense
Topic 5:
police guy car dead murder
Topic 6:
horror house sex girl woman
Topic 7:
role performance comedy actor performances
Topic 8:
series episode war episodes tv
Topic 9:
book version original read novel
Topic 10:
action fight guy guys cool 

基于每个主题的五个最重要单词,你可能会猜测 LDA 识别出了以下几个主题:

  1. 一般来说,糟糕的电影(这并不是真正的主题类别)

  2. 关于家庭的电影

  3. 战争电影

  4. 艺术电影

  5. 犯罪电影

  6. 恐怖电影

  7. 喜剧电影评论

  8. 与电视节目某种程度相关的电影

  9. 基于书籍改编的电影

  10. 动作电影

为了确认这些类别是否合理,我们绘制了三个来自恐怖电影类别的电影(恐怖电影属于类别 6,索引位置为 5):

>>> horror = X_topics[:, 5].argsort()[::-1]
>>> for iter_idx, movie_idx in enumerate(horror[:3]):
...     print('\nHorror movie #%d:' % (iter_idx + 1))
...     print(df['review'][movie_idx][:300], '...')
Horror movie #1:
House of Dracula works from the same basic premise as House of Frankenstein from the year before; namely that Universal's three most famous monsters; Dracula, Frankenstein's Monster and The Wolf Man are appearing in the movie together. Naturally, the film is rather messy therefore, but the fact that ...
Horror movie #2:
Okay, what the hell kind of TRASH have I been watching now? "The Witches' Mountain" has got to be one of the most incoherent and insane Spanish exploitation flicks ever and yet, at the same time, it's also strangely compelling. There's absolutely nothing that makes sense here and I even doubt there ...
Horror movie #3:
<br /><br />Horror movie time, Japanese style. Uzumaki/Spiral was a total freakfest from start to finish. A fun freakfest at that, but at times it was a tad too reliant on kitsch rather than the horror. The story is difficult to summarize succinctly: a carefree, normal teenage girl starts coming fac ... 

使用前面的代码示例,我们打印了前三部恐怖电影的前 300 个字符。评论——尽管我们不知道它们确切属于哪部电影——听起来像是恐怖电影的评论(然而,有人可能会认为 Horror movie #2 也可能适合分类为主题 1: 一般来说,糟糕的电影)。

总结

在本章中,你学习了如何使用机器学习算法根据文本的极性对文档进行分类,这在自然语言处理领域的情感分析中是一个基础任务。你不仅学会了如何使用词袋模型将文档编码为特征向量,还学习了如何使用 tf-idf 根据相关性对词频进行加权。

处理文本数据的计算开销可能非常大,因为在这个过程中会创建大量的特征向量;在上一节中,我们介绍了如何利用外存或增量学习来训练机器学习算法,而不需要将整个数据集加载到计算机的内存中。

最后,你了解了使用 LDA 进行主题建模的概念,通过无监督的方式将电影评论分类到不同的类别中。

在下一章中,我们将使用我们的文档分类器,并学习如何将其嵌入到 Web 应用程序中。

第九章:将机器学习模型嵌入Web应用程序

在前几章中,你学习了许多不同的机器学习概念和算法,它们可以帮助我们做出更好、更高效的决策。然而,机器学习技术不仅限于离线应用和分析,它们已经成为各种Web服务的预测引擎。例如,机器学习模型在Web应用中的一些流行且有用的应用包括提交表单中的垃圾邮件检测、搜索引擎、媒体或购物门户的推荐系统等等。

在本章中,你将学习如何将机器学习模型嵌入到一个Web应用程序中,该应用程序不仅可以分类,还可以实时从数据中学习。我们将覆盖的主题如下:

  • 保存训练过的机器学习模型的当前状态

  • 使用SQLite数据库进行数据存储

  • 使用流行的Flask Web框架开发Web应用程序

  • 将机器学习应用程序部署到公共Web服务器

序列化拟合的scikit-learn估算器

训练机器学习模型可能需要大量计算资源,就像你在第8章将机器学习应用于情感分析中看到的那样。当然,我们不希望每次关闭Python解释器并想要进行新预测或重新加载我们的Web应用程序时,都重新训练模型吧?

一个模型持久化的选项是Python内置的pickle模块(https://docs.python.org/3.7/library/pickle.html),它允许我们将Python对象结构序列化并反序列化为紧凑的字节码,这样我们就可以将分类器以当前状态保存下来,并在需要分类新的未标记样本时重新加载它,而无需让模型重新从训练数据中学习。在执行以下代码之前,请确保你已经在第8章的最后部分训练了外部核心逻辑回归模型,并且它已经准备好在当前的Python会话中使用:

>>> import pickle
>>> import os
>>> dest = os.path.join('movieclassifier', 'pkl_objects')
>>> if not os.path.exists(dest):
...     os.makedirs(dest)
>>> pickle.dump(stop,
...             open(os.path.join(dest, 'stopwords.pkl'), 'wb'),
...             protocol=4)
>>> pickle.dump(clf,
...             open(os.path.join(dest, 'classifier.pkl'), 'wb'),
...             protocol=4) 

使用前面的代码,我们创建了一个movieclassifier目录,在其中存储我们Web应用程序的文件和数据。在这个movieclassifier目录中,我们创建了一个pkl_objects子目录,用来将序列化的Python对象保存到本地硬盘或固态硬盘中。通过pickle模块的dump方法,我们将训练好的逻辑回归模型以及自然语言工具包NLTK)库的停用词集合序列化,这样我们就不需要在服务器上安装NLTK词汇。

dump 方法的第一个参数是我们要进行 Pickle 的对象。第二个参数是一个已打开的文件对象,Python 对象将被写入该文件。通过 open 函数中的 wb 参数,我们以二进制模式打开文件进行 Pickle,并设置 protocol=4 来选择 Python 3.4 中新增的最新且最有效的 Pickle 协议,它与 Python 3.4 或更新版本兼容。如果你在使用 protocol=4 时遇到问题,请检查你是否正在使用最新版本的 Python 3——本书推荐使用 Python 3.7。或者,你可以选择使用较低的协议版本。

还请注意,如果你使用的是自定义 Web 服务器,则需要确保该服务器上的 Python 安装与该协议版本兼容。

使用 joblib 序列化 NumPy 数组

我们的逻辑回归模型包含多个 NumPy 数组,例如权重向量。更高效的序列化 NumPy 数组的方式是使用替代库 joblib。为了确保与我们将在后续章节中使用的服务器环境兼容,我们将使用标准的 pickle 方法。如果你感兴趣,可以在 https://joblib.readthedocs.io 找到更多关于 joblib 的信息。

我们不需要对 HashingVectorizer 进行 Pickle,因为它不需要拟合。相反,我们可以创建一个新的 Python 脚本文件,并从中将矢量化器导入到当前的 Python 会话中。现在,复制以下代码并将其保存为 vectorizer.py 文件,放在 movieclassifier 目录中:

from sklearn.feature_extraction.text import HashingVectorizer
import re
import os
import pickle
cur_dir = os.path.dirname(__file__)
stop = pickle.load(open(os.path.join(
                   cur_dir, 'pkl_objects', 'stopwords.pkl'),
                   'rb'))
def tokenizer(text):
    text = re.sub('<[^>]*>', '', text)
    emoticons = re.findall('(?::|;|=)(?:-)?(?:\)|\(|D|P)',
                           text.lower())
    text = re.sub('[\W]+', ' ', text.lower()) \
                  + ' '.join(emoticons).replace('-', '')
    tokenized = [w for w in text.split() if w not in stop]
    return tokenized

vect = HashingVectorizer(decode_error='ignore',
                         n_features=2**21,
                         preprocessor=None,
                         tokenizer=tokenizer) 

在我们对 Python 对象进行 Pickle 并创建了 vectorizer.py 文件后,最好重新启动 Python 解释器或 Jupyter Notebook 内核,以测试我们是否能够顺利反序列化这些对象。

Pickle 可能带来安全风险

请注意,从不受信任的来源反序列化数据可能会带来安全风险,因为 pickle 模块无法防范恶意代码。由于 pickle 被设计用来序列化任意对象,反序列化过程会执行存储在 pickle 文件中的代码。因此,如果你从不受信任的来源(例如从互联网下载)接收到 pickle 文件,请务必小心,并在虚拟环境中或在不存储重要数据的非关键机器上反序列化这些项目,这样除了你自己,其他人无法访问这些数据。

从终端进入 movieclassifier 目录,启动一个新的 Python 会话,并执行以下代码以验证你是否能够导入 vectorizer 并反序列化分类器:

>>> import pickle
>>> import re
>>> import os
>>> from vectorizer import vect
>>> clf = pickle.load(open(os.path.join(
...                   'pkl_objects', 'classifier.pkl'),
...                   'rb')) 

成功加载 vectorizer 并反序列化分类器后,我们可以使用这些对象对文档示例进行预处理,并对其情感进行预测:

>>> import numpy as np
>>> label = {0:'negative', 1:'positive'}
>>> example = ["I love this movie. It's amazing."]
>>> X = vect.transform(example)
>>> print('Prediction: %s\nProbability: %.2f%%' %\
...       (label[clf.predict(X)[0]],
...        np.max(clf.predict_proba(X))*100))
Prediction: positive
Probability: 95.55% 

由于我们的分类器返回的类别标签预测是整数类型,我们定义了一个简单的Python字典,将这些整数映射到其情感("positive""negative")。虽然这是一个仅包含两个类别的简单应用,但需要注意的是,这种字典映射的方法也可以推广到多类别的设置。此外,这个映射字典也应该与模型一同存档。

在这种情况下,由于字典定义只包含一行代码,我们不会费心使用pickle进行序列化。然而,在实际应用中,如果字典映射较大,可以利用我们在之前代码示例中使用的相同pickle.dumppickle.load命令。

接着讨论之前的代码示例,我们使用了HashingVectorizer将简单的示例文档转换成了一个词向量X。最后,我们使用逻辑回归分类器的predict方法来预测类别标签,并使用predict_proba方法返回相应的预测概率。请注意,predict_proba方法返回一个数组,其中包含每个唯一类别标签的概率值。由于具有最大概率的类别标签就是predict方法返回的类别标签,我们使用了np.max函数来返回预测类别的概率。

设置SQLite数据库以存储数据

在本节中,我们将设置一个简单的SQLite数据库,用于收集Web应用用户对预测结果的可选反馈。我们可以使用这些反馈来更新我们的分类模型。SQLite是一个开源的SQL数据库引擎,无需单独的服务器即可操作,这使得它非常适合小型项目和简单的Web应用。实际上,SQLite数据库可以理解为一个单一的、独立的数据库文件,允许我们直接访问存储文件。

此外,SQLite不需要任何系统特定的配置,并且支持所有常见的操作系统。它因其高可靠性而广受欢迎,被谷歌、Mozilla、Adobe、苹果、微软等许多知名公司使用。如果你想了解更多关于SQLite的信息,请访问官网:http://www.sqlite.org

幸运的是,遵循Python的电池包含哲学,Python标准库中已经有一个APIsqlite3,允许我们与SQLite数据库进行交互。(有关sqlite3的更多信息,请访问:https://docs.python.org/3.7/library/sqlite3.html)。

通过执行以下代码,我们将在movieclassifier目录下创建一个新的SQLite数据库,并存储两个示例电影评论:

>>> import sqlite3
>>> import os
>>> conn = sqlite3.connect('reviews.sqlite')
>>> c = conn.cursor()
>>> c.execute('DROP TABLE IF EXISTS review_db')
>>> c.execute('CREATE TABLE review_db'\
...           ' (review TEXT, sentiment INTEGER, date TEXT)')
>>> example1 = 'I love this movie'
>>> c.execute("INSERT INTO review_db"\
...           " (review, sentiment, date) VALUES"\
...           " (?, ?, DATETIME('now'))", (example1, 1))
>>> example2 = 'I disliked this movie'
>>> c.execute("INSERT INTO review_db"\
...           " (review, sentiment, date) VALUES"\
...           " (?, ?, DATETIME('now'))", (example2, 0))
>>> conn.commit()
>>> conn.close() 

按照前面的代码示例,我们通过调用sqlite3库的connect方法,创建了一个与SQLite数据库文件的连接(conn),如果该数据库文件reviews.sqlitemovieclassifier目录下不存在,它会被新建。

接下来,我们通过cursor方法创建了一个游标,它允许我们使用多功能的SQL语法遍历数据库记录。通过第一次调用execute,我们创建了一个新的数据库表review_db。我们用这个表来存储和访问数据库条目。除了review_db,我们还在这个数据库表中创建了三列:reviewsentimentdate。我们用这些列来存储两条电影评论及其对应的分类标签(情感)。

通过使用DATETIME('now') SQL命令,我们还为我们的条目添加了日期和时间戳。除了时间戳,我们使用问号符号(?)将电影评论文本(example1example2)和相应的分类标签(10)作为位置参数传递给execute方法,作为元组的成员。最后,我们调用了commit方法来保存对数据库所做的更改,并通过close方法关闭连接。

为了检查数据是否正确存储在数据库表中,我们现在将重新打开与数据库的连接,并使用SQL的SELECT命令来获取自2017年初至今天所有已提交的数据库表中的行:

>>> conn = sqlite3.connect('reviews.sqlite')
>>> c = conn.cursor()
>>> c.execute("SELECT * FROM review_db WHERE date"\
...           " BETWEEN '2017-01-01 00:00:00' AND DATETIME('now')")
>>> results = c.fetchall()
>>> conn.close()
>>> print(results)
[('I love this movie', 1, '2019-06-15 17:53:46'), ('I disliked this movie', 0, '2019-06-15 17:53:46')] 

另外,我们也可以使用免费的SQLite浏览器应用(可通过https://sqlitebrowser.org/dl/下载),它提供了一个漂亮的图形用户界面来操作SQLite数据库,如下图所示:

使用Flask开发Web应用

在上一小节准备好用于分类电影评论的代码后,让我们讨论Flask Web框架的基础知识,以便开发我们的Web应用。自2010年Armin Ronacher首次发布Flask以来,该框架获得了巨大的受欢迎程度,使用Flask的流行应用实例包括LinkedIn和Pinterest。由于Flask是用Python编写的,它为我们Python程序员提供了一个方便的接口,用于嵌入现有的Python代码,例如我们的电影分类器。

Flask微框架

Flask也被称为微框架,这意味着它的核心保持简洁而轻量,但可以通过其他库轻松扩展。虽然轻量级的Flask API的学习曲线远不如其他流行的Python Web框架(如Django)陡峭,但仍建议你查看Flask的官方文档:https://flask.palletsprojects.com/en/1.0.x/,了解其更多功能。

如果 Flask 库还没有安装在你当前的 Python 环境中,你可以通过终端使用condapip轻松安装它(在撰写本文时,最新的稳定版本是 1.0.2):

conda install flask
# or: pip install flask 

我们的第一个 Flask Web 应用

在这一小节中,我们将开发一个非常简单的 web 应用,以便更熟悉 Flask API,然后再实现我们的电影分类器。我们将要构建的这个第一个应用包含一个简单的网页,网页上有一个表单字段,让我们输入一个名字。提交名字后,web 应用会将其渲染在一个新页面上。虽然这是一个非常简单的 web 应用示例,但它有助于理解如何在 Flask 框架中在不同部分之间存储和传递变量及值。

首先,我们创建一个目录树:

1st_flask_app_1/
    app.py
    templates/
        first_app.html 

app.py 文件将包含由 Python 解释器执行的主要代码,以运行 Flask 网络应用。templates 目录是 Flask 查找静态 HTML 文件以供在网页浏览器中渲染的目录。现在,让我们来看看 app.py 的内容:

from flask import Flask, render_template
app = Flask(__name__)
@app.route('/')
def index():
    return render_template('first_app.html')
if __name__ == '__main__':
    app.run() 

看过前面的代码示例后,让我们逐步讨论各个部分:

  1. 我们将应用程序作为单个模块运行,因此我们使用参数 __name__ 初始化了一个新的 Flask 实例,让 Flask 知道它可以在当前所在的目录中找到 HTML 模板文件夹(templates)。

  2. 接下来,我们使用路由装饰器(@app.route('/'))来指定应触发 index 函数执行的 URL。

  3. 在这里,我们的 index 函数简单地渲染了位于 templates 文件夹中的 first_app.html HTML 文件。

  4. 最后,我们使用 run 函数来仅在 Python 解释器直接执行此脚本时运行应用程序,这一点我们通过使用 if 语句和 __name__ == '__main__' 来确保。

现在,让我们来看看 first_app.html 文件的内容:

<!doctype html>
<html>
  <head>
    <title>First app</title>
  </head>
  <body>
    <div>Hi, this is my first Flask web app!</div>
  </body>
</html> 

HTML 基础

如果你还不熟悉 HTML 语法,可以访问 https://developer.mozilla.org/en-US/docs/Web/HTML 查阅关于 HTML 基础知识的有用教程。

在这里,我们简单地填充了一个空的 HTML 模板文件,里面有一个包含这句话的<div>元素(块级元素):Hi, this is my first Flask web app!

方便的是,Flask 允许我们在本地运行应用程序,这对在将 web 应用部署到公共服务器之前进行开发和测试非常有用。现在,让我们通过在 1st_flask_app_1 目录中的终端执行命令来启动我们的 web 应用:

python3 app.py 

我们应该在终端看到类似以下的行:

* Running on http://127.0.0.1:5000/ 

这一行包含了我们本地服务器的地址。我们可以在网页浏览器中输入此地址来查看 web 应用的运行效果。

如果一切顺利执行,我们应该能看到一个简单的网站,内容为Hi, this is my first Flask web app!,如以下图所示:

表单验证与渲染

在本小节中,我们将通过HTML表单元素扩展我们的简单Flask Web应用程序,学习如何使用WTForms库从用户收集数据(可以通过condapip安装该库),详细信息请参见https://wtforms.readthedocs.org/en/latest/

conda install wtforms
# or pip install wtforms 

这个Web应用程序将提示用户在文本字段中输入姓名,如下图所示:

在提交按钮(Say Hello)被点击并且表单验证通过后,一个新的HTML页面将被渲染,显示用户的姓名:

设置目录结构

我们需要为此应用程序设置的新目录结构如下:

1st_flask_app_2/
    app.py
    static/
        style.css
    templates/
        _formhelpers.html
        first_app.html
        hello.html 

以下是我们修改后的app.py文件的内容:

from flask import Flask, render_template, request
from wtforms import Form, TextAreaField, validators
app = Flask(__name__)
class HelloForm(Form):
    sayhello = TextAreaField('',[validators.DataRequired()])
@app.route('/')
def index():
    form = HelloForm(request.form)
    return render_template('first_app.html', form=form)
@app.route('/hello', methods=['POST'])
def hello():
    form = HelloForm(request.form)
    if request.method == 'POST' and form.validate():
        name = request.form['sayhello']
        return render_template('hello.html', name=name)
    return render_template('first_app.html', form=form)
if __name__ == '__main__':
    app.run(debug=True) 

让我们逐步讨论之前的代码:

  1. 使用wtforms,我们扩展了index函数,添加了一个文本字段,我们将通过TextAreaField类将其嵌入到我们的首页中,该类会自动检查用户是否提供了有效的输入文本。

  2. 此外,我们定义了一个新的函数hello,该函数将在验证HTML表单后渲染HTML页面hello.html

  3. 在这里,我们使用POST方法将表单数据通过消息体传输到服务器。最后,通过在app.run方法中设置debug=True参数,我们进一步激活了Flask的调试器。这是开发新Web应用程序时非常有用的功能。

使用Jinja2模板引擎实现宏

现在,我们将在_formhelpers.html文件中通过Jinja2模板引擎实现一个通用宏,稍后我们将在first_app.html文件中导入该宏以渲染文本字段:

{% macro render_field(field) %}
  <dt>{{ field.label }}
  <dd>{{ field(**kwargs)|safe }}
  {% if field.errors %}
    <ul class=errors>
    {% for error in field.errors %}
      <li>{{ error }}</li>
    {% endfor %}
    </ul>
  {% endif %}
  </dd>
  </dt>
{% endmacro %} 

对Jinja2模板语言的深入讨论超出了本书的范围。不过,您可以在http://jinja.pocoo.org找到Jinja2语法的详细文档。

通过CSS添加样式

接下来,我们将设置一个简单的层叠样式表CSS)文件,style.css,以演示如何修改HTML文档的外观和感觉。我们必须将以下CSS文件保存到名为static的子目录中,这是Flask查找静态文件(如CSS)的默认目录。文件内容如下:

body {
     font-size: 2em;
} 

以下是修改后的first_app.html文件的内容,该文件现在将渲染一个文本表单,用户可以在其中输入姓名:

<!doctype html>
<html>
  <head>
    <title>First app</title>
      <link rel="stylesheet"
       href="{{ url_for('static', filename='style.css') }}">
  </head>
  <body>
    {% from "_formhelpers.html" import render_field %}
    <div>What's your name?</div>
    <form method=post action="/hello">
      <dl>
        {{ render_field(form.sayhello) }}
      </dl>
      <input type=submit value='Say Hello' name='submit_btn'>
    </form>
  </body>
</html> 

first_app.html 的头部部分,我们加载了 CSS 文件。现在它应该会改变 HTML 页面正文中所有文本元素的大小。在 HTML 页面正文部分,我们从 _formhelpers.html 导入了表单宏,并渲染了我们在 app.py 文件中指定的 sayhello 表单。此外,我们在同一表单元素中添加了一个按钮,以便用户能够提交文本框中的内容。原始和修改后的 first_app.html 文件之间的变化如以下图所示:

创建结果页面

最后,我们将创建一个 hello.html 文件,该文件将通过 hello 函数中的 render_template('hello.html', name=name) 这一行进行渲染,该函数是在 app.py 脚本中定义的,用于显示用户通过文本框提交的内容。文件内容如下:

<!doctype html>
<html>
  <head>
    <title>First app</title>
      <link rel="stylesheet"
       href="{{ url_for('static', filename='style.css') }}">
  </head>
  <body>
    <div>Hello {{ name }}</div>
  </body>
</html> 

由于我们在前一部分已经涵盖了很多内容,下面的图提供了我们创建的文件的概述:

请注意,您不需要复制前一图中的任何代码,因为所有文件内容已经在之前的部分中提到过了。为了方便您,所有文件的副本也可以在网上找到,链接为 https://github.com/rasbt/python-machine-learning-book-3rd-edition/tree/master/ch09/1st_flask_app_2

在设置好修改后的 Flask Web 应用程序后,我们可以通过在应用程序主目录中执行以下命令来在本地运行它:

python3 app.py 

然后,为了查看最终的网页,请在您的终端中输入显示的 IP 地址,通常是 http://127.0.0.1:5000/,将其输入到您的浏览器中查看渲染后的 Web 应用程序,具体内容总结如下图所示:

Flask 文档和示例

如果您是 Web 开发新手,一开始可能会觉得一些概念非常复杂。这种情况下,只需在您的硬盘上创建上述文件,并仔细查看它们。您会发现 Flask Web 框架相对简单,远比它初看起来要容易!此外,如果需要更多帮助,别忘了查阅优秀的 Flask 文档和示例,链接为 http://flask.pocoo.org/docs/1.0/

将电影评论分类器转化为 Web 应用程序

现在我们已经对 Flask Web 开发的基础知识有所了解,接下来让我们进入下一步,将我们的电影分类器实现为一个 Web 应用程序。在这一部分,我们将开发一个 Web 应用程序,首先提示用户输入电影评论,如下图所示:

提交评论后,用户将看到一个新页面,显示预测的类别标签和预测的概率。此外,用户还可以通过点击 CorrectIncorrect 按钮对预测结果提供反馈,如下所示的截图:

如果用户点击了 CorrectIncorrect 按钮,我们的分类模型将根据用户的反馈进行更新。此外,我们还将把用户提供的电影评论文本以及从按钮点击中推断出的建议类别标签存储到 SQLite 数据库中,以便将来参考。(另外,用户也可以跳过更新步骤,点击 Submit another review 按钮提交另一条评论。)

用户在点击反馈按钮后看到的第三个页面是一个简单的 感谢 页面,带有 Submit another review 按钮,点击该按钮将用户重定向回首页。如下所示的截图:

实时演示

在我们仔细查看此 Web 应用程序的代码实现之前,请访问 http://raschkas.pythonanywhere.com 查看实时演示,以便更好地理解我们在本节中要完成的任务。

文件和文件夹 - 查看目录结构

为了从整体上了解,先来看看我们将为这个电影分类应用程序创建的目录结构,如下所示:

在本章的前面部分,我们创建了 vectorizer.py 文件、SQLite 数据库 reviews.sqlite 以及 pkl_objects 子目录,里面存储了已序列化的 Python 对象。

主目录中的 app.py 文件是包含 Flask 代码的 Python 脚本,我们将使用本章前面创建的 review.sqlite 数据库文件来存储提交到我们 Web 应用程序中的电影评论。templates 子目录包含将由 Flask 渲染并显示在浏览器中的 HTML 模板,static 子目录则包含一个简单的 CSS 文件,用于调整渲染 HTML 代码的外观。

获取 movieclassifier 代码文件

这本书的代码示例中提供了一个单独的目录,其中包含本节讨论的电影评论分类应用程序的代码,你可以直接从 Packt 获取或从 GitHub 下载,网址为 https://github.com/rasbt/python-machine-learning-book-3rd-edition/。本节的代码可以在 .../code/ch09/movieclassifier 子目录中找到。

将主应用程序实现为 app.py

由于 app.py 文件相当长,我们将分两步来处理。app.py 的第一部分导入了我们需要的 Python 模块和对象,并包含了解压和设置分类模型的代码:

from flask import Flask, render_template, request
from wtforms import Form, TextAreaField, validators
import pickle
import sqlite3
import os
import numpy as np
# import HashingVectorizer from local dir
from vectorizer import vect
app = Flask(__name__)
######## Preparing the Classifier
cur_dir = os.path.dirname(__file__)
clf = pickle.load(open(os.path.join(cur_dir,
                  'pkl_objects', 'classifier.pkl'),
                  'rb'))
db = os.path.join(cur_dir, 'reviews.sqlite')
def classify(document):
    label = {0: 'negative', 1: 'positive'}
    X = vect.transform([document])
    y = clf.predict(X)[0]
    proba = np.max(clf.predict_proba(X))
    return label[y], proba
def train(document, y):
    X = vect.transform([document])
    clf.partial_fit(X, [y])
def sqlite_entry(path, document, y):
    conn = sqlite3.connect(path)
    c = conn.cursor()
    c.execute("INSERT INTO review_db (review, sentiment, date)"\
              " VALUES (?, ?, DATETIME('now'))", (document, y))
    conn.commit()
    conn.close() 

app.py 脚本的第一部分现在应该非常熟悉了。我们简单地导入了 HashingVectorizer 并解压了逻辑回归分类器。接着,我们定义了一个 classify 函数,用来返回预测的类别标签,以及给定文本文档的相应概率预测。train 函数可以在提供文档和类别标签的情况下,用来更新分类器。

通过 sqlite_entry 函数,我们可以将提交的电影评论连同其类别标签和时间戳一起存储到我们的 SQLite 数据库中,供个人记录使用。请注意,如果我们重新启动 web 应用程序,clf 对象将被重置为其原始的、已序列化的状态。在本章结束时,您将学会如何使用我们在 SQLite 数据库中收集的数据来永久更新分类器。

app.py 脚本第二部分的概念也应该相当熟悉:

######## Flask
class ReviewForm(Form):
    moviereview = TextAreaField('',
                                [validators.DataRequired(),
                                 validators.length(min=15)])
@app.route('/')
def index():
    form = ReviewForm(request.form)
    return render_template('reviewform.html', form=form)
@app.route('/results', methods=['POST'])
def results():
    form = ReviewForm(request.form)
    if request.method == 'POST' and form.validate():
        review = request.form['moviereview']
        y, proba = classify(review)
        return render_template('results.html',
                               content=review,
                               prediction=y,
                               probability=round(proba*100, 2))
    return render_template('reviewform.html', form=form)
@app.route('/thanks', methods=['POST'])
def feedback():
    feedback = request.form['feedback_button']
    review = request.form['review']
    prediction = request.form['prediction']

    inv_label = {'negative': 0, 'positive': 1}
    y = inv_label[prediction]
    if feedback == 'Incorrect':
        y = int(not(y))
    train(review, y)
    sqlite_entry(db, review, y)
    return render_template('thanks.html')
if __name__ == '__main__':
    app.run(debug=True) 

我们定义了一个 ReviewForm 类,该类实例化了一个 TextAreaField,该字段将渲染在 reviewform.html 模板文件中(即我们 web 应用程序的首页)。这个字段又会被 index 函数渲染。通过 validators.length(min=15) 参数,我们要求用户输入至少包含 15 个字符的评论。在 results 函数中,我们获取提交的网页表单内容,并将其传递给我们的分类器来预测电影的情感,预测结果将显示在渲染的 results.html 模板中。

feedback 函数在 app.py 中的实现可能乍一看会显得有点复杂。它本质上是从 results.html 模板中获取预测的类别标签(如果用户点击了 正确错误 反馈按钮),然后将预测的情感转换回整数类别标签,以便通过我们在 app.py 脚本第一部分实现的 train 函数来更新分类器。如果提供了反馈,还会通过 sqlite_entry 函数向 SQLite 数据库中添加新的记录,最终,thanks.html 模板将被渲染,感谢用户的反馈。

设置评论表单

接下来,让我们看看 reviewform.html 模板,它构成了我们应用程序的起始页:

<!doctype html>
<html>
  <head>
    <title>Movie Classification</title>
      <link rel="stylesheet"
       href="{{ url_for('static', filename='style.css') }}">
  </head>
  <body>

    <h2>Please enter your movie review:</h2>

    {% from "_formhelpers.html" import render_field %}

    <form method=post action="/results">
      <dl>
        {{ render_field(form.moviereview, cols='30', rows='10') }}
      </dl>
      <div>
        <input type=submit value='Submit review' name='submit_btn'>
      </div>
    </form>

  </body>
</html> 

在这里,我们仅仅导入了之前在本章表单验证与渲染部分中定义的相同的_formhelpers.html模板。这个宏的render_field函数用于渲染一个TextAreaField,用户可以在其中提供电影评论,并通过页面底部显示的提交评论按钮提交。这一个TextAreaField宽度为30列,高度为10行,效果如下所示:

创建结果页面模板

我们的下一个模板,results.html,看起来稍微有点有趣:

<!doctype html>
<html>
  <head>
    <title>Movie Classification</title>
      <link rel="stylesheet"
       href="{{ url_for('static', filename='style.css') }}">
  </head>
  <body>

    <h3>Your movie review:</h3>
    <div>{{ content }}</div>

    <h3>Prediction:</h3>
    <div>This movie review is <strong>{{ prediction }}</strong>
    (probability: {{ probability }}%).</div>

    <div id='button'>
      <form action="/thanks" method="post">
        <input type=submit value='Correct' name='feedback_button'>
        <input type=submit value='Incorrect' name='feedback_button'>
        <input type=hidden value='{{ prediction }}' name='prediction'>
        <input type=hidden value='{{ content }}' name='review'>
      </form>
    </div>

    <div id='button'>
      <form action="/">
        <input type=submit value='Submit another review'>
      </form>
    </div>

  </body>
</html> 

首先,我们将提交的评论以及预测结果插入到对应的字段{{ content }}{{ prediction }}{{ probability }}中。你可能会注意到,我们在包含正确错误按钮的表单中第二次使用了{{ content }}{{ prediction }}占位符变量(在此上下文中,也称为隐藏字段)。这是通过POST这些值回传给服务器,以便更新分类器并在用户点击这两个按钮中的一个时存储评论的解决方法。

此外,我们还在results.html文件的开头导入了一个CSS文件(style.css)。该文件的设置相当简单:它将Web应用程序内容的宽度限制为600像素,并将带有div id button错误正确按钮向下移动了20像素:

body{
  width:600px;
}
.button{
  padding-top: 20px;
} 

这个CSS文件只是一个占位符,因此请随意修改它,以便根据你的喜好调整Web应用程序的外观和感觉。

我们将为Web应用程序实现的最后一个HTML文件是thanks.html模板。顾名思义,它在用户通过正确错误按钮提供反馈后,简单地向用户展示一条感谢信息。此外,我们还将在此页面底部放置一个提交另一个评论按钮,点击该按钮将把用户重定向到起始页面。thanks.html文件的内容如下:

<!doctype html>
<html>
  <head>
    <title>Movie Classification</title>
      <link rel="stylesheet"
       href="{{ url_for('static', filename='style.css') }}">
  </head>
  <body>

    <h3>Thank you for your feedback!</h3>

    <div id='button'>
      <form action="/">
        <input type=submit value='Submit another review'>
      </form>
    </div>

  </body>
</html> 

现在,在我们继续进行下一小节并将应用程序部署到公共Web服务器之前,最好先通过以下命令在命令行终端中本地启动Web应用程序:

python3 app.py 

在我们完成应用程序的测试后,我们也不应该忘记删除app.py脚本中app.run()命令中的debug=True参数(或者设置debug=False),如下图所示:

部署Web应用程序到公共服务器

在我们本地测试完Web应用程序后,现在可以将其部署到公共Web服务器上。对于本教程,我们将使用PythonAnywhere Web托管服务,该服务专门托管Python Web应用程序,使用起来非常简单方便。而且,PythonAnywhere提供了一个初学者账户选项,允许我们免费运行一个Web应用程序。

创建PythonAnywhere账户

要创建一个新的 PythonAnywhere 账户,我们访问 https://www.pythonanywhere.com/ 网站,点击右上角的定价与注册链接。接着,我们点击创建初学者账户按钮,在那里需要提供用户名、密码和有效的电子邮件地址。阅读并同意条款和条件后,我们就应该拥有一个新的账户。

不幸的是,免费的初学者账户不允许我们通过安全套接字外壳(SSH)协议从终端访问远程服务器。因此,我们需要使用 PythonAnywhere 的网页界面来管理我们的网页应用。但在将本地应用文件上传到服务器之前,我们需要为我们的 PythonAnywhere 账户创建一个新的网页应用。在点击右上角的仪表盘按钮后,我们可以访问页面顶部显示的控制面板。接下来,我们点击页面顶部现在可见的Web标签。然后,我们点击左侧的+ 添加新网页应用按钮,这样就可以创建一个新的 Python 3.7 Flask 网页应用,并将其命名为 movieclassifier

上传电影分类器应用

在为我们的 PythonAnywhere 账户创建了一个新应用后,我们前往文件标签页,使用 PythonAnywhere 的网页界面上传我们本地 movieclassifier 目录中的文件。上传完我们在本地计算机上创建的网页应用文件后,我们的 PythonAnywhere 账户中应该会有一个 movieclassifier 目录,它将包含与本地 movieclassifier 目录相同的目录和文件,如下图所示:

然后,我们再次前往Web标签,点击重新加载 .pythonanywhere.com按钮,以传播更改并刷新我们的网页应用。最后,我们的网页应用应该已经启动并运行,并通过 <username>.pythonanywhere.com 公开可用。

故障排除

不幸的是,网页服务器对我们网页应用中的任何小问题都非常敏感。如果你在 PythonAnywhere 上运行网页应用时遇到问题,并且浏览器中显示错误信息,你可以检查服务器和错误日志,这些日志可以从你的 PythonAnywhere 账户中的Web标签访问,以便更好地诊断问题。

更新电影分类器

尽管每当用户提供分类反馈时,我们的预测模型都会即时更新,但如果 Web 服务器崩溃或重启,clf 对象的更新将会被重置。如果我们重新加载 Web 应用程序,clf 对象将会从 classifier.pkl pickle 文件中重新初始化。为了永久应用更新,一个选择是在每次更新后再次对 clf 对象进行 pickling。然而,随着用户数量的增加,这样做会变得在计算上非常低效,并且如果用户同时提供反馈,pickle 文件可能会损坏。

另一种解决方案是从收集到的 SQLite 数据库中的反馈数据中更新预测模型。一种选择是从 PythonAnywhere 服务器下载 SQLite 数据库,在本地计算机上更新 clf 对象,然后将新的 pickle 文件上传到 PythonAnywhere。为了在本地计算机上更新分类器,我们需要在 movieclassifier 目录中创建一个名为 update.py 的脚本文件,内容如下:

import pickle
import sqlite3
import numpy as np
import os
# import HashingVectorizer from local dir
from vectorizer import vect
def update_model(db_path, model, batch_size=10000):
    conn = sqlite3.connect(db_path)
    c = conn.cursor()
    c.execute('SELECT * from review_db')

    results = c.fetchmany(batch_size)
    while results:
        data = np.array(results)
        X = data[:, 0]
        y = data[:, 1].astype(int)

        classes = np.array([0, 1])
        X_train = vect.transform(X)
        model.partial_fit(X_train, y, classes=classes)
        results = c.fetchmany(batch_size)

    conn.close()
    return model
cur_dir = os.path.dirname(__file__)
clf = pickle.load(open(os.path.join(cur_dir,
                  'pkl_objects',
                  'classifier.pkl'), 'rb'))
db = os.path.join(cur_dir, 'reviews.sqlite')
clf = update_model(db_path=db, model=clf, batch_size=10000)
# Uncomment the following lines if you are sure that
# you want to update your classifier.pkl file
# permanently.
# pickle.dump(clf, open(os.path.join(cur_dir,
#             'pkl_objects', 'classifier.pkl'), 'wb'),
#             protocol=4) 

获取具有更新功能的电影分类器代码文件

一个包含本章讨论的带有更新功能的电影评论分类器应用程序的独立目录,随书提供的代码示例一同提供,您可以直接从 Packt 获取,或者从 GitHub 下载:https://github.com/rasbt/python-machine-learning-book-3rd-edition。本节中的代码位于 .../code/ch09/movieclassifier_with_update 子目录中。

update_model 函数将一次性从 SQLite 数据库中批量获取 10,000 条记录,除非数据库中的记录更少。或者,我们也可以通过使用 fetchone 代替 fetchmany 来逐条获取记录,但这样做会在计算上非常低效。然而,请记住,如果我们处理的是超过计算机或服务器内存容量的大型数据集,使用替代的 fetchall 方法可能会成为一个问题。

现在我们已经创建了 update.py 脚本,我们还可以将其上传到 PythonAnywhere 上的 movieclassifier 目录,并在主应用程序脚本 app.py 中导入 update_model 函数,以便每次重新启动 Web 应用程序时从 SQLite 数据库中更新分类器。为此,我们只需要在 app.py 顶部添加一行代码,导入 update.py 脚本中的 update_model 函数:

# import update function from local dir
from update import update_model 

然后我们需要在主应用程序代码中调用 update_model 函数:

...
if __name__ == '__main__':
    clf = update_model(db_path=db,
                       model=clf,
                       batch_size=10000)
... 

如前所述,前面的代码片段中的修改将更新 PythonAnywhere 上的 pickle 文件。然而,在实际操作中,我们并不经常需要重启 Web 应用程序,因此,在更新之前验证 SQLite 数据库中的用户反馈,以确保这些反馈对分类器有价值,显得更加合理。

创建备份

在实际应用中,你可能还希望定期备份classifier.pkl的pickle文件,以防止文件损坏,例如在每次更新之前创建带时间戳的版本。为了创建pickle分类器的备份,你可以导入以下内容:

from shutil import copyfile
import time 

然后,在更新pickle分类器的代码上方,代码如下:

pickle.dump(
    clf, open(
        os.path.join(
            cur_dir, 'pkl_objects',
            'classifier.pkl'),
        'wb'),
    protocol=4) 

插入以下代码行:

timestr = time.strftime("%Y%m%d-%H%M%S")
orig_path = os.path.join(
    cur_dir, 'pkl_objects', 'classifier.pkl')
backup_path = os.path.join(
    cur_dir, 'pkl_objects',
    'classifier_%s.pkl' % timestr)
copyfile(orig_path, backup_path) 

结果是,pickle后的分类器的备份文件将按照YearMonthDay-HourMinuteSecond的格式创建,例如classifier_20190822-092148.pkl

总结

在本章中,你学习了许多有用且实用的主题,这些内容将扩展你对机器学习理论的理解。你学习了如何在训练后序列化一个模型,以及如何加载它以供以后使用。此外,我们创建了一个SQLite数据库用于高效的数据存储,并创建了一个网页应用程序,让我们能够将我们的电影分类器提供给外部世界。

到目前为止,在本书中,我们已经涵盖了许多机器学习概念、最佳实践以及用于分类的监督模型。在下一章中,我们将探讨监督学习的另一个子类别——回归分析,它可以让我们在连续尺度上预测结果变量,这与我们迄今为止所使用的分类模型的类别标签不同。

第十章:使用回归分析预测连续目标变量

在之前的章节中,您学到了许多关于监督学习的主要概念,并训练了许多不同的分类任务模型,以预测组别成员或分类变量。在本章中,我们将深入探讨监督学习的另一个子类别:回归分析

回归模型用于预测连续范围的目标变量,这使得它们在解决许多科学问题时非常有吸引力。它们在工业中也有应用,例如理解变量之间的关系、评估趋势或进行预测。例如,预测公司未来几个月的销售额。

本章将讨论回归模型的主要概念,并涵盖以下主题:

  • 探索和可视化数据集

  • 查看实现线性回归模型的不同方法

  • 训练对异常值稳健的回归模型

  • 评估回归模型并诊断常见问题

  • 将回归模型拟合到非线性数据

介绍线性回归

线性回归的目标是建模一个或多个特征与连续目标变量之间的关系。与分类——监督学习的另一个子类别——不同,回归分析旨在预测连续范围的输出,而不是分类标签。

在以下的小节中,您将了解最基本的线性回归类型——简单线性回归,并理解如何将其与更一般的多变量情况(具有多个特征的线性回归)联系起来。

简单线性回归

简单(单变量)线性回归的目标是建模单个特征(解释变量x)与连续值目标响应变量y)之间的关系。具有一个解释变量的线性模型方程定义如下:

在这里,权重,,表示y轴截距,而是解释变量的权重系数。我们的目标是学习线性方程的权重,以描述解释变量与目标变量之间的关系,这些权重可以用来预测新的解释变量的响应,这些解释变量不属于训练数据集。

基于我们之前定义的线性方程,线性回归可以理解为寻找一条最佳拟合的直线,通过训练样本,如下图所示:

这条最佳拟合线也叫做回归线,从回归线到训练样本的垂直线被称为偏差残差——即我们的预测误差。

多元线性回归

前一节介绍了简单线性回归,它是具有一个解释变量的线性回归的特例。当然,我们也可以将线性回归模型推广到多个解释变量;这个过程被称为多元线性回归

这里,y轴与的截距。

下图显示了具有两个特征的多元线性回归模型的二维拟合超平面可能的样子:

如你所见,多个线性回归超平面在三维散点图中的可视化,在查看静态图像时已经很难理解。由于我们没有很好的方法在散点图中可视化具有两个维度的超平面(适用于具有三个或更多特征的数据集的多元线性回归模型),本章中的示例和可视化将主要聚焦于单变量情况,使用简单线性回归。然而,简单线性回归和多元线性回归基于相同的概念和评估技术;我们将在本章讨论的代码实现也兼容这两种类型的回归模型。

探索住房数据集

在实现第一个线性回归模型之前,我们将讨论一个新的数据集——住房数据集,它包含由D. Harrison和D.L. Rubinfeld于1978年收集的波士顿郊区房屋信息。该住房数据集已经免费提供,并包含在本书的代码包中。尽管该数据集最近已从UCI机器学习库中删除,但它仍可在线访问,网址为https://raw.githubusercontent.com/rasbt/python-machine-learning-book-3rd-edition/master/ch10/housing.data.txt或通过scikit-learn (https://github.com/scikit-learn/scikit-learn/blob/master/sklearn/datasets/data/boston_house_prices.csv)。和每一个新的数据集一样,使用简单的可视化探索数据,总是有助于更好地理解我们正在处理的内容。

将住房数据集加载到数据框中

在本节中,我们将使用pandas的read_csv函数加载住房数据集,该函数快速且多功能,是处理以纯文本格式存储的表格数据的推荐工具。

住房数据集中506个示例的特征来自之前在https://archive.ics.uci.edu/ml/datasets/Housing上分享的原始数据源,现总结如下:

  • CRIM:按城镇计算的人均犯罪率

  • ZN:划定为超过25,000平方英尺的住宅用地比例

  • INDUS:每个城镇非零售商业用地的比例

  • CHAS:查尔斯河虚拟变量(如果区域界限为河流,则为1,否则为0)

  • NOX:氮氧化物浓度(每千万分之一)

  • RM:每个住宅的平均房间数

  • AGE:1940年前建造的自有住房比例

  • DIS:到波士顿五个就业中心的加权距离

  • RAD:到辐射状高速公路的可达性指数

  • TAX:每 $10,000 的全额物业税税率

  • PTRATIO:各城镇的师生比

  • B:1000(Bk – 0.63)²,其中 Bk 是各城镇中[非洲裔美国人]人口的比例

  • LSTAT:低社会经济地位人群的百分比

  • MEDV:自有住房的中位数价值(单位为 $1000)

在本章的其余部分,我们将把房价(MEDV)视为目标变量——我们希望通过一个或多个解释变量来预测的变量。在进一步探索该数据集之前,让我们先将它加载到 pandas 的 DataFrame 中:

>>> import pandas as pd
>>> df = pd.read_csv('https://raw.githubusercontent.com/rasbt/'
...                  'python-machine-learning-book-3rd-edition'
...                  '/master/ch10/housing.data.txt',
...                  header=None,
...                  sep='\s+')
>>> df.columns = ['CRIM', 'ZN', 'INDUS', 'CHAS',
...               'NOX', 'RM', 'AGE', 'DIS', 'RAD',
...               'TAX', 'PTRATIO', 'B', 'LSTAT', 'MEDV']
>>> df.head() 

为了确认数据集是否已成功加载,我们可以显示数据集的前五行,如下图所示:

获取房屋数据集

你可以在本书的代码包中找到一份房屋数据集的副本(以及本书中使用的所有其他数据集),如果你处于离线状态,或者 web 链接 https://raw.githubusercontent.com/rasbt/python-machine-learning-book-3rd-edition/master/code/ch10/housing.data.txt 暂时不可用,你可以使用该副本。例如,要从本地目录加载房屋数据集,你可以替换以下几行:

df = pd.read_csv(
    'https://raw.githubusercontent.com/rasbt/'
    'python-machine-learning-book-3rd-edition/'
    'master/ch10/housing.data.txt',
    header=None,
    sep='\s+') 

在上面的代码示例中,使用这一行:

df = pd.read_csv('./housing.data.txt',
    sep='\s+') 

可视化数据集的重要特征

探索性数据分析EDA)是训练机器学习模型之前的重要且推荐的第一步。在本节的其余部分,我们将使用一些简单但有用的图形EDA工具箱中的技术,这些技术有助于我们从视觉上检测离群值的存在、数据的分布以及特征之间的关系。

首先,我们将创建一个散点图矩阵,它可以让我们在一个地方可视化数据集中不同特征之间的成对相关性。为了绘制散点图矩阵,我们将使用来自 MLxtend 库的scatterplotmatrix函数(http://rasbt.github.io/mlxtend/),这是一个包含多种机器学习和数据科学应用便捷函数的 Python 库。

你可以通过 conda install mlxtendpip install mlxtend 安装 mlxtend 包。安装完成后,你可以导入该包并按如下方式创建散点图矩阵:

>>> import matplotlib.pyplot as plt
>>> from mlxtend.plotting import scatterplotmatrix
>>> cols = ['LSTAT', 'INDUS', 'NOX', 'RM', 'MEDV']
>>> scatterplotmatrix(df[cols].values, figsize=(10, 8), 
...                   names=cols, alpha=0.5)
>>> plt.tight_layout()
>>> plt.show() 

如下图所示,散点图矩阵为我们提供了一个关于数据集中关系的有用图形总结:

由于篇幅限制和提高可读性的需要,我们仅绘制了数据集中的五列:LSTATINDUSNOXRMMEDV。然而,建议你通过在前面的scatterplotmatrix函数调用中选择不同的列名,或者通过省略列选择器,来创建整个DataFrame的散点图矩阵,以便进一步探索数据集。

使用这个散点图矩阵,我们现在可以快速地观察数据的分布情况,并查看是否存在异常值。例如,我们可以看到RM和房价MEDV之间有线性关系(这是散点图矩阵第四行第五列的内容)。此外,在散点图矩阵的右下子图中,我们可以看到MEDV变量似乎呈正态分布,但包含几个异常值。

线性回归的正态性假设

请注意,与常见的看法相反,训练线性回归模型并不要求解释变量或目标变量必须符合正态分布。正态性假设仅是某些统计和假设检验的要求,而这些内容超出了本书的范围(有关此主题的更多信息,请参阅《线性回归分析导论》,MontgomeryDouglas C. MontgomeryElizabeth A. PeckG. Geoffrey ViningWiley2012,第318-319页)。

使用相关矩阵查看变量之间的关系

在上一节中,我们通过直方图和散点图可视化了住房数据集变量的分布。接下来,我们将创建一个相关矩阵,以量化和总结变量之间的线性关系。相关矩阵与我们在第5章《通过主成分分析进行无监督降维》中讲解的协方差矩阵密切相关。我们可以将相关矩阵解释为协方差矩阵的重新缩放版本。实际上,相关矩阵与从标准化特征计算的协方差矩阵是相同的。

相关矩阵是一个方阵,包含皮尔逊积矩相关系数(通常缩写为皮尔逊r),它衡量特征对之间的线性依赖关系。相关系数的范围是 –1 到 1。两个特征如果r = 1,表示完美的正相关;如果r = 0,表示没有相关性;如果r = –1,表示完美的负相关。如前所述,皮尔逊相关系数可以简单地通过两个特征xy之间的协方差(分子)除以它们标准差的乘积(分母)来计算:

这里, 表示相应特征的均值, 是特征 xy 之间的协方差,而 是特征的标准差。

标准化特征的协方差与相关性

我们可以证明,一对标准化特征之间的协方差,实际上等于它们的线性相关系数。为了证明这一点,让我们首先标准化特征 xy,得到它们的 z 分数,分别表示为

请记住,我们计算两个特征之间的(总体)协方差的公式如下:

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

通过重新代入,我们得到以下结果:

最后,我们可以将此方程简化为以下形式:

在以下代码示例中,我们将使用 NumPy 的 corrcoef 函数,作用于我们之前在散点图矩阵中可视化的五个特征列,并使用 MLxtend 的 heatmap 函数将相关矩阵数组绘制为热图:

>>> from mlxtend.plotting import heatmap
>>> import numpy as np
>>> cm = np.corrcoef(df[cols].values.T)
>>> hm = heatmap(cm,
...              row_names=cols,
...              column_names=cols)
>>> plt.show() 

如结果图所示,相关矩阵为我们提供了另一个有用的总结图表,帮助我们基于特征之间的线性相关性选择特征:

为了拟合线性回归模型,我们关注与目标变量 MEDV 相关性较高的特征。通过查看先前的相关矩阵,我们可以看到目标变量 MEDVLSTAT 变量(-0.74)的相关性最大;然而,正如你从散点图矩阵中观察到的,LSTATMEDV 之间存在明显的非线性关系。另一方面,RMMEDV 之间的相关性也相对较高(0.70)。鉴于我们在散点图中观察到这两个变量之间的线性关系,RM 似乎是引入简单线性回归模型概念的一个很好的探索变量选择。

实现一个普通最小二乘线性回归模型

在本章开始时提到,线性回归可以理解为通过训练数据集中的样本点得到最佳拟合直线。然而,我们既没有定义最佳拟合这一术语,也没有讨论拟合这种模型的不同技术。在接下来的小节中,我们将使用普通最小二乘法OLS)(有时也称为线性最小二乘法)来估计线性回归直线的参数,从而最小化垂直距离(残差或误差)的平方和。

使用梯度下降法求解回归参数

考虑我们在第2章《训练简单机器学习算法进行分类》中实现的自适应线性神经元Adaline)。你一定还记得人工神经元使用的是线性激活函数。此外,我们定义了一个成本函数J(w),并通过优化算法(如梯度下降GD)和随机梯度下降SGD))来最小化它,从而学习权重。Adaline中的成本函数是平方误差和SSE),它与我们用于OLS的成本函数相同:

在这里, 是预测值 (注意,术语 只是为了方便推导GD的更新规则)。本质上,OLS回归可以理解为去掉单位阶跃函数后的Adaline,从而获得连续的目标值,而不是-11的类别标签。为了演示这一点,我们将从第2章《训练简单机器学习算法进行分类》中提取Adaline的GD实现,并去掉单位阶跃函数,来实现我们的第一个线性回归模型:

class LinearRegressionGD(object):

    def __init__(self, eta=0.001, n_iter=20):
        self.eta = eta
        self.n_iter = n_iter

    def fit(self, X, y):
        self.w_ = np.zeros(1 + X.shape[1])
        self.cost_ = []

        for i in range(self.n_iter):
            output = self.net_input(X)
            errors = (y - output)
            self.w_[1:] += self.eta * X.T.dot(errors)
            self.w_[0] += self.eta * errors.sum()
            cost = (errors**2).sum() / 2.0
            self.cost_.append(cost)
        return self

    def net_input(self, X):
        return np.dot(X, self.w_[1:]) + self.w_[0]

    def predict(self, X):
        return self.net_input(X) 

权重更新与梯度下降

如果你需要复习权重是如何更新的——即沿梯度的相反方向更新——请重新阅读第2章《训练简单机器学习算法进行分类》中的自适应线性神经元与学习的收敛性一节。

为了演示我们的LinearRegressionGD回归器的应用,首先使用Housing数据集中的RM(房间数量)变量作为自变量,并训练一个能够预测MEDV(房价)的模型。此外,我们将对变量进行标准化,以便更好地实现GD算法的收敛。代码如下:

>>> X = df[['RM']].values
>>> y = df['MEDV'].values
>>> from sklearn.preprocessing import StandardScaler
>>> sc_x = StandardScaler()
>>> sc_y = StandardScaler()
>>> X_std = sc_x.fit_transform(X)
>>> y_std = sc_y.fit_transform(y[:, np.newaxis]).flatten()
>>> lr = LinearRegressionGD()
>>> lr.fit(X_std, y_std) 

注意关于y_std的变通方法,使用了np.newaxisflatten。scikit-learn中的大多数变换器都期望数据存储为二维数组。在之前的代码示例中,y[:, np.newaxis]中的np.newaxis为数组添加了一个新的维度。然后,在StandardScaler返回缩放后的变量后,我们通过flatten()方法将其转换回原始的、具有一维数组表示的形式,方便我们使用。

我们在第2章训练简单的机器学习算法进行分类》中讨论过,当我们使用优化算法(如GD)时,绘制成本与训练数据集的迭代次数(epoch)之间的关系总是一个好主意,这样可以检查算法是否收敛到一个最小的成本(在这里是一个全局最小成本):

>>> plt.plot(range(1, lr.n_iter+1), lr.cost_)
>>> plt.ylabel('SSE')
>>> plt.xlabel('Epoch')
>>> plt.show() 

正如你在下面的图中看到的那样,GD算法在第5次迭代后收敛:

接下来,让我们可视化线性回归线拟合训练数据的效果。为此,我们将定义一个简单的辅助函数,用于绘制训练样本的散点图,并添加回归线:

>>> def lin_regplot(X, y, model):
...     plt.scatter(X, y, c='steelblue', edgecolor='white', s=70)
...     plt.plot(X, model.predict(X), color='black', lw=2)
...     return None 

现在,我们将使用这个lin_regplot函数将房间数量与房价进行绘图:

>>> lin_regplot(X_std, y_std, lr)
>>> plt.xlabel('Average number of rooms [RM] (standardized)')
>>> plt.ylabel('Price in $1000s [MEDV] (standardized)')
>>> plt.show() 

正如你在下面的图中看到的那样,线性回归线反映了房价随着房间数量的增加而普遍上升的趋势:

尽管这个观察结果是有道理的,但数据也告诉我们,在许多情况下,房间数量并不能很好地解释房价。在本章稍后,我们将讨论如何量化回归模型的表现。有趣的是,我们还可以观察到几个数据点排列在y = 3的位置,这表明价格可能被截断了。在某些应用中,报告预测结果变量的原始尺度可能也很重要。为了将预测的价格结果缩放回Price in $1000s轴,我们可以简单地使用StandardScalerinverse_transform方法:

>>> num_rooms_std = sc_x.transform(np.array([[5.0]]))
>>> price_std = lr.predict(num_rooms_std)
>>> print("Price in $1000s: %.3f" % \
...       sc_y.inverse_transform(price_std))
Price in $1000s: 10.840 

在这个代码示例中,我们使用了之前训练好的线性回归模型来预测一栋有五个房间的房子的价格。根据我们的模型,这样的房子价值$10,840。

顺便提一下,值得一提的是,如果我们使用标准化变量,我们在技术上不需要更新截距项的权重,因为在这种情况下,y轴的截距始终为0。我们可以通过打印权重来快速确认这一点:

>>> print('Slope: %.3f' % lr.w_[1])
Slope: 0.695
>>> print('Intercept: %.3f' % lr.w_[0])
Intercept: -0.000 

通过scikit-learn估计回归模型的系数

在上一节中,我们实现了一个用于回归分析的工作模型;然而,在实际应用中,我们可能会对更高效的实现感兴趣。例如,scikit-learn 的许多回归估计器使用了 SciPy 中的最小二乘实现(scipy.linalg.lstsq),它基于线性代数库(LAPACK)进行了高度优化。scikit-learn 中的线性回归实现也能(更好地)处理未标准化的变量,因为它不使用基于(S)GD 的优化,因此我们可以跳过标准化步骤:

>>> from sklearn.linear_model import LinearRegression
>>> slr = LinearRegression()
>>> slr.fit(X, y)
>>> y_pred = slr.predict(X)
>>> print('Slope: %.3f' % slr.coef_[0])
Slope: 9.102
>>> print('Intercept: %.3f' % slr.intercept_)
Intercept: -34.671 

从执行这段代码可以看出,scikit-learn 的 LinearRegression 模型,在未标准化的 RMMEDV 变量上拟合时,得出了不同的模型系数,因为这些特征没有进行标准化。然而,当我们通过绘制 MEDVRM 的关系来与我们的 GD 实现进行比较时,我们可以定性地看到它同样很好地拟合了数据:

>>> lin_regplot(X, y, slr)
>>> plt.xlabel('Average number of rooms [RM]')
>>> plt.ylabel('Price in $1000s [MEDV]')
>>> plt.show() 

例如,我们可以看到整体结果与我们的 GD 实现非常相似:

线性回归的解析解

除了使用机器学习库外,解决 OLS 的另一个方法是通过一个线性方程组的封闭解,这个解可以在大多数入门统计学教材中找到:

我们可以通过以下方式在 Python 中实现:

# adding a column vector of "ones"
>>> Xb = np.hstack((np.ones((X.shape[0], 1)), X))
>>> w = np.zeros(X.shape[1])
>>> z = np.linalg.inv(np.dot(Xb.T, Xb))
>>> w = np.dot(z, np.dot(Xb.T, y))
>>> print('Slope: %.3f' % w[1])
Slope: 9.102
>>> print('Intercept: %.3f' % w[0])
Intercept: -34.671 

这种方法的优点是它能够通过解析方法保证找到最优解。然而,如果我们处理的是非常大的数据集,求解这个公式中的矩阵逆可能会非常耗时(有时也称为正态方程),或者包含训练样本的矩阵可能是奇异矩阵(不可逆的),因此在某些情况下我们可能更倾向于使用迭代方法。

如果你想了解更多关于如何得到正态方程的信息,可以查看斯蒂芬·波洛克博士在莱斯特大学讲座中的章节 经典线性回归模型,该章节可以免费访问:http://www.le.ac.uk/users/dsgp1/COURSES/MESOMET/ECMETXT/06mesmet.pdf

此外,如果你想比较通过 GD、SGD、封闭解、QR 分解和奇异值分解得到的线性回归解,可以使用 MLxtend 中实现的 LinearRegression 类(http://rasbt.github.io/mlxtend/user_guide/regressor/LinearRegression/),该类允许用户在这些选项之间切换。另一个非常值得推荐的 Python 回归建模库是 Statsmodels,它实现了更为先进的线性回归模型,详细内容请参见 https://www.statsmodels.org/stable/examples/index.html#regression

使用 RANSAC 拟合一个稳健的回归模型

线性回归模型可能会受到异常值的严重影响。在某些情况下,数据中的一个非常小的子集可能对估计的模型系数产生很大影响。有许多统计测试可以用来检测异常值,但这些内容超出了本书的范围。然而,去除异常值总是需要我们作为数据科学家的判断和我们的领域知识。

作为抛弃异常值的替代方法,我们将使用随机样本一致性RANSAC)算法来进行回归分析,该算法将回归模型拟合到数据的子集上,即所谓的内点

我们可以将迭代的 RANSAC 算法总结如下:

  1. 随机选择一定数量的样本作为内点并拟合模型。

  2. 将所有其他数据点与拟合模型进行测试,并将那些落在用户给定容差范围内的点添加到内点中。

  3. 使用所有内点重新拟合模型。

  4. 估算拟合模型与内点之间的误差。

  5. 如果性能达到某个用户定义的阈值或达到固定的迭代次数,则终止算法;否则返回第 1 步。

现在,让我们使用线性模型并结合 scikit-learn 中实现的 RANSAC 算法,使用 RANSACRegressor 类:

>>> from sklearn.linear_model import RANSACRegressor
>>> ransac = RANSACRegressor(LinearRegression(),
...                          max_trials=100,
...                          min_samples=50,
...                          loss='absolute_loss',
...                          residual_threshold=5.0,
...                          random_state=0)
>>> ransac.fit(X, y) 

我们将 RANSACRegressor 的最大迭代次数设置为 100,并且使用 min_samples=50,将随机选择的训练样本的最小数量设置为至少 50。通过将 'absolute_loss' 作为 loss 参数的参数,算法计算拟合线与训练样本之间的绝对垂直距离。通过将 residual_threshold 参数设置为 5.0,我们只允许当训练样本到拟合线的垂直距离在 5 个单位距离以内时才将其包含在内点集中,这在这个特定数据集上表现良好。

默认情况下,scikit-learn 使用MAD估算方法来选择内点阈值,其中 MAD 代表目标值y中位绝对偏差。然而,选择适当的内点阈值是与问题相关的,这是 RANSAC 的一个缺点。近年来,已经开发了许多不同的方法来自动选择合适的内点阈值。你可以在《鲁棒多重结构拟合中的内点阈值自动估计》中找到详细讨论,R. ToldoA. FusielloSpringer2009(见《图像分析与处理–ICIAP 2009》,第123-131页)。

一旦我们拟合了 RANSAC 模型,就让我们从拟合的 RANSAC 线性回归模型中获取内点和外点,并将它们与线性拟合一起绘制:

>>> inlier_mask = ransac.inlier_mask_
>>> outlier_mask = np.logical_not(inlier_mask)
>>> line_X = np.arange(3, 10, 1)
>>> line_y_ransac = ransac.predict(line_X[:, np.newaxis])
>>> plt.scatter(X[inlier_mask], y[inlier_mask],
...             c='steelblue', edgecolor='white',
...             marker='o', label='Inliers')
>>> plt.scatter(X[outlier_mask], y[outlier_mask],
...             c='limegreen', edgecolor='white',
...             marker='s', label='Outliers')
>>> plt.plot(line_X, line_y_ransac, color='black', lw=2)
>>> plt.xlabel('Average number of rooms [RM]')
>>> plt.ylabel('Price in $1000s [MEDV]')
>>> plt.legend(loc='upper left')
>>> plt.show() 

如下图所示,线性回归模型是在检测到的内点集上拟合的,这些内点以圆圈表示:

当我们通过执行以下代码打印模型的斜率和截距时,线性回归线将与我们在上一节中没有使用RANSAC时得到的拟合线稍有不同:

>>> print('Slope: %.3f' % ransac.estimator_.coef_[0])
Slope: 10.735
>>> print('Intercept: %.3f' % ransac.estimator_.intercept_)
Intercept: -44.089 

使用RANSAC,我们减少了数据集中异常值的潜在影响,但我们不知道这种方法是否会对未见数据的预测性能产生积极影响。因此,在下一节中,我们将探讨评估回归模型的不同方法,这是构建预测建模系统中至关重要的一部分。

评估线性回归模型的性能

在上一节中,你学习了如何在训练数据上拟合回归模型。然而,你在前几章中已经发现,至关重要的一点是要在模型没有在训练时见过的数据上进行测试,以获得更不偏倚的泛化性能估计。

正如你会记得的,从第6章模型评估和超参数调优的最佳实践学习,我们希望将数据集分成单独的训练集和测试集,我们将使用前者来拟合模型,后者用来评估其在未见数据上的性能,以估计泛化性能。我们将不再继续使用简单回归模型,而是使用数据集中的所有变量并训练一个多元回归模型:

>>> from sklearn.model_selection import train_test_split
>>> X = df.iloc[:, :-1].values
>>> y = df['MEDV'].values
>>> X_train, X_test, y_train, y_test = train_test_split(
...       X, y, test_size=0.3, random_state=0)
>>> slr = LinearRegression()
>>> slr.fit(X_train, y_train)
>>> y_train_pred = slr.predict(X_train)
>>> y_test_pred = slr.predict(X_test) 

由于我们的模型使用了多个解释变量,我们无法在二维图中可视化线性回归线(或者更准确地说,是超平面),但我们可以绘制残差图(实际值与预测值之间的差异或垂直距离)与预测值的关系图,从而诊断我们的回归模型。残差图是诊断回归模型时常用的图形工具。它们可以帮助检测非线性和异常值,并检查误差是否随机分布。

使用以下代码,我们现在将绘制一个残差图,在该图中,我们简单地从预测响应中减去真实的目标变量:

>>> plt.scatter(y_train_pred, y_train_pred - y_train,
...             c='steelblue', marker='o', edgecolor='white',
...             label='Training data')
>>> plt.scatter(y_test_pred, y_test_pred - y_test,
...             c='limegreen', marker='s', edgecolor='white',
...             label='Test data')
>>> plt.xlabel('Predicted values')
>>> plt.ylabel('Residuals')
>>> plt.legend(loc='upper left')
>>> plt.hlines(y=0, xmin=-10, xmax=50, color='black', lw=2)
>>> plt.xlim([-10, 50])
>>> plt.show() 

执行代码后,我们应该看到一个通过 x 轴原点的残差图,如下所示:

在完美预测的情况下,残差应该恰好为零,但在实际应用中,我们可能永远不会遇到这种情况。然而,对于一个良好的回归模型,我们期望误差是随机分布的,残差应随机散布在中心线周围。如果在残差图中看到某种模式,这意味着我们的模型无法捕捉到一些解释性信息,这些信息已经渗入残差中,正如你在我们之前的残差图中可以略微看到的那样。此外,我们还可以利用残差图来检测异常值,这些异常值由偏离中心线较大的点表示。

另一种评估模型表现的有用定量指标是所谓的均方误差MSE),它仅仅是我们在拟合线性回归模型时最小化的SSE成本的平均值。MSE对于比较不同的回归模型或通过网格搜索和交叉验证调节它们的参数非常有用,因为它通过样本大小对SSE进行归一化:

让我们计算一下训练集和测试集预测的MSE:

>>> from sklearn.metrics import mean_squared_error
>>> print('MSE train: %.3f, test: %.3f' % (
...        mean_squared_error(y_train, y_train_pred),
...        mean_squared_error(y_test, y_test_pred)))
MSE train: 19.958, test: 27.196 

你可以看到,训练数据集上的均方误差(MSE)为19.96,而测试数据集上的MSE要大得多,达到了27.20,这表明我们的模型在这种情况下出现了过拟合。然而,请注意,与分类准确率等指标不同,MSE是没有上限的。换句话说,MSE的解释依赖于数据集和特征缩放。例如,如果房价以千元为单位(带有K后缀)表示,相比于处理未缩放特征的模型,相同的模型会得到较低的MSE。为了进一步说明这一点,

因此,有时报告决定系数)可能更为有用,它可以理解为MSE的标准化版本,有助于更好地解释模型的表现。换句话说,是模型捕获的响应方差的比例。的值定义为:

这里,SSE是误差平方和,SST是总平方和:

换句话说,SST仅仅是响应变量的方差。

让我们快速展示一下,实际上只是MSE的一个重新缩放版本:

对于训练数据集,的值限定在0到1之间,但对于测试数据集,它可能会变成负值。如果,则模型完美拟合数据,对应的MSE为0。

在训练数据上评估时,模型的为0.765,听起来还不错。然而,在测试数据集上的只有0.673,我们可以通过执行以下代码来计算这个值:

>>> from sklearn.metrics import r2_score
>>> print('R^2 train: %.3f, test: %.3f' %
...       (r2_score(y_train, y_train_pred),
...        r2_score(y_test, y_test_pred)))
R^2 train: 0.765, test: 0.673 

使用正则化方法进行回归

正如我们在第3章中讨论的,《使用scikit-learn的机器学习分类器巡礼》,正则化是通过加入额外的信息来应对过拟合问题的一种方法,从而缩小模型参数值,给模型复杂度施加惩罚。最常见的正则化线性回归方法有岭回归最小绝对收缩与选择算子LASSO)和弹性网

岭回归是一个L2惩罚模型,我们只是将权重的平方和添加到最小二乘成本函数中:

这里:

通过增大超参数的值,我们增加了正则化强度,从而缩小了模型的权重。请注意,我们不会对截距项进行正则化,

一个可能导致稀疏模型的替代方法是LASSO。根据正则化强度,某些权重可以变为零,这也使得LASSO成为一种有监督的特征选择技术:

在这里,LASSO的L1惩罚定义为模型权重的绝对值之和,如下所示:

然而,LASSO的一个限制是,当m > n时,它最多选择n个特征,其中n是训练样本的数量。如果在某些特征选择应用中,这可能是不希望的。然而,在实践中,LASSO的这个特性往往是一个优势,因为它避免了模型的饱和。模型饱和是指训练样本的数量等于特征的数量,这是一种过度参数化的形式。因此,饱和模型可以总是完美地拟合训练数据,但仅仅是一种插值形式,因此不被期望能很好地推广。

Ridge回归和LASSO之间的折衷是弹性网,它具有L1惩罚以产生稀疏性,并且具有L2惩罚,因此在m > n时可以用于选择超过n个特征:

这些正则化回归模型都可以通过scikit-learn获得,其使用方式与普通回归模型类似,不同之处在于我们必须通过参数指定正则化强度,例如,通过k折交叉验证进行优化。

可以通过以下方式初始化Ridge回归模型:

>>> from sklearn.linear_model import Ridge
>>> ridge = Ridge(alpha=1.0) 

请注意,正则化强度由参数alpha控制,类似于参数。同样,我们可以从linear_model子模块初始化LASSO回归器:

>>> from sklearn.linear_model import Lasso
>>> lasso = Lasso(alpha=1.0) 

最后,ElasticNet实现允许我们调整L1与L2的比率:

>>> from sklearn.linear_model import ElasticNet
>>> elanet = ElasticNet(alpha=1.0, l1_ratio=0.5) 

例如,如果我们将l1_ratio设置为1.0,则ElasticNet回归器将等于LASSO回归。有关不同线性回归实现的更多详细信息,请参阅文档:http://scikit-learn.org/stable/modules/linear_model.html

将线性回归模型转化为曲线——多项式回归

在前面的部分中,我们假设了解释变量与响应变量之间存在线性关系。为了解决线性假设的违背问题,一种方法是通过添加多项式项来使用多项式回归模型:

这里,d 表示多项式的次数。虽然我们可以使用多项式回归来建模非线性关系,但由于使用的是线性回归系数 w,它仍然被视为一个多元线性回归模型。在接下来的子章节中,我们将看到如何方便地向现有数据集添加这些多项式项,并拟合多项式回归模型。

使用 scikit-learn 添加多项式项

我们现在将学习如何使用 scikit-learn 中的 PolynomialFeatures 变换器类,向一个简单回归问题中添加二次项(d = 2)。然后,我们将通过以下步骤将多项式与线性拟合进行比较:

  1. 添加二次多项式项:

    >>> from sklearn.preprocessing import PolynomialFeatures
    >>> X = np.array([ 258.0, 270.0, 294.0, 320.0, 342.0,
    ...                368.0, 396.0, 446.0, 480.0, 586.0])\
    ...              [:, np.newaxis]
    >>> y = np.array([ 236.4, 234.4, 252.8, 298.6, 314.2,
    ...                342.2, 360.8, 368.0, 391.2, 390.8])
    >>> lr = LinearRegression()
    >>> pr = LinearRegression()
    >>> quadratic = PolynomialFeatures(degree=2)
    >>> X_quad = quadratic.fit_transform(X) 
    
  2. 拟合一个简单的线性回归模型以进行比较:

    >>> lr.fit(X, y)
    >>> X_fit = np.arange(250, 600, 10)[:, np.newaxis]
    >>> y_lin_fit = lr.predict(X_fit) 
    
  3. 对变换后的特征拟合多元回归模型以进行多项式回归:

    >>> pr.fit(X_quad, y)
    >>> y_quad_fit = pr.predict(quadratic.fit_transform(X_fit)) 
    
  4. 绘制结果:

    >>> plt.scatter(X, y, label='Training points')
    >>> plt.plot(X_fit, y_lin_fit,
    ...          label='Linear fit', linestyle='--')
    >>> plt.plot(X_fit, y_quad_fit,
    ...          label='Quadratic fit')
    >>> plt.xlabel('Explanatory variable')
    >>> plt.ylabel('Predicted or known target values')
    >>> plt.legend(loc='upper left')
    >>> plt.tight_layout()
    >>> plt.show() 
    

在结果图中,您可以看到多项式拟合比线性拟合更好地捕捉了响应变量与解释变量之间的关系:

接下来,我们将计算 MSE 和 评估指标:

>>> y_lin_pred = lr.predict(X)
>>> y_quad_pred = pr.predict(X_quad)
>>> print('Training MSE linear: %.3f, quadratic: %.3f' % (
...       mean_squared_error(y, y_lin_pred),
...       mean_squared_error(y, y_quad_pred)))
Training MSE linear: 569.780, quadratic: 61.330
>>> print('Training R^2 linear: %.3f, quadratic: %.3f' % (
...       r2_score(y, y_lin_pred),
...       r2_score(y, y_quad_pred)))
Training R^2 linear: 0.832, quadratic: 0.982 

如您所见,在执行代码后,MSE 从 570(线性拟合)降低到 61(二次拟合);此外,决定系数反映了二次模型()相比于线性模型()在这个特定的示例问题中更贴合。

在房屋数据集中建模非线性关系

在前面的子章节中,您学习了如何构建多项式特征以拟合玩具问题中的非线性关系;现在,让我们来看一个更实际的例子,并将这些概念应用于房屋数据集中的数据。通过执行以下代码,我们将使用二次(平方)和三次(立方)多项式建模房价与 LSTAT(低收入群体百分比)之间的关系,并将其与线性拟合进行比较:

>>> X = df[['LSTAT']].values
>>> y = df['MEDV'].values
>>> regr = LinearRegression()
# create quadratic features
>>> quadratic = PolynomialFeatures(degree=2)
>>> cubic = PolynomialFeatures(degree=3)
>>> X_quad = quadratic.fit_transform(X)
>>> X_cubic = cubic.fit_transform(X)
# fit features
>>> X_fit = np.arange(X.min(), X.max(), 1)[:, np.newaxis]
>>> regr = regr.fit(X, y)
>>> y_lin_fit = regr.predict(X_fit)
>>> linear_r2 = r2_score(y, regr.predict(X))
>>> regr = regr.fit(X_quad, y)
>>> y_quad_fit = regr.predict(quadratic.fit_transform(X_fit))
>>> quadratic_r2 = r2_score(y, regr.predict(X_quad))
>>> regr = regr.fit(X_cubic, y)
>>> y_cubic_fit = regr.predict(cubic.fit_transform(X_fit))
>>> cubic_r2 = r2_score(y, regr.predict(X_cubic))
# plot results
>>> plt.scatter(X, y, label='Training points', color='lightgray')
>>> plt.plot(X_fit, y_lin_fit,
...          label='Linear (d=1), $R^2=%.2f$' % linear_r2,
...          color='blue',
...          lw=2,
...          linestyle=':')
>>> plt.plot(X_fit, y_quad_fit,
...          label='Quadratic (d=2), $R^2=%.2f$' % quadratic_r2,
...          color='red',
...          lw=2,
...          linestyle='-')
>>> plt.plot(X_fit, y_cubic_fit,
...          label='Cubic (d=3), $R^2=%.2f$' % cubic_r2,
...          color='green',
...          lw=2,
...          linestyle='--')
>>> plt.xlabel('% lower status of the population [LSTAT]')
>>> plt.ylabel('Price in $1000s [MEDV]')
>>> plt.legend(loc='upper right')
>>> plt.show() 

结果图如下所示:

如您所见,三次拟合比线性和二次拟合更好地捕捉了房价与 LSTAT 之间的关系。然而,您应该意识到,添加越来越多的多项式特征会增加模型的复杂性,从而增加过拟合的风险。因此,在实际应用中,始终建议在一个单独的测试数据集上评估模型的性能,以估计其泛化能力。

此外,多项式特征并不总是建模非线性关系的最佳选择。例如,通过一些经验或直觉,仅查看MEDV-LSTAT散点图可能会导致一个假设,即对LSTAT特征变量进行对数转换,并对MEDV取平方根可能会将数据投射到适合线性回归拟合的线性特征空间。例如,我认为这两个变量之间的关系看起来非常类似于一个指数函数:

由于指数函数的自然对数是一条直线,我认为这样的对数转换在这里可能是有用的:

让我们通过执行以下代码来测试这一假设:

>>> # transform features
>>> X_log = np.log(X)
>>> y_sqrt = np.sqrt(y)
>>>
>>> # fit features
>>> X_fit = np.arange(X_log.min()-1,
...                   X_log.max()+1, 1)[:, np.newaxis]
>>> regr = regr.fit(X_log, y_sqrt)
>>> y_lin_fit = regr.predict(X_fit)
>>> linear_r2 = r2_score(y_sqrt, regr.predict(X_log))
>>> # plot results
>>> plt.scatter(X_log, y_sqrt,
...             label='Training points',
...             color='lightgray')
>>> plt.plot(X_fit, y_lin_fit,
...          label='Linear (d=1), $R^2=%.2f$' % linear_r2,
...          color='blue',
...          lw=2)
>>> plt.xlabel('log(% lower status of the population [LSTAT])')
>>> plt.ylabel('$\sqrt{Price \; in \; \$1000s \; [MEDV]}$')
>>> plt.legend(loc='lower left')
>>> plt.tight_layout()
>>> plt.show() 

在将解释变量转换到对数空间并对目标变量取平方根后,我们能够通过线性回归线捕捉两个变量之间的关系,该线性回归线似乎比任何先前的多项式特征转换更适合数据():

使用随机森林处理非线性关系

在本节中,我们将介绍随机森林回归,这与本章中先前介绍的回归模型在概念上有所不同。随机森林是多个决策树的集成,可以理解为分段线性函数的总和,与我们之前讨论的全局线性和多项式回归模型形成对比。换句话说,通过决策树算法,我们将输入空间细分为更小的区域,从而更易管理。

决策树回归

决策树算法的优点在于,如果处理非线性数据,它不需要对特征进行任何转换,因为决策树一次分析一个特征,而不考虑加权组合。(同样,决策树不需要归一化或标准化特征。)你会记得第三章使用scikit-learn进行机器学习分类器之旅,我们通过迭代地分割其节点来生长决策树,直到叶子节点纯净或满足停止准则。当我们将决策树用于分类时,我们定义熵作为不纯度的度量,以确定哪个特征分割最大化信息增益IG),可以如下定义用于二进制分割:

这里,是执行分裂的特征,是父节点中的训练样本数量,I是纯度函数,是父节点中的训练样本子集,而分别是分裂后左右子节点中的训练样本子集。请记住,我们的目标是找到最大化信息增益的特征分裂;换句话说,我们希望找到能够最大程度减少子节点纯度的特征分裂。在第3章使用scikit-learn的机器学习分类器导览中,我们讨论了基尼纯度和熵作为纯度度量,它们都是分类问题中的有用标准。然而,若要使用决策树进行回归,我们需要一个适合连续变量的纯度度量,因此我们将节点t的纯度度量定义为均方误差(MSE):

这里,是节点t中的训练样本数量,是节点t中的训练子集,是真实目标值,而是预测的目标值(样本均值):

在决策树回归的背景下,MSE通常被称为节点内方差,这也是为什么分裂标准更常被称为方差减少。为了看到决策树的拟合效果,让我们使用scikit-learn中实现的DecisionTreeRegressor来建模MEDVLSTAT变量之间的非线性关系:

>>> from sklearn.tree import DecisionTreeRegressor
>>> X = df[['LSTAT']].values
>>> y = df['MEDV'].values
>>> tree = DecisionTreeRegressor(max_depth=3)
>>> tree.fit(X, y)
>>> sort_idx = X.flatten().argsort()
>>> lin_regplot(X[sort_idx], y[sort_idx], tree)
>>> plt.xlabel('% lower status of the population [LSTAT]')
>>> plt.ylabel('Price in $1000s [MEDV]')
>>> plt.show() 

正如您在结果图中所看到的,决策树捕捉到了数据中的一般趋势。然而,这个模型的一个局限性是,它没有捕捉到期望预测的连续性和可微性。此外,我们还需要小心选择决策树的适当深度,以避免过拟合或欠拟合数据;在这里,深度为三似乎是一个不错的选择:

在下一节中,我们将介绍一种更强大的回归树拟合方法:随机森林。

随机森林回归

正如你在第3章《使用scikit-learn的机器学习分类器概览》中学到的那样,随机森林算法是一种集成技术,它结合了多个决策树。由于随机性,随机森林通常比单独的决策树具有更好的泛化性能,这有助于降低模型的方差。随机森林的其他优点是它们对数据集中的异常值不太敏感,并且不需要太多的参数调优。随机森林中我们通常需要实验的唯一参数是集成中树的数量。回归的基本随机森林算法与我们在第3章《使用scikit-learn的机器学习分类器概览》中讨论的分类的随机森林算法几乎相同。唯一的区别是我们使用MSE标准来生长各个决策树,预测的目标变量是通过所有决策树的平均预测值来计算的。

现在,让我们使用住房数据集中的所有特征,在60%的样本上拟合一个随机森林回归模型,并在剩余的40%的样本上评估其性能。代码如下:

>>> X = df.iloc[:, :-1].values
>>> y = df['MEDV'].values
>>> X_train, X_test, y_train, y_test =\
...     train_test_split(X, y,
...                      test_size=0.4,
...                      random_state=1)
>>>
>>> from sklearn.ensemble import RandomForestRegressor
>>> forest = RandomForestRegressor(n_estimators=1000,
...                                criterion='mse',
...                                random_state=1,
...                                n_jobs=-1)
>>> forest.fit(X_train, y_train)
>>> y_train_pred = forest.predict(X_train)
>>> y_test_pred = forest.predict(X_test)
>>> print('MSE train: %.3f, test: %.3f' % (
...       mean_squared_error(y_train, y_train_pred),
...       mean_squared_error(y_test, y_test_pred)))
MSE train: 1.642, test: 11.052
>>> print('R^2 train: %.3f, test: %.3f' % (
...       r2_score(y_train, y_train_pred),
...       r2_score(y_test, y_test_pred)))
R^2 train: 0.979, test: 0.878 

不幸的是,你可以看到随机森林倾向于过拟合训练数据。然而,它仍然能够相对较好地解释目标变量和解释变量之间的关系(在测试数据集上见)。

最后,让我们也来看一下预测的残差:

>>> plt.scatter(y_train_pred,
...             y_train_pred - y_train,
...             c='steelblue',
...             edgecolor='white',
...             marker='o',
...             s=35,
...             alpha=0.9,
...             label='Training data')
>>> plt.scatter(y_test_pred,
...             y_test_pred - y_test,
...             c='limegreen',
...             edgecolor='white',
...             marker='s',
...             s=35,
...             alpha=0.9,
...             label='Test data')
>>> plt.xlabel('Predicted values')
>>> plt.ylabel('Residuals')
>>> plt.legend(loc='upper left')
>>> plt.hlines(y=0, xmin=-10, xmax=50, lw=2, color='black')
>>> plt.xlim([-10, 50])
>>> plt.tight_layout()
>>> plt.show() 

正如通过系数已经总结的那样,你可以看到模型在训练数据上的拟合效果优于测试数据,正如y轴方向上的异常值所示。此外,残差的分布似乎并非完全围绕零中心点随机分布,这表明模型无法捕捉到所有的探索性信息。然而,残差图显示出相较于我们在本章早些时候绘制的线性模型的残差图,模型有了很大的改进:

理想情况下,我们的模型误差应该是随机的或不可预测的。换句话说,预测的误差不应与任何解释变量中包含的信息相关,而应反映现实世界分布或模式的随机性。如果我们在预测误差中发现了模式,例如通过检查残差图,这意味着残差图中包含了预测信息。一个常见的原因可能是解释变量的信息泄漏到了这些残差中。

不幸的是,目前没有一种通用方法来处理残差图中的非随机性问题,这需要通过实验来解决。根据我们所拥有的数据,我们可能能够通过转换变量、调优学习算法的超参数、选择简单或复杂的模型、移除离群值或增加额外的变量来改进模型。

支持向量机回归

第3章使用scikit-learn的机器学习分类器概览中,我们还学习了核技巧,核技巧可以与支持向量机SVM)结合使用进行分类,并且在处理非线性问题时非常有用。虽然讨论超出了本书的范围,但SVM也可以应用于非线性回归任务。感兴趣的读者可以参考一篇关于SVM回归的优秀报告:《用于分类和回归的支持向量机》,S. R. Gunn等人,南安普敦大学技术报告,14,1998http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.579.6867&rep=rep1&type=pdf)。scikit-learn中也实现了SVM回归器,关于其使用的更多信息可以参考http://scikit-learn.org/stable/modules/generated/sklearn.svm.SVR.html#sklearn.svm.SVR

总结

在本章开始时,您学习了简单线性回归分析,用于建模单一解释变量与连续响应变量之间的关系。接着,我们讨论了一种有用的解释性数据分析技术,用于观察数据中的模式和异常,这在预测建模任务中是一个重要的第一步。

我们通过实现基于梯度优化方法的线性回归来构建了我们的第一个模型。然后,您看到了如何利用scikit-learn的线性模型进行回归,并实现了一种鲁棒回归技术(RANSAC)作为处理离群值的一种方法。为了评估回归模型的预测性能,我们计算了均方误差和相关的度量。此外,我们还讨论了一种有用的图形化方法来诊断回归模型的问题:残差图。

在我们探索了如何将正则化应用于回归模型以减少模型复杂度并避免过拟合之后,我们还介绍了几种建模非线性关系的方法,包括多项式特征转换和随机森林回归器。

在前几章中,我们详细讨论了监督学习、分类和回归分析。在下一章中,我们将学习机器学习的另一个有趣的子领域——无监督学习,以及如何在没有目标变量的情况下使用聚类分析来发掘数据中的隐藏结构。

第十一章:使用未标记数据进行聚类分析

在前几章中,我们使用监督学习技术构建机器学习模型,使用的是答案已经知道的数据——训练数据中已经有类标签。在本章中,我们将转变思路,探索聚类分析,它是无监督学习的一种技术,允许我们在数据中发现隐藏的结构,而我们并不知道正确答案。聚类的目标是找到数据中的自然分组,使得同一聚类中的项彼此之间比与其他聚类中的项更相似。

由于聚类具有探索性,因此是一个令人兴奋的主题,在本章中,你将学习以下概念,这些概念有助于我们将数据组织成有意义的结构:

  • 使用流行的k-means算法查找相似性的中心

  • 采用自下而上的方法构建层次聚类树

  • 使用基于密度的聚类方法识别任意形状的物体

使用k-means按相似性对物体进行分组

在本节中,我们将学习一种最流行的聚类算法——k-means,它在学术界和工业界都得到了广泛应用。聚类(或聚类分析)是一种技术,它可以帮助我们找到相似物体的组,这些物体之间比与其他组中的物体更为相关。聚类在商业中的应用实例包括根据不同主题对文档、音乐和电影进行分组,或者基于共同的购买行为找到具有相似兴趣的客户,从而为推荐引擎提供依据。

使用scikit-learn进行K-means聚类

正如你将在稍后看到的,k-means算法极其容易实现,但与其他聚类算法相比,它在计算上也非常高效,这可能解释了它的流行。k-means算法属于基于原型的聚类范畴。我们将在本章稍后讨论另外两种聚类类别——层次聚类基于密度的聚类

基于原型的聚类意味着每个聚类由一个原型表示,该原型通常是具有连续特征的相似点的质心平均值),或者在处理类别特征时,表示为中位点(最具代表性的点或最小化与其他属于特定聚类点之间的距离的点)。虽然k-means非常擅长识别具有球形的聚类,但这种聚类算法的一个缺点是我们必须先验指定聚类的数量k。不合适的k选择可能导致聚类效果不佳。稍后我们将在本章中讨论肘部法则轮廓图,这些都是评估聚类质量的有用技术,帮助我们确定最佳的聚类数量k

尽管 k-means 聚类可以应用于高维数据,但我们将使用一个简单的二维数据集来进行讲解,以便于可视化:

>>> from sklearn.datasets import make_blobs
>>> X, y = make_blobs(n_samples=150,
...                   n_features=2,
...                   centers=3,
...                   cluster_std=0.5,
...                   shuffle=True,
...                   random_state=0)
>>> import matplotlib.pyplot as plt
>>> plt.scatter(X[:, 0],
...             X[:, 1],
...             c='white',
...             marker='o',
...             edgecolor='black',
...             s=50)
>>> plt.grid()
>>> plt.tight_layout()
>>> plt.show() 

我们刚刚创建的数据集包含 150 个随机生成的点,这些点大致被分为三个高密度区域,通过二维散点图进行可视化:

在聚类的实际应用中,我们没有关于这些示例的任何真实类别信息(与推理相对的经验性证据提供的信息);如果我们有类标签,这项任务将属于监督学习的范畴。因此,我们的目标是根据特征相似性对示例进行分组,这可以通过 k-means 算法实现,概述如下四个步骤:

  1. 随机从示例中挑选 k 个质心作为初始聚类中心。

  2. 将每个示例分配给最近的质心,

  3. 将质心移动到分配给它的示例的中心。

  4. 重复步骤 2 和 3,直到聚类分配不再变化,或者达到用户定义的容忍度或最大迭代次数。

现在,下一个问题是,我们如何衡量对象之间的相似性?我们可以将相似性定义为距离的反面,而用于聚类具有连续特征的示例的常用距离是两个点 xym 维空间中的 平方欧几里得距离

请注意,在前面的方程中,索引 j 指的是示例输入 xyj 维度(特征列)。在本节的其余部分,我们将使用上标 ij 分别表示示例(数据记录)和聚类索引的索引。

基于这个欧几里得距离度量,我们可以将 k-means 算法描述为一个简单的优化问题,即一个迭代方法,用于最小化聚类内部的 平方误差和SSE),有时也叫做 聚类惯性

在这里, 是聚类 j 的代表点(质心)。如果示例 在聚类 j 中,则为 ,否则为 0。

现在你已经了解了简单的 k-means 算法是如何工作的,让我们通过使用 scikit-learn 的 cluster 模块中的 KMeans 类,将它应用到我们的示例数据集上:

>>> from sklearn.cluster import KMeans
>>> km = KMeans(n_clusters=3,
...             init='random',
...             n_init=10,
...             max_iter=300,
...             tol=1e-04,
...             random_state=0)
>>> y_km = km.fit_predict(X) 

使用前面的代码,我们将期望的聚类数量设置为 3预先指定聚类数量是 k-means 的一个限制。我们设置 n_init=10,使得 k-means 聚类算法独立运行 10 次,并使用不同的随机质心来选择 SSE 最小的最终模型。通过 max_iter 参数,我们指定每次运行的最大迭代次数(这里为 300)。需要注意的是,如果在达到最大迭代次数之前就已经收敛,scikit-learn 中的 k-means 实现会提前停止。然而,也有可能 k-means 在某次运行中未能收敛,如果我们选择较大的 max_iter 值,这可能会带来计算上的问题。解决收敛问题的一种方法是选择更大的 tol 值,这是一个控制聚类内 SSE 变化的容差参数,以宣告收敛。在前面的代码中,我们选择了容差 1e-04 (=0.0001)。

k-means 的一个问题是一个或多个聚类可能为空。需要注意的是,这个问题在 k-medoids 或模糊 C-means 中并不存在,这是一种我们将在本节稍后讨论的算法。

然而,这个问题在当前的 scikit-learn 中的 k-means 实现中得到了考虑。如果一个聚类为空,算法将搜索距离该空聚类质心最远的样本点,然后将质心重新分配到这个最远点。

特征缩放

当我们使用欧几里得距离度量将 k-means 应用于真实世界的数据时,我们希望确保特征在相同的尺度上进行衡量,并在必要时应用 z-score 标准化或最小-最大缩放。

在预测了聚类标签 y_km 并讨论了 k-means 算法的一些挑战之后,接下来我们将可视化 k-means 在数据集中识别出的聚类以及聚类质心。这些信息保存在拟合后的 KMeans 对象的 cluster_centers_ 属性中:

>>> plt.scatter(X[y_km == 0, 0],
...             X[y_km == 0, 1],
...             s=50, c='lightgreen',
...             marker='s', edgecolor='black',
...             label='Cluster 1')
>>> plt.scatter(X[y_km == 1, 0],
...             X[y_km == 1, 1],
...             s=50, c='orange',
...             marker='o', edgecolor='black',
...             label='Cluster 2')
>>> plt.scatter(X[y_km == 2, 0],
...             X[y_km == 2, 1],
...             s=50, c='lightblue',
...             marker='v', edgecolor='black',
...             label='Cluster 3')
>>> plt.scatter(km.cluster_centers_[:, 0],
...             km.cluster_centers_[:, 1],
...             s=250, marker='*',
...             c='red', edgecolor='black',
...             label='Centroids')
>>> plt.legend(scatterpoints=1)
>>> plt.grid()
>>> plt.tight_layout()
>>> plt.show() 

在下面的散点图中,你可以看到 k-means 将三个质心放置在每个球体的中心,考虑到这个数据集,这看起来是一个合理的分组:

尽管 k-means 在这个玩具数据集上表现良好,但我们将突出 k-means 的另一个缺点:我们必须预先指定聚类的数量 k。在实际应用中,选择聚类的数量可能并不总是那么明显,尤其是当我们处理的是无法可视化的高维数据集时。k-means 的其他特点是聚类不重叠且没有层次结构,我们还假设每个聚类中至少有一个样本。在本章后面,我们将遇到不同类型的聚类算法,包括层次聚类和基于密度的聚类。两种算法都不要求我们预先指定聚类数量,也不假设数据集具有球形结构。

在下一小节中,我们将介绍经典k-means算法的一个流行变体——k-means++。虽然它没有解决前面一段中讨论的k-means的假设和缺点,但通过更加智能地初始化聚类中心,它可以显著改善聚类结果。

使用k-means++来智能地放置初始聚类质心。

到目前为止,我们讨论了经典的k-means算法,该算法使用随机种子来放置初始质心,如果初始质心选择不当,可能会导致不良的聚类结果或收敛缓慢。解决这一问题的一种方法是多次运行k-means算法,并选择SSE表现最好的模型。

另一种策略是通过k-means++算法将初始质心放置得彼此远离,这比经典的k-means算法能得到更好且更一致的结果(k-means++: The Advantages of Careful SeedingD. ArthurS. VassilvitskiiProceedings of the eighteenth annual ACM-SIAM symposium on Discrete algorithms,第1027-1035页,Society for Industrial and Applied Mathematics2007)。

k-means++的初始化可以总结如下:

  1. 初始化一个空集M,用于存储正在选择的k个质心。

  2. 随机选择第一个质心,,从输入示例中选取并分配给M

  3. 对于每个不在M中的示例,,找到与M中任何质心的最小平方距离,

  4. 为了随机选择下一个质心,,使用一个等于的加权概率分布。

  5. 重复步骤2和3,直到选择出k个质心。

  6. 继续使用经典的k-means算法。

要在scikit-learn的KMeans对象中使用k-means++,只需要将init参数设置为'k-means++'。事实上,'k-means++'init参数的默认值,这在实践中是强烈推荐的。我们在之前的示例中没有使用它的唯一原因是为了避免一次性引入太多概念。接下来的这一部分关于k-means的内容将使用k-means++,但你可以尝试更多两种不同的初始化方法(经典k-means通过init='random'与k-means++通过init='k-means++')来放置初始聚类质心。

硬聚类与软聚类。

硬聚类描述了一类算法,其中数据集中的每个示例都被分配到一个簇中,就像我们在本章之前讨论的k-means和k-means++算法一样。相反,软聚类(有时也称为模糊聚类)的算法会将一个示例分配给一个或多个簇。模糊C均值FCM)算法(也叫做软k-means模糊k-means)是软聚类的一个流行例子。最初的想法可以追溯到1970年代,当时Joseph C. Dunn首次提出了一个早期版本的模糊聚类,以改进k-means(A Fuzzy Relative of the ISODATA Process and Its Use in Detecting Compact Well-Separated ClustersJ. C. Dunn1973)。近十年后,James C. Bedzek发表了关于模糊聚类算法改进的工作,这一算法现在被称为FCM算法(Pattern Recognition with Fuzzy Objective Function AlgorithmsJ. C. BezdekSpringer Science+Business Media2013)。

FCM过程与k-means非常相似。然而,我们将硬聚类分配替换为每个点属于每个簇的概率。在k-means中,我们可以用一个稀疏的二进制向量来表示示例x的簇成员关系:

在这里,值为1的索引位置表示示例被分配到的簇质心(假设)。相反,FCM中的成员关系向量可以表示如下:

在这里,每个值的范围是[0, 1],表示该点属于相应簇质心的概率。给定示例的所有成员关系之和等于1。与k-means算法一样,我们可以用四个关键步骤总结FCM算法:

  1. 指定k个质心的数量,并随机分配每个点的簇成员关系。

  2. 计算簇质心,

  3. 更新每个点的簇成员关系。

  4. 重复步骤2和步骤3,直到成员系数不再变化,或者达到用户定义的容差或最大迭代次数。

FCM的目标函数——我们简写为——看起来非常类似于我们在k-means中最小化的簇内SSE:

然而,请注意,成员指示符不像k-means中的二值值(),而是一个实值,表示簇成员的概率()。你可能还注意到,我们在中添加了一个额外的指数;这个指数m是一个大于或等于1的任意数字(通常m=2),它是所谓的模糊度系数(或简称模糊因子),控制着模糊性的程度。

m值越大,簇成员资格的值,,就越小,这会导致更模糊的簇。簇成员概率本身的计算公式如下:

例如,如果我们选择了三个簇中心,如前面的k-means例子中所示,我们可以按照如下方式计算属于簇的成员资格:

一个簇的中心,,是通过计算所有样本的加权平均得到的,权重取决于每个样本属于该簇的程度():

仅仅从计算簇成员资格的方程式来看,我们可以说,FCM中的每次迭代比k-means中的迭代更加昂贵。另一方面,FCM通常需要更少的迭代次数才能达到收敛。不幸的是,FCM算法目前并未在scikit-learn中实现。然而,实际应用中发现,k-means和FCM产生的聚类结果非常相似,正如一项研究所描述的那样(Comparative Analysis of k-means and Fuzzy C-Means AlgorithmsS. GhoshS. K. DubeyIJACSA,4: 35–38,2013)。

使用肘部法则找到最佳簇数

无监督学习中的主要挑战之一是我们并不知道确切的答案。我们的数据集中没有可以用来评估监督学习模型表现的真实标签,这使得我们无法应用在第六章中介绍的技术,学习最佳实践:模型评估和超参数调优,来评估监督模型的性能。因此,为了量化聚类质量,我们需要使用内在度量——比如簇内SSE(失真度)——来比较不同k-means聚类的表现。

方便的是,在使用scikit-learn时,我们不需要显式计算簇内SSE,因为在拟合KMeans模型之后,它已经可以通过inertia_属性直接访问:

>>> print('Distortion: %.2f' % km.inertia_)
Distortion: 72.48 

基于簇内SSE,我们可以使用图形化工具,也就是所谓的肘部法则,来估算给定任务的最佳簇数k。我们可以说,如果k增加,失真度会减小。这是因为样本会更接近它们被分配的质心。肘部法则的核心思想是找出k的值,在这个值上,失真度开始最急剧增加,如果我们绘制不同k值的失真度曲线,效果会更加清晰:

>>> distortions = []
>>> for i in range(1, 11):
...     km = KMeans(n_clusters=i,
...                 init='k-means++',
...                 n_init=10,
...                 max_iter=300,
...                 random_state=0)
...     km.fit(X)
...     distortions.append(km.inertia_)
>>> plt.plot(range(1,11), distortions, marker='o')
>>> plt.xlabel('Number of clusters')
>>> plt.ylabel('Distortion')
>>> plt.tight_layout()
>>> plt.show() 

如下图所示,肘部位于k = 3,因此这是证明k = 3确实是这个数据集的一个好选择的证据:

通过轮廓图量化聚类质量

另一种评估聚类质量的内在指标是轮廓分析,它也可以应用于k-means以外的聚类算法,我们将在本章稍后讨论。轮廓分析可以作为一种图形工具,绘制出聚类内样本紧密程度的度量。为了计算我们数据集中单个样本的轮廓系数,我们可以执行以下三个步骤:

  1. 计算聚类内聚度,即样本与同一聚类内所有其他点之间的平均距离,

  2. 计算聚类分离度,即通过下一个最接近的聚类与样本之间的平均距离,,以及该聚类中的所有样本之间的距离。

  3. 计算轮廓系数,,即通过聚类内聚度和分离度之差除以这两者中的较大者,如下所示:

轮廓系数的范围为–1到1。根据前述公式,我们可以看到,当聚类分离度和内聚度相等时,轮廓系数为0()。此外,当聚类分离度较大时,轮廓系数接近理想值1(),因为量化了样本与其他聚类的差异,则告诉我们该样本与自己聚类内其他样本的相似度。

轮廓系数可以通过scikit-learn的metric模块中的silhouette_samples获得,并且可以选择导入silhouette_scores函数以方便使用。silhouette_scores函数计算所有样本的平均轮廓系数,这等价于numpy.mean(silhouette_samples(...))。通过执行以下代码,我们将创建一个k-means聚类的轮廓系数图,k = 3:

>>> km = KMeans(n_clusters=3,
...             init='k-means++',
...             n_init=10,
...             max_iter=300,
...             tol=1e-04,
...             random_state=0)
>>> y_km = km.fit_predict(X)
>>> import numpy as np
>>> from matplotlib import cm
>>> from sklearn.metrics import silhouette_samples
>>> cluster_labels = np.unique(y_km)
>>> n_clusters = cluster_labels.shape[0]
>>> silhouette_vals = silhouette_samples(X,
...                                      y_km,
...                                      metric='euclidean')
>>> y_ax_lower, y_ax_upper = 0, 0
>>> yticks = []
>>> for i, c in enumerate(cluster_labels):
...     c_silhouette_vals = silhouette_vals[y_km == c]
...     c_silhouette_vals.sort()
...     y_ax_upper += len(c_silhouette_vals)
...     color = cm.jet(float(i) / n_clusters)
...     plt.barh(range(y_ax_lower, y_ax_upper),
...              c_silhouette_vals,
...              height=1.0,
...              edgecolor='none',
...              color=color)
...     yticks.append((y_ax_lower + y_ax_upper) / 2.)
...     y_ax_lower += len(c_silhouette_vals)
>>> silhouette_avg = np.mean(silhouette_vals)
>>> plt.axvline(silhouette_avg,
...             color="red",
...             linestyle="--")
>>> plt.yticks(yticks, cluster_labels + 1)
>>> plt.ylabel('Cluster')
>>> plt.xlabel('Silhouette coefficient')
>>> plt.tight_layout()
>>> plt.show() 

通过对轮廓图的可视化检查,我们可以迅速审查不同聚类的大小,并识别包含异常值的聚类:

然而,正如你在之前的轮廓图中看到的,轮廓系数甚至没有接近0,这在本例中是良好聚类的一个指示符。此外,为了总结我们的聚类效果,我们将平均轮廓系数添加到图中(虚线)。

要查看相对的聚类的轮廓图是什么样子,我们将k-means算法的初始质心数设置为仅有两个:

>>> km = KMeans(n_clusters=2,
...             init='k-means++',
...             n_init=10,
...             max_iter=300,
...             tol=1e-04,
...             random_state=0)
>>> y_km = km.fit_predict(X)
>>> plt.scatter(X[y_km == 0, 0],
...             X[y_km == 0, 1],
...             s=50, c='lightgreen',
...             edgecolor='black',
...             marker='s',
...             label='Cluster 1')
>>> plt.scatter(X[y_km == 1, 0],
...             X[y_km == 1, 1],
...             s=50,
...             c='orange',
...             edgecolor='black',
...             marker='o',
...             label='Cluster 2')
>>> plt.scatter(km.cluster_centers_[:, 0],
...             km.cluster_centers_[:, 1],
...             s=250,
...             marker='*',
...             c='red',
...             label='Centroids')
>>> plt.legend()
>>> plt.grid()
>>> plt.tight_layout()
>>> plt.show() 

如你在结果图中看到的,某个质心位于输入数据的三个球形聚类之间。

尽管聚类看起来并不完全糟糕,但它仍然是次优的:

请记住,在实际问题中,我们通常没有将数据集可视化为二维散点图的奢侈,因为我们通常处理的是高维数据。所以接下来,我们将创建轮廓图来评估结果:

>>> cluster_labels = np.unique(y_km)
>>> n_clusters = cluster_labels.shape[0]
>>> silhouette_vals = silhouette_samples(X,
...                                      y_km,
...                                      metric='euclidean')
>>> y_ax_lower, y_ax_upper = 0, 0
>>> yticks = []
>>> for i, c in enumerate(cluster_labels):
...     c_silhouette_vals = silhouette_vals[y_km == c]
...     c_silhouette_vals.sort()
...     y_ax_upper += len(c_silhouette_vals)
...     color = cm.jet(float(i) / n_clusters)
...     plt.barh(range(y_ax_lower, y_ax_upper),
...              c_silhouette_vals,
...              height=1.0,
...              edgecolor='none',
...              color=color)
...     yticks.append((y_ax_lower + y_ax_upper) / 2.)
...     y_ax_lower += len(c_silhouette_vals)
>>> silhouette_avg = np.mean(silhouette_vals)
>>> plt.axvline(silhouette_avg, color="red", linestyle="--")
>>> plt.yticks(yticks, cluster_labels + 1)
>>> plt.ylabel('Cluster')
>>> plt.xlabel('Silhouette coefficient')
>>> plt.tight_layout()
>>> plt.show() 

如结果图所示,轮廓现在有明显不同的长度和宽度,这是相对或者至少是不理想的聚类的证据:

将簇组织为层次树

在本节中,我们将关注一种基于原型的聚类的替代方法:层次聚类。层次聚类算法的一个优点是,它允许我们绘制树状图(二叉层次聚类的可视化),通过创建有意义的分类法来帮助解释结果。另一个优点是这种层次方法不需要我们提前指定簇的数量。

层次聚类的两种主要方法是聚合式层次聚类和分裂式层次聚类。在分裂式层次聚类中,我们从包含完整数据集的一个簇开始,然后迭代地将该簇拆分为更小的簇,直到每个簇只包含一个样本。在本节中,我们将重点讨论聚合式聚类,它采取相反的方法。我们从每个样本作为一个单独的簇开始,逐步合并最接近的簇,直到只剩下一个簇。

自下而上的簇合并

两种标准的聚合式层次聚类算法是单一连接法完全连接法。使用单一连接法时,我们计算每对簇中最相似成员之间的距离,并合并最相似成员之间距离最小的两个簇。完全连接法类似于单一连接法,但不同之处在于,我们比较每对簇中最不相似的成员进行合并。这在下面的图示中有所展示:

替代类型的连接法

其他常用的聚合层次聚类算法包括平均连接法Ward连接法。在平均连接法中,我们根据两个簇中所有组成员之间的最小平均距离来合并簇对。在Ward连接法中,我们合并两个簇,这两个簇的合并会导致总的簇内平方误差(SSE)最小的增加。

在本节中,我们将重点讨论使用完全连接法的聚合式聚类。层次完全连接聚类是一个迭代过程,可以通过以下步骤总结:

  1. 计算所有样本的距离矩阵。

  2. 将每个数据点表示为一个单独的簇。

  3. 基于最不相似(最远)成员之间的距离合并两个最接近的簇。

  4. 更新相似度矩阵。

  5. 重复步骤 2-4,直到只剩下一个簇。

接下来,我们将讨论如何计算距离矩阵(步骤 1)。但首先,让我们生成一个随机数据样本来进行操作。行表示不同的观察值(ID 0-4),列表示这些示例的不同特征(XYZ):

>>> import pandas as pd
>>> import numpy as np
>>> np.random.seed(123)
>>> variables = ['X', 'Y', 'Z']
>>> labels = ['ID_0', 'ID_1', 'ID_2', 'ID_3', 'ID_4']
>>> X = np.random.random_sample([5, 3])*10
>>> df = pd.DataFrame(X, columns=variables, index=labels)
>>> df 

执行前述代码后,我们现在应该看到以下包含随机生成示例的数据框:

对距离矩阵进行层次聚类

为了计算作为层次聚类算法输入的距离矩阵,我们将使用 SciPy 的 spatial.distance 子模块中的 pdist 函数:

>>> from scipy.spatial.distance import pdist, squareform
>>> row_dist = pd.DataFrame(squareform(
...                         pdist(df, metric='euclidean')),
...                         columns=labels, index=labels)
>>> row_dist 

使用前述代码,我们基于特征 XYZ 计算了数据集中每一对输入示例之间的欧几里得距离。

我们将由 pdist 返回的压缩距离矩阵作为输入传递给 squareform 函数,以创建一个对称的成对距离矩阵,如下所示:

接下来,我们将使用 SciPy 的 cluster.hierarchy 子模块中的 linkage 函数对我们的簇应用完全连接聚合,该函数返回所谓的 连接矩阵

然而,在调用 linkage 函数之前,让我们仔细查看一下该函数的文档:

>>> from scipy.cluster.hierarchy import linkage
>>> help(linkage)
[...]
Parameters:
  y : ndarray
    A condensed or redundant distance matrix. A condensed
    distance matrix is a flat array containing the upper
    triangular of the distance matrix. This is the form
    that pdist returns. Alternatively, a collection of m
    observation vectors in n dimensions may be passed as
    an m by n array.

  method : str, optional
    The linkage algorithm to use. See the Linkage Methods
    section below for full descriptions.

  metric : str, optional
    The distance metric to use. See the distance.pdist
    function for a list of valid distance metrics.

  Returns:
  Z : ndarray
    The hierarchical clustering encoded as a linkage matrix.
[...] 

根据函数说明,我们可以使用 pdist 函数返回的压缩距离矩阵(上三角)作为输入属性。或者,我们也可以提供初始数据数组,并在 linkage 中使用 'euclidean' 度量作为函数参数。然而,我们不应使用之前定义的 squareform 距离矩阵,因为它会产生与预期不同的距离值。总而言之,以下是三种可能的情况:

  • 错误方法:使用以下代码片段中显示的 squareform 距离矩阵会导致错误的结果:

    >>> row_clusters = linkage(row_dist,
    ...                        method='complete',
    ...                        metric='euclidean') 
    
  • 正确方法:使用以下代码示例中显示的压缩距离矩阵可以得到正确的连接矩阵:

    >>> row_clusters = linkage(pdist(df, metric='euclidean'),
    ...                        method='complete') 
    
  • 正确方法:使用以下代码片段中显示的完整输入示例矩阵(即所谓的设计矩阵)也能得到与前述方法类似的正确连接矩阵:

    >>> row_clusters = linkage(df.values,
    ...                        method='complete',
    ...                        metric='euclidean') 
    

为了更仔细地查看聚类结果,我们可以将这些结果转换为 pandas DataFrame(在 Jupyter Notebook 中查看效果最佳),如下所示:

>>> pd.DataFrame(row_clusters,
...              columns=['row label 1',
...                       'row label 2',
...                       'distance',
...                       'no. of items in clust.'],
...              index=['cluster %d' % (i + 1) for i in
...                     range(row_clusters.shape[0])]) 

如下图所示,连接矩阵由若干行组成,每行表示一次合并。第一列和第二列表示每个簇中最不相似的成员,第三列表示这两个成员之间的距离。

最后一列返回每个簇中成员的数量:

现在我们已经计算了连接矩阵,可以以树状图的形式可视化结果:

>>> from scipy.cluster.hierarchy import dendrogram
>>> # make dendrogram black (part 1/2)
>>> # from scipy.cluster.hierarchy import set_link_color_palette
>>> # set_link_color_palette(['black'])
>>> row_dendr = dendrogram(row_clusters,
...                        labels=labels,
...                        # make dendrogram black (part 2/2)
...                        # color_threshold=np.inf
...                        )
>>> plt.tight_layout()
>>> plt.ylabel('Euclidean distance')
>>> plt.show() 

如果你正在执行前面的代码或阅读本书的电子书版本,你会注意到生成的树状图中的分支显示为不同的颜色。这种配色方案来自Matplotlib的颜色列表,用于在树状图中的距离阈值之间循环。例如,要以黑色显示树状图,你可以取消注释前面代码中插入的相关部分:

这样的树状图总结了在凝聚层次聚类过程中形成的不同聚类;例如,你可以看到,ID_0ID_4,接着是ID_1ID_2,是基于欧几里得距离度量最相似的样本。

将树状图附加到热力图

在实际应用中,层次聚类树状图通常与热力图结合使用,允许我们通过颜色编码表示包含训练示例的数据数组或矩阵中的各个值。在这一部分,我们将讨论如何将树状图附加到热力图上,并相应地对热力图中的行进行排序。

然而,将树状图附加到热力图上可能有点棘手,因此让我们一步步来执行这个过程:

  1. 我们创建一个新的figure对象,并通过add_axes属性定义树状图的* x 轴位置、 y *轴位置、宽度和高度。此外,我们将树状图逆时针旋转90度。代码如下:

    >>> fig = plt.figure(figsize=(8, 8), facecolor='white')
    >>> axd = fig.add_axes([0.09, 0.1, 0.2, 0.6])
    >>> row_dendr = dendrogram(row_clusters, 
    ...                        orientation='left')
    >>> # note: for matplotlib < v1.5.1, please use
    >>> # orientation='right' 
    
  2. 接下来,我们根据可以从dendrogram对象中访问的聚类标签重新排序我们初始的DataFrame,该对象本质上是一个Python字典,可以通过leaves键访问。代码如下:

    >>> df_rowclust = df.iloc[row_dendr['leaves'][::-1]] 
    
  3. 现在,我们从重新排序的DataFrame构建热力图,并将其放置在树状图旁边:

    >>> axm = fig.add_axes([0.23, 0.1, 0.6, 0.6])
    >>> cax = axm.matshow(df_rowclust,
    ...                   interpolation='nearest',
    ...                   cmap='hot_r') 
    
  4. 最后,我们通过去除坐标轴刻度并隐藏坐标轴脊线来修改树状图的美学效果。我们还添加了一个颜色条,并将特征和数据记录名称分别分配给* x y *轴刻度标签:

    >>> axd.set_xticks([])
    >>> axd.set_yticks([])
    >>> for i in axd.spines.values():
    ...     i.set_visible(False)
    >>> fig.colorbar(cax)
    >>> axm.set_xticklabels([''] + list(df_rowclust.columns))
    >>> axm.set_yticklabels([''] + list(df_rowclust.index))
    >>> plt.show() 
    

按照前面的步骤操作后,热力图应该会显示附带树状图的效果:

如你所见,热力图中行的顺序反映了树状图中示例的聚类。此外,热力图中每个示例和特征的颜色编码值为我们提供了数据集的一个很好的概述。

通过scikit-learn应用凝聚层次聚类

在上一小节中,你已经了解了如何使用 SciPy 执行凝聚层次聚类。然而,scikit-learn 中也有一个AgglomerativeClustering实现,它允许我们选择希望返回的簇的数量。如果我们想修剪层次聚类树,这非常有用。通过将n_cluster参数设置为3,我们将像之前一样使用基于欧几里得距离度量的完整连接方法,将输入示例分为三个组:

>>> from sklearn.cluster import AgglomerativeClustering
>>> ac = AgglomerativeClustering(n_clusters=3,
...                              affinity='euclidean',
...                              linkage='complete')
>>> labels = ac.fit_predict(X)
>>> print('Cluster labels: %s' % labels)
Cluster labels: [1 0 0 2 1] 

通过查看预测的聚类标签,我们可以看到,第一个和第五个示例(ID_0ID_4)被分配到了一个簇(标签为1),而示例ID_1ID_2被分配到了第二个簇(标签为0)。示例ID_3被放入了它自己的簇(标签为2)。总体来看,结果与我们在树状图中观察到的结果一致。然而我们应该注意到,ID_3ID_4ID_0的相似度要高于ID_1ID_2,正如之前树状图中所示;这一点在 scikit-learn 的聚类结果中并不明显。接下来,我们将使用n_cluster=2重新运行AgglomerativeClustering,如以下代码片段所示:

>>> ac = AgglomerativeClustering(n_clusters=2,
...                              affinity='euclidean',
...                              linkage='complete')
>>> labels = ac.fit_predict(X)
>>> print('Cluster labels: %s' % labels)
Cluster labels: [0 1 1 0 0] 

如你所见,在这个修剪后的聚类层次结构中,标签ID_3被分配到了与ID_0ID_4相同的簇中,正如预期的那样。

通过 DBSCAN 定位高密度区域

尽管在本章中我们无法覆盖大量不同的聚类算法,但我们至少再介绍一种聚类方法:基于密度的空间聚类应用及噪声DBSCAN),它不像 k-means 那样对球形簇做出假设,也不像层次聚类那样将数据集分割成需要手动切割的层次结构。正如其名字所示,基于密度的聚类是根据点的密集区域分配簇标签的。在 DBSCAN 中,密度的概念是通过在指定半径内的点数来定义的,

根据 DBSCAN 算法,每个示例(数据点)都会根据以下标准分配一个特殊的标签:

  • 如果一个点至少有指定数量(MinPts)的邻近点位于指定的半径范围内,则该点被视为核心点

  • 边界点是指在ε半径内邻近点数目少于MinPts,但位于核心点的半径范围内的点。

  • 所有既不是核心点也不是边界点的其他点都被视为噪声点

在将点标记为核心点、边界点或噪声点后,DBSCAN 算法可以通过两个简单的步骤总结:

  1. 为每个核心点或核心点的连接组形成一个独立的簇。(如果核心点之间的距离不超过,则它们是连接的。)

  2. 将每个边界点分配给其对应核心点的簇。

为了更好地理解DBSCAN的结果是什么样的,在实现之前,让我们总结一下刚才关于核心点、边界点和噪声点的知识,如下图所示:

使用DBSCAN的主要优势之一是,它不假设聚类具有像k-means那样的球形结构。此外,DBSCAN与k-means和层次聚类的不同之处在于,它不一定会将每个点分配到一个聚类中,而是能够去除噪声点。

为了提供一个更具示范性的例子,让我们创建一个新的半月形状结构的数据集,来比较k-means聚类、层次聚类和DBSCAN。

>>> from sklearn.datasets import make_moons
>>> X, y = make_moons(n_samples=200,
...                   noise=0.05,
...                   random_state=0)
>>> plt.scatter(X[:, 0], X[:, 1])
>>> plt.tight_layout()
>>> plt.show() 

正如你在结果图中看到的,有两个明显的半月形状的组,每个组包含100个示例(数据点):

我们将首先使用k-means算法和完全链接聚类,看看这些之前讨论过的聚类算法是否能成功地将半月形状识别为独立的聚类。代码如下:

>>> f, (ax1, ax2) = plt.subplots(1, 2, figsize=(8, 3))
>>> km = KMeans(n_clusters=2,
...             random_state=0)
>>> y_km = km.fit_predict(X)
>>> ax1.scatter(X[y_km == 0, 0],
...             X[y_km == 0, 1],
...             c='lightblue',
...             edgecolor='black',
...             marker='o',
...             s=40,
...             label='cluster 1')
>>> ax1.scatter(X[y_km == 1, 0],
...             X[y_km == 1, 1],
...             c='red',
...             edgecolor='black',
...             marker='s',
...             s=40,
...             label='cluster 2')
>>> ax1.set_title('K-means clustering')
>>> ac = AgglomerativeClustering(n_clusters=2,
...                              affinity='euclidean',
...                              linkage='complete')
>>> y_ac = ac.fit_predict(X)
>>> ax2.scatter(X[y_ac == 0, 0],
...             X[y_ac == 0, 1],
...             c='lightblue',
...             edgecolor='black',
...             marker='o',
...             s=40,
...             label='Cluster 1')
>>> ax2.scatter(X[y_ac == 1, 0],
...             X[y_ac == 1, 1],
...             c='red',
...             edgecolor='black',
...             marker='s',
...             s=40,
...             label='Cluster 2')
>>> ax2.set_title('Agglomerative clustering')
>>> plt.legend()
>>> plt.tight_layout()
>>> plt.show() 

根据可视化的聚类结果,我们可以看到k-means算法无法将两个聚类分开,而且层次聚类算法也在面对这些复杂形状时遇到了挑战:

最后,让我们在这个数据集上尝试DBSCAN算法,看看它是否能使用基于密度的方法找到两个半月形状的聚类:

>>> from sklearn.cluster import DBSCAN
>>> db = DBSCAN(eps=0.2,
...             min_samples=5,
...             metric='euclidean')
>>> y_db = db.fit_predict(X)
>>> plt.scatter(X[y_db == 0, 0],
...             X[y_db == 0, 1],
...             c='lightblue',
...             edgecolor='black',
...             marker='o',
...             s=40,
...             label='Cluster 1')
>>> plt.scatter(X[y_db == 1, 0],
...             X[y_db == 1, 1],
...             c='red',
...             edgecolor='black',
...             marker='s',
...             s=40,
...             label='Cluster 2')
>>> plt.legend()
>>> plt.tight_layout()
>>> plt.show() 

DBSCAN算法可以成功地检测到半月形状,这突显了DBSCAN的一个优势——能够对任意形状的数据进行聚类:

然而,我们也应注意到DBSCAN的一些缺点。随着数据集中特征数量的增加——假设训练样本数量固定——维度灾难的负面影响会增加。如果我们使用欧几里得距离度量,这个问题尤为严重。然而,维度灾难问题并非DBSCAN特有:它也影响其他使用欧几里得距离度量的聚类算法,比如k-means和层次聚类算法。此外,DBSCAN有两个需要优化的超参数(MinPts和),只有优化它们,才能得到良好的聚类结果。如果数据集中的密度差异较大,找到MinPts和的良好组合可能会变得有问题。

基于图的聚类

到目前为止,我们已经了解了三种最基本的聚类算法类别:基于原型的k均值聚类、凝聚层次聚类和基于密度的DBSCAN聚类。然而,还有第四类更先进的聚类算法我们在本章中没有涉及:基于图的聚类。在基于图的聚类家族中,最为突出的算法可能是谱聚类算法。

尽管谱聚类有许多不同的实现方式,但它们的共同点是使用相似度或距离矩阵的特征向量来推导聚类关系。由于谱聚类超出了本书的讨论范围,你可以阅读Ulrike von Luxburg的精彩教程来了解更多相关内容(谱聚类教程U. Von Luxburg统计与计算,17(4): 395–416,2007)。这篇教程可以从arXiv免费下载,网址为http://arxiv.org/pdf/0711.0189v1.pdf

请注意,实际上并不总是显而易见,哪种聚类算法在给定数据集上表现最好,尤其是当数据维度较高,难以甚至不可能可视化时。此外,需要特别强调的是,成功的聚类不仅仅依赖于算法及其超参数;选择合适的距离度量方法和利用领域知识来指导实验设置可能更为重要。

在高维诅咒的背景下,通常的做法是在执行聚类之前应用降维技术。用于无监督数据集的降维技术包括主成分分析和径向基函数核主成分分析,这些内容我们在第五章《通过降维压缩数据》中已有涉及。此外,特别常见的做法是将数据集压缩到二维子空间,这使我们能够通过二维散点图可视化聚类和分配的标签,这对于评估结果尤为有帮助。

概述

在本章中,你学习了三种不同的聚类算法,这些算法可以帮助我们发现数据中的隐藏结构或信息。我们从基于原型的方法——k均值聚类开始,它根据指定数量的聚类中心将样本聚集成球形。由于聚类是无监督方法,我们没有真实标签来评估模型的表现。因此,我们使用了内在的性能度量方法,如肘部法则或轮廓分析,试图量化聚类的质量。

我们接着看了一种不同的聚类方法:凝聚层次聚类。层次聚类不需要预先指定聚类的数量,结果可以通过树状图(dendrogram)形式进行可视化,这有助于对结果的解释。本章我们讲解的最后一个聚类算法是DBSCAN,这是一种基于局部密度对点进行分组的算法,能够处理异常值并识别非球形的形状。

在这次关于无监督学习的探讨之后,现在是时候介绍一些最激动人心的监督学习算法:多层人工神经网络。随着其近期的复兴,神经网络再次成为机器学习研究中最热门的话题。得益于最近开发的深度学习算法,神经网络被认为是许多复杂任务(如图像分类和语音识别)的最前沿技术。在第12章从零开始实现多层人工神经网络中,我们将构建自己的多层神经网络。在第13章使用TensorFlow并行化神经网络训练中,我们将使用TensorFlow库,该库专门利用图形处理单元(GPU)非常高效地训练具有多层的神经网络模型。

第十二章:从零开始实现多层人工神经网络

如你所知,深度学习正在受到媒体的广泛关注,毫无疑问,它是机器学习领域最热门的话题。深度学习可以理解为机器学习的一个子领域,专注于高效训练具有多层的人工神经网络(NN)。在本章中,你将学习人工神经网络的基本概念,为接下来的章节做好准备,后续章节将介绍基于Python的深度学习库以及特别适用于图像和文本分析的深度神经网络DNN)架构。

本章将覆盖的主题如下:

  • 获得多层神经网络的概念性理解

  • 从头开始实现神经网络训练的基本反向传播算法

  • 训练用于图像分类的基础多层神经网络

用人工神经网络建模复杂函数

在本书的开头,我们从第2章 训练简单的机器学习算法进行分类开始了我们对机器学习算法的探索。人工神经元代表了我们将在本章讨论的多层人工神经网络的构建模块。

人工神经网络的基本概念建立在对人类大脑如何解决复杂问题任务的假设和模型之上。尽管人工神经网络近年来获得了很高的关注,但神经网络的早期研究可以追溯到1940年代,当时沃伦·麦卡洛克(Warren McCulloch)和沃尔特·皮茨(Walter Pitts)首次描述了神经元的工作原理。 (神经活动中固有思想的逻辑演算W. S. McCullochW. Pitts数学生物物理学公报,5(4):115–133,1943年)

然而,在麦卡洛克-皮茨神经元模型首次实现后的几十年里——20世纪50年代的罗森布拉特感知机——许多研究人员和机器学习从业者逐渐失去了对神经网络的兴趣,因为没有人找到一个有效的解决方案来训练具有多层的神经网络。最终,随着D.E. Rumelhart、G.E. Hinton和R.J. Williams在1986年重新发现并推广了反向传播算法,神经网络的兴趣再次被点燃,这使得神经网络的训练更加高效。我们将在本章稍后详细讨论这一点 (通过反向传播错误学习表示D. E. RumelhartG. E. HintonR. J. WilliamsNature,323 (6088): 533–536,1986)。对于那些对人工智能AI)、机器学习和神经网络的历史感兴趣的读者,我们也鼓励阅读维基百科上关于所谓的AI寒冬的文章,AI寒冬指的是研究界在一段时间内对神经网络的研究兴趣大幅下降的时期 (https://en.wikipedia.org/wiki/AI_winter)。

然而,神经网络(NNs)今天比以往任何时候都更受欢迎,这得益于过去十年中的许多重大突破,最终催生了我们现在称之为深度学习算法和架构——由多层组成的神经网络。神经网络不仅在学术研究中是一个热门话题,而且在大科技公司中也同样火热,如Facebook、Microsoft、Amazon、Uber和Google,它们在人工神经网络和深度学习研究方面投入巨大。

截至今天,由深度学习算法驱动的复杂神经网络被认为是解决复杂问题(如图像和语音识别)的最先进解决方案。我们日常生活中许多基于深度学习的产品,如Google的图像搜索和Google翻译,都是这些技术的典型应用——一款智能手机应用,可以自动识别图像中的文字并实时翻译成20多种语言。

许多令人兴奋的深度神经网络(DNNs)应用已在主要科技公司和制药行业中开发,以下是一些示例(并非详尽无遗):

  • Facebook的DeepFace用于图像标记(DeepFace: Closing the Gap to Human-Level Performance in Face VerificationY. TaigmanM. YangM. Ranzato,和L. WolfIEEE计算机视觉与模式识别会议(CVPR),第1701–1708页,2014

  • 百度的DeepSpeech,能够处理普通话语音查询(DeepSpeech: Scaling up end-to-end speech recognitionA. HannunC. CaseJ. CasperB. CatanzaroG. DiamosE. ElsenR. PrengerS. SatheeshS. SenguptaA. Coates,和Andrew Y. Ng,arXiv预印本arXiv:1412.5567,2014

  • Google的新语言翻译服务(Google's Neural Machine Translation System: Bridging the Gap between Human and Machine Translation,arXiv预印本arXiv:1412.5567,2016

  • 药物发现和毒性预测的新技术(Toxicity prediction using Deep LearningT. UnterthinerA. MayrG. Klambauer,和S. Hochreiter,arXiv预印本arXiv:1503.01445,2015

  • 一款可以以类似专业训练皮肤科医生的准确性检测皮肤癌的移动应用(Dermatologist-level classification of skin cancer with deep neural networksA. EstevaB.KuprelR. A. NovoaJ. KoS. M. SwetterH. M. Blau,和S.Thrun,发表于自然杂志,542卷,第7639期,2017,第115-118页)

  • 从基因序列预测蛋白质三维结构(De novo structure prediction with deep-learning based scoringR. EvansJ. JumperJ. KirkpatrickL. SifreT.F.G. GreenC. QinA. ZidekA. NelsonA. BridglandH. PenedonesS. PetersenK. SimonyanS. CrossanD.T. JonesD. SilverK. KavukcuogluD. Hassabis,和A.W. Senior,发表于第十三届蛋白质结构预测技术的关键评估,2018年12月1-4日)

  • 从纯粹的观察数据(如摄像头视频流)中学习如何在密集交通中驾驶(基于模型的预测策略学习与不确定性正则化在密集交通中驾驶的应用,M. Henaff, A. Canziani, Y. LeCun, 2019,发表于国际学习表征会议论文集ICLR,2019)

单层神经网络回顾

本章讲述的是多层神经网络,它们是如何工作的,以及如何训练它们来解决复杂问题。然而,在我们深入探讨某一特定的多层神经网络架构之前,让我们简要回顾一下我们在第2章训练简单的机器学习算法进行分类中介绍的一些单层神经网络的概念,即自适应线性神经元Adaline)算法,如下图所示:

第2章训练简单的机器学习算法进行分类中,我们实现了 Adaline 算法来进行二分类,并使用梯度下降优化算法来学习模型的权重系数。在每一个训练周期(遍历训练数据集)中,我们使用以下更新规则更新权重向量 w

换句话说,我们基于整个训练数据集计算梯度,并通过向梯度的反方向迈进一步来更新模型的权重!。为了找到模型的最优权重,我们优化了我们定义的目标函数,即平方误差之和SSE)成本函数!。此外,我们还将梯度乘以一个因子——学习率,该因子需要我们小心选择,以平衡学习的速度与避免超过成本函数的全局最小值之间的风险。

在梯度下降优化中,我们在每个训练周期(epoch)后同时更新所有权重,并且我们为权重向量 w 中的每个权重定义了偏导数,如下所示:

在这里,是特定样本的目标类标签 ,而是神经元的激活值,在 Adaline 的特例中,这是一个线性函数。

此外,我们还定义了激活函数,如下所示:

在这里,净输入 z 是连接输入层与输出层的权重的线性组合:

虽然我们使用激活值 来计算梯度更新,但我们实现了一个阈值函数,将连续值的输出压缩成二分类标签以进行预测:

单层命名约定

请注意,尽管Adaline由两层组成,一个输入层和一个输出层,但它被称为单层网络,因为输入层与输出层之间只有一个连接。

此外,我们还了解了一种加速模型学习的技巧,即所谓的随机梯度下降SGD)优化方法。SGD通过一个单独的训练样本(在线学习)或一小部分训练样本(小批量学习)来逼近成本。稍后我们在本章中实现并训练多层感知机(MLP)时将会利用这个概念。除了比梯度下降更频繁地更新权重从而加速学习外,SGD的噪声特性在训练具有非线性激活函数且没有凸成本函数的多层神经网络(NN)时也被认为是有益的。这里,增加的噪声有助于逃离局部成本最小值,但我们将在本章后面详细讨论这个话题。

引入多层神经网络架构

在本节中,你将学习如何将多个单个神经元连接成一个多层前馈神经网络;这种特殊类型的全连接网络也被称为MLP

下图展示了一个由三层组成的MLP的概念:

前面图示的MLP有一个输入层、一个隐藏层和一个输出层。隐藏层的单元与输入层全连接,输出层与隐藏层全连接。如果这样一个网络有多个隐藏层,我们也称之为深度人工神经网络

添加额外的隐藏层

我们可以向MLP中添加任意数量的隐藏层,以创建更深的网络架构。在实际操作中,我们可以将神经网络中的层数和单元数视为额外的超参数,利用交叉验证技术优化这些超参数,以解决特定的任务,这一技术我们在第六章中讨论过,模型评估与超参数调优的最佳实践

然而,随着网络中层数的增加,我们稍后通过反向传播计算的误差梯度将变得越来越小。这种梯度消失问题使得模型学习更加困难。因此,已经开发了特殊的算法来帮助训练这种深度神经网络(DNN)结构;这就是深度学习

如前图所示,我们将层 l 中第 i 个激活单元表示为 。为了让数学公式和代码实现更加直观,我们不再使用数字索引来表示层,而是使用 in 上标表示输入层,h 上标表示隐藏层,out 上标表示输出层。例如, 表示输入层中的第 i 个值, 表示隐藏层中的第 i 个单元, 表示输出层中的第 i 个单元。这里,激活单元 是偏置单元,我们将其设为 1。输入层单元的激活值就是它的输入加上偏置单元:

偏置单元的符号约定

本章后面,我们将使用单独的向量来实现 MLP 中的偏置单元,这使得代码实现更高效、更易读。这个概念也被深度学习库 TensorFlow 使用,我们将在第13章《使用 TensorFlow 并行化神经网络训练》中详细讲解。然而,如果我们必须使用额外的偏置变量,接下来的数学方程会显得更加复杂或晦涩。请注意,通过将 1 附加到输入向量(如前所示)并使用权重变量作为偏置,实际上与使用单独的偏置向量的操作完全相同,只是采用了不同的约定。

l 中的每个单元都通过权重系数与层 l + 1 中的所有单元相连接。例如,层 l 中第 k 个单元与层 l + 1 中第 j 个单元之间的连接将写作 。回顾前面的图,我们将连接输入层和隐藏层的权重矩阵表示为 ,而将连接隐藏层和输出层的矩阵表示为

虽然输出层中的一个单元足以处理二分类任务,但我们在前面的图中看到了一种更通用的神经网络形式,它通过一对多OvA)技术的泛化来实现多分类任务。为了更好地理解这种方法,回想一下我们在第4章《构建良好的训练数据集—数据预处理》中介绍的一热编码表示法。

例如,我们可以将熟悉的鸢尾花数据集中的三个类别标签(0=Setosa, 1=Versicolor, 2=Virginica)编码如下:

这种一热编码向量表示法使我们能够处理训练数据集中具有任意数量独特类别标签的分类任务。

如果你对神经网络(NN)表示法不熟悉,索引符号(下标和上标)一开始可能会有些混乱。最初看似过于复杂的东西,在后续章节中当我们对神经网络表示法进行向量化时,会更容易理解。正如前面所介绍的,我们通过矩阵 来总结连接输入层和隐藏层的权重,其中d是隐藏单元的数量,m是包括偏置单元在内的输入单元数量。由于掌握这一表示法对于理解本章后续概念非常重要,让我们通过一个简化的3-4-3 MLP示意图来总结我们刚刚学到的内容:

通过前向传播激活神经网络

在本节中,我们将描述前向传播的过程,以计算MLP模型的输出。为了理解它如何融入学习MLP模型的背景中,我们将MLP学习过程总结为三个简单步骤:

  1. 从输入层开始,我们将训练数据的模式通过网络进行前向传播,以生成输出。

  2. 基于网络的输出,我们计算希望最小化的误差,并使用稍后将描述的成本函数。

  3. 我们反向传播误差,找到它对网络中每个权重的导数,并更新模型。

最后,在我们为多个时期重复这三步并学习MLP的权重后,我们使用前向传播来计算网络输出,并应用阈值函数以获得前一节中描述的独热表示的预测类别标签。

现在,让我们逐步通过前向传播来生成训练数据模式的输出。由于隐藏层中的每个单元都与输入层中的所有单元相连,我们首先计算隐藏层的激活单元 ,计算方式如下:

这里,是净输入,是激活函数,必须可微分,以便使用基于梯度的方法学习连接神经元的权重。为了能够解决图像分类等复杂问题,我们在MLP模型中需要非线性激活函数,例如我们在第3章《使用scikit-learn的机器学习分类器巡礼》中记得的sigmoid(逻辑)激活函数:

如你所记得,sigmoid函数是一个S形曲线,它将净输入z映射到一个在0到1范围内的逻辑分布,在z = 0时切割y轴,如下图所示:

MLP 是一种典型的前馈人工神经网络(NN)。术语 前馈 指的是每一层作为下一层的输入,没有循环,这与递归神经网络(RNN)不同——我们将在本章稍后讨论并在第16章《使用递归神经网络建模序列数据》中详细讨论。术语 多层感知器 可能有点令人困惑,因为在这个网络架构中,人工神经元通常是 sigmoid 单元,而不是感知器。我们可以把 MLP 中的神经元看作是逻辑回归单元,返回的值在 0 到 1 之间的连续范围内。

为了提高代码效率和可读性,我们将使用基础线性代数的概念以更紧凑的形式编写激活函数,这将使我们能够通过 NumPy 向量化实现代码,而不是编写多个嵌套且计算量大的 Python for 循环:

这里, 是我们样本的 维特征向量加上一个偏置单元

是一个 维的权重矩阵,其中 d 是隐藏层中的单元数量。经过矩阵-向量乘法,我们得到 维的净输入向量 ,以计算激活值 (其中 )。

此外,我们可以将此计算推广到训练数据集中的所有 n 个样本:

在这里, 现在是一个 矩阵,矩阵-矩阵乘法将得到一个 维的净输入矩阵 。最后,我们对净输入矩阵中的每个值应用激活函数 ,以获得下一层的激活矩阵(这里是输出层):

类似地,我们可以为多个样本写出输出层的激活值的向量化形式:

在这里,我们将 矩阵 t 是输出单元的数量)与 维的矩阵 相乘,以获得 维的矩阵 (该矩阵的列表示每个样本的输出)。

最后,我们应用 sigmoid 激活函数以获得网络的连续值输出:

手写数字分类

在上一节中,我们讲解了许多关于神经网络(NN)的理论,对于初学者来说,这可能会有些令人不知所措。在我们继续讨论用于学习MLP模型权重的算法——反向传播之前,先暂时休息一下理论部分,看看神经网络的实际应用。

关于反向传播的额外资源

神经网络理论可能相当复杂,因此建议参考两份额外资源,它们更加详细地讨论了我们在本章中讨论的一些概念:

在这一节中,我们将实现并训练我们的第一个多层神经网络,用于从流行的混合国家标准与技术研究所MNIST)数据集中分类手写数字。该数据集由Yann LeCun等人构建,是机器学习算法的一个流行基准数据集(基于梯度的学习应用于文档识别Y. LeCunL. BottouY. BengioP. HaffnerIEEE会议论文集,86(11):2278-2324,1998年11月)。

获取和准备MNIST数据集

MNIST数据集可以在http://yann.lecun.com/exdb/mnist/上公开获取,并包括以下四个部分:

  • 训练数据集图像train-images-idx3-ubyte.gz(9.9 MB,解压后为47 MB,共60,000个样本)

  • 训练数据集标签train-labels-idx1-ubyte.gz(29 KB,解压后为60 KB,共60,000个标签)

  • 测试数据集图像t10k-images-idx3-ubyte.gz(1.6 MB,解压后为7.8 MB,共10,000个样本)

  • 测试数据集标签t10k-labels-idx1-ubyte.gz(5 KB,解压后为10 KB,共10,000个标签)

MNIST数据集是由美国国家标准与技术研究所NIST)的两个数据集构建的。训练数据集包含来自250个不同人的手写数字,其中50%来自高中生,50%来自人口普查局的工作人员。请注意,测试数据集包含来自不同人的手写数字,并且遵循相同的划分方式。

下载文件后,建议使用Unix/Linux中的gzip工具从终端解压这些文件,以提高效率。你可以在本地的MNIST下载目录中使用以下命令:

gzip *ubyte.gz -d 

或者,如果你使用的是运行Microsoft Windows的机器,你也可以使用你喜欢的解压工具。

图像以字节格式存储,我们将它们读取到NumPy数组中,这些数组将用于训练和测试我们的MLP实现。为此,我们将定义以下辅助函数:

import os
import struct
import numpy as np
def load_mnist(path, kind='train'):
    """Load MNIST data from 'path'"""
    labels_path = os.path.join(path,
                               '%s-labels-idx1-ubyte' % kind)
    images_path = os.path.join(path,
                               '%s-images-idx3-ubyte' % kind)

    with open(labels_path, 'rb') as lbpath:
        magic, n = struct.unpack('>II',
                                 lbpath.read(8))
        labels = np.fromfile(lbpath,
                             dtype=np.uint8)

    with open(images_path, 'rb') as imgpath:
        magic, num, rows, cols = struct.unpack(">IIII",
                                               imgpath.read(16))
        images = np.fromfile(imgpath,
                             dtype=np.uint8).reshape(
                             len(labels), 784)
        images = ((images / 255.) - .5) * 2

    return images, labels 

load_mnist函数返回两个数组,第一个是一个维的NumPy数组(images),其中n是示例的数量,m是特征的数量(这里是像素)。训练数据集包含60,000个训练数字,测试数据集包含10,000个示例。

MNIST数据集中的图像由像素组成,每个像素由灰度强度值表示。在这里,我们将像素展开为一维行向量,这些向量代表我们images数组中的行(每行或每幅图像有784个像素)。load_mnist函数返回的第二个数组(labels)包含相应的目标变量,即手写数字的类标签(整数0-9)。

我们读取图像的方式刚开始可能看起来有点奇怪:

magic, n = struct.unpack('>II', lbpath.read(8))
labels = np.fromfile(lbpath, dtype=np.uint8) 

要理解这两行代码是如何工作的,让我们先看看来自MNIST网站的数据集描述:

[offset] [type]           [value]           [description]
0000      32 bit integer  0x00000801(2049)  magic number (MSB first)
0004      32 bit integer  60000             number of items
0008      unsigned byte   ??                label
0009      unsigned byte   ??                label
........
xxxx      unsigned byte   ??                label 

使用前面两行代码,我们首先从文件缓冲区读取魔数,它是文件协议的描述,以及项目数量(n),然后我们使用fromfile方法将接下来的字节加载到NumPy数组中。我们传递给struct.unpackfmt参数值'>II'可以分解成以下两个部分:

  • >: 这是大端字节序——它定义了字节序列的存储顺序;如果你不熟悉大端字节序和小端字节序的术语,你可以在维基百科上找到一篇关于字节序的优秀文章:https://en.wikipedia.org/wiki/Endianness

  • I: 这是一个无符号整数

最后,我们还通过以下代码行将MNIST中的像素值规范化到范围–1到1(原始范围是0到255):

images = ((images / 255.) - .5) * 2 

这样做的原因是,基于梯度的优化在这些条件下更加稳定,正如第2章《为分类训练简单机器学习算法》中所讨论的那样。请注意,我们是按像素逐个对图像进行缩放的,这与我们在前几章中采取的特征缩放方法不同。

之前,我们从训练数据集中推导出缩放参数,并用这些参数对训练数据集和测试数据集中的每一列进行缩放。然而,在处理图像像素时,将像素值居中为零并重新缩放到[–1, 1]范围也是常见的做法,并且在实践中通常效果很好。

批量归一化

一个常用的技巧是通过输入缩放来改善基于梯度的优化收敛性,称为 批量归一化,这是一个高级主题,我们将在 第17章生成对抗网络用于合成新数据 中详细讨论。此外,您还可以阅读由 Sergey IoffeChristian Szegedy(2015年,https://arxiv.org/abs/1502.03167)撰写的优秀研究文章 Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift 来了解更多批量归一化的内容。

to the same directory in which this code was executed.)
>>> X_train, y_train = load_mnist('', kind='train')
>>> print('Rows: %d, columns: %d'
...       % (X_train.shape[0], X_train.shape[1]))
Rows: 60000, columns: 784
>>> X_test, y_test = load_mnist('', kind='t10k')
>>> print('Rows: %d, columns: %d'
...       % (X_test.shape[0], X_test.shape[1]))
Rows: 10000, columns: 784 

为了了解 MNIST 中这些图像的样子,让我们在将特征矩阵中的 784 像素向量重塑为原始的 图像后,通过 Matplotlib 的 imshow 函数来可视化数字 0-9 的示例:

>>> import matplotlib.pyplot as plt
>>> fig, ax = plt.subplots(nrows=2, ncols=5,
...                        sharex=True, sharey=True)
>>> ax = ax.flatten()
>>> for i in range(10):
...     img = X_train[y_train == i][0].reshape(28, 28)
...     ax[i].imshow(img, cmap='Greys')
>>> ax[0].set_xticks([])
>>> ax[0].set_yticks([])
>>> plt.tight_layout()
>>> plt.show() 

我们现在应该看到一个图形,展示了每个独特数字的代表性图像的 子图:

此外,我们还可以绘制多个相同数字的示例,以查看每个手写数字之间的差异:

>>> fig, ax = plt.subplots(nrows=5,
...                        ncols=5,
...                        sharex=True,
...                        sharey=True)
>>> ax = ax.flatten()
>>> for i in range(25):
...     img = X_train[y_train == 7][i].reshape(28, 28)
...     ax[i].imshow(img, cmap='Greys')
>>> ax[0].set_xticks([])
>>> ax[0].set_yticks([])
>>> plt.tight_layout()
>>> plt.show() 

执行代码后,我们应该能看到数字 7 的前 25 种变体:

在完成之前的所有步骤后,最好将缩放后的图像保存为一种格式,以便在新的 Python 会话中更快速地加载,避免再次读取和处理数据的开销。当我们处理 NumPy 数组时,保存多维数组到磁盘的一种高效且方便的方法是使用 NumPy 的 savez 函数。(官方文档可以在这里找到:https://docs.scipy.org/doc/numpy/reference/generated/numpy.savez.html。)

mnist_scaled.npz:
>>> import numpy as np
>>> np.savez_compressed('mnist_scaled.npz',
...                     X_train=X_train,
...                     y_train=y_train,
...                     X_test=X_test,
...                     y_test=y_test) 

创建 .npz 文件后,我们可以使用 NumPy 的 load 函数按如下方式加载预处理过的 MNIST 图像数组:

>>> mnist = np.load('mnist_scaled.npz') 

mnist 变量现在指向一个对象,该对象可以访问我们作为关键字参数提供给 savez_compressed 函数的四个数据数组。这些输入数组现在列在 mnist 对象的 files 属性列表下:

>>> mnist.files
['X_train', 'y_train', 'X_test', 'y_test'] 

例如,要将训练数据加载到当前的 Python 会话中,我们将按如下方式访问 X_train 数组(类似于 Python 字典):

>>> X_train = mnist['X_train'] 

使用列表推导式,我们可以按如下方式检索所有四个数据数组:

>>> X_train, y_train, X_test, y_test = [mnist[f] for
...                                     f in mnist.files] 

请注意,虽然前面的 np.savez_compressednp.load 示例对于执行本章代码并非必需,但它们作为如何方便高效地保存和加载 NumPy 数组的演示是很有帮助的。

使用 scikit-learn 加载 MNIST

使用 scikit-learn 新的 fetch_openml 函数,现在也可以更方便地加载 MNIST 数据集。例如,您可以使用以下代码通过从 https://www.openml.org/d/554 获取数据集来创建一个包含 50,000 个示例的训练数据集和一个包含 10,000 个示例的测试数据集:

>>> from sklearn.datasets import fetch_openml
>>> from sklearn.model_selection import train_test_split
>>> X, y = fetch_openml('mnist_784', version=1,
...                     return_X_y=True)
>>> y = y.astype(int)
>>> X = ((X / 255.) - .5) * 2
>>> X_train, X_test, y_train, y_test =\
...  train_test_split(
...     X, y, test_size=10000,
...     random_state=123, stratify=y) 

请注意,MNIST记录分配到训练集和测试集的方式将不同于本节中概述的手动方法。因此,如果你使用fetch_openmltrain_test_split函数加载数据集,你将在接下来的章节中观察到略有不同的结果。

实现一个多层感知器

在本小节中,我们将从头开始实现一个多层感知器(MLP),以对MNIST数据集中的图像进行分类。为了简化,我们将只实现一个包含一个隐藏层的MLP。由于该方法一开始可能会显得有些复杂,建议你从Packt出版社网站或GitHub(https://github.com/rasbt/python-machine-learning-book-3rd-edition)下载本章的示例代码,以便查看带有注释和语法高亮的MLP实现,增强可读性。

如果你不是在配套的Jupyter Notebook文件中运行代码,或者没有互联网连接,可以将本章中的NeuralNetMLP代码复制到当前工作目录下的Python脚本文件中(例如,neuralnet.py),然后通过以下命令将其导入当前的Python会话:

from neuralnet import NeuralNetMLP 

代码中将包含一些我们尚未讨论的部分,例如反向传播算法,但基于第二章《训练简单的机器学习分类算法》中的Adaline实现和前向传播的讨论,大部分代码对你来说应该是熟悉的。

如果所有代码对你来说一时难以理解,不要担心;我们将在本章后面讲解某些部分。然而,在此阶段回顾代码可以帮助你更容易地理解后续的理论内容。

以下是多层感知器的实现:

import numpy as np
import sys
class NeuralNetMLP(object):
    """ Feedforward neural network / Multi-layer perceptron classifier.

    Parameters
    ------------
    n_hidden : int (default: 30)
        Number of hidden units.
    l2 : float (default: 0.)
        Lambda value for L2-regularization.
        No regularization if l2=0\. (default)
    epochs : int (default: 100)
        Number of passes over the training set.
    eta : float (default: 0.001)
        Learning rate.
    shuffle : bool (default: True)
        Shuffles training data every epoch
        if True to prevent circles.
    minibatch_size : int (default: 1)
        Number of training examples per minibatch.
    seed : int (default: None)
        Random seed for initializing weights and shuffling.

    Attributes
    -----------
    eval_ : dict
        Dictionary collecting the cost, training accuracy,
        and validation accuracy for each epoch during training.

    """
    def __init__(self, n_hidden=30,
                 l2=0., epochs=100, eta=0.001,
                 shuffle=True, minibatch_size=1, seed=None):

        self.random = np.random.RandomState(seed)
        self.n_hidden = n_hidden
        self.l2 = l2
        self.epochs = epochs
        self.eta = eta
        self.shuffle = shuffle
        self.minibatch_size = minibatch_size

    def _onehot(self, y, n_classes):
        """Encode labels into one-hot representation

        Parameters
        ------------
        y : array, shape = [n_examples]
            Target values.

        Returns
        -----------
        onehot : array, shape = (n_examples, n_labels)

        """
        onehot = np.zeros((n_classes, y.shape[0]))
        for idx, val in enumerate(y.astype(int)):
            onehot[val, idx] = 1.
        return onehot.T

    def _sigmoid(self, z):
        """Compute logistic function (sigmoid)"""
        return 1\. / (1\. + np.exp(-np.clip(z, -250, 250)))

    def _forward(self, X):
        """Compute forward propagation step"""

        # step 1: net input of hidden layer
        # [n_examples, n_features] dot [n_features, n_hidden]
        # -> [n_examples, n_hidden]
        z_h = np.dot(X, self.w_h) + self.b_h

        # step 2: activation of hidden layer
        a_h = self._sigmoid(z_h)

        # step 3: net input of output layer
        # [n_examples, n_hidden] dot [n_hidden, n_classlabels]
        # -> [n_examples, n_classlabels]

        z_out = np.dot(a_h, self.w_out) + self.b_out
        # step 4: activation output layer
        a_out = self._sigmoid(z_out)

        return z_h, a_h, z_out, a_out

    def _compute_cost(self, y_enc, output):
        """Compute cost function.

        Parameters
        ----------
        y_enc : array, shape = (n_examples, n_labels)
            one-hot encoded class labels.
        output : array, shape = [n_examples, n_output_units]
            Activation of the output layer (forward propagation)

        Returns
        ---------
        cost : float
            Regularized cost

        """
        L2_term = (self.l2 *
                   (np.sum(self.w_h ** 2.) +
                    np.sum(self.w_out ** 2.)))

        term1 = -y_enc * (np.log(output))
        term2 = (1\. - y_enc) * np.log(1\. - output)
        cost = np.sum(term1 - term2) + L2_term
        return cost

    def predict(self, X):
        """Predict class labels

        Parameters
        -----------
        X : array, shape = [n_examples, n_features]
            Input layer with original features.

        Returns:
        ----------
        y_pred : array, shape = [n_examples]
            Predicted class labels.

        """
        z_h, a_h, z_out, a_out = self._forward(X)
        y_pred = np.argmax(z_out, axis=1)
        return y_pred

    def fit(self, X_train, y_train, X_valid, y_valid):
        """ Learn weights from training data.

        Parameters
        -----------
        X_train : array, shape = [n_examples, n_features]
            Input layer with original features.
        y_train : array, shape = [n_examples]
            Target class labels.
        X_valid : array, shape = [n_examples, n_features]
            Sample features for validation during training
        y_valid : array, shape = [n_examples]
            Sample labels for validation during training

        Returns:
        ----------
        self

        """
        n_output = np.unique(y_train).shape[0] # no. of class
                                               #labels
        n_features = X_train.shape[1]

        ########################
        # Weight initialization
        ########################

        # weights for input -> hidden
        self.b_h = np.zeros(self.n_hidden)
        self.w_h = self.random.normal(loc=0.0, scale=0.1,
                                      size=(n_features,
                                            self.n_hidden))

        # weights for hidden -> output
        self.b_out = np.zeros(n_output)
        self.w_out = self.random.normal(loc=0.0, scale=0.1,
                                        size=(self.n_hidden,
                                              n_output))

        epoch_strlen = len(str(self.epochs)) # for progr. format.
        self.eval_ = {'cost': [], 'train_acc': [], 'valid_acc': \
                      []}

        y_train_enc = self._onehot(y_train, n_output)

        # iterate over training epochs
        for i in range(self.epochs):

            # iterate over minibatches
            indices = np.arange(X_train.shape[0])

            if self.shuffle:
                self.random.shuffle(indices)

            for start_idx in range(0, indices.shape[0] -\
                                   self.minibatch_size +\
                                   1, self.minibatch_size):
                batch_idx = indices[start_idx:start_idx +\
                                    self.minibatch_size]

                # forward propagation
                z_h, a_h, z_out, a_out = \
                    self._forward(X_train[batch_idx])

                ##################
                # Backpropagation
                ##################

                # [n_examples, n_classlabels]
                delta_out = a_out - y_train_enc[batch_idx]

                # [n_examples, n_hidden]
                sigmoid_derivative_h = a_h * (1\. - a_h)

                # [n_examples, n_classlabels] dot [n_classlabels,
                #                                 n_hidden]
                # -> [n_examples, n_hidden]
                delta_h = (np.dot(delta_out, self.w_out.T) *
                           sigmoid_derivative_h)

                # [n_features, n_examples] dot [n_examples,
                #                               n_hidden]
                # -> [n_features, n_hidden]
                grad_w_h = np.dot(X_train[batch_idx].T, delta_h)
                grad_b_h = np.sum(delta_h, axis=0)

                # [n_hidden, n_examples] dot [n_examples,
                #                            n_classlabels]
                # -> [n_hidden, n_classlabels]
                grad_w_out = np.dot(a_h.T, delta_out)
                grad_b_out = np.sum(delta_out, axis=0)

                # Regularization and weight updates
                delta_w_h = (grad_w_h + self.l2*self.w_h)
                delta_b_h = grad_b_h # bias is not regularized
                self.w_h -= self.eta * delta_w_h
                self.b_h -= self.eta * delta_b_h

                delta_w_out = (grad_w_out + self.l2*self.w_out)
                delta_b_out = grad_b_out # bias is not regularized
                self.w_out -= self.eta * delta_w_out
                self.b_out -= self.eta * delta_b_out

            #############
            # Evaluation
            #############

            # Evaluation after each epoch during training
            z_h, a_h, z_out, a_out = self._forward(X_train)

            cost = self._compute_cost(y_enc=y_train_enc,
                                      output=a_out)

            y_train_pred = self.predict(X_train)
            y_valid_pred = self.predict(X_valid)

            train_acc = ((np.sum(y_train ==
                          y_train_pred)).astype(np.float) /
                         X_train.shape[0])
            valid_acc = ((np.sum(y_valid ==
                          y_valid_pred)).astype(np.float) /
                         X_valid.shape[0])

            sys.stderr.write('\r%0*d/%d | Cost: %.2f '
                             '| Train/Valid Acc.: %.2f%%/%.2f%% '
                              %
                             (epoch_strlen, i+1, self.epochs,
                              cost,
                              train_acc*100, valid_acc*100))
            sys.stderr.flush()

            self.eval_['cost'].append(cost)
            self.eval_['train_acc'].append(train_acc)
            self.eval_['valid_acc'].append(valid_acc)

        return self 

执行此代码后,我们接下来将初始化一个新的784-100-10多层感知器(MLP)——一个具有784个输入单元(n_features)、100个隐藏单元(n_hidden)和10个输出单元(n_output)的神经网络:

>>> nn = NeuralNetMLP(n_hidden=100,
...                   l2=0.01,
...                   epochs=200,
...                   eta=0.0005,
...                   minibatch_size=100,
...                   shuffle=True,
...                   seed=1) 

如果你浏览过NeuralNetMLP代码,可能已经猜到这些参数的用途。这里是它们的简短总结:

  • l2:这是用于L2正则化的参数,用于减少过拟合的程度。

  • epochs:这是对训练数据集的迭代次数。

  • eta:这是学习率

  • shuffle:这是用于在每个训练轮次前对训练集进行洗牌,以防止算法陷入循环中。

  • seed:这是用于洗牌和权重初始化的随机种子。

  • minibatch_size:这是每个小批量中的训练样本数量,用于在每个epoch中将训练数据划分为小批量进行随机梯度下降(SGD)。梯度将在每个小批量上单独计算,而不是在整个训练数据上计算,以加速学习。

接下来,我们使用已经打乱的MNIST训练数据集中的55,000个示例来训练MLP,并使用剩余的5,000个示例在训练过程中进行验证。请注意,在标准桌面计算机硬件上,训练神经网络可能需要最多五分钟的时间。

正如你从前面的代码中可能已经注意到的,我们实现了fit方法,使其接受四个输入参数:训练图像、训练标签、验证图像和验证标签。在神经网络训练中,比较训练准确度和验证准确度非常有用,它帮助我们判断给定架构和超参数下,网络模型的表现如何。例如,如果我们观察到训练和验证准确度都很低,可能是训练数据集存在问题,或者超参数设置不理想。如果训练准确度和验证准确度之间存在较大差距,说明模型可能过拟合训练数据集,此时我们需要减少模型中的参数数量,或者增加正则化强度。如果训练和验证准确度都很高,模型可能会很好地泛化到新数据上,例如用于最终模型评估的测试数据集。

一般来说,训练(深度)神经网络比我们到目前为止讨论的其他模型更为昂贵。因此,我们希望在某些情况下提前停止训练,并以不同的超参数设置重新开始。另一方面,如果我们发现模型越来越倾向于过拟合训练数据(通过训练和验证数据集性能之间差距的增加可以观察到),我们也可能希望提前停止训练。

现在,为了开始训练,我们执行以下代码:

>>> nn.fit(X_train=X_train[:55000],
...        y_train=y_train[:55000],
...        X_valid=X_train[55000:],
...        y_valid=y_train[55000:])
200/200 | Cost: 5065.78 | Train/Valid Acc.: 99.28%/97.98% 

在我们的NeuralNetMLP实现中,我们还定义了一个eval_属性,它收集每个epoch的成本、训练准确度和验证准确度,以便我们使用Matplotlib可视化结果:

>>> import matplotlib.pyplot as plt
>>> plt.plot(range(nn.epochs), nn.eval_['cost'])
>>> plt.ylabel('Cost')
>>> plt.xlabel('Epochs')
>>> plt.show() 

前面的代码绘制了200个epoch中的成本变化,如下图所示:

如我们所见,成本在前100个epoch期间大幅下降,并且在最后100个epoch中似乎慢慢收敛。然而,175200个epoch之间的斜率较小,表明继续训练更多epoch后,成本可能会进一步下降。

接下来,我们来看一下训练和验证的准确度:

>>> plt.plot(range(nn.epochs), nn.eval_['train_acc'],
...          label='training')
>>> plt.plot(range(nn.epochs), nn.eval_['valid_acc'],
...          label='validation', linestyle='--')
>>> plt.ylabel('Accuracy')
>>> plt.xlabel('Epochs')
>>> plt.legend(loc='lower right')
>>> plt.show() 

前面的代码示例绘制了这200个训练epoch中的准确度值,如下图所示:

图表显示,随着训练epoch的增加,训练和验证准确度之间的差距逐渐增大。在大约第50个epoch时,训练和验证准确度相等,然后网络开始过拟合训练数据。

请注意,选择这个例子是故意为了说明过拟合的影响,并展示为什么在训练过程中比较验证集和训练集的准确率值是有用的。减少过拟合的一个方法是增加正则化强度——例如,设置l2=0.1。另一个应对神经网络中过拟合的有效技术是dropout,这一点将在第15章《使用深度卷积神经网络进行图像分类》中详细讨论。

最后,让我们通过计算测试数据集上的预测准确率来评估模型的泛化性能:

>>> y_test_pred = nn.predict(X_test)
>>> acc = (np.sum(y_test == y_test_pred)
...        .astype(np.float) / X_test.shape[0])
>>> print('Test accuracy: %.2f%%' % (acc * 100))
Test accuracy: 97.54% 

尽管在训练数据上存在轻微的过拟合,我们的相对简单的一层隐藏层神经网络在测试数据集上取得了相对不错的表现,准确率与验证数据集相似(97.98%)。

为了进一步微调模型,我们可以改变隐藏单元的数量、正则化参数的值和学习率,或者使用多年来开发的各种技巧,这些技巧超出了本书的范围。在第15章《使用深度卷积神经网络进行图像分类》中,你将学习一种不同的神经网络架构,这种架构在图像数据集上表现良好。此外,本章还将介绍一些提升性能的技巧,如自适应学习率、更加复杂的基于SGD的优化算法、批归一化和dropout。

以下是一些常见的技巧,超出了接下来的章节的范围,包括:

  • 添加跳跃连接,这是残差神经网络(深度残差学习用于图像识别,K. He, X. Zhang, S. Ren, J. Sun,2016年,见《IEEE计算机视觉与模式识别会议论文集》,第770-778页)最主要的贡献。

  • 使用学习率调度器,在训练过程中动态改变学习率(神经网络训练中的循环学习率,L.N. Smith,2017年,见《2017年IEEE冬季计算机视觉应用会议(WACV)》论文集,第464-472页)。

  • 将损失函数附加到网络的早期层,这与流行的Inception v3架构中的做法相似(重新思考Inception架构用于计算机视觉,C. Szegedy, V. Vanhoucke, S. Ioffe, J. Shlens, Z. Wojna,2016年,见《IEEE计算机视觉与模式识别会议论文集》,第2818-2826页)。

最后,让我们看看一些我们的多层感知机(MLP)难以处理的图像:

>>> miscl_img = X_test[y_test != y_test_pred][:25]
>>> correct_lab = y_test[y_test != y_test_pred][:25]
>>> miscl_lab = y_test_pred[y_test != y_test_pred][:25]
>>> fig, ax = plt.subplots(nrows=5,
...                        ncols=5,
...                        sharex=True,
...                        sharey=True,)
>>> ax = ax.flatten()
>>> for i in range(25):
...     img = miscl_img[i].reshape(28, 28)
...     ax[i].imshow(img,
...                  cmap='Greys',
...                  interpolation='nearest')
...     ax[i].set_title('%d) t: %d p: %d'
...     % (i+1, correct_lab[i], miscl_lab[i]))
>>> ax[0].set_xticks([])
>>> ax[0].set_yticks([])
>>> plt.tight_layout()
>>> plt.show() 

我们现在应该看到一个子图矩阵,其中字幕中的第一个数字表示情节索引,第二个数字表示真实类别标签(t),第三个数字表示预测类别标签(p):

正如我们在前面的图中看到的那样,其中一些图像甚至对于我们人类来说也很难正确分类。例如,子图8中的6看起来像是一个随意画的0,而子图23中的8可能会因为下部狭窄并且有粗线条而被误判为9。

训练人工神经网络

现在我们已经看到一个神经网络(NN)的实际应用,并通过查看代码对其工作原理有了基本了解,让我们进一步探讨一些概念,比如逻辑成本函数和我们实现的反向传播算法,用于学习权重。

计算逻辑成本函数

我们实现的作为_compute_cost方法的逻辑成本函数实际上很简单,因为它与我们在第3章“使用scikit-learn的机器学习分类器之旅”中描述的逻辑回归部分的成本函数相同:

这里,是数据集中第i个样本的Sigmoid激活值,这是我们在前向传播步骤中计算的:

再次提醒,在这个上下文中,上标[i]是训练示例的索引,而不是层的索引。

现在,让我们添加一个正则化项,这样可以帮助我们减少过拟合的程度。如你从前面的章节中回忆的那样,L2正则化项定义如下(请记住,我们不对偏置单元进行正则化):

通过将L2正则化项添加到我们的逻辑成本函数中,我们得到以下方程:

之前,我们实现了一个用于多类分类的多层感知机(MLP),它返回一个包含t个元素的输出向量,我们需要将其与在独热编码表示中的维目标向量进行比较。如果我们使用这个MLP预测一个标签为2的输入图像的类别标签,那么第三层的激活值和目标可能如下所示:

因此,我们需要将逻辑成本函数推广到我们网络中的所有t个激活单元。

没有正则化项的成本函数变为如下:

这里,再次强调,上标[i]是我们训练数据集中某个特定样本的索引。

以下的广义正则化项一开始可能看起来有些复杂,但这里我们只是计算第l层所有权重的和(不包括偏置项),并将其添加到第一列:

这里,表示给定层l中的单元数量,以下表达式表示惩罚项:

记住,我们的目标是最小化成本函数J(W);因此,我们需要计算每一层网络中参数W相对于每个权重的偏导数:

在接下来的部分中,我们将讨论反向传播算法,该算法允许我们计算这些偏导数以最小化成本函数。

请注意,W由多个矩阵组成。在具有一个隐藏层的MLP中,我们有连接输入到隐藏层的权重矩阵,以及连接隐藏层到输出层的权重矩阵。三维张量W的可视化如下图所示:

在这个简化的图中,似乎的行数和列数相同,但通常情况下并非如此,除非我们初始化一个具有相同隐藏单元数、输出单元数和输入特征的MLP。

如果这听起来令人困惑,请继续关注下一部分,我们将在反向传播算法的背景下更详细地讨论的维度问题。此外,建议您再次阅读带有关于不同矩阵和向量转换维度的有用注释的NeuralNetMLP代码。您可以从Packt或该书的GitHub仓库获取带有注释的代码,网址为https://github.com/rasbt/python-machine-learning-book-3rd-edition

发展您对反向传播的理解

尽管反向传播在30多年前重新发现和广为流传(通过反向传播误差学习表示D.E. RumelhartG.E. HintonR.J. WilliamsNature,323:6088,页533-536,1986年),但它仍然是训练人工神经网络非常高效的最广泛使用的算法之一。如果您对反向传播的历史有兴趣,可以阅读Juergen Schmidhuber撰写的一篇很好的调查文章,Who Invented Backpropagation?,您可以在http://people.idsia.ch/~juergen/who-invented-backpropagation.html找到该文章。

本节将提供一个简短而清晰的总结,并展示这个迷人算法的全貌,然后再深入探讨更多的数学细节。本质上,我们可以将反向传播看作是一种计算多层神经网络(NN)中复杂成本函数的偏导数的高效方法。我们的目标是利用这些导数来学习权重系数,从而为多层人工神经网络(NN)进行参数化。神经网络参数化的挑战在于,我们通常需要处理在高维特征空间中非常大量的权重系数。与我们在前几章中看到的单层神经网络(如 Adaline 或逻辑回归)的成本函数不同,神经网络成本函数的误差面对于参数而言既不凸也不光滑。在这个高维成本面上有许多波动(局部最小值),我们必须克服这些波动,才能找到成本函数的全局最小值。

你可能还记得在初级微积分课程中学过链式法则的概念。链式法则是一种计算复杂嵌套函数(如 f(g(x))的导数的方法,具体如下:

同样,我们也可以对任意长的函数组合使用链式法则。例如,假设我们有五个不同的函数,f(x), g(x), h(x), u(x), 和 v(x),并且令 F 为这些函数的组合:F(x) = f(g(h(u(v(x)))))。应用链式法则,我们可以如下计算该函数的导数:

在计算机代数的背景下,已经开发出一套技术来非常高效地解决此类问题,这也被称为自动微分。如果你有兴趣了解更多关于机器学习中自动微分的应用,可以阅读A. G. Baydin 和 B. A. Pearlmutter的文章《自动微分算法在机器学习中的应用》(Automatic Differentiation of Algorithms for Machine Learning),该文章已在 arXiv 上免费提供,预印本为 arXiv:1404.7456,发布时间为 2014,链接为 http://arxiv.org/pdf/1404.7456.pdf

自动微分有两种模式:前向模式和反向模式;反向传播实际上是反向模式自动微分的一个特例。关键点在于,在前向模式下应用链式法则可能非常昂贵,因为我们需要对每一层(雅可比矩阵)进行大矩阵相乘,最后再与一个向量相乘以获得输出。

反向模式的技巧在于我们从右到左开始:我们将一个矩阵与一个向量相乘,得到另一个向量,再与下一个矩阵相乘,以此类推。矩阵与向量相乘在计算上比矩阵与矩阵相乘便宜得多,这也是为什么反向传播是神经网络训练中最流行的算法之一。

基础微积分回顾

为了完全理解反向传播,我们需要借用一些微积分的概念,这些内容超出了本书的范围。然而,你可以参考本书的附录部分,它涵盖了最基本的微积分概念,可能在本节中会对你有所帮助。该部分讨论了函数的导数、偏导数、梯度和雅可比矩阵。这篇文章可以在 https://sebastianraschka.com/pdf/books/dlb/appendix_d_calculus.pdf 上免费获取。如果你不熟悉微积分或需要简要的复习,建议在阅读下一节之前先阅读这篇文章,作为补充资源。

通过反向传播训练神经网络

在本节中,我们将详细讲解反向传播的数学原理,以帮助你理解如何高效地学习神经网络中的权重。根据你对数学表示的熟悉程度,以下公式可能一开始看起来相对复杂。

在前面的部分中,我们看到如何计算代价函数,它是最后一层激活值与目标类标签之间的差异。现在,我们将从数学角度来看反向传播算法是如何工作的,来更新我们 MLP 模型中的权重,我们在 fit 方法中的 # Backpropagation 代码注释后实现了这一过程。正如我们从本章开头回忆的那样,我们首先需要应用前向传播,以便获得输出层的激活值,我们将其表示如下:

简而言之,我们通过网络中的连接将输入特征前向传播,具体如以下示意图所示:

在反向传播中,我们从右向左传播误差。我们首先计算输出层的误差向量:

这里,y 是真实类标签的向量(在 NeuralNetMLP 代码中对应的变量是 delta_out)。

接下来,我们计算隐藏层的误差项:

在这里, 只是 sigmoid 激活函数的导数,我们在 NeuralNetMLPfit 方法中计算为 sigmoid_derivative_h = a_h * (1. - a_h)

注意, 符号在此处表示按元素相乘。

激活函数的导数

虽然接下来的公式并不是必须完全跟随的,但你可能会好奇激活函数的导数是如何得到的;它在这里进行了逐步总结:

接下来,我们计算 层的误差矩阵(delta_h),计算公式如下:

为了更好地理解我们是如何计算这个项的,让我们更详细地讲解一下。在之前的方程中,我们使用了的转置,即维度的矩阵。这里,t是输出类标签的数量,h是隐藏单元的数量。维度的矩阵与维度的矩阵相乘,得到一个维度的矩阵,然后我们将该矩阵与同维度的sigmoid导数逐元素相乘,得到了维度的矩阵

最终,在获得项后,我们现在可以将成本函数的推导写成如下:

接下来,我们需要累积每一层每个节点的偏导数和下一层节点的误差。不过,请记住,我们需要为训练数据集中的每个样本计算。因此,将其实现为矢量化版本,就像在我们的NeuralNetMLP代码实现中一样,会更容易一些:

在累积偏导数后,我们可以添加以下正则化项:

(请注意,偏置单元通常不进行正则化。)

前面两个数学方程对应于NeuralNetMLP中的代码变量delta_w_hdelta_b_hdelta_w_outdelta_b_out

最后,在计算完梯度后,我们可以通过朝着每一层l的梯度方向迈出一个相反的步伐来更新权重:

它的实现如下:

self.w_h -= self.eta * delta_w_h
self.b_h -= self.eta * delta_b_h
self.w_out -= self.eta * delta_w_out
self.b_out -= self.eta * delta_b_out 

为了将所有内容结合起来,让我们在下图中总结反向传播:

关于神经网络的收敛性

你可能会想,为什么我们没有使用常规的梯度下降法,而是使用小批量学习来训练我们用于手写数字分类的神经网络。你可能还记得我们在讨论 SGD 时提到过在线学习。在在线学习中,我们每次基于一个训练样本(k = 1)来计算梯度并执行权重更新。尽管这是一种随机方法,但它通常能比常规的梯度下降法更快收敛,并且能够得到非常准确的解决方案。小批量学习是 SGD 的一种特殊形式,其中我们基于 n 个训练样本中的一个子集 k(1 < k < n)来计算梯度。与在线学习相比,小批量学习的优势在于我们可以利用向量化实现来提高计算效率。然而,我们的权重更新速度要比常规的梯度下降法快得多。直观地讲,你可以将小批量学习看作是通过对一部分具有代表性的选民群体进行调查,而不是对整个选民群体进行投票调查,来预测总统选举的选民投票率(这相当于进行实际的选举)。

多层神经网络(NN)比简单的算法如 Adaline、逻辑回归或支持向量机更难训练。在多层神经网络中,我们通常需要优化数百、数千甚至数十亿个权重。不幸的是,输出函数具有粗糙的表面,优化算法很容易陷入局部最小值,正如下图所示:

请注意,这个表示方式是极其简化的,因为我们的神经网络具有许多维度,这使得人眼无法可视化实际的代价表面。在这里,我们仅展示了一个权重在 x 轴上的代价表面。然而,主要的信息是我们不希望我们的算法陷入局部最小值。通过增加学习率,我们可以更容易地跳出这些局部最小值。另一方面,如果学习率过大,我们也会增加越过全局最优解的机会。由于我们是随机初始化权重的,所以我们从一个通常完全错误的优化问题解开始。

关于神经网络实现的最后几点

你可能会想,为什么我们要通过这些理论来实现一个简单的多层人工神经网络,它可以对手写数字进行分类,而不使用开源的 Python 机器学习库。实际上,在接下来的章节中,我们将介绍更复杂的神经网络模型,并使用开源的 TensorFlow 库进行训练(https://www.tensorflow.org)。

尽管本章中从零实现的过程一开始看起来有些繁琐,但它是理解反向传播和神经网络训练基本原理的一个好练习,而对算法的基本理解对于恰当地和成功地应用机器学习技术至关重要。

现在你已经了解了前馈神经网络的工作原理,我们准备使用TensorFlow探索更复杂的DNN,它能让我们更高效地构建神经网络,正如我们将在第13章《使用TensorFlow并行训练神经网络》中看到的那样。

在过去的两年里,自2015年11月发布以来,TensorFlow在机器学习研究人员中获得了广泛的关注,他们使用它来构建DNN,因为它能够优化多维数组上使用图形处理单元GPU)进行计算的数学表达式。虽然TensorFlow可以被认为是一个低级深度学习库,但已经开发出了像Keras这样的简化API,使得构建常见的深度学习模型更加便捷,正如我们在第13章《使用TensorFlow并行训练神经网络》中将看到的那样。

总结

在本章中,你学习了多层人工神经网络的基本概念,这是目前机器学习研究中最热门的话题。在第2章《训练简单的机器学习算法进行分类》中,我们从简单的单层神经网络结构开始,而现在我们已经将多个神经元连接成一个强大的神经网络架构,用来解决如手写数字识别等复杂问题。我们解开了流行的反向传播算法的神秘面纱,反向传播是许多深度学习神经网络模型的构建基石。通过学习本章的反向传播算法,我们已经为探索更复杂的DNN架构做好了准备。在接下来的章节中,我们将介绍TensorFlow,一个面向深度学习的开源库,它能让我们更高效地实现和训练多层神经网络。

第十三章:使用 TensorFlow 并行化神经网络训练

在本章中,我们将从机器学习和深度学习的数学基础转向 TensorFlow。TensorFlow 是目前最流行的深度学习库之一,它让我们比之前任何的 NumPy 实现更高效地实现神经网络(NN)。在本章中,我们将开始使用 TensorFlow,并看到它如何显著提升训练性能。

本章将开始我们进入机器学习和深度学习的新阶段,我们将探索以下主题:

  • TensorFlow 如何提高训练性能

  • 使用 TensorFlow 的 Dataset API(tf.data)构建输入管道和高效的模型训练

  • 使用 TensorFlow 编写优化的机器学习代码

  • 使用 TensorFlow 高级 API 构建多层神经网络

  • 选择人工神经网络的激活函数

  • 介绍 Keras(tf.keras),这是一个高层次的 TensorFlow 封装器,可方便地实现常见的深度学习架构

TensorFlow 和训练性能

TensorFlow 可以显著加速我们的机器学习任务。为了理解它是如何做到这一点的,让我们首先讨论在硬件上进行昂贵计算时通常遇到的一些性能挑战。然后,我们将高层次地了解 TensorFlow 是什么,以及我们在本章中的学习方法是什么。

性能挑战

当然,近年来计算机处理器的性能不断提高,这使我们能够训练更强大和复杂的学习系统,这意味着我们可以提高机器学习模型的预测性能。即使是目前最便宜的桌面计算机硬件,也配备了多个核心的处理单元。

在前面的章节中,我们看到 scikit-learn 中的许多函数允许我们将计算分散到多个处理单元上。然而,默认情况下,由于全局解释器锁GIL),Python 限制了只能在一个核心上执行。因此,尽管我们确实利用了 Python 的多进程库将计算分布到多个核心上,但我们仍然需要考虑到,最先进的桌面硬件通常只有八个或十六个这样的核心。

你会记得在第12章从零开始实现多层人工神经网络中,我们实现了一个非常简单的多层感知器(MLP),只有一个包含100个单元的隐藏层。我们需要优化大约80,000个权重参数([784*100 + 100] + [100 * 10] + 10 = 79,510)来学习一个非常简单的图像分类模型。MNIST中的图像相当小(),我们可以想象如果想要添加更多的隐藏层或处理更高像素密度的图像,参数的数量会爆炸式增长。这样的任务对于单一的处理单元来说很快就变得不可行。那么问题就来了,如何更有效地处理这样的任务呢?

解决这一问题的显而易见的方法是使用图形处理单元(GPU),它们是真正的工作马。你可以将显卡看作是你机器内部的一个小型计算机集群。另一个优势是,与最先进的中央处理单元(CPU)相比,现代GPU的价格相对便宜,正如以下概览所示:

表格中信息的来源是以下网站(日期:2019年10月):

以现代CPU价格的65%为代价,我们可以获得一款GPU,其核心数是现代CPU的272倍,且每秒能够进行约10倍更多的浮点计算。那么,是什么阻碍我们利用GPU来完成机器学习任务呢?挑战在于,编写针对GPU的代码并不像在解释器中执行Python代码那样简单。我们需要使用特殊的包,如CUDA和OpenCL,来利用GPU。然而,使用CUDA或OpenCL编写代码可能不是实现和运行机器学习算法的最便捷环境。好消息是,这正是TensorFlow的开发初衷!

什么是TensorFlow?

TensorFlow 是一个可扩展的多平台编程接口,用于实现和运行机器学习算法,包括深度学习的便捷封装。TensorFlow 是由 Google Brain 团队的研究人员和工程师开发的。虽然主要的开发工作由 Google 的研究人员和软件工程师团队领导,但其开发也得到了开源社区的许多贡献。TensorFlow 最初是为 Google 内部使用而构建的,但随后于 2015 年 11 月在宽松的开源许可证下发布。许多来自学术界和行业的机器学习研究人员和从业者已将 TensorFlow 应用于开发深度学习解决方案。

为了提高训练机器学习模型的性能,TensorFlow 允许在 CPU 和 GPU 上执行。然而,当使用 GPU 时,其最大的性能优势可以得到体现。TensorFlow 官方支持 CUDA 启用的 GPU。对 OpenCL 启用的设备的支持仍处于实验阶段。然而,OpenCL 很可能会在不久的将来得到官方支持。TensorFlow 当前支持多种编程语言的前端接口。

对于我们作为 Python 用户来说,TensorFlow 的 Python API 当前是最完整的 API,因此它吸引了许多机器学习和深度学习从业者。此外,TensorFlow 还提供了官方的 C++ API。此外,基于 TensorFlow 的新工具 TensorFlow.js 和 TensorFlow Lite 已经发布,专注于在 Web 浏览器和移动设备及物联网(IoT)设备上运行和部署机器学习模型。其他语言的 API,如 Java、Haskell、Node.js 和 Go,仍不稳定,但开源社区和 TensorFlow 开发人员正在不断改进它们。

TensorFlow 是围绕一个由一组节点组成的计算图构建的。每个节点代表一个操作,该操作可能有零个或多个输入和输出。张量是作为一个符号句柄来引用这些操作的输入和输出。

从数学角度来看,张量可以理解为标量、向量、矩阵等的推广。更具体地说,标量可以定义为一个零阶张量,向量可以定义为一个一阶张量,矩阵可以定义为一个二阶张量,堆叠在第三维的矩阵可以定义为三阶张量。但需要注意的是,在 TensorFlow 中,值是存储在 NumPy 数组中的,而张量提供对这些数组的引用。

为了更清楚地理解张量的概念,考虑下图,它在第一行表示零阶和一阶张量,在第二行表示二阶和三阶张量:

在原版 TensorFlow 发布时,TensorFlow 的计算依赖于构建一个静态的有向图来表示数据流。由于静态计算图的使用对于许多用户来说是一个主要的障碍,TensorFlow 库最近在 2.0 版本中进行了大规模的改进,使得构建和训练神经网络模型变得更加简单。尽管 TensorFlow 2.0 仍然支持静态计算图,但它现在使用动态计算图,这使得操作更加灵活。

我们将如何学习 TensorFlow

首先,我们将介绍 TensorFlow 的编程模型,特别是如何创建和操作张量。然后,我们将学习如何加载数据并利用 TensorFlow Dataset 对象,这将使我们能够高效地迭代数据集。此外,我们还将讨论 tensorflow_datasets 子模块中现有的、可直接使用的数据集,并学习如何使用它们。

学习了这些基础知识后,tf.keras API 将被介绍,我们将继续构建机器学习模型,学习如何编译和训练模型,并学习如何将训练好的模型保存在磁盘上,以便将来评估。

TensorFlow 的第一步

在本节中,我们将迈出使用低级 TensorFlow API 的第一步。安装 TensorFlow 后,我们将介绍如何在 TensorFlow 中创建张量,以及如何操作它们的不同方式,例如改变它们的形状、数据类型等。

安装 TensorFlow

根据系统设置的不同,通常你只需使用 Python 的 pip 安装器,通过在终端中执行以下命令从 PyPI 安装 TensorFlow:

pip install tensorflow 

这将安装最新的 稳定 版本,即本文撰写时的 2.0.0 版本。为了确保本章中展示的代码能够按预期执行,建议使用 TensorFlow 2.0.0 版本,可以通过明确指定版本来安装:

pip install tensorflow==[desired-version] 

如果你想使用 GPU(推荐),你需要一块兼容的 NVIDIA 显卡,以及安装 CUDA Toolkit 和 NVIDIA cuDNN 库。如果你的机器满足这些要求,你可以按照以下步骤安装支持 GPU 的 TensorFlow:

**pip install tensorflow-gpu** 

有关安装和设置过程的更多信息,请参阅官方推荐的https://www.tensorflow.org/install/gpu

请注意,TensorFlow 仍在积极开发中;因此,每隔几个月会发布新版本,并进行重要的变更。在撰写本章时,最新的 TensorFlow 版本是 2.0。你可以通过终端来验证你的 TensorFlow 版本,如下所示:

python -c 'import tensorflow as tf; print(tf.__version__)' 

解决 TensorFlow 安装问题

如果在安装过程中遇到问题,请查看 https://www.tensorflow.org/install/ 上提供的系统和平台特定的建议。请注意,本章中的所有代码都可以在 CPU 上运行;使用 GPU 是完全可选的,但如果你希望充分利用 TensorFlow 的优势,推荐使用 GPU。例如,使用 CPU 训练一些神经网络模型可能需要一周时间,而在现代 GPU 上训练相同的模型只需要几个小时。如果你有显卡,请参考安装页面以正确设置它。此外,你可能会发现这个 TensorFlow-GPU 安装指南很有用,指导你如何在 Ubuntu 上安装 NVIDIA 显卡驱动程序、CUDA 和 cuDNN(这些不是必需的,但如果你想在 GPU 上运行 TensorFlow,它们是推荐的要求):https://sebastianraschka.com/pdf/books/dlb/appendix_h_cloud-computing.pdf。此外,正如你将在 第17章生成对抗网络用于合成新数据》中看到的,你还可以通过 Google Colab 免费使用 GPU 来训练模型。

在 TensorFlow 中创建张量

现在,让我们考虑几种不同的创建张量的方式,并查看它们的一些属性以及如何操作它们。首先,我们可以通过 tf.convert_to_tensor 函数从列表或 NumPy 数组简单地创建一个张量,如下所示:

>>> import tensorflow as tf
>>> import numpy as np
>>> np.set_printoptions(precision=3)
>>> a = np.array([1, 2, 3], dtype=np.int32)
>>> b = [4, 5, 6]
>>> t_a = tf.convert_to_tensor(a)
>>> t_b = tf.convert_to_tensor(b)
>>> print(t_a)
>>> print(t_b)
tf.Tensor([1 2 3], shape=(3,), dtype=int32)
tf.Tensor([4 5 6], shape=(3,), dtype=int32) 

这导致了张量 t_at_b,它们的属性为 shape=(3,)dtype=int32,这些属性来自它们的源。类似于 NumPy 数组,我们还可以查看这些属性:

>>> t_ones = tf.ones((2, 3))
>>> t_ones.shape
TensorShape([2, 3]) 

要访问张量所引用的值,我们只需调用张量的 .numpy() 方法:

>>> t_ones.numpy()
array([[1., 1., 1.],
       [1., 1., 1.]], dtype=float32) 

最后,创建一个常量值的张量可以通过以下方式完成:

>>> const_tensor = tf.constant([1.2, 5, np.pi],
...                            dtype=tf.float32)
>>> print(const_tensor)
tf.Tensor([1.2   5\.    3.142], shape=(3,), dtype=float32) 

操作张量的数据类型和形状

学习如何操作张量是非常必要的,以使它们能够兼容模型或操作的输入。在本节中,你将学习如何通过几个 TensorFlow 函数操作张量的数据类型和形状,这些函数包括转换、重塑、转置和压缩。

tf.cast() 函数可用于将张量的数据类型转换为所需的类型:

>>> t_a_new = tf.cast(t_a, tf.int64)
>>> print(t_a_new.dtype)
<dtype: 'int64'> 

正如你将在后续章节中看到的,某些操作要求输入张量具有特定的维度(即秩)和特定数量的元素(形状)。因此,我们可能需要更改张量的形状、添加新维度或压缩不必要的维度。TensorFlow 提供了有用的函数(或操作)来实现这一点,例如 tf.transpose()tf.reshape()tf.squeeze()。让我们来看一些例子:

  • 转置一个张量

    >>> t = tf.random.uniform(shape=(3, 5))
    >>> t_tr = tf.transpose(t)
    >>> print(t.shape, ' --> ', t_tr.shape)
    (3, 5)  -->  (5, 3) 
    
  • 重塑一个张量(例如,从 1D 向量到 2D 数组)

    >>> t = tf.zeros((30,))
    >>> t_reshape = tf.reshape(t, shape=(5, 6))
    >>> print(t_reshape.shape)
    (5, 6) 
    
  • 去除不必要的维度(大小为 1 的维度,通常是不需要的)

    >>> t = tf.zeros((1, 2, 1, 4, 1))
    >>> t_sqz = tf.squeeze(t, axis=(2, 4))
    >>> print(t.shape, ' --> ', t_sqz.shape)
    (1, 2, 1, 4, 1)  -->  (1, 2, 4) 
    

对张量应用数学操作

应用数学运算,特别是线性代数运算,对于构建大多数机器学习模型是必需的。在本小节中,我们将介绍一些常用的线性代数操作,如元素级乘积、矩阵乘法和计算张量的范数。

首先,让我们实例化两个随机张量,一个是在区间 [–1, 1) 内均匀分布的张量,另一个是标准正态分布的张量:

>>> tf.random.set_seed(1)
>>> t1 = tf.random.uniform(shape=(5, 2),
...                        minval=-1.0, maxval=1.0)
>>> t2 = tf.random.normal(shape=(5, 2),
...                       mean=0.0, stddev=1.0) 

请注意,t1t2 具有相同的形状。现在,为了计算 t1t2 的元素级乘积,我们可以使用以下方法:

>>> t3 = tf.multiply(t1, t2).numpy()
>>> print(t3)
[[-0.27  -0.874]
 [-0.017 -0.175]
 [-0.296 -0.139]
 [-0.727  0.135]
 [-0.401  0.004]] 

要沿某一轴(或多个轴)计算均值、和与标准差,我们可以使用 tf.math.reduce_mean()tf.math.reduce_sum()tf.math.reduce_std()。例如,可以按照如下方式计算 t1 每一列的均值:

>>> t4 = tf.math.reduce_mean(t1, axis=0)
>>> print(t4)
tf.Tensor([0.09  0.207], shape=(2,), dtype=float32) 

t1t2 之间的矩阵乘积(即 ,其中上标 T 表示转置)可以通过使用 tf.linalg.matmul() 函数按如下方式计算:

>>> t5 = tf.linalg.matmul(t1, t2, transpose_b=True)
>>> print(t5.numpy())
[[-1.144  1.115 -0.87  -0.321  0.856]
 [ 0.248 -0.191  0.25  -0.064 -0.331]
 [-0.478  0.407 -0.436  0.022  0.527]
 [ 0.525 -0.234  0.741 -0.593 -1.194]
 [-0.099  0.26   0.125 -0.462 -0.396]] 

另一方面,计算 是通过转置 t1 来完成的,结果是一个大小为 的数组:

>>> t6 = tf.linalg.matmul(t1, t2, transpose_a=True)
>>> print(t6.numpy())
[[-1.711  0.302]
 [ 0.371 -1.049]] 

最后,tf.norm() 函数对于计算张量的 范数非常有用。例如,我们可以按照如下方式计算 t1 范数:

>>> norm_t1 = tf.norm(t1, ord=2, axis=1).numpy()
>>> print(norm_t1)
[1.046 0.293 0.504 0.96  0.383] 
the  norm of t1 correctly, you can compare the results with the following NumPy function: np.sqrt(np.sum(np.square(t1), axis=1)).

拆分、堆叠和连接张量

在本小节中,我们将介绍 TensorFlow 中的操作,用于将一个张量拆分为多个张量,或执行相反的操作:将多个张量堆叠和连接成一个张量。

假设我们有一个单一的张量,并且我们想将其拆分成两个或更多张量。为此,TensorFlow 提供了一个便捷的 tf.split() 函数,它将输入张量分割成一个等大小的张量列表。我们可以使用参数 num_or_size_splits 来确定期望的拆分数量,沿着指定的维度(由 axis 参数指定)拆分张量。在这种情况下,输入张量在指定维度上的总大小必须能够被期望的拆分数量整除。或者,我们可以提供一个包含期望大小的列表。让我们看一下这两种选项的示例:

  • 提供拆分的数量(必须是可整除的)

    >>> tf.random.set_seed(1)
    >>> t = tf.random.uniform((6,))
    >>> print(t.numpy())
    [0.165 0.901 0.631 0.435 0.292 0.643]
    >>> t_splits = tf.split(t, num_or_size_splits=3)
    >>> [item.numpy() for item in t_splits]
    [array([0.165, 0.901], dtype=float32),
     array([0.631, 0.435], dtype=float32),
     array([0.292, 0.643], dtype=float32)] 
    

    在这个例子中,一个大小为 6 的张量被分成了一个由三个大小为 2 的张量组成的列表。

  • 提供不同拆分的大小

    或者,除了定义拆分的数量外,我们还可以直接指定输出张量的大小。在这里,我们将一个大小为 5 的张量拆分成大小为 32 的张量:

    >>> tf.random.set_seed(1)
    >>> t = tf.random.uniform((5,))
    >>> print(t.numpy())
    [0.165 0.901 0.631 0.435 0.292]
    >>> t_splits = tf.split(t, num_or_size_splits=[3, 2])
    >>> [item.numpy() for item in t_splits]
    [array([0.165, 0.901, 0.631], dtype=float32),
     array([0.435, 0.292], dtype=float32)] 
    

有时候,我们需要处理多个张量,并将它们连接或堆叠以创建一个单一的张量。在这种情况下,TensorFlow 的函数如 tf.stack()tf.concat() 很有用。例如,让我们创建一个大小为 3 的包含 1 的一维张量 A 和一个大小为 2 的包含 0 的一维张量 B,并将它们连接成一个大小为 5 的一维张量 C

>>> A = tf.ones((3,))
>>> B = tf.zeros((2,))
>>> C = tf.concat([A, B], axis=0)
>>> print(C.numpy())
[1\. 1\. 1\. 0\. 0.] 

如果我们创建了大小为3的1D张量AB,那么我们可以将它们堆叠在一起形成一个2D张量S

>>> A = tf.ones((3,))
>>> B = tf.zeros((3,))
>>> S = tf.stack([A, B], axis=1)
>>> print(S.numpy())
[[1\. 0.]
 [1\. 0.]
 [1\. 0.]] 

TensorFlow API提供了许多操作,可以用于构建模型、处理数据等。然而,涵盖所有的函数超出了本书的范围,本书将重点介绍最基本的操作。有关所有操作和函数的完整列表,可以参考TensorFlow文档页面:https://www.tensorflow.org/versions/r2.0/api_docs/python/tf

使用tf.data构建输入管道——TensorFlow数据集API

在训练深度神经网络(NN)模型时,我们通常使用迭代优化算法(如随机梯度下降)逐步训练模型,正如我们在前面的章节中所看到的那样。

正如本章开头提到的,Keras API是一个围绕TensorFlow构建神经网络模型的包装器。Keras API提供了一个方法.fit()来训练模型。在训练数据集较小并且可以作为张量加载到内存中的情况下,使用Keras API构建的TensorFlow模型可以直接通过.fit()方法使用这个张量进行训练。然而,在典型的使用场景中,当数据集太大无法完全载入计算机内存时,我们需要从主存储设备(例如硬盘或固态硬盘)按批次加载数据(注意本章使用“批次”而非“迷你批次”以更接近TensorFlow术语)。此外,我们可能需要构建一个数据处理管道,应用某些转换和预处理步骤,如均值中心化、缩放或添加噪声,以增强训练过程并防止过拟合。

每次手动应用预处理函数可能会非常繁琐。幸运的是,TensorFlow提供了一个专门的类来构建高效且便捷的预处理管道。在这一节中,我们将概述不同的构建TensorFlow Dataset的方法,包括数据集转换和常见的预处理步骤。

从现有的张量创建一个TensorFlow数据集

如果数据已经以张量对象、Python列表或NumPy数组的形式存在,我们可以使用tf.data.Dataset.from_tensor_slices()函数轻松地创建数据集。该函数返回一个Dataset类的对象,我们可以用它来逐一遍历输入数据集中的元素。作为一个简单的例子,考虑以下代码,它从一个数值列表中创建数据集:

>>> a = [1.2, 3.4, 7.5, 4.1, 5.0, 1.0]
>>> ds = tf.data.Dataset.from_tensor_slices(a)
>>> print(ds)
<TensorSliceDataset shapes: (), types: tf.float32> 

我们可以通过如下方式轻松地逐条遍历数据集中的每一项:

>>> for item in ds:
...     print(item)
tf.Tensor(1.2, shape=(), dtype=float32)
tf.Tensor(3.4, shape=(), dtype=float32)
tf.Tensor(7.5, shape=(), dtype=float32)
tf.Tensor(4.1, shape=(), dtype=float32)
tf.Tensor(5.0, shape=(), dtype=float32)
tf.Tensor(1.0, shape=(), dtype=float32) 

如果我们想从这个数据集中创建批次,设定每个批次的大小为3,可以通过以下方式实现:

>>> ds_batch = ds.batch(3)
>>> for i, elem in enumerate(ds_batch, 1):
...    print('batch {}:'.format(i), elem.numpy())
batch 1: [1.2 3.4 7.5]
batch 2: [4.1 5\.  1\. ] 

这将从该数据集中创建两个批次,其中前三个元素进入批次#1,剩余的元素进入批次#2。.batch()方法有一个可选参数drop_remainder,当张量中的元素数量不能被期望的批次大小整除时,drop_remainder非常有用。drop_remainder的默认值是False。我们将在后面的打乱、批处理和重复小节中看到更多示例,展示该方法的行为。

将两个张量组合成一个联合数据集

通常,我们可能会将数据存储在两个(或可能更多)张量中。例如,我们可能会有一个存储特征的张量和一个存储标签的张量。在这种情况下,我们需要构建一个数据集,将这些张量组合在一起,这样我们就可以以元组的形式获取这些张量的元素。

假设我们有两个张量,t_xt_y。张量t_x存储我们的特征值,每个大小为3,而t_y存储类别标签。对于这个例子,我们首先按如下方式创建这两个张量:

>>> tf.random.set_seed(1)
>>> t_x = tf.random.uniform([4, 3], dtype=tf.float32)
>>> t_y = tf.range(4) 

现在,我们想要从这两个张量中创建一个联合数据集。请注意,这两个张量的元素之间需要一一对应:

>>> ds_x = tf.data.Dataset.from_tensor_slices(t_x)
>>> ds_y = tf.data.Dataset.from_tensor_slices(t_y)
>>>
>>> ds_joint = tf.data.Dataset.zip((ds_x, ds_y))
>>> for example in ds_joint:
...     print('  x:', example[0].numpy(),
...           '  y:', example[1].numpy())
  x: [0.165 0.901 0.631]   y: 0
  x: [0.435 0.292 0.643]   y: 1
  x: [0.976 0.435 0.66 ]   y: 2
  x: [0.605 0.637 0.614]   y: 3 

在这里,我们首先创建了两个独立的数据集,分别是ds_xds_y。然后,我们使用zip函数将它们组合成一个联合数据集。或者,我们也可以通过tf.data.Dataset.from_tensor_slices()来创建联合数据集,方法如下:

>>> ds_joint = tf.data.Dataset.from_tensor_slices((t_x, t_y))
>>> for example in ds_joint:
...     print('  x:', example[0].numpy(),
...           '  y:', example[1].numpy())
  x: [0.165 0.901 0.631]   y: 0
  x: [0.435 0.292 0.643]   y: 1
  x: [0.976 0.435 0.66 ]   y: 2
  x: [0.605 0.637 0.614]   y: 3 

这会产生相同的输出。

请注意,一个常见的错误来源是,原始特征(x)和标签(y)之间的逐元素对应关系可能会丢失(例如,如果两个数据集分别被打乱)。但是,一旦它们合并为一个数据集,就可以安全地应用这些操作。

接下来,我们将看到如何对数据集中的每个元素应用转换。为此,我们将使用之前的ds_joint数据集,并应用特征缩放,将值缩放到范围[-1, 1),因为目前t_x的值基于随机均匀分布在[0, 1)范围内:

>>> ds_trans = ds_joint.map(lambda x, y: (x*2-1.0, y))
>>> for example in ds_trans:
...    print('  x:', example[0].numpy(),
...          '  y:', example[1].numpy())
  x: [-0.67   0.803  0.262]   y: 0
  x: [-0.131 -0.416  0.285]   y: 1
  x: [ 0.952 -0.13   0.32 ]   y: 2
  x: [ 0.21   0.273  0.229]   y: 3 

应用这种转换可以用于用户自定义的函数。例如,如果我们有一个由磁盘上图像文件名列表创建的数据集,我们可以定义一个函数从这些文件名中加载图像,并通过调用.map()方法应用该函数。你将在本章后面看到一个示例,展示如何对数据集应用多重转换。

打乱、批处理和重复

第2章:训练简单的机器学习分类算法中所述,要使用随机梯度下降优化训练神经网络模型,重要的是要将训练数据作为随机打乱的批次输入。你已经看到如何通过调用数据集对象的.batch()方法来创建批次。现在,除了创建批次之外,你将看到如何对数据集进行打乱并重复迭代。我们将继续使用之前的ds_joint数据集。

首先,让我们从ds_joint数据集中创建一个洗牌版本:

>>> tf.random.set_seed(1)
>>> ds = ds_joint.shuffle(buffer_size=len(t_x))
>>> for example in ds:
...     print('  x:', example[0].numpy(),
...           '  y:', example[1].numpy())
  x: [0.976 0.435 0.66 ]   y: 2
  x: [0.435 0.292 0.643]   y: 1
  x: [0.165 0.901 0.631]   y: 0
  x: [0.605 0.637 0.614]   y: 3 

其中,行被洗牌,但不失去xy之间的一对一对应关系。.shuffle()方法需要一个叫做buffer_size的参数,该参数决定在洗牌之前,数据集中有多少个元素被组合在一起。缓冲区中的元素会被随机提取,并且其在缓冲区中的位置会被赋给原始(未洗牌)数据集中下一个元素的位置。因此,如果选择一个较小的buffer_size,我们可能无法完美地洗牌数据集。

如果数据集较小,选择一个相对较小的buffer_size可能会对神经网络的预测性能产生负面影响,因为数据集可能无法完全随机化。然而,实际上,当处理相对较大的数据集时(深度学习中常见的情况),通常不会产生明显的影响。或者,为了确保在每个epoch期间完全随机化,我们可以选择一个等于训练样本数量的缓冲区大小,就像前面的代码一样(buffer_size=len(t_x))。

你可能还记得,为了进行模型训练,将数据集划分为批次是通过调用.batch()方法来完成的。现在,让我们从ds_joint数据集中创建这样的批次,并看看一个批次是什么样的:

>>> ds = ds_joint.batch(batch_size=3,
...                     drop_remainder=False)
>>> batch_x, batch_y = next(iter(ds))
>>> print('Batch-x:\n', batch_x.numpy())
Batch-x:
[[0.165 0.901 0.631]
 [0.435 0.292 0.643]
 [0.976 0.435 0.66 ]]
>>> print('Batch-y: ', batch_y.numpy())
Batch-y: [0 1 2] 

此外,当对模型进行多次训练时,我们需要根据所需的epoch次数来洗牌并迭代数据集。因此,让我们将批处理数据集重复两次:

>>> ds = ds_joint.batch(3).repeat(count=2)
>>> for i,(batch_x, batch_y) in enumerate(ds):
...     print(i, batch_x.shape, batch_y.numpy())
0 (3, 3) [0 1 2]
1 (1, 3) [3]
2 (3, 3) [0 1 2]
3 (1, 3) [3] 

这会导致每个批次有两份副本。如果我们改变这两个操作的顺序,也就是先批处理然后重复,结果会不同:

>>> ds = ds_joint.repeat(count=2).batch(3)
>>> for i,(batch_x, batch_y) in enumerate(ds):
...     print(i, batch_x.shape, batch_y.numpy())
0 (3, 3) [0 1 2]
1 (3, 3) [3 0 1]
2 (2, 3) [2 3] 

注意批次之间的差异。当我们首先进行批处理然后重复时,会得到四个批次。另一方面,当先进行重复操作时,则会创建三个批次。

最后,为了更好地理解这三种操作(批处理、洗牌和重复)如何表现,让我们尝试以不同的顺序进行实验。首先,我们将按以下顺序组合这些操作:(1) 洗牌,(2) 批处理,(3) 重复:

## Order 1: shuffle -> batch -> repeat
>>> tf.random.set_seed(1)
>>> ds = ds_joint.shuffle(4).batch(2).repeat(3)
>>> for i,(batch_x, batch_y) in enumerate(ds):
...     print(i, batch_x.shape, batch_y.numpy())
0 (2, 3) [2 1]
1 (2, 3) [0 3]
2 (2, 3) [0 3]
3 (2, 3) [1 2]
4 (2, 3) [3 0]
5 (2, 3) [1 2] 

现在,让我们尝试另一种顺序:(2) 批处理,(1) 洗牌,(3) 重复:

## Order 2: batch -> shuffle -> repeat
>>> tf.random.set_seed(1)
>>> ds = ds_joint.batch(2).shuffle(4).repeat(3)
>>> for i,(batch_x, batch_y) in enumerate(ds):
...     print(i, batch_x.shape, batch_y.numpy())
0 (2, 3) [0 1]
1 (2, 3) [2 3]
2 (2, 3) [0 1]
3 (2, 3) [2 3]
4 (2, 3) [2 3]
5 (2, 3) [0 1] 

虽然第一个代码示例(洗牌、批处理、重复)似乎按预期洗牌了数据集,但我们可以看到在第二种情况下(批处理、洗牌、重复),批次内的元素根本没有被洗牌。通过仔细查看包含目标值y的张量,我们可以观察到这种没有洗牌的情况。所有批次要么包含一对值[y=0, y=1],要么包含另一对值[y=2, y=3];我们没有观察到其他可能的排列:[y=2, y=0][y=1, y=3],依此类推。请注意,为了确保这些结果不是巧合,您可能希望使用大于3的重复次数。例如,尝试.repeat(20)

现在,你能预测在使用重复操作之后,如果我们使用 shuffle 操作会发生什么吗?例如,(2) 批量处理,(3) 重复操作,(1) 打乱顺序?试试看。

一个常见的错误来源是对给定数据集连续调用 .batch() 两次。这样做会导致从结果数据集中提取项目时,生成批量的批量示例。基本上,每次对数据集调用 .batch() 时,它都会将提取的张量的维度增加一。

从本地存储磁盘上的文件创建数据集

在本节中,我们将从存储在磁盘上的图像文件构建数据集。本章的在线内容包含一个图像文件夹。下载该文件夹后,你应该能够看到六张猫和狗的 JPEG 格式图像。

这个小数据集将展示如何从存储的文件中构建数据集的一般过程。为了实现这一点,我们将使用 TensorFlow 中的两个额外模块:tf.io 用于读取图像文件内容,tf.image 用于解码原始内容并进行图像大小调整。

tf.io 和 tf.image 模块

tf.iotf.image 模块提供了许多额外且有用的功能,超出了本书的范围。建议你浏览官方文档,了解更多关于这些功能的内容:

https://www.tensorflow.org/versions/r2.0/api_docs/python/tf/io 了解 tf.io

https://www.tensorflow.org/versions/r2.0/api_docs/python/tf/image 了解 tf.image

在我们开始之前,让我们先看看这些文件的内容。我们将使用 pathlib 库生成一个图像文件列表:

>>> import pathlib
>>> imgdir_path = pathlib.Path('cat_dog_images')
>>> file_list = sorted([str(path) for path in
...                     imgdir_path.glob('*.jpg')])
['cat_dog_images/dog-03.jpg', 'cat_dog_images/cat-01.jpg', 'cat_dog_images/cat-02.jpg', 'cat_dog_images/cat-03.jpg', 'cat_dog_images/dog-01.jpg', 'cat_dog_images/dog-02.jpg'] 

接下来,我们将使用 Matplotlib 可视化这些图像示例:

>>> import matplotlib.pyplot as plt
>>> fig = plt.figure(figsize=(10, 5))
>>> for i, file in enumerate(file_list):
...     img_raw = tf.io.read_file(file)
...     img = tf.image.decode_image(img_raw)
...     print('Image shape: ', img.shape)
...     ax = fig.add_subplot(2, 3, i+1)
...     ax.set_xticks([]); ax.set_yticks([])
...     ax.imshow(img)
...     ax.set_title(os.path.basename(file), size=15)
>>> plt.tight_layout()
>>> plt.show()
Image shape:  (900, 1200, 3)
Image shape:  (900, 1200, 3)
Image shape:  (900, 1200, 3)
Image shape:  (900, 742, 3)
Image shape:  (800, 1200, 3)
Image shape:  (800, 1200, 3) 

以下图展示了示例图像:

仅凭这个可视化和打印的图像形状,我们已经可以看到图像具有不同的纵横比。如果你打印这些图像的纵横比(或数据数组形状),你会看到一些图像高 900 像素,宽 1200 像素(),有些是 ,还有一个是 。稍后,我们将把这些图像预处理为统一的尺寸。另一个需要考虑的点是,这些图像的标签包含在其文件名中。因此,我们将从文件名列表中提取这些标签,将标签 1 分配给狗,将标签 0 分配给猫:

>>> labels = [1 if 'dog' in os.path.basename(file) else 0
...           for file in file_list]
>>> print(labels)
[1, 0, 0, 0, 1, 1] 

现在,我们有两个列表:一个是文件名列表(或者每个图像的路径),另一个是它们的标签。在上一节中,你已经学会了从两个张量创建联合数据集的两种方法。这里我们将使用第二种方法,如下所示:

>>> ds_files_labels = tf.data.Dataset.from_tensor_slices(
...                                   (file_list, labels))
>>> for item in ds_files_labels:
...     print(item[0].numpy(), item[1].numpy())
b'cat_dog_images/dog-03.jpg' 1
b'cat_dog_images/cat-01.jpg' 0
b'cat_dog_images/cat-02.jpg' 0
b'cat_dog_images/cat-03.jpg' 0
b'cat_dog_images/dog-01.jpg' 1
b'cat_dog_images/dog-02.jpg' 1 

我们将这个数据集命名为 ds_files_labels,因为它包含文件名和标签。接下来,我们需要对这个数据集应用转换:从文件路径加载图像内容,解码原始内容,并调整到所需大小,例如,。之前,我们已经看过如何使用 .map() 方法应用一个 lambda 函数。然而,由于这次我们需要应用多个预处理步骤,我们将编写一个辅助函数,并在调用 .map() 方法时使用它:

>>> def load_and_preprocess(path, label):
...     image = tf.io.read_file(path)
...     image = tf.image.decode_jpeg(image, channels=3)
...     image = tf.image.resize(image, [img_height, img_width])
...     image /= 255.0
...     return image, label
>>> img_width, img_height = 120, 80
>>> ds_images_labels = ds_files_labels.map(load_and_preprocess)
>>>
>>> fig = plt.figure(figsize=(10, 6))
>>> for i,example in enumerate(ds_images_labels):
...     ax = fig.add_subplot(2, 3, i+1)
...     ax.set_xticks([]); ax.set_yticks([])
...     ax.imshow(example[0])
...     ax.set_title('{}'.format(example[1].numpy()),
...                  size=15)
>>> plt.tight_layout()
>>> plt.show() 

这会生成以下可视化效果,显示检索到的示例图像及其标签:

load_and_preprocess() 函数将所有四个步骤封装成一个函数,包括加载原始内容、解码和调整图像大小。该函数随后返回一个可以遍历的数据集,并可以应用我们在前面章节中学习的其他操作。

从 tensorflow_datasets 库中获取可用数据集

tensorflow_datasets 库提供了一系列免费可用的数据集,适用于训练或评估深度学习模型。这些数据集格式规范,并附带有信息性描述,包括特征和标签的格式、类型和维度,以及原始论文的引用,引用格式为 BibTeX。另外一个优点是,这些数据集已经准备好并可以作为 tf.data.Dataset 对象直接使用,因此我们在前面章节中介绍的所有函数都可以直接使用。那么,让我们看看如何在实际中使用这些数据集。

首先,我们需要通过命令行使用 pip 安装 tensorflow_datasets 库:

pip install tensorflow-datasets 

现在,让我们导入这个模块,看看可用数据集的列表:

>>> import tensorflow_datasets as tfds
>>> print(len(tfds.list_builders()))
101
>>> print(tfds.list_builders()[:5])
['abstract_reasoning', 'aflw2k3d', 'amazon_us_reviews', 'bair_robot_pushing_small', 'bigearthnet'] 

上面的代码表明,目前有 101 个可用数据集(截至写本章时有 101 个数据集,但这个数字可能会增加)——我们将前五个数据集打印到了命令行。有两种获取数据集的方式,我们将在接下来的段落中介绍,通过获取两个不同的数据集:CelebA(celeb_a)和 MNIST 数字数据集。

第一种方法包含三个步骤:

  1. 调用数据集构建函数

  2. 执行 download_and_prepare() 方法

  3. 调用 as_dataset() 方法

让我们从 CelebA 数据集的第一步开始,并打印出库中提供的相关描述:

>>> celeba_bldr = tfds.builder('celeb_a')
>>> print(celeba_bldr.info.features)
FeaturesDict({'image': Image(shape=(218, 178, 3), dtype=tf.uint8), 'landmarks': FeaturesDict({'lefteye_x': Tensor(shape=(), dtype=tf.int64), 'lefteye_y': Tensor(shape=(), dtype=tf.int64), 'righteye_x': Tensor(shape=(), dtype=tf.int64), 'righteye_y': ...
>>> print(celeba_bldr.info.features['image'])
Image(shape=(218, 178, 3), dtype=tf.uint8)
>>> print(celeba_bldr.info.features['attributes'].keys())
dict_keys(['5_o_Clock_Shadow', 'Arched_Eyebrows', ...
>>> print(celeba_bldr.info.citation)
@inproceedings{conf/iccv/LiuLWT15,
  added-at = {2018-10-09T00:00:00.000+0200},
  author = {Liu, Ziwei and Luo, Ping and Wang, Xiaogang and Tang, Xiaoou},
  biburl = {https://www.bibsonomy.org/bibtex/250e4959be61db325d2f02c1d8cd7bfbb/dblp},
  booktitle = {ICCV},
  crossref = {conf/iccv/2015},
  ee = {http://doi.ieeecomputersociety.org/10.1109/ICCV.2015.425},
  interhash = {3f735aaa11957e73914bbe2ca9d5e702},
  intrahash = {50e4959be61db325d2f02c1d8cd7bfbb},
  isbn = {978-1-4673-8391-2},
  keywords = {dblp},
  pages = {3730-3738},
  publisher = {IEEE Computer Society},
  timestamp = {2018-10-11T11:43:28.000+0200},
  title = {Deep Learning Face Attributes in the Wild.},
  url = {http://dblp.uni-trier.de/db/conf/iccv/iccv2015.html#LiuLWT15},
  year = 2015
} 

这提供了一些有用的信息,帮助我们理解该数据集的结构。特征以字典的形式存储,包含三个键:'image''landmarks''attributes'

'image'条目表示名人的面部图像;'landmarks'表示提取的面部特征点字典,例如眼睛、鼻子的位置信息等;'attributes'是该图像中人物的40个面部特征属性字典,如面部表情、化妆、发型特征等。

接下来,我们将调用 download_and_prepare() 方法。这将下载数据并将其存储在所有 TensorFlow 数据集的指定文件夹中。如果您已经执行过此操作,它只会检查数据是否已下载,以便在指定位置已经存在数据时不会重新下载:

>>> celeba_bldr.download_and_prepare() 

接下来,我们将按如下方式实例化数据集:

>>> datasets = celeba_bldr.as_dataset(shuffle_files=False)
>>> datasets.keys()
dict_keys(['test', 'train', 'validation']) 

该数据集已经被拆分为训练集、测试集和验证集。为了查看图像示例的样子,我们可以执行以下代码:

>>> ds_train = datasets['train']
>>> assert isinstance(ds_train, tf.data.Dataset)
>>> example = next(iter(ds_train))
>>> print(type(example))
<class 'dict'>
>>> print(example.keys())
dict_keys(['image', 'landmarks', 'attributes']) 

请注意,该数据集的元素以字典的形式出现。如果我们想在训练过程中将此数据集传递给一个监督学习的深度学习模型,我们必须将其重新格式化为 (features, label) 的元组。对于标签,我们将使用来自属性中的 'Male' 类别。我们将通过 map() 应用转换来完成这一操作:

>>> ds_train = ds_train.map(lambda item:
...                (item['image'],
...                tf.cast(item['attributes']['Male'], tf.int32))) 

最后,让我们对数据集进行批处理,并从中提取18个示例批次,以便使用其标签可视化它们:

>>> ds_train = ds_train.batch(18)
>>> images, labels = next(iter(ds_train))
>>> print(images.shape, labels)
(18, 218, 178, 3) tf.Tensor([0 0 0 1 1 1 0 1 1 0 1 1 0 1 0 1 1 1], shape=(18,), dtype=int32)
>>> fig = plt.figure(figsize=(12, 8))
>>> for i,(image,label) in enumerate(zip(images, labels)):
...     ax = fig.add_subplot(3, 6, i+1)
...     ax.set_xticks([]); ax.set_yticks([])
...     ax.imshow(image)
...     ax.set_title('{}'.format(label), size=15)
>>> plt.show() 

ds_train中提取的示例及其标签如下图所示:

这就是我们需要做的所有事情,以获取并使用 CelebA 图像数据集。

接下来,我们将继续使用第二种方法,从 tensorflow_datasets 获取数据集。有一个叫做 load() 的包装函数,它将获取数据集的三个步骤合并为一个。让我们看看它如何用于获取 MNIST 数字数据集:

>>> mnist, mnist_info = tfds.load('mnist', with_info=True,
...                               shuffle_files=False)
>>> print(mnist_info)
tfds.core.DatasetInfo(
    name='mnist',
    version=1.0.0,
    description='The MNIST database of handwritten digits.',
    urls=['https://storage.googleapis.com/cvdf-datasets/mnist/'],
    features=FeaturesDict({
        'image': Image(shape=(28, 28, 1), dtype=tf.uint8),
        'label': ClassLabel(shape=(), dtype=tf.int64, num_classes=10)
    },
    total_num_examples=70000,
    splits={
        'test': <tfds.core.SplitInfo num_examples=10000>,
        'train': <tfds.core.SplitInfo num_examples=60000>
    },
    supervised_keys=('image', 'label'),
    citation="""
        @article{lecun2010mnist,
          title={MNIST handwritten digit database},
          author={LeCun, Yann and Cortes, Corinna and Burges, CJ},
          journal={ATT Labs [Online]. Availablist},
          volume={2},
          year={2010}
        }

    """,
    redistribution_info=,
)
>>> print(mnist.keys())
dict_keys(['test', 'train']) 

如我们所见,MNIST 数据集被拆分为两个部分。现在,我们可以获取训练部分,应用转换将元素从字典转换为元组,并可视化 10 个示例:

>>> ds_train = mnist['train']
>>> ds_train = ds_train.map(lambda item:
...                         (item['image'], item['label']))
>>> ds_train = ds_train.batch(10)
>>> batch = next(iter(ds_train))
>>> print(batch[0].shape, batch[1])
(10, 28, 28, 1) tf.Tensor([8 4 7 7 0 9 0 3 3 3], shape=(10,), dtype=int64)
>>> fig = plt.figure(figsize=(15, 6))
>>> for i,(image,label) in enumerate(zip(batch[0], batch[1])):
...     ax = fig.add_subplot(2, 5, i+1)
...     ax.set_xticks([]); ax.set_yticks([])
...     ax.imshow(image[:, :, 0], cmap='gray_r')
...     ax.set_title('{}'.format(label), size=15)
>>> plt.show() 

从该数据集中提取的手写数字示例如下所示:

这就结束了我们关于构建和操作数据集以及从 tensorflow_datasets 库获取数据集的介绍。接下来,我们将学习如何在 TensorFlow 中构建神经网络模型。

TensorFlow 风格指南

请注意,官方 TensorFlow 风格指南(https://www.tensorflow.org/community/style_guide)建议使用两个字符的缩进。然而,本书采用四个字符的缩进,因为它与官方 Python 风格指南更加一致,并且有助于在许多文本编辑器中正确显示代码语法高亮,以及在https://github.com/rasbt/python-machine-learning-book-3rd-edition上的 Jupyter 代码笔记本。

在 TensorFlow 中构建神经网络模型

到目前为止,在本章中,你已经了解了 TensorFlow 中用于操作张量和将数据组织为可以在训练过程中迭代的格式的基本工具组件。在本节中,我们将最终在 TensorFlow 中实现我们的第一个预测模型。由于 TensorFlow 比如 scikit-learn 等机器学习库更具灵活性,但也更复杂,因此我们将从一个简单的线性回归模型开始。

TensorFlow Keras API(tf.keras)

Keras 是一个高级神经网络(NN)API,最初是为了在其他库(如 TensorFlow 和 Theano)之上运行而开发的。Keras 提供了一个用户友好且模块化的编程接口,允许用户在几行代码内轻松地进行原型设计并构建复杂的模型。Keras 可以独立安装于 PyPI 中,然后配置为使用 TensorFlow 作为其后端引擎。Keras 与 TensorFlow 紧密集成,其模块可以通过 tf.keras 进行访问。在 TensorFlow 2.0 中,tf.keras 已成为实现模型的主要且推荐的方式。这种方式的优点在于它支持 TensorFlow 特有的功能,比如使用 tf.data 构建数据集流水线,正如你在前一节中所学到的那样。在本书中,我们将使用 tf.keras 模块来构建神经网络模型。

正如你将在以下小节中看到的,Keras API(tf.keras)使得构建神经网络模型变得异常简单。构建神经网络的最常用方法是通过 tf.keras.Sequential(),该方法允许通过堆叠层来形成网络。层的堆叠可以通过将 Python 列表传递给定义为 tf.keras.Sequential() 的模型来实现。或者,也可以使用 .add() 方法逐一添加层。

此外,tf.keras 允许我们通过继承 tf.keras.Model 来定义模型。这使我们可以通过为模型类定义 call() 方法,明确地指定前向传播,从而对前向传播过程有更多的控制。我们将看到如何使用这两种方法,通过 tf.keras API 来构建神经网络模型的例子。

最后,正如你将在以下小节中看到的,通过 tf.keras API 构建的模型可以通过 .compile().fit() 方法进行编译和训练。

构建一个线性回归模型

在这一小节中,我们将构建一个简单的模型来解决线性回归问题。首先,让我们在 NumPy 中创建一个玩具数据集并进行可视化:

>>> X_train = np.arange(10).reshape((10, 1))
>>> y_train = np.array([1.0, 1.3, 3.1, 2.0, 5.0, 6.3,
...                     6.6, 7.4, 8.0, 9.0])
>>> plt.plot(X_train, y_train, 'o', markersize=10)
>>> plt.xlabel('x')
>>> plt.ylabel('y')
>>> plt.show() 

结果是,训练样本将显示在如下所示的散点图中:

接下来,我们将对特征进行标准化(均值中心化并除以标准差),并创建一个 TensorFlow Dataset

>>> X_train_norm = (X_train - np.mean(X_train))/np.std(X_train)
>>> ds_train_orig = tf.data.Dataset.from_tensor_slices(
...                   (tf.cast(X_train_norm, tf.float32),
...                    tf.cast(y_train, tf.float32))) 

现在,我们可以为线性回归定义模型,如下图所示:。在这里,我们将使用 Keras API。tf.keras 提供了用于构建复杂神经网络模型的预定义层,但为了开始,你将学习如何从头开始定义模型。在本章稍后的部分,你将看到如何使用那些预定义层。

对于这个回归问题,我们将定义一个新的类,继承自tf.keras.Model类。通过子类化tf.keras.Model,我们可以使用Keras工具来探索模型、训练和评估。在我们类的构造函数中,我们将定义模型的参数wb,它们分别对应权重和偏置参数。最后,我们将定义call()方法,以确定模型如何使用输入数据生成输出:

>>> class MyModel(tf.keras.Model):
...     def __init__(self):
...         super(MyModel, self).__init__()
...         self.w = tf.Variable(0.0, name='weight')
...         self.b = tf.Variable(0.0, name='bias')
...
...     def call(self, x):
...         return self.w * x + self.b 

接下来,我们将从MyModel()类实例化一个新模型,我们可以根据训练数据对其进行训练。TensorFlow Keras API提供了一个名为.summary()的方法,用于从tf.keras.Model实例化的模型,这允许我们逐层查看模型组件的摘要以及每层的参数数量。由于我们已经将模型从tf.keras.Model子类化,因此.summary()方法对我们也是可用的。但是,为了能够调用model.summary(),我们首先需要指定此模型的输入维度(特征数量)。我们可以通过调用model.build()并传递期望的输入数据形状来做到这一点:

>>> model = MyModel()
>>> model.build(input_shape=(None, 1))
>>> model.summary()
Model: "my_model"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
Total params: 2
Trainable params: 2
Non-trainable params: 0
_________________________________________________________________ 

请注意,我们通过model.build()None用作期望输入张量的第一个维度的占位符,这使得我们可以使用任意批次大小。然而,特征数量是固定的(这里为1),因为它直接对应于模型的权重参数数量。通过调用.build()方法在实例化后构建模型层和参数被称为延迟变量创建。对于这个简单的模型,我们已经在构造函数中创建了模型参数;因此,通过build()指定input_shape对我们的参数没有进一步的影响,但如果我们想调用model.summary(),仍然是必要的。

定义模型后,我们可以定义我们想要最小化的代价函数,以找到最优的模型权重。在这里,我们将选择均方误差MSE)作为我们的代价函数。此外,为了学习模型的权重参数,我们将使用随机梯度下降。在这一小节中,我们将通过随机梯度下降过程自己实现这个训练,但在下一小节中,我们将使用Keras方法compile()fit()来做相同的事情。

要实现随机梯度下降算法,我们需要计算梯度。我们将使用TensorFlow API中的tf.GradientTape来计算梯度,而不是手动计算梯度。我们将在第14章深入探讨——TensorFlow的原理中讲解tf.GradientTape及其不同的行为。代码如下:

>>> def loss_fn(y_true, y_pred):
...     return tf.reduce_mean(tf.square(y_true - y_pred))
>>> def train(model, inputs, outputs, learning_rate):
...     with tf.GradientTape() as tape:
...         current_loss = loss_fn(model(inputs), outputs)
...     dW, db = tape.gradient(current_loss, [model.w, model.b])
...     model.w.assign_sub(learning_rate * dW)
...     model.b.assign_sub(learning_rate * db) 

现在,我们可以设置超参数并训练模型200个周期。我们将创建一个批处理版本的数据集,并通过count=None重复数据集,这将导致数据集无限重复:

>>> tf.random.set_seed(1)
>>> num_epochs = 200
>>> log_steps = 100
>>> learning_rate = 0.001
>>> batch_size = 1
>>> steps_per_epoch = int(np.ceil(len(y_train) / batch_size))
>>> ds_train = ds_train_orig.shuffle(buffer_size=len(y_train))
>>> ds_train = ds_train.repeat(count=None)
>>> ds_train = ds_train.batch(1)
>>> Ws, bs = [], []
>>> for i, batch in enumerate(ds_train):
...     if i >= steps_per_epoch * num_epochs:
...         # break the infinite loop
...         break
...     Ws.append(model.w.numpy())
...     bs.append(model.b.numpy())
...
...     bx, by = batch
...     loss_val = loss_fn(model(bx), by)
...
...     train(model, bx, by, learning_rate=learning_rate)
...     if i%log_steps==0:
...         print('Epoch {:4d} Step {:2d} Loss {:6.4f}'.format(
...               int(i/steps_per_epoch), i, loss_val))
Epoch    0 Step  0 Loss 43.5600
Epoch   10 Step 100 Loss 0.7530
Epoch   20 Step 200 Loss 20.1759
Epoch   30 Step 300 Loss 23.3976
Epoch   40 Step 400 Loss 6.3481
Epoch   50 Step 500 Loss 4.6356
Epoch   60 Step 600 Loss 0.2411
Epoch   70 Step 700 Loss 0.2036
Epoch   80 Step 800 Loss 3.8177
Epoch   90 Step 900 Loss 0.9416
Epoch  100 Step 1000 Loss 0.7035
Epoch  110 Step 1100 Loss 0.0348
Epoch  120 Step 1200 Loss 0.5404
Epoch  130 Step 1300 Loss 0.1170
Epoch  140 Step 1400 Loss 0.1195
Epoch  150 Step 1500 Loss 0.0944
Epoch  160 Step 1600 Loss 0.4670
Epoch  170 Step 1700 Loss 2.0695
Epoch  180 Step 1800 Loss 0.0020
Epoch  190 Step 1900 Loss 0.3612 

让我们查看训练后的模型并对其进行绘制。对于测试数据,我们将创建一个 NumPy 数组,包含从 0 到 9 之间均匀间隔的值。由于我们用标准化特征训练了模型,因此我们也会对测试数据应用相同的标准化:

>>> print('Final Parameters: ', model.w.numpy(), model.b.numpy())
Final Parameters:  2.6576622 4.8798566
>>> X_test = np.linspace(0, 9, num=100).reshape(-1, 1)
>>> X_test_norm = (X_test - np.mean(X_train)) / np.std(X_train)
>>> y_pred = model(tf.cast(X_test_norm, dtype=tf.float32))
>>> fig = plt.figure(figsize=(13, 5))
>>> ax = fig.add_subplot(1, 2, 1)
>>> plt.plot(X_train_norm, y_train, 'o', markersize=10)
>>> plt.plot(X_test_norm, y_pred, '--', lw=3)
>>> plt.legend(['Training examples', 'Linear Reg.'], fontsize=15)
>>> ax.set_xlabel('x', size=15)
>>> ax.set_ylabel('y', size=15)
>>> ax.tick_params(axis='both', which='major', labelsize=15)
>>> ax = fig.add_subplot(1, 2, 2)
>>> plt.plot(Ws, lw=3)
>>> plt.plot(bs, lw=3)
>>> plt.legend(['Weight w', 'Bias unit b'], fontsize=15)
>>> ax.set_xlabel('Iteration', size=15)
>>> ax.set_ylabel('Value', size=15)
>>> ax.tick_params(axis='both', which='major', labelsize=15)
>>> plt.show() 

下图展示了训练样本的散点图以及训练后的线性回归模型,同时还展示了权重 w 和偏置单位 b 的收敛历史:

使用 .compile() 和 .fit() 方法训练模型

在前一个示例中,我们看到了如何通过编写自定义函数 train() 来训练模型,并应用随机梯度下降优化方法。然而,编写 train() 函数在不同项目中可能是一个重复的任务。TensorFlow Keras API 提供了一个便捷的 .fit() 方法,可以在实例化的模型上调用。为了展示这一点,让我们创建一个新模型并通过选择优化器、损失函数和评估指标来编译它:

>>> tf.random.set_seed(1)
>>> model = MyModel()
>>> model.compile(optimizer='sgd',
...               loss=loss_fn,
...               metrics=['mae', 'mse']) 

现在,我们可以直接调用 fit() 方法来训练模型。我们可以传入一个批量数据集(例如在前一个示例中创建的 ds_train)。然而,这一次你会看到,我们可以直接传入 NumPy 数组作为 xy,无需再创建数据集:

>>> model.fit(X_train_norm, y_train,
...           epochs=num_epochs, batch_size=batch_size,
...           verbose=1)
Train on 10 samples
Epoch 1/200
10/10 [==============================] - 0s 4ms/sample - loss: 27.8578 - mae: 4.5810 - mse: 27.8578
Epoch 2/200
10/10 [==============================] - 0s 738us/sample - loss: 18.6640 - mae: 3.7395 - mse: 18.6640
...
Epoch 200/200
10/10 [==============================] - 0s 1ms/sample - loss: 0.4139 - mae: 0.4942 - mse: 0.4139 

模型训练完成后,进行可视化并确保结果与前一种方法的结果相似。

构建一个多层感知机,用于分类 Iris 数据集中的花卉

在前一个示例中,你看到了如何从零开始构建一个模型。我们使用随机梯度下降优化方法训练了这个模型。虽然我们从最简单的例子入手,但你可以看到,即使是如此简单的情况,完全从零定义模型既不吸引人也不具备良好的实践性。相反,TensorFlow 提供了通过 tf.keras.layers 预定义的层,可以直接作为神经网络模型的构建模块。在这一部分,你将学习如何使用这些层来解决 Iris 花卉数据集的分类任务,并使用 Keras API 构建一个两层感知机。首先,让我们从 tensorflow_datasets 获取数据:

>>> iris, iris_info = tfds.load('iris', with_info=True)
>>> print(iris_info) 

这会打印一些关于数据集的信息(为节省空间,本文未打印)。然而,你会在显示的信息中注意到该数据集只有一个分区,因此我们必须自行将数据集拆分为训练集和测试集(并且为符合机器学习的最佳实践,也需要有验证集)。假设我们希望使用数据集的三分之二进行训练,其余部分用于测试。tensorflow_datasets 库提供了一个便捷的工具,允许我们在加载数据集之前通过 DatasetBuilder 对象来确定数据集的切片和拆分。你可以在 https://www.tensorflow.org/datasets/splits 了解更多有关数据拆分的信息。

另一种方法是先加载整个数据集,然后使用 .take().skip() 将数据集分成两个部分。如果数据集开始时没有进行洗牌,我们也可以对数据集进行洗牌。然而,我们需要非常小心,因为这可能导致训练集和测试集混合,这在机器学习中是不可接受的。为了避免这种情况,我们必须在 .shuffle() 方法中设置一个参数,reshuffle_each_iteration=False。将数据集拆分为训练集/测试集的代码如下:

>>> tf.random.set_seed(1)
>>> ds_orig = iris['train']
>>> ds_orig = ds_orig.shuffle(150, reshuffle_each_iteration=False)
>>> ds_train_orig = ds_orig.take(100)
>>> ds_test = ds_orig.skip(100) 

接下来,正如你在前面的部分中已经看到的,我们需要通过 .map() 方法应用一个转换,将字典转换为元组:

>>> ds_train_orig = ds_train_orig.map(
...     lambda x: (x['features'], x['label']))
>>> ds_test = ds_test.map(
...     lambda x: (x['features'], x['label'])) 

现在,我们已经准备好使用 Keras API 高效地构建模型。特别是,使用 tf.keras.Sequential 类,我们可以堆叠一些 Keras 层并构建一个神经网络。你可以在https://www.tensorflow.org/versions/r2.0/api_docs/python/tf/keras/layers查看所有已存在的 Keras 层的列表。对于这个问题,我们将使用 Dense 层(tf.keras.layers.Dense),它也被称为全连接(FC)层或线性层,可以通过 最好地表示,其中 x 是输入特征,wb 是权重矩阵和偏置向量,f 是激活函数。

如果你考虑神经网络中的每一层,每一层都会从前一层接收输入;因此,它的维度(秩和形状)是固定的。通常,我们只在设计神经网络架构时才需要关心输出的维度。(注意:第一层是例外,但 TensorFlow/Keras 允许我们在定义模型后通过延迟变量创建来决定第一层的输入维度。)在这里,我们希望定义一个包含两层隐藏层的模型。第一层接收四个特征的输入,并将其投影到16个神经元上。第二层接收前一层的输出(其大小为16),并将其投影到三个输出神经元上,因为我们有三个类别标签。这可以通过 Keras 中的 Sequential 类和 Dense 层来实现,如下所示:

>>> iris_model = tf.keras.Sequential([
...         tf.keras.layers.Dense(16, activation='sigmoid',
...                               name='fc1', input_shape=(4,)),
...         tf.keras.layers.Dense(3, name='fc2',
...                               activation='softmax')])
>>> iris_model.summary()
Model: "sequential"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
fc1 (Dense)                  (None, 16)                80        
_________________________________________________________________
fc2 (Dense)                  (None, 3)                 51        
=================================================================
Total params: 131
Trainable params: 131
Non-trainable params: 0
_________________________________________________________________ 

请注意,我们通过 input_shape=(4,) 确定了第一层的输入形状,因此我们不再需要调用 .build() 来使用 iris_model.summary()

打印的模型摘要表明,第一个层(fc1)有80个参数,第二个层有51个参数。你可以通过查看来验证这一点,其中是输入单元的数量,是输出单元的数量。回想一下,对于全连接层(密集连接层),可学习的参数是大小为的权重矩阵和大小为的偏置向量。此外,请注意,我们为第一个层使用了sigmoid激活函数,并为最后一层(输出层)使用了softmax激活函数。最后一层的softmax激活函数用于支持多类分类,因为在这里我们有三个类别标签(这也是为什么输出层有三个神经元)。我们将在本章稍后讨论不同的激活函数及其应用。

接下来,我们将编译此模型以指定损失函数、优化器和评估指标:

>>> iris_model.compile(optimizer='adam',
...                    loss='sparse_categorical_crossentropy',
...                    metrics=['accuracy']) 

现在,我们可以训练模型。我们将指定训练的epoch数量为100,batch大小为2。在以下代码中,我们将构建一个无限重复的数据集,并将其传递给fit()方法来训练模型。在这种情况下,为了让fit()方法能够跟踪epoch数量,它需要知道每个epoch的步数。

根据我们的训练数据的大小(这里是100)和batch大小(batch_size),我们可以确定每个epoch中的步数steps_per_epoch

>>> num_epochs = 100
>>> training_size = 100
>>> batch_size = 2
>>> steps_per_epoch = np.ceil(training_size / batch_size)
>>> ds_train = ds_train_orig.shuffle(buffer_size=training_size)
>>> ds_train = ds_train.repeat()
>>> ds_train = ds_train.batch(batch_size=batch_size)
>>> ds_train = ds_train.prefetch(buffer_size=1000)
>>> history = iris_model.fit(ds_train, epochs=num_epochs,
...                          steps_per_epoch=steps_per_epoch,
...                          verbose=0) 

返回的变量history保存了每个epoch之后的训练损失和训练准确度(因为它们在iris_model.compile()中被指定为评估指标)。我们可以使用它来可视化学习曲线,如下所示:

>>> hist = history.history
>>> fig = plt.figure(figsize=(12, 5))
>>> ax = fig.add_subplot(1, 2, 1)
>>> ax.plot(hist['loss'], lw=3)
>>> ax.set_title('Training loss', size=15)
>>> ax.set_xlabel('Epoch', size=15)
>>> ax.tick_params(axis='both', which='major', labelsize=15)
>>> ax = fig.add_subplot(1, 2, 2)
>>> ax.plot(hist['accuracy'], lw=3)
>>> ax.set_title('Training accuracy', size=15)
>>> ax.set_xlabel('Epoch', size=15)
>>> ax.tick_params(axis='both', which='major', labelsize=15)
>>> plt.show() 

学习曲线(训练损失和训练准确度)如下:

在测试数据集上评估训练过的模型

由于我们在iris_model.compile()中指定了'accuracy'作为评估指标,现在我们可以直接在测试数据集上评估模型:

>>> results = iris_model.evaluate(ds_test.batch(50), verbose=0)
>>> print('Test loss: {:.4f}   Test Acc.: {:.4f}'.format(*results))
Test loss: 0.0692   Test Acc.: 0.9800 

请注意,我们也需要对测试数据集进行batch处理,以确保输入到模型的数据具有正确的维度(秩)。正如我们之前讨论的,调用.batch()会将获取的张量的秩增加1。.evaluate()方法的输入数据必须具有指定的批次维度,尽管在这里(评估时)批次大小并不重要。因此,如果我们将ds_batch.batch(50)传递给.evaluate()方法,整个测试数据集将在一个大小为50的批次中处理,但如果我们传递ds_batch.batch(1),则会处理50个大小为1的批次。

保存和重新加载训练好的模型

训练好的模型可以保存到磁盘以供将来使用。可以通过以下方式进行保存:

>>> iris_model.save('iris-classifier.h5',
...            overwrite=True,
...            include_optimizer=True,
...            save_format='h5') 

第一个选项是文件名。调用iris_model.save()将保存模型架构和所有学习到的参数。然而,如果你只想保存架构,可以使用iris_model.to_json()方法,这将以JSON格式保存模型配置。如果你只想保存模型权重,可以通过调用iris_model.save_weights()来实现。save_format可以指定为'h5'(HDF5格式)或'tf'(TensorFlow格式)。

现在,让我们重新加载已保存的模型。由于我们已经保存了模型架构和权重,我们可以通过一行代码轻松重建和重新加载参数:

>>> iris_model_new = tf.keras.models.load_model('iris-classifier.h5') 

尝试通过调用iris_model_new.summary()来验证模型架构。

最后,让我们评估这个新模型,并在测试数据集上重新加载它,以验证结果是否与之前相同:

>>> results = iris_model_new.evaluate(ds_test.batch(33), verbose=0)
>>> print('Test loss: {:.4f}   Test Acc.: {:.4f}'.format(*results))
Test loss: 0.0692   Test Acc.: 0.9800 

为多层神经网络选择激活函数

为了简便起见,我们到目前为止只讨论了在多层前馈神经网络中的sigmoid激活函数;我们在第12章中的多层感知机实现中,既在隐藏层也在输出层使用了它(从头开始实现多层人工神经网络)。

请注意,在本书中,S形逻辑函数()为了简洁起见被称为sigmoid函数,这在机器学习文献中是很常见的术语。在接下来的各小节中,您将了解更多关于实现多层神经网络(NN)时有用的其他非线性函数。

从技术上讲,只要激活函数是可微分的,我们可以在多层神经网络中使用任何函数。我们甚至可以使用线性激活函数,比如在Adaline中(第2章训练简单的机器学习算法进行分类)。然而,实际上,如果我们在隐藏层和输出层都使用线性激活函数,这并不是很有用,因为在典型的人工神经网络中,我们希望引入非线性,以便能够处理复杂的问题。毕竟,线性函数的和仍然是线性函数。

我们在第12章中使用的逻辑(sigmoid)激活函数可能最接近模仿大脑中神经元的概念——我们可以将其视为神经元是否发火的概率。然而,如果输入值非常负,逻辑(sigmoid)激活函数可能会出现问题,因为此时sigmoid函数的输出将接近零。如果sigmoid函数返回接近零的输出,神经网络的学习速度将非常慢,并且在训练过程中更容易陷入局部最小值。这就是为什么人们通常更倾向于在隐藏层使用双曲正切作为激活函数的原因。

在我们讨论双曲正切的具体形态之前,让我们简要回顾一下逻辑函数的一些基本内容,并看看它的推广形式,看看它如何在多标签分类问题中更有用。

逻辑函数回顾

正如本节介绍中所提到的,Logistic函数实际上是Sigmoid函数的一个特例。你会从 第3章,《使用scikit-learn的机器学习分类器巡礼》中关于Logistic回归的部分回忆起,我们可以使用Logistic函数来建模样本 x 在二分类任务中属于正类(类别 1)的概率。

给定的净输入 z 如下方公式所示:

Logistic(Sigmoid)函数将计算以下内容:

注意,是偏置单元(y-轴截距,这意味着)。为了提供一个更具体的例子,假设我们有一个二维数据点 x 的模型,并且该模型分配给 w 向量以下的权重系数:

>>> import numpy as np
>>> X = np.array([1, 1.4, 2.5]) ## first value must be 1
>>> w = np.array([0.4, 0.3, 0.5])
>>> def net_input(X, w):
...     return np.dot(X, w)
>>> def logistic(z):
...     return 1.0 / (1.0 + np.exp(-z))
>>> def logistic_activation(X, w):
...     z = net_input(X, w)
...     return logistic(z)
>>> print(‘P(y=1|x) = %.3f’ % logistic_activation(X, w))
P(y=1|x) = 0.888 

如果我们计算净输入(z)并用它来激活具有这些特定特征值和权重系数的Logistic神经元,我们得到一个0.888的值,我们可以将其解释为该特定样本 x 属于正类的88.8%概率。

第12章 中,我们使用了独热编码技术来表示多类的真实标签,并设计了由多个Logistic激活单元组成的输出层。然而,正如以下代码示例所演示的,包含多个Logistic激活单元的输出层并没有生成有意义的、可解释的概率值:

>>> # W : array with shape = (n_output_units, n_hidden_units+1)
>>> #     note that the first column are the bias units
>>> W = np.array([[1.1, 1.2, 0.8, 0.4],
...               [0.2, 0.4, 1.0, 0.2],
...               [0.6, 1.5, 1.2, 0.7]])
>>> # A : data array with shape = (n_hidden_units + 1, n_samples)
>>> #     note that the first column of this array must be 1
>>> A = np.array([[1, 0.1, 0.4, 0.6]])
>>> Z = np.dot(W, A[0])
>>> y_probas = logistic(Z)
>>> print(‘Net Input: \n’, Z)
Net Input:
[ 1.78  0.76  1.65]
>>> print(‘Output Units:\n’, y_probas)
Output Units:
[ 0.85569687  0.68135373  0.83889105] 

如你在输出中所看到的,得到的值不能解释为三类问题的概率。原因是这些值的总和不等于1。然而,如果我们仅使用我们的模型来预测类别标签,而不是类别成员概率,这实际上并不是一个大问题。从先前得到的输出单元中预测类别标签的一种方法是使用最大值:

>>> y_class = np.argmax(Z, axis=0)
>>> print(‘Predicted class label: %d’ % y_class)
Predicted class label: 0 

在某些情况下,计算多类预测的有意义类别概率是有用的。在接下来的部分,我们将介绍Logistic函数的推广版,即softmax函数,它可以帮助我们完成这一任务。

通过softmax函数估计多类分类中的类别概率

在上一部分,你看到我们如何使用argmax函数获得类别标签。之前,在《为鸢尾花数据集构建多层感知机进行分类》部分中,我们确定了MLP模型最后一层的activation='softmax'softmax函数是argmax函数的一种软形式;它不是给出一个单一的类别索引,而是提供每个类别的概率。因此,它允许我们在多类设置中计算有意义的类别概率(多项式Logistic回归)。

softmax中,某个样本的净输入z属于第i类的概率可以通过分母中的归一化项计算,即所有指数加权线性函数的和:

要查看softmax的实际应用,我们可以用Python来实现:

>>> def softmax(z):
...     return np.exp(z) / np.sum(np.exp(z))
>>> y_probas = softmax(Z)
>>> print(‘Probabilities:\n’, y_probas)
Probabilities:
[ 0.44668973  0.16107406  0.39223621]
>>> np.sum(y_probas)
1.0 

如您所见,预测的类别概率现在加起来为1,这正是我们所期望的。值得注意的是,预测的类别标签与我们对逻辑输出应用argmax函数时得到的结果相同。

可以将softmax函数的结果看作是一个归一化输出,这对于在多分类设置中获取有意义的类别成员预测非常有用。因此,当我们在TensorFlow中构建多分类模型时,我们可以使用tf.keras.activations.softmax()函数来估计输入批次样本每个类别的概率。为了展示如何在TensorFlow中使用softmax激活函数,在以下代码中,我们将Z转换为张量,并为批次大小预留额外的维度:

>>> import tensorflow as tf
>>> Z_tensor = tf.expand_dims(Z, axis=0)
>>> tf.keras.activations.softmax(Z_tensor)
<tf.Tensor: id=21, shape=(1, 3), dtype=float64, numpy=array([[0.44668973, 0.16107406, 0.39223621]])> 

使用双曲正切扩展输出谱

另一个常用于人工神经网络隐藏层的S形函数是双曲正切(通常称为tanh),它可以被解释为逻辑函数的缩放版本:

双曲正切函数相较于逻辑函数的优势在于,它有一个更广泛的输出谱,范围在开区间(–1, 1)内,这可以改善反向传播算法的收敛性(模式识别中的神经网络C. M. Bishop牛津大学出版社,第500-501页,1995)。

相比之下,逻辑函数的输出信号范围在开区间(0, 1)内。为了简单对比逻辑函数和双曲正切函数,我们可以绘制这两个S形函数:

>>> import matplotlib.pyplot as plt
>>> def tanh(z):
...     e_p = np.exp(z)
...     e_m = np.exp(-z)
...     return (e_p - e_m) / (e_p + e_m)
>>> z = np.arange(-5, 5, 0.005)
>>> log_act = logistic(z)
>>> tanh_act = tanh(z)
>>> plt.ylim([-1.5, 1.5])
>>> plt.xlabel(‘net input $z$’)
>>> plt.ylabel(‘activation $\phi(z)$’)
>>> plt.axhline(1, color=’black’, linestyle=’:’)
>>> plt.axhline(0.5, color=’black’, linestyle=’:’)
>>> plt.axhline(0, color=’black’, linestyle=’:’)
>>> plt.axhline(-0.5, color=’black’, linestyle=’:’)
>>> plt.axhline(-1, color=’black’, linestyle=’:’)
>>> plt.plot(z, tanh_act,
...          linewidth=3, linestyle=’--’,
...          label=’tanh’)
>>> plt.plot(z, log_act,
...          linewidth=3,
...          label=’logistic’)
>>> plt.legend(loc=’lower right’)
>>> plt.tight_layout()
>>> plt.show() 

如您所见,这两个S形曲线的形状非常相似;然而,tanh函数的输出空间是logistic函数的两倍:

请注意,我们之前为了演示目的详细实现了logistictanh函数。实际上,我们可以使用NumPy的tanh函数。

或者,在构建神经网络模型时,我们可以在TensorFlow中使用tf.keras.activations.tanh()来实现相同的效果:

>>> np.tanh(z)
array([-0.9999092 , -0.99990829, -0.99990737, ...,  0.99990644,
        0.99990737,  0.99990829])
>>> tf.keras.activations.tanh(z)
<tf.Tensor: id=14, shape=(2000,), dtype=float64, numpy=
array([-0.9999092 , -0.99990829, -0.99990737, ...,  0.99990644,
        0.99990737,  0.99990829])> 

此外,逻辑函数可以在SciPy的special模块中找到:

>>> from scipy.special import expit
>>> expit(z)
array([0.00669285, 0.00672617, 0.00675966, ..., 0.99320669, 0.99324034,
       0.99327383]) 

类似地,我们可以在TensorFlow中使用tf.keras.activations.sigmoid()函数进行相同的计算,代码如下:

>>> tf.keras.activations.sigmoid(z)
<tf.Tensor: id=16, shape=(2000,), dtype=float64, numpy=
array([0.00669285, 0.00672617, 0.00675966, ..., 0.99320669, 0.99324034,
       0.99327383])> 

整流线性单元激活

整流线性单元ReLU)是另一个常用于深度神经网络中的激活函数。在深入研究ReLU之前,我们需要回顾一下tanh和逻辑激活函数的梯度消失问题。

为了理解这个问题,我们假设最初我们有净输入 ,它变化为 。计算 tanh 激活函数后,我们得到 ,显示输出没有变化(由于 tanh 函数的渐近行为和数值误差)。

这意味着,当 z 变得很大时,激活函数对净输入的导数会减小。因此,在训练阶段,学习权重变得非常缓慢,因为梯度项可能非常接近零。ReLU 激活函数解决了这个问题。从数学上讲,ReLU 定义如下:

ReLU 仍然是一个非线性函数,适合用神经网络学习复杂的函数。除此之外,ReLU 对其输入的导数对于正输入值始终为 1。因此,它解决了梯度消失的问题,使其适合深度神经网络。在 TensorFlow 中,我们可以如下应用 ReLU 激活函数:

>>> tf.keras.activations.tanh(z)
<tf.Tensor: id=23, shape=(2000,), dtype=float64, numpy=array([0\.   , 0\.   , 0\.   , ..., 4.985, 4.99 , 4.995])> 

在下一章中,我们将使用 ReLU 激活函数作为多层卷积神经网络的激活函数。

现在我们了解了更多关于常用的人工神经网络激活函数,让我们通过概述本书中遇到的不同激活函数来总结这一部分:

你可以在 https://www.tensorflow.org/versions/r2.0/api_docs/python/tf/keras/activations 查找 Keras API 中所有可用的激活函数列表。

总结

在本章中,你学习了如何使用 TensorFlow —— 一个开源的数值计算库,特别关注深度学习。虽然 TensorFlow 比 NumPy 更不方便使用,因为它需要额外的复杂性来支持 GPU,但它允许我们非常高效地定义和训练大型多层神经网络。

此外,你还学习了如何使用 TensorFlow Keras API 来构建复杂的机器学习和神经网络模型并高效运行它们。我们通过继承 tf.keras.Model 类从零开始定义模型,探索了 TensorFlow 中的模型构建。当我们必须在矩阵-向量乘法的层面编程并定义每个操作的细节时,模型的实现可能会很繁琐。然而,优点是,这使得我们作为开发人员能够将这些基础操作结合起来,构建更复杂的模型。接着,我们探索了 tf.keras.layers,它使得构建神经网络模型比从头实现它们要容易得多。

最后,你学习了不同的激活函数,并理解了它们的行为和应用。具体来说,在本章中,我们讨论了 tanh、softmax 和 ReLU。

在下一章,我们将继续我们的旅程,深入探讨 TensorFlow,在那里我们将与 TensorFlow 函数装饰器和 TensorFlow Estimators 一起工作。在这个过程中,你将学习到许多新概念,例如变量和特征列。

第十四章:深入探讨 – TensorFlow 的机制

第 13 章 中,使用 TensorFlow 并行化神经网络训练,我们介绍了如何定义和操作张量,并使用 tf.data API 构建输入流水线。我们进一步使用 TensorFlow Keras API (tf.keras) 构建和训练了一个多层感知器,以对鸢尾花数据集进行分类。

现在我们已经通过 TensorFlow 神经网络(NN)训练和机器学习有了一些实际经验,是时候深入研究 TensorFlow 库并探索其丰富的功能集了,这将使我们能够在接下来的章节中实现更高级的深度学习模型。

在本章中,我们将使用 TensorFlow API 的不同方面来实现 NNs。特别是,我们将再次使用 Keras API,它提供了多层抽象,使得实现标准架构非常方便。TensorFlow 还允许我们实现自定义 NN 层,这在需要更多定制的研究项目中非常有用。本章后面,我们将实现这样一个自定义层。

为了说明使用 Keras API 构建模型的不同方式,我们还将考虑经典的异或XOR)问题。首先,我们将使用 Sequential 类构建多层感知器。然后,我们将考虑其他方法,如使用 tf.keras.Model 的子类化定义自定义层。最后,我们将介绍 tf.estimator,这是一个高级 TensorFlow API,将从原始输入到预测的机器学习步骤封装起来。

我们将涵盖的主题如下:

  • 理解和使用 TensorFlow 图以及迁移到 TensorFlow v2

  • 图编译的函数装饰

  • 使用 TensorFlow 变量

  • 解决经典的 XOR 问题并理解模型容量

  • 使用 Keras 的 Model 类和 Keras 函数式 API 构建复杂的 NN 模型

  • 使用自动微分和 tf.GradientTape 计算梯度

  • 使用 TensorFlow Estimators

TensorFlow 的关键特性

TensorFlow 为我们提供了一个可扩展的、多平台的编程接口,用于实现和运行机器学习算法。自 2017 年的 1.0 版本发布以来,TensorFlow API 相对稳定和成熟,但在 2019 年的最新 2.0 版本中经历了重大重新设计,本书中我们使用的就是这个版本。

自 2015 年首次发布以来,TensorFlow 已成为最广泛采用的深度学习库。然而,它的一个主要痛点是它是围绕静态计算图构建的。静态计算图具有某些优势,如更好的图优化和支持更广泛的硬件设备;但是,静态计算图需要单独的图声明和图评估步骤,这使得用户难以与 NNs 进行互动式开发和工作。

考虑到所有用户的反馈,TensorFlow 团队决定在 TensorFlow 2.0 中将动态计算图作为默认选项,这使得神经网络的开发和训练更加便捷。在接下来的章节中,我们将介绍从 TensorFlow v1.x 到 v2 的一些重要变化。动态计算图使得图的声明和评估步骤能够交替进行,这使得 TensorFlow 2.0 对于 Python 和 NumPy 用户来说,比起以前的版本更加自然。不过,请注意,TensorFlow 2.0 仍然允许用户通过 tf.compat 子模块使用 "旧版" TensorFlow v1.x API。这有助于用户更顺利地将代码库过渡到新的 TensorFlow v2 API。

TensorFlow 的一个关键特性,第13章中也提到过,使用 TensorFlow 并行化神经网络训练,是它能与单个或多个图形处理单元(GPU)协同工作。这使得用户能够在大型数据集和大规模系统上高效地训练深度学习模型。

虽然 TensorFlow 是一个开源库,任何人都可以自由使用,但其开发由 Google 提供资金支持,并且有一个庞大的软件工程团队在持续扩展和改进该库。由于 TensorFlow 是开源的,它还得到了来自 Google 以外的其他开发者的强大支持,他们热心地贡献代码并提供用户反馈。

这使得 TensorFlow 库对学术研究人员和开发人员都更加有用。这些因素的进一步影响是,TensorFlow 拥有丰富的文档和教程,帮助新用户上手。

最后但同样重要的是,TensorFlow 支持移动端部署,这使得它成为一个非常适合生产环境的工具。

TensorFlow 的计算图:迁移到 TensorFlow v2

TensorFlow 基于有向无环图(DAG)进行计算。在 TensorFlow v1.x 中,这些图可以在低级 API 中显式定义,尽管对于大型和复杂的模型来说,这并非易事。在这一节中,我们将看到如何为简单的算术计算定义这些图。然后,我们将了解如何将图迁移到 TensorFlow v2,即时执行和动态图范式,以及如何通过函数装饰器加速计算。

理解计算图

TensorFlow 核心依赖于构建计算图,它利用这个计算图从输入到输出推导张量之间的关系。假设我们有 0 阶(标量)张量 abc,并且我们希望计算 。这个计算可以表示为一个计算图,如下图所示:

如您所见,计算图实际上是一个节点网络。每个节点类似于一个操作,它将一个或多个输入张量应用一个函数,并返回一个或多个输出张量。TensorFlow 构建这个计算图并使用它来相应地计算梯度。在接下来的小节中,我们将看到使用 TensorFlow v1.x 和 v2 风格创建计算图的一些示例。

在 TensorFlow v1.x 中创建图

在 TensorFlow 较早版本(v1.x)中的低级 API 中,这个图必须显式声明。构建、编译和评估这种计算图的各个步骤如下所示:

  1. 实例化一个新的空计算图

  2. 向计算图中添加节点(张量和操作)

  3. 评估(执行)图:

    1. 启动一个新的 session

    2. 初始化图中的变量

    3. 在此 session 中运行计算图

在我们研究 TensorFlow v2 中的动态方法之前,让我们先看一个简单的例子,说明如何在 TensorFlow v1.x 中创建一个图来评估上图所示的 。变量 abc 是标量(单个数字),我们将这些定义为 TensorFlow 常量。然后可以通过调用 tf.Graph() 创建一个图。变量和计算代表图的节点,我们将按照以下方式定义它们:

## TF v1.x style
>>> g = tf.Graph()
>>> with g.as_default():
...     a = tf.constant(1, name='a')
...	     b = tf.constant(2, name='b')
...	     c = tf.constant(3, name='c')
...	     z = 2*(a-b) + c 

在这段代码中,我们首先通过 g=tf.Graph() 定义了图 g。然后,我们使用 with g.as_default() 向图 g 中添加了节点。然而,请注意,如果我们没有显式创建图,总是会有一个默认图,变量和计算将自动添加到这个图中。

在 TensorFlow v1.x 中,session 是一个环境,在这个环境中可以执行图的操作和张量。Session 类在 TensorFlow v2 中被移除;然而,目前它仍然可以通过 tf.compat 子模块使用,以保持与 TensorFlow v1.x 的兼容性。可以通过调用 tf.compat.v1.Session() 创建一个 session 对象,该对象可以接收一个现有的图(这里是 g)作为参数,如 Session(graph=g)

在 TensorFlow session 中启动图后,我们可以执行它的节点,也就是说,评估它的张量或执行它的操作符。评估每个独立的张量涉及在当前 session 中调用它的 eval() 方法。当评估图中的特定张量时,TensorFlow 必须执行图中的所有前置节点,直到它到达给定的目标节点。如果有一个或多个占位符变量,我们还需要通过 session 的 run 方法为这些变量提供值,正如我们稍后在本章中会看到的那样。

z, as follows:
## TF v1.x style
>>> with tf.compat.v1.Session(graph=g) as sess:
...     print(Result: z =', sess.run(z))
Result: z = 1 

将图迁移到 TensorFlow v2

接下来,让我们看看如何将这段代码迁移到 TensorFlow v2。TensorFlow v2 默认使用动态图(与静态图相对)(这也被称为 TensorFlow 中的急切执行),允许我们即时评估一个操作。因此,我们不必显式创建图和会话,这使得开发工作流更加方便:

## TF v2 style
>>> a = tf.constant(1, name='a')
>>> b = tf.constant(2, name='b')
>>> c = tf.constant(3, name='c')
>>> z = 2*(a - b) + c
>>> tf.print('Result: z= ', z)
Result: z = 1 

将输入数据加载到模型中:TensorFlow v1.x 风格

从 TensorFlow v1.x 到 v2 的另一个重要改进是关于如何将数据加载到模型中。在 TensorFlow v2 中,我们可以直接以 Python 变量或 NumPy 数组的形式提供数据。然而,在使用 TensorFlow v1.x 的低级 API 时,我们必须创建占位符变量来为模型提供输入数据。对于前面的简单计算图示例,,假设 abc 是秩 0 的输入张量。我们可以定义三个占位符,然后通过一个名为 feed_dict 的字典将数据“输入”到模型中,如下所示:

## TF-v1.x style
>>> g = tf.Graph()
>>> with g.as_default():
...     a = tf.compat.v1.placeholder(shape=None,
...                                  dtype=tf.int32, name='tf_a')
...     b = tf.compat.v1.placeholder(shape=None,
...                                  dtype=tf.int32, name='tf_b')
...     c = tf.compat.v1.placeholder(shape=None,
...                                  dtype=tf.int32, name='tf_c')
...     z = 2*(a-b) + c
>>> with tf.compat.v1.Session(graph=g) as sess:
...     feed_dict={a:1, b:2, c:3}
...     print('Result: z =', sess.run(z, feed_dict=feed_dict))
Result: z = 1 

将输入数据加载到模型中:TensorFlow v2 风格

在 TensorFlow v2 中,所有这些操作可以简单地通过定义一个普通的 Python 函数,将 abc 作为输入参数来完成,例如:

## TF-v2 style
>>> def compute_z(a, b, c):
...     r1 = tf.subtract(a, b)
...     r2 = tf.multiply(2, r1)
...     z = tf.add(r2, c)
...     return z 

现在,为了执行计算,我们只需将Tensor对象作为函数参数调用此函数。请注意,TensorFlow 函数如 addsubtractmultiply 也允许我们以 TensorFlow Tensor 对象、NumPy 数组或其他 Python 对象(如列表和元组)的形式提供更高秩的输入。在下面的代码示例中,我们提供了标量输入(秩 0),以及秩 1 和秩 2 的输入,这些输入以列表的形式提供:

>>> tf.print('Scalar Inputs:', compute_z(1, 2, 3))
Scalar Inputs: 1
>>> tf.print('Rank 1 Inputs:', compute_z([1], [2], [3]))
Rank 1 Inputs: [1]
>>> tf.print('Rank 2 Inputs:', compute_z([[1]], [[2]], [[3]]))
Rank 2 Inputs: [[1]] 

在本节中,您看到如何通过避免显式创建图和会话的步骤,迁移到 TensorFlow v2 使编程风格变得简单高效。现在,我们已经了解了 TensorFlow v1.x 与 TensorFlow v2 的比较,接下来我们将专注于 TensorFlow v2,在本书的剩余部分,我们将更深入地探讨如何将 Python 函数装饰成一个图,以便实现更快速的计算。

使用函数装饰器提高计算性能

如前一节所示,我们可以轻松编写一个普通的 Python 函数并利用 TensorFlow 操作。然而,通过动态执行(动态图)模式进行的计算效率不如 TensorFlow v1.x 中的静态图执行。因此,TensorFlow v2 提供了一种名为 AutoGraph 的工具,它可以自动将 Python 代码转换为 TensorFlow 的图代码,从而实现更快的执行。此外,TensorFlow 还提供了一种简单的机制,将普通的 Python 函数编译为静态 TensorFlow 图,以提高计算效率。

为了看看这如何在实际中工作,让我们用之前的 compute_z 函数并为其添加注解,以便使用 @tf.function 装饰器进行图编译:

>>> @tf.function
... def compute_z(a, b, c):
...     r1 = tf.subtract(a, b)
...     r2 = tf.multiply(2, r1)
...     z = tf.add(r2, c)
...     return z 

请注意,我们可以像以前一样使用和调用此函数,但现在 TensorFlow 将根据输入参数构建一个静态图。Python 支持动态类型和多态性,因此我们可以定义一个函数,如 def f(a, b): return a+b,然后使用整数、浮动、列表或字符串作为输入来调用它(回顾一下,a+b 是一个有效的列表和字符串操作)。虽然 TensorFlow 图需要静态类型和形状,tf.function 支持这种动态类型能力。例如,让我们使用以下输入调用此函数:

>>> tf.print('Scalar Inputs:', compute_z(1, 2, 3))
>>> tf.print('Rank 1 Inputs:', compute_z([1], [2], [3]))
>>> tf.print('Rank 2 Inputs:', compute_z([[1]], [[2]], [[3]])) 

这将产生与之前相同的输出。在这里,TensorFlow 使用跟踪机制根据输入参数构建图。对于这个跟踪机制,TensorFlow 会基于调用函数时提供的输入签名生成一个键的元组。生成的键如下所示:

  • 对于 tf.Tensor 参数,键是基于它们的形状和数据类型的。

  • 对于 Python 类型,例如列表,它们的 id() 被用来生成缓存键。

  • 对于 Python 原始值,缓存键是基于输入值的。

调用这样的装饰函数时,TensorFlow 会检查是否已经生成了具有相应键的图。如果该图不存在,TensorFlow 会生成一个新图并存储新的键。另一方面,如果我们希望限制函数的调用方式,我们可以在定义函数时通过一组 tf.TensorSpec 对象指定其输入签名。例如,让我们重新定义之前的函数 compute_z,并指定只允许秩为 1 的 tf.int32 类型张量:

>>> @tf.function(input_signature=(tf.TensorSpec(shape=[None],
...                                             dtype=tf.int32),
...                               tf.TensorSpec(shape=[None],
...                                             dtype=tf.int32),
...                               tf.TensorSpec(shape=[None],
...                                             dtype=tf.int32),))
... def compute_z(a, b, c):
...     r1 = tf.subtract(a, b)
...     r2 = tf.multiply(2, r1)
...     z = tf.add(r2, c)
...     return z 

现在,我们可以使用秩为 1 的张量(或可以转换为秩 1 张量的列表)来调用此函数:

>>> tf.print('Rank 1 Inputs:', compute_z([1], [2], [3]))
>>> tf.print('Rank 1 Inputs:', compute_z([1, 2], [2, 4], [3, 6])) 

然而,使用秩不为 1 的张量调用此函数将导致错误,因为秩将与指定的输入签名不匹配,具体如下:

>>> tf.print('Rank 0 Inputs:', compute_z(1, 2, 3)
### will result in error
>>> tf.print('Rank 2 Inputs:', compute_z([[1], [2]],
...                                      [[2], [4]],
...                                      [[3], [6]]))
### will result in error 

在本节中,我们学习了如何注解一个普通的 Python 函数,以便 TensorFlow 会将其编译成图以加速执行。接下来,我们将了解 TensorFlow 变量:如何创建它们以及如何使用它们。

用于存储和更新模型参数的 TensorFlow 变量对象

我们在第 13 章《使用 TensorFlow 并行训练神经网络》中讲解了 Tensor 对象。在 TensorFlow 中,Variable 是一个特殊的 Tensor 对象,允许我们在训练过程中存储和更新模型的参数。通过调用 tf.Variable 类并传入用户指定的初始值,可以创建一个 Variable。在下面的代码中,我们将生成 float32int32boolstring 类型的 Variable 对象:

>>> a = tf.Variable(initial_value=3.14, name='var_a')
>>> print(a)
<tf.Variable 'var_a:0' shape=() dtype=float32, numpy=3.14>
>>> b = tf.Variable(initial_value=[1, 2, 3], name='var_b')
>>> print(b)
<tf.Variable 'var_b:0' shape=(3,) dtype=int32, numpy=array([1, 2, 3], dtype=int32)>
>>> c = tf.Variable(initial_value=[True, False], dtype=tf.bool)
>>> print(c)
<tf.Variable 'Variable:0' shape=(2,) dtype=bool, numpy=array([ True, False])>
>>> d = tf.Variable(initial_value=['abc'], dtype=tf.string)
>>> print(d)
<tf.Variable 'Variable:0' shape=(1,) dtype=string, numpy=array([b'abc'], dtype=object)> 

注意,我们在创建 Variable 时总是需要提供初始值。变量有一个名为 trainable 的属性,默认值为 True。像 Keras 这样的高级 API 将使用此属性来管理可训练变量和不可训练变量。你可以如下定义一个不可训练的 Variable

>>> w = tf.Variable([1, 2, 3], trainable=False)
>>> print(w.trainable)
False 

Variable的值可以通过执行一些操作,如.assign().assign_add()和相关方法来高效修改。让我们来看一些例子:

>>> print(w.assign([3, 1, 4], read_value=True))
<tf.Variable 'UnreadVariable' shape=(3,) dtype=int32, numpy=array(
[3, 1, 4], dtype=int32)>
>>> w.assign_add([2, -1, 2], read_value=False)
>>> print(w.value())
tf.Tensor([5 0 6], shape=(3,), dtype=int32) 

read_value参数设置为True(这也是默认值)时,这些操作会在更新Variable的当前值后自动返回新值。将read_value设置为False将抑制更新值的自动返回(但Variable仍然会就地更新)。调用w.value()将返回张量格式的值。请注意,在赋值期间,我们不能更改Variable的形状或类型。

你可能还记得,对于神经网络模型,使用随机权重初始化模型参数是必要的,以便在反向传播过程中打破对称性——否则,多层神经网络将和单层神经网络(例如逻辑回归)一样没有意义。在创建TensorFlow的Variable时,我们也可以使用随机初始化方案。TensorFlow可以通过tf.random基于多种分布生成随机数(请参见https://www.tensorflow.org/versions/r2.0/api_docs/python/tf/random)。在以下示例中,我们将查看Keras中也可用的一些标准初始化方法(请参见https://www.tensorflow.org/versions/r2.0/api_docs/python/tf/keras/initializers)。

那么,接下来我们来看一下如何使用Glorot初始化创建一个Variable,这是一种经典的随机初始化方案,由Xavier Glorot和Yoshua Bengio提出。为此,我们创建一个名为init的操作符,作为GlorotNormal类的对象。然后,我们调用这个操作符并提供所需输出张量的形状:

>>> tf.random.set_seed(1)
>>> init = tf.keras.initializers.GlorotNormal()
>>> tf.print(init(shape=(3,)))
[-0.722795904 1.01456821 0.251808226] 

现在,我们可以使用这个操作符来初始化形状为Variable

>>> v = tf.Variable(init(shape=(2, 3)))
>>> tf.print(v)
[[0.28982234 -0.782292783 -0.0453658961]
 [0.960991383 -0.120003454 0.708528221]] 

Xavier(或Glorot)初始化

在深度学习的早期发展中,人们观察到随机均匀或随机正态权重初始化往往会导致模型在训练期间性能不佳。

在2010年,Glorot和Bengio研究了初始化的效果,并提出了一种新的、更稳健的初始化方案,以促进深度网络的训练。Xavier初始化的基本思想是大致平衡不同层之间的梯度方差。否则,一些层可能在训练中获得过多关注,而其他层则滞后。

根据Glorot和Bengio的研究论文,如果我们想要从均匀分布初始化权重,我们应该选择以下均匀分布的区间:

在这里, 是乘以权重的输入神经元数量, 是传递到下一层的输出神经元数量。为了从高斯(正态)分布初始化权重,建议选择此高斯分布的标准差为

TensorFlow 支持 Xavier 初始化,既可以使用均匀分布,也可以使用正态分布的权重。

关于 Glorot 和 Bengio 的初始化方案的更多信息,包括数学推导和证明,请阅读他们的原始论文(理解深度前馈神经网络的困难Xavier GlorotYoshua Bengio,2010),该论文可以免费在 http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf 获取。

现在,为了将其置于更实际的用例上下文中,让我们看看如何在基础的 tf.Module 类中定义 Variable。我们将定义两个变量:一个是可训练的,另一个是不可训练的:

>>> class MyModule(tf.Module):
...     def __init__(self):
...         init = tf.keras.initializers.GlorotNormal()
...         self.w1 = tf.Variable(init(shape=(2, 3)),
...                               trainable=True)
...         self.w2 = tf.Variable(init(shape=(1, 2)),
...                               trainable=False)
>>> m = MyModule()
>>> print('All module variables:', [v.shape for v in m.variables])
All module variables: [TensorShape([2, 3]), TensorShape([1, 2])]
>>> print('Trainable variable:', [v.shape for v in
...                               m.trainable_variables])
Trainable variable: [TensorShape([2, 3])] 

正如你在这个代码示例中看到的,子类化 tf.Module 类使我们可以通过 .variables 属性直接访问给定对象(这里是我们自定义的 MyModule 类的实例)中定义的所有变量。

最后,让我们来看一下在装饰有 tf.function 的函数中使用变量。当我们在普通函数(未装饰)中定义一个 TensorFlow Variable 时,我们可能会预期每次调用该函数时都会创建并初始化一个新的 Variable。然而,tf.function 会基于追踪和图的创建尝试重用 Variable。因此,TensorFlow 不允许在装饰的函数内部创建 Variable,因此,以下代码将引发错误:

>>> @tf.function
... def f(x):
...     w = tf.Variable([1, 2, 3])
>>> f([1])
ValueError: tf.function-decorated function tried to create variables on non-first call. 

避免此问题的一种方法是将 Variable 定义在装饰的函数外部,并在函数内部使用它:

>>> w = tf.Variable(tf.random.uniform((3, 3)))
>>> @tf.function
... def compute_z(x):
...     return tf.matmul(w, x)
>>> x = tf.constant([[1], [2], [3]], dtype=tf.float32)
>>> tf.print(compute_z(x)) 

通过自动微分和 GradientTape 计算梯度

正如你已经知道的,优化神经网络(NN)需要计算成本函数相对于神经网络权重的梯度。这是优化算法(如随机梯度下降(SGD))所必需的。此外,梯度还有其他应用,如诊断网络,找出为什么神经网络模型会对某个测试样本做出特定的预测。因此,在本节中,我们将讨论如何计算某些变量相对于计算的梯度。

计算损失相对于可训练变量的梯度

TensorFlow 支持 自动微分,它可以看作是计算嵌套函数梯度的 链式法则 的实现。当我们定义一系列操作,得到某个输出甚至中间张量时,TensorFlow 提供了一个计算这些张量梯度的上下文,这些梯度是相对于计算图中的依赖节点计算的。为了计算这些梯度,我们需要通过 tf.GradientTape 来“记录”这些计算。

让我们通过一个简单的例子来计算!,并将损失定义为目标与预测之间的平方损失,。在更一般的情况下,当我们可能有多个预测和目标时,我们将损失计算为平方误差的和,。为了在 TensorFlow 中实现这个计算,我们将定义模型参数,wb,作为变量,而输入 xy 作为张量。我们将在 tf.GradientTape 上下文中进行 z 和损失的计算:

>>> w = tf.Variable(1.0)
>>> b = tf.Variable(0.5)
>>> print(w.trainable, b.trainable)
True True
>>> x = tf.convert_to_tensor([1.4])
>>> y = tf.convert_to_tensor([2.1])
>>> with tf.GradientTape() as tape:
...     z = tf.add(tf.multiply(w, x), b)
...     loss = tf.reduce_sum(tf.square(y – z))
>>> dloss_dw = tape.gradient(loss, w)
>>> tf.print('dL/dw:', dloss_dw)
dL/dw: -0.559999764 

在计算 z 的值时,我们可以将需要的操作(我们已记录到“梯度带”中)视为神经网络中的前向传播。我们使用 tape.gradient 来计算 。由于这是一个非常简单的例子,我们可以象征性地获得导数,,以验证计算出的梯度是否与我们在前面的代码示例中得到的结果一致:

# verifying the computed gradient
>>> tf.print(2*x*(w*x+b-y))
[-0.559999764] 

理解自动微分

自动微分代表了一组计算技术,用于计算任意算术操作的导数或梯度。在这个过程中,通过重复应用链式法则来积累梯度,从而得到计算(表示为一系列操作)的梯度。为了更好地理解自动微分的概念,让我们考虑一系列计算,,其中输入为 x,输出为 y。这可以分解为以下几个步骤:

导数 可以通过两种不同的方式计算:前向积累,从 开始,和反向积累,从 开始。请注意,TensorFlow 使用的是后者,即反向积累。

计算相对于不可训练张量的梯度

tf.GradientTape 自动支持可训练变量的梯度。但是,对于不可训练变量和其他 Tensor 对象,我们需要对 GradientTape 进行额外修改,称为 tape.watch(),以便监视它们。例如,如果我们想计算 ,代码如下:

>>> with tf.GradientTape() as tape:
...     tape.watch(x)
...     z = tf.add(tf.multiply(w, x), b)
...     loss = tf.reduce_sum(tf.square(y - z))
>>> dloss_dx = tape.gradient(loss, x)
>>> tf.print('dL/dx:', dloss_dx)
dL/dx: [-0.399999857] 

对抗样本

计算损失相对于输入示例的梯度用于生成对抗样本(或对抗攻击)。在计算机视觉中,对抗样本是通过向输入示例添加一些微小但不可察觉的噪音(或扰动)而生成的示例,这导致深度神经网络对其进行错误分类。覆盖对抗样本超出了本书的范围,但如果您感兴趣,可以在Christian Szegedy等人撰写的原始论文《神经网络的有趣性质》中找到,标题为Intriguing properties of neural networks,网址为https://arxiv.org/pdf/1312.6199.pdf

保持多个梯度计算的资源

当我们在tf.GradientTape的上下文中监视计算时,默认情况下,磁带将仅保留单次梯度计算的资源。例如,调用一次tape.gradient()后,资源将被释放并清除磁带。因此,如果我们要计算多个梯度,例如,,我们需要使磁带持久化:

>>> with tf.GradientTape(persistent=True) as tape:
...     z = tf.add(tf.multiply(w, x), b)
...     loss = tf.reduce_sum(tf.square(y – z))
>>> dloss_dw = tape.gradient(loss, w)
>>> tf.print('dL/dw:', dloss_dw)
dL/dw: -0.559999764
>>> dloss_db = tape.gradient(loss, b)
>>> tf.print('dL/db:', dloss_db)
dL/db: -0.399999857 

但是,请记住,这仅在我们想要计算多个梯度时才需要,因为记录并保留梯度磁带比在单次梯度计算后释放内存不那么内存高效。这也是为什么默认设置为persistent=False的原因。

最后,如果我们正在计算损失项相对于模型参数的梯度,我们可以定义一个优化器,并使用tf.keras API应用梯度来优化模型参数,如下所示:

>>> optimizer = tf.keras.optimizers.SGD()
>>> optimizer.apply_gradients(zip([dloss_dw, dloss_db], [w, b]))
>>> tf.print('Updated w:', w)
Updated w: 1.0056
>>> tf.print('Updated bias:', b)
Updated bias: 0.504 

你会记得初始权重和偏置单元分别为w = 1.0和b = 0.5,应用于损失相对于模型参数的梯度后,模型参数变为w = 1.0056和b = 0.504。

通过Keras API简化常见架构的实现

你已经看到了构建前馈神经网络模型的一些示例(例如,多层感知器),并使用Keras的Sequential类定义了一系列层。在我们探讨配置这些层的不同方法之前,让我们简要回顾一下通过构建一个具有两个全连接层的模型的基本步骤:

>>> model = tf.keras.Sequential()
>>> model.add(tf.keras.layers.Dense(units=16, activation='relu'))
>>> model.add(tf.keras.layers.Dense(units=32, activation='relu'))
>>> ## late variable creation
>>> model.build(input_shape=(None, 4))
>>> model.summary()
Model: "sequential"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
dense (Dense)                multiple                  80        
_________________________________________________________________
dense_1 (Dense)              multiple                  544       
=================================================================
Total params: 624
Trainable params: 624
Non-trainable params: 0
_________________________________________________________________ 

我们使用model.build()指定了输入形状,在为该特定形状定义模型后实例化变量。每层的参数数量显示如下:是第一层,是第二层。一旦创建变量(或模型参数),我们可以访问可训练和不可训练变量如下:

>>> ## printing variables of the model
>>> for v in model.variables:
...     print('{:20s}'.format(v.name), v.trainable, v.shape)
dense/kernel:0       True (4, 16)
dense/bias:0         True (16,)
dense_1/kernel:0     True (16, 32)
dense_1/bias:0       True (32,) 

在这种情况下,每个层都有一个称为kernel的权重矩阵以及一个偏置向量。

接下来,让我们配置这些层,例如,通过为参数应用不同的激活函数、变量初始化器或正则化方法。这些类别的所有可用选项的完整列表可以在官方文档中找到:

在下面的代码示例中,我们将通过为内核和偏差变量指定初始化器来配置第一层。然后,我们将通过为内核(权重矩阵)指定 L1 正则化器来配置第二层:

>>> model = tf.keras.Sequential()
>>> model.add(
...     tf.keras.layers.Dense(
...         units=16,
...         activation=tf.keras.activations.relu,
...         kernel_initializer= \
...             tf.keras.initializers.glorot_uniform(),
...         bias_initializer=tf.keras.initializers.Constant(2.0)
...     ))
>>> model.add(
...     tf.keras.layers.Dense(
...         units=32,
...         activation=tf.keras.activations.sigmoid,
...         kernel_regularizer=tf.keras.regularizers.l1
...     )) 

此外,除了配置单独的层,我们还可以在编译模型时对其进行配置。我们可以为训练指定优化器的类型和损失函数,以及为报告训练、验证和测试数据集的性能指定哪些指标。再次提醒,所有可用选项的完整列表可以在官方文档中找到:

选择损失函数

关于优化算法的选择,SGD 和 Adam 是最广泛使用的方法。损失函数的选择取决于任务;例如,对于回归问题,您可能会使用均方误差损失函数。

交叉熵损失函数家族为分类任务提供了可能的选择,这些内容在第 15 章《使用深度卷积神经网络进行图像分类》中有广泛讨论。

此外,你可以使用在前几章学到的技巧(例如,第 6 章:模型评估的最佳实践与超参数调优 中的模型评估技巧),结合问题的适当评估指标。例如,精度、召回率、准确率、曲线下面积(AUC),以及假阴性和假阳性评分,都是评估分类模型的适当指标。

在这个示例中,我们将使用 SGD 优化器、交叉熵损失(适用于二分类)和一系列特定的评估指标来编译模型,其中包括准确率、精度和召回率:

>>> model.compile(
...     optimizer=tf.keras.optimizers.SGD(learning_rate=0.001),
...     loss=tf.keras.losses.BinaryCrossentropy(),
...     metrics=[tf.keras.metrics.Accuracy(),
...              tf.keras.metrics.Precision(),
...              tf.keras.metrics.Recall(),]) 

当我们通过调用 model.fit(...) 来训练这个模型时,将返回包含损失值和指定评估指标的历史记录(如果使用了验证数据集),这些信息可以帮助诊断学习行为。

接下来,我们将看一个更实际的例子:使用 Keras API 解决经典的 XOR 分类问题。首先,我们将使用 tf.keras.Sequential() 类构建模型。在这个过程中,你还将了解模型处理非线性决策边界的能力。然后,我们将介绍其他构建模型的方式,这些方式将为我们提供更多的灵活性和对网络层的控制。

解决 XOR 分类问题

XOR 分类问题是分析模型捕捉两个类别之间非线性决策边界能力的经典问题。我们生成了一个包含 200 个训练示例的玩具数据集,具有两个特征 ,这些特征是从 之间的均匀分布中抽取的。然后,我们根据以下规则为训练示例 i 分配真实标签:

我们将使用一半的数据(100 个训练示例)用于训练,另一半用于验证。生成数据并将其拆分为训练集和验证集的代码如下:

>>> import tensorflow as tf
>>> import numpy as np
>>> import matplotlib.pyplot as plt
>>> tf.random.set_seed(1)
>>> np.random.seed(1)
>>> x = np.random.uniform(low=-1, high=1, size=(200, 2))
>>> y = np.ones(len(x))
>>> y[x[:, 0] * x[:, 1]<0] = 0
>>> x_train = x[:100, :]
>>> y_train = y[:100]
>>> x_valid = x[100:, :]
>>> y_valid = y[100:]
>>> fig = plt.figure(figsize=(6, 6))
>>> plt.plot(x[y==0, 0],
...          x[y==0, 1], 'o', alpha=0.75, markersize=10)
>>> plt.plot(x[y==1, 0],
...          x[y==1, 1], '<', alpha=0.75, markersize=10)
>>> plt.xlabel(r'$x_1$', size=15)
>>> plt.ylabel(r'$x_2$', size=15)
>>> plt.show() 

代码的结果是以下散点图,展示了不同类别标签的训练和验证示例,且根据其类别标签使用不同的标记表示:

在上一小节中,我们介绍了在 TensorFlow 中实现分类器所需的基本工具。现在,我们需要决定应该选择什么架构来处理此任务和数据集。作为一个一般规则,层数越多,每层神经元数量越多,模型的容量就越大。在这里,模型容量可以看作是衡量模型如何轻松逼近复杂函数的一个标准。尽管更多的参数意味着网络能够拟合更复杂的函数,但较大的模型通常更难训练(并且容易过拟合)。实际上,最好从一个简单的模型开始作为基线,例如,像逻辑回归这样的单层神经网络(NN):

>>> model = tf.keras.Sequential()
>>> model.add(tf.keras.layers.Dense(units=1,
...                                 input_shape=(2,),
...                                 activation='sigmoid'))
>>> model.summary()
Model: "sequential"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
dense (Dense)                (None, 1)                 3         
=================================================================
Total params: 3
Trainable params: 3
Non-trainable params: 0
_________________________________________________________________ 

这个简单的逻辑回归模型的参数总大小为3:一个大小为 的权重矩阵(或内核)和一个大小为1的偏置向量。定义好模型后,我们将编译模型,并用批量大小为2训练200个周期:

>>> model.compile(optimizer=tf.keras.optimizers.SGD(),
...               loss=tf.keras.losses.BinaryCrossentropy(),
...               metrics=[tf.keras.metrics.BinaryAccuracy()])
>>> hist = model.fit(x_train, y_train,
...                  validation_data=(x_valid, y_valid),
...                  epochs=200, batch_size=2, verbose=0) 

注意,model.fit()返回的是训练周期的历史记录,这对于训练后进行可视化检查非常有用。在以下代码中,我们将绘制学习曲线,包括训练损失、验证损失及其准确率。

我们还将使用MLxtend库来可视化验证数据和决策边界。

可以通过condapip安装MLxtend,如下所示:

conda install mlxtend -c conda-forge
pip install mlxtend 

以下代码将绘制训练表现以及决策区域的偏差:

>>> from mlxtend.plotting import plot_decision_regions
>>> history = hist.history
>>> fig = plt.figure(figsize=(16, 4))
>>> ax = fig.add_subplot(1, 3, 1)
>>> plt.plot(history['loss'], lw=4)
>>> plt.plot(history['val_loss'], lw=4)
>>> plt.legend(['Train loss', 'Validation loss'], fontsize=15)
>>> ax.set_xlabel('Epochs', size=15)
>>> ax = fig.add_subplot(1, 3, 2)
>>> plt.plot(history['binary_accuracy'], lw=4)
>>> plt.plot(history['val_binary_accuracy'], lw=4)
>>> plt.legend(['Train Acc.', 'Validation Acc.'], fontsize=15)
>>> ax.set_xlabel('Epochs', size=15)
>>> ax = fig.add_subplot(1, 3, 3)
>>> plot_decision_regions(X=x_valid, y=y_valid.astype(np.integer),
...                       clf=model)
>>> ax.set_xlabel(r'$x_1$', size=15)
>>> ax.xaxis.set_label_coords(1, -0.025)
>>> ax.set_ylabel(r'$x_2$', size=15)
>>> ax.yaxis.set_label_coords(-0.025, 1)
>>> plt.show() 

这将产生以下图形,展示了损失、准确率以及验证示例的散点图,同时包括决策边界:

如你所见,一个没有隐藏层的简单模型只能得出一个线性决策边界,无法解决XOR问题。因此,我们可以观察到训练集和验证集的损失值都非常高,分类准确率也很低。

为了得出非线性决策边界,我们可以添加一个或多个通过非线性激活函数连接的隐藏层。通用逼近定理指出,一个具有单个隐藏层和相对较多隐藏单元的前馈神经网络(NN)可以相对较好地逼近任意连续函数。因此,解决XOR问题的一种更令人满意的方法是添加一个隐藏层,并比较不同数量的隐藏单元,直到我们在验证集上观察到满意的结果。添加更多隐藏单元相当于增加一个层的宽度。

或者,我们也可以添加更多的隐藏层,这将使模型更深。使网络更深而不是更宽的优势在于,达到相当的模型容量所需的参数较少。然而,深度(与宽度)模型的一个缺点是,深度模型容易出现梯度消失和爆炸问题,这使得它们更难训练。

作为一个练习,尝试添加一、二、三和四个隐藏层,每个层包含四个隐藏单元。在以下示例中,我们将查看具有三个隐藏层的前馈神经网络(NN)结果:

>>> tf.random.set_seed(1)
>>> model = tf.keras.Sequential()
>>> model.add(tf.keras.layers.Dense(units=4, input_shape=(2,),
...                                 activation='relu'))
>>> model.add(tf.keras.layers.Dense(units=4, activation='relu'))
>>> model.add(tf.keras.layers.Dense(units=4, activation='relu'))
>>> model.add(tf.keras.layers.Dense(units=1, activation='sigmoid'))
>>> model.summary()
Model: "sequential_4"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
dense_11 (Dense)             (None, 4)                 12        
_________________________________________________________________
dense_12 (Dense)             (None, 4)                 20        
_________________________________________________________________
dense_13 (Dense)             (None, 4)                 20        
_________________________________________________________________
dense_14 (Dense)             (None, 1)                 5         
=================================================================
Total params: 57
Trainable params: 57
Non-trainable params: 0
_________________________________________________________________
>>> ## compile:
>>> model.compile(optimizer=tf.keras.optimizers.SGD(),
...               loss=tf.keras.losses.BinaryCrossentropy(),
...               metrics=[tf.keras.metrics.BinaryAccuracy()])
>>> ## train:
>>> hist = model.fit(x_train, y_train,
...                  validation_data=(x_valid, y_valid),
...                  epochs=200, batch_size=2, verbose=0) 

我们可以重复前面的代码进行可视化,得到以下结果:

现在,我们可以看到模型能够为这些数据得出一个非线性的决策边界,并且该模型在训练集上的准确率达到了100%。验证集的准确率为95%,这表明模型有轻微的过拟合现象。

使用Keras的函数式API让模型构建更具灵活性

在之前的示例中,我们使用了 Keras 的 Sequential 类来创建一个具有多层的全连接神经网络(NN)。这是一种非常常见且便捷的构建模型的方式。然而,它不允许我们创建具有多个输入、输出或中间分支的更复杂模型。在这种情况下,Keras 的所谓功能性 API 就显得非常有用。

为了说明如何使用功能性 API,我们将实现与上一节中使用面向对象(Sequential)方法构建的相同架构;不过这次我们将使用功能性方法。在这种方法中,我们首先指定输入。然后,构建隐藏层,并将它们的输出命名为 h1h2h3。对于这个问题,我们将每一层的输出作为下一层的输入(注意,如果你正在构建更复杂的模型,有多个分支,这种方法可能不适用,但通过功能性 API 仍然可以做到)。最后,我们指定输出为接收 h3 作为输入的最终全连接层。代码如下:

>>> tf.random.set_seed(1)
>>> ## input layer:
>>> inputs = tf.keras.Input(shape=(2,))
>>> ## hidden layers
>>> h1 = tf.keras.layers.Dense(units=4, activation='relu')(inputs)
>>> h2 = tf.keras.layers.Dense(units=4, activation='relu')(h1)
>>> h3 = tf.keras.layers.Dense(units=4, activation='relu')(h2)
>>> ## output:
>>> outputs = tf.keras.layers.Dense(units=1, activation='sigmoid')(h3)
>>> ## construct a model:
>>> model = tf.keras.Model(inputs=inputs, outputs=outputs)
>>> model.summary() 

编译和训练该模型与我们之前做的类似:

>>> ## compile:
>>> model.compile(
...     optimizer=tf.keras.optimizers.SGD(),
...     loss=tf.keras.losses.BinaryCrossentropy(),
...     metrics=[tf.keras.metrics.BinaryAccuracy()])
>>> ## train:
>>> hist = model.fit(
...     x_train, y_train,
...     validation_data=(x_valid, y_valid),
...     epochs=200, batch_size=2, verbose=0) 

基于 Keras 的 Model 类实现模型

构建复杂模型的另一种方式是通过子类化 tf.keras.Model。在这种方法中,我们创建一个从 tf.keras.Model 派生的新类,并将 __init__() 函数定义为构造函数。call() 方法用于指定前向传播。在构造函数 __init__() 中,我们将层定义为类的属性,以便通过 self 引用访问它们。然后,在 call() 方法中,我们指定这些层如何在神经网络的前向传播中使用。定义实现前面模型的新类的代码如下:

>>> class MyModel(tf.keras.Model):
...     def __init__(self):
...         super(MyModel, self).__init__()
...         self.hidden_1 = tf.keras.layers.Dense(
...             units=4, activation='relu')
...         self.hidden_2 = tf.keras.layers.Dense(
...             units=4, activation='relu')
...         self.hidden_3 = tf.keras.layers.Dense(
...             units=4, activation='relu')
...         self.output_layer = tf.keras.layers.Dense(
...             units=1, activation='sigmoid')
...         
...     def call(self, inputs):
...         h = self.hidden_1(inputs)
...         h = self.hidden_2(h)
...         h = self.hidden_3(h)
...         return self.output_layer(h) 

注意,我们为所有隐藏层使用了相同的输出名称 h。这样使得代码更加可读和易于跟随。

tf.keras.Model 类派生的模型类通过继承获得了一般的模型属性,如 build()compile()fit()。因此,一旦我们定义了这个新类的实例,就可以像使用 Keras 构建的其他模型一样编译和训练它:

>>> tf.random.set_seed(1)
>>> model = MyModel()
>>> model.build(input_shape=(None, 2))
>>> model.summary()
>>> ## compile:
>>> model.compile(optimizer=tf.keras.optimizers.SGD(),
...               loss=tf.keras.losses.BinaryCrossentropy(),
...               metrics=[tf.keras.metrics.BinaryAccuracy()])
>>> ## train:
>>> hist = model.fit(x_train, y_train,
...                  validation_data=(x_valid, y_valid),
...                  epochs=200, batch_size=2, verbose=0) 

编写自定义 Keras 层

当我们想定义一个 Keras 不支持的新层时,我们可以定义一个从 tf.keras.layers.Layer 类派生的新类。这对于设计新的层或定制现有层尤其有用。

为了说明实现自定义层的概念,让我们考虑一个简单的例子。假设我们想定义一个新的线性层,它计算 ,其中 代表一个作为噪声变量的随机变量。为了实现这个计算,我们定义一个新的类,作为 tf.keras.layers.Layer 的子类。对于这个新类,我们必须同时定义构造函数 __init__() 方法和 call() 方法。在构造函数中,我们为我们的自定义层定义变量和其他所需的张量。如果构造函数给定了 input_shape,我们可以选择在构造函数中创建变量并初始化它们。或者,如果我们事先不知道确切的输入形状,我们可以延迟变量初始化,并将其委托给 build() 方法进行后期创建。此外,我们可以定义 get_config() 来进行序列化,这意味着使用我们自定义层的模型可以通过 TensorFlow 的模型保存和加载功能有效地保存。

为了查看一个具体的例子,我们将定义一个新的层,名为 NoisyLinear,它实现前面段落中提到的计算

>>> class NoisyLinear(tf.keras.layers.Layer):
...     def __init__(self, output_dim, noise_stddev=0.1, **kwargs):
...         self.output_dim = output_dim
...         self.noise_stddev = noise_stddev
...         super(NoisyLinear, self).__init__(**kwargs)
...
...     def build(self, input_shape):
...         self.w = self.add_weight(name='weights',
...                                  shape=(input_shape[1],
...                                         self.output_dim),
...                                  initializer='random_normal',
...                                  trainable=True)
...         
...         self.b = self.add_weight(shape=(self.output_dim,),
...                                  initializer='zeros',
...                                  trainable=True)
...
...     def call(self, inputs, training=False):
...         if training:
...             batch = tf.shape(inputs)[0]
...             dim = tf.shape(inputs)[1]
...             noise = tf.random.normal(shape=(batch, dim),
...                                      mean=0.0,
...                                      stddev=self.noise_stddev)
...
...             noisy_inputs = tf.add(inputs, noise)
...         else:
...             noisy_inputs = inputs
...         z = tf.matmul(noisy_inputs, self.w) + self.b
...         return tf.keras.activations.relu(z)
...     
...     def get_config(self):
...         config = super(NoisyLinear, self).get_config()
...         config.update({'output_dim': self.output_dim,
...                        'noise_stddev': self.noise_stddev})
...         return config 
, was to be generated and added to the input during training only and not used for inference or evaluation.

在我们更进一步并在模型中使用自定义的 NoisyLinear 层之前,让我们在一个简单的例子中测试它。

在接下来的代码中,我们将定义这个层的新实例,通过调用 .build() 初始化它,并在输入张量上执行它。然后,我们将通过 .get_config() 序列化它,并通过 .from_config() 恢复序列化的对象:

>>> tf.random.set_seed(1)
>>> noisy_layer = NoisyLinear(4)
>>> noisy_layer.build(input_shape=(None, 4))
>>> x = tf.zeros(shape=(1, 4))
>>> tf.print(noisy_layer(x, training=True))
[[0 0.00821428 0 0]]
>>> ## re-building from config:
>>> config = noisy_layer.get_config()
>>> new_layer = NoisyLinear.from_config(config)
>>> tf.print(new_layer(x, training=True))
[[0 0.0108502861 0 0]] 
NoisyLinear layer added random noise to the input tensor.

现在,让我们创建一个类似于之前的模型,用于解决 XOR 分类任务。和之前一样,我们将使用 Keras 的 Sequential 类,但这次我们将使用 NoisyLinear 层作为多层感知机的第一个隐藏层。代码如下:

>>> tf.random.set_seed(1)
>>> model = tf.keras.Sequential([
...     NoisyLinear(4, noise_stddev=0.1),
...     tf.keras.layers.Dense(units=4, activation='relu'),
...     tf.keras.layers.Dense(units=4, activation='relu'),
...     tf.keras.layers.Dense(units=1, activation='sigmoid')])
>>> model.build(input_shape=(None, 2))
>>> model.summary()
>>> ## compile:
>>> model.compile(optimizer=tf.keras.optimizers.SGD(),
...               loss=tf.keras.losses.BinaryCrossentropy(),
...               metrics=[tf.keras.metrics.BinaryAccuracy()])
>>> ## train:
>>> hist = model.fit(x_train, y_train,
...                  validation_data=(x_valid, y_valid),
...                  epochs=200, batch_size=2,
...                  verbose=0)
>>> ## Plotting
>>> history = hist.history
>>> fig = plt.figure(figsize=(16, 4))
>>> ax = fig.add_subplot(1, 3, 1)
>>> plt.plot(history['loss'], lw=4)
>>> plt.plot(history['val_loss'], lw=4)
>>> plt.legend(['Train loss', 'Validation loss'], fontsize=15)
>>> ax.set_xlabel('Epochs', size=15)
>>> ax = fig.add_subplot(1, 3, 2)
>>> plt.plot(history['binary_accuracy'], lw=4)
>>> plt.plot(history['val_binary_accuracy'], lw=4)
>>> plt.legend(['Train Acc.', 'Validation Acc.'], fontsize=15)
>>> ax.set_xlabel('Epochs', size=15)
>>> ax = fig.add_subplot(1, 3, 3)
>>> plot_decision_regions(X=x_valid, y=y_valid.astype(np.integer),
...                       clf=model)
>>> ax.set_xlabel(r'$x_1$', size=15)
>>> ax.xaxis.set_label_coords(1, -0.025)
>>> ax.set_ylabel(r'$x_2$', size=15)
>>> ax.yaxis.set_label_coords(-0.025, 1)
>>> plt.show() 

生成的图形将如下所示:

在这里,我们的目标是学习如何定义一个从 tf.keras.layers.Layer 子类化的新自定义层,并像使用其他标准 Keras 层一样使用它。虽然在这个特定的例子中,NoisyLinear 并没有帮助提高性能,但请记住,我们的主要目标是学习如何从头编写一个自定义层。通常,编写新的自定义层在其他应用中是有用的,例如,如果你开发了一个依赖于新层的算法,而这个新层超出了现有的层的范畴。

TensorFlow 估算器

到目前为止,在本章中我们大多集中于低级别的TensorFlow API。我们使用装饰器修改函数,以显式地编译计算图,以提高计算效率。然后,我们使用了Keras API并实现了前馈神经网络,并为其添加了自定义层。在本节中,我们将转换思路,使用TensorFlow Estimators。tf.estimator API封装了机器学习任务中的基本步骤,如训练、预测(推理)和评估。与本章前面讲解的其他方法相比,Estimators封装性更强,同时也更具可扩展性。此外,tf.estimator API还支持在多个平台上运行模型,而无需进行重大代码更改,这使得它们更适合工业应用中的所谓“生产阶段”。另外,TensorFlow还提供了一些现成的Estimators,用于常见的机器学习和深度学习架构,适用于比较研究,例如快速评估某种方法是否适用于特定的数据集或问题。

在本章的后续部分,你将学习如何使用这些预制的Estimators,以及如何从现有的Keras模型创建Estimator。Estimator的一个关键要素是定义特征列,这是将数据导入基于Estimator的模型的一种机制,下一节我们将详细讲解。

使用特征列

在机器学习和深度学习应用中,我们可能会遇到各种不同类型的特征:连续型、无序类别型(名义型)和有序类别型(顺序型)。你会记得在第4章构建良好的训练数据集 – 数据预处理中,我们讲解了不同类型的特征,并学习了如何处理每种类型。需要注意的是,虽然数值数据可以是连续型或离散型,但在TensorFlow API的语境下,“数值”数据特指浮动点类型的连续数据。

有时,特征集由多种不同类型的特征组成。尽管TensorFlow Estimators被设计用来处理这些不同类型的特征,但我们必须指定每个特征应该如何被Estimator解释。例如,考虑以下图示中的七个不同特征:

图中显示的特征(车型年份、气缸数、排量、马力、重量、加速度和来源)来自Auto MPG数据集,这是一个常见的机器学习基准数据集,用于预测汽车的油耗(每加仑多少英里)。完整的数据集及其描述可以通过UCI的机器学习仓库访问,网址为https://archive.ics.uci.edu/ml/datasets/auto+mpg

我们将从Auto MPG数据集中选择五个特征(气缸数、排量、马力、重量和加速度)作为“数值型”(即连续)特征。车型年份可以视为有序的类别(ordinal)特征。最后,制造原产地可以视为无序的类别(nominal)特征,具有三个可能的离散值,1、2和3,分别对应美国、欧洲和日本。

首先加载数据并应用必要的预处理步骤,如将数据集划分为训练集和测试集,以及标准化连续特征:

>>> import pandas as pd
>>> dataset_path = tf.keras.utils.get_file(
...     "auto-mpg.data",
...     ("http://archive.ics.uci.edu/ml/machine-learning"
...      "-databases/auto-mpg/auto-mpg.data"))
>>> column_names = [
...     'MPG', 'Cylinders', 'Displacement',
...     'Horsepower', 'Weight', 'Acceleration',
...     'ModelYear', 'Origin']
>>> df = pd.read_csv(dataset_path, names=column_names,
...                  na_values = '?', comment='\t',
...                  sep=' ', skipinitialspace=True)
>>> ## drop the NA rows
>>> df = df.dropna()
>>> df = df.reset_index(drop=True)
>>> ## train/test splits:
>>> import sklearn
>>> import sklearn.model_selection
>>> df_train, df_test = sklearn.model_selection.train_test_split(
...    df, train_size=0.8)
>>> train_stats = df_train.describe().transpose()
>>> numeric_column_names = [
...     'Cylinders', 'Displacement',
...     'Horsepower', 'Weight',
...     'Acceleration']
>>> df_train_norm, df_test_norm = df_train.copy(), df_test.copy()
>>> for col_name in numeric_column_names:
...     mean = train_stats.loc[col_name, 'mean']
...     std  = train_stats.loc[col_name, 'std']
...     df_train_norm.loc[:, col_name] = (
...         df_train_norm.loc[:, col_name] - mean)/std
...     df_test_norm.loc[:, col_name] = (
...         df_test_norm.loc[:, col_name] - mean)/std
>>> df_train_norm.tail() 

这将得到如下结果:

float. These columns will constitute the continuous features. In the following code, we will use TensorFlow's feature_column function to transform these continuous features into the feature column data structure that TensorFlow Estimators can work with:
>>> numeric_features = []
>>> for col_name in numeric_column_names:
...     numeric_features.append(
...         tf.feature_column.numeric_column(key=col_name)) 

接下来,将较为精细的车型年份信息分组到桶中,以简化我们稍后将要训练的模型的学习任务。具体来说,我们将把每辆车分配到以下四个“年份”桶中的一个:

请注意,选择的区间是为了说明“分桶”(bucketing)概念而任意选择的。为了将汽车分组到这些桶中,我们将首先根据每个原始车型年份定义一个数值特征。然后,这些数值特征将传递给bucketized_column函数,我们将为其指定三个区间切割值:[73, 76, 79]。指定的值包括右侧切割值。这些切割值用于指定半闭区间,例如,,和。代码如下:

>>> feature_year = tf.feature_column.numeric_column(key='ModelYear')
>>> bucketized_features = []
>>> bucketized_features.append(
...     tf.feature_column.bucketized_column(
...         source_column=feature_year,
...         boundaries=[73, 76, 79])) 

为了保持一致性,我们将此分桶特征列添加到一个Python列表中,尽管该列表只有一个条目。在接下来的步骤中,我们将把这个列表与其他特征列表合并,然后将合并后的结果作为输入提供给基于TensorFlow Estimator的模型。

接下来,我们将继续为无序的分类特征 Origin 定义一个列表。在 TensorFlow 中,有多种方式可以创建分类特征列。如果数据包含类别名称(例如,像“US”,“Europe”和“Japan”这样的字符串格式),则可以使用 tf.feature_column.categorical_column_with_vocabulary_list 并提供一个唯一的类别名称列表作为输入。如果可能的类别列表过大,例如,在典型的文本分析上下文中,可以使用 tf.feature_column.categorical_column_with_vocabulary_file。使用此函数时,我们只需提供一个包含所有类别/单词的文件,这样就无需将所有可能的单词列表存储在内存中。此外,如果特征已经与类别索引相关联,索引范围为 [0, num_categories),则可以使用 tf.feature_column.categorical_column_with_identity 函数。然而,在这种情况下,特征 Origin 被表示为整数值 1,2,3(而不是 0,1,2),这与分类索引的要求不匹配,因为它期望索引从 0 开始。

在以下代码示例中,我们将使用词汇表列表:

>>> feature_origin = tf.feature_column.categorical_column_with_vocabulary_list(
...     key='Origin',
...     vocabulary_list=[1, 2, 3]) 

某些 Estimators,如 DNNClassifierDNNRegressor,仅接受所谓的“稠密列”。因此,下一步是将现有的分类特征列转换为这种稠密列。有两种方法可以做到这一点:通过 embedding_column 使用嵌入列,或通过 indicator_column 使用指示符列。指示符列将分类索引转换为独热编码向量,例如,索引 0 会被编码为 [1, 0, 0],索引 1 会被编码为 [0, 1, 0],依此类推。另一方面,嵌入列将每个索引映射到一个随机数向量,类型为 float,该向量可以训练。

当类别数量较多时,使用维度少于类别数量的嵌入列可以提高性能。在以下代码片段中,我们将使用指示符列方法处理分类特征,以将其转换为稠密格式:

>>> categorical_indicator_features = []
>>> categorical_indicator_features.append(
...     tf.feature_column.indicator_column(feature_origin)) 

在本节中,我们已经介绍了几种常见的创建特征列的方法,这些特征列可以与 TensorFlow Estimators 配合使用。然而,还有一些额外的特征列我们没有讨论,包括哈希列和交叉列。有关这些其他特征列的更多信息,可以在官方 TensorFlow 文档中找到,链接:https://www.tensorflow.org/versions/r2.0/api_docs/python/tf/feature_column

使用预制估计器的机器学习

现在,在构建了必要的特征列后,我们终于可以使用 TensorFlow 的 Estimators 了。使用预制的 Estimators 可以概括为四个步骤:

  1. 定义数据加载的输入函数

  2. 将数据集转换为特征列

  3. 实例化一个估算器(使用预制的估算器或创建一个新的估算器,例如通过将Keras模型转换为估算器)

  4. 使用估算器方法train()evaluate()predict()

继续使用上一节中的Auto MPG示例,我们将应用这四个步骤来说明如何在实践中使用估算器。在第一步中,我们需要定义一个处理数据并返回一个TensorFlow数据集的函数,该数据集包含一个元组,其中包含输入特征和标签(真实的MPG值)。请注意,特征必须以字典格式提供,并且字典的键必须与特征列的名称匹配。

从第一步开始,我们将定义训练数据的输入函数,如下所示:

>>> def train_input_fn(df_train, batch_size=8):
...     df = df_train.copy()
...     train_x, train_y = df, df.pop('MPG')
...     dataset = tf.data.Dataset.from_tensor_slices(
...         (dict(train_x), train_y))
... 
...     # shuffle, repeat, and batch the examples.
...     return dataset.shuffle(1000).repeat().batch(batch_size) 

请注意,我们在这个函数中使用了dict(train_x)来将pandas的DataFrame对象转换为Python字典。我们来加载一个批次的数据,看看它的样子:

>>> ds = train_input_fn(df_train_norm)
>>> batch = next(iter(ds))
>>> print('Keys:', batch[0].keys())
Keys: dict_keys(['Cylinders', 'Displacement', 'Horsepower', 'Weight', 'Acceleration', 'ModelYear', 'Origin'])
>>> print('Batch Model Years:', batch[0]['ModelYear'])
Batch Model Years: tf.Tensor([74 71 81 72 82 81 70 74], shape=(8,), dtype=int32) 

我们还需要定义一个测试数据集的输入函数,用于在模型训练后进行评估:

>>> def eval_input_fn(df_test, batch_size=8):
...     df = df_test.copy()
...     test_x, test_y = df, df.pop('MPG')
...     dataset = tf.data.Dataset.from_tensor_slices(
...         (dict(test_x), test_y))
...     return dataset.batch(batch_size) 

现在,进入第2步,我们需要定义特征列。我们已经定义了一个包含连续特征的列表、一个包含桶化特征列的列表以及一个包含类别特征列的列表。现在,我们可以将这些单独的列表合并成一个包含所有特征列的单一列表:

>>> all_feature_columns = (
...     numeric_features +
...     bucketized_features +
...     categorical_indicator_features) 

对于第3步,我们需要实例化一个新的估算器。由于预测MPG值是一个典型的回归问题,我们将使用tf.estimator.DNNRegressor。在实例化回归估算器时,我们将提供特征列的列表,并使用hidden_units参数指定每个隐藏层的单元数。这里,我们将使用两个隐藏层,第一个隐藏层有32个单元,第二个隐藏层有10个单元:

>>> regressor = tf.estimator.DNNRegressor(
...     feature_columns=all_feature_columns,
...     hidden_units=[32, 10],
...     model_dir='models/autompg-dnnregressor/') 

另一个参数model_dir指定了用于保存模型参数的目录。估算器的一个优势是它们在训练过程中会自动保存模型检查点,因此,如果模型训练因为意外原因(如停电)中断,我们可以轻松加载最后保存的检查点并从那里继续训练。检查点也将保存在model_dir指定的目录中。如果我们没有指定model_dir参数,估算器将创建一个随机的临时文件夹(例如,在Linux操作系统中,会在/tmp/目录中创建一个随机文件夹),用于保存检查点。

完成这三步基本设置后,我们终于可以使用估算器进行训练、评估,并最终进行预测。回归器可以通过调用train()方法进行训练,而我们需要之前定义的输入函数:

>>> EPOCHS = 1000
>>> BATCH_SIZE = 8
>>> total_steps = EPOCHS * int(np.ceil(len(df_train) / BATCH_SIZE))
>>> print('Training Steps:', total_steps)
Training Steps: 40000
>>> regressor.train(
...     input_fn=lambda:train_input_fn(
...         df_train_norm, batch_size=BATCH_SIZE),
...     steps=total_steps) 

调用.train()将自动在模型训练过程中保存检查点。我们可以随后重新加载最后一个检查点:

>>> reloaded_regressor = tf.estimator.DNNRegressor(
...     feature_columns=all_feature_columns,
...     hidden_units=[32, 10],
...     warm_start_from='models/autompg-dnnregressor/',
...     model_dir='models/autompg-dnnregressor/') 

然后,为了评估训练模型的预测性能,我们可以使用 evaluate() 方法,如下所示:

>>> eval_results = reloaded_regressor.evaluate(
...     input_fn=lambda:eval_input_fn(df_test_norm, batch_size=8))
>>> print('Average-Loss {:.4f}'.format(
...       eval_results['average_loss']))
Average-Loss 15.1866 

最后,为了对新数据点预测目标值,我们可以使用 predict() 方法。对于本示例,假设测试数据集表示的是现实应用中的新、未标记的数据点。

请注意,在真实的预测任务中,输入函数只需要返回一个包含特征的数据集,假设标签不可用。在这里,我们将简单地使用我们在评估时使用的相同输入函数来获取每个示例的预测值:

>>> pred_res = regressor.predict(
...     input_fn=lambda: eval_input_fn(
...         df_test_norm, batch_size=8))
>>> print(next(iter(pred_res)))
{'predictions': array([23.747658], dtype=float32)} 

虽然前面的代码片段展示了使用预制 Estimators 所需的四个步骤,但为了练习,让我们看看另一个预制 Estimator:提升树回归器 tf.estimator.BoostedTreeRegressor。由于输入函数和特征列已经构建完成,我们只需要重复步骤 3 和步骤 4。对于步骤 3,我们将创建一个 BoostedTreeRegressor 实例,并将其配置为拥有 200 棵树。

决策树提升

我们已经在第 7 章结合不同模型进行集成学习》中介绍了集成算法,包括提升算法。提升树算法是提升算法的一个特殊家族,它基于任意损失函数的优化。请随时访问 https://medium.com/mlreview/gradient-boosting-from-scratch-1e317ae4587d 了解更多信息。

>>> boosted_tree = tf.estimator.BoostedTreesRegressor(
...     feature_columns=all_feature_columns,
...     n_batches_per_layer=20,
...     n_trees=200)
>>> boosted_tree.train(
...     input_fn=lambda:train_input_fn(
...         df_train_norm, batch_size=BATCH_SIZE))
>>> eval_results = boosted_tree.evaluate(
...     input_fn=lambda:eval_input_fn(
...         df_test_norm, batch_size=8))
>>> print('Average-Loss {:.4f}'.format(
...       eval_results['average_loss']))
Average-Loss 11.2609 

如你所见,提升树回归器的平均损失低于 DNNRegressor。对于这样一个小数据集,这是预期中的结果。

在本节中,我们介绍了使用 TensorFlow 的 Estimators 进行回归的基本步骤。在下一小节中,我们将通过一个典型的分类示例来展示如何使用 Estimators。

使用 Estimators 进行 MNIST 手写数字分类

对于这个分类问题,我们将使用 TensorFlow 提供的 DNNClassifier Estimator,它可以让我们方便地实现多层感知机。在上一节中,我们详细介绍了使用预制 Estimators 的四个基本步骤,在本节中我们将需要重复这些步骤。首先,我们将导入 tensorflow_datasets (tfds) 子模块,利用它加载 MNIST 数据集,并指定模型的超参数。

Estimator API 和图问题

由于 TensorFlow 2.0 的某些部分仍然存在一些不完善的地方,你在执行下一个代码块时可能会遇到以下问题:RuntimeError: Graph is finalized and cannot be modified. 目前,没有很好的解决方案,建议的解决方法是在执行下一个代码块之前,重新启动 Python、IPython 或 Jupyter Notebook 会话。

设置步骤包括加载数据集并指定超参数(BUFFER_SIZE用于数据集的洗牌,BATCH_SIZE用于小批量的大小,以及训练周期数):

>>> import tensorflow_datasets as tfds
>>> import tensorflow as tf
>>> import numpy as np
>>> BUFFER_SIZE = 10000
>>> BATCH_SIZE = 64
>>> NUM_EPOCHS = 20
>>> steps_per_epoch = np.ceil(60000 / BATCH_SIZE) 

请注意,steps_per_epoch决定了每个周期中的迭代次数,这是处理无限重复数据集所必需的(如在第13章《使用TensorFlow并行化神经网络训练》中讨论的)。接下来,我们将定义一个辅助函数,用于预处理输入图像及其标签。

由于输入图像最初是'uint8'类型(在[0, 255]范围内),我们将使用tf.image.convert_image_dtype()将其类型转换为tf.float32(从而使其在[0, 1]范围内):

>>> def preprocess(item):
...     image = item['image']
...     label = item['label']
...     image = tf.image.convert_image_dtype(
...         image, tf.float32)
...     image = tf.reshape(image, (-1,))
...
...     return {'image-pixels':image}, label[..., tf.newaxis] 

步骤1:定义两个输入函数(一个用于训练,另一个用于评估):

>>> ## Step 1: Define the input functions
>>> def train_input_fn():
...     datasets = tfds.load(name='mnist')
...     mnist_train = datasets['train']
...
...     dataset = mnist_train.map(preprocess)
...     dataset = dataset.shuffle(BUFFER_SIZE)
...     dataset = dataset.batch(BATCH_SIZE)
...     return dataset.repeat()
>>> def eval_input_fn():
...     datasets = tfds.load(name='mnist')
...     mnist_test = datasets['test']
...     dataset = mnist_test.map(preprocess).batch(BATCH_SIZE)
...     return dataset 

请注意,特征字典中只有一个键,'image-pixels'。我们将在下一步中使用这个键。

步骤2:定义特征列:

>>> ## Step 2: feature columns
>>> image_feature_column = tf.feature_column.numeric_column(
...     key='image-pixels', shape=(28*28)) 

请注意,这里我们定义的特征列的大小为784(即 ),这是输入MNIST图像在扁平化后得到的大小。

步骤3:创建一个新的估计器。在这里,我们指定两个隐藏层:第一个隐藏层有32个单元,第二个隐藏层有16个单元。

我们还使用n_classes参数指定类别的数量(请记住,MNIST包含10个不同的数字,0-9):

>>> ## Step 3: instantiate the estimator
>>> dnn_classifier = tf.estimator.DNNClassifier(
...     feature_columns=[image_feature_column],
...     hidden_units=[32, 16],
...     n_classes=10,
...     model_dir='models/mnist-dnn/') 

步骤4:使用估计器进行训练、评估和预测:

>>> ## Step 4: train and evaluate
>>> dnn_classifier.train(
...     input_fn=train_input_fn,
...     steps=NUM_EPOCHS * steps_per_epoch)
>>> eval_result = dnn_classifier.evaluate(
...     input_fn=eval_input_fn)
>>> print(eval_result)
{'accuracy': 0.8957, 'average_loss': 0.3876346, 'loss': 0.38815108, 'global_step': 18760} 

到目前为止,你已经学会了如何使用预制的估计器(Estimators)并将其应用于初步评估,以便查看,例如,现有的模型是否适用于特定的问题。除了使用预制的估计器,我们还可以通过将一个Keras模型转换为估计器来创建一个新的估计器,我们将在下一小节中进行此操作。

从现有的Keras模型创建一个自定义估计器

将Keras模型转换为估计器在学术界和工业界都非常有用,特别是在你已经开发了一个模型并希望发布或与组织中的其他成员共享该模型的情况下。这样的转换使我们能够利用估计器的优势,比如分布式训练和自动检查点。此外,这将使其他人能够轻松使用此模型,特别是通过指定特征列和输入函数来避免在解释输入特征时的混乱。

为了学习如何从Keras模型创建我们自己的估计器,我们将使用之前的XOR问题。首先,我们将重新生成数据并将其拆分为训练集和验证集:

>>> tf.random.set_seed(1)
>>> np.random.seed(1)
>>> ## Create the data
>>> x = np.random.uniform(low=-1, high=1, size=(200, 2))
>>> y = np.ones(len(x))
>>> y[x[:, 0] * x[:, 1]<0] = 0
>>> x_train = x[:100, :]
>>> y_train = y[:100]
>>> x_valid = x[100:, :]
>>> y_valid = y[100:] 

让我们也构建一个我们想要稍后转换为估计器的Keras模型。我们将像之前一样使用Sequential类定义模型。这一次,我们还将添加一个输入层,定义为tf.keras.layers.Input,以给该模型的输入命名:

>>> model = tf.keras.Sequential([
...     tf.keras.layers.Input(shape=(2,), name='input-features'),
...     tf.keras.layers.Dense(units=4, activation='relu'),
...     tf.keras.layers.Dense(units=4, activation='relu'),
...     tf.keras.layers.Dense(units=4, activation='relu'),
...     tf.keras.layers.Dense(1, activation='sigmoid')
... ]) 

接下来,我们将回顾我们在前一小节中描述的四个步骤。步骤 1、2 和 4 与我们使用预制 Estimator 时相同。请注意,我们在步骤 1 和 2 中使用的输入特征的关键名称必须与我们在模型的输入层中定义的名称一致。代码如下:

>>> ## Step 1: Define the input functions
>>> def train_input_fn(x_train, y_train, batch_size=8):
...     dataset = tf.data.Dataset.from_tensor_slices(
...         ({'input-features':x_train}, y_train.reshape(-1, 1)))
...
...     # shuffle, repeat, and batch the examples.
...     return dataset.shuffle(100).repeat().batch(batch_size)
>>> def eval_input_fn(x_test, y_test=None, batch_size=8):
...     if y_test is None:
...         dataset = tf.data.Dataset.from_tensor_slices(
...             {'input-features':x_test})
...     else:
...         dataset = tf.data.Dataset.from_tensor_slices(
...             ({'input-features':x_test}, y_test.reshape(-1, 1)))
...
...
...     # shuffle, repeat, and batch the examples.
...     return dataset.batch(batch_size)
>>> ## Step 2: Define the feature columns
>>> features = [
...     tf.feature_column.numeric_column(
...         key='input-features:', shape=(2,))
... ] 

在步骤 3 中,我们将使用 tf.keras.estimator.model_to_estimator 将模型转换为 Estimator,而不是实例化一个预先制作好的 Estimator。在转换模型之前,我们首先需要对其进行编译:

>>> model.compile(optimizer=tf.keras.optimizers.SGD(),
...               loss=tf.keras.losses.BinaryCrossentropy(),
...               metrics=[tf.keras.metrics.BinaryAccuracy()])
>>> my_estimator = tf.keras.estimator.model_to_estimator(
...     keras_model=model,
...     model_dir='models/estimator-for-XOR/') 

最后,在步骤 4 中,我们可以使用 Estimator 训练模型并在验证数据集上评估其表现:

>>> ## Step 4: Use the estimator
>>> num_epochs = 200
>>> batch_size = 2
>>> steps_per_epoch = np.ceil(len(x_train) / batch_size)
>>> my_estimator.train(
...     input_fn=lambda: train_input_fn(x_train, y_train, batch_size),
...     steps=num_epochs * steps_per_epoch)
>>> my_estimator.evaluate(
...     input_fn=lambda: eval_input_fn(x_valid, y_valid, batch_size))
{'binary_accuracy': 0.96, 'loss': 0.081909806, 'global_step': 10000} 

如你所见,将一个 Keras 模型转换为 Estimator 非常直接。这样做使我们能够轻松利用 Estimator 的各种优势,例如分布式训练和在训练过程中自动保存检查点。

概述

在本章中,我们介绍了 TensorFlow 最基本和最有用的特性。我们首先讨论了从 TensorFlow v1.x 到 v2 的迁移,特别是我们使用了 TensorFlow 的动态计算图方法——即所谓的即时执行模式(eager execution),这种方式相比使用静态图使得实现计算更加便捷。我们还介绍了将 TensorFlow Variable 对象定义为模型参数的语义,并使用 tf.function 装饰器注解 Python 函数,以通过图编译提高计算效率。

在我们考虑了计算任意函数的偏导数和梯度的概念之后,我们更加详细地介绍了 Keras API。它为我们提供了一个用户友好的接口,用于构建更复杂的深度神经网络模型。最后,我们利用 TensorFlow 的 tf.estimator API 提供了一个一致的接口,这通常在生产环境中更为优选。我们通过将 Keras 模型转换为自定义 Estimator 来结束本章内容。

现在我们已经介绍了 TensorFlow 的核心机制,下一章将介绍 卷积神经网络CNN)架构在深度学习中的概念。卷积神经网络是强大的模型,在计算机视觉领域表现出色。

第十五章:使用深度卷积神经网络进行图像分类

在上一章中,我们深入探讨了TensorFlow API的不同方面,熟悉了张量和装饰函数,学习了如何使用TensorFlow Estimators。在本章中,你将学习用于图像分类的卷积神经网络CNNs)。我们将从讨论CNN的基本构建模块开始,采用自下而上的方法。接着,我们将深入了解CNN架构,并探索如何在TensorFlow中实现CNN。在本章中,我们将涵盖以下主题:

  • 一维和二维的卷积操作

  • CNN架构的构建模块

  • 在TensorFlow中实现深度CNN

  • 提高泛化性能的数据增强技术

  • 实现基于面部图像的CNN分类器,以预测个人的性别

CNN的构建模块

CNN是一个模型家族,最初的灵感来自于人类大脑视觉皮层在识别物体时的工作方式。CNN的发展可以追溯到1990年代,当时Yann LeCun和他的同事们提出了一种新型的神经网络架构,用于从图像中分类手写数字(Handwritten Digit Recognition with a Back-Propagation NetworkY. LeCun 等人,1989,发表于Neural Information Processing Systems (NeurIPS)会议)。

人类视觉皮层

1959年,David H. Hubel和Torsten Wiesel首次发现了我们大脑视觉皮层的功能,当时他们将一个微电极插入到麻醉猫的初级视觉皮层。然后,他们观察到,在猫眼前投射不同的光图案时,大脑神经元的反应不同。这最终导致了视觉皮层不同层次的发现。初级层主要检测边缘和直线,而更高层次则更专注于提取复杂的形状和图案。

由于卷积神经网络(CNN)在图像分类任务中的出色表现,这种特定类型的前馈神经网络(NN)受到了广泛关注,并推动了计算机视觉领域机器学习的巨大进步。几年后,2019年,Yann LeCun因其在人工智能(AI)领域的贡献获得了图灵奖(计算机科学领域的最高奖项),与他一起获奖的还有另外两位研究者,Yoshua Bengio和Geoffrey Hinton,他们的名字你在前面的章节中已经遇到过。

在接下来的章节中,我们将讨论CNN的更广泛概念,以及为什么卷积架构通常被描述为“特征提取层”。然后,我们将深入探讨CNN中常用的卷积操作类型的理论定义,并通过一维和二维卷积计算的示例来讲解。

理解CNN和特征层次

成功提取显著相关特征是任何机器学习算法性能的关键,传统的机器学习模型依赖于输入特征,这些特征可能来自领域专家或基于计算特征提取技术。

某些类型的神经网络,如CNN,能够自动从原始数据中学习出对于特定任务最有用的特征。因此,通常将CNN层视为特征提取器:早期的层(即输入层之后的层)从原始数据中提取低级特征,而后来的层(通常是全连接层,如多层感知器(MLP)中的层)则使用这些特征来预测连续目标值或分类标签。

某些类型的多层神经网络,尤其是深度卷积神经网络,构建了所谓的特征层次结构,通过逐层组合低级特征来形成高级特征。例如,如果我们处理的是图像,那么低级特征,如边缘和斑块,来自早期层,这些特征组合在一起形成高级特征。这些高级特征可以形成更复杂的形状,例如建筑物、猫或狗的轮廓。

如下图所示,CNN从输入图像中计算特征图,其中每个元素来自输入图像中局部像素块:

(图片由Alexander Dummer提供,来源于Unsplash)

这个局部像素块称为局部感受野。CNN通常在图像相关任务上表现非常好,这主要得益于两个重要思想:

  • 稀疏连接:特征图中的单个元素仅连接到一个小的像素块。(这与感知器连接到整个输入图像非常不同。你可能会发现回顾并比较我们在第12章从零开始实现多层人工神经网络”中实现的全连接网络非常有用。)

  • 参数共享:相同的权重用于输入图像的不同块区域。

作为这两种思想的直接结果,用卷积层替换传统的全连接多层感知器(MLP)大大减少了网络中的权重(参数)数量,我们将看到在捕捉显著特征的能力上有所提升。在图像数据的背景下,假设邻近的像素通常比远离彼此的像素更相关,这是合理的。

通常,CNN 由多个卷积层和子采样层组成,最后会接一个或多个全连接层。全连接层本质上是一个多层感知器(MLP),其中每个输入单元 i 都与每个输出单元 j 连接,并且有权重 (我们在第12章《从零开始实现多层人工神经网络》中已经详细介绍过)。

请注意,子采样层,通常称为池化层,没有任何可学习的参数;例如,池化层中没有权重或偏置单元。然而,卷积层和全连接层有权重和偏置,这些会在训练过程中进行优化。

在接下来的几节中,我们将更详细地研究卷积层和池化层,并了解它们是如何工作的。为了理解卷积操作的原理,我们从一维卷积开始,它有时用于处理某些类型的序列数据,如文本。在讨论完一维卷积后,我们将继续探讨通常应用于二维图像的典型二维卷积。

执行离散卷积

离散卷积(或简称卷积)是卷积神经网络(CNN)中的一个基础操作。因此,理解这个操作的原理非常重要。在本节中,我们将介绍数学定义,并讨论一些计算一维张量(向量)和二维张量(矩阵)卷积的简单算法。

请注意,本节中的公式和描述仅用于帮助理解 CNN 中卷积操作的原理。事实上,像 TensorFlow 这样的包中已经存在更高效的卷积操作实现,正如你将在本章后面看到的那样。

数学符号

在本章中,我们将使用下标来表示多维数组(张量)的大小;例如, 是一个大小为 的二维数组。我们使用方括号 [ ] 来表示多维数组的索引。

例如,A[i, j] 表示矩阵 A 中索引 ij 处的元素。此外,请注意,我们使用一个特殊符号 来表示两个向量或矩阵之间的卷积操作,这与 Python 中的乘法操作符 * 不同。

一维离散卷积

让我们从一些基本的定义和符号开始,这些是我们接下来将使用的。两个向量 xw 的离散卷积用 表示,其中向量 x 是我们的输入(有时称为信号),而 w 被称为滤波器。离散卷积在数学上定义如下:

如前所述,方括号[ ]用于表示向量元素的索引。索引 i 遍历输出向量 y 的每个元素。前面的公式中有两个需要澄清的奇怪之处: 的索引和 x 的负索引。

求和从 的过程看起来很奇怪,主要是因为在机器学习应用中,我们通常处理的是有限的特征向量。例如,如果 x 有 10 个特征,索引为 0, 1, 2,…, 8, 9,那么索引 对于 x 来说是越界的。因此,为了正确计算前面公式中的求和,假设 xw 被填充了零。这将导致输出向量 y 也具有无限大小,并且包含很多零。由于在实际应用中这并没有用处,因此 x 仅会被填充有限数量的零。

这个过程被称为 零填充,或简称为 填充。这里,填充在每一侧的零的数量用 p 表示。下面的图示展示了一个一维向量 x 的填充示例:

假设原始输入 x 和滤波器 w 分别有 nm 个元素,其中 。因此,填充后的向量 的大小为 n + 2p。计算离散卷积的实际公式将变为以下形式:

现在我们已经解决了无限索引的问题,第二个问题是使用 i + mk 来索引 x。这里需要注意的要点是,在这个求和过程中,xw 的索引方向是不同的。用一个反向方向的索引计算和,相当于在将其中一个向量(xw)填充后,翻转其中一个向量,使得两个索引都朝前计算,然后简单地计算它们的点积。假设我们将滤波器 w 进行翻转(旋转),得到旋转后的滤波器,。然后,计算点积 ,得到一个元素 y[i],其中 x[i: i + m] 是 x 中大小为 m 的一个片段。这个操作像滑动窗口一样重复,直到计算出所有的输出元素。下图展示了一个例子,其中 x = [3 2 1 7 1 2 5 4],并且 ,计算得到前三个输出元素:

你可以在前面的示例中看到填充大小为零(p = 0)。注意,旋转后的滤波器 每次平移时都移动了两个单元。这种平移是卷积的另一个超参数,即步幅s。在这个例子中,步幅是二,s = 2。请注意,步幅必须是小于输入向量大小的正数。我们将在下一部分详细讨论填充和步幅。

互相关

输入向量与滤波器之间的互相关(或简称相关)用 表示,它与卷积非常相似,唯一的区别是:在互相关中,乘法是在相同方向上进行的。因此,旋转滤波器矩阵w在每个维度中并不是必需的。从数学角度讲,互相关定义如下:

填充和步幅的相同规则也可以应用于互相关。请注意,大多数深度学习框架(包括 TensorFlow)实现了互相关,但称其为卷积,这在深度学习领域是一种常见的约定。

填充输入以控制输出特征图的大小

到目前为止,我们仅在卷积中使用了零填充来计算有限大小的输出向量。从技术上讲,可以使用任何 来应用填充。根据p的选择,边界单元可能与位于 x 中间的单元有所不同。

现在,考虑一个例子,其中n = 5 和 m = 3。那么,在p = 0的情况下,x[0] 仅用于计算一个输出元素(例如,y[0]),而 x[1] 用于计算两个输出元素(例如,y[0] 和 y[1])。因此,你可以看到,x 中元素的这种不同处理方法可以人为地更加突出中间元素 x[2],因为它出现在大多数计算中。如果选择p = 2,则可以避免这个问题,在这种情况下,x 的每个元素都将参与计算 y 的三个元素。

此外,输出的大小y也取决于我们使用的填充策略。

在实践中,常用的填充模式有三种:全填充相同填充有效填充

  • 在全模式下,填充参数 p 设置为 p = m – 1。全填充增加了输出的维度,因此在CNN架构中很少使用。

  • 相同填充通常用于确保输出向量与输入向量 x 的大小相同。在这种情况下,填充参数 p 是根据滤波器大小计算的,并且要求输入和输出大小相同。

  • 最后,在有效模式下计算卷积是指p = 0(无填充)的情况。

下图展示了对于一个简单的 像素输入,卷积核大小为 ,步长为1时的三种不同填充模式:

在卷积神经网络(CNN)中,最常用的填充模式是同填充(same padding)。它相对于其他填充模式的一个优点是,它能够保持向量的大小——或者在处理与图像相关的任务时保持输入图像的高度和宽度——这使得设计网络架构更加方便。

例如,valid填充相对于full和same填充的一个大缺点是,在具有多个层的神经网络中,张量的体积会显著减少,这可能会对网络性能产生不利影响。

在实践中,建议使用同填充(same padding)来保持卷积层的空间大小,而通过池化层来减少空间大小。至于全填充(full padding),它的大小导致输出大于输入大小。全填充通常用于信号处理应用中,在这些应用中,最小化边界效应很重要。然而,在深度学习中,边界效应通常不是问题,因此我们很少看到全填充被实际使用。

确定卷积输出的大小

卷积的输出大小由我们沿输入向量移动滤波器 w 的次数决定。假设输入向量的大小为 n,滤波器的大小为 m,那么带有填充 p 和步长 s 的输出大小可以通过以下公式确定:

在这里, 表示 向下取整 操作。

向下取整操作

向下取整操作返回不大于输入值的最大整数,例如:

考虑以下两种情况:

  • 计算输入向量大小为10,卷积核大小为5,填充为2,步长为1时的输出大小:

    (请注意,在这种情况下,输出大小与输入相同;因此,我们可以得出结论,这就是同填充模式。)

  • 当我们有一个大小为3的卷积核和步长为2时,对于相同的输入向量,输出大小如何变化?

如果你有兴趣深入了解卷积输出的大小,我们推荐阅读 Vincent DumoulinFrancesco Visin 的手稿《深度学习卷积算术指南》,该文档可以在 https://arxiv.org/abs/1603.07285 免费获取。

最后,为了学习如何计算一维卷积,以下代码块展示了一个简单的实现,并将其结果与 numpy.convolve 函数进行比较。代码如下:

>>> import numpy as np
>>> def conv1d(x, w, p=0, s=1):
...     w_rot = np.array(w[::-1])
...     x_padded = np.array(x)
...     if p > 0:
...         zero_pad = np.zeros(shape=p)
...         x_padded = np.concatenate([zero_pad,
...                                    x_padded,
...                                    zero_pad])
...     res = []
...     for i in range(0, int(len(x)/s),s):
...         res.append(np.sum(x_padded[i:i+w_rot.shape[0]] *
...                           w_rot))
...     return np.array(res)
>>> ## Testing:
>>> x = [1, 3, 2, 4, 5, 6, 1, 3]
>>> w = [1, 0, 3, 1, 2]
>>> print('Conv1d Implementation:',
...       conv1d(x, w, p=2, s=1))
Conv1d Implementation: [ 5\. 14\. 16\. 26\. 24\. 34\. 19\. 22.]
>>> print('NumPy Results:',
...       np.convolve(x, w, mode='same'))
NumPy Results: [ 5 14 16 26 24 34 19 22] 

到目前为止,我们主要关注的是向量的卷积(1D卷积)。我们从1D卷积开始,以便使概念更易于理解。在下一节中,我们将更详细地介绍二维卷积,它们是卷积神经网络(CNN)在与图像相关任务中的基本构建模块。

执行二维离散卷积

你在前几节中学到的概念可以很容易地扩展到二维。当我们处理二维输入,如矩阵 和滤波器矩阵 ,其中 ,那么矩阵 就是 XW 之间的二维卷积的结果。数学上可以定义为如下:

请注意,如果你省略了其中一个维度,剩下的公式与我们之前用于计算1D卷积的公式完全相同。实际上,之前提到的所有技术,如零填充、旋转滤波器矩阵和步幅的使用,也适用于二维卷积,只要它们被独立地扩展到两个维度。下图展示了对大小为 的输入矩阵进行二维卷积,使用的卷积核大小为 。输入矩阵在零填充下,p = 1。结果,二维卷积的输出将具有大小

下例说明了如何计算输入矩阵 和卷积核矩阵 之间的二维卷积,使用填充 p = (1, 1) 和步幅 s = (2, 2)。根据指定的填充方式,输入矩阵的每一侧都会添加一层零,得到填充后的矩阵 ,如下所示:

对于上述滤波器,旋转后的滤波器为:

请注意,这种旋转与转置矩阵不同。为了在NumPy中得到旋转后的滤波器,我们可以写W_rot=W[::-1,::-1]。接下来,我们可以将旋转后的滤波器矩阵沿着填充的输入矩阵 移动,像滑动窗口一样,并计算元素乘积的和,这个操作在下图中用 表示:

结果将是 矩阵,Y

我们也可以根据所描述的简单算法实现二维卷积。scipy.signal包提供了一种通过scipy.signal.convolve2d函数计算二维卷积的方法:

>>> import numpy as np
>>> import scipy.signal
>>> def conv2d(X, W, p=(0, 0), s=(1, 1)):
...     W_rot = np.array(W)[::-1,::-1]
...     X_orig = np.array(X)
...     n1 = X_orig.shape[0] + 2*p[0]
...     n2 = X_orig.shape[1] + 2*p[1]
...     X_padded = np.zeros(shape=(n1, n2))
...     X_padded[p[0]:p[0]+X_orig.shape[0],
...              p[1]:p[1]+X_orig.shape[1]] = X_orig
...
...     res = []
...     for i in range(0, int((X_padded.shape[0] - \
...                            W_rot.shape[0])/s[0])+1, s[0]):
...         res.append([])
...         for j in range(0, int((X_padded.shape[1] - \
...                                W_rot.shape[1])/s[1])+1, s[1]):
...             X_sub = X_padded[i:i+W_rot.shape[0],
...                              j:j+W_rot.shape[1]]
...             res[-1].append(np.sum(X_sub * W_rot))
...     return(np.array(res))
>>> X = [[1, 3, 2, 4], [5, 6, 1, 3], [1, 2, 0, 2], [3, 4, 3, 2]]
>>> W = [[1, 0, 3], [1, 2, 1], [0, 1, 1]]
>>> print('Conv2d Implementation:\n',
...       conv2d(X, W, p=(1, 1), s=(1, 1)))
Conv2d Implementation:
[[ 11\.  25\.  32\.  13.]
 [ 19\.  25\.  24\.  13.]
 [ 13\.  28\.  25\.  17.]
 [ 11\.  17\.  14\.   9.]]
>>> print('SciPy Results:\n',
...       scipy.signal.convolve2d(X, W, mode='same'))
SciPy Results:
[[11 25 32 13]
 [19 25 24 13]
 [13 28 25 17]
 [11 17 14  9]] 

计算卷积的高效算法

我们提供了一个简单的实现来计算二维卷积,以便帮助理解相关概念。然而,这个实现从内存需求和计算复杂度的角度来看非常低效。因此,它不应该在实际的神经网络应用中使用。

其中一方面是,滤波矩阵在大多数工具(如TensorFlow)中实际上并不会旋转。此外,近年来,已经开发出更多高效的算法,利用傅里叶变换来计算卷积。还需要注意的是,在神经网络的背景下,卷积核的大小通常远小于输入图像的大小。

例如,现代CNN通常使用如 等卷积核大小,针对这些卷积操作,已经设计了高效的算法,使得卷积操作能够更高效地执行,如Winograd最小滤波算法。这些算法超出了本书的范围,但如果你有兴趣了解更多,可以阅读Andrew LavinScott Gray于2015年发布的手稿《卷积神经网络的快速算法》(Fast Algorithms for Convolutional Neural Networks),该文献可以在https://arxiv.org/abs/1509.09308免费下载。

在下一节中,我们将讨论下采样或池化,这是CNN中常用的另一种重要操作。

下采样层

下采样通常应用于卷积神经网络(CNN)中的两种池化操作:最大池化均值池化(也称为平均池化)。池化层通常用 表示。在这里,下标决定了执行最大或均值操作的邻域大小(每个维度中相邻像素的数量)。我们将这样的邻域称为池化大小

操作在以下图中描述。在这里,最大池化从像素的邻域中提取最大值,而均值池化计算它们的平均值:

池化的优势有两个方面:

  • 池化(最大池化)引入了局部不变性。这意味着局部邻域的微小变化不会改变最大池化的结果。

    因此,它有助于生成对输入数据噪声更加鲁棒的特征。参见以下示例,显示了两个不同输入矩阵的最大池化结果,,它们产生了相同的输出:

  • 池化减少了特征的大小,从而提高了计算效率。此外,减少特征数量可能还会降低过拟合的程度。

重叠池化与非重叠池化

传统上,池化假设是非重叠的。池化通常在非重叠的邻域上执行,可以通过将步幅(stride)参数设置为池化大小来实现。例如,非重叠池化层需要步幅参数。另一方面,如果步幅小于池化大小,则会发生重叠池化。在卷积网络中使用重叠池化的一个例子,详见 A. KrizhevskyI. SutskeverG. Hinton 在2012年发表的论文 ImageNet Classification with Deep Convolutional Neural Networks,该论文可在https://papers.nips.cc/paper/4824-imagenet-classification-with-deep-convolutional-neural-networks免费下载。

尽管池化仍然是许多CNN架构的一个重要组成部分,但也有一些CNN架构在没有使用池化层的情况下被开发出来。研究人员使用具有步幅为2\的卷积层来代替池化层,以减少特征的大小。

从某种意义上讲,你可以将步幅为2的卷积层视为具有可学习权重的池化层。如果你对开发有无池化层的不同CNN架构的实证比较感兴趣,我们推荐阅读研究文章 Striving for Simplicity: The All Convolutional Net,作者 Jost Tobias SpringenbergAlexey DosovitskiyThomas BroxMartin Riedmiller。该文章可以在https://arxiv.org/abs/1412.6806免费下载。

将一切整合——实现CNN

到目前为止,你已经学习了CNN的基本组成部分。本章所述的概念实际上并不比传统的多层神经网络(NN)更加复杂。我们可以说,传统神经网络中最重要的操作是矩阵乘法。例如,我们使用矩阵乘法来计算预激活(或净输入),如 z = Wx + b。这里,x 是表示像素的列向量(矩阵),W 是将像素输入与每个隐藏单元连接的权重矩阵。

在卷积神经网络(CNN)中,这一操作通过卷积操作来替代,如图中的,其中 X 是表示像素的矩阵,采用排列方式。在这两种情况下,预激活值会传递到激活函数,以获得隐藏单元的激活值,即,其中是激活函数。此外,你还记得,子采样是CNN的另一个组成部分,通常以池化(pooling)的形式出现,如前一节所述。

处理多个输入或颜色通道

卷积层的输入可能包含一个或多个二维数组或矩阵,尺寸为 (例如,图像的高度和宽度,以像素为单位)。这些 矩阵被称为 通道。常规的卷积层实现期望输入为三维张量表示,例如三维数组 ,其中 是输入通道的数量。例如,假设我们将图像作为 CNN 第一层的输入。如果图像是彩色的并使用 RGB 色彩模式,则 (表示 RGB 中的红、绿、蓝色通道)。然而,如果图像是灰度图像,则只有一个通道,即 ,其中包含灰度像素强度值。

读取图像文件

在处理图像时,我们可以使用 uint8(无符号 8 位整数)数据类型将图像读取为 NumPy 数组,相比于 16 位、32 位或 64 位整数类型,这样可以减少内存使用。

无符号 8 位整数的取值范围为 [0, 255],这个范围足以存储 RGB 图像的像素信息,而 RGB 图像的像素值也在这个范围内。

第13章,《使用 TensorFlow 并行化神经网络训练》中,你看到 TensorFlow 提供了一个模块,通过 tf.iotf.image 子模块加载/存储和操作图像。让我们回顾一下如何读取图像(这个 RGB 图像位于代码包文件夹中,章节提供的 https://github.com/rasbt/python-machine-learning-book-3rd-edition/tree/master/code/ch15):

>>> import tensorflow as tf
>>> img_raw = tf.io.read_file('example-image.png')
>>> img = tf.image.decode_image(img_raw)
>>> print('Image shape:', img.shape)
Image shape: (252, 221, 3) 

在 TensorFlow 中构建模型和数据加载器时,建议也使用 tf.image 来读取输入图像。

现在,让我们看一个如何在 Python 会话中读取图像的例子,使用 imageio 包。我们可以通过 condapip 在命令行终端中安装 imageio

> conda install imageio 

或者

> pip install imageio 

一旦安装了 imageio,我们可以使用 imread 函数通过 imageio 包读取我们之前使用的相同图像:

>>> import imageio
>>> img = imageio.imread('example-image.png')
>>> print('Image shape:', img.shape)
Image shape: (252, 221, 3)
>>> print('Number of channels:', img.shape[2])
Number of channels: 3
>>> print('Image data type:', img.dtype)
Image data type: uint8
>>> print(img[100:102, 100:102, :])
[[[179 134 110]
 [182 136 112]]
[[180 135 11]
 [182 137 113]]] 

现在你已经熟悉了输入数据的结构,接下来的问题是,如何将多个输入通道融入我们在前面讨论的卷积操作中?答案很简单:我们为每个通道分别执行卷积操作,然后通过矩阵求和将结果加起来。与每个通道 (c) 相关的卷积有其自己的卷积核矩阵,记为 W[:, :, c]。总的预激活结果可以通过以下公式计算:

最终结果,A,是一个特征图。通常,CNN的卷积层有多个特征图。如果我们使用多个特征图,卷积核张量将变为四维:。这里, 是卷积核的大小, 是输入通道的数量, 是输出特征图的数量。因此,现在我们在前面的公式中加入输出特征图的数量并更新它,如下所示:

为了总结我们在神经网络中计算卷积的讨论,让我们来看一下下面的例子,展示了一个卷积层,后面跟着一个池化层。在这个例子中,有三个输入通道。卷积核张量是四维的。每个卷积核矩阵表示为 ,并且有三个卷积核,每个输入通道一个。此外,有五个这样的卷积核,对应五个输出特征图。最后,池化层用于对特征图进行下采样:

在上述例子中有多少可训练参数?

为了说明卷积、参数共享稀疏连接的优势,我们通过一个例子来说明。前面图中显示的网络中的卷积层是一个四维张量。因此,与卷积核相关的参数数量为 。此外,对于每个卷积层的输出特征图,还有一个偏置向量。因此,偏置向量的大小为5。池化层没有任何(可训练的)参数;因此,我们可以写出以下公式:

如果输入张量的大小为 ,假设卷积采用相同填充模式,则输出特征图的大小将为

请注意,如果我们使用全连接层代替卷积层,则这个数字会大得多。在全连接层的情况下,为了达到相同数量的输出单元,权重矩阵的参数数量将如下所示:

此外,偏置向量的大小为 (每个输出单元有一个偏置元素)。由于 ,我们可以看到可训练参数数量的差异是显著的。

最后,正如之前提到的,通常卷积操作是通过将具有多个颜色通道的输入图像视为矩阵堆栈来执行的;也就是说,我们分别在每个矩阵上执行卷积,然后将结果相加,正如之前的图示所示。然而,卷积也可以扩展到3D体积,如果你处理的是3D数据集,例如,如Daniel MaturanaSebastian Scherer于2015年发表的论文VoxNet: A 3D Convolutional Neural Network for Real-Time Object Recognition中所示(可以通过https://www.ri.cmu.edu/pub_files/2015/9/voxnet_maturana_scherer_iros15.pdf访问)。

在下一节中,我们将讨论如何对神经网络进行正则化。

使用dropout正则化神经网络

选择网络的大小,无论是传统的(全连接的)神经网络(NN)还是卷积神经网络(CNN),一直是一个具有挑战性的问题。例如,权重矩阵的大小和层数需要调整,以实现合理的性能。

你可能还记得在第14章深入探索——TensorFlow的机制中提到,只有一个简单的没有隐藏层的网络只能捕捉到一个线性决策边界,这不足以应对异或(XOR)或类似的问题。网络的容量是指它能够学习并逼近的函数的复杂度。小型网络或具有相对较少参数的网络容量较低,因此可能会欠拟合,导致性能较差,因为它们无法学习复杂数据集的潜在结构。然而,过大的网络可能会导致过拟合,即网络会记住训练数据,在训练数据集上表现极其优秀,但在保留的测试数据集上表现差劲。当我们处理现实世界的机器学习问题时,我们并不知道网络应该有多大先验

解决这个问题的一种方法是构建一个容量相对较大的网络(实际上,我们希望选择一个稍微大于必要的容量)以便在训练数据集上表现良好。然后,为了防止过拟合,我们可以应用一种或多种正则化方法,以在新数据上实现良好的泛化性能,例如保留的测试数据集。

第3章使用scikit-learn的机器学习分类器简介中,我们讨论了L1和L2正则化。在通过正则化应对过拟合一节中,您看到,L1和L2正则化都可以通过在损失函数中加入惩罚项来防止或减少过拟合的影响,从而在训练过程中缩小权重参数。虽然L1和L2正则化同样适用于神经网络(NNs),且L2是更常见的选择,但还有其他用于正则化神经网络的方法,例如dropout(丢弃法),我们将在本节中讨论。在我们继续讨论dropout之前,要在卷积网络或全连接(稠密)网络中使用L2正则化,您可以通过在使用Keras API时设置某一层的kernel_regularizer,简单地将L2惩罚项添加到损失函数中,如下所示(它将自动相应地修改损失函数):

>>> from tensorflow import keras 
>>> conv_layer = keras.layers.Conv2D(
...     filters=16,
...     kernel_size=(3,3),
...     kernel_regularizer=keras.regularizers.l2(0.001))
>>> fc_layer = keras.layers.Dense(
...     units=16,
...     kernel_regularizer=keras.regularizers.l2(0.001)) 

近年来,dropout成为一种流行的正则化(深度)神经网络技术,用于避免过拟合,从而提高泛化性能(Dropout: a simple way to prevent neural networks from overfitting,作者:N. Srivastava, G. Hinton, A. Krizhevsky, I. Sutskever, 和 R. Salakhutdinov机器学习研究杂志 15.1,第1929-1958页,2014,http://www.jmlr.org/papers/volume15/srivastava14a/srivastava14a.pdf)。Dropout通常应用于较高层次的隐藏单元,其工作原理如下:在神经网络的训练阶段,每次迭代时,按概率(或保持概率)随机丢弃一部分隐藏单元。这个dropout概率由用户决定,常见的选择是p = 0.5,正如Nitish Srivastava等人2014年在上述文章中讨论的。当丢弃一定比例的输入神经元时,剩余神经元的权重会进行重新缩放,以补偿丢失的(被丢弃的)神经元。

这种随机dropout的效果是迫使网络学习数据的冗余表示。因此,网络不能依赖于任何一组隐藏单元的激活,因为它们可能在训练过程中随时被关闭,网络被迫从数据中学习更一般和更强健的模式。

这种随机dropout可以有效防止过拟合。下图展示了在训练阶段应用概率p = 0.5的dropout的示例,其中一半的神经元会随机变得不活跃(每次前向传播时随机选择丢弃的单元)。然而,在预测过程中,所有神经元都会参与计算下一层的预激活。

如图所示,一个需要记住的重要点是,单元在训练过程中可能会随机丢失,而在评估(推理)阶段,所有的隐藏单元都必须保持激活(例如, )。为了确保在训练和预测时整体激活值在相同的尺度上,必须适当缩放激活的神经元(例如,如果dropout概率设置为 p = 0.5,则需要将激活值减半)。

然而,由于在做预测时总是缩放激活值不方便,TensorFlow等工具会在训练过程中缩放激活值(例如,如果dropout概率设置为 p = 0.5,则将激活值加倍)。这种方法通常被称为逆向dropout。

虽然关系并不立即显现,但dropout可以解释为一个模型集成的共识(平均)。正如第7章中讨论的,将不同模型结合用于集成学习,在集成学习中,我们独立训练多个模型。在预测时,我们使用所有训练过的模型的共识。我们已经知道,模型集成的表现通常优于单一模型。然而,在深度学习中,训练多个模型以及收集和平均多个模型的输出计算代价很高。这里,dropout提供了一种解决方法,它可以高效地一次性训练多个模型,并在测试或预测时计算它们的平均预测。

如前所述,模型集成与dropout之间的关系并不立即显现。然而,考虑到在dropout中,我们每个小批量数据都有一个不同的模型(因为在每次前向传播时,随机将部分权重置为零)。

然后,通过对小批量数据进行迭代,我们本质上对 个模型进行采样,其中 h 是隐藏单元的数量。

然而,dropout与常规集成的一个不同之处在于,我们在这些“不同的模型”之间共享权重,这可以看作是一种正则化形式。然后,在“推理”过程中(例如,预测测试数据集中的标签),我们可以对训练过程中采样的所有不同模型进行平均。这是非常昂贵的。

然后,平均模型,即计算由模型 i 返回的类成员概率的几何平均,可以按如下方式计算:

现在,dropout背后的技巧是,模型集成的几何平均(这里指的是 M 个模型)可以通过将训练过程中最后一个(或最终)模型的预测结果按 1/(1 – p) 的比例进行缩放来近似,这比使用前面公式显式计算几何平均要便宜得多。(实际上,如果我们考虑线性模型,这个近似与真实的几何平均是完全等价的。)

分类的损失函数

第 13 章使用 TensorFlow 并行化神经网络训练 中,我们见过不同的激活函数,如 ReLU、sigmoid 和 tanh。像 ReLU 这样的激活函数,主要用于神经网络的中间(隐藏)层,为模型加入非线性。但其他激活函数,如 sigmoid(用于二分类)和 softmax(用于多分类),则被添加到最后的(输出)层,从而使得模型的输出为类别成员概率。如果输出层没有包含 sigmoid 或 softmax 激活函数,那么模型将计算 logits,而非类别成员概率。

这里重点讲解分类问题,依据问题类型(二分类与多分类)和输出类型(logits 与概率),我们应选择合适的损失函数来训练我们的模型。二元交叉熵是二分类(具有单一输出单元)的损失函数,而分类交叉熵是多分类问题的损失函数。在 Keras API 中,分类交叉熵损失函数提供了两种选项,取决于真实标签是采用 one-hot 编码格式(例如,[0, 0, 1, 0]),还是作为整数标签(例如,y=2),在 Keras 中,这也被称为“稀疏”表示。

下表描述了 Keras 中三种可用于处理以下三种情况的损失函数:二分类、多类别(使用 one-hot 编码的真实标签)和多类别(使用整数(稀疏)标签)。这三种损失函数中的每一种也可以选择接受 logits 或类别成员概率形式的预测:

请注意,通常由于数值稳定性的原因,提供 logits 而非类别成员概率来计算交叉熵损失更为优选。如果我们将 logits 作为输入提供给损失函数并设置 from_logits=True,相应的 TensorFlow 函数将使用更高效的实现来计算损失及损失对权重的导数。这是可能的,因为某些数学项会相互抵消,因此在提供 logits 作为输入时,不需要显式计算这些项。

以下代码将展示如何使用这三种损失函数,并且输入可以是 logits 或类别成员概率的两种不同格式:

>>> import tensorflow_datasets as tfds
>>> ####### Binary Crossentropy
>>> bce_probas = tf.keras.losses.BinaryCrossentropy(from_logits=False)
>>> bce_logits = tf.keras.losses.BinaryCrossentropy(from_logits=True)
>>> logits = tf.constant([0.8])
>>> probas = tf.keras.activations.sigmoid(logits)
>>> tf.print(
...     'BCE (w Probas): {:.4f}'.format(
...     bce_probas(y_true=[1], y_pred=probas)),
...     '(w Logits): {:.4f}'.format(
...     bce_logits(y_true=[1], y_pred=logits)))
BCE (w Probas): 0.3711 (w Logits): 0.3711
>>> ####### Categorical Crossentropy
>>> cce_probas = tf.keras.losses.CategoricalCrossentropy(
...     from_logits=False)
>>> cce_logits = tf.keras.losses.CategoricalCrossentropy(
...     from_logits=True)
>>> logits = tf.constant([[1.5, 0.8, 2.1]])
>>> probas = tf.keras.activations.softmax(logits)
>>> tf.print(
...     'CCE (w Probas): {:.4f}'.format(
...     cce_probas(y_true=[0, 0, 1], y_pred=probas)),
...     '(w Logits): {:.4f}'.format(
...     cce_logits(y_true=[0, 0, 1], y_pred=logits)))
CCE (w Probas): 0.5996 (w Logits): 0.5996
>>> ####### Sparse Categorical Crossentropy
>>> sp_cce_probas = tf.keras.losses.SparseCategoricalCrossentropy(
...     from_logits=False)
>>> sp_cce_logits = tf.keras.losses.SparseCategoricalCrossentropy(
...     from_logits=True)
>>> tf.print(
...     'Sparse CCE (w Probas): {:.4f}'.format(
...     sp_cce_probas(y_true=[2], y_pred=probas)),
...     '(w Logits): {:.4f}'.format(
...     sp_cce_logits(y_true=[2], y_pred=logits)))
Sparse CCE (w Probas): 0.5996 (w Logits): 0.5996 

请注意,有时你可能会遇到一个实现,其中对于二分类任务使用了类别交叉熵损失。通常,当我们进行二分类任务时,模型会为每个示例返回一个单一的输出值。我们将这个单一的模型输出解释为正类的概率(例如,类别 1),P[class = 1]。在二分类问题中,隐含着 P[class = 0] = 1 – P[class = 1];因此,我们不需要第二个输出单元来获取负类的概率。然而,有时实践者会选择为每个训练示例返回两个输出,并将它们解释为每个类别的概率:P[class = 0] 与 P[class = 1]。在这种情况下,建议使用 softmax 函数(而不是逻辑 sigmoid)来归一化输出(使它们的和为 1),并且类别交叉熵是合适的损失函数。

使用 TensorFlow 实现深度 CNN

第14章中,深入了解 – TensorFlow的机制,你可能还记得我们使用了 TensorFlow Estimators 来解决手写数字识别问题,使用了不同的 TensorFlow API 级别。你可能还记得我们通过使用 DNNClassifier Estimator 和两层隐藏层,达到了大约 89% 的准确率。

现在,让我们实现一个卷积神经网络(CNN),看看它是否能在手写数字分类任务中实现比多层感知机(DNNClassifier)更好的预测性能。请注意,在第14章中,我们看到的全连接层在这个问题上表现良好。然而,在某些应用中,如从手写数字中读取银行账户号码,即使是微小的错误也可能造成巨大的损失。因此,减少这个错误至关重要。

多层卷积神经网络架构

我们将要实现的网络架构如下图所示。输入为 灰度图像。考虑到通道数(灰度图像的通道数为 1)以及一批输入图像,输入张量的维度将是

输入数据经过两个卷积层处理,卷积核大小为 。第一个卷积层有 32 个输出特征图,第二个卷积层有 64 个输出特征图。每个卷积层后面都有一个子采样层,采用最大池化操作,。然后,一个全连接层将输出传递给第二个全连接层,后者作为最终的 softmax 输出层。我们将要实现的网络架构如下图所示:

每一层张量的维度如下:

  • 输入:

  • Conv_1:

  • Pooling_1:

  • Conv_2:

  • Pooling_2:

  • FC_1:

  • FC_2 和 softmax 层:

对于卷积核,我们使用 strides=1,使输入维度在生成的特征图中得到保留。对于池化层,我们使用 strides=2 来对图像进行子采样,从而缩小输出特征图的尺寸。我们将使用 TensorFlow Keras API 实现这个网络。

加载和预处理数据

你会回想起在第13章,《使用 TensorFlow 并行化神经网络训练》中,你学到了两种从 tensorflow_datasets 模块加载可用数据集的方法。一种方法基于一个三步过程,而另一种更简单的方法是使用一个叫 load 的函数,它将这三步封装起来。这里,我们将使用第一种方法。加载 MNIST 数据集的三步过程如下:

>>> import tensorflow_datasets as tfds
>>> ## Loading the data
>>> mnist_bldr = tfds.builder('mnist')
>>> mnist_bldr.download_and_prepare()
>>> datasets = mnist_bldr.as_dataset(shuffle_files=False)
>>> mnist_train_orig = datasets['train']
>>> mnist_test_orig = datasets['test'] 

MNIST 数据集提供了预先指定的训练集和测试集分割方案,但我们还希望从训练集分割出一个验证集。请注意,在第三步中,我们在 .as_dataset() 方法中使用了一个可选参数 shuffle_files=False。这防止了数据集的初始打乱,这对于我们来说是必要的,因为我们希望将训练数据集分成两部分:一部分较小的训练集和一部分验证集。(注意:如果没有关闭初始打乱,每次获取小批量数据时,数据集都会重新打乱。)

这种行为的一个例子展示在本章的在线内容中,在那里你可以看到,由于训练集/验证集的重新打乱,验证数据集中的标签数量发生了变化。这可能会导致模型的错误性能估计,因为训练集/验证集实际上是混合的。我们可以按如下方式划分训练集/验证集:

>>> BUFFER_SIZE = 10000
>>> BATCH_SIZE = 64
>>> NUM_EPOCHS = 20
>>> mnist_train = mnist_train_orig.map(
...     lambda item: (tf.cast(item['image'], tf.float32)/255.0,
...                   tf.cast(item['label'], tf.int32)))
>>> mnist_test = mnist_test_orig.map(
...     lambda item: (tf.cast(item['image'], tf.float32)/255.0,
...                   tf.cast(item['label'], tf.int32)))
>>> tf.random.set_seed(1)
>>> mnist_train = mnist_train.shuffle(buffer_size=BUFFER_SIZE,
...                   reshuffle_each_iteration=False)
>>> mnist_valid = mnist_train.take(10000).batch(BATCH_SIZE)
>>> mnist_train = mnist_train.skip(10000).batch(BATCH_SIZE) 

现在,在准备好数据集后,我们可以实现刚才描述的 CNN。

使用 TensorFlow Keras API 实现 CNN

在 TensorFlow 中实现 CNN 时,我们使用 Keras 的 Sequential 类来堆叠不同的层,如卷积层、池化层、Dropout 层以及全连接(密集)层。Keras 层 API 为每个层提供了相应的类:tf.keras.layers.Conv2D 用于二维卷积层;tf.keras.layers.MaxPool2Dtf.keras.layers.AvgPool2D 用于子采样(最大池化和平均池化);tf.keras.layers.Dropout 用于通过 Dropout 实现正则化。我们将更详细地介绍这些类。

在 Keras 中配置 CNN 层

使用 Conv2D 类构建卷积层时,我们需要指定输出过滤器的数量(这相当于输出特征图的数量)和卷积核的大小。

此外,还有一些可选的参数可以用来配置卷积层。最常用的参数是步幅(strides,在* x y *维度上的默认值为1)和填充(padding),填充可以是samevalid。更多的配置参数列在官方文档中:https://www.tensorflow.org/versions/r2.0/api_docs/python/tf/keras/layers/Conv2D

值得一提的是,通常当我们读取图像时,通道的默认维度是张量数组的最后一维。这被称为“NHWC”格式,其中N表示批次中的图像数量,HW分别表示高度和宽度,C表示通道。

请注意,Conv2D类默认假设输入采用NHWC格式。(其他工具,如PyTorch,使用NCHW格式。)然而,如果你遇到一些数据,其通道位于第一维(即批次维度之后的第一维,或者考虑批次维度后为第二维),你需要在数据中交换轴,将通道移至最后一维。或者,另一种处理NCHW格式输入的方式是设置data_format="channels_first"。在构建该层后,可以通过提供一个四维张量来调用它,第一个维度保留作为一批样本;根据data_format参数,第二维或第四维对应于通道;其他两个维度是空间维度。

如我们想要构建的CNN模型架构所示,每个卷积层后面都跟着一个池化层进行子采样(减小特征图的尺寸)。MaxPool2DAvgPool2D类分别构造最大池化层和平均池化层。参数pool_size决定了用于计算最大值或均值操作的窗口(或邻域)的大小。此外,strides参数可以用来配置池化层,正如我们之前讨论的那样。

最后,Dropout类将构建用于正则化的丢弃层,参数rate用于确定在训练过程中丢弃输入单元的概率。调用该层时,可以通过一个名为training的参数来控制其行为,以指定该调用是在训练期间进行的还是在推理期间进行的。

在Keras中构建CNN

现在你已经了解了这些类,我们可以构建前面图中显示的CNN模型。在以下代码中,我们将使用Sequential类并添加卷积层和池化层:

>>> model = tf.keras.Sequential()
>>> model.add(tf.keras.layers.Conv2D(
...     filters=32, kernel_size=(5, 5),
...     strides=(1, 1), padding='same',
...     data_format='channels_last',
...     name='conv_1', activation='relu'))
>>> model.add(tf.keras.layers.MaxPool2D(
...     pool_size=(2, 2), name='pool_1'))
>>> model.add(tf.keras.layers.Conv2D(
...     filters=64, kernel_size=(5, 5),
...     strides=(1, 1), padding='same',
...     name='conv_2', activation='relu'))
>>> model.add(tf.keras.layers.MaxPool2D(
...     pool_size=(2, 2), name='pool_2')) 

到目前为止,我们已向模型中添加了两个卷积层。对于每个卷积层,我们使用了大小为 的卷积核,并采用了 'same' 填充方式。如前所述,使用 padding='same' 保留了特征图的空间维度(纵向和横向维度),使得输入和输出具有相同的高度和宽度(而通道数仅可能因所用滤波器的数量不同而有所不同)。最大池化层的池化大小为 ,步幅为 2,将空间维度减少了一半。(注意,如果在 MaxPool2D 中未指定 strides 参数,则默认将其设置为与池化大小相等。)

虽然我们可以手动计算此阶段特征图的大小,但 Keras API 提供了一个方便的方法来为我们计算:

>>> model.compute_output_shape(input_shape=(16, 28, 28, 1))
TensorShape([16, 7, 7, 64]) 

通过在此示例中提供元组形式的输入形状,compute_output_shape 方法计算得出输出的形状为 (16, 7, 7, 64),表示具有 64 个通道和空间大小为 的特征图。第一个维度对应于批次维度,我们任意选择了 16。我们也可以使用 None,即 input_shape=(None, 28, 28, 1)

我们接下来要添加的层是一个全连接层(或称密集层),用于在卷积层和池化层之上实现分类器。此层的输入必须是二维的,即形状为 []。因此,我们需要将前面层的输出展平,以满足全连接层的要求:

>>> model.add(tf.keras.layers.Flatten())
>>> model.compute_output_shape(input_shape=(16, 28, 28, 1))
TensorShape([16, 3136]) 

compute_output_shape 的结果所示,密集层的输入维度已正确设置。接下来,我们将添加两个密集层,中间夹一个丢弃层:

>>> model.add(tf.keras.layers.Dense(
...     units=1024, name='fc_1',
...     activation='relu'))
>>> model.add(tf.keras.layers.Dropout(
...     rate=0.5))
>>> model.add(tf.keras.layers.Dense(
...     units=10, name='fc_2',
...     activation='softmax')) 

最后一层全连接层,命名为 'fc_2',具有 10 个输出单元,用于 MNIST 数据集中的 10 个类别标签。此外,我们使用 softmax 激活函数来获得每个输入样本的类别归属概率,假设各类别是互斥的,因此每个样本的概率之和为 1(这意味着一个训练样本只能属于一个类别)。根据我们在 分类损失函数 部分讨论的内容,我们应该使用哪种损失函数呢?记住,对于具有整数(稀疏)标签的多类分类(与独热编码标签相对),我们使用 SparseCategoricalCrossentropy。以下代码将调用 build() 方法以进行延迟变量创建,并编译模型:

>>> tf.random.set_seed(1)
>>> model.build(input_shape=(None, 28, 28, 1))
>>> model.compile(
...     optimizer=tf.keras.optimizers.Adam(),
...     loss=tf.keras.losses.SparseCategoricalCrossentropy(),
...     metrics=['accuracy']) 

Adam 优化器

请注意,在此实现中,我们使用了 tf.keras.optimizers.Adam() 类来训练 CNN 模型。Adam 优化器是一种强大的基于梯度的优化方法,适用于非凸优化和机器学习问题。两个受 Adam 启发的流行优化方法是:RMSPropAdaGrad

Adam的关键优势在于选择基于梯度矩的运行平均值计算更新步长。请随意阅读更多关于Adam优化器的文献,文献名为Adam: A Method for Stochastic Optimization,作者为Diederik P. KingmaJimmy Lei Ba,2014年。文章可以在https://arxiv.org/abs/1412.6980免费获取。

正如你已经知道的,我们可以通过调用fit()方法来训练模型。请注意,使用指定的训练和评估方法(如evaluate()predict())将自动为dropout层设置模式并适当重新缩放隐藏单元,这样我们就不需要担心这些问题。接下来,我们将训练这个CNN模型,并使用我们为监控学习进展所创建的验证数据集:

>>> history = model.fit(mnist_train, epochs=NUM_EPOCHS,
...                     validation_data=mnist_valid,
...                     shuffle=True)
Epoch 1/20
782/782 [==============================] - 35s 45ms/step - loss: 0.1450 - accuracy: 0.8882 - val_loss: 0.0000e+00 - val_accuracy: 0.0000e+00
Epoch 2/20
782/782 [==============================] - 34s 43ms/step - loss: 0.0472 - accuracy: 0.9833 - val_loss: 0.0507 - val_accuracy: 0.9839
..
Epoch 20/20
782/782 [==============================] - 34s 44ms/step - loss: 0.0047 - accuracy: 0.9985 - val_loss: 0.0488 - val_accuracy: 0.9920 

一旦完成20个训练周期,我们可以可视化学习曲线:

>>> import matplotlib.pyplot as plt
>>> hist = history.history
>>> x_arr = np.arange(len(hist['loss'])) + 1
>>> fig = plt.figure(figsize=(12, 4))
>>> ax = fig.add_subplot(1, 2, 1)
>>> ax.plot(x_arr, hist['loss'], '-o', label='Train loss')
>>> ax.plot(x_arr, hist['val_loss'], '--<', label='Validation loss')
>>> ax.legend(fontsize=15)
>>> ax = fig.add_subplot(1, 2, 2)
>>> ax.plot(x_arr, hist['accuracy'], '-o', label='Train acc.')
>>> ax.plot(x_arr, hist['val_accuracy'], '--<', 
...         label='Validation acc.')
>>> ax.legend(fontsize=15)
>>> plt.show() 

正如你在前两章中已经了解到的,可以通过调用.evaluate()方法在测试数据集上评估训练好的模型:

>>> test_results = model.evaluate(mnist_test.batch(20))
>>> print('Test Acc.: {:.2f}\%'.format(test_results[1]*100))
Test Acc.: 99.39% 

该CNN模型达到了99.39%的准确率。记住,在第14章深入探讨 – TensorFlow的工作原理》中,我们使用Estimator DNNClassifier时,准确率约为90%。

最后,我们可以以类别成员概率的形式获得预测结果,并通过使用tf.argmax函数来找到最大概率的元素,从而将其转换为预测标签。我们将对一批12个示例进行操作并可视化输入和预测标签:

>>> batch_test = next(iter(mnist_test.batch(12)))
>>> preds = model(batch_test[0])
>>> tf.print(preds.shape)
TensorShape([12, 10])
>>> preds = tf.argmax(preds, axis=1)
>>> print(preds)
tf.Tensor([6 2 3 7 2 2 3 4 7 6 6 9], shape=(12,), dtype=int64)
>>> fig = plt.figure(figsize=(12, 4))
>>> for i in range(12):
...     ax = fig.add_subplot(2, 6, i+1)
...     ax.set_xticks([]); ax.set_yticks([])
...     img = batch_test[0][i, :, :, 0]
...     ax.imshow(img, cmap='gray_r')
...     ax.text(0.9, 0.1, '{}'.format(preds[i]),
...             size=15, color='blue',
...             horizontalalignment='center',
...             verticalalignment='center',
...             transform=ax.transAxes)
>>> plt.show() 

下图展示了手写输入和它们的预测标签:

在这一组绘制的示例中,所有的预测标签都是正确的。

我们将展示一些误分类的数字,这一任务与第12章从零实现多层人工神经网络》中的练习类似,作为读者的练习。

使用CNN从面部图像进行性别分类

在本节中,我们将使用CelebA数据集实现一个用于从面部图像进行性别分类的CNN。如同你在第13章使用TensorFlow并行化神经网络训练》中看到的,CelebA数据集包含了202,599张名人面部图像。此外,每张图像还包含40个二进制面部属性,包括性别(男性或女性)和年龄(年轻或年老)。

基于你目前学到的内容,本节的目标是构建并训练一个CNN模型,用于从这些面部图像中预测性别属性。为了简单起见,我们将仅使用一小部分训练数据(16,000个训练示例)来加速训练过程。然而,为了提高泛化性能并减少在如此小的数据集上的过拟合,我们将使用一种称为数据增强的技术。

加载CelebA数据集

首先,我们将以与上一节加载 MNIST 数据集时类似的方式加载数据。CelebA 数据集分为三个部分:训练数据集、验证数据集和测试数据集。接下来,我们将实现一个简单的函数来统计每个数据集中的样本数量:

>>> import tensorflow as tf
>>> import tensorflow_datasets as tfds
>>> celeba_bldr = tfds.builder('celeb_a')
>>> celeba_bldr.download_and_prepare()
>>> celeba = celeba_bldr.as_dataset(shuffle_files=False)
>>> celeba_train = celeba['train']
>>> celeba_valid = celeba['validation']
>>> celeba_test = celeba['test']
>>>
>>> def count_items(ds):
...     n = 0
...     for _ in ds:
...         n += 1
...     return n
>>> print('Train set:  {}'.format(count_items(celeba_train)))
Train set:  162770
>>> print('Validation: {}'.format(count_items(celeba_valid)))
Validation: 19867
>>> print('Test set:   {}'.format(count_items(celeba_test)))
Test set:   19962 

因此,我们不会使用所有可用的训练和验证数据,而是选择一个包含16,000个训练样本和1,000个验证样本的子集,具体如下:

>>> celeba_train = celeba_train.take(16000)
>>> celeba_valid = celeba_valid.take(1000)
>>> print('Train set:  {}'.format(count_items(celeba_train)))
Train set:  16000
>>> print('Validation: {}'.format(count_items(celeba_valid)))
Validation: 1000 

需要注意的是,如果在celeba_bldr.as_dataset()中的shuffle_files参数没有设置为False,我们仍然会看到16,000个训练样本和1,000个验证样本。然而,在每次迭代中,训练数据将被重新洗牌,并选取一组新的16,000个样本。这将违背我们的目的,因为我们的目标是故意用一个小数据集来训练模型。接下来,我们将讨论数据增强作为提高深度神经网络性能的技术。

图像变换与数据增强

数据增强总结了一组广泛的技术,用于处理训练数据有限的情况。例如,某些数据增强技术允许我们修改或甚至人工合成更多数据,从而通过减少过拟合来提高机器学习或深度学习模型的性能。尽管数据增强不仅限于图像数据,但有一组转换方法特别适用于图像数据,如裁剪图像的一部分、翻转、调整对比度、亮度和饱和度。让我们看看通过tf.image模块可以使用的一些变换。在以下代码块中,我们将首先从celeba_train数据集中获取五个样本,并应用五种不同的变换:1)将图像裁剪到边界框,2)水平翻转图像,3)调整对比度,4)调整亮度,5)对图像进行中心裁剪,并将结果图像调整回其原始大小(218,178)。在下面的代码中,我们将可视化这些变换的结果,并将每个变换单独展示在一列中以供比较:

>>> import matplotlib.pyplot as plt
>>> # take 5 examples
>>> examples = []
>>> for example in celeba_train.take(5):
...     examples.append(example['image'])
>>> fig = plt.figure(figsize=(16, 8.5))
>>> ## Column 1: cropping to a bounding-box
>>> ax = fig.add_subplot(2, 5, 1)
>>> ax.set_title('Crop to a \nbounding-box', size=15)
>>> ax.imshow(examples[0])
>>> ax = fig.add_subplot(2, 5, 6)
>>> img_cropped = tf.image.crop_to_bounding_box(
...     examples[0], 50, 20, 128, 128)
>>> ax.imshow(img_cropped)
>>> ## Column 2: flipping (horizontally)
>>> ax = fig.add_subplot(2, 5, 2)
>>> ax.set_title('Flip (horizontal)', size=15)
>>> ax.imshow(examples[1])
>>> ax = fig.add_subplot(2, 5, 7)
>>> img_flipped = tf.image.flip_left_right(examples[1])
>>> ax.imshow(img_flipped)
>>> ## Column 3: adjust contrast
>>> ax = fig.add_subplot(2, 5, 3)
>>> ax.set_title('Adjust constrast', size=15)
>>> ax.imshow(examples[2])
>>> ax = fig.add_subplot(2, 5, 8)
>>> img_adj_contrast = tf.image.adjust_contrast(
...     examples[2], contrast_factor=2)
>>> ax.imshow(img_adj_contrast)
>>> ## Column 4: adjust brightness
>>> ax = fig.add_subplot(2, 5, 4)
>>> ax.set_title('Adjust brightness', size=15)
>>> ax.imshow(examples[3])
>>> ax = fig.add_subplot(2, 5, 9)
>>> img_adj_brightness = tf.image.adjust_brightness(
...     examples[3], delta=0.3)
>>> ax.imshow(img_adj_brightness)
>>> ## Column 5: cropping from image center
>>> ax = fig.add_subplot(2, 5, 5)
>>> ax.set_title('Centeral crop\nand resize', size=15)
>>> ax.imshow(examples[4])
>>> ax = fig.add_subplot(2, 5, 10)
>>> img_center_crop = tf.image.central_crop(
...     examples[4], 0.7)
>>> img_resized = tf.image.resize(
...     img_center_crop, size=(218, 178))
>>> ax.imshow(img_resized.numpy().astype('uint8'))
>>> plt.show() 

以下是结果图:

在前面的图中,原始图像显示在第一行,它们的变换版本显示在第二行。请注意,对于第一个变换(最左列),边界框由四个数字指定:边界框左上角的坐标(此处为x=20, y=50),以及框的宽度和高度(width=128, height=128)。还需要注意,对于由 TensorFlow(以及其他包如imageio)加载的图像,原点(即坐标为(0,0)的位置)是图像的左上角。

之前代码块中的变换是确定性的。然而,所有这些变换也可以进行随机化,这对于模型训练中的数据增强是推荐的。例如,可以从图像中随机裁剪出一个随机边界框(左上角坐标随机选择),图像也可以以0.5的概率沿水平方向或垂直方向随机翻转,或者图像的对比度可以随机改变,contrast_factor 在一个范围内随机选择,并采用均匀分布。此外,我们还可以创建这些变换的管道。

例如,我们可以先随机裁剪图像,然后随机翻转它,最后将其调整到所需的大小。代码如下(由于我们使用了随机元素,我们设置了随机种子以确保可重现性):

>>> tf.random.set_seed(1)
>>> fig = plt.figure(figsize=(14, 12))
>>> for i,example in enumerate(celeba_train.take(3)):
...     image = example['image']
...
...     ax = fig.add_subplot(3, 4, i*4+1)
...     ax.imshow(image)
...     if i == 0:
...         ax.set_title('Orig', size=15)
...
...     ax = fig.add_subplot(3, 4, i*4+2)
...     img_crop = tf.image.random_crop(image, size=(178, 178, 3))
...     ax.imshow(img_crop)
...     if i == 0:
...         ax.set_title('Step 1: Random crop', size=15)
...
...     ax = fig.add_subplot(3, 4, i*4+3)
...     img_flip = tf.image.random_flip_left_right(img_crop)
...     ax.imshow(tf.cast(img_flip, tf.uint8))
...     if i == 0:
...         ax.set_title('Step 2: Random flip', size=15)
...
...     ax = fig.add_subplot(3, 4, i*4+4)
...     img_resize = tf.image.resize(img_flip, size=(128, 128))
...     ax.imshow(tf.cast(img_resize, tf.uint8))
...     if i == 0:
...         ax.set_title('Step 3: Resize', size=15)
>>> plt.show() 

以下图展示了对三个示例图像进行随机变换的效果:

请注意,每次迭代这三个示例时,由于随机变换,得到的图像会略有不同。

为了方便起见,我们可以定义一个包装函数,在模型训练期间使用该管道进行数据增强。在以下代码中,我们将定义函数preprocess(),它将接收一个包含键 'image''attributes' 的字典。该函数将返回一个元组,包含变换后的图像和从属性字典中提取的标签。

然而,我们只会对训练样本应用数据增强,而不会对验证或测试图像进行处理。代码如下:

>>> def preprocess(example, size=(64, 64), mode='train'):
...     image = example['image']
...     label = example['attributes']['Male']
...     if mode == 'train':
...         image_cropped = tf.image.random_crop(
...             image, size=(178, 178, 3))
...         image_resized = tf.image.resize(
...             image_cropped, size=size)
...         image_flip = tf.image.random_flip_left_right(
...             image_resized)
...         return image_flip/255.0, tf.cast(label, tf.int32)
...     else: # use center- instead of 
...           # random-crops for non-training data
...         image_cropped = tf.image.crop_to_bounding_box(
...             image, offset_height=20, offset_width=0,
...             target_height=178, target_width=178)
...         image_resized = tf.image.resize(
...             image_cropped, size=size)
...         return image_resized/255.0, tf.cast(label, tf.int32) 

现在,为了观察数据增强的实际效果,让我们创建一个小的训练数据集子集,应用这个函数,并对数据集进行五次迭代:

>>> tf.random.set_seed(1)
>>> ds = celeba_train.shuffle(1000, reshuffle_each_iteration=False)
>>> ds = ds.take(2).repeat(5)
>>> ds = ds.map(lambda x:preprocess(x, size=(178, 178), mode='train'))
>>> fig = plt.figure(figsize=(15, 6))
>>> for j,example in enumerate(ds):
...     ax = fig.add_subplot(2, 5, j//2+(j%2)*5+1)
...     ax.set_xticks([])
...     ax.set_yticks([])
...     ax.imshow(example[0])
>>> plt.show() 

这张图展示了对两个示例图像进行数据增强后的五种转换结果:

接下来,我们将把这个预处理函数应用于我们的训练和验证数据集。我们将使用 (64, 64) 的图像大小。此外,当处理训练数据时,我们将指定 mode='train',而对于验证数据,我们使用 mode='eval',这样数据增强管道中的随机元素只会应用于训练数据:

>>> import numpy as np
>>> BATCH_SIZE = 32
>>> BUFFER_SIZE = 1000
>>> IMAGE_SIZE = (64, 64)
>>> steps_per_epoch = np.ceil(16000/BATCH_SIZE)
>>> ds_train = celeba_train.map(
...     lambda x: preprocess(x, size=IMAGE_SIZE, mode='train'))
>>> ds_train = ds_train.shuffle(buffer_size=BUFFER_SIZE).repeat()
>>> ds_train = ds_train.batch(BATCH_SIZE)
>>> ds_valid = celeba_valid.map(
...     lambda x: preprocess(x, size=IMAGE_SIZE, mode='eval'))
>>> ds_valid = ds_valid.batch(BATCH_SIZE) 

训练一个CNN性别分类器

到目前为止,使用TensorFlow的Keras API构建和训练模型应该是很直接的。我们的CNN设计如下:CNN模型接收尺寸为的输入图像(这些图像有三个颜色通道,采用 'channels_last')。

输入数据通过四个卷积层,使用大小为的滤波器生成32、64、128和256个特征图。前三个卷积层后面跟着最大池化层,。还包括两个dropout层用于正则化:

>>> model = tf.keras.Sequential([
...     tf.keras.layers.Conv2D(
...         32, (3, 3), padding='same', activation='relu'),
...     tf.keras.layers.MaxPooling2D((2, 2)),
...     tf.keras.layers.Dropout(rate=0.5),
...     
...     tf.keras.layers.Conv2D(
...         64, (3, 3), padding='same', activation='relu'),
...     tf.keras.layers.MaxPooling2D((2, 2)),
...     tf.keras.layers.Dropout(rate=0.5),
...     
...     tf.keras.layers.Conv2D(
...         128, (3, 3), padding='same', activation='relu'),
...     tf.keras.layers.MaxPooling2D((2, 2)),
...     
...     tf.keras.layers.Conv2D(
...         256, (3, 3), padding='same', activation='relu')
>>>     ]) 

让我们看看在应用这些层之后,输出特征图的形状:

>>> model.compute_output_shape(input_shape=(None, 64, 64, 3))
TensorShape([None, 8, 8, 256]) 

这里有256个特征图(或通道),大小为 。现在,我们可以添加一个全连接层,以便到达输出层,输出一个单一单元。如果我们将特征图展平(flatten),则输入到这个全连接层的单元数将是 。另外,我们可以考虑使用一个新的层,称为全局平均池化,它分别计算每个特征图的平均值,从而将隐藏单元减少到256。接着,我们可以添加一个全连接层。尽管我们没有明确讨论全局平均池化,但它在概念上与其他池化层非常相似。实际上,全球平均池化可以视为平均池化的特例,当池化大小等于输入特征图的大小时。

为了理解这一点,请参考下面的图,展示了输入特征图的示例,形状为 。通道编号为 。全局平均池化操作计算每个通道的平均值,因此输出将具有形状 。 (注意:Keras API中的GlobalAveragePooling2D会自动压缩输出。)

如果不进行输出压缩,形状将是 ,因为全局平均池化将把 的空间维度减少到

一张包含物体、天线的图片,描述自动生成

因此,考虑到在我们案例中,这一层之前的特征图形状为 ,我们期望得到256个单元作为输出,也就是说,输出的形状将是 。让我们添加这个层并重新计算输出形状,以验证这一点:

>>> model.add(tf.keras.layers.GlobalAveragePooling2D())
>>> model.compute_output_shape(input_shape=(None, 64, 64, 3))
TensorShape([None, 256]) 

最后,我们可以添加一个全连接(密集)层,得到一个单一的输出单元。在这种情况下,我们可以指定激活函数为 'sigmoid',或者直接使用 activation=None,这样模型将输出logits(而不是类别概率),因为在TensorFlow和Keras中,使用logits进行模型训练更具数值稳定性,如前所述:

>>> model.add(tf.keras.layers.Dense(1, activation=None))
>>> tf.random.set_seed(1)
>>> model.build(input_shape=(None, 64, 64, 3))
>>> model.summary()
Model: "sequential"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
conv2d (Conv2D)              multiple                  896       
_________________________________________________________________
max_pooling2d (MaxPooling2D) multiple                  0         
_________________________________________________________________
dropout (Dropout)            multiple                  0         
_________________________________________________________________
conv2d_1 (Conv2D)            multiple                  18496     
_________________________________________________________________
max_pooling2d_1 (MaxPooling2 multiple                  0         
_________________________________________________________________
dropout_1 (Dropout)          multiple                  0         
_________________________________________________________________
conv2d_2 (Conv2D)            multiple                  73856     
_________________________________________________________________
max_pooling2d_2 (MaxPooling2 multiple                  0         
_________________________________________________________________
conv2d_3 (Conv2D)            multiple                  295168    
_________________________________________________________________
global_average_pooling2d (Gl multiple                  0         
_________________________________________________________________
dense (Dense)                multiple                  257       
=================================================================
Total params: 388,673
Trainable params: 388,673
Non-trainable params: 0
_________________________________________________________________ 

下一步是编译模型,在此时我们需要决定使用什么损失函数。我们有一个二分类任务,输出单元为单一单元,所以我们应该使用 BinaryCrossentropy。另外,由于我们最后一层没有应用sigmoid激活(我们使用了 activation=None),所以模型的输出是logits,而不是概率。因此,我们还将在 BinaryCrossentropy 中指定 from_logits=True,这样损失函数会内部应用sigmoid函数,由于底层代码的优化,这比手动执行更高效。编译和训练模型的代码如下:

>>> model.compile(optimizer=tf.keras.optimizers.Adam(),
...     loss=tf.keras.losses.BinaryCrossentropy(from_logits=True),
...               metrics=['accuracy'])
>>> history = model.fit(ds_train, validation_data=ds_valid,
...                     epochs=20,
...                     steps_per_epoch=steps_per_epoch) 

现在,让我们可视化学习曲线,并比较每个周期后的训练和验证损失及准确率:

>>> hist = history.history
>>> x_arr = np.arange(len(hist['loss'])) + 1
>>> fig = plt.figure(figsize=(12, 4))
>>> ax = fig.add_subplot(1, 2, 1)
>>> ax.plot(x_arr, hist['loss'], '-o', label='Train loss')
>>> ax.plot(x_arr, hist['val_loss'], '--<', label='Validation loss')
>>> ax.legend(fontsize=15)
>>> ax.set_xlabel('Epoch', size=15)
>>> ax.set_ylabel('Loss', size=15)
>>> ax = fig.add_subplot(1, 2, 2)
>>> ax.plot(x_arr, hist['accuracy'], '-o', label='Train acc.')
>>> ax.plot(x_arr, hist['val_accuracy'], '--<',
...         label='Validation acc.')
>>> ax.legend(fontsize=15)
>>> ax.set_xlabel('Epoch', size=15)
>>> ax.set_ylabel('Accuracy', size=15)
>>> plt.show() 

下图展示了训练和验证的损失与准确率:

如学习曲线所示,训练和验证的损失尚未收敛到平稳区域。基于这个结果,我们本可以继续训练更多的 epochs。使用 fit() 方法,我们可以按照如下方式继续训练额外的 10 个 epochs:

>>> history = model.fit(ds_train, validation_data=ds_valid,
...                     epochs=30, initial_epoch=20,
...                     steps_per_epoch=steps_per_epoch) 

一旦我们对学习曲线感到满意,就可以在保持的测试数据集上评估模型:

>>> ds_test = celeba_test.map(
...   lambda x:preprocess(x, size=IMAGE_SIZE, mode='eval')).batch(32)
>>> test_results = model.evaluate(ds_test)
>>> print('Test Acc: {:.2f}%'.format(test_results[1]*100))
Test Acc: 94.75% 

最后,我们已经知道如何使用 model.predict() 获取某些测试样本的预测结果。然而,请记住,模型输出的是 logits 而不是概率。如果我们对该二分类问题(具有单个输出单元)的类别成员概率感兴趣,可以使用 tf.sigmoid 函数来计算类别 1 的概率(对于多类问题,我们将使用 tf.math.softmax)。在下面的代码中,我们将从我们预处理过的测试数据集(ds_test)中提取 10 个样本,并运行 model.predict() 来获取 logits。然后,我们将计算每个样本属于类别 1 的概率(根据 CelebA 提供的标签,这对应于男性),并可视化这些样本及其真实标签和预测概率。请注意,我们首先对 ds_test 数据集应用 unbatch(),然后再取 10 个样本,否则 take() 方法将返回 10 个大小为 32 的批次,而不是 10 个单独的样本:

>>> ds = ds_test.unbatch().take(10)
>>> pred_logits = model.predict(ds.batch(10))
>>> probas = tf.sigmoid(pred_logits)
>>> probas = probas.numpy().flatten()*100
>>> fig = plt.figure(figsize=(15, 7))
>>> for j,example in enumerate(ds):
...     ax = fig.add_subplot(2, 5, j+1)
...     ax.set_xticks([]); ax.set_yticks([])
...     ax.imshow(example[0])
...     if example[1].numpy() == 1:
...         label='M'
...     else:
...         label = 'F'
...     ax.text(
...         0.5, -0.15, 'GT: {:s}\nPr(Male)={:.0f}%'
...         ''.format(label, probas[j]),
...         size=16,
...         horizontalalignment='center',
...         verticalalignment='center',
...         transform=ax.transAxes)
>>> plt.tight_layout()
>>> plt.show() 

在下图中,您可以看到 10 个示例图像,以及它们的真实标签和它们属于类别 1(男性)的概率:

类别 1(即 CelebA 数据集中标记为男性)的概率列在每个图像下方。如你所见,我们训练的模型在这组 10 个测试样本中只犯了一个错误。

作为一个可选的练习,建议您尝试使用整个训练数据集,而不是我们创建的这个小子集。此外,您还可以更改或修改 CNN 架构。例如,您可以更改不同卷积层中的 dropout 概率和滤波器数量。同时,您也可以用全连接层替代全局平均池化层。如果您使用的是本章中训练的 CNN 架构并应用整个训练数据集,您应该能够达到约 97-99% 的准确率。

总结

在本章中,我们学习了卷积神经网络(CNN)及其主要组件。我们从卷积操作开始,研究了 1D 和 2D 的实现方式。接着,我们介绍了 CNN 中常见的另一种层:子采样层或称为池化层。我们主要聚焦于两种最常见的池化形式:最大池化(max-pooling)和平均池化(average-pooling)。

接下来,将所有这些单独的概念结合起来,我们使用 TensorFlow Keras API 实现了深度 CNN。我们实现的第一个网络应用于已经熟悉的 MNIST 手写数字识别问题。

然后,我们在一个包含人脸图像的更复杂数据集上实现了第二个 CNN,并训练该 CNN 进行性别分类。在此过程中,你还了解了数据增强以及如何使用 TensorFlow Dataset 类对人脸图像进行不同的变换。

在下一章,我们将讨论递归神经网络RNNs)。RNN 用于学习序列数据的结构,并且具有一些令人着迷的应用,包括语言翻译和图像字幕生成。

第十六章:使用递归神经网络建模顺序数据

在上一章中,我们集中讨论了卷积神经网络CNNs)。我们介绍了CNN架构的构建块以及如何在TensorFlow中实现深度CNN。最后,你学习了如何使用CNN进行图像分类。在本章中,我们将探讨递归神经网络RNNs),并看到它们在建模顺序数据中的应用。

我们将讨论以下主题:

  • 介绍顺序数据

  • 使用RNN建模序列

  • 长短期记忆(LSTM)

  • 截断反向传播通过时间(TBPTT)

  • 在TensorFlow中实现多层RNN进行序列建模

  • 项目一:RNN情感分析——IMDb电影评论数据集

  • 项目二:使用LSTM单元进行RNN字符级语言建模,使用来自儒勒·凡尔纳的《神秘岛》的文本数据

  • 使用梯度裁剪来避免梯度爆炸

  • 介绍Transformer模型并理解自注意力机制

介绍顺序数据

我们通过先了解顺序数据的性质开始讨论RNN,这种数据通常被称为序列数据或序列。我们将查看序列的独特属性,这些属性使其与其他类型的数据不同。然后我们将看到如何表示顺序数据,并探讨针对顺序数据的各种模型类别,这些类别基于模型的输入和输出。这样有助于我们在本章中探讨RNN与序列的关系。

建模顺序数据——顺序很重要

与其他类型的数据相比,顺序数据的独特之处在于,序列中的元素按特定顺序出现,并且它们彼此之间不是独立的。典型的监督学习机器学习算法假设输入是独立同分布IID)的数据,这意味着训练样本是相互独立的,并且有相同的底层分布。在这种假设下,训练样本的顺序对模型并不重要。例如,如果我们有一个包含n个训练样本的样本集,,我们用这些数据训练机器学习算法时,数据的顺序并不重要。这种情形的例子可以是我们之前使用的鸢尾花数据集。在鸢尾花数据集中,每朵花的测量值是独立的,某朵花的测量结果不会影响其他花的测量结果。

然而,当我们处理序列时,这一假设是不成立的——根据定义,顺序非常重要。预测某只股票的市场价值就是这种情况的一个例子。例如,假设我们有一个包含n个训练样本的数据集,其中每个训练样本表示某只股票在某一天的市场价值。如果我们的任务是预测未来三天的股市价值,那么考虑按日期排序的历史股价来推导趋势,而不是随机顺序使用这些训练样本,会更有意义。

顺序数据与时间序列数据

时间序列数据是一种特殊类型的顺序数据,其中每个示例都与时间维度相关联。在时间序列数据中,样本在连续的时间戳上采集,因此时间维度决定了数据点之间的顺序。例如,股价和语音或语音记录就是时间序列数据。

另一方面,并非所有的顺序数据都具有时间维度,例如文本数据或DNA序列,尽管这些示例是有序的,但它们并不符合时间序列数据的定义。正如您将在本章中看到的,我们将涵盖一些自然语言处理(NLP)和文本建模的示例,它们不是时间序列数据,但需要注意的是,RNN同样可以用于时间序列数据。

序列表示

我们已经确定,数据点之间的顺序在顺序数据中非常重要,因此我们接下来需要找到一种方法,将这个顺序信息融入到机器学习模型中。在本章中,我们将以 来表示序列。上标索引表示实例的顺序,序列的长度为T。为了给出一个合理的序列示例,考虑时间序列数据,其中每个示例点,,对应于某一特定时间点,t。下图展示了时间序列数据的一个示例,其中输入特征(x)和目标标签(y)自然地按时间轴顺序排列;因此,xy 都是序列:

如我们之前提到的,我们目前讨论的标准神经网络(NN)模型,如多层感知器(MLP)和用于图像数据的卷积神经网络(CNN),假设训练样本彼此独立,因此没有考虑顺序信息。我们可以说,这些模型没有记忆以前见过的训练样本。例如,样本会经过前馈和反向传播步骤,权重的更新与训练样本处理的顺序无关。

与此相对,循环神经网络(RNN)专门用于建模序列,能够记住过去的信息并根据新事件进行处理,这在处理序列数据时具有明显的优势。

序列建模的不同类别

序列建模有许多引人入胜的应用,例如语言翻译(例如,将英语文本翻译成德语)、图像标注和文本生成。然而,为了选择适当的架构和方法,我们必须理解并能够区分这些不同的序列建模任务。下面的图基于Andrej Karpathy的优秀文章《递归神经网络的非理性有效性》(http://karpathy.github.io/2015/05/21/rnn-effectiveness/)中的解释,总结了最常见的序列建模任务,这些任务取决于输入和输出数据的关系类别:

让我们更详细地讨论输入和输出数据之间的不同关系类别,这些类别在之前的图中有所展示。如果输入和输出数据都不是序列,那么我们处理的是标准数据,可能只需使用多层感知机(或本书之前介绍的其他分类模型)来建模此类数据。然而,如果输入或输出是序列,那么建模任务可能属于以下类别之一:

  • 多对一:输入数据是一个序列,但输出是一个固定大小的向量或标量,而不是一个序列。例如,在情感分析中,输入是基于文本的(例如,一篇电影评论),输出是一个类别标签(例如,表示评论者是否喜欢这部电影的标签)。

  • 一对多:输入数据是标准格式,而不是序列,但输出是一个序列。这个类别的一个例子是图像标注——输入是一张图像,输出是一个总结该图像内容的英文短语。

  • 多对多:输入和输出数组都是序列。这个类别可以根据输入和输出是否同步进一步细分。一个同步的多对多建模任务的例子是视频分类,其中视频中的每一帧都被标注。一个延迟的多对多建模任务的例子是将一种语言翻译成另一种语言。例如,必须先由机器读取并处理整个英语句子,然后才能生成其德语翻译。

现在,在总结了序列建模的三大类之后,我们可以继续讨论RNN的结构。

用于建模序列的RNN

在本节中,在开始在TensorFlow中实现RNN之前,我们将讨论RNN的主要概念。我们将首先查看RNN的典型结构,其中包含递归组件来建模序列数据。然后,我们将研究在典型RNN中神经元激活是如何计算的。这将为我们讨论训练RNN时遇到的常见挑战提供背景,并接着讨论解决这些挑战的方法,例如LSTM和门控递归单元(GRU)。

理解 RNN 循环机制

让我们从 RNN 的架构开始。下图显示了一个标准的前馈神经网络和一个 RNN,便于进行比较:

这两个网络只有一个隐藏层。在这个表示中,单元未显示,但我们假设输入层(x)、隐藏层(h)和输出层(o)是包含多个单元的向量。

确定 RNN 的输出类型

这种通用 RNN 架构可能对应两种序列建模类别,其中输入是一个序列。通常,循环层可以返回一个序列作为输出,,或者仅返回最后一个输出(在 t = T 时,即 )。因此,它可以是多对多的,或者如果我们仅使用最后一个元素,,作为最终输出,则它也可以是多对一的。

正如你稍后将看到的,在 TensorFlow Keras API 中,可以通过将参数return_sequences设置为TrueFalse来指定循环层的行为,分别表示返回一个序列作为输出或仅使用最后一个输出。

在标准的前馈神经网络中,信息从输入层流向隐藏层,然后再从隐藏层流向输出层。另一方面,在 RNN 中,隐藏层的输入来自当前时间步的输入层和前一个时间步的隐藏层。

邻近时间步中隐藏层的信息流动使得网络能够记住过去的事件。这种信息流通常显示为一个循环,也称为循环边,在图示符号中就是这种通用 RNN 架构的名字来源。

与多层感知器类似,RNN 也可以包含多个隐藏层。注意,通常约定将只有一个隐藏层的 RNN 称为单层 RNN,这与没有隐藏层的单层神经网络(如 Adaline 或逻辑回归)不同。下图展示了一个带有一个隐藏层的 RNN(上图)和一个带有两个隐藏层的 RNN(下图):

为了检查 RNN 的架构和信息流,可以展开带有循环边的紧凑表示,你可以在前面的图中看到它。

正如我们所知,标准 NN 中的每个隐藏单元只接收一个输入——与输入层相关的网络预激活。相比之下,RNN 中的每个隐藏单元接收两个不同的输入——来自输入层的预激活和来自前一个时间步(t - 1)的相同隐藏层的激活。

在第一个时间步 t = 0 时,隐藏单元被初始化为零或小的随机值。然后,在 t > 0 的时间步中,隐藏单元从当前时间的数据点!和前一个时间步 t – 1 的隐藏单元值接收输入,表示为!

类似地,在多层RNN的情况下,我们可以将信息流总结如下:

  • = 1:这里,隐藏层表示为!,并接收来自数据点!的输入,以及来自同一层但在前一个时间步的隐藏值!

  • = 2:第二个隐藏层,,接收来自下层的当前时间步的输出()以及来自前一个时间步的自身隐藏值,

因为在这种情况下,每个递归层必须接收一个序列作为输入,所以除最后一个层外,所有递归层都必须返回一个序列作为输出(即,return_sequences=True)。最后一个递归层的行为取决于问题的类型。

在RNN中计算激活值

现在你已经了解了RNN的结构和信息流的一般流程,让我们更加具体地计算隐藏层的实际激活值,以及输出层的激活值。为简化起见,我们只考虑一个隐藏层;然而,同样的概念也适用于多层RNN。

我们刚刚看到的RNN表示中的每条有向边(框之间的连接)都与一个权重矩阵相关联。这些权重与时间无关,t;因此,它们在时间轴上是共享的。单层RNN中的不同权重矩阵如下:

  • :输入与隐藏层之间的权重矩阵,h

  • :与递归边相关的权重矩阵

  • :隐藏层与输出层之间的权重矩阵

这些权重矩阵在下图中表示:

在某些实现中,你可能会看到权重矩阵! 被合并为一个矩阵!。稍后在本节中,我们也将使用这种符号。

计算激活值非常类似于标准的多层感知机和其他类型的前馈神经网络。对于隐藏层,净输入!(预激活值)是通过线性组合计算的,即我们计算权重矩阵与相应向量的乘积之和,并加上偏置单元:

然后,隐藏单元在时间步长 t 时的激活计算如下:

这里, 是隐藏单元的偏置向量, 是隐藏层的激活函数。

如果你想使用连接的权重矩阵,,则计算隐藏单元的公式将发生变化,如下所示:

一旦计算出当前时间步的隐藏单元的激活值,就会计算输出单元的激活值,如下所示:

为了进一步澄清这一点,以下图展示了使用这两种公式计算激活值的过程:

使用通过时间反向传播(BPTT)训练RNN

RNN的学习算法于1990年提出:反向传播通过时间:它的作用及如何实现Paul WerbosIEEE会议录,78(10):1550-1560,1990)。

梯度的推导可能有点复杂,但基本思想是整体损失,L,是从时间 t = 1 到 t = T 所有损失函数的总和:

由于时间 t 的损失依赖于所有之前时间步1 : t的隐藏单元,因此梯度将按如下方式计算:

这里, 是通过相邻时间步的乘法计算得出的:

隐藏层递归与输出层递归

到目前为止,你已经看到了隐藏层具有递归特性的递归网络。然而,注意还有一种替代模型,其中递归连接来自输出层。在这种情况下,来自前一时间步的输出层的净激活值,,可以通过以下两种方式之一添加:

  • 对当前时间步的隐藏层,(下图显示为输出到隐藏的递归)

  • 对当前时间步的输出层,(下图显示为输出到输出的递归)

如前图所示,这些架构之间的差异可以清晰地通过递归连接看到。根据我们的符号,隐藏到隐藏的递归的权重将用 表示,输出到隐藏的递归的权重用 表示,输出到输出的递归的权重用 表示。在一些文献中,与递归连接相关的权重也用 表示。

为了看看这种方法如何在实践中工作,让我们手动计算其中一种循环类型的前向传递。使用TensorFlow Keras API,可以通过SimpleRNN定义一个循环层,这类似于输出到输出的递归。在下面的代码中,我们将从SimpleRNN创建一个循环层,并对长度为3的输入序列进行前向传递,计算输出。我们还将手动计算前向传递,并将结果与SimpleRNN的结果进行比较。首先,让我们创建这个层并为我们的手动计算分配权重:

>>> import tensorflow as tf
>>> tf.random.set_seed(1)
>>> rnn_layer = tf.keras.layers.SimpleRNN(
...     units=2, use_bias=True,
...     return_sequences=True)
>>> rnn_layer.build(input_shape=(None, None, 5))
>>> w_xh, w_oo, b_h = rnn_layer.weights
>>> print('W_xh shape:', w_xh.shape)
>>> print('W_oo shape:', w_oo.shape)
>>> print('b_h  shape:', b_h.shape)
W_xh shape: (5, 2)
W_oo shape: (2, 2)
b_h  shape: (2,) 

该层的输入形状是(None, None, 5),其中第一个维度是批次维度(使用None表示可变批量大小),第二个维度对应序列(使用None表示可变序列长度),最后一个维度对应特征。注意,我们设置了return_sequences=True,这对于长度为3的输入序列将导致输出序列为 。否则,它只会返回最终输出,

现在,我们将调用rnn_layer的前向传递,并手动计算每个时间步的输出并进行比较:

>>> x_seq = tf.convert_to_tensor(
...     [[1.0]*5, [2.0]*5, [3.0]*5],
...     dtype=tf.float32)
>>> ## output of SimepleRNN:
>>> output = rnn_layer(tf.reshape(x_seq, shape=(1, 3, 5)))
>>> ## manually computing the output:
>>> out_man = []
>>> for t in range(len(x_seq)):
...     xt = tf.reshape(x_seq[t], (1, 5))
...     print('Time step {} =>'.format(t))
...     print('   Input           :', xt.numpy())
...     
...     ht = tf.matmul(xt, w_xh) + b_h
...     print('   Hidden          :', ht.numpy())
...     
...     if t>0:
...         prev_o = out_man[t-1]
...     else:
...         prev_o = tf.zeros(shape=(ht.shape))
...     ot = ht + tf.matmul(prev_o, w_oo)
...     ot = tf.math.tanh(ot)
...     out_man.append(ot)
...     print('   Output (manual) :', ot.numpy())
...     print('   SimpleRNN output:'.format(t),
...           output[0][t].numpy())
...     print()
Time step 0 =>
   Input           : [[1\. 1\. 1\. 1\. 1.]]
   Hidden          : [[0.41464037 0.96012145]]
   Output (manual) : [[0.39240566 0.74433106]]
   SimpleRNN output: [0.39240566 0.74433106]
Time step 1 =>
   Input           : [[2\. 2\. 2\. 2\. 2.]]
   Hidden          : [[0.82928073 1.9202429 ]]
   Output (manual) : [[0.80116504 0.9912947 ]]
   SimpleRNN output: [0.80116504 0.9912947 ]
Time step 2 =>
   Input           : [[3\. 3\. 3\. 3\. 3.]]
   Hidden          : [[1.243921  2.8803642]]
   Output (manual) : [[0.95468265 0.9993069 ]]
   SimpleRNN output: [0.95468265 0.9993069 ] 

在我们的手动前向计算中,我们使用了双曲正切(tanh)激活函数,因为它也用于SimpleRNN(默认激活函数)。从打印的结果可以看出,手动前向计算的输出与每个时间步的SimpleRNN层输出完全一致。希望这个动手任务能够启发你了解循环神经网络的奥秘。

学习长期交互的挑战

BPTT(之前简要提到的反向传播通过时间)引入了一些新的挑战。由于在计算损失函数的梯度时存在乘法因子 ,因此出现了所谓的消失爆炸梯度问题。以下图中的示例解释了这些问题,图中展示了一个仅包含一个隐藏单元的RNN,简化了计算过程:

基本上, 具有 tk 次乘法运算;因此,将权重 w 自身乘以 tk 次,结果得到一个因子,。因此,如果 ,当 tk 很大时,这个因子会变得非常小。另一方面,如果递归边的权重是 ,那么当 tk 很大时, 会变得非常大。需要注意的是,大的 tk 指的是长程依赖。我们可以看出,通过确保 ,可以得到一个避免梯度消失或爆炸的简单解决方案。如果你有兴趣并希望更详细地了解,可以阅读 R. PascanuT. MikolovY. Bengio 2012年发表的 On the difficulty of training recurrent neural networkshttps://arxiv.org/pdf/1211.5063.pdf)。

在实践中,解决此问题的方案至少有三种:

  • 梯度裁剪

  • TBPTT

  • LSTM

使用梯度裁剪时,我们为梯度指定一个截止值或阈值,并将此截止值分配给超出此值的梯度值。相比之下,TBPTT仅限于信号在每次前向传播后反向传播的时间步数。例如,即使序列有100个元素或步骤,我们也许只能反向传播最近的20个时间步。

虽然梯度裁剪和TBPTT都可以解决梯度爆炸问题,但截断限制了梯度有效反向传播并正确更新权重的步骤数。另一方面,LSTM由Sepp Hochreiter和Jürgen Schmidhuber于1997年设计,在解决梯度消失和爆炸问题时,通过使用记忆单元在建模长程依赖方面取得了更大的成功。让我们更详细地讨论LSTM。

长短期记忆单元(LSTM)

如前所述,LSTM最初是为了克服梯度消失问题而提出的(长短期记忆S. HochreiterJ. SchmidhuberNeural Computation,9(8):1735-1780,1997)。LSTM的构建模块是记忆单元,它本质上代表或取代了标准RNN的隐藏层。

在每个记忆单元中,都有一条递归边,其权重为我们所讨论的理想值 w = 1,用以克服梯度消失和爆炸问题。与此递归边相关的值统称为 单元状态。现代LSTM单元的展开结构如下图所示:

注意,从前一个时间步的单元状态!被修改以获得当前时间步的单元状态!,并没有直接与任何权重因子相乘。这个记忆单元的信息流由几个计算单元(通常称为)控制,这些计算单元将在此处描述。在前图中,表示元素级乘积(逐元素乘法),而表示元素级求和(逐元素加法)。此外,表示时间t的输入数据,而表示时间t - 1的隐藏单元。四个框表示带有激活函数的单元,激活函数可能是sigmoid函数()或tanh函数,并且有一组权重;这些框通过对输入(即)执行矩阵向量乘法,应用线性组合。这些带有sigmoid激活函数的计算单元,其输出单位经过处理,称为“门”。

在LSTM单元中,有三种不同类型的门,它们分别是遗忘门、输入门和输出门:

  • 遗忘门)允许记忆单元重置单元状态,从而避免无限增长。实际上,遗忘门决定哪些信息允许通过,哪些信息需要抑制。现在,的计算方式如下:

    请注意,遗忘门并不是原始LSTM单元的一部分;它是在几年后被添加进来的,用以改进原始模型(学习遗忘:LSTM的持续预测F. GersJ. Schmidhuber,和F. Cummins神经计算 12,2451-2471,2000)。

  • 输入门)和候选值)负责更新单元状态。它们的计算方式如下:

    在时间t的单元状态计算如下:

  • 输出门)决定如何更新隐藏单元的值:

    给定这一点,当前时间步的隐藏单元计算如下:

LSTM单元的结构及其底层计算可能看起来非常复杂且难以实现。然而,好消息是,TensorFlow已经将所有内容实现为优化的包装函数,使我们能够轻松高效地定义LSTM单元。我们将在本章后面将RNN和LSTM应用于实际数据集。

其他先进的RNN模型

LSTM提供了一种基础的方法,用于建模序列中的长距离依赖关系。然而,值得注意的是,文献中有许多不同类型的LSTM变种(An Empirical Exploration of Recurrent Network ArchitecturesRafal JozefowiczWojciech ZarembaIlya SutskeverProceedings of ICML,2342-2350,2015)。同样值得注意的是,2014年提出的一种更新方法——门控递归单元(GRU)。GRU的架构比LSTM更为简洁,因此在计算上更高效,同时在一些任务(如多音音乐建模)中的表现与LSTM相当。如果你对这些现代RNN架构感兴趣,可以参考Junyoung Chung等人的论文Empirical Evaluation of Gated Recurrent Neural Networks on Sequence Modeling2014https://arxiv.org/pdf/1412.3555v1.pdf)。

在TensorFlow中实现RNN用于序列建模

现在我们已经覆盖了RNN背后的基础理论,准备进入本章的更实际部分:在TensorFlow中实现RNN。在接下来的章节中,我们将把RNN应用于两个常见的任务:

  1. 情感分析

  2. 语言建模

这两个项目将在接下来的页面中一起讨论,既有趣又涉及内容丰富。因此,我们不会一次性给出所有代码,而是将实现过程分成几个步骤,并详细讨论代码。如果你想先了解大致框架,并在深入讨论之前看到完整代码,可以先查看代码实现,网址为https://github.com/rasbt/python-machine-learning-book-3rd-edition/tree/master/ch16

项目一——预测IMDb电影评论的情感

你可能还记得在第8章将机器学习应用于情感分析中,情感分析是关于分析句子或文本文档中表达的意见。在本节及随后的子节中,我们将使用多层RNN实现情感分析,采用多对一架构。

在下一节中,我们将实现一个多对多的RNN,应用于语言建模。虽然所选择的示例故意简单,以介绍RNN的主要概念,但语言建模有许多有趣的应用,比如构建聊天机器人——赋予计算机直接与人类交谈和互动的能力。

准备电影评论数据

第8章的预处理步骤中,我们创建了一个名为movie_data.csv的干净数据集,今天我们将再次使用它。首先,我们将导入必要的模块,并将数据读取到pandas DataFrame中,如下所示:

>>> import tensorflow as tf
>>> import tensorflow_datasets as tfds
>>> import numpy as np
>>> import pandas as pd
>>> df = pd.read_csv('movie_data.csv', encoding='utf-8') 

请记住,这个数据框 df 包含两列,分别是 'review''sentiment',其中 'review' 包含电影评论的文本(输入特征),而 'sentiment' 代表我们想要预测的目标标签(0 代表负面情感,1 代表正面情感)。这些电影评论的文本由一系列单词组成,RNN 模型将每个序列分类为正面(1)或负面(0)评论。

然而,在我们将数据输入到 RNN 模型之前,我们需要执行几个预处理步骤:

  1. 创建一个 TensorFlow 数据集对象,并将其划分为独立的训练、测试和验证分区。

  2. 识别训练数据集中的唯一词。

  3. 将每个独特的词映射到一个唯一的整数,并将评论文本编码为编码后的整数(每个唯一词的索引)。

  4. 将数据集划分为小批次,作为模型的输入。

让我们从第一步开始:从这个数据框创建一个 TensorFlow 数据集:

>>> ## Step 1: create a dataset
>>> target = df.pop('sentiment')
>>> ds_raw = tf.data.Dataset.from_tensor_slices(
...     (df.values, target.values))
>>> ## inspection:
>>> for ex in ds_raw.take(3):
...     tf.print(ex[0].numpy()[0][ :50], ex[1])
b'In 1974, the teenager Martha Moxley (Maggie Grace)' 1
b'OK... so... I really like Kris Kristofferson and h' 0
b'***SPOILER*** Do not read this, if you think about' 0 

现在,我们可以将其划分为训练集、测试集和验证集。整个数据集包含 50,000 个样本。我们将保留前 25,000 个样本用于评估(保留测试集),然后 20,000 个样本用于训练,5,000 个样本用于验证。代码如下:

>>> tf.random.set_seed(1)
>>> ds_raw = ds_raw.shuffle(
...     50000, reshuffle_each_iteration=False)
>>> ds_raw_test = ds_raw.take(25000)
>>> ds_raw_train_valid = ds_raw.skip(25000)
>>> ds_raw_train = ds_raw_train_valid.take(20000)
>>> ds_raw_valid = ds_raw_train_valid.skip(20000) 

为了将数据准备好输入到神经网络(NN),我们需要将其编码为数值,如步骤 2 和 3 所提到的那样。为此,我们将首先在训练数据集中找到唯一的单词(标记)。虽然找到唯一的标记是一个可以使用 Python 数据集的过程,但使用 Python 标准库中的 collections 包中的 Counter 类会更高效。

在接下来的代码中,我们将实例化一个新的 Counter 对象(token_counts),该对象将收集唯一单词的频率。请注意,在这个特定的应用中(与词袋模型不同),我们只关心唯一词的集合,而不需要词频,它们是作为副产品创建的。为了将文本拆分为单词(或标记),tensorflow_datasets 包提供了一个 Tokenizer 类。

收集唯一标记的代码如下:

>>> ## Step 2: find unique tokens (words)
>>> from collections import Counter
>>> tokenizer = tfds.features.text.Tokenizer()
>>> token_counts = Counter()
>>> for example in ds_raw_train:
...     tokens = tokenizer.tokenize(example[0].numpy()[0])
...     token_counts.update(tokens)
>>> print('Vocab-size:', len(token_counts))
Vocab-size: 87007 

如果你想了解更多关于 Counter 的信息,可以参考其文档:https://docs.python.org/3/library/collections.html#collections.Counter

接下来,我们将把每个独特的单词映射到一个唯一的整数。这可以通过手动使用 Python 字典来完成,其中键是独特的词汇(单词),每个键对应的值是唯一的整数。然而,tensorflow_datasets 包已经提供了一个类,TokenTextEncoder,我们可以用它来创建这样的映射并对整个数据集进行编码。首先,我们将通过传递唯一的词汇(token_counts 包含了词汇和它们的计数,尽管这里计数不需要,因此会被忽略)来从 TokenTextEncoder 类创建一个 encoder 对象。调用 encoder.encode() 方法将把输入文本转换为一个整数值的列表:

>>> ## Step 3: encoding unique tokens to integers
>>> encoder = tfds.features.text.TokenTextEncoder(token_counts)
>>> example_str = 'This is an example!'
>>> print(encoder.encode(example_str))
[232, 9, 270, 1123] 

请注意,验证数据或测试数据中可能存在一些在训练数据中没有出现的词汇,因此它们不包含在映射中。如果我们有 q 个词汇(即传递给 TokenTextEncodertoken_counts 大小,在此案例中为 87,007),那么所有之前未见过的词汇,因而不在 token_counts 中的,将被分配整数 q + 1(在我们这个例子中为 87,008)。换句话说,索引 q + 1 被保留给未知词汇。另一个保留值是整数 0,它作为占位符来调整序列长度。稍后,当我们在 TensorFlow 中构建 RNN 模型时,我们将更加详细地考虑这两个占位符,0 和 q + 1。

我们可以使用数据集对象的 map() 方法来相应地转换数据集中的每个文本,就像我们对数据集应用任何其他变换一样。然而,有一个小问题:在这里,文本数据被封装在张量对象中,我们可以通过在激活执行模式下调用张量的 numpy() 方法来访问它们。但在通过 map() 方法进行转换时,激活执行将被禁用。为了解决这个问题,我们可以定义两个函数,第一个函数将处理输入的张量,就像启用了激活执行模式一样:

>>> ## Step 3-A: define the function for transformation
>>> def encode(text_tensor, label):
...     text = text_tensor.numpy()[0]
...     encoded_text = encoder.encode(text)
...     return encoded_text, label 

在第二个函数中,我们将使用 tf.py_function 包装第一个函数,将其转换为 TensorFlow 操作符,之后可以通过其 map() 方法使用。将文本编码为整数列表的过程可以使用以下代码完成:

>>> ## Step 3-B: wrap the encode function to a TF Op.
>>> def encode_map_fn(text, label):
...     return tf.py_function(encode, inp=[text, label],
...                           Tout=(tf.int64, tf.int64))
>>> ds_train = ds_raw_train.map(encode_map_fn)
>>> ds_valid = ds_raw_valid.map(encode_map_fn)
>>> ds_test = ds_raw_test.map(encode_map_fn)
>>> # look at the shape of some examples:
>>> tf.random.set_seed(1)
>>> for example in ds_train.shuffle(1000).take(5):
...     print('Sequence length:', example[0].shape)
Sequence length: (24,)
Sequence length: (179,)
Sequence length: (262,)
Sequence length: (535,)
Sequence length: (130,) 

到目前为止,我们已经将单词序列转换为整数序列。然而,我们仍然有一个问题需要解决——当前的序列长度不同(如执行前面代码后,随机选择的五个例子的结果所示)。尽管一般来说,RNN 可以处理不同长度的序列,但我们仍需要确保每个小批次中的所有序列具有相同的长度,以便能有效地存储在张量中。

为了将具有不同形状元素的数据集划分为 mini-batch,TensorFlow 提供了一种不同的方法 padded_batch()(代替 batch()),它会自动将要合并为批次的连续元素用占位符值(0)进行填充,从而确保每个批次中的所有序列具有相同的形状。为了通过实际示例说明这一点,假设我们从训练数据集 ds_train 中提取一个大小为 8 的小子集,并对该子集应用 padded_batch() 方法,batch_size=4。我们还将打印合并成 mini-batch 前单个元素的大小,以及结果 mini-batch 的维度:

>>> ## Take a small subset
>>> ds_subset = ds_train.take(8)
>>> for example in ds_subset:
...     print('Individual size:', example[0].shape)
Individual size: (119,)
Individual size: (688,)
Individual size: (308,)
Individual size: (204,)
Individual size: (326,)
Individual size: (240,)
Individual size: (127,)
Individual size: (453,)
>>> ## Dividing the dataset into batches
>>> ds_batched = ds_subset.padded_batch(
...              4, padded_shapes=([-1], []))
>>> for batch in ds_batched:
...     print('Batch dimension:', batch[0].shape)
Batch dimension: (4, 688)
Batch dimension: (4, 453) 

如从打印的张量形状中可以观察到,第一批次的列数(即 .shape[1])为 688,这是通过将前四个示例合并成一个批次并使用这些示例的最大大小得到的。这意味着该批次中的其他三个示例已经被填充了必要的零值,以匹配此大小。类似地,第二批次保持其四个示例的最大大小,即 453,并填充其他示例,使它们的长度小于最大长度。

让我们将所有三个数据集分成 mini-batch,每个批次大小为 32:

>>> train_data = ds_train.padded_batch(
...     32, padded_shapes=([-1],[]))
>>> valid_data = ds_valid.padded_batch(
...     32, padded_shapes=([-1],[]))
>>> test_data = ds_test.padded_batch(
...     32, padded_shapes=([-1],[])) 

现在,数据已经被转换为适合 RNN 模型的格式,我们将在接下来的子章节中实现该模型。然而,在下一节中,我们将首先讨论特征嵌入,这是一种可选但强烈推荐的预处理步骤,用于减少词向量的维度。

用于句子编码的嵌入层

在前一步的数据准备过程中,我们生成了相同长度的序列。这些序列的元素是整数,表示唯一单词的索引。这些单词索引可以通过几种不同的方式转换为输入特征。一种简单的方法是应用 one-hot 编码,将索引转换为零和一的向量。然后,每个单词将被映射到一个向量,其大小是整个数据集中唯一单词的数量。考虑到唯一单词的数量(词汇表大小)可能达到 ,这也将是我们的输入特征的数量,基于这些特征训练的模型可能会遭遇维度灾难。此外,这些特征非常稀疏,因为除了一个以外,所有元素都是零。

一种更优雅的方法是将每个单词映射到一个具有固定大小的向量,向量元素为实数(不一定是整数)。与 one-hot 编码向量不同,我们可以使用有限大小的向量来表示无限多个实数。(理论上,我们可以从给定的区间中提取无限多个实数,例如 [–1, 1]。)

这就是嵌入(embedding)背后的思想,它是一种特征学习技术,我们可以在这里利用它自动学习显著特征来表示数据集中的单词。考虑到唯一单词的数量,,我们可以选择嵌入向量的大小(即嵌入维度)远小于唯一单词的数量(),以便将整个词汇表表示为输入特征。

嵌入相对于独热编码的优势如下:

  • 通过减少特征空间的维度来降低维度灾难的影响

  • 提取显著特征,因为神经网络中的嵌入层可以被优化(或学习)

以下示意图展示了嵌入是如何通过将标记索引映射到一个可训练的嵌入矩阵来工作的:

给定一个包含n + 2个标记的集合(n是标记集合的大小,索引0保留用于填充占位符,n + 1用于表示不在标记集合中的单词),将创建一个大小为的嵌入矩阵,其中矩阵的每一行表示与一个标记相关联的数值特征。因此,当输入一个整数索引i时,嵌入层将查找矩阵中索引i对应的行,并返回数值特征。嵌入矩阵作为我们的神经网络模型的输入层。在实践中,创建嵌入层可以通过tf.keras.layers.Embedding来简便地实现。接下来让我们看一个示例,我们将创建一个模型并添加嵌入层,如下所示:

>>> from tensorflow.keras.layers import Embedding
>>> model = tf.keras.Sequential()
>>> model.add(Embedding(input_dim=100,
...                     output_dim=6,
...                     input_length=20,
...                     name='embed-layer'))
>>> model.summary()
Model: "sequential"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
embed-layer (Embedding)      (None, 20, 6)             600      
=================================================================
Total params: 6,00
Trainable params: 6,00
Non-trainable params: 0
_________________________________________________________________ 

该模型的输入(嵌入层)必须具有2的秩,维度为,其中是序列的长度(在这里通过input_length参数设置为20)。例如,迷你批次中的一个输入序列可能是,其中每个元素是唯一单词的索引。输出将具有维度,其中是嵌入特征的大小(在这里通过output_dim设置为6)。嵌入层的另一个参数input_dim对应于模型将接收的唯一整数值(例如,n + 2,这里设置为100)。因此,在这种情况下,嵌入矩阵的大小是

处理可变序列长度

请注意,input_length参数不是必需的,我们可以在输入序列长度变化的情况下使用None。你可以在官方文档中找到更多关于此函数的信息,链接是:https://www.tensorflow.org/versions/r2.0/api_docs/python/tf/keras/layers/Embedding

构建一个RNN模型

现在我们准备好构建RNN模型了。使用Keras的 Sequential 类,我们可以将嵌入层、RNN的循环层和非循环的全连接层结合起来。对于循环层,我们可以使用以下任何一种实现:

  • SimpleRNN:一个常规的RNN层,即完全连接的循环层

  • LSTM:长短期记忆RNN,用于捕捉长期依赖关系

  • GRU:带有门控循环单元的循环层,正如在 《使用RNN编码器-解码器进行统计机器翻译学习短语表示》https://arxiv.org/abs/1406.1078v3)中提出的,作为LSTM的替代方案

要查看如何使用这些循环层之一构建多层RNN模型,在以下示例中,我们将创建一个RNN模型,首先使用 input_dim=1000output_dim=32 的嵌入层。然后,添加两个类型为 SimpleRNN 的循环层。最后,我们将添加一个非循环的全连接层作为输出层,该层将返回一个单一的输出值作为预测:

>>> from tensorflow.keras import Sequential
>>> from tensorflow.keras.layers import Embedding
>>> from tensorflow.keras.layers import SimpleRNN
>>> from tensorflow.keras.layers import Dense
>>> model = Sequential()
>>> model.add(Embedding(input_dim=1000, output_dim=32))
>>> model.add(SimpleRNN(32, return_sequences=True))
>>> model.add(SimpleRNN(32))
>>> model.add(Dense(1))
>>> model.summary()
Model: "sequential"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
embedding (Embedding)        (None, None, 32)          32000     
_________________________________________________________________
simple_rnn (SimpleRNN)       (None, None, 32)          2080      
_________________________________________________________________
simple_rnn_1 (SimpleRNN)     (None, 32)                2080      
_________________________________________________________________
dense (Dense)                (None, 1)                 33        
=================================================================
Total params: 36,193
Trainable params: 36,193
Non-trainable params: 0
_________________________________________________________________ 

如你所见,使用这些循环层构建RNN模型是相当简单的。在接下来的小节中,我们将回到情感分析任务,并构建一个RNN模型来解决该问题。

为情感分析任务构建RNN模型

由于我们有非常长的序列,我们将使用LSTM层来考虑长期效应。此外,我们还会将LSTM层放入 Bidirectional 包装器中,这样循环层将从两个方向处理输入序列,从前到后以及反向方向:

>>> embedding_dim = 20
>>> vocab_size = len(token_counts) + 2
>>> tf.random.set_seed(1)
>>> ## build the model
>>> bi_lstm_model = tf.keras.Sequential([
...     tf.keras.layers.Embedding(
...         input_dim=vocab_size,
...         output_dim=embedding_dim,
...         name='embed-layer'),
...     
...     tf.keras.layers.Bidirectional(
...         tf.keras.layers.LSTM(64, name='lstm-layer'),
...         name='bidir-lstm'),
...
...     tf.keras.layers.Dense(64, activation='relu'),
...     
...     tf.keras.layers.Dense(1, activation='sigmoid')
>>> ])
>>> bi_lstm_model.summary()
>>> ## compile and train:
>>> bi_lstm_model.compile(
...     optimizer=tf.keras.optimizers.Adam(1e-3),
...     loss=tf.keras.losses.BinaryCrossentropy(from_logits=False),
...     metrics=['accuracy'])
>>> history = bi_lstm_model.fit(
...     train_data,
...     validation_data=valid_data,
...     epochs=10)
>>> ## evaluate on the test data
>>> test_results = bi_lstm_model.evaluate(test_data)
>>> print('Test Acc.: {:.2f}%'.format(test_results[1]*100))
Epoch 1/10
625/625 [==============================] - 96s 154ms/step - loss: 0.4410 - accuracy: 0.7782 - val_loss: 0.0000e+00 - val_accuracy: 0.0000e+00
Epoch 2/10
625/625 [==============================] - 95s 152ms/step - loss: 0.1799 - accuracy: 0.9326 - val_loss: 0.4833 - val_accuracy: 0.8414
. . .
Test Acc.: 85.15% 

在训练此模型10个epoch后,对测试数据的评估显示准确率为85%。 (请注意,与在IMDb数据集上使用的最先进方法相比,这个结果并不是最好的。目标仅仅是展示RNN如何工作。)

关于双向RNN的更多内容

Bidirectional 包装器对每个输入序列进行两次处理:一次前向传递和一次反向或后向传递(注意,这与反向传播中的前向和后向传递不同)。这些前向和后向传递的结果默认会被连接在一起。但如果你想改变这个行为,可以将参数 merge_mode 设置为 'sum'(求和)、'mul'(将两个传递结果相乘)、'ave'(取两个结果的平均值)、'concat'(默认值)或 None,后者会将两个张量返回为列表。如需了解有关 Bidirectional 包装器的更多信息,请查看官方文档:https://www.tensorflow.org/versions/r2.0/api_docs/python/tf/keras/layers/Bidirectional

我们还可以尝试其他类型的递归层,例如 SimpleRNN。然而,事实证明,使用常规递归层构建的模型无法达到良好的预测性能(即使是在训练数据上)。例如,如果你尝试用单向的 SimpleRNN 层替换前面代码中的双向 LSTM 层,并用全长序列训练模型,你可能会发现训练过程中损失甚至没有下降。原因是该数据集中的序列过长,因此具有 SimpleRNN 层的模型无法学习长期依赖关系,可能会遭遇梯度消失或爆炸的问题。

为了在此数据集上使用 SimpleRNN 获得合理的预测性能,我们可以截断序列。此外,利用我们的“领域知识”,我们可能会假设电影评论的最后几段包含大部分关于其情感的信息。因此,我们可以只关注每个评论的最后部分。为此,我们将定义一个辅助函数 preprocess_datasets(),以结合预处理步骤 2-4。此函数的一个可选参数是 max_seq_length,它决定每个评论应使用多少个标记。例如,如果我们设置 max_seq_length=100 并且某个评论的标记数超过 100,那么只会使用最后的 100 个标记。如果将 max_seq_length 设置为 None,则会像之前一样使用全长序列。尝试不同的 max_seq_length 值将为我们提供更多关于不同 RNN 模型处理长序列能力的见解。

preprocess_datasets() 函数的代码如下:

>>> from collections import Counter
>>> def preprocess_datasets(
...     ds_raw_train,
...     ds_raw_valid,
...     ds_raw_test,
...     max_seq_length=None,
...     batch_size=32):
...     
...     ## (step 1 is already done)
...     ## Step 2: find unique tokens
...     tokenizer = tfds.features.text.Tokenizer()
...     token_counts = Counter()
...
...     for example in ds_raw_train:
...         tokens = tokenizer.tokenize(example[0].numpy()[0])
...         if max_seq_length is not None:
...             tokens = tokens[-max_seq_length:]
...         token_counts.update(tokens)
...
...     print('Vocab-size:', len(token_counts))
...
...     ## Step 3: encoding the texts
...     encoder = tfds.features.text.TokenTextEncoder(
...                   token_counts)
...     def encode(text_tensor, label):
...         text = text_tensor.numpy()[0]
...         encoded_text = encoder.encode(text)
...         if max_seq_length is not None:
...             encoded_text = encoded_text[-max_seq_length:]
...         return encoded_text, label
...
...     def encode_map_fn(text, label):
...         return tf.py_function(encode, inp=[text, label],
...                               Tout=(tf.int64, tf.int64))
...
...     ds_train = ds_raw_train.map(encode_map_fn)
...     ds_valid = ds_raw_valid.map(encode_map_fn)
...     ds_test = ds_raw_test.map(encode_map_fn)
...
...     ## Step 4: batching the datasets
...     train_data = ds_train.padded_batch(
...         batch_size, padded_shapes=([-1],[]))
...
...     valid_data = ds_valid.padded_batch(
...         batch_size, padded_shapes=([-1],[]))
...
...     test_data = ds_test.padded_batch(
...         batch_size, padded_shapes=([-1],[]))
...
...     return (train_data, valid_data,
...             test_data, len(token_counts)) 

接下来,我们将定义另一个辅助函数 build_rnn_model(),用于更方便地构建具有不同架构的模型:

>>> from tensorflow.keras.layers import Embedding
>>> from tensorflow.keras.layers import Bidirectional
>>> from tensorflow.keras.layers import SimpleRNN
>>> from tensorflow.keras.layers import LSTM
>>> from tensorflow.keras.layers import GRU
>>> def build_rnn_model(embedding_dim, vocab_size,
...                     recurrent_type='SimpleRNN',
...                     n_recurrent_units=64,
...                     n_recurrent_layers=1,
...                     bidirectional=True):
...
...     tf.random.set_seed(1)
...
...     # build the model
...     model = tf.keras.Sequential()
...     
...     model.add(
...         Embedding(
...             input_dim=vocab_size,
...             output_dim=embedding_dim,
...             name='embed-layer')
...     )
...     
...     for i in range(n_recurrent_layers):
...         return_sequences = (i < n_recurrent_layers-1)
...             
...         if recurrent_type == 'SimpleRNN':
...             recurrent_layer = SimpleRNN(
...                 units=n_recurrent_units,
...                 return_sequences=return_sequences,
...                 name='simprnn-layer-{}'.format(i))
...         elif recurrent_type == 'LSTM':
...             recurrent_layer = LSTM(
...                 units=n_recurrent_units,
...                 return_sequences=return_sequences,
...                 name='lstm-layer-{}'.format(i))
...         elif recurrent_type == 'GRU':
...             recurrent_layer = GRU(
...                 units=n_recurrent_units,
...                 return_sequences=return_sequences,
...                 name='gru-layer-{}'.format(i))
...         
...         if bidirectional:
...             recurrent_layer = Bidirectional(
...                 recurrent_layer, name='bidir-' +
...                 recurrent_layer.name)
...             
...         model.add(recurrent_layer)
...
...     model.add(tf.keras.layers.Dense(64, activation='relu'))
...     model.add(tf.keras.layers.Dense(1, activation='sigmoid'))
...     
...     return model 

现在,利用这两个相对通用且便捷的辅助函数,我们可以轻松比较不同的 RNN 模型和不同输入序列长度的表现。作为示例,在下面的代码中,我们将尝试使用一个具有单层 SimpleRNN 的模型,并将序列截断为最多 100 个标记的长度:

>>> batch_size = 32
>>> embedding_dim = 20
>>> max_seq_length = 100
>>> train_data, valid_data, test_data, n = preprocess_datasets(
...     ds_raw_train, ds_raw_valid, ds_raw_test,
...     max_seq_length=max_seq_length,
...     batch_size=batch_size
... )
>>> vocab_size = n + 2
>>> rnn_model = build_rnn_model(
...     embedding_dim, vocab_size,
...     recurrent_type='SimpleRNN',
...     n_recurrent_units=64,
...     n_recurrent_layers=1,
...     bidirectional=True)
>>> rnn_model.summary()
Model: "sequential"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
embed-layer (Embedding)      (None, None, 20)          1161300   
_________________________________________________________________
bidir-simprnn-layer-0 (Bidir (None, 128)               10880     
_________________________________________________________________
Dense    (Dense)             (None, 64)                8256      
_________________________________________________________________
dense_1  (Dense)             (None, 1)                 65        
=================================================================
Total params: 1,180,501
Trainable params: 1,180,501
Non-trainable params: 0
_________________________________________________________________
>>> rnn_model.compile(
...     optimizer=tf.keras.optimizers.Adam(1e-3),
...     loss=tf.keras.losses.BinaryCrossentropy(
...          from_logits=False), metrics=['accuracy'])
>>> history = rnn_model.fit(
...     train_data,
...     validation_data=valid_data,
...     epochs=10)
Epoch 1/10
625/625 [==============================] - 73s 118ms/step - loss: 0.6996 - accuracy: 0.5074 - val_loss: 0.6880 - val_accuracy: 0.5476
Epoch 2/10
>>> results = rnn_model.evaluate(test_data)
>>> print('Test Acc.: {:.2f}%'.format(results[1]*100))
Test Acc.: 80.70% 

例如,将序列截断为 100 个标记并使用双向 SimpleRNN 层,最终获得了 80% 的分类准确率。尽管与之前的双向 LSTM 模型(在测试数据集上的准确率为 85.15%)相比,预测稍低,但在这些截断序列上的表现要远好于我们用 SimpleRNN 对全长电影评论进行建模时的表现。作为一个可选练习,你可以通过使用我们已经定义的两个辅助函数来验证这一点。尝试将 max_seq_length=None,并在 build_rnn_model() 辅助函数中将 bidirectional 参数设置为 False。 (为了方便你,这段代码可以在本书的在线材料中找到。)

项目二 – 在 TensorFlow 中进行字符级语言建模

语言建模是一项迷人的应用,使机器能够执行与人类语言相关的任务,例如生成英语句子。该领域的一个有趣研究是生成文本的递归神经网络伊利亚·苏茨凯弗詹姆斯·马滕斯杰弗里·E·辛顿第28届国际机器学习会议论文集(ICML-11)2011年https://pdfs.semanticscholar.org/93c2/0e38c85b69fc2d2eb314b3c1217913f7db11.pdf

在我们现在要构建的模型中,输入是一个文本文件,我们的目标是开发一个可以生成与输入文件风格相似的新文本的模型。这类输入的示例包括书籍或特定编程语言中的计算机程序。

在字符级语言建模中,输入被分解为一系列字符,并且每次将一个字符输入到我们的网络中。网络将结合先前看到的字符的记忆处理每个新字符,以预测下一个字符。下图展示了一个字符级语言建模的示例(请注意,EOS代表“序列结束”):

我们可以将此实现分为三个独立的步骤:准备数据、构建RNN模型,以及执行下一个字符预测和采样来生成新文本。

数据集预处理

在本节中,我们将准备字符级语言建模的数据。

要获取输入数据,请访问Project Gutenberg网站,该网站提供了成千上万的免费电子书。以我们的示例为例,您可以从http://www.gutenberg.org/files/1268/1268-0.txt下载《神秘岛》一书(由儒勒·凡尔纳于1874年出版)的纯文本格式。

请注意,此链接将直接带您到下载页面。如果您使用的是macOS或Linux操作系统,您可以在终端中使用以下命令下载文件:

curl -O http://www.gutenberg.org/files/1268/1268-0.txt 

如果此资源将来无法使用,本章代码库中书籍的代码仓库也包含了该文本的副本,位于https://github.com/rasbt/python-machine-learning-book-3rd-edition/code/ch16

一旦我们下载了数据集,就可以将其作为纯文本读取到Python会话中。使用以下代码,我们将直接从下载的文件中读取文本,并去掉开头和结尾的部分(这些部分包含一些关于古腾堡项目的描述)。然后,我们将创建一个Python变量char_set,表示在此文本中观察到的唯一字符集:

>>> import numpy as np
>>> ## Reading and processing text
>>> with open('1268-0.txt', 'r') as fp:
...     text=fp.read()
>>> start_indx = text.find('THE MYSTERIOUS ISLAND')
>>> end_indx = text.find('End of the Project Gutenberg')
>>> text = text[start_indx:end_indx]
>>> char_set = set(text)
>>> print('Total Length:', len(text))
Total Length: 1112350
>>> print('Unique Characters:', len(char_set))
Unique Characters: 80 

下载并预处理文本后,我们得到了一个包含1,112,350个字符、80个独特字符的序列。然而,大多数神经网络库和循环神经网络(RNN)实现无法处理字符串格式的输入数据,这就是为什么我们需要将文本转换为数字格式。为此,我们将创建一个简单的Python字典,将每个字符映射到一个整数,char2int。我们还需要一个反向映射,将模型的输出结果转换回文本。尽管可以通过使用字典将整数键与字符值关联来实现反向映射,但使用NumPy数组并通过索引数组将索引映射到这些独特的字符更高效。下图展示了将字符转换为整数及其反向操作的示例,以单词"Hello""world"为例:

如前图所示,构建字典以将字符映射到整数,并通过索引NumPy数组进行反向映射如下所示:

>>> chars_sorted = sorted(char_set)
>>> char2int = {ch:i for i,ch in enumerate(chars_sorted)}
>>> char_array = np.array(chars_sorted)
>>> text_encoded = np.array(
...     [char2int[ch] for ch in text],
...     dtype=np.int32)
>>> print('Text encoded shape:', text_encoded.shape)
Text encoded shape: (1112350,)
>>> print(text[:15], '== Encoding ==>', text_encoded[:15])
>>> print(text_encoded[15:21], '== Reverse ==>',
...     ''.join(char_array[text_encoded[15:21]]))
THE MYSTERIOUS == Encoding ==> [44 32 29  1 37 48 43 44 29 42 33 39 45 43  1]
[33 43 36 25 38 28] == Reverse ==> ISLAND 

NumPy数组text_encoded包含文本中所有字符的编码值。现在,我们将从该数组创建一个TensorFlow数据集:

>>> import tensorflow as tf
>>> ds_text_encoded = tf.data.Dataset.from_tensor_slices(
...                   text_encoded)
>>> for ex in ds_text_encoded.take(5):
...     print('{} -> {}'.format(ex.numpy(), char_array[ex.numpy()]))
44 -> T
32 -> H
29 -> E
1 ->  
37 -> M 

到目前为止,我们已经创建了一个可迭代的Dataset对象,用于按文本中字符出现的顺序获取字符。现在,让我们回顾一下我们正在尝试做的大局观。对于文本生成任务,我们可以将问题表述为一个分类任务。

假设我们有一组不完整的文本字符序列,如下图所示:

在前面的图中,我们可以将左侧框中显示的序列视为输入。为了生成新的文本,我们的目标是设计一个模型,该模型能够预测给定输入序列的下一个字符,其中输入序列表示一个不完整的文本。例如,在看到 "Deep Learn" 后,模型应该预测 "i" 作为下一个字符。考虑到我们有80个独特的字符,这个问题变成了一个多类别分类任务。

从长度为1的序列开始(即一个单独的字母),我们可以基于这种多类别分类方法迭代地生成新文本,如下图所示:

为了在TensorFlow中实现文本生成任务,首先我们将序列长度限制为40。这意味着输入张量 x 包含40个标记。实际上,序列长度会影响生成文本的质量。较长的序列可能会生成更有意义的句子。然而,对于较短的序列,模型可能会专注于正确捕捉单个词汇,而大部分情况下忽略上下文。尽管较长的序列通常能生成更有意义的句子,但如前所述,对于长序列,RNN模型在捕捉长期依赖时可能会出现问题。因此,实际中,找到合适的序列长度是一项超参数优化问题,我们需要通过经验评估。在这里,我们选择40,因为它提供了一个较好的折衷方案。

如前图所示,输入 x 和目标 y 相差一个字符。因此,我们将把文本拆分为41个字符的块:前40个字符将构成输入序列 x,最后40个元素将构成目标序列 y

我们已经将整个编码后的文本按原始顺序存储在 Dataset 对象 ds_text_encoded 中。使用本章中已涉及的关于转换数据集的技术(在 准备电影评论数据 部分),你能想到一种方法来获得输入 x 和目标 y,如前图所示吗?答案很简单:我们将首先使用 batch() 方法创建每个包含41个字符的文本块。这意味着我们将设置 batch_size=41。如果最后一个批次少于41个字符,我们会将其去除。因此,新的分块数据集,命名为 ds_chunks,将始终包含41个字符大小的序列。然后,这些41个字符的块将用于构建序列 x(即输入),以及序列 y(即目标),这两个序列都将包含40个元素。例如,序列 x 将由索引[0, 1, …, 39]的元素组成。此外,由于序列 y 相对于 x 会向右移动一个位置,因此其对应的索引将是[1, 2, …, 40]。然后,我们将使用 map() 方法应用一个转换函数,相应地分离 xy 序列:

>>> seq_length = 40
>>> chunk_size = seq_length + 1
>>> ds_chunks = ds_text_encoded.batch(chunk_size, 
...                                   drop_remainder=True)
>>> ## define the function for splitting x & y
>>> def split_input_target(chunk):
...     input_seq = chunk[:-1]
...     target_seq = chunk[1:]
...     return input_seq, target_seq
>>> ds_sequences = ds_chunks.map(split_input_target) 

让我们来看一下从这个转换后的数据集中提取的示例序列:

>>> for example in ds_sequences.take(2):
...     print(' Input (x): ', 
...           repr(''.join(char_array[example[0].numpy()])))
...     print('Target (y): ', 
...           repr(''.join(char_array[example[1].numpy()])))
...     print()
 Input (x):  'THE MYSTERIOUS ISLAND ***\n\n\n\n\nProduced b'
Target (y):  'HE MYSTERIOUS ISLAND ***\n\n\n\n\nProduced by'
 Input (x):  ' Anthony Matonak, and Trevor Carlson\n\n\n\n'
Target (y):  'Anthony Matonak, and Trevor Carlson\n\n\n\n\n' 

最后,准备数据集的最后一步是将数据集划分为小批次。在第一次预处理步骤中,我们将数据集划分为批次时,创建了句子块。每个块代表一个句子,对应一个训练样本。现在,我们将重新洗牌训练样本,并再次将输入划分为小批次;不过这一次,每个批次将包含多个训练样本:

>>> BATCH_SIZE = 64
>>> BUFFER_SIZE = 10000
>>> ds = ds_sequences.shuffle(BUFFER_SIZE).batch(BATCH_SIZE) 

构建字符级RNN模型

现在数据集已经准备好,构建模型将相对简单。为了代码的可重用性,我们将编写一个名为build_model的函数,通过Keras的Sequential类定义一个RNN模型。然后,我们可以指定训练参数并调用该函数来获取RNN模型:

>>> def build_model(vocab_size, embedding_dim,rnn_units):
...     model = tf.keras.Sequential([
...         tf.keras.layers.Embedding(vocab_size, embedding_dim),
...         tf.keras.layers.LSTM(
...             rnn_units,
...             return_sequences=True),
...         tf.keras.layers.Dense(vocab_size)
...     ])
...     return model
>>> ## Setting the training parameters
>>> charset_size = len(char_array)
>>> embedding_dim = 256
>>> rnn_units = 512
>>> tf.random.set_seed(1)
>>> model = build_model(
...     vocab_size=charset_size,
...     embedding_dim=embedding_dim,
...     rnn_units=rnn_units)
>>> model.summary()
Model: "sequential"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
embedding (Embedding)        (None, None, 256)         20480     
_________________________________________________________________
lstm (LSTM)                  (None, None, 512)         1574912   
_________________________________________________________________
dense (Dense)                (None, None, 80)          41040     
=================================================================
Total params: 1,636,432
Trainable params: 1,636,432
Non-trainable params: 0
_________________________________________________________________ 

请注意,该模型中的LSTM层具有输出形状(None, None, 512),这意味着LSTM的输出是3维的。第一个维度表示批次数,第二个维度表示输出序列的长度,最后一个维度对应隐藏单元的数量。LSTM层输出为3维的原因是我们在定义LSTM层时指定了return_sequences=True。全连接层(Dense)接收来自LSTM单元的输出,并计算输出序列中每个元素的logits。因此,模型的最终输出也将是一个3维的张量。

此外,我们在最后的全连接层中指定了activation=None。这样做的原因是,我们需要将logits作为模型的输出,以便从模型预测中采样生成新文本。采样部分稍后会介绍。现在,让我们开始训练模型:

>>> model.compile(
...     optimizer='adam',
...     loss=tf.keras.losses.SparseCategoricalCrossentropy(
...         from_logits=True
...     ))
>>> model.fit(ds, epochs=20)
Epoch 1/20
424/424 [==============================] - 80s 189ms/step - loss: 2.3437
Epoch 2/20
424/424 [==============================] - 79s 187ms/step - loss: 1.7654
...
Epoch 20/20
424/424 [==============================] - 79s 187ms/step - loss: 1.0478 

现在,我们可以评估模型以生成新文本,从给定的短字符串开始。在下一节中,我们将定义一个函数来评估训练好的模型。

评估阶段 – 生成新文本段落

我们在上一节中训练的RNN模型返回每个独特字符的logits,大小为80。这些logits可以通过softmax函数轻松转换为概率,即一个特定字符作为下一个字符出现的概率。为了预测序列中的下一个字符,我们可以简单地选择具有最大logit值的元素,这等同于选择具有最高概率的字符。然而,我们并不总是选择具有最高概率的字符,而是希望从输出中(随机)采样;否则,模型将始终生成相同的文本。TensorFlow已经提供了一个函数tf.random.categorical(),我们可以使用它从分类分布中抽取随机样本。为了理解这一过程,让我们从三个类别[0, 1, 2]中生成一些随机样本,输入logits为[1, 1, 1]。

>>> tf.random.set_seed(1)
>>> logits = [[1.0, 1.0, 1.0]]
>>> print('Probabilities:', tf.math.softmax(logits).numpy()[0])
Probabilities: [0.33333334 0.33333334 0.33333334]
>>> samples = tf.random.categorical(
...     logits=logits, num_samples=10)
>>> tf.print(samples.numpy())
array([[0, 0, 1, 2, 0, 0, 0, 0, 1, 0]]) 

如你所见,给定的logits下,各类别的概率是相同的(即,各类别具有相同概率)。因此,如果我们使用一个大样本量(),我们预计每个类别的出现次数将达到样本量的 。如果我们将logits更改为[1, 1, 3],那么我们预计类别2会出现更多的次数(当从该分布中抽取大量样本时):

>>> tf.random.set_seed(1)
>>> logits = [[1.0, 1.0, 3.0]]
>>> print('Probabilities: ', tf.math.softmax(logits).numpy()[0])
Probabilities: [0.10650698 0.10650698 0.78698605]
>>> samples = tf.random.categorical(
...     logits=logits, num_samples=10)
>>> tf.print(samples.numpy())
array([[2, 0, 2, 2, 2, 0, 1, 2, 2, 0]]) 

使用 tf.random.categorical 函数,我们可以基于模型计算的 logits 生成示例。我们定义一个名为 sample() 的函数,接收一个短起始字符串 starting_str,并生成一个新字符串 generated_str,其初始设置为输入字符串。然后,从 generated_str 的末尾取大小为 max_input_length 的字符串,并将其编码为整数序列 encoded_inputencoded_input 被传递给 RNN 模型以计算 logits。需要注意的是,由于我们为 RNN 模型的最后一个循环层指定了 return_sequences=True,因此从 RNN 模型的输出是与输入序列相同长度的 logits 序列。因此,RNN 模型输出的每个元素表示模型在观察输入序列后下一个字符的 logits(这里是一个大小为 80 的向量,即字符的总数)。

在这里,我们仅使用输出 logits 的最后一个元素(即 ),该元素被传递给 tf.random.categorical() 函数以生成一个新的样本。这个新样本被转换为一个字符,然后附加到生成的字符串 generated_text 的末尾,使其长度增加 1。然后,此过程重复进行,从 generated_str 的末尾取最后 max_input_length 个字符,并使用它来生成一个新字符,直到生成字符串的长度达到所需值。将生成序列作为生成新元素的输入的过程称为 自回归

返回序列作为输出

您可能会想知道为什么在仅使用最后一个字符来生成新字符并忽略输出的其余部分时我们使用 return_sequences=True。虽然这个问题非常合理,但不要忘记我们使用整个输出序列进行训练。损失是基于输出的每个预测而不仅仅是最后一个来计算的。

sample() 函数的代码如下所示:

>>> def sample(model, starting_str,
...            len_generated_text=500,
...            max_input_length=40,
...            scale_factor=1.0):
...     encoded_input = [char2int[s] for s in starting_str]
...     encoded_input = tf.reshape(encoded_input, (1, -1))
...
...     generated_str = starting_str
...
...     model.reset_states()
...     for i in range(len_generated_text):
...         logits = model(encoded_input)
...         logits = tf.squeeze(logits, 0)
...
...         scaled_logits = logits * scale_factor
...         new_char_indx = tf.random.categorical(
...             scaled_logits, num_samples=1)
...         
...         new_char_indx = tf.squeeze(new_char_indx)[-1].numpy()
...
...         generated_str += str(char_array[new_char_indx])
...         
...         new_char_indx = tf.expand_dims([new_char_indx], 0)
...         encoded_input = tf.concat(
...             [encoded_input, new_char_indx],
...             axis=1)
...         encoded_input = encoded_input[:, -max_input_length:]
...
...     return generated_str 

现在让我们生成一些新文本:

>>> tf.random.set_seed(1)
>>> print(sample(model, starting_str='The island'))
The island is probable that the view of the vegetable discharge on unexplainst felt, a thore, did not
refrain it existing to the greatest
possing bain and production, for a hundred streamled
established some branches of the
holizontal direction. It was there is all ready, from one things from
contention of the Pacific
acid, and
according to an occurry so
summ on the rooms. When numbered the prud Spilett received an exceppering from their head, and by went inhabited.
"What are the most abundance a report 

正如你所见,模型生成的大部分单词是正确的,并且在某些情况下,句子部分有意义。您可以进一步调整训练参数,例如用于训练的输入序列的长度,模型架构以及抽样参数(例如 max_input_length)。

此外,为了控制生成样本的可预测性(即生成遵循训练文本中学习到的模式的文本,还是增加更多的随机性),RNN 模型计算出的 logits 可以在传递给 tf.random.categorical() 进行采样之前进行缩放。缩放因子,,可以解释为物理学中的温度的倒数。较高的温度会导致更多的随机性,而较低的温度则会产生更可预测的行为。通过使用 缩放 logits,softmax 函数计算出的概率变得更加均匀,如以下代码所示:

>>> logits = np.array([[1.0, 1.0, 3.0]])
>>> print('Probabilities before scaling:        ',
...       tf.math.softmax(logits).numpy()[0])
>>> print('Probabilities after scaling with 0.5:',
...       tf.math.softmax(0.5*logits).numpy()[0])
>>> print('Probabilities after scaling with 0.1:',
...       tf.math.softmax(0.1*logits).numpy()[0])
Probabilities before scaling:         [0.10650698 0.10650698 0.78698604]
Probabilities after scaling with 0.5: [0.21194156 0.21194156 0.57611688]
Probabilities after scaling with 0.1: [0.31042377 0.31042377 0.37915245] 

如你所见,通过 缩放 logits 会产生接近均匀的概率 [0.31, 0.31, 0.38]。现在,我们可以将生成的文本与 进行比较,如下所示:

  • >>> tf.random.set_seed(1)
    >>> print(sample(model, starting_str='The island',
    ...              scale_factor=2.0))
    The island spoke of heavy torn into the island from the sea.
    The noise of the inhabitants of the island was to be feared that the colonists had come a project with a straight be put to the bank of the island was the surface of the lake and sulphuric acid, and several supply of her animals. The first stranger carried a sort of accessible to break these screen barrels to their distance from the palisade.
    "The first huntil," said the reporter, "and his companions the reporter extended to build a few days a 
    
  • >>> tf.random.set_seed(1)
    >>> print(sample(model, starting_str='The island',
    ...              scale_factor=0.5))
    The island
    glissed in
    ascercicedly useful? loigeh, Cyrus,
    Spileots," henseporvemented
    House to a left
    the centlic moment. Tonsense craw.
    Pencrular ed/ of times," tading had coflently often above anzand?"
    "Wat;" then:y."
    Ardivify he acpearly, howcovered--he hassime; however, fenquests hen adgents!'.? Let us Neg eqiAl?.
    GencNal, my surved thirtyin" ou; is Harding; treuths. Osew apartarned. "N,
    the poltuge of about-but durired with purteg.
    Chappes wason!
    Fears," returned Spilett; "if
    you tear 8t trung 
    

结果表明,通过 缩放 logits(提高温度)会生成更多随机的文本。在生成文本的新颖性和正确性之间存在权衡。

在这一节中,我们使用了字符级文本生成,这是一个序列到序列(seq2seq)建模任务。虽然这个例子本身可能并不特别有用,但很容易想到几种这种类型模型的实际应用;例如,可以训练一个类似的 RNN 模型作为聊天机器人,以帮助用户解答简单问题。

理解使用 Transformer 模型的语言

本章中,我们使用基于 RNN 的神经网络解决了两个序列建模问题。然而,最近出现了一种新的架构,它在多个自然语言处理任务中已经被证明优于基于 RNN 的 seq2seq 模型。

这种架构被称为 Transformer,它能够建模输入和输出序列之间的全局依赖关系,并且在2017年由 Ashish Vaswani 等人在 NeurIPS 论文 Attention Is All You Need 中提出(该论文可以在线查阅:http://papers.nips.cc/paper/7181-attention-is-all-you-need)。Transformer 架构基于一种名为 注意力 的概念,具体来说,是 自注意力机制。我们来回顾一下本章早些时候讨论的情感分析任务。在这种情况下,使用注意力机制意味着我们的模型能够学会集中注意输入序列中与情感相关性更强的部分。

理解自注意力机制

本节将解释自注意力机制以及它如何帮助 Transformer 模型在自然语言处理(NLP)中聚焦于序列中的重要部分。第一小节将介绍一种非常基础的自注意力形式,以阐明学习文本表示的整体思路。接下来,我们将加入不同的权重参数,从而得到 Transformer 模型中常用的自注意力机制。

自注意力的基础版本

为了介绍自注意力背后的基本概念,假设我们有一个长度为 T 的输入序列,,以及一个输出序列,。这些序列的每个元素,,是大小为 d 的向量(即,)。然后,对于 seq2seq 任务,自注意力的目标是对输出序列中每个元素与输入元素之间的依赖关系建模。为了实现这一点,注意力机制由三个阶段组成。首先,我们基于当前元素与序列中所有其他元素之间的相似性来推导重要性权重。其次,我们对这些权重进行归一化,这通常涉及使用已熟悉的 softmax 函数。第三,我们将这些权重与相应的序列元素结合使用,以计算注意力值。

更正式地,自注意力的输出是所有输入序列的加权和。例如,对于第 i 个输入元素,计算其对应的输出值如下:

在这里,权重,,是基于当前输入元素,,与输入序列中所有其他元素之间的相似性来计算的。更具体地,这种相似性是通过当前输入元素,,与输入序列中的另一个元素,,的点积来计算的:

在为第 i 个输入以及序列中的所有输入计算这些基于相似性的权重之后(),这些“原始”权重()将使用熟悉的 softmax 函数进行归一化,如下所示:

注意,由于应用了 softmax 函数,权重在此归一化后将总和为 1,即,

总结一下,让我们概括自注意力操作背后的三个主要步骤:

  1. 对于给定的输入元素,,以及区间 [0,T] 中的每个第 j 个元素,计算点积,

  2. 通过使用 softmax 函数归一化点积来获得权重,

  3. 计算输出,,作为对整个输入序列的加权和:

以下图进一步说明这些步骤:

用查询、键和值权重参数化自注意力机制

现在,您已经了解了自注意力机制的基本概念,本小节总结了在 Transformer 模型中使用的更高级的自注意力机制。请注意,在上一小节中,我们在计算输出时没有涉及任何可学习的参数。因此,如果我们想要训练一个语言模型,并希望通过改变注意力值来优化目标,例如最小化分类错误,我们就需要更改每个输入元素的词嵌入(即输入向量)!。换句话说,使用前面介绍的基本自注意力机制,Transformer 模型在优化给定序列时,更新或更改注意力值的能力相当有限。为了使自注意力机制更加灵活,并便于模型优化,我们将引入三个额外的权重矩阵,这些矩阵可以在模型训练期间作为模型参数进行拟合。我们将这三个权重矩阵表示为!,!,和!。它们用于将输入投影到查询序列元素:

  • 查询序列: 对应

  • 关键序列: 对应

  • 值序列: 对应

这里,! 和!都是大小为!的向量。因此,投影矩阵! 和!的形状为!,而!的形状为!。为了简化,我们可以设计这些向量具有相同的形状,例如使用!。现在,我们可以计算查询和键之间的点积,而不是计算给定输入序列元素!第j个序列元素!之间的未归一化权重的成对点积:

然后,我们可以进一步使用 m,或更准确地说,!,来缩放 ,然后通过 softmax 函数对其进行归一化,如下所示:

请注意,通过 缩放 ,可以确保权重向量的欧几里得长度大致处于相同范围内。

多头注意力和 Transformer 块

另一个大大提升自注意力机制判别能力的技巧是多头注意力MHA),它将多个自注意力操作组合在一起。在这种情况下,每个自注意力机制被称为,可以并行计算。使用r个并行头,每个头都会产生一个大小为m的向量h。这些向量随后被串联起来,得到一个形状为的向量z。最后,使用输出矩阵对串联向量进行投影,得到最终输出,如下所示:

Transformer块的架构如下图所示:

请注意,在前面图示的Transformer架构中,我们添加了两个尚未讨论的额外组件。其中一个组件是残差连接,它将层(或多个层)的输出添加到其输入中,也就是说,x + layer(x)。由一个层(或多个层)与这种残差连接组成的模块称为残差块。前面图示中的Transformer模块包含两个残差块。

另一个新的组件是层归一化,在前面图中表示为“Layer norm”。归一化层有一个家族,包括批量归一化,我们将在第17章《用于生成新数据的生成对抗网络》中介绍。现在,你可以将层归一化看作是规范化或缩放每层神经网络输入和激活的一种更花哨或更先进的方法。

回到前面图示的Transformer模型,现在我们来讨论这个模型是如何工作的。首先,输入序列传递到MHA层,该层基于我们之前讨论的自注意力机制。此外,输入序列通过残差连接添加到MHA层的输出中——这确保了在训练过程中早期的层将接收到足够的梯度信号,这是提高训练速度和收敛性的一种常见技巧。如果你感兴趣,你可以阅读Deep Residual Learning for Image Recognition(深度残差学习用于图像识别)一文,作者为何凯明、张祥宇、任少卿和孙剑,该文章可以在http://openaccess.thecvf.com/content_cvpr_2016/html/He_Deep_Residual_Learning_CVPR_2016_paper.html免费获取。

在将输入序列添加到MHA层的输出后,通过层归一化对输出进行归一化。然后,这些归一化信号通过一系列MLP(即全连接)层,这些层还具有残差连接。最后,残差块的输出再次进行归一化,并作为输出序列返回,可用于序列分类或序列生成。

为了节省空间,省略了Transformer模型的实现和训练说明。然而,有兴趣的读者可以在官方TensorFlow文档中找到出色的实现和详细说明,链接如下:

https://www.tensorflow.org/tutorials/text/transformer

总结

在本章中,您首先了解了使序列与其他类型的数据(如结构化数据或图像)不同的特性。然后,我们介绍了用于序列建模的RNN基础知识。您了解了基本RNN模型的工作原理,并讨论了其在捕获序列数据中的长期依赖方面的局限性。接下来,我们介绍了LSTM单元,它包括门控机制以减少基本RNN模型中常见的梯度爆炸和消失问题的影响。

讨论了RNN背后的主要概念后,我们使用Keras API实现了几个具有不同递归层的RNN模型。特别是,我们实现了一个用于情感分析的RNN模型,以及一个用于生成文本的RNN模型。最后,我们介绍了Transformer模型,它利用自注意力机制来集中关注序列中的相关部分。

在下一章中,您将学习生成模型,特别是在计算机视觉社区中展示了显著结果的生成对抗网络GANs)的相关视觉任务。

第十七章:用于合成新数据的生成对抗网络

在上一章,我们重点介绍了用于建模序列的循环神经网络。在本章中,我们将探索生成对抗网络GANs)并了解其在合成新数据样本中的应用。GANs被认为是深度学习中的最重要突破,它允许计算机生成新数据(例如新图像)。

本章将涵盖以下主题:

  • 引入用于合成新数据的生成模型

  • 自编码器、变分自编码器VAEs)及其与GAN的关系

  • 了解GAN的构建模块

  • 实现一个简单的GAN模型来生成手写数字

  • 了解转置卷积和批归一化BatchNormBN

  • 改进GAN:深度卷积GAN和使用Wasserstein距离的GAN

引入生成对抗网络

让我们首先来看一下GAN模型的基础。GAN的总体目标是合成具有与训练数据集相同分布的新数据。因此,GAN在其原始形式下被认为是无监督学习类别中的机器学习任务,因为不需要标注数据。然而,值得注意的是,对原始GAN的扩展可以同时应用于半监督和监督任务。

2014年,Ian Goodfellow及其同事首次提出了通用的GAN概念,作为一种使用深度神经网络(NNs)合成新图像的方法(Goodfellow, I., Pouget-Abadie, J., Mirza, M., Xu, B., Warde-Farley, D., Ozair, S., Courville, A.和Bengio, Y.,《生成对抗网络》,发表于《神经信息处理系统进展》,第2672-2680页,2014年)。虽然这篇论文中提出的最初GAN架构是基于完全连接的层,类似于多层感知机架构,并且训练生成低分辨率的MNIST风格手写数字,但它更多的是作为一个概念验证,展示了这种新方法的可行性。

然而,自从它的提出以来,原作者以及许多其他研究人员提出了众多改进和不同领域的应用。例如,在计算机视觉中,GANs被用于图像到图像的转换(学习如何将输入图像映射到输出图像)、图像超分辨率(从低分辨率版本生成高分辨率图像)、图像修复(学习如何重建图像中缺失的部分)等多个应用。比如,最近GAN研究的进展已经导致了能够生成新高分辨率人脸图像的模型。此类高分辨率图像的示例可以在https://www.thispersondoesnotexist.com/上找到,该网站展示了由GAN生成的合成面部图像。

从自编码器开始

在我们讨论GAN如何工作之前,我们首先从自动编码器开始,自动编码器可以压缩和解压训练数据。虽然标准的自动编码器无法生成新数据,但理解它们的功能将帮助你在下一节中理解GAN。

自动编码器由两个网络连接在一起组成:编码器网络和解码器网络。编码器网络接收一个与示例 x 相关的 d 维输入特征向量(即,),并将其编码成一个 p 维向量 z(即,)。换句话说,编码器的作用是学习如何建模函数 。编码后的向量 z 也叫做潜在向量,或潜在特征表示。通常,潜在向量的维度小于输入示例的维度;换句话说,p < d。因此,我们可以说编码器充当了数据压缩函数的角色。然后,解码器从低维的潜在向量 z 解压出 ,我们可以把解码器看作是一个函数,。下图展示了一个简单的自动编码器架构,其中编码器和解码器部分各自只包含一个全连接层:

自动编码器与降维之间的关系

第5章通过降维压缩数据,你学习了降维技术,如主成分分析(PCA)和线性判别分析(LDA)。自动编码器也可以作为一种降维技术。事实上,当两个子网络(编码器和解码器)中都没有非线性时,自动编码器方法几乎与PCA相同

在这种情况下,如果我们假设单层编码器(没有隐藏层和非线性激活函数)的权重用矩阵 U 表示,那么编码器建模 。类似地,单层线性解码器建模 。将这两个组件结合起来,我们得到了 。这正是PCA所做的,唯一的区别是PCA有一个附加的正交归一约束:

虽然前面的图展示了一个没有隐藏层的编码器和解码器的自动编码器,但我们当然可以添加多个带有非线性的隐藏层(如多层神经网络)来构建一个深度自动编码器,它能够学习更有效的数据压缩和重建功能。此外,注意到这一节中提到的自动编码器使用的是全连接层。当我们处理图像时,我们可以用卷积层替代全连接层,正如你在第15章使用深度卷积神经网络分类图像中学到的那样。

基于潜在空间大小的其他类型的自动编码器

如前所述,自编码器的潜在空间维度通常低于输入的维度(p < d),这使得自编码器适用于降维。因此,潜在向量通常也被称为“瓶颈”,这种特定配置的自编码器被称为欠完备。然而,还有一种不同类型的自编码器,称为过完备,在这种情况下,潜在向量的维度,z,实际上大于输入示例的维度(p > d)。

在训练过完备自编码器时,会出现一个简单的解决方案,其中编码器和解码器可以仅通过学习复制(记忆)输入特征到其输出层。显然,这个解决方案并不是很有用。然而,通过对训练过程进行一些修改,过完备自编码器可以用于降噪

在这种情况下,在训练过程中,会向输入示例中添加随机噪声,,网络学习从噪声信号中重建干净的示例,x。然后,在评估时,我们提供自然带有噪声的新示例(即,噪声已经存在,因此不再添加额外的人工噪声,),以从这些示例中去除已有的噪声。这种特定的自编码器架构和训练方法被称为去噪自编码器

如果您感兴趣,可以通过Vincent等人的研究文章《堆叠去噪自编码器:通过局部去噪标准在深度网络中学习有用的表示》进一步了解,该文章可以免费访问:http://www.jmlr.org/papers/v11/vincent10a.html

用于合成新数据的生成模型

自编码器是确定性模型,这意味着在自编码器训练完成后,给定输入,x,它将能够从其在低维空间中的压缩版本中重建输入。因此,它无法生成超出通过压缩表示的转换重建其输入的新数据。

另一方面,生成模型可以从一个随机向量,z(对应于潜在表示),生成一个新的示例,。生成模型的示意图如下所示。随机向量,z,来自一个简单的分布,具有完全已知的特征,因此我们可以轻松地从该分布中采样。例如,z 的每个元素可以来自范围[–1, 1]内的均匀分布(我们可以写作),或者来自标准正态分布(在这种情况下,我们写作)。

当我们将注意力从自编码器转移到生成模型时,你可能已经注意到自编码器的解码器组件与生成模型有些相似。特别是,它们都接收潜在向量z作为输入,并返回与x相同空间的输出。(对于自编码器,是输入x的重构,对于生成模型,是合成的样本。)

然而,两者之间的主要区别在于我们不知道自编码器中z的分布,而在生成模型中,z的分布是完全可以表征的。不过,将自编码器推广为生成模型是可能的。一种方法是变分自编码器(VAEs)

在VAE中,接收到输入示例x时,编码器网络被修改成这样一种形式,使其计算潜在向量的两个时刻:均值,,和方差,。在VAE的训练过程中,网络被迫使这些时刻与标准正态分布的时刻匹配(即均值为零,方差为单位)。然后,在VAE模型训练完成后,编码器被丢弃,我们可以使用解码器网络通过输入来自“学习”高斯分布的随机z向量来生成新的示例,

除了变分自编码器(VAEs),还有其他类型的生成模型,例如自回归模型正则化流模型。然而,在本章中,我们只会专注于GAN模型,后者是深度学习中最现代且最流行的生成模型之一。

什么是生成模型?

请注意,生成模型通常定义为模拟数据输入分布的算法,p(x),或者输入数据与相关目标的联合分布,p(x, y)。按照定义,这些模型也能从某些特征中采样,,并且条件于另一个特征,,这称为条件推理。然而,在深度学习的背景下,生成模型这个术语通常指的是生成逼真数据的模型。这意味着我们可以从输入分布p(x)中采样,但不一定能够进行条件推理。

使用GAN生成新样本

简而言之,为了理解GAN的作用,我们首先假设有一个网络,它接收一个从已知分布中采样的随机向量z并生成输出图像x。我们将这个网络称为生成器G),并使用符号表示生成的输出。假设我们的目标是生成一些图像,例如人脸图像、建筑物图像、动物图像,甚至是像MNIST这样的手写数字。

像往常一样,我们将用随机权重初始化这个网络。因此,在这些权重调整之前,第一次输出的图像看起来像白噪声。现在,假设存在一个可以评估图像质量的函数(我们称之为 评估函数)。

如果存在这样的函数,我们可以利用该函数的反馈来告诉生成器网络如何调整其权重,以提高生成图像的质量。通过这种方式,我们可以基于评估函数的反馈训练生成器,使其学习改善输出,朝着生成真实感图像的方向努力。

尽管前述的评估函数会使图像生成任务变得非常简单,但问题在于是否存在这样一个通用函数来评估图像质量,如果存在,它又是如何定义的。显然,作为人类,我们可以轻松地评估当我们观察网络输出时图像的质量;尽管我们目前还不能(暂时)将这一结果从大脑反向传播到网络。那么,如果我们的脑袋能够评估合成图像的质量,我们能否设计一个神经网络模型来做同样的事情?实际上,这正是 GAN 的基本思想。如以下图所示,GAN 模型包含一个额外的神经网络,称为 判别器 (D),它是一个分类器,学习区分合成图像 和真实图像 x

在 GAN 模型中,生成器和判别器两个网络是一起训练的。最初,在初始化模型权重后,生成器创建的图像看起来不真实。类似地,判别器也难以区分真实图像和生成器合成的图像。但随着时间的推移(即通过训练),两个网络会随着相互作用变得越来越好。实际上,这两个网络玩的是一种对抗性游戏,生成器学习提高其输出,以便能够欺骗判别器。同时,判别器变得更擅长检测合成图像。

理解 GAN 模型中生成器和判别器网络的损失函数

GANs 的目标函数,如 Goodfellow 等人在原始论文 Generative Adversarial Nets 中所描述的 (https://papers.nips.cc/paper/5423-generative-adversarial-nets.pdf),如下所示:

这里,!被称为价值函数,它可以解释为一种收益:我们希望相对于判别器(D)最大化其值,同时相对于生成器(G)最小化其值,即!Dx)是一个概率,表示输入样本x是否为真实的(即,是否为生成的)。表达式!指的是相对于数据分布(真实样本的分布)的期望值;!指的是相对于输入z向量分布的期望值。

一个包含这种价值函数的GAN模型训练步骤需要两次优化: (1) 最大化判别器的收益,(2) 最小化生成器的收益。训练GAN的一个实用方法是交替进行这两次优化步骤: (1) 固定(冻结)一个网络的参数,优化另一个网络的权重,(2) 固定第二个网络并优化第一个网络。这个过程应在每次训练迭代时重复。假设生成器网络被固定,我们想要优化判别器。价值函数中的两项!都会对优化判别器产生贡献,其中第一项对应于真实样本的损失,第二项则是假样本的损失。因此,当G被固定时,我们的目标是最大化,即使判别器更好地区分真实图像和生成图像。

在使用真实样本和假样本的损失项优化判别器后,我们接着固定判别器并优化生成器。在这种情况下,只有公式中第二项!会对生成器的梯度产生贡献。因此,当D被固定时,我们的目标是最小化,其表达式可以写为!。正如Goodfellow等人在原始GAN论文中提到的,这个函数!在训练初期会出现梯度消失问题。其原因在于,在学习过程的初期,Gz)的输出与真实样本差异很大,因此DGz))的值将以很高的置信度接近零。这个现象称为饱和。为了解决这个问题,我们可以通过将最小化目标!重新写为!来重新构造。

这一替换意味着在训练生成器时,我们可以交换真实和伪造样本的标签,并执行常规的函数最小化。换句话说,尽管生成器合成的示例是假的,因此标记为0,但我们可以通过将标签设置为1来翻转标签,并最小化这些新标签下的二元交叉熵损失,而不是最大化!

现在我们已经介绍了训练GAN模型的常规优化过程,接下来我们来探讨在训练GAN时可以使用的各种数据标签。由于判别器是一个二元分类器(类标签分别为0和1,表示伪造和真实图像),因此我们可以使用二元交叉熵损失函数。因此,我们可以按如下方式确定判别器损失的地面真相标签:

那么训练生成器的标签应该是什么呢?因为我们希望生成器合成真实的图像,所以当生成器的输出没有被判别器分类为真实时,我们希望对生成器进行惩罚。这意味着我们在计算生成器的损失函数时,将假定生成器输出的地面真相标签为1。

将所有内容汇总,以下图展示了一个简单GAN模型中的各个步骤:

在接下来的章节中,我们将从零开始实现一个GAN,生成新的手写数字。

从零开始实现GAN

在本节中,我们将介绍如何实现和训练一个GAN模型来生成新的图像,比如MNIST数字。由于在普通中央处理单元(CPU)上训练可能需要很长时间,在接下来的子节中,我们将介绍如何设置Google Colab环境,这样我们就可以在图形处理单元(GPU)上运行计算。

在Google Colab上训练GAN模型

本章中的一些代码示例可能需要大量计算资源,这些资源超出了普通笔记本电脑或没有GPU的工作站的能力。如果您已经有一台配备NVIDIA GPU的计算机,并且已安装CUDA和cuDNN库,那么您可以使用它来加速计算。

然而,由于我们很多人没有高性能的计算资源,我们将使用Google Colaboratory环境(通常称为Google Colab),它是一个免费的云计算服务(在大多数国家/地区都可用)。

Google Colab提供了基于云运行的Jupyter Notebook实例;这些笔记本可以保存在Google Drive或GitHub上。尽管该平台提供了多种不同的计算资源,如CPU、GPU甚至张量处理单元(TPU),但需要强调的是,执行时间目前限制为12小时。因此,任何运行超过12小时的笔记本将会被中断。

本章中的代码块最大需要的计算时间为两到三小时,因此这不会成为问题。然而,如果你决定在其他项目中使用 Google Colab 并且这些项目的运行时间超过 12 小时,请务必使用检查点并保存中间检查点。

Jupyter Notebook

Jupyter Notebook 是一个图形用户界面(GUI),用于交互式运行代码,并将代码与文本文档和图形交织在一起。由于其多功能性和易用性,它已成为数据科学中最受欢迎的工具之一。

如需了解有关 Jupyter Notebook 图形用户界面的更多信息,请查看官方文档:https://jupyter-notebook.readthedocs.io/en/stable/。本书中的所有代码也以 Jupyter 笔记本的形式提供,简短的介绍可以在第一章的代码目录中找到:https://github.com/rasbt/python-machine-learning-book-3rd-edition/tree/master/ch01#pythonjupyter-notebook

最后,我们强烈推荐 Adam Rule 等人撰写的文章 Ten simple rules for writing and sharing computational analyses in Jupyter Notebooks,该文章介绍了如何在科学研究项目中有效使用 Jupyter Notebook,文章可以在https://journals.plos.org/ploscompbiol/article?id=10.1371/journal.pcbi.1007007免费下载。

访问 Google Colab 非常简单。你可以访问 https://colab.research.google.com,该网址会自动带你进入一个提示窗口,在这里你可以看到现有的 Jupyter 笔记本。在这个提示窗口中,点击GOOGLE DRIVE标签,如下图所示。这是你将保存笔记本的地方。

然后,点击提示窗口底部的链接NEW PYTHON 3 NOTEBOOK以创建一个新的笔记本:

这将为你创建并打开一个新的笔记本。你在此笔记本中编写的所有代码示例将自动保存,你以后可以通过 Google Drive 中名为 Colab Notebooks 的目录访问该笔记本。

在下一步中,我们将利用 GPU 运行本笔记本中的代码示例。为此,在本笔记本的菜单栏中,从运行时选项中点击更改运行时类型,并选择GPU,如图所示:

在最后一步,我们只需要安装本章所需的 Python 包。Colab Notebooks 环境已经预装了某些包,如 NumPy、SciPy 和最新的稳定版本的 TensorFlow。然而,在撰写本文时,Google Colab 上的最新稳定版本是 TensorFlow 1.15.0,但我们希望使用 TensorFlow 2.0。因此,首先,我们需要通过在 notebook 的新单元格中执行以下命令来安装带 GPU 支持的 TensorFlow 2.0:

! pip install -q tensorflow-gpu==2.0.0 

(在 Jupyter Notebook 中,以感叹号开头的单元格将被解释为 Linux shell 命令。)

现在,我们可以通过以下代码来测试安装并验证 GPU 是否可用:

>>> import tensorflow as tf
>>> print(tf.__version__)
'2.0.0'
>>> print("GPU Available:", tf.test.is_gpu_available())
GPU Available: True
>>> if tf.test.is_gpu_available():
...     device_name = tf.test.gpu_device_name()
... else:
...     device_name = '/CPU:0'
>>> print(device_name)
'/device:GPU:0' 

此外,如果你想将模型保存到个人的 Google Drive,或者传输或上传其他文件,你需要挂载 Google Drive。为此,请在 notebook 中的新单元格中执行以下操作:

>>> from google.colab import drive
>>> drive.mount('/content/drive/') 

这将提供一个链接,用于验证 Colab Notebook 访问你的 Google Drive。在按照验证步骤操作后,它会提供一个认证代码,你需要将其复制并粘贴到刚才执行的单元格下方的指定输入框中。然后,你的 Google Drive 将被挂载,并可以在 /content/drive/My Drive 位置访问。

实现生成器和判别器网络

我们将通过实现一个生成器和判别器的第一版 GAN 模型开始,其中生成器和判别器是两个完全连接的网络,包含一个或多个隐藏层(见下图)。

这是原始的 GAN 版本,我们将其称为 原生 GAN

在这个模型中,对于每个隐藏层,我们将应用带泄漏的 ReLU 激活函数。ReLU 的使用会导致稀疏梯度,这在我们希望对所有输入值范围的梯度进行计算时可能不太合适。在判别器网络中,每个隐藏层后面还会接一个 dropout 层。此外,生成器中的输出层使用双曲正切(tanh)激活函数。(推荐在生成器网络中使用 tanh 激活函数,因为它有助于学习过程。)

判别器中的输出层没有激活函数(即线性激活)来获取 logits。或者,我们可以使用 sigmoid 激活函数来获得概率作为输出:

带泄漏的修正线性单元(ReLU)激活函数

第 13 章使用 TensorFlow 并行化神经网络训练,我们讨论了在神经网络模型中可以使用的不同非线性激活函数。如果你还记得,ReLU 激活函数定义为 ,它会抑制负的(预激活)输入;也就是说,负输入会被设为零。因此,使用 ReLU 激活函数可能会导致反向传播时梯度稀疏。稀疏梯度并不总是有害的,甚至可以对分类模型有益。然而,在某些应用中,例如 GANs,获取完整输入值范围的梯度是有益的,我们可以通过对 ReLU 函数做小幅修改来实现这一点,使其对负输入也输出小的值。这个修改版本的 ReLU 函数也被称为泄漏 ReLU。简而言之,泄漏 ReLU 激活函数允许负输入也产生非零梯度,因此,它使网络整体上更具表现力。

泄漏 ReLU 激活函数定义如下:

在这里, 确定了负(预激活)输入的斜率。

我们将为每个网络定义两个辅助函数,从 Keras Sequential 类实例化一个模型,并按描述添加各层。代码如下:

>>> import tensorflow as tf
>>> import tensorflow_datasets as tfds
>>> import numpy as np
>>> import matplotlib.pyplot as plt
>>> ## define a function for the generator:
>>> def make_generator_network(
...         num_hidden_layers=1,
...         num_hidden_units=100,
...         num_output_units=784):
...
...     model = tf.keras.Sequential()
...     for i in range(num_hidden_layers):
...         model.add(
...             tf.keras.layers.Dense(
...                 units=num_hidden_units, use_bias=False))
...         model.add(tf.keras.layers.LeakyReLU())
...         
...     model.add(
...         tf.keras.layers.Dense(
...             units=num_output_units, activation='tanh'))
...     return model
>>> ## define a function for the discriminator:
>>> def make_discriminator_network(
...         num_hidden_layers=1,
...         num_hidden_units=100,
...         num_output_units=1):
...
...     model = tf.keras.Sequential()
...     for i in range(num_hidden_layers):
...         model.add(
...             tf.keras.layers.Dense(units=num_hidden_units))
...         model.add(tf.keras.layers.LeakyReLU())
...         model.add(tf.keras.layers.Dropout(rate=0.5))
...         
...     model.add(
...         tf.keras.layers.Dense(
...             units=num_output_units, activation=None))
...     return model 

接下来,我们将为模型指定训练设置。如你所记得,MNIST 数据集中的图像大小是 像素。(因为 MNIST 只包含灰度图像,所以只有一个颜色通道。)我们还将进一步指定输入向量 z 的大小为 20,并使用随机均匀分布来初始化模型权重。由于我们仅为说明目的实现了一个非常简单的 GAN 模型,并且使用的是全连接层,所以我们将在每个网络中只使用一个包含 100 个单元的隐藏层。在下面的代码中,我们将指定并初始化这两个网络,并打印它们的摘要信息:

>>> image_size = (28, 28)
>>> z_size = 20
>>> mode_z = 'uniform' # 'uniform' vs. 'normal'
>>> gen_hidden_layers = 1
>>> gen_hidden_size = 100
>>> disc_hidden_layers = 1
>>> disc_hidden_size = 100
>>> tf.random.set_seed(1)
>>> gen_model = make_generator_network(
...     num_hidden_layers=gen_hidden_layers,
...     num_hidden_units=gen_hidden_size,
...     num_output_units=np.prod(image_size))
>>> gen_model.build(input_shape=(None, z_size))
>>> gen_model.summary()
Model: "sequential"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
dense (Dense)                multiple                  2000      
_________________________________________________________________
leaky_re_lu (LeakyReLU)      multiple                  0         
_________________________________________________________________
dense_1 (Dense)              multiple                  79184     
=================================================================
Total params: 81,184
Trainable params: 81,184
Non-trainable params: 0
_________________________________________________________________
>>> disc_model = make_discriminator_network(
...     num_hidden_layers=disc_hidden_layers,
...     num_hidden_units=disc_hidden_size)
>>> disc_model.build(input_shape=(None, np.prod(image_size)))
>>> disc_model.summary()
Model: "sequential_1"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
dense_2 (Dense)              multiple                  78500     
_________________________________________________________________
leaky_re_lu_1 (LeakyReLU)    multiple                  0         
_________________________________________________________________
dropout (Dropout)            multiple                  0         
_________________________________________________________________
dense_3 (Dense)              multiple                  101       
=================================================================
Total params: 78,601
Trainable params: 78,601
Non-trainable params: 0
_________________________________________________________________ 

定义训练数据集

在下一步中,我们将加载 MNIST 数据集并应用必要的预处理步骤。由于生成器的输出层使用了 tanh 激活函数,合成图像的像素值将在(–1, 1)范围内。然而,MNIST 图像的输入像素值范围是 [0, 255](使用 TensorFlow 数据类型 tf.uint8)。因此,在预处理步骤中,我们将使用 tf.image.convert_image_dtype 函数将输入图像张量的 dtypetf.uint8 转换为 tf.float32。结果,除了改变 dtype,调用此函数还会将输入像素的强度范围更改为 [0, 1]。然后,我们可以通过一个因子 2 来缩放它们,并将其偏移 –1,使得像素强度重新缩放到 [–1, 1] 范围内。此外,我们还将根据所需的随机分布(在这个代码示例中是均匀分布或正态分布,它们是最常见的选择)创建一个随机向量 z,并返回预处理后的图像和随机向量作为一个元组:

>>> mnist_bldr = tfds.builder('mnist')
>>> mnist_bldr.download_and_prepare()
>>> mnist = mnist_bldr.as_dataset(shuffle_files=False)
>>> def preprocess(ex, mode='uniform'):
...     image = ex['image']
...     image = tf.image.convert_image_dtype(image, tf.float32)
...     image = tf.reshape(image, [-1])
...     image = image*2 - 1.0
...     if mode == 'uniform':
...         input_z = tf.random.uniform(
...             shape=(z_size,), minval=-1.0, maxval=1.0)
...     elif mode == 'normal':
...         input_z = tf.random.normal(shape=(z_size,))
...     return input_z, image
>>> mnist_trainset = mnist['train']
>>> mnist_trainset = mnist_trainset.map(preprocess) 

请注意,这里我们返回了输入向量 z 和图像,以便在模型训练过程中方便地获取训练数据。然而,这并不意味着向量 z 与图像有任何关系——输入图像来自数据集,而向量 z 是随机生成的。在每次训练迭代中,随机生成的向量 z 代表了生成器接收到的输入,用于合成新图像,而图像(无论是真实图像还是合成图像)则是判别器的输入。

让我们检查一下我们创建的数据集对象。在以下代码中,我们将取一批样本,并打印这一批输入向量和图像的数组形状。此外,为了理解 GAN 模型的整体数据流,在以下代码中,我们将处理一次生成器和判别器的前向传播。

首先,我们将输入 z 向量批次喂入生成器,得到它的输出 g_output。这将是一批假样本,将被输入到判别器模型中,以获取这批假样本的 logits,即 d_logits_fake。此外,我们从数据集对象中获取的处理后的图像将被输入到判别器模型中,从而得到真实样本的 logits,即 d_logits_real。代码如下:

>>> mnist_trainset = mnist_trainset.batch(32, drop_remainder=True)
>>> input_z, input_real = next(iter(mnist_trainset))
>>> print('input-z -- shape:   ', input_z.shape)
>>> print('input-real -- shape:', input_real.shape)
input-z -- shape:    (32, 20)
input-real -- shape: (32, 784)
>>> g_output = gen_model(input_z)
>>> print('Output of G -- shape:', g_output.shape)
Output of G -- shape: (32, 784)
>>> d_logits_real = disc_model(input_real)
>>> d_logits_fake = disc_model(g_output)
>>> print('Disc. (real) -- shape:', d_logits_real.shape)
>>> print('Disc. (fake) -- shape:', d_logits_fake.shape)
Disc. (real) -- shape: (32, 1)
Disc. (fake) -- shape: (32, 1) 

两个 logits,d_logits_faked_logits_real,将用于计算模型训练的损失函数。

训练 GAN 模型

下一步,我们将创建一个BinaryCrossentropy的实例作为我们的损失函数,并用它来计算刚刚处理的批次中生成器和鉴别器的损失。为此,我们还需要每个输出的地面真实标签。对于生成器,我们将创建一个与包含生成图像预测logits的向量d_logits_fake形状相同的1向量。对于鉴别器损失,我们有两个术语:涉及d_logits_fake检测伪例的损失和基于d_logits_real检测真实例的损失。

伪造术语的真实标签将是一个由tf.zeros()(或tf.zeros_like())函数生成的零向量。类似地,我们可以通过tf.ones()(或tf.ones_like())函数为真实图像生成地面真实值,该函数创建一个由1组成的向量:

>>> loss_fn = tf.keras.losses.BinaryCrossentropy(from_logits=True)
>>> ## Loss for the Generator
>>> g_labels_real = tf.ones_like(d_logits_fake)
>>> g_loss = loss_fn(y_true=g_labels_real, y_pred=d_logits_fake)
>>> print('Generator Loss: {:.4f}'.format(g_loss))
Generator Loss: 0.7505
>>> ## Loss for the Discriminator
>>> d_labels_real = tf.ones_like(d_logits_real)
>>> d_labels_fake = tf.zeros_like(d_logits_fake)
>>> d_loss_real = loss_fn(y_true=d_labels_real,
...                       y_pred=d_logits_real)
>>> d_loss_fake = loss_fn(y_true=d_labels_fake,
...                       y_pred=d_logits_fake)
>>> print('Discriminator Losses: Real {:.4f} Fake {:.4f}'
...       .format(d_loss_real.numpy(), d_loss_fake.numpy()))
Discriminator Losses: Real 1.3683 Fake 0.6434 

前面的代码示例展示了逐步计算不同损失项的过程,以便理解训练GAN模型背后的整体概念。接下来的代码将设置GAN模型并实现训练循环,我们将在for循环中包含这些计算。

另外,我们将使用tf.GradientTape()来计算相对于模型权重的损失梯度,并使用两个单独的Adam优化器来优化生成器和鉴别器的参数。正如您将在以下代码中看到的那样,为了在TensorFlow中交替训练生成器和鉴别器,我们明确地提供了每个网络的参数,并将每个网络的梯度分别应用于各自指定的优化器:

>>> import time
>>> num_epochs = 100
>>> batch_size = 64
>>> image_size = (28, 28)
>>> z_size = 20
>>> mode_z = 'uniform'
>>> gen_hidden_layers = 1
>>> gen_hidden_size = 100
>>> disc_hidden_layers = 1
>>> disc_hidden_size = 100
>>> tf.random.set_seed(1)
>>> np.random.seed(1)
>>> if mode_z == 'uniform':
...     fixed_z = tf.random.uniform(
...         shape=(batch_size, z_size),
...         minval=-1, maxval=1)
>>> elif mode_z == 'normal':
...     fixed_z = tf.random.normal(
...         shape=(batch_size, z_size))
>>> def create_samples(g_model, input_z):
...     g_output = g_model(input_z, training=False)
...     images = tf.reshape(g_output, (batch_size, *image_size))
...     return (images+1)/2.0
>>> ## Set-up the dataset
>>> mnist_trainset = mnist['train']
>>> mnist_trainset = mnist_trainset.map(
...     lambda ex: preprocess(ex, mode=mode_z))
>>> mnist_trainset = mnist_trainset.shuffle(10000)
>>> mnist_trainset = mnist_trainset.batch(
...     batch_size, drop_remainder=True)
>>> ## Set-up the model
>>> with tf.device(device_name):
...     gen_model = make_generator_network(
...         num_hidden_layers=gen_hidden_layers,
...         num_hidden_units=gen_hidden_size,
...         num_output_units=np.prod(image_size))
...     gen_model.build(input_shape=(None, z_size))
...
...     disc_model = make_discriminator_network(
...         num_hidden_layers=disc_hidden_layers,
...         num_hidden_units=disc_hidden_size)
...     disc_model.build(input_shape=(None, np.prod(image_size)))
>>> ## Loss function and optimizers:
>>> loss_fn = tf.keras.losses.BinaryCrossentropy(from_logits=True)
>>> g_optimizer = tf.keras.optimizers.Adam()
>>> d_optimizer = tf.keras.optimizers.Adam()
>>> all_losses = []
>>> all_d_vals = []
>>> epoch_samples = []
>>> start_time = time.time()
>>> for epoch in range(1, num_epochs+1):
...
...     epoch_losses, epoch_d_vals = [], []
...
...     for i,(input_z,input_real) in enumerate(mnist_trainset):
...         
...         ## Compute generator's loss
...         with tf.GradientTape() as g_tape:
...             g_output = gen_model(input_z)
...             d_logits_fake = disc_model(g_output,
...                                        training=True)
...             labels_real = tf.ones_like(d_logits_fake)
...             g_loss = loss_fn(y_true=labels_real,
...                              y_pred=d_logits_fake)
...             
...         ## Compute the gradients of g_loss
...         g_grads = g_tape.gradient(g_loss,
...                       gen_model.trainable_variables)
...
...         ## Optimization: Apply the gradients
...         g_optimizer.apply_gradients(
...             grads_and_vars=zip(g_grads,
...             gen_model.trainable_variables))
...
...         ## Compute discriminator's loss
...         with tf.GradientTape() as d_tape:
...             d_logits_real = disc_model(input_real,
...                                        training=True)
...
...             d_labels_real = tf.ones_like(d_logits_real)
...             
...             d_loss_real = loss_fn(
...                 y_true=d_labels_real, y_pred=d_logits_real)
...
...             d_logits_fake = disc_model(g_output,
...                                        training=True)
...             d_labels_fake = tf.zeros_like(d_logits_fake)
...
...             d_loss_fake = loss_fn(
...                 y_true=d_labels_fake, y_pred=d_logits_fake)
...
...             d_loss = d_loss_real + d_loss_fake
...
...         ## Compute the gradients of d_loss
...         d_grads = d_tape.gradient(d_loss,
...                       disc_model.trainable_variables)
...         
...         ## Optimization: Apply the gradients
...         d_optimizer.apply_gradients(
...             grads_and_vars=zip(d_grads,
...             disc_model.trainable_variables))
...                            
...         epoch_losses.append(
...             (g_loss.numpy(), d_loss.numpy(),
...              d_loss_real.numpy(), d_loss_fake.numpy()))
...         
...         d_probs_real = tf.reduce_mean(
...                            tf.sigmoid(d_logits_real))
...         d_probs_fake = tf.reduce_mean(
...                            tf.sigmoid(d_logits_fake))
...         epoch_d_vals.append((d_probs_real.numpy(),
...                              d_probs_fake.numpy()))
...       
...     all_losses.append(epoch_losses)
...     all_d_vals.append(epoch_d_vals)
...     print(
...         'Epoch {:03d} | ET {:.2f} min | Avg Losses >>'
...         ' G/D {:.4f}/{:.4f} [D-Real: {:.4f} D-Fake: {:.4f}]'
...         .format(
...             epoch, (time.time() - start_time)/60,
...             *list(np.mean(all_losses[-1], axis=0))))
...     epoch_samples.append(
...         create_samples(gen_model, fixed_z).numpy())
Epoch 001 | ET 0.88 min | Avg Losses >> G/D 2.9594/0.2843 [D-Real: 0.0306 D-Fake: 0.2537]
Epoch 002 | ET 1.77 min | Avg Losses >> G/D 5.2096/0.3193 [D-Real: 0.1002 D-Fake: 0.2191]
Epoch ...
Epoch 100 | ET 88.25 min | Avg Losses >> G/D 0.8909/1.3262 [D-Real: 0.6655 D-Fake: 0.6607] 

使用GPU,在Google Colab上我们实现的训练过程应该在一个小时内完成。(如果您有一台最近和能力强大的CPU和GPU,您的个人电脑上可能会更快。)模型训练完成后,通常有助于绘制鉴别器和生成器损失,以分析两个子网络的行为并评估它们是否收敛。

还有助于绘制鉴别器在每次迭代中计算的真实和伪例子批次的平均概率。我们预计这些概率约为0.5,这意味着鉴别器不能确信地区分真实和伪造图像。

>>> import itertools
>>> fig = plt.figure(figsize=(16, 6))
>>> ## Plotting the losses
>>> ax = fig.add_subplot(1, 2, 1)
>>> g_losses = [item[0] for item in itertools.chain(*all_losses)]
>>> d_losses = [item[1]/2.0 for item in itertools.chain(
...             *all_losses)]
>>> plt.plot(g_losses, label='Generator loss', alpha=0.95)
>>> plt.plot(d_losses, label='Discriminator loss', alpha=0.95)
>>> plt.legend(fontsize=20)
>>> ax.set_xlabel('Iteration', size=15)
>>> ax.set_ylabel('Loss', size=15)
>>> epochs = np.arange(1, 101)
>>> epoch2iter = lambda e: e*len(all_losses[-1])
>>> epoch_ticks = [1, 20, 40, 60, 80, 100]
>>> newpos = [epoch2iter(e) for e in epoch_ticks]
>>> ax2 = ax.twiny()
>>> ax2.set_xticks(newpos)
>>> ax2.set_xticklabels(epoch_ticks)
>>> ax2.xaxis.set_ticks_position('bottom')
>>> ax2.xaxis.set_label_position('bottom')
>>> ax2.spines['bottom'].set_position(('outward', 60))
>>> ax2.set_xlabel('Epoch', size=15)
>>> ax2.set_xlim(ax.get_xlim())
>>> ax.tick_params(axis='both', which='major', labelsize=15)
>>> ax2.tick_params(axis='both', which='major', labelsize=15)
>>> ## Plotting the outputs of the discriminator
>>> ax = fig.add_subplot(1, 2, 2)
>>> d_vals_real = [item[0] for item in itertools.chain(
...                *all_d_vals)]
>>> d_vals_fake = [item[1] for item in itertools.chain(
...                *all_d_vals)]
>>> plt.plot(d_vals_real, alpha=0.75,
...          label=r'Real: $D(\mathbf{x})$')
>>> plt.plot(d_vals_fake, alpha=0.75,
...          label=r'Fake: $D(G(\mathbf{z}))$')
>>> plt.legend(fontsize=20)
>>> ax.set_xlabel('Iteration', size=15)
>>> ax.set_ylabel('Discriminator output', size=15)
>>> ax2 = ax.twiny()
>>> ax2.set_xticks(newpos)
>>> ax2.set_xticklabels(epoch_ticks)
>>> ax2.xaxis.set_ticks_position('bottom')
>>> ax2.xaxis.set_label_position('bottom')
>>> ax2.spines['bottom'].set_position(('outward', 60))
>>> ax2.set_xlabel('Epoch', size=15)
>>> ax2.set_xlim(ax.get_xlim())
>>> ax.tick_params(axis='both', which='major', labelsize=15)
>>> ax2.tick_params(axis='both', which='major', labelsize=15)
>>> plt.show() 

下图显示了结果:

注意,鉴别器模型输出logits,但为了这个可视化,我们已经存储了通过sigmoid函数计算的概率,在计算每批次的平均值之前。

如你从前面的判别器输出中看到的,在训练的早期阶段,判别器能够迅速并准确地区分真实和伪造样本,即伪造样本的概率接近0,真实样本的概率接近1。原因是伪造样本与真实样本差异很大,因此区分它们相对容易。随着训练的继续,生成器会越来越擅长合成逼真的图像,这将导致真实和伪造样本的概率都接近0.5。

此外,我们还可以看到生成器输出(即合成图像)在训练过程中如何变化。在每个周期之后,我们通过调用create_samples()函数生成一些样本,并将它们存储在Python列表中。在以下代码中,我们将可视化在不同周期生成器产生的部分图像:

>>> selected_epochs = [1, 2, 4, 10, 50, 100]
>>> fig = plt.figure(figsize=(10, 14))
>>> for i,e in enumerate(selected_epochs):
...     for j in range(5):
...         ax = fig.add_subplot(6, 5, i*5+j+1)
...         ax.set_xticks([])
...         ax.set_yticks([])
...         if j == 0:
...             ax.text(
...                 -0.06, 0.5, 'Epoch {}'.format(e),
...                 rotation=90, size=18, color='red',
...                 horizontalalignment='right',
...                 verticalalignment='center',
...                 transform=ax.transAxes)
...         
...         image = epoch_samples[e-1][j]
...         ax.imshow(image, cmap='gray_r')
...     
>>> plt.show() 

以下图展示了生成的图像:

如你从前面的图示中看到的,生成器网络随着训练的进行,生成的图像变得越来越真实。然而,即便训练了100个周期,生成的图像与MNIST数据集中包含的手写数字仍然有很大的不同。

在本节中,我们设计了一个非常简单的GAN模型,生成器和判别器都有一个单独的全连接隐藏层。经过在MNIST数据集上的训练,我们能够取得一些有希望的结果,虽然还未达到理想状态,生成了新的手写数字。如我们在第15章使用深度卷积神经网络进行图像分类》中学到的,卷积层的神经网络架构在图像分类方面,相比全连接层有许多优势。类似地,向我们的GAN模型中添加卷积层来处理图像数据,可能会改善结果。在下一节中,我们将实现一个深度卷积GANDCGAN),该模型为生成器和判别器网络都使用卷积层。

使用卷积和Wasserstein GAN提高合成图像的质量

在本节中,我们将实现一个DCGAN,这将使我们能够提高在前一个GAN示例中看到的性能。此外,我们还将采用一些额外的关键技术,并实现一个Wasserstein GANWGAN)。

本节中,我们将介绍以下技术:

  • 转置卷积

  • 批量归一化(BatchNorm)

  • Wasserstein GAN(WGAN)

  • 梯度惩罚

DCGAN于2016年由A. Radford、L. Metz和S. Chintala在其文章《无监督表示学习与深度卷积生成对抗网络》中提出,文章可以在https://arxiv.org/pdf/1511.06434.pdf免费获取。在这篇文章中,研究人员提出在生成器和判别器网络中都使用卷积层。从随机向量z开始,DCGAN首先使用全连接层将z投影到一个新的向量,使其具有适当的大小,以便可以将其重塑为空间卷积表示(),该表示比输出图像大小要小。然后,使用一系列卷积层,称为转置卷积,来将特征图上采样到所需的输出图像大小。

转置卷积

第15章使用深度卷积神经网络进行图像分类中,你学习了在一维和二维空间中的卷积操作。特别地,我们探讨了填充和步幅的选择如何改变输出特征图。虽然卷积操作通常用于对特征空间进行下采样(例如,通过将步幅设置为2,或在卷积层后添加池化层),但转置卷积操作通常用于上采样特征空间。

为了理解转置卷积操作,让我们通过一个简单的思想实验。假设我们有一个大小为的输入特征图。然后,我们对这个输入应用一个带有特定填充和步幅参数的二维卷积操作,得到一个大小为的输出特征图。现在,问题是,我们如何应用另一个卷积操作,从这个输出特征图中获得具有初始维度的特征图,同时保持输入和输出之间的连接模式?请注意,只有输入矩阵的形状被恢复,而不是实际的矩阵值。这正是转置卷积的作用,如下图所示:

转置卷积与反卷积

转置卷积也叫做分数步幅卷积。在深度学习文献中,另一个常用的术语是反卷积,用于指代转置卷积。然而,值得注意的是,反卷积最初被定义为卷积操作f的逆操作,它作用于特征图x,并与权重参数w结合,产生特征图。然后,反卷积函数可以定义为。然而,转置卷积仅关注恢复特征空间的维度,而非实际的数值。

使用转置卷积进行特征图上采样,通过在输入特征图的元素之间插入0来工作。下图显示了应用转置卷积于大小为 的输入的示例,步幅为 ,卷积核大小为 。中间的 大小矩阵显示了将0插入输入特征图后的结果。然后,使用步幅为1的 卷积核进行常规卷积,得到大小为 的输出。我们可以通过对输出进行步幅为2的常规卷积来验证反向方向,从而得到大小为 的输出特征图,这与原始输入大小相同:

上述插图展示了转置卷积的一般工作原理。在各种情况下,输入大小、卷积核大小、步幅和填充变化可能会改变输出。如果您想了解所有这些不同情况的更多信息,请参考 Vincent Dumoulin 和 Francesco Visin 撰写的教程 A Guide to Convolution Arithmetic for Deep Learning,该教程可以在 https://arxiv.org/pdf/1603.07285.pdf 上免费获得。

批量归一化

BatchNorm 是由 Sergey Ioffe 和 Christian Szegedy 于2015年在文章 Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift 中提出的,您可以通过 arXiv 在 https://arxiv.org/pdf/1502.03167.pdf 访问这篇文章。BatchNorm 背后的主要思想之一是对层的输入进行归一化,并防止训练过程中其分布的变化,这使得训练更快且收敛效果更好。

BatchNorm 根据计算出的统计数据转换一个小批次的特征。假设我们有一个四维张量 Z,它是在卷积层之后得到的净激活特征图,其形状为 ,其中 m 是批次中的样本数(即批次大小), 是特征图的空间维度,c 是通道数。BatchNorm 可以总结为三个步骤,如下所示:

  1. 计算每个小批次的净输入的均值和标准差:

    ,其中 都的大小为 c

  2. 标准化批次中所有样本的净输入:,其中 是一个小数值,用于数值稳定性(即避免除以零)。

  3. 使用两个可学习的参数向量 对归一化后的净输入进行缩放和平移,它们的大小为 c(通道数):

以下图示说明了这个过程:

在 BatchNorm 的第一步中,会计算小批量的均值,,和标准差,。这两个值,,都是大小为 c 的向量(其中 c 是通道数)。接着,在第二步中,这些统计量用于通过 z-score 标准化(标准化)来缩放每个小批量中的示例,从而得到标准化后的网络输入,。因此,这些网络输入是均值中心化的,并且具有 单位方差,这通常是基于梯度下降优化的有用特性。另一方面,始终将网络输入标准化,使得它们在不同的小批量中具有相同的特性,而这些小批量可能是多样化的,这可能会严重影响神经网络的表征能力。这可以通过考虑一个特征,,它经过 sigmoid 激活后变成 ,导致对于接近 0 的值出现线性区域来理解。因此,在第三步中,可学习的参数,,它们是大小为 c 的向量(通道数),使得 BatchNorm 可以控制归一化特征的偏移和扩展。

在训练过程中,会计算运行平均值,,和运行方差,,这些值将与调优后的参数,,一起用于在评估时归一化测试示例。

为什么 BatchNorm 有助于优化?

最初,BatchNorm 是为了减少所谓的 内部协方差偏移,即由于训练过程中更新的网络参数而导致的某一层激活值分布的变化。

通过一个简单的例子来解释,考虑一个固定的批次,它在第 1 轮通过网络。我们记录这个批次在每一层的激活值。经过遍历整个训练数据集并更新模型参数后,我们开始第二轮训练,这时之前固定的批次再次通过网络。然后,我们将第一轮和第二轮的层激活值进行比较。由于网络参数发生了变化,我们观察到激活值也发生了变化。这种现象被称为内部协方差偏移,曾被认为会减缓神经网络的训练。

然而,在 2018 年,S. Santurkar、D. Tsipras、A. Ilyas 和 A. Madry 进一步研究了 BatchNorm 为什么如此有效。在他们的研究中,研究人员观察到 BatchNorm 对内部协方差偏移的影响是微不足道的。根据实验结果,他们假设 BatchNorm 的有效性实际上是基于损失函数的平滑表面,这使得非凸优化更加稳健。

如果你对进一步了解这些结果感兴趣,可以阅读原始论文《Batch Normalization如何帮助优化?》,该论文可以在http://papers.nips.cc/paper/7515-how-does-batch-normalization-help-optimization.pdf免费获取。

TensorFlow Keras API提供了一个类tf.keras.layers.BatchNormalization(),我们可以在定义模型时作为一层使用;它将执行我们描述的所有BatchNorm步骤。请注意,更新可学习参数的行为取决于training=False还是training=True,这可以确保这些参数只在训练期间进行学习。

实现生成器和判别器

到目前为止,我们已经介绍了DCGAN模型的主要组件,接下来我们将实现这些组件。生成器和判别器网络的架构总结在以下两个图中。

生成器以一个大小为20的向量z作为输入,应用全连接(密集)层将其大小增加到6,272,然后将其重塑为一个形状为(空间维度和128个通道)的3阶张量。接着,使用tf.keras.layers.Conv2DTransposed()进行一系列转置卷积操作,直到生成的特征图的空间维度达到。每个转置卷积层后,通道数减半,除了最后一层,它仅使用一个输出滤波器生成灰度图像。每个转置卷积层后跟随BatchNorm和leaky ReLU激活函数,除了最后一层,它使用tanh激活函数(不使用BatchNorm)。生成器的架构(每层之后的特征图)如下图所示:

判别器接收大小为的图像,并通过四个卷积层进行处理。前三个卷积层将空间维度降低4倍,同时增加特征图的通道数。每个卷积层后面也跟着BatchNorm、leaky ReLU激活函数和一个丢弃层,丢弃率为rate=0.3(丢弃概率)。最后一个卷积层使用大小为的卷积核和一个滤波器,将输出的空间维度减少到

卷积GAN的架构设计考虑

注意,生成器和判别器之间的特征图数量趋势是不同的。在生成器中,我们从大量的特征图开始,并随着接近最后一层时逐渐减少。而在判别器中,我们从少量的通道开始,并向最后一层逐步增加。这是设计CNN时一个重要的要点,特征图数量和特征图的空间大小是反向排列的。当特征图的空间大小增大时,特征图的数量减少,反之亦然。

此外,注意通常不建议在BatchNorm层之后的层中使用偏置单元。使用偏置单元在这种情况下是多余的,因为BatchNorm已经有一个平移参数,。你可以通过在tf.keras.layers.Densetf.keras.layers.Conv2D中设置use_bias=False来省略给定层的偏置单元。

用于创建生成器和判别器网络的两个辅助函数的代码如下:

>>> def make_dcgan_generator(
...         z_size=20,
...         output_size=(28, 28, 1),
...         n_filters=128,
...         n_blocks=2):
...     size_factor = 2**n_blocks
...     hidden_size = (
...         output_size[0]//size_factor,
...         output_size[1]//size_factor)
...     
...     model = tf.keras.Sequential([
...         tf.keras.layers.Input(shape=(z_size,)),
...         
...         tf.keras.layers.Dense(
...             units=n_filters*np.prod(hidden_size),
...             use_bias=False),
...         tf.keras.layers.BatchNormalization(),
...         tf.keras.layers.LeakyReLU(),
...         tf.keras.layers.Reshape(
...             (hidden_size[0], hidden_size[1], n_filters)),
...     
...         tf.keras.layers.Conv2DTranspose(
...             filters=n_filters, kernel_size=(5, 5),
...             strides=(1, 1), padding='same', use_bias=False),
...         tf.keras.layers.BatchNormalization(),
...         tf.keras.layers.LeakyReLU()
...     ])
...         
...     nf = n_filters
...     for i in range(n_blocks):
...         nf = nf // 2
...         model.add(
...             tf.keras.layers.Conv2DTranspose(
...                 filters=nf, kernel_size=(5, 5),
...                 strides=(2, 2), padding='same',
...                 use_bias=False))
...         model.add(tf.keras.layers.BatchNormalization())
...         model.add(tf.keras.layers.LeakyReLU())
...                 
...     model.add(
...         tf.keras.layers.Conv2DTranspose(
...             filters=output_size[2], kernel_size=(5, 5),
...             strides=(1, 1), padding='same', use_bias=False,
...             activation='tanh'))
...         
...     return model
>>> def make_dcgan_discriminator(
...         input_size=(28, 28, 1),
...         n_filters=64,
...         n_blocks=2):
...     model = tf.keras.Sequential([
...         tf.keras.layers.Input(shape=input_size),
...         tf.keras.layers.Conv2D(
...             filters=n_filters, kernel_size=5,
...             strides=(1, 1), padding='same'),
...         tf.keras.layers.BatchNormalization(),
...         tf.keras.layers.LeakyReLU()
...     ])
...     
...     nf = n_filters
...     for i in range(n_blocks):
...         nf = nf*2
...         model.add(
...             tf.keras.layers.Conv2D(
...                 filters=nf, kernel_size=(5, 5),
...                 strides=(2, 2),padding='same'))
...         model.add(tf.keras.layers.BatchNormalization())
...         model.add(tf.keras.layers.LeakyReLU())
...         model.add(tf.keras.layers.Dropout(0.3))
...         
...     model.add(
...         tf.keras.layers.Conv2D(
...                 filters=1, kernel_size=(7, 7),
...                 padding='valid'))
...     
...     model.add(tf.keras.layers.Reshape((1,)))
...     
...     return model 

有了这两个辅助函数,你可以构建一个DCGAN模型,并使用我们在上一节中初始化的相同MNIST数据集对象来训练它,当时我们实现了简单的全连接GAN。此外,我们可以像之前一样使用相同的损失函数和训练过程。

在本章的剩余部分,我们将对DCGAN模型进行一些额外的修改。请注意,preprocess()函数用于转换数据集时,必须更改为输出图像张量,而不是将图像展平为向量。以下代码显示了构建数据集所需的修改,以及创建新的生成器和判别器网络:

>>> mnist_bldr = tfds.builder('mnist')
>>> mnist_bldr.download_and_prepare()
>>> mnist = mnist_bldr.as_dataset(shuffle_files=False)
>>> def preprocess(ex, mode='uniform'):
...     image = ex['image']
...     image = tf.image.convert_image_dtype(image, tf.float32)
...
...     image = image*2 - 1.0
...     if mode == 'uniform':
...         input_z = tf.random.uniform(
...             shape=(z_size,), minval=-1.0, maxval=1.0)
...     elif mode == 'normal':
...         input_z = tf.random.normal(shape=(z_size,))
...     return input_z, image 

我们可以使用辅助函数make_dcgan_generator()来创建生成器网络,并按如下方式打印其架构:

>>> gen_model = make_dcgan_generator()
>>> gen_model.summary()
Model: "sequential_2"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
dense_1 (Dense)              (None, 6272)              125440    
_________________________________________________________________
batch_normalization_7 (Batch (None, 6272)              25088     
_________________________________________________________________
leaky_re_lu_7 (LeakyReLU)    (None, 6272)              0         
_________________________________________________________________
reshape_2 (Reshape)          (None, 7, 7, 128)         0         
_________________________________________________________________
conv2d_transpose_4 (Conv2DTr (None, 7, 7, 128)         409600    
_________________________________________________________________
batch_normalization_8 (Batch (None, 7, 7, 128)         512       
_________________________________________________________________
leaky_re_lu_8 (LeakyReLU)    (None, 7, 7, 128)         0         
_________________________________________________________________
conv2d_transpose_5 (Conv2DTr (None, 14, 14, 64)        204800    
_________________________________________________________________
batch_normalization_9 (Batch (None, 14, 14, 64)        256       
_________________________________________________________________
leaky_re_lu_9 (LeakyReLU)    (None, 14, 14, 64)        0         
_________________________________________________________________
conv2d_transpose_6 (Conv2DTr (None, 28, 28, 32)        51200     
_________________________________________________________________
batch_normalization_10 (Batc (None, 28, 28, 32)        128       
_________________________________________________________________
leaky_re_lu_10 (LeakyReLU)   (None, 28, 28, 32)        0         
_________________________________________________________________
conv2d_transpose_7 (Conv2DTr (None, 28, 28, 1)         800       
=================================================================
Total params: 817,824
Trainable params: 804,832
Non-trainable params: 12,992
_________________________________________________________________ 

同样地,我们可以生成判别器网络并查看其架构:

>>> disc_model = make_dcgan_discriminator()
>>> disc_model.summary()
Model: "sequential_3"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
conv2d_4 (Conv2D)            (None, 28, 28, 64)        1664      
_________________________________________________________________
batch_normalization_11 (Batc (None, 28, 28, 64)        256       
_________________________________________________________________
leaky_re_lu_11 (LeakyReLU)   (None, 28, 28, 64)        0         
_________________________________________________________________
conv2d_5 (Conv2D)            (None, 14, 14, 128)       204928    
_________________________________________________________________
batch_normalization_12 (Batc (None, 14, 14, 128)       512       
_________________________________________________________________
leaky_re_lu_12 (LeakyReLU)   (None, 14, 14, 128)       0         
_________________________________________________________________
dropout_2 (Dropout)          (None, 14, 14, 128)       0         
_________________________________________________________________
conv2d_6 (Conv2D)            (None, 7, 7, 256)         819456    
_________________________________________________________________
batch_normalization_13 (Batc (None, 7, 7, 256)         1024      
_________________________________________________________________
leaky_re_lu_13 (LeakyReLU)   (None, 7, 7, 256)         0         
_________________________________________________________________
dropout_3 (Dropout)          (None, 7, 7, 256)         0         
_________________________________________________________________
conv2d_7 (Conv2D)            (None, 1, 1, 1)           12545     
_________________________________________________________________
reshape_3 (Reshape)          (None, 1)                 0         
=================================================================
Total params: 1,040,385
Trainable params: 1,039,489
Non-trainable params: 896
_________________________________________________________________ 

注意,BatchNorm层的参数数量确实是通道数的四倍()。记住,BatchNorm参数,,表示从给定批次推断出的每个特征值的(非可训练参数)均值和标准差; 是可训练的BN参数。

请注意,这种特定的架构在使用交叉熵作为损失函数时表现并不好。

在下一小节中,我们将介绍WGAN,它使用基于所谓的Wasserstein-1(或地球移动者)距离的修改版损失函数,来改进真实图像和假图像分布之间的训练性能。

两个分布之间的相异度度量

我们将首先看到几种计算两个分布之间散度的方法。接着,我们将看到这些方法中哪一种已经嵌入到原始的GAN模型中。最后,在GAN中切换这种度量将引导我们实现WGAN。

如本章开头所述,生成模型的目标是学习如何合成新的样本,这些样本与训练数据集的分布相同。让P(x)和Q(x)表示随机变量x的分布,如下图所示。

首先,看看下图所示的几种方法,我们可以用来衡量两个分布之间的差异,PQ

在总变差(TV)度量中使用的上确界函数,sup(S),指的是大于集合S中所有元素的最小值。换句话说,sup(S)是S的最小上界。反之,EM距离中使用的下确界函数,inf(S),指的是小于集合S中所有元素的最大值(即最大下界)。让我们通过简单的语言简要了解这些度量方法的作用:

  • 第一种,TV距离,衡量的是两个分布在每个点上的最大差异。

  • EM距离可以理解为将一个分布转化为另一个分布所需的最小工作量。EM距离中的下确界函数是在!上取值的,这个集合包含了所有其边际分布为PQ的联合分布。然后,!是一个传输计划,表示我们如何在地理位置uv之间重新分配地球资源,同时确保在这种转移后分布仍然有效。计算EM距离本身是一个优化问题,目标是找到最优的传输计划,!

  • Kullback-Leibler(KL)散度和Jensen-Shannon(JS)散度源自信息论领域。请注意,KL散度不是对称的,即与JS散度不同,!

上一图中提供的差异度量公式适用于连续分布,但也可以扩展到离散情况。以下图展示了计算这几种不同差异度量方法的例子,使用了两个简单的离散分布:

请注意,在EM距离的情况下,对于这个简单的例子,我们可以看到在 x = 2 时,Q(x) 的过剩值为 ,而其他两个 xQ 值低于1/3. 因此,最小的工作量是在 x = 2 时将额外的值转移到 x = 1 和 x = 3,如前图所示。对于这个简单的例子,很容易看出这些转移将导致所有可能转移中的最小工作量。然而,对于更复杂的情况,这种方法可能不可行。

KL散度与交叉熵之间的关系

KL散度,,衡量分布 P 相对于参考分布 Q 的相对熵。KL散度的公式可以扩展为

此外,对于离散分布,KL散度可以写成

可以类似地扩展为

基于扩展的公式(无论是离散的还是连续的),KL散度被视为 PQ 之间的交叉熵(前面公式中的第一项),减去 P 的(自)熵(第二项),即

现在,回到我们对GAN的讨论,让我们看看这些不同的距离度量如何与GAN的损失函数相关。可以通过数学证明,原始GAN中的损失函数确实最小化了真实样本和假样本分布之间的JS散度。但是,正如Martin Arjovsky等人(Wasserstein Generative Adversarial Networkshttp://proceedings.mlr.press/v70/arjovsky17a/arjovsky17a.pdf)在一篇文章中讨论的那样,JS散度在训练GAN模型时存在问题,因此,为了改善训练,研究人员提出使用EM距离作为衡量真实样本和假样本分布之间相异性的度量。

使用EM距离的优势是什么?

为了回答这个问题,我们可以考虑Martin Arjovsky等人文章中给出的一个例子,标题为 Wasserstein GAN。简单来说,假设我们有两个分布 PQ,它们是两条平行线。一条线固定在 x = 0,另一条线可以沿 x-轴移动,但最初位于 ,其中

可以证明,KL、TV和JS不相似度度量是 。这些不相似度度量都不是参数 的函数,因此,它们无法对 进行求导来使得分布 PQ 变得相似。另一方面,EM距离是 ,其关于 的梯度存在,并且可以推动 QP 靠近。

现在,让我们集中注意力在EM距离如何用于训练GAN模型上。假设 是真实样本的分布,而 表示假(生成)样本的分布。 代替了EM距离公式中的 PQ。如前所述,计算EM距离本身就是一个优化问题;因此,如果我们想在GAN训练循环的每次迭代中重复计算它,这会变得计算上不可行。幸运的是,EM距离的计算可以通过一个叫做 Kantorovich-Rubinstein 对偶性 的定理来简化,如下所示:

这里,supremum是取所有1-Lipschitz连续函数的上确界,记作

Lipschitz 连续性

基于1-Lipschitz连续性,函数 f 必须满足以下性质:

此外,满足该性质的实函数为

被称为K-Lipschitz连续。

在实践中使用EM距离来训练GAN

现在,问题是,如何找到这样的1-Lipschitz连续函数来计算真实样本分布()和假样本分布()之间的Wasserstein距离?虽然WGAN方法背后的理论概念一开始可能看起来很复杂,但这个问题的答案其实比它看起来要简单。回想一下,我们认为深度神经网络是通用的函数逼近器。这意味着我们可以简单地训练一个神经网络模型来逼近Wasserstein距离函数。正如你在上一部分看到的,简单的GAN使用了一个分类器形式的鉴别器。对于WGAN,鉴别器可以改为作为一个 评论家 来工作,它返回一个标量分数,而不是一个概率值。我们可以将这个分数解释为输入图像的真实度(就像艺术评论家给画廊中的艺术作品打分一样)。

为了使用 Wasserstein 距离训练 GAN,定义了鉴别器 D 和生成器 G 的损失如下。鉴别器(即鉴别网络)返回其对于真实图像样本批次和生成样本批次的输出。我们分别使用符号 D(x) 和 D(G(z))。然后,可以定义以下损失项:

  • 鉴别器损失的真实部分:

  • 鉴别器损失的伪造部分:

  • 生成器的损失:

WGAN 的所有内容就到此为止,除了我们需要确保在训练过程中鉴别器函数的 1-Lipschitz 性质得到保持。为此,WGAN 论文建议将权重限制在一个小区域内,例如 [–0.01, 0.01]。

梯度惩罚

在 Arjovsky 等人的论文中,建议通过权重裁剪来保持鉴别器(或鉴别网络)的 1-Lipschitz 性质。然而,在另一篇题为 改进的 Wasserstein GAN 训练 的论文中,该论文可以在 https://arxiv.org/pdf/1704.00028.pdf 免费获取,Ishaan Gulrajani 等人展示了裁剪权重可能导致梯度爆炸和梯度消失。此外,权重裁剪还可能导致模型容量不足,这意味着鉴别器网络只能学习一些简单的函数,而无法学习更复杂的函数。因此,Ishaan Gulrajani 等人提出了 梯度惩罚 (GP) 作为替代方案。最终结果就是 带有梯度惩罚的 WGAN (WGAN-GP)。

每次迭代中加入的 GP 过程可以通过以下步骤总结:

  1. 对于给定批次中的每对真实和伪造样本 ,选择一个随机数,,从均匀分布中采样,即

  2. 计算真实和伪造样本之间的插值:,得到一批插值样本。

  3. 计算所有插值样本的鉴别器(鉴别器)输出,

  4. 计算鉴别器输出相对于每个插值样本的梯度,即:

  5. 计算梯度惩罚为:

鉴别器的总损失如下所示:

这里, 是一个可调的超参数。

实现 WGAN-GP 来训练 DCGAN 模型

我们已经定义了创建 DCGAN 生成器和鉴别器网络的辅助函数(make_dcgan_generator()make_dcgan_discriminator())。构建 DCGAN 模型的代码如下:

>>> num_epochs = 100
>>> batch_size = 128
>>> image_size = (28, 28)
>>> z_size = 20
>>> mode_x = 'uniform'
>>> lambda_gp = 10.0
>>> tf.random.set_seed(1)
>>> np.random.seed(1)
>>> ## Set-up the dataset
>>> mnist_trainset = mnist['train']
>>> mnist_trainset = mnist_trainset.map(preprocess)
>>> mnist_trainset = mnist_trainset.shuffle(10000)
>>> mnist_trainset = mnist_trainset.batch(
...     batch_size, drop_remainder=True)
>>> ## Set-up the model
>>> with tf.device(device_name):
...     gen_model = make_dcgan_generator()
...     gen_model.build(input_shape=(None, z_size))
...
...     disc_model = make_dcgan_discriminator()
...     disc_model.build(input_shape=(None, np.prod(image_size))) 

现在我们可以开始训练模型。请注意,通常建议使用 RMSprop 优化器来训练 WGAN(不带 GP),而使用 Adam 优化器来训练 WGAN-GP。代码如下:

>>> import time
>>> ## Optimizers:
>>> g_optimizer = tf.keras.optimizers.Adam(0.0002)
>>> d_optimizer = tf.keras.optimizers.Adam(0.0002)
>>> if mode_z == 'uniform':
...     fixed_z = tf.random.uniform(
...         shape=(batch_size, z_size), minval=-1, maxval=1)
... elif mode_z == 'normal':
...     fixed_z = tf.random.normal(shape=(batch_size, z_size))
...
>>> def create_samples(g_model, input_z):
...     g_output = g_model(input_z, training=False)
...     images = tf.reshape(g_output, (batch_size, *image_size))
...     return (images+1)/2.0
>>> all_losses = []
>>> epoch_samples = []
>>> start_time = time.time()
>>> for epoch in range(1, num_epochs+1):
...
...     epoch_losses = []
...
...     for i,(input_z,input_real) in enumerate(mnist_trainset):
...         
...         with tf.GradientTape() as d_tape, tf.GradientTape() \
...                 as g_tape:
... 
...             g_output = gen_model(input_z, training=True)
...             
...             d_critics_real = disc_model(input_real,
...                 training=True)
...             d_critics_fake = disc_model(g_output,
...                 training=True)
...
...             ## Compute generator's loss:
...             g_loss = -tf.math.reduce_mean(d_critics_fake)
...
...             ## compute discriminator's losses:
...             d_loss_real = -tf.math.reduce_mean(d_critics_real)
...             d_loss_fake =  tf.math.reduce_mean(d_critics_fake)
...             d_loss = d_loss_real + d_loss_fake
...         
...             ## Gradient-penalty:
...             with tf.GradientTape() as gp_tape:
...                 alpha = tf.random.uniform(
...                     shape=[d_critics_real.shape[0], 1, 1, 1],
...                     minval=0.0, maxval=1.0)
...                 interpolated = (alpha*input_real +
...                                  (1-alpha)*g_output)
...                 gp_tape.watch(interpolated)
...                 d_critics_intp = disc_model(interpolated)
...             
...             grads_intp = gp_tape.gradient(
...                 d_critics_intp, [interpolated,])[0]
...             grads_intp_l2 = tf.sqrt(
...                 tf.reduce_sum(tf.square(grads_intp),
...                               axis=[1, 2, 3]))
...             grad_penalty = tf.reduce_mean(tf.square(
...                                grads_intp_l2 - 1.0))
...         
...             d_loss = d_loss + lambda_gp*grad_penalty
...             
...         ## Optimization: Compute the gradients apply them
...         d_grads = d_tape.gradient(d_loss,
...                       disc_model.trainable_variables)
...         d_optimizer.apply_gradients(
...             grads_and_vars=zip(d_grads,
...             disc_model.trainable_variables))
...
...         g_grads = g_tape.gradient(g_loss,
...                       gen_model.trainable_variables)
...         g_optimizer.apply_gradients(
...             grads_and_vars=zip(g_grads,
...             gen_model.trainable_variables))
...
...         epoch_losses.append(
...             (g_loss.numpy(), d_loss.numpy(),
...              d_loss_real.numpy(), d_loss_fake.numpy()))
...
...     all_losses.append(epoch_losses)
...     print(
...         'Epoch {:03d} | ET {:.2f} min | Avg Losses >>'
...         ' G/D {:6.2f}/{:6.2f} [D-Real: {:6.2f}'
...         ' D-Fake: {:6.2f}]'
...         .format(
...             epoch, (time.time() - start_time)/60,
...             *list(np.mean(all_losses[-1], axis=0))))
...     epoch_samples.append(
...         create_samples(gen_model, fixed_z).numpy()) 

最后,让我们可视化在一些训练周期保存的样本,看看模型是如何学习的,以及合成样本的质量如何随着学习过程而变化:

>>> selected_epochs = [1, 2, 4, 10, 50, 100]
>>> fig = plt.figure(figsize=(10, 14))
>>> for i,e in enumerate(selected_epochs):
...     for j in range(5):
...         ax = fig.add_subplot(6, 5, i*5+j+1)
...         ax.set_xticks([])
...         ax.set_yticks([])
...         if j == 0:
...             ax.text(-0.06, 0.5, 'Epoch {}'.format(e),
...                     rotation=90, size=18, color='red',
...                     horizontalalignment='right',
...                     verticalalignment='center',
...                     transform=ax.transAxes)
...         
...         image = epoch_samples[e-1][j]
...         ax.imshow(image, cmap='gray_r')
>>> plt.show() 

以下图展示了结果:

我们使用与香草 GAN 部分相同的代码来可视化结果。比较新的示例可以看到,DCGAN(结合 Wasserstein 和 GP)能够生成质量更高的图像。

模式崩溃

由于 GAN 模型的对抗性特性,它们的训练非常困难。训练 GAN 失败的一个常见原因是生成器卡在一个小子空间中,并学会生成相似的样本。这被称为模式崩溃,以下图展示了一个例子。

这个图中的合成样本并非精心挑选的。这表明生成器未能学会整个数据分布,而是采取了一种懒惰的方法,专注于一个子空间:

除了我们之前看到的梯度消失和梯度爆炸问题外,还有一些其他方面也可能使得 GAN 模型的训练变得困难(事实上,这是一门艺术)。这里有一些来自 GAN 艺术家的建议技巧。

一种方法叫做小批量判别,它基于以下事实:由真实或伪造样本组成的小批量数据分别输入判别器。在小批量判别中,我们让判别器比较这些批次中的样本,看看一个批次是来自真实数据还是伪造数据。如果模型发生模式崩溃,那么仅由真实样本组成的批次的多样性通常会高于伪造批次的多样性。

另一种常用来稳定 GAN 训练的技术是特征匹配。在特征匹配中,我们通过增加一个额外的项来稍微修改生成器的目标函数,该项最小化基于判别器中间表示(特征图)之间的原始图像和合成图像的差异。我们鼓励您阅读 Ting-Chun Wang 等人撰写的原始文章,标题为使用条件 GAN 进行高分辨率图像合成和语义操控,该文章可以免费访问,链接:https://arxiv.org/pdf/1711.11585.pdf

在训练过程中,GAN 模型也可能会卡在几个模式之间,只是在这些模式之间跳来跳去。为了避免这种情况,您可以存储一些旧的样本并将其输入判别器,以防止生成器重新访问先前的模式。这种技术被称为经验回放。此外,您还可以用不同的随机种子训练多个 GAN,这样所有 GAN 的组合覆盖的数据分布范围要比单一模型更广。

其他 GAN 应用

在本章中,我们主要集中于使用GAN生成示例,并探讨了一些技巧和技术,以提高合成输出的质量。GAN的应用正在迅速扩展,包括计算机视觉、机器学习,甚至其他科学和工程领域。在https://github.com/hindupuravinash/the-gan-zoo上可以找到一份有关不同GAN模型和应用领域的良好列表。

值得一提的是,我们在本章中以无监督的方式介绍了GAN,也就是说,在本章所覆盖的模型中并未使用类别标签信息。然而,GAN方法也可以推广到半监督和监督任务。例如,Mehdi Mirza和Simon Osindero在论文Conditional Generative Adversarial Nets中提出的条件GAN(cGAN)(https://arxiv.org/pdf/1411.1784.pdf)使用类别标签信息,并学习在提供的标签条件下合成新的图像,即!—应用于MNIST数据集。这使得我们可以有选择性地生成0-9范围内的不同数字。此外,条件GAN还允许我们进行图像到图像的转换,即学习如何将特定领域的给定图像转换到另一个领域。在这个背景下,一个有趣的工作是Pix2Pix算法,它发表在Philip Isola等人的论文Image-to-Image Translation with Conditional Adversarial Networks中(https://arxiv.org/pdf/1611.07004.pdf)。值得一提的是,在Pix2Pix算法中,判别器提供多个图像区域的真/假预测,而不是对整个图像的单一预测。

CycleGAN是另一个基于cGAN的有趣GAN模型,同样用于图像到图像的转换。然而,请注意,在CycleGAN中,来自两个领域的训练示例是非配对的,这意味着输入和输出之间没有一一对应关系。例如,使用CycleGAN,我们可以将一张夏季拍摄的图片的季节转换为冬季。在Jun-Yan Zhu等人的论文Unpaired Image-to-Image Translation Using Cycle-Consistent Adversarial Networks中(https://arxiv.org/pdf/1703.10593.pdf),一个令人印象深刻的示例展示了将马转化为斑马。

小结

在本章中,你首先了解了深度学习中的生成模型及其总体目标:合成新数据。接着我们介绍了GAN模型如何使用生成网络和判别网络,在对抗训练设置中互相竞争,从而彼此改进。随后,我们实现了一个简单的GAN模型,其中生成器和判别器都只使用了全连接层。

我们还讨论了如何改进GAN模型。首先,你了解了DCGAN,它为生成器和判别器都使用了深度卷积网络。在这个过程中,你还学习了两个新概念:转置卷积(用于上采样特征图的空间维度)和BatchNorm(用于提高训练过程中的收敛性)。

然后我们讨论了WGAN,它使用EM距离来衡量真实样本和伪造样本之间的分布距离。最后,我们讲解了带有GP的WGAN,它通过保持1-Lipschitz性质来代替对权重的裁剪。

在下一章,我们将讨论强化学习,这是与本书目前内容完全不同的机器学习类别。

第十八章:强化学习在复杂环境中的决策制定

在前几章中,我们重点讨论了监督学习和无监督学习。我们还学习了如何利用人工神经网络和深度学习来解决这些类型机器学习中遇到的问题。如你所记得,监督学习专注于根据给定的输入特征向量预测类别标签或连续值。无监督学习则专注于从数据中提取模式,因此它在数据压缩(第5章通过降维压缩数据)、聚类(第11章处理无标签数据——聚类分析)或通过近似训练集分布来生成新数据(第17章生成对抗网络用于合成新数据)等方面非常有用。

本章我们将讨论机器学习的另一类,强化学习RL),它不同于前面讨论的类别,因为它专注于学习一系列动作以优化整体奖励——例如,在国际象棋游戏中获胜。总之,本章将涵盖以下内容:

  • 学习RL的基础知识,熟悉智能体/环境交互,并理解奖励过程的工作原理,从而帮助在复杂环境中做出决策

  • 介绍不同类别的RL问题、基于模型和无模型的学习任务、蒙特卡洛算法和时序差分学习算法

  • 在表格格式中实现Q学习算法

  • 理解函数逼近在解决RL问题中的应用,并通过实现深度Q学习算法将RL与深度学习相结合

强化学习是一个复杂且广泛的研究领域,本章重点介绍基础内容。由于本章是入门章节,为了让我们集中精力关注重要的方法和算法,我们将主要使用基本示例来说明主要概念。然而,在本章的最后,我们将讨论一个更具挑战性的例子,并使用深度学习架构来实现一种特定的RL方法,称为深度Q学习。

引言——从经验中学习

在本节中,我们将首先介绍强化学习(RL)作为机器学习的一个分支,并与其他机器学习任务进行比较,了解其主要差异。接着,我们将讨论RL系统的基本组成部分。然后,我们将介绍基于马尔可夫决策过程的RL数学模型。

理解强化学习

到目前为止,本书主要关注了监督学习和无监督学习。回顾一下,在监督学习中,我们依赖于标注的训练样本,这些样本由监督者或人类专家提供,目标是训练一个能够很好地对未见过的、未标注的测试样本进行泛化的模型。这意味着监督学习模型应该学习如何为给定的输入样本分配与监督者或人类专家相同的标签或值。另一方面,在无监督学习中,目标是学习或捕捉数据集的潜在结构,例如在聚类和降维方法中;或者学习如何生成具有相似潜在分布的新的合成训练样本。强化学习(RL)与监督学习和无监督学习有本质的不同,因此强化学习通常被视为“机器学习的第三类”。

区分强化学习与机器学习其他子任务(如监督学习和无监督学习)的关键因素是,强化学习围绕通过交互学习的概念展开。这意味着在强化学习中,模型通过与环境的交互来学习,以最大化奖励函数

虽然最大化奖励函数与监督学习中最小化成本函数的概念相关,但在强化学习(RL)中,学习一系列动作的正确标签是未知的或没有事先定义的——相反,它们需要通过与环境的交互来学习,以实现某个期望的结果——例如赢得一场游戏。在强化学习中,模型(也称为智能体)与环境进行交互,通过这些交互生成一系列的交互过程,统称为一个回合。通过这些交互,智能体收集由环境确定的一系列奖励。这些奖励可以是正向的,也可以是负向的,有时直到回合结束才会向智能体披露奖励。

举个例子,假设我们想教一个计算机下棋,并且赢得人类玩家的比赛。每一个由计算机做出的棋步的标签(奖励)在游戏结束之前是无法知道的,因为在游戏过程中,我们并不清楚某一步棋是否会导致胜利或失败。只有在游戏结束时,反馈才会被确定。如果计算机赢得了比赛,那么反馈很可能是一个正向奖励,因为智能体实现了预期的最终目标;相反,如果计算机输了比赛,那么很可能会给一个负向奖励。

此外,考虑到下棋的例子,输入是当前的配置,例如棋盘上各个棋子的排列。由于可能的输入(系统的状态)种类繁多,我们无法将每种配置或状态标记为正面或负面。因此,为了定义学习过程,我们在每场比赛结束时提供奖励(或惩罚),此时我们已经知道是否达到了期望的结果——无论我们是否赢得了比赛。

这就是强化学习的本质。在强化学习中,我们不能也不需要教智能体、计算机或机器人,如何做事情;我们只能指定我们希望智能体达成的目标。然后,根据特定尝试的结果,我们可以根据智能体的成功或失败来确定奖励。这使得强化学习在复杂环境中的决策制定中非常有吸引力——特别是当问题解决任务需要一系列未知的步骤,或者这些步骤很难解释或定义时。

除了在游戏和机器人学中的应用外,强化学习的例子也可以在自然界中找到。例如,训练一只狗就涉及强化学习——当狗做出某些期望的动作时,我们会给予它奖励(零食)。再比如,考虑一只训练有素的医疗犬,它能够警告其主人即将发生癫痫发作。在这种情况下,我们不知道狗如何准确地检测到即将发生的癫痫发作,甚至如果我们知道这一机制,也无法定义一系列步骤来学习癫痫检测。然而,如果狗成功地检测到癫痫发作,我们可以通过奖励它零食来强化这一行为!

虽然强化学习提供了一个强大的框架,用于学习实现某个目标所需的任意一系列动作,但请记住,强化学习仍然是一个相对年轻且活跃的研究领域,面临许多未解决的挑战。使得训练强化学习模型特别具有挑战性的一方面是,随之而来的模型输入依赖于先前采取的行动。这可能会导致各种问题,通常会导致不稳定的学习行为。此外,强化学习中的这一序列依赖性会产生所谓的延迟效应,这意味着在时间步* t *采取的动作可能导致未来某个任意步数后出现奖励。

定义强化学习系统中的智能体-环境接口

在所有强化学习的例子中,我们可以找到两个截然不同的实体:一个是智能体,一个是环境。正式来说,智能体被定义为一种通过采取行动来学习如何做出决策并与周围环境互动的实体。作为回报,智能体采取行动后,环境根据规定返回观察值和奖励信号。环境是指智能体之外的任何事物。环境与智能体进行交互,并确定智能体行为的奖励信号及其观察结果。

奖励信号是智能体通过与环境互动所收到的反馈,通常以标量值的形式提供,可能是正值也可能是负值。奖励的目的是告诉智能体它的表现如何。智能体接收到奖励的频率取决于特定的任务或问题。例如,在国际象棋游戏中,奖励会在整场比赛结束后,根据所有棋步的结果来决定:获胜或失败。另一方面,我们也可以定义一个迷宫,在每个时间步之后决定奖励。在这样的迷宫中,智能体会尝试最大化它在生命周期中的累计奖励——其中生命周期指的是一个回合的持续时间。

以下图示说明了智能体与环境之间的交互和通信:

如前图所示,智能体的状态是它所有变量的集合(1)。例如,在机器人无人机的情况下,这些变量可能包括无人机的当前位置(经度、纬度和高度)、剩余电池电量、每个风扇的速度等。每个时间步,智能体通过一组可用动作与环境进行交互 (2)。根据智能体所采取的动作!,当它处于状态 时,智能体将接收到一个奖励信号 (3),并且它的状态将变为 (4)。

在学习过程中,智能体必须尝试不同的动作(探索),这样它就能逐步学会哪些动作更优,并更加频繁地执行这些动作(利用),以最大化总的累计奖励。为了理解这个概念,我们可以考虑一个非常简单的例子:一个新毕业的计算机科学硕士,专注于软件工程,他在考虑是加入一家公司工作(利用)还是继续攻读硕士或博士学位,深入学习数据科学和机器学习(探索)。一般来说,利用会导致选择短期回报更大的动作,而探索则有可能在长期内带来更大的总回报。探索与利用之间的权衡已被广泛研究,但对于这一决策困境,目前并没有普遍的答案。

强化学习的理论基础

在我们跳入一些实际的例子并开始训练一个强化学习(RL)模型之前,接下来我们将在本章进行相关操作,首先让我们了解一下强化学习的一些理论基础。接下来的章节将首先通过考察马尔可夫决策过程的数学形式、阶段性任务与连续任务的区别、一些强化学习的关键术语,以及使用贝尔曼方程的动态规划,来开始介绍。让我们从马尔可夫决策过程开始。

马尔可夫决策过程

通常,强化学习所处理的问题类型通常被表述为马尔可夫决策过程MDPs)。解决 MDP 问题的标准方法是使用动态规划,但强化学习相比动态规划具有一些关键优势。

动态规划

动态规划是理查德·贝尔曼(Richard Bellman)在1950年代开发的一组计算机算法和编程方法。从某种意义上说,动态规划是一种递归问题求解方法——通过将相对复杂的问题分解为更小的子问题来解决。

递归和动态规划之间的主要区别在于,动态规划会存储子问题的结果(通常以字典或其他查找表的形式),以便在将来再次遇到时可以在常数时间内访问(而不是重新计算)。

动态规划解决的计算机科学中的一些著名问题包括序列对齐和计算从 A 点到 B 点的最短路径。

然而,当状态的规模(即可能的配置数量)相对较大时,动态规划并不是一种可行的方法。在这种情况下,强化学习被认为是一种更加高效且实用的替代方法,用于解决 MDP 问题。

马尔可夫决策过程的数学表述

需要学习互动和顺序决策过程的问题类型,其中时间步 t 的决策会影响后续的情况,数学上被形式化为马尔可夫决策过程(MDPs)。

在强化学习中,若我们将智能体的初始状态表示为 ,那么智能体和环境之间的交互会产生如下的序列:

请注意,大括号仅作为视觉辅助。这里, 表示时间步 t 时的状态和所采取的动作。 表示执行动作 后从环境中获得的奖励。请注意, 是时间相关的随机变量,它们的取值来自预定义的有限集合,分别由 表示。在马尔可夫决策过程中,这些时间相关的随机变量 的概率分布只依赖于它们在前一个时间步 t - 1 的值。 的概率分布可以表示为对前一个状态 () 和所采取动作 () 的条件概率,如下所示:

这个概率分布完全定义了环境的动态(或环境模型),因为基于这个分布,可以计算环境的所有转移概率。因此,环境动态是对不同强化学习方法进行分类的核心标准。需要环境模型或试图学习环境模型(即环境动态)的强化学习方法被称为基于模型方法,区别于无模型方法。

无模型和基于模型的强化学习

当概率 已知时,可以通过动态规划来解决学习任务。但当环境的动态未知时(这在许多现实世界问题中是常见的),则需要通过与环境交互获得大量样本,以弥补环境动态的未知。

解决这个问题的两种主要方法是无模型蒙特卡洛(MC)方法和时间差分(TD)方法。下图展示了这两种主要类别及其各自的分支:

本章将从理论到实际算法介绍这些不同的方法及其分支。

如果在给定状态下的特定动作总是或从不被执行,则可以认为环境动态是确定性的,即 。否则,在更一般的情况下,环境将表现出随机行为。

为了理解这种随机行为,让我们考虑在当前状态 和执行的动作 条件下观察未来状态 的概率。这用 表示。

它可以通过对所有可能的奖励求和来计算为边际概率:

这个概率称为状态转移概率。基于状态转移概率,如果环境动态是确定性的,那么这意味着当智能体在状态 执行动作 时,转移到下一个状态 将是 100% 确定的,即

马尔可夫过程的可视化

马尔可夫过程可以表示为一个有向环图,其中图中的节点表示环境的不同状态。图的边(即节点之间的连接)表示状态之间的转移概率。

例如,假设我们考虑一个学生在三种不同情境间做出选择:(A) 在家备考,(B) 在家玩电子游戏,或者 (C) 在图书馆学习。此外,还存在一个终端状态 (T),即去睡觉。每小时做出一个决策,做出决策后,学生将在该小时内保持在选择的情境中。假设在家(状态 A)时,学生有50%的概率会切换到玩电子游戏(状态 B)。而当学生处于状态 B(玩电子游戏)时,学生有相对较高的概率(80%)在接下来的小时继续玩电子游戏。

学生行为的动态在下图中作为马尔可夫过程展示,其中包含一个循环图和转移表:

图中边缘的数值表示学生行为的转移概率,这些数值也在右侧的表格中显示。查看表格中的行时,请注意,从每个状态(节点)出来的转移概率总和始终为1。

回合任务与持续任务

随着代理与环境的互动,观察或状态的序列形成一条轨迹。轨迹有两种类型。如果一个代理的轨迹可以被分割成若干子部分,使得每部分都从时间 t = 0 开始,并以终端状态 结束(在 t = T 时),则该任务被称为 回合任务。另一方面,如果轨迹是无限连续的,并且没有终端状态,则该任务被称为 持续任务

与学习代理相关的任务,例如国际象棋游戏,是一个回合任务;而保持房屋整洁的清洁机器人通常执行的是一个持续任务。本章中,我们仅考虑回合任务。

在回合任务中,回合是一个代理从起始状态 到终端状态 所经历的序列或轨迹:

对于前述图中展示的马尔可夫过程,该过程描述了学生为考试学习的任务,我们可能会遇到如下三个示例的回合:

强化学习术语:回报、策略和值函数

接下来,我们将定义一些剩余章节中将用到的强化学习(RL)专有术语。

回报

所谓的时间 t 时的回报是从整个回合期间获得的累计奖励。回想一下, 是执行动作 后,在时间 t 获得的 即时奖励后续奖励则是 ,依此类推。

时间 t 时的回报可以通过即时奖励以及后续奖励计算得出,公式如下:

这里,是[0, 1]范围内的折现因子。参数 表示当前时刻(时间t)未来奖励的“价值”。请注意,通过设置 ,我们实际上是在表明不关心未来的奖励。在这种情况下,回报将等于即时奖励,忽略t+1之后的奖励,智能体将变得目光短浅。另一方面,如果 ,则回报将是所有后续奖励的无权重总和。

此外,请注意,回报的方程可以通过递归的方式更简洁地表达,如下所示:

这意味着在时间t时的回报等于即时奖励r加上时间t+1时折现后的未来回报。这是一个非常重要的属性,便于回报的计算。

折现因子的直觉

为了理解折现因子的含义,可以参考以下图示,展示今天赚取100美元与一年后赚取100美元的价值。在某些经济情况下,比如通货膨胀,今天赚到100美元的价值可能比一年后赚到更多:

因此,我们可以说,如果这张票现在值100美元,那么在一年后,考虑到折现因子,它的价值将是90美元!

让我们计算在前面学生示例中的不同时间步的回报。假设 ,并且唯一的奖励是基于考试结果(通过考试得+1,未通过得–1)。中间时间步的奖励为0。

  • ...

  • ...

我们将回报的计算留给第三集作为读者的练习。

策略

一个通常用 表示的策略是一个函数,用来决定采取什么动作,动作可以是确定性的,也可以是随机的(即,采取下一步动作的概率)。随机策略则有一个动作的概率分布,表示在给定状态下智能体可以采取的动作:

在学习过程中,随着智能体积累更多经验,策略可能会发生变化。例如,智能体可能从一个随机策略开始,在这种策略下,所有动作的概率是均匀的;与此同时,智能体希望能够学习并优化其策略,朝着最优策略靠近。最优策略 是能带来最高回报的策略。

价值函数

价值函数,也称为状态价值函数,衡量每个状态的优劣——换句话说,就是处于某个特定状态时,这个状态好坏的衡量标准。请注意,优劣的标准是基于回报的。

现在,基于回报 ,我们将状态 s 的价值函数定义为在遵循策略 后的期望回报(所有可能情境的平均回报):

在实际实现中,我们通常使用查找表来估算价值函数,这样就不必多次重新计算它。(这就是动态规划的方面。)例如,在实际应用中,当我们使用这种表格方法来估算价值函数时,我们将所有状态值存储在一个由 V(s) 表示的表格中。在 Python 实现中,这可能是一个列表或一个 NumPy 数组,其索引指向不同的状态;或者,它也可以是一个 Python 字典,其中字典的键将状态映射到相应的值。

此外,我们还可以为每个状态-动作对定义一个值,这个值称为动作价值函数,表示为 。动作价值函数指的是在代理处于状态 并执行动作 时的期望回报 。将状态价值函数的定义扩展到状态-动作对,我们得到以下公式:

与最优策略的表示类似, 同样表示最优的状态价值函数和动作价值函数。

估算价值函数是强化学习(RL)方法中的一个重要组成部分。我们将在本章稍后讨论计算和估算状态价值函数和动作价值函数的不同方法。

奖励、回报和价值函数之间的区别

奖励是代理在给定环境当前状态下执行某个动作后获得的结果。换句话说,奖励是代理在执行动作以从一个状态转移到下一个状态时收到的信号。然而,请记住,并不是每个动作都会产生正或负的奖励——回想一下我们的象棋示例,只有在赢得比赛时才会获得正奖励,所有中间动作的奖励都是零。

一个状态本身有一个特定的价值,我们为它赋值以衡量这个状态的优劣——这就是价值函数的作用所在。通常,具有“高”或“好”价值的状态是那些具有高期望回报的状态,并且在给定特定策略时,很可能会产生高奖励。

例如,让我们再次考虑一个下棋的计算机。如果计算机赢得比赛,可能只有在比赛结束时才会给予正向奖励。如果计算机输掉比赛,则不会有(正向)奖励。现在,假设计算机进行了一步棋,捕获了对手的皇后,并且这对计算机没有负面影响。由于计算机只有在赢得比赛时才会获得奖励,因此通过这一捕获对手皇后的动作,计算机并不会立即获得奖励。然而,新的状态(捕获皇后后的棋盘状态)可能具有较高的价值,这可能会在比赛最终获胜时带来奖励。从直觉上讲,我们可以认为,捕获对手皇后的高价值与捕获皇后通常会导致赢得比赛这一事实相关联——因此有较高的预期回报或价值。然而,请注意,捕获对手的皇后并不总是意味着赢得比赛;因此,智能体很可能会获得正向奖励,但这并不保证。

简而言之,回报是整个回合奖励的加权和,在我们的国际象棋示例中,它将等于折扣后的最终奖励(因为只有一个奖励)。值函数是所有可能回合的期望,它基本上计算的是做出某个特定动作的“价值”平均值。

在我们直接进入一些强化学习(RL)算法之前,先简要回顾一下贝尔曼方程的推导过程,我们可以利用它来实现策略评估。

使用贝尔曼方程的动态规划

贝尔曼方程是许多强化学习算法的核心元素之一。贝尔曼方程简化了值函数的计算,避免了对多个时间步的求和,而是使用类似于计算回报的递归。

基于总回报的递归方程!,我们可以将值函数重写如下:

请注意,即时奖励r被从期望值中移除,因为它是一个常量,并且在时刻t时是已知的。

类似地,对于动作值函数,我们可以写成:

我们可以利用环境动态来通过对所有可能的下一状态!及相应的奖励r的概率求和,来计算期望:

现在,我们可以看到回报的期望!,本质上是状态值函数!。所以,我们可以将!写成!的函数:

这被称为贝尔曼方程,它将一个状态s的价值函数与其后继状态的价值函数 关联起来。这大大简化了价值函数的计算,因为它消除了沿时间轴的迭代循环。

强化学习算法

在本节中,我们将介绍一系列学习算法。我们将从动态规划开始,假设转移动态(或者环境动态,即 )是已知的。然而,在大多数强化学习(RL)问题中,情况并非如此。为了应对未知的环境动态,开发了通过与环境互动来学习的RL技术。这些技术包括MC、TD学习,以及日益流行的Q学习和深度Q学习方法。下图描述了RL算法的进展过程,从动态规划到Q学习:

在本章的后续部分,我们将逐步讲解这些RL算法。我们将从动态规划开始,然后是MC,最后是TD及其分支:基于策略的SARSA状态–动作–奖励–状态–动作)和基于离策略的Q学习。我们还将进入深度Q学习,同时构建一些实际模型。

动态规划

在本节中,我们将重点解决在以下假设下的RL问题:

  • 我们对环境动态有完整的知识;也就是说,所有的转移概率 都是已知的。

  • 智能体的状态具有马尔可夫性质,这意味着下一个动作和奖励仅依赖于当前状态以及我们在此时刻或当前时间步选择的动作。

使用马尔可夫决策过程(MDP)来描述RL问题的数学公式已在本章前面介绍。如果需要复习,请参考名为马尔可夫决策过程的数学公式的章节,其中介绍了价值函数的正式定义 ,跟随策略 ,以及通过环境动态推导出的贝尔曼方程。

我们应该强调,动态规划并不是解决RL问题的实际方法。使用动态规划的一个问题是它假设已知环境动态,但在大多数现实应用中,这通常是不可行或不现实的。然而,从教育的角度来看,动态规划有助于以简单的方式引入RL,并激发使用更先进和复杂的RL算法的兴趣。

以下小节描述的任务有两个主要目标:

  1. 获取真实的状态值函数,;此任务也称为预测任务,通过策略评估来完成。

  2. 找到最优值函数,,这是通过广义策略迭代来完成的。

策略评估——使用动态规划预测值函数

基于贝尔曼方程,当已知环境动态时,我们可以通过动态规划计算任意策略 的值函数。为了计算这个值函数,我们可以采用迭代解法,从 开始,该值对于每个状态初始化为零。然后,在每次迭代 i + 1 时,我们根据贝尔曼方程更新每个状态的值,而贝尔曼方程又基于上一迭代 i 的状态值,具体如下:

可以证明,当迭代次数趋于无穷大时, 会收敛到真实的状态值函数

另外,请注意,我们在这里不需要与环境互动。原因是我们已经准确了解了环境动态。因此,我们可以利用这些信息,轻松估算值函数。

计算完值函数后,一个显而易见的问题是,如果我们的策略仍然是随机策略,那么该值函数如何对我们有用。答案是,我们实际上可以利用这个计算出的 来改进我们的策略,正如我们接下来将看到的那样。

使用估计的值函数改进策略

现在我们已经通过遵循现有策略 计算出了值函数 ,我们希望利用 来改进现有的策略 。这意味着我们希望找到一个新的策略,,对于每个状态 s,遵循 会比使用当前策略 产生更高的值或至少相等的值。用数学语言表达,我们可以将改进策略 的目标表示为:

首先,回顾一下,策略 决定了在代理处于状态 s 时选择每个动作 a 的概率。现在,为了找到 ,确保每个状态都具有更好或至少相等的值,我们首先基于计算出的状态值(使用值函数 )为每个状态 s 和动作 a 计算动作值函数 。我们遍历所有状态,对于每个状态 s,我们比较如果选择动作 a 时,下一状态 的值。

在通过 评估所有状态-动作对后获得最高状态值后,我们可以将对应的动作与当前策略选择的动作进行比较。如果当前策略建议的动作(即 )与动作值函数建议的动作(即 )不同,那么我们可以通过重新分配动作的概率来更新策略,以匹配给出最高动作值的动作,即 。这被称为 策略改进 算法。

策略迭代

使用上一小节中描述的策略改进算法,可以证明,策略改进将严格地产生一个更好的策略,除非当前策略已经是最优的(这意味着对于每个 ,都有 )。因此,如果我们反复执行策略评估然后进行策略改进,我们就能确保找到最优策略。

请注意,这种技术被称为 广义策略迭代GPI),它在许多强化学习方法中很常见。我们将在本章的后续部分中使用 GPI 来实现蒙特卡洛和时序差分学习方法。

值迭代

我们看到,通过反复进行策略评估(计算 )和策略改进(找到 ,使得 ),我们可以达到最优策略。然而,如果我们将策略评估和策略改进的两个任务合并为一个步骤,那么可以提高效率。以下方程式更新迭代 i + 1 的值函数(用 表示),它基于选择最大化下一个状态值和即时奖励的加权和的动作()。

在这种情况下,更新后的值是通过从所有可能的动作中选择最佳动作来最大化的,而在策略评估中,更新的值是通过对所有动作的加权和来计算的。

状态值函数和动作值函数的表格估计符号

在大多数强化学习文献和教科书中,使用小写字母 来表示真实的状态值函数和真实的动作值函数,分别作为数学函数。

同时,对于实际的实现,这些值函数被定义为查找表。这些值函数的表格估计用 表示。本章中我们也将使用这种符号。

蒙特卡洛强化学习

正如我们在前一节动态规划中看到的,它依赖于一个简单的假设,即环境的动态是完全已知的。脱离动态规划方法,我们现在假设我们对环境的动态一无所知。

也就是说,我们不知道环境的状态转移概率,而是希望代理通过与环境交互来学习。使用MC方法,学习过程基于所谓的模拟经验

对于基于MC的强化学习,我们定义一个遵循概率策略的代理类,,并根据这个策略,代理在每一步采取一个动作。这会生成一个模拟情节。

早些时候,我们定义了状态值函数,其中状态的值表示从该状态开始的预期回报。在动态规划中,这一计算依赖于对环境动态的了解,即!

然而,从现在开始,我们将开发不需要环境动态的算法。基于MC的方法通过生成模拟情节来解决这个问题,代理与环境进行交互。从这些模拟情节中,我们将能够计算在该模拟情节中访问的每个状态的平均回报。

使用MC进行状态值函数估计

在生成一组情节后,对于每个状态s,考虑所有通过状态s的情节集合来计算状态s的值。假设使用查找表来获取与值函数对应的值,。MC更新用于估计值函数,基于从第一次访问状态s开始的情节中获得的总回报。这个算法被称为首次访问蒙特卡罗值预测。

使用MC进行动作值函数估计

当环境的动态已知时,我们可以通过向前看一步来推断出动作值函数,找到给出最大值的动作,正如动态规划部分所示。然而,如果环境动态未知,这是不可行的。

为了解决这个问题,我们可以扩展算法,用于估计首次访问MC状态值预测。例如,我们可以使用动作值函数来计算每个状态-动作对的估计回报。为了获得这个估计回报,我们考虑访问每个状态-动作对(sa),这指的是访问状态s并采取动作a

然而,出现了一个问题,因为有些动作可能永远不会被选择,从而导致探索不足。有几种方法可以解决这个问题。最简单的方法叫做探索性起始,它假设每个状态-动作对在情节开始时有一个非零的概率。

解决探索不足问题的另一种方法叫做 -贪婪策略,将在下一节关于策略改进的内容中讨论。

使用 MC 控制找到最优策略

MC 控制是指优化过程以改进策略。类似于前一节中的策略迭代方法(动态规划),我们可以反复在策略评估和策略改进之间交替,直到得到最优策略。因此,从一个随机策略开始,,策略评估和策略改进交替进行的过程可以表示如下:

策略改进——从动作-价值函数计算贪婪策略

给定一个动作-价值函数,q(s, a),我们可以生成如下的贪婪(确定性)策略:

为了避免探索不足问题,并考虑到之前讨论的未访问的状态-动作对,我们可以让非最优动作有一个小的概率()被选择。这被称为 -贪婪策略,根据该策略,所有在状态 s 下的非最优动作都有一个最小的 概率被选择(而不是 0),而最优动作的概率是 (而不是 1)。

时序差分学习

到目前为止,我们已经看到了两种基本的强化学习技术,动态规划和基于 MC 的学习。回想一下,动态规划依赖于对环境动态的完全准确了解,而基于 MC 的方法则通过模拟经验进行学习。在本节中,我们将介绍第三种强化学习方法——TD 学习,它可以被视为对基于 MC 的强化学习方法的改进或扩展。

类似于 MC 技术,TD 学习也是基于经验学习,因此不需要了解环境动态和转移概率。TD 和 MC 技术的主要区别在于,在 MC 中,我们必须等到一集结束才能计算总回报。

然而,在 TD 学习中,我们可以利用一些已经学习到的属性,在一集结束之前就更新估计值。这被称为 引导(在强化学习的上下文中,术语引导不应与我们在第7章 结合不同模型进行集成学习 中使用的自举估计混淆)。

类似于动态规划方法和基于 MC 的学习,我们将考虑两个任务:估计价值函数(也叫做价值预测)和改进策略(也叫做控制任务)。

TD 预测

让我们首先回顾一下MC的值预测。在每个回合结束时,我们能够为每个时间步t估计回报!。因此,我们可以按如下方式更新已访问状态的估计值:

在这里,用作目标回报来更新估计值,而是添加到我们当前估计值中的修正项。值是表示学习率的超参数,在学习过程中保持不变。

注意,在MC中,修正项使用的是实际回报!,这在回合结束之前是未知的。为了澄清这一点,我们可以将实际回报!重命名为!,其中下标表示这是在时间步t时获得的回报,并且考虑了从时间步t到最终时间步T期间发生的所有事件。

在TD学习中,我们将实际回报!替换为新的目标回报!,这大大简化了值函数的更新。基于TD学习的更新公式如下:

在这里,目标回报!使用的是观察到的奖励!和下一步的估计值。注意MC与TD的区别。在MC中,直到本回合结束后才能获得,因此我们需要执行尽可能多的步骤才能到达那里。相反,在TD中,我们只需前进一步即可获得目标回报。这也称为TD(0)。

此外,TD(0)算法可以推广到所谓的n步TD算法,它包括更多的未来步骤——更精确地说,是n个未来步骤的加权和。如果我们定义n = 1,那么n步TD过程与上一段中描述的TD(0)相同。但是,如果,那么n步TD算法将与MC算法相同。n步TD的更新规则如下:

定义为:

MC与TD:哪种方法收敛更快?

尽管这个问题的精确答案仍然未知,但在实践中,实验证明TD收敛的速度通常比MC快。如果你感兴趣的话,可以在《强化学习:导论》一书中,Richard S. Sutton和Andrew G. Barto为你提供更多关于MC和TD收敛性的信息。

现在我们已经讲解了使用TD算法的预测任务,我们可以继续讨论控制任务。我们将讨论两种TD控制算法:基于策略的控制和离策略的控制。在这两种情况下,我们都使用了在动态规划和MC算法中使用的GPI。在基于策略的TD控制中,价值函数是根据智能体遵循的相同策略中的动作来更新的,而在离策略算法中,价值函数是根据当前策略以外的动作来更新的。

基于策略的TD控制(SARSA)

为了简化起见,我们只考虑一步TD算法,或者说TD(0)。然而,基于策略的TD控制算法可以很容易地推广到 n 步TD。我们将从扩展预测公式开始,定义状态值函数,并用它来描述动作值函数。为此,我们使用查找表,即一个二维数组 ,它表示每个状态-动作对的动作值函数。在这种情况下,我们将有以下内容:

这个算法通常称为SARSA,指的是更新公式中使用的五元组

正如我们在前面的部分描述动态规划和MC算法时所看到的,我们可以使用GPI框架,并从随机策略开始,我们可以反复估计当前策略的动作值函数,然后使用基于当前动作值函数的 -贪婪策略来优化策略。

离策略TD控制(Q学习)

我们看到,在使用前面提到的基于策略的TD控制算法时,如何估计动作值函数是基于在模拟回合中使用的策略。在更新了动作值函数后,我们通过采取具有更高值的动作来执行单独的策略改进步骤。

一种替代(且更好的)方法是将这两步结合起来。换句话说,假设智能体遵循策略 ,生成一个当前过渡五元组 的回合。与其使用智能体采取的 动作值来更新动作值函数,我们可以即使该动作没有被智能体根据当前策略实际选择,也能找到最佳动作。(这就是为什么它被认为是 off-policy 算法的原因。)

为了做到这一点,我们可以修改更新规则,通过变化下一状态中不同动作的最大Q值来进行考虑。更新Q值的修改公式如下:

我们鼓励你将此处的更新规则与SARSA算法的更新规则进行比较。正如你所看到的,我们在下一个状态中找到最佳动作 ,并将其用于修正项,以更新我们对 的估计。

为了更好地理解这些材料,在接下来的部分,我们将展示如何实现用于解决网格世界问题的Q学习算法。

实现我们的第一个强化学习算法

在这一部分,我们将介绍如何实现Q学习算法来解决网格世界问题。为此,我们使用OpenAI Gym工具包。

介绍OpenAI Gym工具包

OpenAI Gym是一个专门的工具包,用于促进强化学习模型的开发。OpenAI Gym提供了多个预定义的环境。一些基础示例包括CartPole和MountainCar,任务分别是保持杆子平衡和让小车爬坡,正如其名称所示。还有许多高级机器人环境,用于训练机器人去取、推、或获取桌子上的物品,或者训练机器人手去定位块、球或笔。此外,OpenAI Gym还提供了一个便捷的统一框架,供开发新环境使用。更多信息可以在其官网找到:https://gym.openai.com/

要跟随接下来的OpenAI Gym代码示例,你需要安装gym库,可以通过pip轻松安装:

> pip install gym 

如果你需要额外的安装帮助,请参考官方安装指南:https://gym.openai.com/docs/#installation

使用OpenAI Gym中的现有环境

为了练习使用Gym环境,让我们从OpenAI Gym中创建一个现有的CartPole-v1环境。在这个示例环境中,有一个杆子附着在一个可以水平移动的小车上,如下图所示:

杆子的运动受物理定律的控制,强化学习代理的目标是学习如何移动小车以稳定杆子,并防止其倾斜到任一侧。

现在,让我们从强化学习的角度来看一下CartPole环境的一些特性,比如其状态(或观测)空间、动作空间,以及如何执行一个动作:

>>> import gym
>>> env = gym.make('CartPole-v1')
>>> env.observation_space
Box(4,)
>>> env.action_space
Discrete(2) 

在之前的代码中,我们为CartPole问题创建了一个环境。该环境的观测空间是Box(4,),表示一个四维空间,对应四个实数值:小车的位置、小车的速度、杆子的角度和杆子尖端的速度。动作空间是一个离散空间,Discrete(2),有两个选择:将小车推向左边或右边。

我们之前通过调用gym.make('CartPole-v1')创建的环境对象env,有一个reset()方法,我们可以在每次开始一个新回合之前使用它重新初始化环境。调用reset()方法将基本上设置杆子的初始状态():

>>> env.reset()
array([-0.03908273, -0.00837535,  0.03277162, -0.0207195 ]) 

env.reset()方法调用返回的数组中的值表示小车的初始位置为–0.039,速度为–0.008,杆子的角度为0.033弧度,而其尖端的角速度为–0.021。调用reset()方法时,这些值会初始化为在[–0.05, 0.05]范围内的均匀分布的随机值。

重置环境后,我们可以通过选择一个动作并将该动作传递给step()方法来与环境进行交互:

>>> env.step(action=0)
(array([-0.03925023, -0.20395158,  0.03235723,  0.28212046]), 1.0, False, {})
>>> env.step(action=1)
(array([-0.04332927, -0.00930575,  0.03799964, -0.00018409]), 1.0, False, {}) 

通过之前的两个命令,env.step(action=0)env.step(action=1),我们分别将小车推向左侧(action=0)和右侧(action=1)。根据选择的动作,小车及其杆子会按照物理定律运动。每次调用env.step()时,它会返回一个包含四个元素的元组:

  • 新状态(或观测)的数组

  • 奖励(类型为float的标量值)

  • 终止标志(TrueFalse

  • 包含辅助信息的Python字典

env对象也有一个render()方法,我们可以在每个步骤(或一系列步骤)之后执行它,以便通过时间可视化环境以及杆子和小车的运动。

当杆子的角度相对于虚拟垂直轴超过12度(无论哪一侧)时,或者当小车的位置距离中心位置超过2.4个单位时,剧集终止。在这个示例中定义的奖励是最大化小车和杆子在有效区域内稳定的时间——换句话说,通过最大化剧集的长度可以最大化总奖励(即回报)。

网格世界示例

在将CartPole环境作为使用OpenAI Gym工具包的热身练习之后,我们将切换到另一个环境。我们将使用一个网格世界示例,这是一个简化的环境,具有m行和n列。假设m = 4且n = 6,我们可以总结这个环境,如下图所示:

在这个环境中,有30个不同的可能状态。其中四个状态是终止状态:在状态16处有一个金锅,状态10、15和22处分别有三个陷阱。落入这四个终止状态中的任何一个都会结束剧集,但金锅和陷阱状态有所不同。落入金锅状态会获得正奖励+1,而进入陷阱状态则会获得负奖励–1。所有其他状态的奖励为0。代理始终从状态0开始。因此,每次重置环境时,代理都会回到状态0。动作空间由四个方向组成:向上、向下、向左和向右。

当代理位于网格的外边界时,选择一个会导致离开网格的动作不会改变状态。

接下来,我们将看到如何使用OpenAI Gym包在Python中实现这个环境。

在OpenAI Gym中实现网格世界环境

在通过OpenAI Gym实验网格世界环境时,强烈建议使用脚本编辑器或IDE,而不是交互式执行代码。

首先,我们创建一个新的Python脚本,命名为gridworld_env.py,然后导入必要的包和我们定义的两个辅助函数,这些函数用于构建环境的可视化。

为了将环境渲染以便进行可视化,OpenAI Gym库使用了Pyglet库,并为我们的方便提供了封装类和函数。在以下代码示例中,我们将使用这些封装类来可视化网格世界环境。关于这些封装类的更多信息,可以参考:

https://github.com/openai/gym/blob/master/gym/envs/classic_control/rendering.py

以下代码示例使用了这些封装类:

## Script: gridworld_env.py
import numpy as np
from gym.envs.toy_text import discrete
from collections import defaultdict
import time
import pickle
import os
from gym.envs.classic_control import rendering
CELL_SIZE = 100
MARGIN = 10
def get_coords(row, col, loc='center'):
    xc = (col+1.5) * CELL_SIZE
    yc = (row+1.5) * CELL_SIZE
    if loc == 'center':
        return xc, yc
    elif loc == 'interior_corners':
        half_size = CELL_SIZE//2 - MARGIN
        xl, xr = xc - half_size, xc + half_size
        yt, yb = xc - half_size, xc + half_size
        return [(xl, yt), (xr, yt), (xr, yb), (xl, yb)]
    elif loc == 'interior_triangle':
        x1, y1 = xc, yc + CELL_SIZE//3
        x2, y2 = xc + CELL_SIZE//3, yc - CELL_SIZE//3
        x3, y3 = xc - CELL_SIZE//3, yc - CELL_SIZE//3
        return [(x1, y1), (x2, y2), (x3, y3)]
def draw_object(coords_list):
    if len(coords_list) == 1: # -> circle
        obj = rendering.make_circle(int(0.45*CELL_SIZE))
        obj_transform = rendering.Transform()
        obj.add_attr(obj_transform)
        obj_transform.set_translation(*coords_list[0])
        obj.set_color(0.2, 0.2, 0.2) # -> black
    elif len(coords_list) == 3: # -> triangle
        obj = rendering.FilledPolygon(coords_list)
        obj.set_color(0.9, 0.6, 0.2) # -> yellow
    elif len(coords_list) > 3: # -> polygon
        obj = rendering.FilledPolygon(coords_list)
        obj.set_color(0.4, 0.4, 0.8) # -> blue
    return obj 

第一个辅助函数get_coords()返回我们将用于标注网格世界环境的几何形状的坐标,例如用三角形表示金币,或者用圆形表示陷阱。坐标列表传递给draw_object(),该函数根据输入坐标列表的长度决定绘制圆形、三角形或多边形。

现在,我们可以定义网格世界环境。在同一个文件(gridworld_env.py)中,我们定义了一个名为GridWorldEnv的类,该类继承自OpenAI Gym的DiscreteEnv类。这个类最重要的功能是构造方法__init__(),在该方法中我们定义了动作空间,指定了每个动作的作用,并确定了终止状态(金币和陷阱),具体如下:

class GridWorldEnv(discrete.DiscreteEnv):
    def __init__(self, num_rows=4, num_cols=6, delay=0.05):
        self.num_rows = num_rows
        self.num_cols = num_cols
        self.delay = delay
        move_up = lambda row, col: (max(row-1, 0), col)
        move_down = lambda row, col: (min(row+1, num_rows-1), col)
        move_left = lambda row, col: (row, max(col-1, 0))
        move_right = lambda row, col: (
            row, min(col+1, num_cols-1))
        self.action_defs={0: move_up, 1: move_right,
                          2: move_down, 3: move_left}
        ## Number of states/actions
        nS = num_cols*num_rows
        nA = len(self.action_defs)
        self.grid2state_dict={(s//num_cols, s%num_cols):s
                              for s in range(nS)}
        self.state2grid_dict={s:(s//num_cols, s%num_cols)
                              for s in range(nS)}
        ## Gold state
        gold_cell = (num_rows//2, num_cols-2)

        ## Trap states
        trap_cells = [((gold_cell[0]+1), gold_cell[1]),
                       (gold_cell[0], gold_cell[1]-1),
                       ((gold_cell[0]-1), gold_cell[1])]
        gold_state = self.grid2state_dict[gold_cell]
        trap_states = [self.grid2state_dict[(r, c)]
                       for (r, c) in trap_cells]
        self.terminal_states = [gold_state] + trap_states
        print(self.terminal_states)
        ## Build the transition probability
        P = defaultdict(dict)
        for s in range(nS):
            row, col = self.state2grid_dict[s]
            P[s] = defaultdict(list)
            for a in range(nA):
                action = self.action_defs[a]
                next_s = self.grid2state_dict[action(row, col)]

                ## Terminal state
                if self.is_terminal(next_s):
                    r = (1.0 if next_s == self.terminal_states[0]
                         else -1.0)
                else:
                    r = 0.0
                if self.is_terminal(s):
                    done = True
                    next_s = s
                else:
                    done = False
                P[s][a] = [(1.0, next_s, r, done)]
        ## Initial state distribution
        isd = np.zeros(nS)
        isd[0] = 1.0
        super(GridWorldEnv, self).__init__(nS, nA, P, isd)
        self.viewer = None
        self._build_display(gold_cell, trap_cells)
    def is_terminal(self, state):
        return state in self.terminal_states
    def _build_display(self, gold_cell, trap_cells):
        screen_width = (self.num_cols+2) * CELL_SIZE
        screen_height = (self.num_rows+2) * CELL_SIZE
        self.viewer = rendering.Viewer(screen_width, 
                                       screen_height)
        all_objects = []
        ## List of border points' coordinates
        bp_list = [
            (CELL_SIZE-MARGIN, CELL_SIZE-MARGIN),
            (screen_width-CELL_SIZE+MARGIN, CELL_SIZE-MARGIN),
            (screen_width-CELL_SIZE+MARGIN,
             screen_height-CELL_SIZE+MARGIN),
            (CELL_SIZE-MARGIN, screen_height-CELL_SIZE+MARGIN)
        ]
        border = rendering.PolyLine(bp_list, True)
        border.set_linewidth(5)
        all_objects.append(border)
        ## Vertical lines
        for col in range(self.num_cols+1):
            x1, y1 = (col+1)*CELL_SIZE, CELL_SIZE
            x2, y2 = (col+1)*CELL_SIZE,\
                     (self.num_rows+1)*CELL_SIZE
            line = rendering.PolyLine([(x1, y1), (x2, y2)], False)
            all_objects.append(line)

        ## Horizontal lines
        for row in range(self.num_rows+1):
            x1, y1 = CELL_SIZE, (row+1)*CELL_SIZE
            x2, y2 = (self.num_cols+1)*CELL_SIZE,\
                     (row+1)*CELL_SIZE
            line=rendering.PolyLine([(x1, y1), (x2, y2)], False)
            all_objects.append(line)

        ## Traps: --> circles
        for cell in trap_cells:
            trap_coords = get_coords(*cell, loc='center')
            all_objects.append(draw_object([trap_coords]))

        ## Gold:  --> triangle
        gold_coords = get_coords(*gold_cell,
                                 loc='interior_triangle')
        all_objects.append(draw_object(gold_coords))
        ## Agent --> square or robot
        if (os.path.exists('robot-coordinates.pkl') and
                CELL_SIZE==100):
            agent_coords = pickle.load(
                open('robot-coordinates.pkl', 'rb'))
            starting_coords = get_coords(0, 0, loc='center')
            agent_coords += np.array(starting_coords)
        else:
            agent_coords = get_coords(
                0, 0, loc='interior_corners')
        agent = draw_object(agent_coords)
        self.agent_trans = rendering.Transform()
        agent.add_attr(self.agent_trans)
        all_objects.append(agent)
        for obj in all_objects:
            self.viewer.add_geom(obj)
    def render(self, mode='human', done=False):
        if done:
            sleep_time = 1
        else:
            sleep_time = self.delay
        x_coord = self.s % self.num_cols
        y_coord = self.s // self.num_cols
        x_coord = (x_coord+0) * CELL_SIZE
        y_coord = (y_coord+0) * CELL_SIZE
        self.agent_trans.set_translation(x_coord, y_coord)
        rend = self.viewer.render(
             return_rgb_array=(mode=='rgb_array'))
        time.sleep(sleep_time)
        return rend
    def close(self):
        if self.viewer:
            self.viewer.close()
            self.viewer = None 

这段代码实现了网格世界环境,我们可以基于此创建该环境的实例。然后,我们可以像在CartPole示例中一样与之互动。实现的类GridWorldEnv继承了reset()等方法用于重置状态,以及step()方法用于执行动作。实现的具体细节如下:

  • 我们使用lambda函数定义了四个不同的动作:move_up()move_down()move_left()move_right()

  • NumPy数组isd保存了起始状态的概率,因此在调用reset()方法(来自父类)时,系统会根据该分布随机选择一个状态。由于我们总是从状态0(网格世界的左下角)开始,因此我们将状态0的概率设置为1.0,其他29个状态的概率设置为0.0。

  • 在Python字典P中定义的转移概率,决定了在选择一个动作时,从一个状态到另一个状态的概率。这使得我们可以拥有一个概率环境,其中执行一个动作可能会有不同的结果,取决于环境的随机性。为了简单起见,我们只使用一个结果,那就是根据选择的动作改变状态。最终,这些转移概率将由env.step()函数用于确定下一个状态。

  • 此外,函数_build_display()将设置环境的初始可视化,而render()函数将展示代理的运动过程。

请注意,在学习过程中,我们并不知道转移概率,目标是通过与环境的互动来学习。因此,我们在类定义之外无法访问P

现在,我们可以通过创建一个新的环境并通过在每个状态下采取随机动作来可视化一个随机的回合,来测试这个实现。在同一个Python脚本(gridworld_env.py)的末尾加入以下代码,然后执行脚本:

if __name__ == '__main__':
    env = GridWorldEnv(5, 6)
    for i in range(1):
        s = env.reset()
        env.render(mode='human', done=False)
        while True:
            action = np.random.choice(env.nA)
            res = env.step(action)
            print('Action  ', env.s, action, ' -> ', res)
            env.render(mode='human', done=res[2])
            if res[2]:
                break
    env.close() 

执行脚本后,你应该会看到一个网格世界环境的可视化,如下图所示:

Une image contenant capture d’écran  Description générée automatiquement

使用Q-learning解决网格世界问题

在关注RL算法的理论与开发过程,并通过OpenAI Gym工具包设置环境之后,我们将实现当前最流行的RL算法——Q-learning。为此,我们将使用之前在脚本gridworld_env.py中实现的网格世界示例。

实现Q-learning算法

现在,我们创建一个新的脚本并命名为agent.py。在这个agent.py脚本中,我们定义了一个用于与环境交互的代理,具体如下:

## Script: agent.py
from collections import defaultdict
import numpy as np
class Agent(object):
    def __init__(
            self, env,
            learning_rate=0.01,
            discount_factor=0.9,
            epsilon_greedy=0.9,
            epsilon_min=0.1,
            epsilon_decay=0.95):
        self.env = env
        self.lr = learning_rate
        self.gamma = discount_factor
        self.epsilon = epsilon_greedy
        self.epsilon_min = epsilon_min
        self.epsilon_decay = epsilon_decay
        ## Define the q_table
        self.q_table = defaultdict(lambda: np.zeros(self.env.nA))
    def choose_action(self, state):
        if np.random.uniform() < self.epsilon:
            action = np.random.choice(self.env.nA)
        else:
            q_vals = self.q_table[state]
            perm_actions = np.random.permutation(self.env.nA)
            q_vals = [q_vals[a] for a in perm_actions]
            perm_q_argmax = np.argmax(q_vals)
            action = perm_actions[perm_q_argmax]
        return action
    def _learn(self, transition):
        s, a, r, next_s, done = transition
        q_val = self.q_table[s][a]
        if done:
            q_target = r
        else:
            q_target = r + self.gamma*np.max(self.q_table[next_s])
        ## Update the q_table
        self.q_table[s][a] += self.lr * (q_target - q_val)
        ## Adjust the epislon
        self._adjust_epsilon()
    def _adjust_epsilon(self):
        if self.epsilon > self.epsilon_min:
            self.epsilon *= self.epsilon_decay 

__init__()构造函数设置了各种超参数,如学习率、折扣因子(),以及-贪婪策略的参数。最初,我们从较高的值开始,但方法_adjust_epsilon()会逐步将其减少,直到达到最小值。方法choose_action()根据-贪婪策略选择一个动作。通过选择一个随机均匀数,来决定动作是应该随机选择还是根据动作价值函数进行选择。方法_learn()实现了Q-learning算法的更新规则。它接收每个过渡的元组,其中包括当前状态(s)、选择的动作(a)、观察到的奖励(r)、下一个状态(s'),以及一个标志来判断是否已达到回合结束。若该标志表示回合结束,则目标值等于观察到的奖励(r);否则,目标值为

最后,为了进行下一步操作,我们创建了一个新的脚本qlearning.py,将所有内容整合在一起,并使用Q-learning算法训练代理。

在下面的代码中,我们定义了一个函数run_qlearning(),它实现了Q-learning算法,通过调用代理的_choose_action()方法并执行环境来模拟一个回合。然后,过渡元组被传递给代理的_learn()方法,用于更新动作价值函数。此外,为了监控学习过程,我们还存储了每个回合的最终奖励(可能是-1或+1),以及回合的长度(代理从回合开始到结束所采取的动作数)。

然后使用函数plot_learning_history()绘制奖励和动作次数的列表:

## Script: qlearning.py
from gridworld_env import GridWorldEnv
from agent import Agent
from collections import namedtuple
import matplotlib.pyplot as plt
import numpy as np
np.random.seed(1)
Transition = namedtuple(
    'Transition', ('state', 'action', 'reward',
                   'next_state', 'done'))
def run_qlearning(agent, env, num_episodes=50):
    history = []
    for episode in range(num_episodes):
        state = env.reset()
        env.render(mode='human')
        final_reward, n_moves = 0.0, 0
        while True:
            action = agent.choose_action(state)
            next_s, reward, done, _ = env.step(action)
            agent._learn(Transition(state, action, reward,
                                    next_s, done))
            env.render(mode='human', done=done)
            state = next_s
            n_moves += 1
            if done:
                break
            final_reward = reward
        history.append((n_moves, final_reward))
        print('Episode %d: Reward %.1f #Moves %d'
              % (episode, final_reward, n_moves))
    return history
def plot_learning_history(history):
    fig = plt.figure(1, figsize=(14, 10))
    ax = fig.add_subplot(2, 1, 1)
    episodes = np.arange(len(history))
    moves = np.array([h[0] for h in history])
    plt.plot(episodes, moves, lw=4,
             marker='o', markersize=10)
    ax.tick_params(axis='both', which='major', labelsize=15)
    plt.xlabel('Episodes', size=20)
    plt.ylabel('# moves', size=20)
    ax = fig.add_subplot(2, 1, 2)
    rewards = np.array([h[1] for h in history])
    plt.step(episodes, rewards, lw=4)
    ax.tick_params(axis='both', which='major', labelsize=15)
    plt.xlabel('Episodes', size=20)
    plt.ylabel('Final rewards', size=20)
    plt.savefig('q-learning-history.png', dpi=300)
    plt.show()
if __name__ == '__main__':
    env = GridWorldEnv(num_rows=5, num_cols=6)
    agent = Agent(env)
    history = run_qlearning(agent, env)
    env.close()
    plot_learning_history(history) 

执行此脚本将运行Q-learning程序50个回合。代理的行为将被可视化,您会看到在学习过程的开始阶段,代理大多会进入陷阱状态。但随着时间的推移,它从失败中学习,并最终找到金状态(例如,第7回合第一次找到)。下图展示了代理的动作次数和奖励:

前面图中绘制的学习历史表明,经过30个回合后,代理学会了找到一条通往金状态的短路径。因此,30回合之后的每个回合长度基本相同,只有由于-贪婪策略产生的微小偏差。

深度Q学习概述

在前面的代码中,我们展示了一个针对网格世界示例的流行Q-learning算法的实现。该示例包含了一个大小为30的离散状态空间,在这里,将Q值存储在Python字典中就足够了。

然而,我们应该注意到,有时状态的数量可能非常大,甚至几乎是无限大的。此外,我们可能会处理连续的状态空间,而不是离散的状态。并且,某些状态在训练期间可能根本不会被访问,这在将智能体泛化到处理这些未见过的状态时可能会带来问题。

为了解决这些问题,我们不再像图像 中那样以表格形式表示值函数,或者对于动作值函数,我们使用 函数逼近 方法。在这里,我们定义了一个参数化函数 ,它可以学习逼近真实的值函数,即 ,其中 是一组输入特征(或“特征化”状态)。

当逼近器函数 是深度神经网络(DNN)时,得到的模型称为 深度 Q 网络DQN)。在训练 DQN 模型时,权重会根据 Q-learning 算法进行更新。下图展示了一个 DQN 模型的示例,其中状态表示为传递到第一层的特征:

现在,让我们看看如何使用 深度 Q-learning 算法训练 DQN。总体来说,主要方法与表格 Q-learning 方法非常相似。主要区别在于,我们现在有一个多层神经网络来计算动作值。

根据 Q-learning 算法训练 DQN 模型

在本节中,我们描述了使用 Q-learning 算法训练 DQN 模型的过程。深度 Q-learning 方法要求我们对之前实现的标准 Q-learning 方法进行一些修改。

其中一个修改是在智能体的 choose_action() 方法中,在上一节 Q-learning 的代码中,这个方法仅仅是访问存储在字典中的动作值。现在,这个函数应该被修改为执行神经网络模型的前向传播,以计算动作值。

深度 Q-learning 算法所需的其他修改在以下两个小节中描述。

回放记忆

使用之前的表格方法进行 Q-learning 时,我们可以更新特定状态-动作对的值,而不会影响其他值。然而,现在我们使用神经网络模型来逼近 q(s, a),更新一个状态-动作对的权重很可能会影响其他状态的输出。在使用随机梯度下降法训练神经网络进行监督任务(例如分类任务)时,我们会使用多个训练周期多次迭代训练数据,直到收敛。

这在 Q-learning 中不可行,因为在训练过程中,回合会发生变化,结果是,一些在训练早期访问的状态在后期变得不太可能被再次访问。

此外,另一个问题是,当我们训练神经网络时,通常假设训练示例是 IID独立同分布)。然而,从智能体的一个回合中采样的样本并不是 IID,因为它们显然形成了一个过渡序列。

为了解决这些问题,当智能体与环境交互并生成一个过渡五元组 时,我们将大量(但有限)这样的过渡存储在一个记忆缓冲区中,这通常称为 回放记忆。每次新的交互(即智能体选择一个动作并在环境中执行)之后,产生的新过渡五元组会被添加到记忆中。

为了保持记忆的大小有限,最旧的过渡将从记忆中移除(例如,如果它是一个 Python 列表,我们可以使用 pop(0) 方法移除列表中的第一个元素)。然后,从记忆缓冲区中随机选择一个小批量的示例,用于计算损失并更新网络参数。下图说明了这个过程:

实现回放记忆

回放记忆可以使用 Python 列表实现,每次向列表中添加新元素时,我们需要检查列表的大小,并在必要时调用 pop(0)

或者,我们可以使用 Python collections 库中的 deque 数据结构,它允许我们指定一个可选参数 max_len。通过指定 max_len 参数,我们将获得一个有界的双端队列。因此,当队列已满时,添加新元素会自动移除队列中的一个元素。

请注意,这比使用 Python 列表更高效,因为使用 pop(0) 从列表中移除第一个元素的时间复杂度是 O(n),而双端队列的运行时复杂度是 O(1)。你可以通过官方文档了解更多关于双端队列的实现。

https://docs.python.org/3.7/library/collections.html#collections.deque

确定计算损失的目标值

从表格 Q-learning 方法中需要做的另一个变更是如何调整更新规则来训练 DQN 模型参数。回想一下,批量示例中存储的过渡五元组 T 包含

如下图所示,我们执行了DQN模型的两次前向传播。第一次前向传播使用当前状态的特征(!)。然后,第二次前向传播使用下一个状态的特征(!)。因此,我们将分别从第一次和第二次前向传播中获得估计的动作值,!和!。 (在这里,这个!符号表示的是在!中所有动作的Q值向量。)从过渡五元组中,我们知道智能体选择了动作a

因此,根据Q学习算法,我们需要更新与状态-动作对对应的动作值!,其标量目标值为!。我们将不再形成标量目标值,而是创建一个目标动作值向量,该向量保留了其他动作的动作值,!,如下面的图所示:

我们将其视为一个回归问题,使用以下三个量:

  • 当前预测值,!

  • 如上所述的目标值向量

  • 标准均方误差(MSE)损失函数

结果是,除了a以外,所有动作的损失都为零。最终,计算出的损失将通过反向传播更新网络参数。

实现深度Q学习算法

最后,我们将使用所有这些技术来实现深度Q学习算法。这次,我们使用的是前面介绍的OpenAI Gym环境中的CartPole环境。回想一下,CartPole环境的状态空间是连续的,大小为4。在接下来的代码中,我们定义了一个类DQNAgent,该类构建了模型并指定了各种超参数。

与之前基于表格Q学习的智能体相比,此类多了两个方法。方法remember()将会把新的过渡五元组添加到记忆缓冲区,而方法replay()会创建一个小批量的示例过渡,并将其传递给_learn()方法以更新网络的权重参数:

import gym
import numpy as np
import tensorflow as tf
import random
import matplotlib.pyplot as plt
from collections import namedtuple
from collections import deque
np.random.seed(1)
tf.random.set_seed(1)
Transition = namedtuple(
            'Transition', ('state', 'action', 'reward',
                           'next_state', 'done'))
class DQNAgent:
    def __init__(
            self, env, discount_factor=0.95,
            epsilon_greedy=1.0, epsilon_min=0.01,
            epsilon_decay=0.995, learning_rate=1e-3,
            max_memory_size=2000):
        self.enf = env
        self.state_size = env.observation_space.shape[0]
        self.action_size = env.action_space.n
        self.memory = deque(maxlen=max_memory_size)
        self.gamma = discount_factor
        self.epsilon = epsilon_greedy
        self.epsilon_min = epsilon_min
        self.epsilon_decay = epsilon_decay
        self.lr = learning_rate
        self._build_nn_model()
    def _build_nn_model(self, n_layers=3):
        self.model = tf.keras.Sequential()

        ## Hidden layers
        for n in range(n_layers-1):
            self.model.add(tf.keras.layers.Dense(
                units=32, activation='relu'))
            self.model.add(tf.keras.layers.Dense(
                units=32, activation='relu'))
        ## Last layer
        self.model.add(tf.keras.layers.Dense(
            units=self.action_size))
        ## Build & compile model
        self.model.build(input_shape=(None, self.state_size))
        self.model.compile(
            loss='mse',
            optimizer=tf.keras.optimizers.Adam(lr=self.lr))
    def remember(self, transition):
        self.memory.append(transition)
    def choose_action(self, state):
        if np.random.rand() <= self.epsilon:
            return random.randrange(self.action_size)
        q_values = self.model.predict(state)[0]
        return np.argmax(q_values)  # returns action
    def _learn(self, batch_samples):
        batch_states, batch_targets = [], []
        for transition in batch_samples:
            s, a, r, next_s, done = transition
            if done:
                target = r
            else:
                target = (r +
                    self.gamma * np.amax(
                        self.model.predict(next_s)[0]
                    )
                )
            target_all = self.model.predict(s)[0]
            target_all[a] = target
            batch_states.append(s.flatten())
            batch_targets.append(target_all)
            self._adjust_epsilon()
        return self.model.fit(x=np.array(batch_states),
                              y=np.array(batch_targets),
                              epochs=1,
                              verbose=0)
    def _adjust_epsilon(self):
        if self.epsilon > self.epsilon_min:
            self.epsilon *= self.epsilon_decay
    def replay(self, batch_size):
        samples = random.sample(self.memory, batch_size)
        history = self._learn(samples)
        return history.history['loss'][0] 

最后,使用以下代码,我们训练模型200个回合,并在结束时使用plot_learning_history()函数可视化学习历史:

def plot_learning_history(history):
    fig = plt.figure(1, figsize=(14, 5))
    ax = fig.add_subplot(1, 1, 1)
    episodes = np.arange(len(history[0]))+1
    plt.plot(episodes, history[0], lw=4,
             marker='o', markersize=10)
    ax.tick_params(axis='both', which='major', labelsize=15)
    plt.xlabel('Episodes', size=20)
    plt.ylabel('# Total Rewards', size=20)
    plt.show()
## General settings
EPISODES = 200
batch_size = 32
init_replay_memory_size = 500
if __name__ == '__main__':
    env = gym.make('CartPole-v1')
    agent = DQNAgent(env)
    state = env.reset()
    state = np.reshape(state, [1, agent.state_size])
    ## Filling up the replay-memory
    for i in range(init_replay_memory_size):
        action = agent.choose_action(state)
        next_state, reward, done, _ = env.step(action)
        next_state = np.reshape(next_state, [1, agent.state_size])
        agent.remember(Transition(state, action, reward,
                                  next_state, done))
        if done:
            state = env.reset()
            state = np.reshape(state, [1, agent.state_size])
        else:
            state = next_state
    total_rewards, losses = [], []
    for e in range(EPISODES):
        state = env.reset()
        if e % 10 == 0:
            env.render()
        state = np.reshape(state, [1, agent.state_size])
        for i in range(500):
            action = agent.choose_action(state)
            next_state, reward, done, _ = env.step(action)
            next_state = np.reshape(next_state,
                                    [1, agent.state_size])
            agent.remember(Transition(state, action, reward,
                                      next_state, done))
            state = next_state
            if e % 10 == 0:
                env.render()
            if done:
                total_rewards.append(i)
                print('Episode: %d/%d, Total reward: %d'
                      % (e, EPISODES, i))
                break
            loss = agent.replay(batch_size)
            losses.append(loss)
    plot_learning_history(total_rewards) 

在训练智能体200个回合后,我们发现它确实学会了随着时间的推移增加总奖励,如下图所示:

请注意,某一回合中获得的总奖励等于智能体能够保持杆平衡的时间。图中的学习历史显示,在大约30个回合后,智能体学会了如何平衡杆并将其保持超过200个时间步。

章节和书籍总结

在本章中,我们介绍了强化学习(RL)中的基本概念,从最基础的内容开始,探讨了强化学习如何支持在复杂环境中做出决策。

我们学习了智能体与环境的互动和马尔科夫决策过程(MDP),并考虑了解决强化学习问题的三种主要方法:动态规划、MC学习和TD学习。我们讨论了动态规划算法假设已知完整的环境动态,而这一假设通常不适用于大多数实际问题。

然后,我们看到基于MC和TD的算法通过允许智能体与环境互动并生成模拟经验来学习。在讨论了相关理论之后,我们实现了Q学习算法,作为TD算法的一个离策略子类别,用于解决网格世界示例。最后,我们介绍了函数逼近的概念,特别是深度Q学习,它可以用于处理具有大规模或连续状态空间的问题。

我们希望你喜欢《Python机器学习》的最后一章,以及我们对机器学习和深度学习的精彩之旅。在本书的旅程中,我们涵盖了该领域提供的基本主题,现在你应该已经能够将这些技术应用于解决实际问题。

我们从简要概述不同类型的学习任务开始:监督学习、强化学习和无监督学习。接着,我们讨论了几种用于分类的学习算法,从第二章中的简单单层神经网络开始,训练简单的机器学习算法进行分类

我们继续在第三章中讨论了高级分类算法,使用scikit-learn探索机器学习分类器,并且在第四章中学习了机器学习管道中最重要的几个方面,构建良好的训练数据集——数据预处理,以及第五章通过降维压缩数据

请记住,即使是最先进的算法,也受到它所学习的训练数据中信息的限制。因此,在第六章中,构建模型评估和超参数调优的最佳实践,我们学习了构建和评估预测模型的最佳实践,这是机器学习应用中另一个重要的方面。

如果单一的学习算法未能达到我们期望的性能,有时通过创建一个专家集群来进行预测是有帮助的。我们在第七章中探讨了这一点,结合不同模型进行集成学习

然后,在第八章中,将机器学习应用于情感分析,我们应用机器学习来分析现代社交媒体平台主导的互联网时代中最受欢迎和最有趣的数据形式之一——文本文件。

接下来,我们提醒自己,机器学习技术不仅限于离线数据分析,在第9章将机器学习模型嵌入到Web应用中中,我们展示了如何将机器学习模型嵌入到Web应用中,与外界分享。

大部分时间里,我们的重点是分类算法,这是机器学习中最常见的应用。然而,这并不是我们旅程的终点!在第10章使用回归分析预测连续目标变量中,我们探讨了几种回归分析算法,用于预测连续的目标变量。

机器学习的另一个令人激动的子领域是聚类分析,它可以帮助我们发现数据中隐藏的结构,即使我们的训练数据没有正确的答案可以学习。在第11章处理无标签数据——聚类分析中,我们探讨了这一领域。

接着,我们将注意力转向了整个机器学习领域中最令人兴奋的算法之一——人工神经网络。我们从第12章从头开始实现多层感知器开始,使用NumPy实现了一个多层感知器。

第13章使用TensorFlow并行化神经网络训练中,TensorFlow 2在深度学习中的应用变得显而易见,我们使用TensorFlow简化了神经网络模型的构建过程,处理了TensorFlow Dataset对象,并学习了如何对数据集应用预处理步骤。

第14章深入探讨——TensorFlow的机制中,我们更深入地了解了TensorFlow的机制,讨论了TensorFlow的各个方面,包括变量、TensorFlow函数装饰器、计算梯度以及TensorFlow估算器。

第15章使用深度卷积神经网络分类图像中,我们深入研究了卷积神经网络,目前这些网络在计算机视觉中广泛应用,特别是在图像分类任务中的出色表现。

第16章使用循环神经网络建模序列数据中,我们学习了使用RNN进行序列建模,并介绍了Transformer模型,这是最近在seq2seq建模中使用的深度学习算法之一。

第17章利用生成对抗网络合成新数据中,我们学习了如何使用GAN生成新图像,同时还了解了自动编码器、批量归一化、转置卷积以及Wasserstein GAN。

最后,在本章中,我们讨论了机器学习任务的一个完全独立的类别,展示了如何开发通过与环境交互并通过奖励过程进行学习的算法。

尽管深入研究深度学习远超本书的范围,但我们希望已经激发了你足够的兴趣,能够跟进这一领域中最新的深度学习进展。

如果你正在考虑从事机器学习职业,或者只是想跟上这一领域的最新进展,我们可以推荐以下机器学习领域的顶尖专家的作品:

仅仅列举几位!

最后,你可以通过以下网站了解我们这些作者的最新动态:

https://sebastianraschka.com

http://vahidmirjalili.com

如果你对本书有任何问题,或需要一些关于机器学习的常规建议,欢迎随时联系我们。

posted @ 2025-09-03 10:18  绝不原创的飞龙  阅读(5)  评论(0)    收藏  举报