PyTorch-与-Sklearn-机器学习指南-全-

PyTorch 与 Sklearn 机器学习指南(全)

原文:zh.annas-archive.org/md5/2a872f7dd98f6fbe3043a236f689e451

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

通过接触新闻和社交媒体,您可能已经熟悉了机器学习已成为我们时代最激动人心的技术之一这一事实。微软、谷歌、Meta、苹果、亚马逊、IBM 等大公司都在机器学习研究和应用方面进行重大投资,这是有充分理由的。虽然看起来机器学习已成为我们时代的时尚词汇,但它绝非炒作。这一激动人心的领域开辟了新的可能性,并已成为我们日常生活中不可或缺的部分。与我们智能手机上的语音助手交谈,为客户推荐合适的产品,预防信用卡欺诈,从电子邮件收件箱中过滤垃圾邮件,检测和诊断医疗疾病,这些都是它的应用案例。

如果你想成为一名机器学习实践者,一个更好的问题解决者,甚至考虑从事机器学习研究的职业,那么这本书适合你!然而,对于初学者来说,机器学习背后的理论概念可能会非常压倒性。然而,近年来出版的许多实用书籍将帮助您通过实施强大的学习算法来开始机器学习的学习。

接触实际代码示例,并通过机器学习示例应用程序的实例工作,是深入了解这一领域的好方法。具体的例子帮助说明更广泛的概念,通过直接实施学习材料来展示。然而,请记住,强大的力量伴随着巨大的责任!除了使用 Python 和基于 Python 的机器学习库提供实际机器学习经验外,本书还介绍了机器学习算法背后的数学概念,这对于成功使用机器学习至关重要。因此,本书不同于纯粹的实用书籍;它讨论了关于机器学习概念的必要细节,提供直观而信息丰富的解释,说明机器学习算法的工作原理,如何使用它们,以及最重要的是如何避免最常见的陷阱。

在本书中,我们将踏上一段充满挑战的旅程,涵盖所有必要的主题和概念,为您在这一领域的起步提供帮助。如果您发现您对知识的渴望还没有得到满足,本书引用了许多有用的资源,供您追踪这一领域的重要突破。

本书适合谁

本书是学习如何将机器学习和深度学习应用于各种任务和数据集的理想伴侣。如果你是一个想跟上技术最新趋势的程序员,那这本书绝对适合你。此外,如果你是一名学生或考虑职业转型,这本书将是你了解机器学习世界的介绍和全面指南。

本书涵盖的内容

第一章赋予计算机从数据中学习的能力,向您介绍了处理各种问题任务的主要机器学习子领域。此外,它讨论了创建典型的机器学习模型构建流水线的基本步骤,这将指导我们接下来的章节。

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

第三章使用 Scikit-Learn 进行机器学习分类器之旅,描述了用于分类的基本机器学习算法,并使用最流行和全面的开源机器学习库之一 scikit-learn 提供了实际示例。

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

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

第六章学习模型评估和超参数调优的最佳实践,讨论了估计预测模型性能的可行与不可行之处。此外,它讨论了衡量我们模型性能的不同指标以及调优机器学习算法的技术。

第七章结合不同模型进行集成学习,向您介绍了有效地组合多个学习算法的不同概念。它教授您如何构建专家集合来克服单个学习器的弱点,从而获得更准确和可靠的预测。

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

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

第十章使用无标签数据进行工作 - 聚类分析,将注意力转向机器学习的另一个子领域,即无监督学习。我们将应用来自三个基本聚类算法家族的算法,以找到共享一定相似度的对象群体。

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

第十二章使用 PyTorch 并行训练神经网络,基于前一章节的知识提供了一个实用指南,用于更有效地训练神经网络。本章的重点是 PyTorch,这是一个开源的 Python 库,允许我们利用现代 GPU 的多个核心,并通过用户友好和灵活的 API 从常见的构建模块构建深度神经网络。

第十三章深入探讨 - PyTorch 的机制,延续了上一章的内容,介绍了 PyTorch 更高级的概念和功能。PyTorch 是一个非常广泛和复杂的库,本章将向您介绍诸如动态计算图和自动微分等概念。您还将学习如何使用 PyTorch 的面向对象 API 来实现复杂的神经网络,以及 PyTorch Lightning 如何帮助您遵循最佳实践和减少样板代码。

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

第十五章使用递归神经网络对序列数据建模,介绍了另一种流行的用于深度学习的神经网络架构,特别适用于处理文本和其他类型的序列数据以及时间序列数据。作为热身练习,本章介绍了用于预测电影评论情感的递归神经网络。然后,我们将教会递归网络如何从书籍中摘取信息,以生成全新的文本。

第十六章Transformer – 通过注意机制改进自然语言处理,专注于自然语言处理的最新趋势,并解释了注意机制如何帮助建模长序列中的复杂关系。特别是,本章介绍了具有影响力的 Transformer 架构以及诸如 BERT 和 GPT 等最新的 Transformer 模型。

第十七章生成对抗网络用于合成新数据,介绍了一种流行的对抗性训练方法,适用于生成新的、看起来逼真的图像。本章从简要介绍自编码器开始,自编码器是一种特定类型的神经网络架构,可用于数据压缩。接着,本章展示了如何将自编码器的解码器部分与第二个神经网络结合起来,该神经网络能够区分真实和合成的图像。通过让两个神经网络在对抗训练中相互竞争,您将实现一个生成对抗网络,用于生成新的手写数字。

第十八章图神经网络用于捕捉图结构化数据的依赖关系,超越了处理表格数据集、图像和文本的范畴。本章介绍了操作于图结构数据(如社交媒体网络和分子)上的图神经网络。在解释了图卷积的基础知识后,本章包括了一个教程,向您展示如何为分子数据实现预测模型。

第十九章强化学习用于复杂环境中的决策制定,涵盖了机器学习的一个子类别,常用于训练机器人和其他自主系统。本章首先介绍了强化学习的基础知识(RL),以便熟悉代理/环境交互、RL 系统的奖励过程以及从经验中学习的概念。在了解了 RL 的主要类别之后,您将实现并训练一个代理,该代理可以使用 Q-learning 算法在网格世界环境中导航。最后,本章介绍了深度 Q-learning 算法,这是 Q-learning 的一种变体,使用深度神经网络。

要充分利用本书

理想情况下,您已经熟悉使用 Python 进行编程,以便跟随我们提供的代码示例来说明和应用各种算法和模型。要充分利用本书,理解数学符号将会很有帮助。

一台普通的笔记本电脑或台式计算机应该足以运行本书中的大部分代码,我们会在第一章中为您的 Python 环境提供指导。后续章节将在需要时介绍额外的库和安装建议。

最近的图形处理单元GPU)可以加速后续深度学习章节中的代码运行时间。然而,并非必须使用 GPU,我们还提供使用免费云资源的说明。

下载示例代码文件

所有代码示例可通过 GitHub 下载,地址为github.com/rasbt/machine-learning-book。我们还提供来自丰富书目和视频目录的其他代码包,可在github.com/PacktPublishing/查看!

虽然我们建议使用 Jupyter Notebook 来执行交互式代码,但所有代码示例都以 Python 脚本(例如,ch02/ch02.py)和 Jupyter Notebook 格式(例如,ch02/ch02.ipynb)提供。此外,我们建议查看随每个章节附带的README.md文件,获取额外的信息和更新。

下载彩色图像

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在此处下载:static.packt-cdn.com/downloads/9781801819312_ColorImages.pdf。此外,本书的代码笔记本中也包含了分辨率较低的彩色图像,随示例代码文件捆绑提供。

约定

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

这里有一些这些风格的示例及其含义的解释。文本中的代码词汇显示如下:“已安装的包可以通过--upgrade标志进行更新。”

代码块设置如下:

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 

Python 解释器中的任何输入都将按以下方式显示(请注意>>>符号)。预期的输出将显示无>>>符号:

>>> 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 

任何命令行输入或输出都将按以下方式显示:

pip install gym==0.20 

新术语重要词汇以粗体显示。你在屏幕上看到的单词,例如在菜单或对话框中,会在文本中以这种方式出现:“单击下一步按钮将您移动到下一个屏幕。”

警告或重要说明会以如下方式出现在框中。

小贴士和技巧会以这种方式出现。

联系我们

我们非常欢迎读者的反馈。

总体反馈:电子邮件至 feedback@packtpub.com,并在主题中提及书名。如有关于本书的任何问题,请发送电子邮件至 questions@packtpub.com。

勘误: 尽管我们已尽最大努力确保内容的准确性,但错误确实会发生。如果您在本书中发现错误,我们将不胜感激。请访问www.packtpub.com/submit-errata,选择您的书籍,单击勘误提交表单链接,然后输入详细信息。

盗版问题:如果你在互联网上发现我们的作品以任何形式存在非法副本,我们将不胜感激您提供具体位置或网站名称。请通过电子邮件联系我们,邮箱为 copyright@packtpub.com,并提供相关链接。

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

分享你的想法

一旦你阅读了《使用 PyTorch 和 Scikit-Learn 进行机器学习》,我们很想听听你的想法!请点击此处前往亚马逊评论页面为这本书分享你的反馈。

你的评论对我们和技术社区都非常重要,将帮助我们确保我们提供的内容质量卓越。

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

在我看来,机器学习,即利用算法理解数据的应用与科学,是所有计算机科学领域中最激动人心的领域!我们生活在一个数据丰富的时代;利用来自机器学习领域的自学习算法,我们可以将这些数据转化为知识。多亏了近年来开发的许多强大开源库,现在可能是进入机器学习领域并学习如何利用强大算法来识别数据模式并对未来事件进行预测的最佳时机。

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

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

  • 机器学习的一般概念

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

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

  • 安装和设置 Python 进行数据分析和机器学习

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

在现代技术时代,我们拥有大量结构化和非结构化数据资源。在 20 世纪下半叶,人工智能AI)的一个子领域——机器学习,作为自学习算法的一部分,通过从数据中获取知识来进行预测逐渐发展起来。

与要求人类手动推导规则并从分析大量数据中构建模型相比,机器学习提供了更高效的选择,即从数据中提取知识以逐步改进预测模型的性能并做出数据驱动的决策。

机器学习不仅在计算机科学研究中变得日益重要,而且在我们的日常生活中也扮演着越来越重要的角色。由于机器学习,我们享受到强大的电子邮件垃圾邮件过滤器,便捷的文本和语音识别软件,可靠的网络搜索引擎,推荐有趣电影观看,移动支票存款,估计送餐时间等服务。希望不久的将来,我们还将增加安全高效的自动驾驶汽车到这一列表中。在医疗应用方面也取得了显著进展;例如,研究人员证明深度学习模型可以以接近人类的准确度检测皮肤癌(www.nature.com/articles/nature21056)。最近,DeepMind 的研究人员使用深度学习预测了三维蛋白质结构,表现出色地超过了基于物理的方法(deepmind.com/blog/article/alphafold-a-solution-to-a-50-year-old-grand-challenge-in-biology)。准确的三维蛋白质结构预测在生物和制药研究中发挥着重要作用,最近在医疗保健领域中也有许多其他重要的机器学习应用。例如,研究人员设计了系统,可以预测 COVID-19 患者未来四天的氧需求,以帮助医院分配资源给需要的人群(ai.facebook.com/blog/new-ai-research-to-help-predict-covid-19-resource-needs-from-a-series-of-x-rays/)。当今社会的另一个重要话题是气候变化,这是我们面临的最大和最关键的挑战之一。目前,许多工作致力于开发智能系统来应对气候变化(www.forbes.com/sites/robtoews/2021/06/20/these-are-the-startups-applying-ai-to-tackle-climate-change)。解决气候变化的众多方法之一是精准农业的新兴领域。在这里,研究人员旨在设计基于计算机视觉的机器学习系统,优化资源配置,以最小化化肥的使用和浪费。

机器学习的三种不同类型

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

图形用户界面,文本,应用 由自动生成描述

图 1.1: 三种不同类型的机器学习

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

监督学习的主要目标是从带标签的训练数据中学习模型,使我们能够对未见过或将来的数据进行预测。在这里,“监督”一词指的是一组训练示例(数据输入),其中已知所需的输出信号(标签)。监督学习就是对数据输入和标签之间关系建模的过程。因此,我们也可以将监督学习看作是“标签学习”。

图 1.2 概述了典型的监督学习工作流程,其中标记的训练数据传递给机器学习算法,以拟合一个可以对新的未标记数据输入进行预测的预测模型:

自动生成的图表描述

图 1.2: 监督学习过程

考虑电子邮件垃圾邮件过滤的例子,我们可以使用监督机器学习算法在一组带标记的电子邮件上进行模型训练,这些电子邮件已正确标记为垃圾邮件或非垃圾邮件,以预测新电子邮件是否属于这两个类别之一。像前面的电子邮件垃圾邮件过滤例子中这样的监督学习任务,也被称为分类任务。监督学习的另一个子类是回归,在回归中,输出信号是连续值。

预测类标签的分类

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

图 1.3 说明了一个二元分类任务的概念,给出了 30 个训练示例;其中 15 个训练示例标记为 A 类,15 个标记为 B 类。在这种情况下,我们的数据集是二维的,这意味着每个示例都与两个值相关联:x[1] 和 x[2]。现在,我们可以使用监督机器学习算法学习一个规则——以虚线表示的决策边界,它可以分开这两个类,并根据其 x[1] 和 x[2] 值将新数据分类到这两个类别中:

图 1.3: 分类新数据点

然而,类标签集合不必具有二进制性质。监督学习算法学习的预测模型可以将训练数据集中呈现的任何类标签分配给新的未标记数据点或实例。

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

用于预测连续结果的回归

我们在前一节中学到,分类任务的目标是将无序标签分配给实例。监督学习的第二种类型是预测连续结果,也称为回归分析。在回归分析中,我们给定一些预测(解释)变量和一个连续的响应变量(结果),并试图找到这些变量之间的关系,以便预测结果。

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

例如,假设我们有兴趣预测学生的数学 SAT 成绩。(SAT 是美国常用的大学入学标准化测试。)如果学习时间与最终成绩之间存在关系,我们可以将其作为训练数据,学习一个模型,该模型使用学习时间来预测计划参加该测试的未来学生的测试成绩。

均值回归

“回归”一词由弗朗西斯·高尔顿在他的文章《遗传体质的中等回归》中于 1886 年创造。高尔顿描述了一个生物现象,即人群中身高的变异不会随时间增加。

他观察到父母的身高并不会传递给他们的孩子,而是他们孩子的身高会回归到人群的平均水平。

图 1.4 说明了线性回归的概念。给定一个特征变量 x 和一个目标变量 y,我们拟合一条直线到这些数据上,以最小化数据点与拟合线之间的距离——通常是平均平方距离。

现在,我们可以使用从这些数据中学到的截距和斜率来预测新数据的目标变量:

图表,散点图 描述自动生成

图 1.4:线性回归示例

使用强化学习解决交互式问题

另一种机器学习的类型是强化学习。在强化学习中,目标是开发一个系统(代理),通过与环境的互动来提高其性能。由于关于环境当前状态的信息通常还包括所谓的奖励信号,我们可以将强化学习看作与监督学习相关的领域。然而,在强化学习中,这种反馈不是正确的地面真实标签或值,而是一个衡量行动如何受奖励函数影响的度量。通过与环境的互动,代理可以利用强化学习来学习一系列通过探索性试错方法或审慎计划最大化此奖励的动作。

强化学习的一个流行例子是象棋程序。在这里,代理根据棋盘的状态(环境)决定一系列动作,奖励可以定义为在比赛结束时

Diagram  Description automatically generated

图 1.5:强化学习过程

强化学习有许多不同的子类型。然而,一个通用的方案是,在强化学习中,代理试图通过与环境的一系列互动来最大化奖励。每个状态可以与正面或负面奖励相关联,奖励可以定义为实现总体目标,例如赢得或输掉一场象棋比赛。例如,在象棋中,每一步的结果可以被看作是环境的不同状态。

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

总之,强化学习关注于学习选择一系列动作,以最大化总奖励,这可以通过即时采取行动后或通过延迟反馈来获得。

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

在监督学习中,当我们训练模型时,我们事先知道正确答案(标签或目标变量),在强化学习中,我们为代理执行的特定操作定义奖励措施。然而,在无监督学习中,我们处理的是未标记数据或未知结构的数据。使用无监督学习技术,我们能够探索数据的结构,从中提取有意义的信息,而无需已知的结果变量或奖励函数的指导。

利用聚类找到子群体

聚类是一种探索性数据分析或模式发现技术,允许我们将一堆信息组织成有意义的子群体(),而不需要事先了解它们的群组成员资格。在分析过程中产生的每个簇定义了一组共享某种相似度但与其他簇中的对象更为不同的对象,这也是为什么有时将聚类称为无监督分类。聚类是一种从数据中提取有意义关系的重要技术。例如,它允许市场营销人员根据客户的兴趣发现客户群体,以制定不同的营销计划。

图 1.6说明了如何将聚类应用于将未标记数据组织成三个不同组或簇(A、B 和 C,顺序任意),基于它们特征的相似性,x[1]和x[2]:

Diagram  Description automatically generated

图 1.6:聚类的工作原理

数据压缩的降维

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

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

图 1.7:从三维到二维的降维示例

基本术语和符号介绍

现在我们已经讨论了机器学习的三大类别——监督学习、无监督学习和强化学习——让我们来看看本书中将要使用的基本术语。下面的小节涵盖了我们在谈论数据集不同方面时会使用的常见术语,以及更精确和高效地进行数学表示的符号约定。

由于机器学习是一个广泛且跨学科的领域,您很可能会很快遇到许多指代相同概念的不同术语。第二小节汇总了机器学习文献中使用的许多常用术语,这对您作为参考资料可能会很有用。

本书中使用的符号和约定

图 1.8 描述了鸢尾花数据集的摘录,这是机器学习领域中的一个经典例子(更多信息可在 archive.ics.uci.edu/ml/datasets/iris 找到)。鸢尾花数据集包含了来自三种不同物种——山鸢尾、变色鸢尾和维吉尼亚鸢尾——的 150 朵鸢尾花的测量数据。

在这里,每个花样本代表数据集中的一行,而以厘米为单位的花测量数据则以列的形式存储,我们也称之为数据集的特征

图 1.8 描述 自动生成

图 1.8:鸢尾花数据集

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

鸢尾花数据集包含 150 个样本和四个特征,可以写成一个 150×4 的矩阵,形式上表示为

符号约定

在本书的大部分内容中,除非另有说明,我们将使用上标 i 表示第 i 个训练样本,使用下标 j 表示训练数据集的第 j 个维度。

我们将使用小写的粗体字母来指代向量 (),使用大写的粗体字母来指代矩阵 ()。为了指代向量或矩阵中的单个元素,我们将字母写成斜体 (x(n^) 或 )。

例如, 指的是鸢尾花样本 150 的第一维,即萼片长度。矩阵 X 中的每一行代表一个花实例,并且可以写成一个四维行向量,

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

类似地,我们可以将目标变量(这里是类标签)表示为一个 150 维列向量:

机器学习术语

机器学习是一个广泛的领域,也是一个非常跨学科的领域,它将许多其他研究领域的科学家聚集在一起。事实上,许多术语和概念已经被重新发现或重新定义,可能已经对您不陌生,但在不同的名称下出现。为了您的方便,在以下列表中,您可以找到一些常用术语及其同义词的选择,这在阅读本书和机器学习文献时可能会对您有所帮助:

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

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

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

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

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

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

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

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

自动生成的图表描述

图 1.9:预测建模工作流程

预处理 - 将数据整理成形

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

如果我们以前一节中的鸢尾花数据集为例,我们可以将原始数据看作一系列花卉图像,我们希望从中提取有意义的特征。有用的特征可能围绕花卉的颜色或花卉的高度、长度和宽度。

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

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

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

训练和选择预测模型

正如您将在后续章节中看到的那样,已经开发了许多不同的机器学习算法来解决不同的问题任务。从大卫·沃尔珀特(David Wolpert)著名的无免费午餐定理中可以总结出一个重要观点,即我们不能“免费”学习(没有先验区分的学习算法,D.H.沃尔珀特,1996 年;优化的无免费午餐定理,D.H.沃尔珀特和 W.G.麦克瑞迪,1997 年)。我们可以将这个概念与流行的说法联系起来,我想如果你唯一拥有的工具是一把锤子,那么处理一切就像处理钉子一样是诱人的(亚伯拉罕·马斯洛,1966 年)。例如,每种分类算法都有其固有的偏见,如果我们不对任务做任何假设,没有单一的分类模型能够享有优势。因此,在实践中,比较至少几种不同的学习算法以训练和选择表现最佳的模型至关重要。但在我们能够比较不同模型之前,我们首先必须决定一个用于衡量性能的度量标准。一个常用的度量标准是分类准确度,它定义为正确分类实例的比例。

一个合理的问题是:如果我们不在模型选择中使用这个测试数据集,而是将其保留到最后模型评估时使用,那么我们如何知道哪个模型在最终测试数据集和真实世界数据上表现良好?为了解决这个问题,可以使用总称为“交叉验证”的不同技术。在交叉验证中,我们进一步将数据集分成训练和验证子集,以估计模型的泛化性能。

最后,我们也不能期望软件库提供的不同学习算法的默认参数对我们的特定问题任务是最优的。因此,在后续章节中,我们将频繁使用超参数优化技术,这些技术帮助我们调整模型的性能。

我们可以将这些超参数视为不从数据中学习的参数,而是表示模型旋钮,我们可以调整它们以提高其性能。在后续章节中,当我们看到实际示例时,这将变得更加清晰。

评估模型和预测未见数据实例

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

使用 Python 进行机器学习

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

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

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

安装 Python 和从 Python 包索引中安装包

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

本书中提供的代码示例已针对 Python 3.9 编写和测试,我们通常建议您使用最新版本的 Python 3。一些代码也可能与 Python 2.7 兼容,但由于 Python 2.7 的官方支持已于 2019 年结束,并且大多数开源库已停止支持 Python 2.7(python3statement.org),我们强烈建议您使用 Python 3.9 或更新版本。

您可以通过执行以下命令检查您的 Python 版本

python --version 

python3 --version 

在您的终端(如果使用 Windows,则为 PowerShell)中执行。

本书中将使用的额外软件包可以通过pip安装程序安装,pip已成为 Python 标准库的一部分,自 Python 3.3 起。有关pip的更多信息,请访问docs.python.org/3/installing/index.html

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

pip install SomePackage 

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

pip install SomePackage --upgrade 

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

一个高度推荐的开源软件包管理系统,用于安装用于科学计算的 Python,是由 Continuum Analytics 提供的 conda。Conda 是免费的,并在宽松的开源许可下授权。其目标是帮助在不同操作系统上管理 Python 数据科学、数学和工程软件包的安装和版本。如果您希望使用 conda,它有不同的版本,包括 Anaconda、Miniconda 和 Miniforge:

成功安装 conda(通过 Anaconda、Miniconda 或 Miniforge),我们可以使用以下命令安装新的 Python 软件包:

conda install SomePackage 

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

conda update SomePackage 

通过社区支持的 conda-forge 项目(conda-forge.org)可能会提供不在官方 conda 频道中的包,可以通过--channel conda-forge标志指定。例如:

conda install SomePackage --channel conda-forge 

不能通过默认的 conda 频道或 conda-forge 获取的包可以通过pip安装,如前所述。例如:

pip install SomePackage 

用于科学计算、数据科学和机器学习的包

在本书的前半部分,我们主要使用 NumPy 的多维数组来存储和操作数据。偶尔,我们将使用建立在 NumPy 之上的 pandas 库,它提供了额外的高级数据操作工具,使得处理表格数据变得更加方便。为了增强您的学习体验并可视化定量数据,Matplotlib 库是非常可定制化的,对于理解数据非常有帮助。

本书主要使用的机器学习库是 scikit-learn(第三章第十一章)。第十二章,“使用 PyTorch 并行化神经网络训练”,将介绍深度学习库 PyTorch。

编写本书所用的主要 Python 包的版本号列在以下列表中。请确保您安装的包的版本号与这些版本号理想情况下相等,以确保代码示例正确运行:

  • NumPy 1.21.2

  • SciPy 1.7.0

  • Scikit-learn 1.0

  • Matplotlib 3.4.3

  • pandas 1.3.2

安装这些包后,您可以通过在 Python 中导入包并访问其__version__属性来再次检查安装的版本,例如:

>>> import numpy
>>> numpy.__version__
'1.21.2' 

为了方便起见,我们在本书的免费代码存储库github.com/rasbt/machine-learning-book中包含了一个名为python-environment-check.py的脚本,这样您可以通过执行此脚本检查您的 Python 版本和包版本。

某些章节将需要额外的包,并将提供有关安装的信息。例如,目前不需要安装 PyTorch。第十二章将在需要时提供提示和说明。

如果尽管您的代码与章节中的代码完全相同仍然遇到错误,请先检查底层包的版本号,然后再花时间调试或与出版商或作者联系。有时,库的新版本引入了不向后兼容的更改,这可能解释这些错误。

如果你不想改变主要的 Python 安装包版本,我们建议在安装本书中使用的包时使用虚拟环境。如果你使用 Python 而没有 conda 管理器,你可以使用venv库创建一个新的虚拟环境。例如,你可以通过以下两个命令创建和激活虚拟环境:

python3 -m venv /Users/sebastian/Desktop/pyml-book
source /Users/sebastian/Desktop/pyml-book/bin/activate 

请注意,每次打开新终端或 PowerShell 时都需要激活虚拟环境。你可以在docs.python.org/3/library/venv.html找到更多关于venv的信息。

如果你正在使用带有 conda 包管理器的 Anaconda,你可以按照以下方式创建和激活虚拟环境:

conda create -n pyml python=3.9
conda activate pyml 

摘要

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

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

在本书的后续部分中,除了机器学习本身,我们还将介绍不同的技术来预处理数据集,这将帮助你充分发挥不同机器学习算法的性能。虽然我们将在整本书中广泛涵盖分类算法,但我们也将探讨回归分析和聚类的不同技术。

我们前方有一段激动人心的旅程,涵盖机器学习广阔领域中的许多强大技术。然而,我们将一步步地接近机器学习,通过本书的各章节逐渐建立我们的知识基础。在接下来的章节中,我们将通过实现最早的分类机器学习算法之一来开始这段旅程,这将为我们准备好第三章使用 scikit-learn 进行机器学习分类器的导览,在那里我们将涵盖更高级的机器学习算法,使用 scikit-learn 开源机器学习库。

加入我们书籍的 Discord 空间

加入书籍的 Discord 工作空间,与作者进行每月的问我任何会话:

packt.link/MLwPyTorch

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

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

探讨使用自适应线性神经元进行优化基础,为通过 scikit-learn 机器学习库使用更复杂分类器奠定基础,《第三章》使用 scikit-learn 进行机器学习分类器之旅

本章我们将讨论以下主题:

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

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

  • 在 Python 中为 2 类问题实现线性分类器

人工神经元——对机器学习早期历史的简要介绍

在我们详细讨论感知器及相关算法之前,让我们简要回顾一下机器学习的起源。为了设计一种人工智能AI),沃伦·麦卡洛克和沃尔特·皮茨在 1943 年发表了第一个简化脑细胞的概念,即所谓的麦卡洛克-皮茨MCP)神经元(参见《神经活动中内在思想的逻辑演算》由W. S. 麦卡洛克W. 皮茨数学生物物理学公报,5(4):115-133,1943 年)。

生物神经元是大脑中相互连接的神经细胞,参与处理和传递化学和电信号,如图 2.1所示:

Diagram  Description automatically generated

图 2.1:神经元处理化学和电信号

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

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

人工神经元的正式定义

更正式地说,我们可以将人工神经元的概念放入二分类任务(类别为 0 和 1)的背景中。我们可以定义一个决策函数,,它接受某些输入值x和相应的权重向量w的线性组合,其中z称为所谓的净输入z = w[1]x[1] + w[2]x[2] + ... + w[m]x[m]:

现在,如果特定示例x(i^)的净输入大于定义的阈值,,我们预测类别 1;否则,预测类别 0。在感知器算法中,决策函数,,是单位阶跃函数的一个变体:

为了简化后续的代码实现,我们可以通过几个步骤修改此设置。首先,我们将阈值,,移动到方程的左侧:

其次,我们将所谓的偏置单元定义为 ,并将其作为净输入的一部分:

z = w[1]x[1] + ... + w[m]x[m] + b = w^Tx + b

第三,考虑到引入偏置单元和上述净输入z的重新定义,我们可以如下重新定义决策函数:

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

在接下来的章节中,我们经常会使用线性代数的基本符号。例如,我们将使用向量点积来简写xw中值的乘积之和,而上标T代表转置,这是一种将列向量转换为行向量及其相反操作。例如,假设我们有以下两个列向量:

然后,我们可以将向量a的转置表示为a^T = [a[1] a[2] a[3]],并将点积表示为

此外,转置操作也可以应用于矩阵,以反映其沿对角线的镜像,例如:

请注意,转置操作严格来说只适用于矩阵;然而,在机器学习的背景下,当我们使用术语“向量”时,我们指的是n × 1 或 1 × m矩阵。

在本书中,我们只会使用线性代数中非常基础的概念;然而,如果您需要快速复习,请查看 Zico Kolter 的出色的线性代数复习和参考,可在www.cs.cmu.edu/~zkolter/course/linalg/linalg_notes.pdf免费获取。

图 2.2说明了如何将净输入z = w^Tx + b通过感知器的决策函数(左子图)压缩为二进制输出(0 或 1),以及如何使用它来区分可以通过线性决策边界分离的两个类别(右子图):

图表,散点图  自动生成的描述

图 2.2:阈值函数为二元分类问题生成线性决策边界

感知器学习规则

MCP 神经元和 Rosenblatt 的阈值感知器模型背后的整个理念是使用还原主义方法模拟大脑中的单个神经元的工作方式:它要么发射,要么不发射。因此,Rosenblatt 的经典感知器规则非常简单,感知器算法可以总结为以下步骤:

  1. 将权重和偏置单元初始化为 0 或小的随机数。

  2. 对于每个训练示例x(i^),

    1. 计算输出值,图

    2. 更新权重和偏置单元。

在这里,输出值是由我们之前定义的单位阶跃函数预测的类标签,而权重向量w中的偏置单元和每个权重w[j]的同时更新,可以更正式地写成:

图

更新值(“增量”)计算如下:

图

注意,与偏置单元不同,权重w[j]对应于数据集中的特征x[j],它们参与确定上面定义的更新值图。此外,图学习率(通常是一个介于 0.0 和 1.0 之间的常数),y(i^)是第i个训练示例的真实类标签图预测类标签。重要的是要注意,偏置单元和权重向量中的所有权重是同时更新的,这意味着在更新之前我们不重新计算预测标签图,直到通过相应的更新值图图更新偏置单元和所有权重。具体来说,对于二维数据集,我们可以将更新写成:

图

在我们在 Python 中实现感知器规则之前,让我们通过一个简单的思想实验来说明这个学习规则有多简单。在感知器正确预测类标签的两种情况下,由于更新值为 0,偏置单元和权重保持不变:

(1) 图

(2) 图

然而,在预测错误的情况下,权重被推向正类或负类的方向:

(3) 图

(4) 图

为了更好地理解特征值作为乘法因子,图,让我们通过另一个简单的例子来说明:

图

假设,我们将这个例子误分类为类 0。在这种情况下,我们会总共增加相应的权重 2.5,以便下次我们遇到这个例子时,净输入会更加正向,因此更有可能超过单位阶跃函数的阈值,将例子分类为类 1

权重更新,,与的值成比例。例如,如果我们有另一个例子,,被错误地分类为类 0,那么我们将进一步推动决策边界,以便下次正确分类这个例子:

需要注意的是,感知器的收敛仅在两类线性可分的情况下才保证,这意味着两类不能通过线性决策边界完全分离。(感兴趣的读者可以在我的讲义中找到收敛证明:sebastianraschka.com/pdf/lecture-notes/stat453ss21/L03_perceptron_slides.pdf)。图 2.3展示了线性可分和线性不可分场景的视觉示例:

图表,散点图  自动生成的描述

图 2.3:线性可分和非线性可分类示例

如果两类不能通过线性决策边界分开,我们可以设置对训练数据集的最大遍历次数(epochs)和/或允许的误分类数量阈值。否则,感知器将永远不会停止更新权重。本章后面,我们将介绍 Adaline 算法,该算法产生线性决策边界,即使类别不完全线性可分。在第三章,我们将学习可以产生非线性决策边界的算法。

下载示例代码

如果您直接从 Packt 购买了本书,可以从您在www.packtpub.com的帐户中下载示例代码文件。如果您在其他地方购买了本书,可以直接从github.com/rasbt/machine-learning-book下载所有代码示例和数据集。

现在,在我们进入下一节的实现之前,你刚刚学到的内容可以用一个简单的图表来总结,说明感知器的一般概念:

图表  自动生成的描述

图 2.4:模型的权重和偏置根据误差函数进行更新

上图说明了感知器如何接收示例(x)的输入,并将其与偏置单元(b)和权重(w)结合起来计算净输入。然后,净输入传递给阈值函数,该函数生成 0 或 1 的二进制输出——示例的预测类标签。在学习阶段,此输出用于计算预测错误并更新权重和偏置单元。

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

在前面的部分,我们学习了 Rosenblatt 的感知器规则的工作原理;现在让我们在 Python 中实现它,并将其应用于我们在第一章使计算机能够从数据中学习中介绍的鸢尾花数据集。

一个面向对象的感知器 API

我们将以面向对象的方式定义感知器接口作为 Python 类,这将允许我们通过fit方法初始化新的Perceptron对象,该对象可以从数据中学习,并通过单独的predict方法进行预测。作为惯例,我们在未初始化对象时通过调用对象的其他方法为属性添加下划线(_),例如self.w_

Python 科学计算栈的其他资源

如果您对 Python 的科学库还不熟悉或需要复习,请参阅以下资源:

以下是 Python 中感知器的实现:

import numpy as np
class Perceptron:
    """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.
    b_ : Scalar
      Bias unit 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=X.shape[1])
        self.b_ = np.float_(0.)
        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_ += update * xi
                self.b_ += 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_) + self.b_

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

使用此感知器实现,我们现在可以通过给定的学习率eta)和迭代次数n_iter(训练数据集的遍数)来初始化新的Perceptron对象。

通过fit方法,我们将偏置self.b_初始化为初始值 0,并将self.w_中的权重初始化为向量,,其中m表示数据集中的维度(特征)数量。

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

从技术上讲,我们可以将权重初始化为零(事实上,这是原始感知器算法中所做的)。然而,如果我们这样做,学习率 (eta) 将不会对决策边界产生影响。如果所有权重都初始化为零,则学习率参数 eta 只影响权重向量的规模,而不影响方向。如果你熟悉三角学,考虑一个向量 v1 =[1 2 3],其中向量 v2 = 0.5 × v1 的角度将完全为零,如以下代码片段所示:

>>> 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,这是任意的;请记住,我们只是希望获得小的随机值,以避免全零向量的特性,正如前面讨论的。)

在阅读本章后,作为可选练习,你可以将self.w_ = rgen.normal(loc=0.0, scale=0.01, size=X.shape[1])改为self.w_ = np.zeros(X.shape[1]),然后使用不同的eta值运行下一节中呈现的感知器训练代码。你会观察到决策边界不会改变。

NumPy 数组索引

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

在权重初始化完成后,fit 方法遍历训练数据集中的所有单个示例,并根据我们在前一节讨论的感知器学习规则更新权重。

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

向量化:用矢量化代码替代 for 循环

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

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

为了测试我们的感知器实现,我们将在本章的其余部分限制以下分析和示例到两个特征变量(维度)。虽然感知器规则不限于两个维度,但仅考虑两个特征,萼片长度和花瓣长度,将允许我们在散点图中可视化训练模型的决策区域,以便学习目的。

请注意,出于实际原因,我们还将仅考虑鸢尾花数据集中的两个花类别,山鸢尾和变色鸢尾——记住,感知器是一个二元分类器。然而,感知器算法可以扩展到多类分类,例如一对全OvA)技术。

多类分类的 OvA 方法

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

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

>>> import os
>>> import pandas as pd
>>> s = 'https://archive.ics.uci.edu/ml/'\
...     'machine-learning-databases/iris/iris.data'
>>> print('From URL:', s)
From 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 数据集的最后五行:

图,日历描述自动生成

图 2.5:Iris 数据集的最后五行

加载 Iris 数据集

如果你在离线工作或者 UCI 服务器在archive.ics.uci.edu/ml/machine-learning-databases/iris/iris.data暂时不可用时,你可以在本书的代码包中找到 Iris 数据集(以及本书中使用的所有其他数据集)。例如,要从本地目录加载 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') 

接下来,我们提取与 50 朵山鸢尾花和 50 朵变色鸢尾花对应的前 100 个类标签,并将这些类标签转换为两个整数类标签1(变色鸢尾花)和0(山鸢尾花),然后将其分配给向量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', 0, 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='s', label='Versicolor')
>>> plt.xlabel('Sepal length [cm]')
>>> plt.ylabel('Petal length [cm]')
>>> plt.legend(loc='upper left')
>>> plt.show() 

执行上述代码示例后,我们应该看到以下散点图:

图,散点图描述自动生成

图 2.6:以萼片和花瓣长度分类的山鸢尾花和变色鸢尾花的散点图

图 2.6显示了 Iris 数据集中花例子在两个特征轴上的分布:花瓣长度和萼片长度(以厘米为单位)。在这个二维特征子空间中,我们可以看到线性决策边界应该足以将山鸢尾花和变色鸢尾花分开。因此,感知器这样的线性分类器应该能够完美地分类这个数据集中的花。

现在,是时候在我们刚刚提取的 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() 

请注意,误分类错误的数量和更新次数是相同的,因为每当感知器错误分类一个示例时,感知器的权重和偏置就会更新。执行上述代码后,我们应该能看到误分类错误与迭代次数的图示,如图 2.7所示:

图 2.7:误分类错误与迭代次数的图示

正如我们在图 2.7中所看到的,我们的感知器在第六次 epoch 后收敛,现在应该能够完美地对训练示例进行分类。让我们实现一个小便捷函数来可视化二维数据集的决策边界:

from matplotlib.colors import ListedColormap
def plot_decision_regions(X, y, classifier, resolution=0.02):
    # setup marker generator and color map
    markers = ('o', 's', '^', '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))
    lab = classifier.predict(np.array([xx1.ravel(), xx2.ravel()]).T)
    lab = lab.reshape(xx1.shape)
    plt.contourf(xx1, xx2, lab, 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=f'Class {cl}',
                    edgecolor='black') 

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

将预测的类标签lab重新整形为与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() 

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

图表,散点图  自动生成描述图 2.8:感知器决策区域的绘图

如图中所示,感知器学习了一个能够完美分类鸢尾花训练子集中所有样本的决策边界。

感知器收敛性

尽管感知器完美地分类了两类鸢尾花,但收敛是感知器的最大问题之一。罗森布拉特在数学上证明了,如果两个类可以通过线性超平面分开,感知器学习规则将收敛。然而,如果这些类不能通过线性决策边界完美分开,权重将永远不会停止更新,除非我们设置最大迭代次数。有兴趣的读者可以在我的讲义中找到这个证明的摘要,链接在sebastianraschka.com/pdf/lecture-notes/stat453ss21/L03_perceptron_slides.pdf

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

在本节中,我们将介绍另一种单层神经网络NN):自适应线性神经元Adaline)。 Adaline 由伯纳德·维德罗和他的博士生泰德·霍夫在罗森布拉特感知器算法几年后发布,可以看作是后者的改进(An Adaptive “Adaline” Neuron Using Chemical “Memistors”, Technical Report Number 1553-2 by B. Widrow and colleagues, Stanford Electron Labs, Stanford, CA, October 1960)。

Adaline 算法特别有趣,因为它展示了定义和最小化连续损失函数的关键概念。这为理解其他用于分类的机器学习算法奠定了基础,如逻辑回归、支持向量机和多层神经网络,以及线性回归模型,我们将在后续章节中讨论。

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

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

感知器和 Adaline 算法之间的主要区别在 图 2.9 中被突出显示:

图 2.9:感知器与 Adaline 算法的比较Figure 2.9: 感知器和 Adaline 算法的比较

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

用梯度下降最小化损失函数

监督机器学习算法的一个关键组成部分是一个定义好的目标函数,在学习过程中要进行优化。这个目标函数通常是我们想要最小化的损失或成本函数。在 Adaline 的情况下,我们可以定义损失函数 L 为模型参数学习的均方误差MSE),即计算结果与真实类标签之间的平均平方误差:

这个术语 只是为了方便起见,并且会使得推导损失函数对权重参数的梯度更加容易,正如我们将在下面的段落中看到的那样。这种连续的线性激活函数与单位阶跃函数相比的主要优势在于损失函数的可微性。这个损失函数的另一个好处是它是凸的;因此,我们可以使用一个非常简单但功能强大的优化算法,称为梯度下降,来找到最小化我们的损失函数以对 Iris 数据集中的示例进行分类的权重。

图 2.10 所示,我们可以将梯度下降的主要思想描述为在达到局部或全局损失最小值之前 向下爬山。在每次迭代中,我们沿着梯度的反方向迈出一步,步长由学习速率的值以及梯度的斜率决定(为简单起见,以下图仅为单个权重 w 可视化此过程):

图 2.10:梯度下降的工作原理Figure 2.10: 梯度下降的工作原理

使用梯度下降,我们现在可以通过沿着损失函数 L 的梯度 的反方向来更新模型参数 wb

参数变化,,被定义为负梯度乘以学习率

要计算损失函数的梯度,我们需要计算损失函数对每个权重 w[j] 的偏导数:

类似地,我们计算损失对偏置的偏导数为:

请注意,分子中的 2 仅仅是一个常数缩放因子,我们可以省略它而不影响算法。去除缩放因子的效果与将学习率乘以 2 相同。以下信息框解释了这个缩放因子的来源。

因此,我们可以将权重更新写为:

由于我们同时更新所有参数,我们的 Adaline 学习规则变为:

均方误差导数

如果您熟悉微积分,可以得到 MSE 损失函数对第j个权重的偏导数如下:

同样的方法可以用来找到部分导数 ,除了 等于 -1,因此最后一步简化为

尽管 Adaline 学习规则看起来与感知器规则相同,我们应该注意 是一个实数,而不是整数类标签。此外,权重更新是基于训练数据集中的所有示例计算的(而不是在每个训练示例之后逐步更新参数),这也是为什么这种方法被称为 批量梯度下降。为了更加明确,并且在本章和本书后续讨论相关概念时避免混淆,我们将这个过程称为 全批量梯度下降

在 Python 中实现 Adaline

由于感知器规则和 Adaline 非常相似,我们将采用先前定义的感知器实现,并更改fit方法,以便通过梯度下降来最小化损失函数更新权重和偏置参数:

class AdalineGD:
    """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.
    b_ : Scalar
        Bias unit after fitting.
    losses_ : list
      Mean squared error loss function values 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=X.shape[1])
        self.b_ = np.float_(0.)
        self.losses_ = []

        for i in range(self.n_iter):
            net_input = self.net_input(X)
            output = self.activation(net_input)
            errors = (y - output)
            self.w_ += self.eta * 2.0 * X.T.dot(errors) / X.shape[0]
            self.b_ += self.eta * 2.0 * errors.mean()
            loss = (errors**2).mean()
            self.losses_.append(loss)
        return self

    def net_input(self, X):
        """Calculate net input"""
        return np.dot(X, self.w_) + self.b_

    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.5, 1, 0) 

与感知器不同,我们不是在评估每个单独的训练样例后更新权重,而是基于整个训练数据集计算梯度。对于偏置单元,这是通过self.eta * 2.0 * errors.mean()完成的,其中errors是包含偏导数值的数组。类似地,我们更新权重。然而,请注意,通过偏导数更新权重涉及特征值x[j],我们可以通过将errors与每个权重的每个特征值相乘来计算它们:

 for w_j in range(self.w_.shape[0]):
                self.w_[w_j] += self.eta * 
                    (2.0 * (X[:, w_j]*errors)).mean() 

为了更有效地实现权重更新而不使用for循环,我们可以在特征矩阵和误差向量之间进行矩阵-向量乘法:

self.w_ += self.eta * 2.0 * X.T.dot(errors) / X.shape[0] 

请注意,activation方法对代码没有影响,因为它仅仅是一个恒等函数。在这里,我们添加了激活函数(通过activation方法计算得到)来说明信息如何通过单层神经网络传播的一般概念:来自输入数据的特征,净输入,激活和输出。

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

现在,类似于之前的感知器实现,我们将损失值收集在self.losses_列表中,以检查算法在训练后是否收敛。

矩阵乘法

执行矩阵乘法类似于计算向量点积,其中矩阵中的每一行被视为单行向量。这种向量化方法代表了更紧凑的表示法,并且利用 NumPy 进行更有效的计算。例如:

请注意,在前述方程中,我们正在将一个矩阵与一个向量相乘,从数学上讲这是没有定义的。然而,请记住,我们使用的惯例是将此前述向量视为一个 3×1 矩阵。

在实践中,通常需要一些实验来找到一个良好的学习率,,以实现最佳收敛。因此,让我们选择两个不同的学习率,,开始,并绘制损失函数与迭代次数的图表,以查看 Adaline 实现从训练数据中学习的效果。

超参数

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

现在让我们绘制两种不同学习率下的损失随着迭代次数的变化图表:

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

正如我们在得到的损失函数图中所看到的,我们遇到了两种不同类型的问题。左图显示了如果选择的学习率过大可能会发生的情况。在每个 epoch 中,而不是最小化损失函数,MSE 都会变大,因为我们超调了全局最小值。另一方面,我们可以看到右图中损失在减少,但是所选的学习率 太小,算法需要非常多的 epoch 才能收敛到全局损失最小值:

自动生成的图标描述

图 2.11: 子优化学习率下的误差图

图 2.12 描述了如果我们改变某个权重参数的值以最小化损失函数 L,可能会发生的情况。左侧的子图说明了选择得当的学习率的情况,损失逐渐减小,朝着全局最小值的方向移动。

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

图 2.12: 自动化生成的图表描述

图 2.12: 选用适当学习率与学习率过大的比较

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

在本书中我们会遇到许多需要进行某种形式特征缩放以获得最佳性能的机器学习算法,在第三章使用 Scikit-Learn 的机器学习分类器概览,和第四章构建良好的训练数据集——数据预处理中会详细讨论这一点。

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

图 2.11: 子优化学习率下的误差图

在这里,x[j] 是由所有训练样本 n 的第 j 个特征值组成的向量,这种标准化技术应用于数据集中的每个特征 j

标准化有助于梯度下降学习的一个原因是,更容易找到适合所有权重(和偏差)的学习率。如果特征在非常不同的尺度上,那么对一个权重进行更新有效的学习率可能对另一个权重来说太大或太小。总体而言,使用标准化特征可以稳定训练过程,使优化器无需经过更多步骤就能找到良好或最优解(全局损失最小值)。图 2.13 展示了未经标准化特征(左侧)和经过标准化特征(右侧)可能的梯度更新情况,其中同心圆代表了在二维分类问题中两个模型权重的损失表面函数:

工程绘图描述自动生成

图 2.13:未经标准化和标准化特征上的梯度更新比较

可以通过使用内置的 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,并看到它现在在使用学习率为 的少量周期后收敛:

>>> ada_gd = AdalineGD(n_iter=20, eta=0.5)
>>> 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.losses_) + 1),
...          ada_gd.losses_, marker='o')
>>> plt.xlabel('Epochs')
>>> plt.ylabel('Mean squared error')
>>> plt.tight_layout()
>>> plt.show() 

执行此代码后,我们应该看到决策区域的图以及损失下降的图,如 图 2.14 所示:

自动生成描述的图表

图 2.14:Adaline 的决策区域和随着周期数变化的 MSE 的绘图

正如图中所示,Adaline 现在在训练标准化特征后已经收敛。但请注意,即使所有花的例子都被正确分类,MSE 仍保持非零。

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

在前一节中,我们学习了通过沿着从整个训练数据集计算出的损失梯度的反方向迈出步伐来最小化损失函数;这也是为什么有时这种方法被称为全批量梯度下降。现在想象一下,我们有一个包含数百万数据点的非常大的数据集,在许多机器学习应用中这并不罕见。在这种情况下,运行全批量梯度下降可能在计算上非常昂贵,因为每次朝全局最小值迈出一步时,我们都需要重新评估整个训练数据集。

批量梯度下降算法的一种流行替代方案是随机梯度下降SGD),有时也称为迭代或在线梯度下降。而不是基于所有训练样本上累积误差的和来更新权重,x(i^):

我们为每个训练样本逐步更新参数,例如:

虽然 SGD 可以被看作是梯度下降的一种近似,但它通常会因为更频繁的权重更新而更快地达到收敛。由于每个梯度是基于单个训练样本计算的,误差曲面比梯度下降中的要嘈杂,这也可以作为一个优势,即如果我们使用非线性损失函数,SGD 更容易逃离浅层局部最小值,正如我们稍后在 第十一章 从零开始实现多层人工神经网络 中将看到的。通过 SGD 获得令人满意的结果,重要的是要以随机顺序呈现训练数据;此外,我们希望在每个 epoch 中对训练数据集进行洗牌,以防止循环。

调整训练中的学习率

在 SGD 的实现中,固定的学习率,,通常会被随着时间推移逐渐减小的自适应学习率所取代,例如:

其中 c[1] 和 c[2] 是常数。注意,SGD 并不会达到全局损失最小值,而是非常接近它的某个区域。通过使用自适应学习率,我们可以进一步接近损失最小值。

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

小批量梯度下降

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

由于我们已经使用梯度下降实现了 Adaline 学习规则,我们只需进行一些调整即可修改学习算法以通过 SGD 更新权重。在 fit 方法内部,我们现在会在每个训练示例后更新权重。此外,我们将实现一个额外的 partial_fit 方法,该方法不会重新初始化权重,用于在线学习。为了在训练后检查算法是否收敛,我们将计算每个 epoch 中训练示例的平均损失。此外,我们将添加一个选项,在每个 epoch 前对训练数据进行洗牌,以避免优化损失函数时出现重复循环;通过 random_state 参数,我们允许指定一个随机种子以实现可重现性。

class AdalineSGD:
    """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.
    b_ : Scalar
        Bias unit after fitting.
    losses_ : list
        Mean squared error loss 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.losses_ = []
        for i in range(self.n_iter):
            if self.shuffle:
                X, y = self._shuffle(X, y)
            losses = []
            for xi, target in zip(X, y):
                losses.append(self._update_weights(xi, target))
            avg_loss = np.mean(losses) 
            self.losses_.append(avg_loss)
        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=m)
        self.b_ = np.float_(0.)
        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_ += self.eta * 2.0 * xi * (error)
        self.b_ += self.eta * 2.0 * error
        loss = error**2
        return loss

    def net_input(self, X):
        """Calculate net input"""
        return np.dot(X, self.w_) + self.b_

    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.5, 1, 0) 

我们现在在 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.losses_) + 1), ada_sgd.losses_,
...          marker='o')
>>> plt.xlabel('Epochs')
>>> plt.ylabel('Average loss')
>>> plt.tight_layout()
>>> plt.show() 

我们从执行前述代码示例中获得的两个图表如 图 2.15 所示:

自动生成描述的图表

图 2.15:使用 SGD 训练 Adaline 模型后的决策区域和平均损失图

正如您所见,平均损失迅速下降,经过 15 个 epoch 后的最终决策边界看起来类似于批量梯度下降的 Adaline。如果我们想要在流式数据的在线学习场景中更新我们的模型,我们可以简单地对单个训练示例调用 partial_fit 方法,例如 ada_sgd.partial_fit(X_std[0, :], y[0])

总结

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

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

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

加入我们书籍的 Discord 空间

加入书籍的 Discord 工作空间,与作者进行每月的 问答 会话:

packt.link/MLwPyTorch

第三章:使用 Scikit-Learn 进行机器学习分类器的介绍

在本章中,我们将介绍一些在学术界和工业界常用的流行和强大的机器学习算法。在学习了解几种用于分类的监督学习算法之间的差异时,我们还将理解它们各自的优缺点。此外,我们将首次使用 Scikit-Learn 库,该库提供了一个用户友好且一致的接口,有效和高效地使用这些算法。

本章节将涵盖以下主题:

  • 介绍了用于分类的稳健和流行算法,如逻辑回归、支持向量机、决策树和k最近邻算法。

  • 使用 Scikit-Learn 机器学习库的示例和解释,该库通过用户友好的 Python API 提供了各种机器学习算法。

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

选择分类算法

为特定问题任务选择适当的分类算法需要实践和经验;每种算法都有其独特之处,并基于某些假设。用 David H. Wolpert 的没有免费午餐定理的话来说,没有单一的分类器适用于所有可能的场景(The Lack of A Priori Distinctions Between Learning Algorithms, Wolpert, David H, Neural Computation 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 函数和类方法也可以处理字符串格式的类标签,但使用整数标签是一种推荐的方法,可以避免技术故障并提高计算性能,因为整数标签具有较小的内存占用;此外,将类标签编码为整数是大多数机器学习库的常规约定。

为了评估训练模型在未见数据上的表现如何,我们将进一步将数据集分割为单独的训练和测试数据集。在 第六章学习模型评估和超参数调优的最佳实践 中,我们将更详细地讨论模型评估的最佳实践。使用 scikit-learn 的 model_selection 模块中的 train_test_split 函数,我们随机将 Xy 数组分割为 30% 的测试数据(45 个例子)和 70% 的训练数据(105 个例子):

>>> 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函数在分割之前已经内部对训练数据集进行了洗牌;否则,所有来自类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] 

许多机器学习和优化算法也需要特征缩放以获得最佳性能,正如我们在第二章中的梯度下降示例中看到的那样。在这里,我们将使用 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接口会让你想起我们在第二章中实现的感知器。在从linear_model模块加载Perceptron类之后,我们初始化了一个新的Perceptron对象,并通过fit方法训练了模型。在这里,模型参数eta0相当于我们自己感知器实现中使用的学习率eta

正如您在第二章中记得的那样,找到合适的学习率需要一些实验。如果学习率太大,算法将会超出全局损失最小值。如果学习率太小,算法将需要更多的周期直到收敛,这可能会使学习变慢,尤其是对于大型数据集。此外,我们使用了random_state参数来确保每个周期后对训练数据集的初始洗牌具有可重复性。

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

>>> 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–error = 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 

过拟合

请注意,在本章中,我们将根据测试数据集评估模型的性能。在第六章中,您将学习到一些有用的技术,包括图形分析,如学习曲线,以检测和预防过拟合。过拟合是指模型在训练数据中捕捉到模式,但在未见数据中无法很好泛化。

最后,我们可以使用第二章中的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 = ('o', 's', '^', '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))
    lab = classifier.predict(np.array([xx1.ravel(), xx2.ravel()]).T)
    lab = lab.reshape(xx1.shape)
    plt.contourf(xx1, xx2, lab, 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=f'Class {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='none', 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() 

正如我们在结果图中所看到的,三种花的类别不能完全通过线性决策边界分开:

图表 描述自动生成,置信度较低

图 3.1:适用于鸢尾花数据集的多类感知器模型的决策边界

然而,请记住我们在第二章中的讨论,感知器算法在数据集上永远不会收敛,除非数据集完全线性可分,这就是为什么实际上不建议在实践中使用感知器算法的原因。在接下来的章节中,我们将看看更强大的线性分类器,即使类别不是完全线性可分的,它们也会收敛到损失最小值。

额外的感知器设置

Perceptron以及其他 scikit-learn 函数和类通常具有我们为了清晰起见而省略的其他参数。您可以使用 Python 中的help函数(例如help(Perceptron))或通过查阅优秀的 scikit-learn 在线文档scikit-learn.org/stable/了解更多有关这些参数的信息。

通过逻辑回归对类别概率建模

尽管感知器规则为分类的机器学习算法提供了一个不错和简单的介绍,但它的最大缺点是,如果类别不是完全线性可分的,它永远不会收敛。前一节中的分类任务就是这种情况的一个例子。原因在于权重不断更新,因为每个时期至少存在一个误分类的训练示例。当然,您可以改变学习率并增加时期的数量,但请注意,感知器将永远不会在这个数据集上收敛。

为了更好地利用我们的时间,现在我们将看一看另一种简单但更强大的线性和二元分类问题的算法:逻辑回归。请注意,尽管名为逻辑回归,但逻辑回归是一种分类模型,而不是回归模型。

逻辑回归和条件概率

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

逻辑回归用于多类

逻辑回归可以方便地推广到多类设置中,这被称为多项逻辑回归Softmax 回归。关于多项逻辑回归的更详细覆盖超出了本书的范围,但有兴趣的读者可以在我的讲义笔记中找到更多信息,网址为sebastianraschka.com/pdf/lecture-notes/stat453ss21/L08_logistic__slides.pdfyoutu.be/L0FU8NFpx4E

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

要解释作为二元分类的概率模型的逻辑回归的主要机制,让我们首先介绍几率:支持特定事件的几率。几率可以写作,其中p代表积极事件的概率。术语“积极事件”并不一定意味着“好”,而是指我们要预测的事件,例如,患者在某些症状下患某种疾病的概率;我们可以将积极事件看作类标签y = 1,症状看作特征x。因此,简要地说,我们可以定义概率pp(y = 1|x),即给定其特征x的特定示例属于某个类 1 的条件概率。

接着我们可以进一步定义logit函数,它就是对数几率的对数(log-odds):

注意log表示自然对数,这是计算机科学中的常规约定。logit函数接受范围为 0 到 1 的输入值,并将它们转换为整个实数范围的值。

在逻辑模型下,我们假设加权输入(在第二章中称为净输入)与对数几率之间存在线性关系:

尽管前面描述了我们对于对数几率与净输入之间的线性关系的假设,我们实际上感兴趣的是概率p,即给定其特征的示例的类成员概率。虽然 logit 函数将概率映射到实数范围,我们可以考虑该函数的反函数将实数范围映射回[0, 1]范围的概率p

这个 logit 函数的反函数通常称为逻辑 Sigmoid 函数,由于其典型的 S 形状,有时简称为Sigmoid 函数

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

z = w^Tx + b

现在,让我们简单地绘制 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)
>>> sigma_z = sigmoid(z)
>>> plt.plot(z, sigma_z)
>>> plt.axvline(0.0, color='k')
>>> plt.ylim(-0.1, 1.1)
>>> plt.xlabel('z')
>>> plt.ylabel('$\sigma (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 形(sigmoid)曲线:

图表,直方图 自动生成的描述

图 3.2:逻辑 Sigmoid 函数的绘图

我们可以看到,如果z趋向于无穷大(z→∞),接近 1,因为ez 在z较大时变得非常小。类似地,如果z趋向于负无穷大(z→–∞),趋向于 0,这是因为分母变得越来越大。因此,我们可以得出结论,这个 Sigmoid 函数接受实数值作为输入,并将它们转换为[0, 1]范围内的值,并在处截距。

要对逻辑回归模型建立一些理解,我们可以将其与第二章相关联。在 Adaline 中,我们使用了恒等函数,,作为激活函数。在逻辑回归中,这个激活函数简单地变成了我们之前定义的 sigmoid 函数。

Adaline 与逻辑回归之间的区别在下图中有所说明,唯一的区别在于激活函数:

Diagram, schematic  Description automatically generated

图 3.3:逻辑回归与 Adaline 比较

然后,通过 sigmoid 函数的输出解释为特定示例属于类 1 的概率,,给定其特征,x,并由权重和偏置参数化,wb。例如,如果我们计算一个特定花示例的 ,这意味着这个示例是Iris-versicolor花的概率为 80%。因此,这种花是Iris-setosa花的概率可以计算为 p(y = 0|xwb) = 1 – p(y = 1|xwb) = 0.2,或者 20%。

预测的概率可以简单地通过阈值函数转换为二进制结果:

如果我们看看 sigmoid 函数的前面的图,这等同于以下内容:

实际上,有许多应用场景不仅关注预测的类标签,而是特别关注预测类成员概率的估计(应用阈值函数之前的 sigmoid 函数输出)。例如,逻辑回归在天气预报中使用,不仅可以预测某一天是否会下雨,还可以报告降雨的可能性。同样地,逻辑回归可以用于根据某些症状预测患者患某种疾病的概率,这就是为什么逻辑回归在医学领域中非常流行的原因。

通过逻辑损失函数学习模型权重

您已经了解了如何使用逻辑回归模型预测概率和类标签;现在,让我们简要讨论如何拟合模型的参数,例如权重和偏置单元,wb。在前一章中,我们定义了均方误差损失函数如下:

我们最小化这个函数,以便学习我们的 Adaline 分类模型的参数。为了解释我们如何推导逻辑回归的损失函数,让我们首先定义可能性,,即当我们构建逻辑回归模型时要最大化的可能性,假设数据集中的个体示例是彼此独立的。该公式如下:

在实践中,最大化(自然)对数化简化了这个方程,这被称为对数似然函数:

首先,应用对数函数减少了数值下溢的可能性,这种情况可能发生在似然值非常小的情况下。其次,我们可以将因子的乘积转换为因子的总和,这样可以更容易地通过加法技巧获取此函数的导数,正如你可能从微积分中记得的那样。

推导似然函数

我们可以得到给定数据的模型似然性表达式,,如下所示。考虑到我们有一个二元分类问题,类标签为 0 和 1,我们可以将标签 1 视为伯努利变量——它可以取两个值,0 和 1,概率为 p。对于单个数据点,我们可以将这个概率写为

将这两个表达式放在一起,并使用简写 ,我们得到了伯努利变量的概率质量函数:

我们可以写出训练标签的似然性,假设所有训练示例是独立的,使用乘法规则计算所有事件发生的概率,如下所示:

现在,代入伯努利变量的概率质量函数,我们得到似然性的表达式,通过改变模型参数来最大化:

现在,我们可以使用梯度上升等优化算法来最大化这个对数似然函数。(梯度上升的工作方式与第二章中解释的梯度下降完全相同,只是梯度上升是最大化一个函数,而不是最小化它。)或者,让我们将对数似然重写为可以使用梯度下降最小化的损失函数 L,如第二章中所述:

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

查看方程式,我们可以看到如果 y = 0,第一项变为零,如果 y = 1,第二项变为零:

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

结果绘制显示了在 x 轴上 0 到 1 范围内的 sigmoid 激活(sigmoid 函数的输入为范围在 –10 到 10 的 z 值),以及与之相关联的逻辑损失在 y 轴上:

图表 说明自动生成

图 3.4:逻辑回归中使用的损失函数图

我们可以看到,如果我们正确预测一个示例属于类别 1(连续线),损失接近于 0。类似地,我们可以看到在 y 轴上,如果我们正确预测 y = 0(虚线),损失也接近于 0。然而,如果预测错误,损失将趋向于无穷大。主要问题是我们通过越来越大的损失来惩罚错误的预测。

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

如果我们要自己实现逻辑回归,我们可以简单地在 第二章 中的 Adaline 实现中用新的损失函数 L 替换原有的损失函数:

我们使用这个来计算每个时期分类所有训练示例的损失。此外,我们需要用 sigmoid 函数替换线性激活函数。如果我们对 Adaline 代码进行这些更改,我们将得到一个可工作的逻辑回归实现。以下是完全批量梯度下降的实现(但请注意,同样的更改也可以应用于随机梯度下降版本):

class LogisticRegressionGD:
    """Gradient descent-based logistic regression 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 training.
    b_ : Scalar
      Bias unit after fitting.
    losses_ : list
      Mean squared error loss function values 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 : Instance of LogisticRegressionGD
        """
        rgen = np.random.RandomState(self.random_state)
        self.w_ = rgen.normal(loc=0.0, scale=0.01, size=X.shape[1])
        self.b_ = np.float_(0.)
        self.losses_ = []
        for i in range(self.n_iter):
            net_input = self.net_input(X)
            output = self.activation(net_input)
            errors = (y - output)
            self.w_ += self.eta * 2.0 * X.T.dot(errors) / X.shape[0]
            self.b_ += self.eta * 2.0 * errors.mean()
            loss = (-y.dot(np.log(output))
                   - ((1 - y).dot(np.log(1 - output)))
                    / X.shape[0])
            self.losses_.append(loss)
        return self
    def net_input(self, X):
        """Calculate net input"""
        return np.dot(X, self.w_) + self.b_
    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.activation(self.net_input(X)) >= 0.5, 1, 0) 

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

因此,让我们仅考虑山鸢尾和变色鸢尾花(类别 01),并检查我们的逻辑回归实现是否有效:

>>> X_train_01_subset = X_train_std[(y_train == 0) | (y_train == 1)]
>>> y_train_01_subset = y_train[(y_train == 0) | (y_train == 1)]
>>> lrgd = LogisticRegressionGD(eta=0.3,
...                             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() 

所得的决策区域图如下所示:

自动生成的散点图说明

图 3.5:逻辑回归模型的决策区域图

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

如果您将上一章中的 LogisticRegressionGD 与第二章中的 AdalineGD 代码进行比较,您可能会注意到权重和偏置更新规则保持不变(除了缩放因子 2)。使用微积分,我们可以证明梯度下降通过逻辑回归和 Adaline 更新参数的确是相似的。但请注意,下面导出的梯度下降学习规则的推导是为了对数回归梯度下降学习规则背后的数学概念感兴趣的读者。对于本章其余部分的跟进,这并非必需。

图 3.6 总结了我们如何计算对第 j 个权重的对数似然函数的偏导数:

自动生成的图表说明

图 3.6:计算对数似然函数的偏导数

注意,为简洁起见,我们省略了对训练示例的平均值。

请记住,从 第二章 我们知道,我们在梯度相反的方向上采取步骤。因此,我们反转 并更新第 j 个权重如下,包括学习率

虽然未显示损失函数对偏置单位的偏导数,但是偏置导数遵循相同的链式法则概念,导致以下更新规则:

权重和偏置单位的更新与 第二章 中 Adaline 的更新相同。

使用 scikit-learn 训练 logistic 回归模型

在前一小节中,我们刚刚完成了有用的编码和数学练习,这有助于说明 Adaline 和 logistic 回归之间的概念差异。 现在,让我们学习如何使用 scikit-learn 更优化的 logistic 回归实现,它还支持即插即用的多类设置。 请注意,在 scikit-learn 的最新版本中,用于多类分类的技术,即 multinomial 或 OvR,是自动选择的。 在以下代码示例中,我们将使用 sklearn.linear_model.LogisticRegression 类以及熟悉的 fit 方法在标准化的花卉训练数据集中训练模型的所有三个类。 另外,我们设置 multi_class='ovr' 以进行说明目的。 作为读者的练习,您可能希望将结果与 multi_class='multinomial' 进行比较。 请注意,multinomial 设置现在是 scikit-learn 的 LogisticRegression 类的默认选择,并且在实践中推荐用于互斥类,例如在 Iris 数据集中找到的类。 在这里,“互斥” 意味着每个训练示例只能属于一个单一类(与多标签分类相对,其中训练示例可以是多个类的成员)。

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

>>> from sklearn.linear_model import LogisticRegression
>>> lr = LogisticRegression(C=100.0, 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() 

在对训练数据拟合模型之后,我们绘制了决策区域、训练示例和测试示例,如 图 3.7 所示:

一个包含图表描述的图片

图 3.7:scikit-learn 的多类 logistic 回归模型的决策区域

凸优化算法

请注意,存在许多不同的算法用于解决优化问题。 对于最小化凸损失函数(例如 logistic 回归损失),建议使用比常规的 随机梯度下降 (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)算法(en.wikipedia.org/wiki/Limited-memory_BFGS),在这方面更加灵活。

查看我们用于训练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,这意味着第一个示例属于类别 3(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]) 

通过正则化解决过拟合

过拟合是机器学习中常见的问题,指的是模型在训练数据上表现良好,但在未见过的数据(测试数据)上表现不佳。如果一个模型存在过拟合问题,我们也会说这个模型具有高方差,这可能是由于参数过多导致模型过于复杂,无法很好地适应基础数据。同样,我们的模型也可能遭受欠拟合(高偏差)的问题,这意味着我们的模型不够复杂,无法很好地捕捉训练数据中的模式,因此在未见数据上也表现较差。

尽管到目前为止我们只遇到过用于分类的线性模型,但通过将线性决策边界与更复杂、非线性决策边界进行比较,可以最好地说明过拟合和欠拟合的问题,如在图 3.8中所示:

图表,雷达图 说明自动生成

图 3.8:欠拟合、拟合良好和过拟合模型的示例

偏差-方差权衡

研究人员经常使用“偏差”和“方差”或“偏差-方差权衡”这些术语来描述模型的性能,也就是说,你可能会听到有人说某个模型具有“高方差”或“高偏差”。那么,这是什么意思呢?一般来说,我们可能会说“高方差”与过拟合成正比,“高偏差”与欠拟合成正比。

在机器学习模型的背景下,方差衡量了在多次重新训练模型时,例如在训练数据集的不同子集上,为对特定示例进行分类,模型预测的一致性(或变异性)。我们可以说模型对训练数据中随机性敏感。相反,偏差衡量了多次在不同训练数据集上重新构建模型时,预测值与正确值的偏差程度;偏差是衡量由于非随机性而产生的系统误差的指标。

如果你对“偏差”和“方差”这两个术语的技术规范和推导感兴趣,可以在我的讲义中找到相关内容:sebastianraschka.com/pdf/lecture-notes/stat451fs20/08-model-eval-1-intro__notes.pdf

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

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

在这里,被称为正则化参数。请注意,分母中的 2 只是一个缩放因子,因此在计算损失梯度时会被抵消。样本大小n被添加以将正则化项缩放类似于损失。

正则化和特征标准化

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

逻辑回归的损失函数可以通过添加简单的正则化项进行正则化,这将在模型训练过程中缩小权重:

未正则化损失的偏导数定义如下:

将正则化项添加到损失函数中会改变偏导数的形式如下:

通过正则化参数,我们可以控制拟合训练数据的紧密程度,同时保持权重较小。通过增加的值,我们增加了正则化强度。请注意,偏置单元,本质上是拦截项或负阈值,如我们在第二章中学到的那样,通常不被正则化。

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

>>> weights, params = [], []
>>> for c in np.arange(-5, 5):
...     lr = LogisticRegression(C=10.**c,
...                             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)的权重系数,对所有分类器进行了采集。请记住,我们正在使用一对多技术进行多类分类。

正如我们在生成的图表中所看到的,如果减少参数C,即增加正则化强度,权重系数会收缩:

图表,线图  自动生成描述

图 3.9:逆正则化强度参数 C 对 L2 正则化模型结果的影响

增加正则化强度可以减少过拟合,因此我们可能会问为什么不默认强力正则化所有模型。原因是我们在调整正则化强度时必须小心。例如,如果正则化强度过高,权重系数接近零,模型可能因为欠拟合而表现非常糟糕,正如图 3.8所示。

关于逻辑回归的额外资源

由于个别分类算法的深入涵盖超出了本书的范围,推荐给希望了解更多关于逻辑回归的读者,《逻辑回归:从入门到高级概念与应用》,斯科特·梅纳德博士,Sage Publications,2009 年。

使用支持向量机进行最大间隔分类

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

这在图 3.10中有所说明:

图,图表,散点图 描述自动生成

图 3.10:SVM 在决策边界和训练数据点之间最大化间隔

最大间隔直觉

决策边界具有大间隔的背后理念在于,它们往往具有较低的泛化误差,而具有小间隔的模型更容易过拟合。

不幸的是,虽然 SVM 背后的主要直觉相对简单,但其中的数学内容相当深奥,需要对约束优化有扎实的理解。

因此,SVM 中最大间隔优化背后的细节超出了本书的范围。但是,如果您有兴趣了解更多内容,我们建议以下资源:

  • 克里斯·J·C·伯吉斯在《支持向量机模式识别的教程》(数据挖掘与知识发现,2(2): 121-167, 1998)中的出色解释

  • 弗拉基米尔·瓦普尼克的著作《统计学习理论的本质》,Springer Science+Business Media, 2000

  • 安德鲁·吴的非常详细的讲义笔记,可在see.stanford.edu/materials/aimlcs229/cs229-notes3.pdf获取

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

虽然我们不打算深入探讨最大间隔分类背后更复杂的数学概念,但让我们简要提到所谓的松弛变量,它由弗拉基米尔·瓦普尼克在 1995 年引入,导致了所谓的软间隔分类。引入松弛变量的动机是,在非线性可分数据中,SVM 优化目标中的线性约束需要放宽,以允许在适当的损失惩罚下优化的收敛,即使存在分类错误。

松弛变量的使用引入了常被称为S的变量,在支持向量机的上下文中,我们可以把S看作是控制误分类惩罚的超参数。较大的S值对应着较大的误差惩罚,而如果选择较小的S值,则对误分类错误的严格性较低。我们可以使用S参数来控制边界的宽度,从而调整偏差-方差的权衡,如图 3.11所示:

图表,散点图  自动生成的描述

图 3.11: 逆正则化强度大值和小值对分类的影响

这个概念与正则化有关,我们在前一节中讨论了正则化回归的情况,在这种情况下,减小C的值会增加模型的偏差(欠拟合),降低方差(过拟合)。

现在我们已经了解了线性支持向量机的基本概念,让我们训练一个 SVM 模型来对我们鸢尾花数据集中的不同花进行分类:

>>> 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() 

在使用前述代码示例训练鸢尾花数据集的分类器后,展示了支持向量机的三个决策区域,如图 3.12所示:

一张包含散点图  自动生成的图片描述

图 3.12: 支持向量机的决策区域

逻辑回归与支持向量机

在实际分类任务中,线性逻辑回归和线性支持向量机通常产生非常相似的结果。逻辑回归试图最大化训练数据的条件概率,这使得它比支持向量机更容易受到异常值的影响,后者主要关注最靠近决策边界的点(支持向量)。另一方面,逻辑回归有简单模型的优势,并且更容易实现,数学上更容易解释。此外,逻辑回归模型可以轻松更新,在处理流数据时非常有吸引力。

在 scikit-learn 中的替代实现方式

scikit-learn 库中的LogisticRegression类,我们在前面的章节中使用过,可以通过设置solver='liblinear'来使用 LIBLINEAR 库。LIBLINEAR 是台湾大学开发的高度优化的 C/C++库(www.csie.ntu.edu.tw/~cjlin/liblinear/)。

同样地,我们用来训练支持向量机的SVC类使用了 LIBSVM,这是一种专门用于支持向量机的等效 C/C++库(www.csie.ntu.edu.tw/~cjlin/libsvm/)。

使用 LIBLINEAR 和 LIBSVM 相比于例如原生 Python 实现的优势在于,它们允许快速训练大量线性分类器。然而,有时我们的数据集太大而无法放入计算机内存。因此,scikit-learn 还通过SGDClassifier类提供了替代实现,该类还通过partial_fit方法支持在线学习。SGDClassifier类的概念类似于我们在第二章中为 Adaline 实现的随机梯度算法。

我们可以初始化感知器的 SGD 版本(loss='perceptron')、逻辑回归(loss='log')和具有默认参数的 SVM(loss='hinge'),如下所示:

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

使用核 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, 0)
>>> plt.scatter(X_xor[y_xor == 1, 0],
...             X_xor[y_xor == 1, 1],
...             c='royalblue', marker='s',
...             label='Class 1')
>>> plt.scatter(X_xor[y_xor == 0, 0],
...             X_xor[y_xor == 0, 1],
...             c='tomato', marker='o',
...             label='Class 0')
>>> plt.xlim([-3, 3])
>>> plt.ylim([-3, 3])
>>> plt.xlabel('Feature 1')
>>> plt.ylabel('Feature 2')
>>> plt.legend(loc='best')
>>> plt.tight_layout()
>>> plt.show() 

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

自动生成的散点图描述

图 3.13:XOR 数据集的绘图

显然,如果我们使用线性超平面作为决策边界,通过前面章节讨论过的线性逻辑回归或线性支持向量机模型,我们将无法很好地将正类和负类的示例分开。

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

这使我们能够通过一个线性超平面将绘图中显示的两类分开,如果我们将其投影回原始特征空间,则这将成为一个非线性决策边界,如下所示的同心圆数据集:

自动生成的图表描述

图 3.14:使用核方法对非线性数据进行分类的过程

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

要使用支持向量机解决非线性问题,我们会通过映射函数 将训练数据转换为更高维度的特征空间,并训练一个线性支持向量机模型来在这个新的特征空间中对数据进行分类。然后,我们可以使用同样的映射函数 将新的未见数据进行转换,并使用线性支持向量机模型进行分类。

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

虽然我们没有详细介绍如何解决二次规划任务来训练支持向量机,但在实践中,我们只需将点积 x(i)Tx(j^) 替换为 。为了避免显式计算两个点之间的点积步骤,我们定义了所谓的核函数

最常用的核函数之一是径向基函数RBF)核,也可以简称为高斯核

这通常被简化为:

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

大致而言,术语“核”可以被解释为一对示例之间的相似度函数。负号将距离度量反转为相似度评分,并且由于指数项的存在,结果的相似度评分将落入 1(完全相似示例)到 0(非常不相似示例)的范围内。

现在我们已经了解了核技巧背后的大局,让我们看看能否训练一个能够很好地分离异或数据的核支持向量机。在这里,我们简单地使用我们之前导入的 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() 

如我们在结果图中所见,核支持向量机相对较好地分离了异或数据:

图表,散点图  生成的描述

图 3.15:使用核方法在异或数据上的决策边界

我们将参数 ,设置为 gamma=0.1,可以理解为高斯球体的截断参数。如果增加 的值,我们将增加训练示例的影响或范围,这将导致更紧密和更崎岖的决策边界。为了更好地理解 ,让我们将 RBF 核支持向量机应用于我们的鸢尾花数据集:

>>> 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 核支持向量机模型的决策边界将比较柔和,如图 3.16所示:

一个包含图表的图片  生成的描述

图 3.16:使用具有小图 3.17值的 RBF 核 SVM 模型在鸢尾花数据集上的决策边界

现在,让我们增加图 3.17中的值并观察决策边界的效果:

>>> 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() 

图 3.17中,我们现在可以看到围绕类别01的决策边界使用了一个相对较大的值的情况:

散点图 描述自动生成

图 3.17:使用具有大图 3.17值的 RBF 核 SVM 模型在鸢尾花数据集上的决策边界

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

决策树学习

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

考虑以下例子,我们使用决策树来决定特定一天的活动:

图 3.18:决策树的一个示例

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

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

最大化信息增益 - 在投入最少的前提下获得最大的收益

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

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

在二叉决策树中常用的三种不纯度度量或分裂准则是基尼不纯度I[G])、I[H])和分类错误I[E])。让我们从所有非空类的熵定义开始():

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

为了提供直观感受,让我们通过以下代码可视化不同类分布的熵值:

>>> def entropy(p):
...     return - p * np.log2(p) - (1 - p) * np.log2((1 - p))
>>> x = np.arange(0.0, 1.0, 0.01)
>>> ent = [entropy(p) if p != 0 else None for p in x]
>>> plt.ylabel('Entropy')
>>> plt.xlabel('Class-membership probability p(i=1)')
>>> plt.plot(x, ent)
>>> plt.show() 

图 3.19 如下所示是前述代码生成的图形:

Venn diagram  Description automatically generated

图 3.19:不同类成员概率的熵值

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

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

然而,在实践中,基尼不纯度和熵通常产生非常相似的结果,因此通常不值得花费大量时间评估不同的不纯度准则,而是试验不同的修剪阈值。实际上,正如您将在图 3.21 中看到的那样,基尼不纯度和熵的形状是相似的。

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

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

图 3.20:决策树数据分割

我们从父节点开始,有一个数据集 D[p],其中包含来自类别 1 的 40 个示例和来自类别 2 的 40 个示例,我们将其分成两个数据集 D[left] 和 D[right]。使用分类错误作为分裂准则的信息增益在情景 A 和 B 中是相同的 (IG[E] = 0.25):

然而,基尼不纯度会偏向于情景 B 的分裂(),而非情景 A(IG[G] = 0.125),这的确更加纯净:

类似地,熵准则也会偏向于情景 B (IG[H] = 0.31),而非情景 A (IG[H] = 0.19):

为了更直观地比较我们之前讨论过的三种不纯度准则,让我们绘制类别 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')
>>> plt.show() 

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

自动生成描述的图表

图 3.21:不同类别成员概率在 0 到 1 之间的不纯度指数

构建决策树

决策树可以通过将特征空间划分为矩形来构建复杂的决策边界。然而,我们必须小心,因为决策树越深,决策边界就会变得越复杂,这很容易导致过拟合。使用 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() 

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

自动生成描述的散点图

图 3.22:使用决策树的鸢尾花数据的决策边界

在 scikit-learn 中的一个很好的特性是,它允许我们在训练后直接可视化决策树模型,代码如下:

>>> from sklearn import tree
>>> feature_names = ['Sepal length', 'Sepal width',
...                  'Petal length', 'Petal width']
>>> tree.plot_tree(tree_model,
...                feature_names=feature_names,
...                filled=True)
>>> plt.show() 

包含自动生成描述的文字的图片

图 3.23:拟合到鸢尾花数据集的决策树模型

在我们调用的 plot_tree 函数中设置 filled=True 将会根据该节点处的主要类标签为节点着色。还有许多其他选项可供选择,您可以在文档中找到:scikit-learn.org/stable/modules/generated/sklearn.tree.plot_tree.html

查看决策树图,现在我们可以清楚地追溯决策树从训练数据集中确定的分割。关于每个节点的特征分割标准,请注意,向左的分支对应“True”,向右的分支对应“False”。

查看根节点,顶部有 105 个示例。第一个分割使用萼片宽度≤ 0.75 cm 的截止值将根节点分割为两个子节点,左子节点有 35 个示例,右子节点有 70 个示例。第一个分割后,我们可以看到左子节点已经是纯净的,只包含来自Iris-setosa类的示例(基尼不纯度 = 0)。然后,右侧的进一步分割用于将示例从Iris-versicolorIris-virginica类中分离出来。

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

另外,scikit-learn 提供了决策树的自动成本复杂度后修剪过程。有兴趣的读者可以在以下教程中找到关于这个更高级主题的更多信息:scikit-learn.org/stable/auto_examples/tree/plot_cost_complexity_pruning.html

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

集成方法在机器学习应用中已经广受欢迎,因为它们具有良好的分类性能并且对过拟合具有鲁棒性。虽然我们将在第七章“组合不同模型进行集成学习”中涵盖不同的集成方法,包括装袋提升,让我们先讨论基于决策树的随机森林算法,这个算法以其良好的可扩展性和易用性而闻名。随机森林可以被看作是决策树的集成。随机森林的理念是将多个(深度)决策树进行平均,这些树个体上具有高方差,以建立一个更健壮的模型,具有更好的泛化性能,并且不易过拟合。随机森林算法可以总结为四个简单步骤:

  1. 绘制大小为n的随机bootstrap样本(从训练数据集中用替换随机选择n个示例)。

  2. 从 bootstrap 样本中生长一棵决策树。在每个节点:

    1. 随机选择d个特征,不替换。

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

  3. 重复步骤 1-2 k次。

  4. 通过每棵树的预测结果进行聚合,通过多数投票分配类标签。关于多数投票的详细讨论将在第七章中进行。

在训练单独的决策树时,我们需要注意步骤 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)。通常来说,树的数量越多,随机森林分类器的性能越好,但计算成本也会增加。

虽然在实践中较少见,随机森林分类器的其他超参数也可以进行优化——我们将在第六章“学习模型评估和超参数调优最佳实践”中讨论使用的技术——分别是 bootstrap 样本的大小n步骤 1)和每次分割时随机选择的特征数d步骤 2a)。通过 bootstrap 样本的大小n,我们控制随机森林的偏差-方差权衡。

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

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

幸运的是,我们不必自己构建随机森林分类器,因为在 scikit-learn 中已经有了一个可以使用的实现:

>>> from sklearn.ensemble import RandomForestClassifier
>>> forest = RandomForestClassifier(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() 

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

散点图 描述自动生成

图 3.24: 使用随机森林在鸢尾花数据集上的决策边界

使用上述代码,我们通过n_estimators参数训练了一个包含 25 棵决策树的随机森林。默认情况下,它使用基尼不纯度作为节点分裂的标准。尽管我们从一个非常小的训练数据集中生成了一个非常小的随机森林,但出于演示目的,我们使用了n_jobs参数,它允许我们在计算机的多个核心(这里是两个核心)上并行化模型训练。如果您在此代码中遇到错误,则可能是您的计算机不支持多进程。您可以省略n_jobs参数或将其设置为n_jobs=None

K 最近邻算法 — 一种惰性学习算法

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

KNN 是“懒惰学习器”的典型例子。它之所以被称为“懒惰”,不是因为它的表面简单性,而是因为它不从训练数据中学习判别函数,而是记忆训练数据集。

参数化与非参数化模型

机器学习算法可以分为参数化和非参数化模型。使用参数化模型,我们从训练数据集中估计参数,以学习可以分类新数据点的函数,而无需再需要原始训练数据集。典型的参数化模型包括感知器、逻辑回归和线性支持向量机。相比之下,非参数化模型无法用固定的一组参数来描述,并且参数数量随着训练数据量的增加而变化。到目前为止,我们看到的两个非参数化模型的例子是决策树分类器/随机森林和核(非线性)支持向量机。

KNN 属于描述为基于实例学习的非参数模型的子类。基于实例学习的模型以记忆训练数据集为特征,惰性学习是与学习过程中没有(零)成本相关的基于实例学习的特殊情况。

KNN 算法本身非常简单,可以通过以下步骤进行总结:

  1. 选择k的数量和距离度量

  2. 找出我们要分类的数据记录的k个最近邻居

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

图 3.25说明了如何根据其五个最近邻中的多数投票将新数据点(?)分配到三角形类标签:

图 3.25:k 近邻如何工作

根据选择的距离度量,KNN 算法找出训练数据集中与我们要分类的点最接近(最相似)的k个示例。然后,数据点的类标签由其k个最近邻之间的多数投票确定。

基于内存的方法的优缺点

这种基于内存的方法的主要优势在于,随着我们收集新的训练数据,分类器立即适应。然而,缺点是在最坏情况下,对新示例进行分类的计算复杂度随着训练数据集中示例数量的线性增长而增加,除非数据集的维度(特征)非常少,并且算法使用了有效的数据结构来更有效地查询训练数据。这些数据结构包括 k-d 树(en.wikipedia.org/wiki/K-d_tree)和球树(en.wikipedia.org/wiki/Ball_tree),这两者都受到 scikit-learn 支持。此外,除了查询数据的计算成本之外,大型数据集在存储能力有限的情况下也可能存在问题。

然而,在许多情况下,当我们处理相对较小到中等大小的数据集时,基于内存的方法可以提供良好的预测和计算性能,因此是处理许多现实世界问题的良好选择。最近使用最近邻方法的示例包括预测制药药物靶标属性(Machine Learning to Identify Flexibility Signatures of Class A GPCR Inhibition,Biomolecules,2020,Joe Bemister-Buffington,Alex J. Wolf,Sebastian Raschka 和 Leslie A. Kuhn,www.mdpi.com/2218-273X/10/3/454)和最先进的语言模型(Efficient Nearest Neighbor Language Models,2021,Junxian He,Graham Neubig 和 Taylor Berg-Kirkpatrick,arxiv.org/abs/2109.04212)。

通过执行以下代码,我们将使用 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 模型中指定五个邻居,我们得到一个相对平滑的决策边界,如图 3.26所示:

包含地图描述的图片 自动产生

图 3.26:鸢尾花数据集上的 k 近邻决策边界

解决平局

在平局的情况下,scikit-learn 实现的 KNN 算法会优先选择与要分类的数据记录距离较近的邻居。如果邻居的距离相似,则算法会选择在训练数据集中出现较早的类标签。

正确选择k值对于找到过拟合和欠拟合之间的良好平衡至关重要。我们还必须确保选择一个适合数据集特征的距离度量。通常,对于实值示例,如我们的鸢尾花数据集中的花,简单的欧氏距离度量常被使用,其特征以厘米为单位测量。然而,如果我们使用欧氏距离度量,同样重要的是标准化数据,以确保每个特征对距离的贡献相等。我们在之前代码中使用的minkowski距离只是欧氏距离和曼哈顿距离的一般化,可以写成如下形式:

如果设置参数p=2,则变为欧氏距离,或在p=1时变为曼哈顿距离。scikit-learn 中提供了许多其他距离度量,并可提供给metric参数。它们列在scikit-learn.org/stable/modules/generated/sklearn.metrics.DistanceMetric.html上。

最后,需要提到的是 KNN 非常容易因为维度灾难而过拟合。维度灾难描述了在固定大小的训练数据集的维度增加时,特征空间变得越来越稀疏的现象。即使是最近的邻居在高维空间中也可能相距甚远,导致估计不准确。

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

带有 GPU 支持的替代机器学习实现

在处理大型数据集时,运行 k 最近邻算法或拟合具有许多估计器的随机森林可能需要大量的计算资源和处理时间。如果您的计算机配备了与最新版本的 NVIDIA CUDA 库兼容的 NVIDIA GPU,我们建议考虑使用 RAPIDS 生态系统(docs.rapids.ai/api)。例如,RAPIDS 的 cuML(docs.rapids.ai/api/cuml/stable/)库实现了许多带有 GPU 支持的 scikit-learn 机器学习算法,以加速处理速度。您可以在docs.rapids.ai/api/cuml/stable/estimator_intro.html找到 cuML 的介绍。如果您有兴趣了解更多关于 RAPIDS 生态系统的内容,请参阅我们与 RAPIDS 团队合作撰写的免费获取的期刊文章:《Python 中的机器学习:数据科学、机器学习和人工智能的主要发展和技术趋势》(www.mdpi.com/2078-2489/11/4/193)。

总结

在本章中,您学习了许多不同的机器学习算法,用于解决线性和非线性问题。您已经看到,如果我们关心解释性,决策树尤其具有吸引力。逻辑回归不仅是通过 SGD 进行在线学习的有用模型,还允许我们预测特定事件的概率。

虽然支持向量机(SVM)是强大的线性模型,可以通过核技巧扩展到非线性问题,但它们有许多需要调整的参数才能做出良好的预测。相比之下,集成方法如随机森林不需要太多参数调整,并且不像决策树那样容易过拟合,这使它们成为许多实际问题领域的理想模型。K 最近邻分类器通过惰性学习提供了一种替代分类的方法,允许我们在没有模型训练的情况下进行预测,但预测步骤更加消耗计算资源。

然而,比选择合适的学习算法更重要的是我们训练数据集中的可用数据。没有任何算法能够在没有信息丰富和有歧视性的特征的情况下做出良好的预测。

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

加入我们书籍的 Discord 空间

加入书籍的 Discord 工作区,与作者进行每月的问我任何会话:

packt.link/MLwPyTorch

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

数据的质量和包含的有用信息量是决定机器学习算法学习效果的关键因素。因此,在将数据提供给机器学习算法之前,确保我们对数据进行必要的检查和预处理是非常关键的。在本章中,我们将讨论关键的数据预处理技术,这些技术将帮助我们构建良好的机器学习模型。

本章中我们将讨论的主题包括:

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

  • 将分类数据准备好供机器学习算法使用

  • 选择用于模型构建的相关特征

处理缺失数据

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

在本节中,我们将通过从数据集中删除条目或从其他训练样本和特征填补缺失值来解决缺失值的几种实用技术。

在表格数据中识别缺失值

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

>>> 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_data的字符串读入 pandas 的DataFrame,就像它是硬盘上常规 CSV 文件一样。

对于较大的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 对象作为输入,但由于 scikit-learn API 中 NumPy 数组处理更为成熟,建议在可能的情况下使用 NumPy 数组。请注意,在将其馈送到 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()) 

图 4.1:用均值替换数据中的缺失值

用于缺失数据的其他填补方法

对于包括基于 k 最近邻方法的KNNImputer在内的其他填补技术,以通过最近邻来填补缺失特征,我们建议查阅 scikit-learn 填补文档 scikit-learn.org/stable/modules/impute.html

理解 scikit-learn 估计器 API

在前一节中,我们使用了 scikit-learn 中的SimpleImputer类来填补数据集中的缺失值。SimpleImputer类是 scikit-learn 中所谓的转换器API 的一部分,用于实现与数据转换相关的 Python 类。请注意,scikit-learn 转换器 API 与用于自然语言处理的 transformer 架构不要混淆,我们将在第十六章使用注意力机制改进自然语言处理的 Transformers中更详细地讨论后者。这些估计器的两个关键方法是fittransformfit方法用于从训练数据中学习参数,transform方法使用这些参数来转换数据。任何要转换的数据数组都需要与用于拟合模型的数据数组具有相同数量的特征。

图 4.2展示了一个在训练数据上拟合的 scikit-learn 转换器实例如何用于转换训练数据集以及新的测试数据集:

Diagram  Description automatically generated

图 4.2:使用 scikit-learn API 进行数据转换

我们在第三章使用 Scikit-Learn 进行机器学习分类器导览中使用的分类器属于 scikit-learn 中所谓的估计器,其 API 在概念上与 scikit-learn 转换器 API 非常相似。估计器具有一个predict方法,但也可以有一个transform方法,正如你将在本章后面看到的。正如你可能记得的那样,我们还使用fit方法来学习这些估计器进行分类时的模型参数。然而,在监督学习任务中,我们额外提供类标签来拟合模型,然后可以通过predict方法对新的未标记数据示例进行预测,如图 4.3所示:

Diagram  Description automatically generated

图 4.3:使用 scikit-learn API 进行分类器等预测模型的使用

处理分类数据

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

当我们谈论分类数据时,我们必须进一步区分序数名义特征。序数特征可以理解为可以排序或有序的分类值。例如,T 恤尺码就是一个序数特征,因为我们可以定义一个顺序:XL > L > M。相反,名义特征则不涉及任何顺序;继续上面的例子,我们可以认为 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 大于 bluered 大于 green。虽然这种假设是不正确的,分类器仍然可能产生有用的结果。然而,这些结果将不会是最优的。

对于这个问题的一个常见解决方案是使用一种称为 独热编码 的技术。这种方法的理念是为名义特征列中的每个唯一值创建一个新的虚拟特征。在这里,我们将把 color 特征转换为三个新特征:bluegreenred。二进制值可以用来表示示例的特定 color;例如,一个 blue 示例可以被编码为 blue=1green=0red=0。要执行这种转换,我们可以使用 scikit-learn 的 preprocessing 模块中实现的 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 方法更方便地创建这些虚拟特征的方法是应用于 DataFrameget_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]]) 

名义数据的附加编码方案

虽然独热编码是编码无序分类变量的最常见方式,但也存在几种替代方法。在处理具有高基数(大量唯一类别标签)的分类特征时,某些技术可能会很有用。例如:

  • 二进制编码,产生多个类似于独热编码的二进制特征,但需要较少的特征列,即log2而不是K – 1,其中K是唯一类别的数量。在二进制编码中,数字首先转换为二进制表示,然后每个二进制数位置将形成一个新的特征列。

  • 计数或频率编码,用训练集中每个类别出现的次数或频率替换每个类别的标签。

这些方法以及额外的分类编码方案都可以通过与 scikit-learn 兼容的category_encoders库来实现:contrib.scikit-learn.org/category_encoders/

虽然这些方法在模型性能方面并不能保证优于独热编码,但我们可以考虑选择分类编码方案作为改进模型性能的额外“超参数”。

可选:编码有序特征

如果我们不确定有序特征类别之间的数值差异,或者两个有序值之间的差异未定义,我们也可以使用阈值编码将其编码为 0/1 值。例如,我们可以将具有MLXL值的特征size拆分为两个新特征,x > Mx > 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 的DataFrameapply方法,通过写入自定义 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 

将数据集分成单独的训练集和测试集。

我们在第一章“使计算机能够从数据中学习”和第三章“使用 Scikit-Learn 进行机器学习分类器之旅”中简要介绍了将数据集划分为用于训练和测试的单独数据集的概念。请记住,在测试集中将预测与真实标签进行比较,可以理解为在我们将模型放入真实世界之前对其进行无偏差的性能评估。在本节中,我们将准备一个新的数据集,即Wine数据集。在我们预处理数据集之后,我们将探讨不同的特征选择技术以减少数据集的维度。

Wine 数据集是另一个开源数据集,可以从 UCI 机器学习库获取(archive.ics.uci.edu/ml/datasets/Wine);它包含了 178 个葡萄酒示例,其中 13 个特征描述了它们不同的化学特性。

获取 Wine 数据集

您可以在本书的代码包中找到 Wine 数据集的副本(以及本书中使用的所有其他数据集),如果您在离线工作或者 UCI 服务器上的数据集临时不可用时,您可以使用该数据集。例如,要从本地目录加载 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 个葡萄酒示例的化学特性,详见以下表:

图 4.4:Wine 数据集的样本

这些示例属于三个不同的类别之一,123,这些类别指的是在同一意大利地区种植的三种不同葡萄类型,但来自不同的葡萄酒品种,如数据集摘要所述(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 的划分也是常见且合适的。例如,如果数据集包含超过 10 万个训练样本,则仅保留 1 万个样本进行测试可能足以得到泛化性能的良好估计。更多信息和插图可以在我的文章《机器学习中的模型评估、模型选择和算法选择》第一章找到,该文章可以在arxiv.org/pdf/1811.12808.pdf免费获取。此外,我们将在第六章 学习模型评估和超参数调优的最佳实践中重新讨论模型评估的主题并进行更详细的讨论。

此外,与其在模型训练和评估后丢弃分配的测试数据,重新在整个数据集上训练分类器是一种常见的做法,因为这可以提高模型的预测性能。虽然这种方法通常是推荐的,但如果数据集很小且测试数据集包含异常值,例如,它可能导致更差的泛化性能。此外,在整个数据集上重新拟合模型之后,我们将没有任何独立的数据来评估其性能。

将特征调整到相同的尺度

  • 特征缩放 是我们预处理流程中一个关键的步骤,容易被忽视。决策树随机森林 是为数不多的两种机器学习算法,我们不需要担心特征缩放。这些算法是尺度不变的。然而,大多数机器学习和优化算法如果特征处于相同的尺度上表现更好,正如我们在第二章 用于分类的简单机器学习算法的训练中实现 梯度下降优化 算法时所看到的那样。

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

当我们考虑 Adaline 中的平方误差函数(来自第二章)时,可以说该算法主要忙于根据第二特征中较大的错误来优化权重。另一个例子是使用欧氏距离的k 最近邻KNN)算法:计算的示例间距离将由第二特征轴主导。

现在,有两种常见方法将不同的特征调整到相同的比例:归一化标准化。这些术语在不同领域中通常使用得相当松散,其含义必须从上下文中推断出来。最常见的情况是,归一化是指将特征重新缩放到[0, 1]的范围,这是最小-最大缩放的一种特殊情况。要将我们的数据归一化,我们可以简单地对每个特征列应用最小-最大缩放,其中示例的新值,,可以计算如下:

这里,x(i^) 是一个特定的示例,x[min] 是特征列中的最小值,x[max] 是最大值。

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

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

虽然通过最小-最大缩放进行标准化是一种常用的技术,当我们需要在有界区间内的值时很有用,但对于许多机器学习算法,特别是像梯度下降这样的优化算法,标准化可能更为实用。原因是许多线性模型,例如第三章中的逻辑回归和 SVM,将权重初始化为 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

表 4.1:标准化和最小-最大归一化的比较

您可以通过执行以下代码示例手动执行表中显示的标准化和归一化:

>>> 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 四分位数(即 25th 和 75th 分位数)来缩放数据集,使得更极端的值和异常值变得不那么显著。有兴趣的读者可以在官方 scikit-learn 文档中找到关于RobustScaler的更多信息,网址为scikit-learn.org/stable/modules/generated/sklearn.preprocessing.RobustScaler.html

选择有意义的特征

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

  • 收集更多的训练数据

  • 引入正则化通过复杂性来惩罚

  • 选择一个具有较少参数的简单模型

  • 减少数据的维度

增加更多的训练数据通常是不适用的。在第六章学习模型评估和超参数调优的最佳实践中,我们将学习一种有用的技术来检查是否增加更多的训练数据是有益的。在接下来的几节中,我们将探讨通过正则化和特征选择来减少过拟合的常见方法,从而通过需要较少参数来拟合数据的简化模型。然后,在第五章通过降维压缩数据,我们将查看其他的特征提取技术。

L1 和 L2 正则化作为抵抗模型复杂性的惩罚项

你还记得第三章讲到的L2 正则化是通过对大的个体权重进行惩罚来减少模型复杂度的一种方法。我们定义了权重向量w的平方 L2 范数如下:

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

在这里,我们简单地用权重的绝对值之和替换了权重的平方。与 L2 正则化相比,L1 正则化通常会产生稀疏的特征向量,大多数特征权重将为零。如果我们有一个高维数据集,有许多无关的特征,尤其是在训练样本比无关维度更多的情况下,稀疏性在实践中可能会很有用。从这个意义上讲,L1 正则化可以被理解为一种特征选择技术。

L2 正则化的几何解释

正如前一节提到的,L2 正则化向损失函数添加一个惩罚项,使得相比使用非正则化损失函数训练的模型具有较少极端的权重值。

为了更好地理解 L1 正则化如何促进稀疏性,让我们退一步,从正则化的几何解释开始。我们来绘制两个权重系数w[1]和w[2]的凸损失函数等高线。

在这里,我们将考虑均方误差MSE)损失函数,我们在第二章中用于 Adaline 的,它计算真实和预测类标签y之间的平方距离,平均值为所有N个训练集示例。由于 MSE 是球形的,比逻辑回归的损失函数更容易绘制;然而,相同的概念适用。记住,我们的目标是找到最小化训练数据损失函数的权重系数组合,如图 4.5所示(椭圆中心的点):

图表,工程图绘制 自动生成描述

图 4.5:最小化均方误差损失函数

我们可以将正则化视为向损失函数添加惩罚项以鼓励较小的权重;换句话说,我们惩罚较大的权重。因此,通过增加正则化参数来增强正则化强度,,我们将权重收缩到零附近,并减少模型对训练数据的依赖。让我们在以下图中以 L2 惩罚项说明这个概念:

图解释自动生成

图 4.6:将 L2 正则化应用于损失函数

二次 L2 正则化项由阴影球表示。在这里,我们的权重系数不能超出正则化预算——权重系数的组合不能超出阴影区域。另一方面,我们仍然希望最小化损失函数。在惩罚约束下,我们最好的选择是选择 L2 球与未惩罚损失函数轮廓相交的点。正则化参数值越大,惩罚损失增长速度越快,导致 L2 球越窄。例如,如果我们将正则化参数增加至无穷大,权重系数将有效变为零,即 L2 球的中心。总结这个示例的主要信息,我们的目标是最小化未惩罚损失加上惩罚项的总和,这可以理解为添加偏差并偏好简化模型以减少在缺乏足够训练数据来拟合模型时的方差。

L1 正则化下的稀疏解决方案

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

图解释自动生成

图 4.7:将 L1 正则化应用于损失函数

在上述图中,我们可以看到损失函数的轮廓与 L1 钻石在w[1] = 0 处接触。由于 L1 正则化系统的轮廓尖锐,最优解——即损失函数的椭圆和 L1 钻石边界的交点——更可能位于轴上,这鼓励稀疏性。

L1 正则化和稀疏性

L1 正则化如何导致稀疏解的数学细节超出了本书的范围。如果您有兴趣,可以在Trevor Hastie, Robert TibshiraniJerome Friedman, Springer Science+Business Media, 2009第 3.4 节中找到关于 L2 与 L1 正则化的优秀解释。

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

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

请注意,由于'lbfgs'当前不支持 L1 正则化损失优化,我们还需要选择不同的优化算法(例如,solver='liblinear')。应用于标准化的 Wine 数据,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.26317363, -1.21537306, -2.37111954]) 

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

>>> lr.coef_
array([[ 1.24647953,  0.18050894,  0.74540443, -1.16301108,
         0\.        ,0\.        ,  1.16243821,  0\.        ,
         0\.        ,  0\.        , 0\.        ,  0.55620267,
         2.50890638],
       [-1.53919461, -0.38562247, -0.99565934,  0.36390047,
        -0.05892612, 0\.        ,  0.66710883,  0\.        ,
         0\.        , -1.9318798 , 1.23775092,  0\.        ,
        -2.23280039],
       [ 0.13557571,  0.16848763,  0.35710712,  0\.        ,
         0\.        , 0\.        , -2.43804744,  0\.        ,
         0\.        ,  1.56388787, -0.81881015, -0.49217022,
         0\.        ]]) 

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

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

在 scikit-learn 中,intercept_对应于偏置单元,coef_对应于值w[j]。

由于 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 (inverse regularization strength)')
>>> 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是正则化参数的倒数,

一个包含图表描述的图片

图 4.8:正则化强度超参数 C 值的影响

顺序特征选择算法

减少模型复杂性和避免过拟合的另一种方法是通过特征选择进行降维,特别适用于未正则化的模型。主要有两类降维技术:特征选择特征提取。通过特征选择,我们选择原始特征的一个子集,而在特征提取中,我们特征集中提取信息以构建一个新的特征子空间。

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

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

经典的顺序特征选择算法是顺序向后选择SBS),其旨在减少初始特征子空间的维数,同时最小化分类器性能下降,以提高计算效率。在某些情况下,如果模型存在过拟合问题,SBS 甚至可以改善模型的预测能力。

贪婪搜索算法

贪婪算法在组合搜索问题的每个阶段都会做出局部最优选择,通常会得到问题的次优解,与穷举搜索算法相比,后者会评估所有可能的组合并保证找到最优解。然而,在实践中,穷举搜索通常计算量过大,而贪婪算法可以提供更简单、计算更高效的解决方案。

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

由准则函数计算的准则可以简单地是分类器在移除特定特征之前和之后性能差异。然后,在每个阶段,我们可以简单地定义要移除的特征为最大化此准则的特征;或者更简单地说,在每个阶段,我们消除导致去除后性能损失最小的特征。基于前述对 SBS 的定义,我们可以用四个简单步骤概述算法:

  1. 使用k = d初始化算法,其中d是完整特征空间X[d]的维数。

  2. 确定最大化准则的特征x–,其中**x**– = argmax J(X[k] – x),其中

  3. 从特征集中移除特征x^–:X[k][–1] = X[k] – x^–;k = k – 1。

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

    关于顺序特征算法的资源

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

为了练习我们的编码技能和实现我们自己的算法的能力,让我们从头开始用 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() 

正如我们在图 4.9中看到的,随着特征数量的减少,KNN 分类器在验证数据集上的准确率有所提高,这很可能是由于我们在第三章中讨论的 KNN 算法背景下维度诅咒的减少。此外,在下图中我们可以看到,对于k = {3, 7, 8, 9, 10, 11, 12},分类器在验证数据集上实现了 100%的准确率:

图 4.9:特征数量对模型准确率的影响

出于我们自己的好奇心,让我们看看最小的特征子集(k=3),它在验证数据集上表现出色:

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

使用上述代码,我们从sbs.subsets_属性的第 11 个位置获得了三特征子集的列索引,并从 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 中的特征选择算法

您可以在 Python 包mlxtendrasbt.github.io/mlxtend/user_guide/feature_selection/SequentialFeatureSelector/找到与我们之前实现的简单 SBS 相关的几种不同顺序特征选择的实现。虽然我们的mlxtend实现带有许多功能,但我们与 scikit-learn 团队合作实现了一个简化的、用户友好的版本,这已经成为最近 v0.24 版本的一部分。使用和行为与我们在本章实现的SBS代码非常相似。如果您想了解更多,请参阅scikit-learn.org/stable/modules/generated/sklearn.feature_selection.SequentialFeatureSelector.html的文档。

通过 scikit-learn 提供的特征选择算法有很多选择。这些包括基于特征权重的递归向后消除、基于重要性选择特征的基于树的方法以及单变量统计测试。本书不涵盖所有特征选择方法的详细讨论,但可以在scikit-learn.org/stable/modules/feature_selection.html找到一个具有说明性示例的良好总结。

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

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

>>> 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 

执行代码后,我们创建了一个图表,根据它们的相对重要性对 Wine 数据集中的不同特征进行了排序;请注意,特征重要性值已经标准化,使它们总和为 1.0。

图 4.10:基于 Wine 数据集的基于随机森林的特征重要性

根据 500 棵决策树中平均不纯度减少,我们可以得出,葡萄酒的脯氨酸和黄酮类水平、颜色强度、OD280/OD315 波谱和酒精浓度是数据集中最具区分性的特征。有趣的是,绘图中排名前两位的特征也出现在我们在前一节实施的 SBS 算法的三特征子集选择中(酒精浓度和稀释葡萄酒的 OD280/OD315)。

然而,就可解释性而言,随机森林技术有一个值得注意的重要。如果两个或更多特征高度相关,一个特征可能排名很高,而另一个特征的信息可能未能完全捕获。另一方面,如果我们只关心模型的预测性能而不是特征重要性值的解释,那么我们就不需要担心这个问题。

结束对特征重要性值和随机森林的讨论,值得一提的是,scikit-learn 还实现了一个SelectFromModel对象,该对象在模型拟合后基于用户指定的阈值选择特征。如果我们希望将RandomForestClassifier作为特征选择器和 scikit-learn 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 正则化,它可以通过减少模型的复杂性来帮助我们避免过拟合。作为移除不相关特征的替代方法,我们使用了顺序特征选择算法从数据集中选择有意义的特征。

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

加入我们书籍的 Discord 空间

加入书籍的 Discord 工作空间,与作者进行每月的问我任何事会话:

packt.link/MLwPyTorch

第五章:通过降维压缩数据

第四章构建良好的训练数据集 – 数据预处理中,您已经了解了使用不同特征选择技术来降低数据集维度的不同方法。作为降维的替代方法,特征提取允许您通过将数据转换到比原始维度更低的新特征子空间来总结数据集的信息内容。数据压缩是机器学习中的重要主题,它帮助我们存储和分析在技术发展的现代时代中产生和收集的增加量的数据。

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

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

  • 线性判别分析作为一种监督降维技术,旨在最大化类别可分性。

  • 关于非线性降维技术的简要概述和用于数据可视化的 t-分布随机邻居嵌入

通过主成分分析实现无监督降维

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

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

主成分分析中的主要步骤

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

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

图 5.1:使用 PCA 在数据集中找到最大方差的方向。

图 5.1中,x[1]和x[2]是原始特征轴,PC 1PC 2是主成分。

如果我们使用 PCA 进行降维,我们构造一个d×k维的变换矩阵W,它允许我们将训练示例的特征向量x映射到一个新的k维特征子空间,该子空间的维度少于原始的d维特征空间。例如,过程如下。假设我们有一个特征向量x

然后由变换矩阵变换。

xW = z

得到输出向量:

将原始d维数据变换到这个新的k维子空间(通常k << d)的结果是,第一个主成分将具有可能的最大方差。所有随后的主成分将具有最大方差,考虑到这些成分与其他主成分是不相关的(正交的)。需要注意的是,PCA 方向对数据缩放非常敏感,如果希望给所有特征赋予相同的重要性,并且这些特征是在不同尺度上测量的,则在 PCA 之前需要标准化特征。

在更详细地查看用于降维的 PCA 算法之前,让我们用几个简单的步骤总结这个方法:

  1. 标准化d维数据集。

  2. 构建协方差矩阵。

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

  4. 将特征值按降序排序,以排名对应的特征向量。

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

  6. 从“顶部”的k个特征向量构造投影矩阵W

  7. 使用投影矩阵Wd维输入数据集X转换为新的k维特征子空间。

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

特征分解:将矩阵分解为特征向量和特征值

特征分解,将一个方阵分解成所谓的特征值特征向量,是本节描述的 PCA 过程的核心。

协方差矩阵是方阵的一种特殊情况:它是对称矩阵,这意味着矩阵等于其转置,A = A^T。

当我们将这样的对称矩阵分解时,特征值是实数(而不是复数),特征向量彼此正交(垂直)。此外,特征值和特征向量是成对出现的。如果我们将协方差矩阵分解为其特征向量和特征值,与最高特征值相关联的特征向量对应于数据集中方差的最大方向。在这里,这个“方向”是数据集特征列的线性变换。

尽管本书不涉及特征值和特征向量的详细讨论,但可以在维基百科上找到相对详尽的处理方法和指向其他资源的指针,网址为en.wikipedia.org/wiki/Eigenvalues_and_eigenvectors

逐步提取主成分

在本小节中,我们将解决 PCA 的前四个步骤:

  1. 数据标准化

  2. 构建协方差矩阵

  3. 获得协方差矩阵的特征值和特征向量

  4. 将特征值按降序排列以排名特征向量

首先,我们将加载在第四章 构建良好的训练数据集 - 数据预处理 中使用过的葡萄酒数据集:

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

获取葡萄酒数据集

您可以在本书的代码包中找到葡萄酒数据集的副本(以及本书中使用的所有其他数据集),如果您离线工作或 UCI 服务器在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
) 

接下来,我们将逐步处理葡萄酒数据,将其分为独立的训练和测试数据集—分别使用数据的 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×d维协方差矩阵,其中d是数据集中的维数,它存储不同特征之间的成对协方差。例如,人口水平上两个特征x[j]和x[k]之间的协方差可以通过以下方程计算:

在这里,分别是特征jk的样本均值。请注意,如果我们标准化了数据集,样本均值将为零。两个特征之间的正协方差表明特征一起增加或减少,而负协方差表明特征以相反的方向变化。例如,三个特征的协方差矩阵可以写成如下形式(注意是希腊大写字母 sigma,与求和符号不要混淆):

协方差矩阵的特征向量代表主成分(方差最大的方向),而相应的特征值则定义了它们的大小。在 Wine 数据集的情况下,我们将从 13×13 维度的协方差矩阵中获得 13 个特征向量和特征值。

现在,进入我们的第三步,让我们获取协方差矩阵的特征对。如果您上过线性代数课程,可能已经了解到特征向量v满足以下条件:

在这里,是一个标量:特征值。由于手动计算特征向量和特征值有些冗长且复杂,我们将使用 NumPy 的linalg.eig函数来获取 Wine 协方差矩阵的特征对:

>>> import numpy as np
>>> cov_mat = np.cov(X_train_std.T)
>>> eigen_vals, eigen_vecs = np.linalg.eig(cov_mat)
>>> print('\nEigenvalues \n', 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 个特征值,并且将相应的特征向量存储为 13×13 维矩阵的列(eigen_vecs)。

NumPy 中的特征分解

numpy.linalg.eig函数被设计用于操作对称和非对称方阵。但是,在某些情况下,您可能会发现它返回复数特征值。

相关函数numpy.linalg.eigh已实现对分解 Hermetian 矩阵的操作,这是一种在处理诸如协方差矩阵等对称矩阵时更稳定的数值方法;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, 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%的方差:

图表,直方图 由系统自动生成的描述

图 5.2:主成分所捕获的总方差的比例

尽管解释方差图表提醒我们,我们在第四章中通过随机森林计算的特征重要性值,我们应该提醒自己 PCA 是一种无监督方法,这意味着忽略了类标签的信息。而随机森林使用类成员信息来计算节点不纯度,方差则测量了沿特征轴的值的分布范围。

特征变换

现在我们已经成功地将协方差矩阵分解为特征对,让我们继续进行最后三个步骤,将葡萄酒数据集转换到新的主成分轴上。本节中我们将处理的剩余步骤如下:

  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]] 

通过执行上述代码,我们创建了一个 13×2 维的投影矩阵W,由前两个特征向量构成。

镜像投影

取决于您使用的 NumPy 和 LAPACK 版本,您可能会获得矩阵W及其符号翻转的情况。请注意,这不是问题;如果v是矩阵的特征向量,则有:

在这里,v是特征向量,–v也是特征向量,我们可以如下展示。使用基本代数,我们可以将方程两边乘以标量

由于矩阵乘法对标量乘法是结合的,我们可以将其重新排列为以下形式:

现在,我们可以看到是具有相同特征值的特征向量,适用于。因此,v和–v都是特征向量。

利用投影矩阵,我们现在可以将一个例子x(表示为 13 维行向量)转换到 PCA 子空间(主成分一和二),得到x′,现在是一个由两个新特征组成的二维例子向量:

x′ = xW

>>> X_train_std[0].dot(w)
array([ 2.38299011,  0.45458499]) 

类似地,我们可以通过计算矩阵点积,将整个 124×13 维的训练数据集转换为两个主成分:

X′ = XW

>>> X_train_pca = X_train_std.dot(w) 

最后,让我们将转换后的 Wine 训练数据集可视化为一个 124×2 维的矩阵,在二维散点图中显示:

>>> colors = ['r', 'b', 'g']
>>> markers = ['o', 's', '^']
>>> 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=f'Class {l}', marker=m)
>>> plt.xlabel('PC 1')
>>> plt.ylabel('PC 2')
>>> plt.legend(loc='lower left')
>>> plt.tight_layout()
>>> plt.show() 

正如我们在图 5.3中所看到的,数据沿第一主成分(x轴)的分布比第二主成分(y轴)更广泛,这与我们在前一小节创建的解释方差比例图一致。然而,我们可以看出线性分类器很可能能够很好地分离这些类别:

图,散点图 说明自动生成

图 5.3:通过 PCA 将 Wine 数据集投影到二维特征空间

尽管我们在前面的散点图中对类标签信息进行了编码以说明问题,但我们必须记住,PCA 是一种不使用任何类标签信息的无监督技术。

在 scikit-learn 中的主成分分析

虽然在前一小节中详细的方法帮助我们理解 PCA 的内部工作,但现在我们将讨论如何使用 scikit-learn 中实现的 PCA 类。

PCA 类是 scikit-learn 的另一个转换器类之一,我们首先使用训练数据拟合模型,然后使用相同的模型参数转换训练数据和测试数据集。现在,让我们在 Wine 训练数据集上使用 scikit-learn 中的 PCA 类,通过逻辑回归对转换后的示例进行分类,并通过我们在 第二章分类简单机器学习算法的训练 中定义的 plot_decision_regions 函数可视化决策区域:

from matplotlib.colors import ListedColormap
def plot_decision_regions(X, y, classifier, test_idx=None, resolution=0.02):
    # setup marker generator and color map
    markers = ('o', 's', '^', '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))
    lab = classifier.predict(np.array([xx1.ravel(), xx2.ravel()]).T)
    lab = lab.reshape(xx1.shape)
    plt.contourf(xx1, xx2, lab, 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=f'Class {cl}',
                    edgecolor='black') 

为了您的方便,您可以将前述的 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() 

通过执行此代码,我们现在应该可以看到将训练数据的决策区域减少为两个主成分轴:

图表,散点图  自动生成描述

图 5.4: 使用 scikit-learn 的 PCA 进行降维后的训练示例和逻辑回归决策区域

当我们将 scikit-learn 中的 PCA 投影与我们自己的 PCA 实现进行比较时,我们可能会看到生成的图是彼此的镜像。请注意,这不是这两个实现中的任何一个错误的原因;这种差异的原因是,依赖于特征求解器,特征向量可以具有负或正的符号。

并不重要,但如果我们希望,可以通过将数据乘以 -1 简单地将镜像图像还原;请注意,特征向量通常缩放为单位长度 1。为了完整起见,让我们绘制转换后测试数据集上的逻辑回归决策区域,以查看它是否能很好地分离类别:

>>> plot_decision_regions(X_test_pca, y_test, classifier=lr)
>>> plt.xlabel('PC 1')
>>> plt.ylabel('PC 2')
>>> plt.legend(loc='lower left')
>>> plt.tight_layout()
>>> plt.show() 

在执行上述代码并为测试数据集绘制决策区域之后,我们可以看到逻辑回归在这个小的二维特征子空间上表现相当不错,仅在测试数据集中错误分类了一些示例:

图表,散点图  自动生成描述

图 5.5: 在基于 PCA 特征空间中的测试数据点与逻辑回归决策区域

如果我们对不同主成分的解释方差比感兴趣,可以简单地使用 n_components 参数设置为 None 初始化 PCA 类,这样所有主成分都会被保留,并且可以通过 explained_variance_ratio_ 属性访问解释的方差比:

>>> pca = PCA(n_components=None)
>>> X_train_pca = pca.fit_transform(X_train_std)
>>> pca.explained_variance_ratio_
array([ 0.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,以便按排序顺序返回所有主成分,而不进行降维。

评估特征贡献

在本节中,我们将简要介绍如何评估原始特征对主成分的贡献。正如我们所学,通过 PCA,我们创建代表特征线性组合的主成分。有时,我们有兴趣知道每个原始特征对给定主成分的贡献有多少。这些贡献通常称为负荷量

可以通过将特征向量按特征值的平方根进行缩放来计算因子负荷。然后,可以将结果值解释为原始特征与主成分之间的相关性。为了说明这一点,让我们绘制第一主成分的负荷。

首先,我们通过将特征向量乘以特征值的平方根来计算 13×13 维负荷矩阵:

>>> loadings = eigen_vecs * np.sqrt(eigen_vals) 

接着,我们绘制第一主成分的负荷量 loadings[:, 0],这是矩阵中的第一列:

>>> fig, ax = plt.subplots()
>>> ax.bar(range(13), loadings[:, 0], align='center')
>>> ax.set_ylabel('Loadings for PC 1')
>>> ax.set_xticks(range(13))
>>> ax.set_xticklabels(df_wine.columns[1:], rotation=90)
>>> plt.ylim([-1, 1])
>>> plt.tight_layout()
>>> plt.show() 

图 5.6 中,我们可以看到,例如,酒精与第一主成分呈负相关(约为 -0.3),而苹果酸呈正相关(约为 0.54)。请注意,数值为 1 表示完全正相关,而数值为-1 对应完全负相关:

包含图形用户界面的图片  自动生成的描述

图 5.6:与第一主成分的特征相关性

在上述代码示例中,我们计算了我们自己的 PCA 实现的因子负荷。我们可以以类似的方式从适合的 scikit-learn PCA 对象中获取负荷,其中 pca.components_ 表示特征向量,pca.explained_variance_ 表示特征值:

>>> sklearn_loadings = pca.components_.T * np.sqrt(pca.explained_variance_) 

为了将 scikit-learn PCA 的负荷与我们之前创建的负荷进行比较,让我们创建一个类似的条形图:

>>> fig, ax = plt.subplots()
>>> ax.bar(range(13), sklearn_loadings[:, 0], align='center')
>>> ax.set_ylabel('Loadings for PC 1')
>>> ax.set_xticks(range(13))
>>> ax.set_xticklabels(df_wine.columns[1:], rotation=90)
>>> plt.ylim([-1, 1])
>>> plt.tight_layout()
>>> plt.show() 

正如我们所看到的,条形图看起来一样:

包含图形用户界面的图片  自动生成的描述

图 5.7:使用 scikit-learn 计算的特征与第一主成分的相关性

在探讨 PCA 作为无监督特征提取技术之后,下一节将介绍线性判别分析LDA),这是一种线性变换技术,考虑了类标签信息。

通过线性判别分析进行监督数据压缩

LDA 可以作为一种特征提取技术,用于增加计算效率并减少非正则化模型中因维度诅咒而导致的过拟合程度。LDA 的一般概念与 PCA 非常相似,但是 PCA 试图找到数据集中方差最大的正交组件轴,而 LDA 的目标是找到优化类别可分性的特征子空间。在接下来的几节中,我们将更详细地讨论 LDA 与 PCA 之间的相似之处,并逐步介绍 LDA 方法。

主成分分析与线性判别分析

PCA 和 LDA 都是用于减少数据集维数的线性变换技术;前者是无监督算法,而后者是有监督的。因此,我们可能认为在分类任务中,LDA 是比 PCA 更优越的特征提取技术。然而,A.M. Martinez 报告称,在某些情况下,通过 PCA 预处理在图像识别任务中会导致更好的分类结果,例如,如果每个类别只包含少量示例(PCA 与 LDA,作者A. M. MartinezA. C. KakIEEE 模式分析与机器智能交易,23(2):228-233,2001 年)。

费舍尔 LDA

LDA 有时也被称为费舍尔 LDA。罗纳德·A·费舍尔最初在 1936 年为双类分类问题制定了费舍尔线性判别在分类问题中使用多次测量,作者R. A. Fisher遗传学年刊,7(2):179-188,1936 年)。1948 年,C. Radhakrishna Rao 在假设等类别协方差和正态分布类别的情况下推广了费舍尔线性判别,现在我们称之为 LDA(在生物分类问题中利用多次测量,作者C. R. Rao英国皇家统计学会系列 B(方法学),10(2):159-203,1948 年)。

图 5.8总结了用于双类问题的 LDA 概念。来自类别 1 的示例显示为圆圈,来自类别 2 的示例显示为交叉点:

图 5.8:用于双类问题的 LDA 概念

线性判别分析所示的线性判别,如x轴(LD 1),能够很好地分离两个正态分布类别。尽管在y轴上显示的示例线性判别(LD 2)捕捉了数据集的大部分方差,但它作为一个好的线性判别失败了,因为它没有捕捉任何类别区分信息。

LDA 中的一个假设是数据服从正态分布。此外,我们假设类别具有相同的协方差矩阵,并且训练样本彼此统计独立。然而,即使这些假设中的一个或多个(略微)违反,LDA 在降维方面仍然可以表现得相当好(模式分类第 2 版,作者R. O. DudaP. E. HartD. G. Stork纽约,2001 年)。

线性判别分析的内部工作原理

在我们深入代码实现之前,让我们简要总结执行 LDA 所需的主要步骤:

  1. 标准化d维数据集(d为特征数)。

  2. 对于每个类别,计算d维均值向量。

  3. 构建类间散布矩阵S[B]和类内散布矩阵S[W]。

  4. 计算矩阵的特征向量和相应的特征值,

  5. 按降序排列特征值以排列相应的特征向量。

  6. 选择与最大特征值k对应的k个特征向量来构造d×k维的转换矩阵 W;这些特征向量是矩阵的列。

  7. 使用转换矩阵 W 将示例投影到新的特征子空间。

正如我们所见,LDA 在某种意义上与 PCA 相似,因为我们将矩阵分解为特征值和特征向量,这将形成新的低维特征空间。然而,正如前面提到的,LDA 考虑了类标签信息,这以步骤 2中计算的均值向量形式体现出来。在接下来的章节中,我们将更详细地讨论这七个步骤,并附有示例代码实现。

计算散布矩阵

由于我们在本章开头的 PCA 部分已经标准化了 Wine 数据集的特征,我们可以跳过第一步,直接计算均值向量,然后分别用于构造类内散布矩阵和类间散布矩阵。每个均值向量 m[i] 存储关于类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(f'MV {label}: {mean_vecs[label - 1]}\n')
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] 

使用均值向量,我们现在可以计算类内散布矩阵 S[W]:

这通过对每个单独类 i 的散布矩阵 S[i] 求和来计算:

>>> d = 13 # number of features
>>> S_W = np.zeros((d, d))
>>> for label, mv in zip(range(1, 4), mean_vecs):
...     class_scatter = np.zeros((d, d))
...     for row in X_train_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: '
...       f'{S_W.shape[0]}x{S_W.shape[1]}')
Within-class scatter matrix: 13x13 

在计算散布矩阵时,我们所做的假设是训练数据集中的类标签是均匀分布的。然而,如果我们打印类标签的数量,我们会发现这一假设是不成立的:

>>> print('Class label distribution:',
...       np.bincount(y_train)[1:])
Class label distribution: [41 50 33] 

因此,我们希望在将它们加总为散布矩阵 S[W] 之前,先对各个散布矩阵 S[i] 进行缩放。当我们将散布矩阵除以类示例数 n[i] 时,我们可以看到,计算散布矩阵实际上与计算协方差矩阵 是一样的——协方差矩阵是散布矩阵的归一化版本:

计算缩放的类内散布矩阵的代码如下:

>>> d = 13 # number of features
>>> S_W = np.zeros((d, d))
>>> for label,mv in zip(range(1, 4), mean_vecs):
...     class_scatter = np.cov(X_train_std[y_train==label].T)
...     S_W += class_scatter
>>> print('Scaled within-class scatter matrix: '
...       f'{S_W.shape[0]}x{S_W.shape[1]}')
Scaled within-class scatter matrix: 13x13 

在计算缩放的类内散布矩阵(或协方差矩阵)之后,我们可以继续下一步,计算类间散布矩阵 S[B]:

这里,m 是总体均值,包括所有 c 类的示例:

>>> mean_overall = np.mean(X_train_std, axis=0)
>>> mean_overall = mean_overall.reshape(d, 1)
>>> 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
...     S_B += n * (mean_vec - mean_overall).dot(
...     (mean_vec - mean_overall).T)
>>> print('Between-class scatter matrix: '
...       f'{S_B.shape[0]}x{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 是类别标签的数量,因为类间散布矩阵 S[B] 是 c 个秩为一或更低的矩阵之和。事实上,我们只有两个非零特征值(特征值 3-13 并非完全为零,这是由于 NumPy 中的浮点运算)。

共线性

注意,在极少数情况下出现完美共线性(所有对齐的示例点位于一条直线上),协方差矩阵将具有秩为一,这将导致只有一个非零特征值的特征向量。

为了衡量线性判别子(特征向量)捕获了多少类别区分信息,让我们绘制按降序排列的线性判别子,类似于我们在 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, 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() 

正如我们在 图 5.9 中所看到的,仅用前两个线性判别分量就可以捕获葡萄酒训练数据集中 100%的有用信息:

一个包含图表的图片 自动生成的描述

图 5.9:前两个判别分量捕获了 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′ = XW

>>> X_train_lda = X_train_std.dot(w)
>>> colors = ['r', 'b', 'g']
>>> markers = ['o', 's', '^']
>>> 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= f'Class {l}', marker=m)
>>> plt.xlabel('LD 1')
>>> plt.ylabel('LD 2')
>>> plt.legend(loc='lower right')
>>> plt.tight_layout()
>>> plt.show() 

正如我们在 图 5.10 中所看到的,现在三个葡萄酒类别在新的特征子空间中完全线性可分:

图表,散点图 自动生成的描述

图 5.10:将数据投影到前两个判别分量后,葡萄酒类别完全可分

使用 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() 

图 5.11 所示,我们可以看到逻辑回归模型误分类了来自第二类的一个示例:

图表,散点图 自动生成的描述

图 5.11:逻辑回归模型误分类了一个类别

通过降低正则化强度,我们可能可以移动决策边界,使得逻辑回归模型能够在训练数据集中正确分类所有示例。然而,更重要的是,让我们看看在测试数据集上的结果:

>>> 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() 

如我们在图 5.12中所见,逻辑回归分类器能够在测试数据集中只使用一个二维特征子空间,而不是原始的 13 个葡萄酒特征,获得完美的准确度分数:

Chart, scatter chart  Description automatically generated

图 5.12:逻辑回归模型在测试数据上的完美表现

非线性降维和可视化

在前一节中,我们介绍了主成分分析(PCA)和线性判别分析(LDA)等线性变换技术进行特征提取。在本节中,我们将讨论为什么考虑非线性降维技术可能是值得的。

特别值得强调的一个非线性降维技术是t 分布随机邻居嵌入t-SNE),因为它经常用于文献中以二维或三维形式可视化高维数据集。我们将看到如何应用 t-SNE 来在二维特征空间中绘制手写图像的图像。

为什么考虑非线性降维?

许多机器学习算法对输入数据的线性可分性有假设。您已经学到了感知器甚至需要完全线性可分的训练数据才能收敛。到目前为止,我们所涵盖的其他算法假设缺乏完全线性可分性是由于噪声引起的:Adaline、逻辑回归和(标准)支持向量机等。

然而,如果我们处理非线性问题,这在实际应用中可能经常遇到,那么线性变换技术如主成分分析(PCA)和线性判别分析(LDA)可能不是最佳选择:

图 5.13:线性与非线性问题的区别

scikit-learn 库实现了一些先进的非线性降维技术,超出了本书的范围。有兴趣的读者可以在scikit-learn.org/stable/modules/manifold.html找到 scikit-learn 当前实现的良好概述,并配有说明性示例。

非线性降维技术的开发和应用通常也被称为流形学习,其中流形指的是嵌入在高维空间中的低维拓扑空间。流形学习算法必须捕捉数据的复杂结构,以便将其投影到一个保持数据点关系的低维空间中。

流形学习的一个经典示例是三维瑞士卷,如图 5.14所示:

Diagram  根据中等置信度自动生成的描述

图 5.14:将三维瑞士卷投影到较低的二维空间

尽管非线性降维和流形学习算法非常强大,但需要注意的是,这些技术因难以使用而闻名,如果超参数选择不理想,可能弊大于利。导致这种困难的原因在于我们通常处理的是无法轻易可视化且结构不明显的高维数据集(不像图 5.14中的瑞士卷示例)。此外,除非将数据集投影到二维或三维空间(这通常不足以捕捉更复杂的关系),否则很难甚至不可能评估结果的质量。因此,许多人仍依赖于更简单的技术,如 PCA 和 LDA 进行降维。

通过 t-分布随机近邻嵌入进行数据可视化

在介绍非线性降维及其一些挑战后,让我们看一个涉及 t-SNE 的实际示例,这在二维或三维中经常用于可视化复杂数据集。

简而言之,t-SNE 根据高维(原始)特征空间中数据点的成对距离建模数据点。然后,它找到新的低维空间中成对距离的概率分布,该分布接近原始空间中成对距离的概率分布。换句话说,t-SNE 学习将数据点嵌入到低维空间中,使得原始空间中的成对距离得以保持。您可以在 Maaten 和 Hinton 的原始研究论文《Visualizing data using t-SNE》 中找到有关此方法的更多详细信息,发表于 2018 年《机器学习研究期刊》(www.jmlr.org/papers/volume9/vandermaaten08a/vandermaaten08a.pdf)。然而,正如研究论文的标题所示,t-SNE 是一种用于可视化目的的技术,因为它需要整个数据集进行投影。由于它直接投影点(不像 PCA,它不涉及投影矩阵),我们无法将 t-SNE 应用于新的数据点。

下面的代码显示了如何快速演示 t-SNE 如何应用于一个 64 维数据集。首先,我们从 scikit-learn 加载 Digits 数据集,其中包含低分辨率手写数字(数字 0-9)的图像:

>>> from sklearn.datasets import load_digits
>>> digits = load_digits() 

这些数字是 8×8 的灰度图像。下面的代码绘制了数据集中的前四幅图像,总共包含 1,797 幅图像:

>>> fig, ax = plt.subplots(1, 4)
>>> for i in range(4):
>>>     ax[i].imshow(digits.images[i], cmap='Greys')
>>> plt.show() 

正如我们在图 5.15中看到的那样,图像的分辨率相对较低,为 8×8 像素(即每个图像 64 个像素):

一个包含文本的图片,剪贴画  自动生成描述

图 5.15:手写数字的低分辨率图像

请注意,digits.data属性使我们能够访问该数据集的表格版本,其中示例由行表示,列对应于像素:

>>> digits.data.shape
(1797, 64) 

接下来,让我们将特征(像素)分配给一个新变量X_digits,并将标签分配给另一个新变量y_digits

>>> y_digits = digits.target
>>> X_digits = digits.data 

然后,我们从 scikit-learn 中导入 t-SNE 类,并拟合一个新的tsne对象。使用fit_transform,我们在一步中执行 t-SNE 拟合和数据转换:

>>> from sklearn.manifold import TSNE
>>> tsne = TSNE(n_components=2, init='pca',
...             random_state=123)
>>> X_digits_tsne = tsne.fit_transform(X_digits) 

使用这段代码,我们将 64 维数据集投影到二维空间。我们指定了init='pca',这在研究文章《Initialization is critical for preserving global data structure in both t-SNE and UMAP》中被推荐使用 PCA 进行 t-SNE 嵌入的初始化,该文章的作者是KobakLinderman,发表于《Nature Biotechnology Volume 39》,第 156-157 页,2021 年(www.nature.com/articles/s41587-020-00809-z)。

请注意,t-SNE 还包括额外的超参数,如困惑度和学习率(通常称为epsilon),我们在示例中省略了这些(使用了 scikit-learn 的默认值)。实际应用中,我们建议您也探索这些参数。有关这些参数及其对结果影响的更多信息,请参阅文章《How to Use t-SNE Effectively》,作者是WattenbergViegasJohnson,发表于《Distill》,2016 年(distill.pub/2016/misread-tsne/)。

最后,让我们使用以下代码可视化 2D t-SNE 嵌入:

>>> import matplotlib.patheffects as PathEffects
>>> def plot_projection(x, colors):
...     f = plt.figure(figsize=(8, 8))
...     ax = plt.subplot(aspect='equal')
...     for i in range(10):
...         plt.scatter(x[colors == i, 0],
...                     x[colors == i, 1])
...     for i in range(10):
...         xtext, ytext = np.median(x[colors == i, :], axis=0)
...         txt = ax.text(xtext, ytext, str(i), fontsize=24)
...         txt.set_path_effects([
...             PathEffects.Stroke(linewidth=5, foreground="w"),
...             PathEffects.Normal()])
>>> plot_projection(X_digits_tsne, y_digits)
>>> plt.show() 

与 PCA 类似,t-SNE 是一种无监督方法,在前述代码中,我们仅出于可视化目的使用类标签y_digits(0-9)通过函数颜色参数。Matplotlib 的PathEffects用于视觉效果,使得每个相应数字数据点的类标签显示在中心(通过np.median)。生成的图如下所示:

散点图  自动生成描述

图 5.16:展示了 t-SNE 如何将手写数字嵌入二维特征空间的可视化

正如我们所看到的,尽管不完美,t-SNE 能够很好地分离不同的数字(类别)。通过调整超参数,可能可以实现更好的分离。然而,由于难以辨认的手写,一定程度的类混合可能是不可避免的。例如,通过检查单个图像,我们可能会发现某些数字 3 的实例看起来确实像数字 9,等等。

均匀流形逼近和投影

另一种流行的可视化技术是均匀流形逼近和投影UMAP)。虽然 UMAP 可以产生与 t-SNE 类似好的结果(例如,请参阅之前引用的 Kobak 和 Linderman 的论文),但通常速度更快,并且还可以用于投影新数据,这使其在机器学习背景下作为降维技术更具吸引力,类似于 PCA。对 UMAP 感兴趣的读者可以在原始论文中找到更多信息:UMAP: Uniform manifold approximation and projection for dimension reduction,作者是McInnes, HealyMelville,2018 年(arxiv.org/abs/1802.03426)。UMAP 的 scikit-learn 兼容实现可以在umap-learn.readthedocs.io找到。

摘要

在本章中,您了解了两种用于特征提取的基本降维技术:PCA 和 LDA。使用 PCA,我们将数据投影到低维子空间中,以最大化沿正交特征轴的方差,同时忽略类标签。与 PCA 相反,LDA 是一种用于监督降维的技术,这意味着它考虑训练数据集中的类信息,试图在线性特征空间中最大化类的可分离性。最后,您还了解了 t-SNE,这是一种非线性特征提取技术,可用于在二维或三维中可视化数据。

配备了 PCA 和 LDA 作为基本数据预处理技术,您现在已经准备好学习如何在下一章高效地结合不同的预处理技术并评估不同模型的性能。

加入我们书籍的 Discord 空间

参加每月一次的作者问答活动,可在书籍的 Discord 工作区参与:

packt.link/MLwPyTorch

第六章:学习模型评估和超参数调整的最佳实践

在前几章中,我们学习了分类的基本机器学习算法,以及在将数据输入这些算法之前如何整理数据。现在,是时候学习通过微调算法和评估模型性能来构建优秀机器学习模型的最佳实践了。在本章中,我们将学习如何执行以下操作:

  • 评估机器学习模型的性能

  • 诊断机器学习算法的常见问题

  • 微调机器学习模型

  • 使用不同性能指标评估预测模型的性能

使用管道流程优化工作流程

当我们在前几章中应用不同的预处理技术,例如特征缩放的标准化在 第四章,“构建良好的训练数据集 - 数据预处理” 中,或数据压缩的主成分分析在 第五章,“通过降维压缩数据” 中,您学到我们必须重用在训练数据拟合期间获得的参数来缩放和压缩任何新数据,例如单独测试数据集中的示例。在本节中,您将学习到一个非常方便的工具,即Pipeline类在 scikit-learn 中。它允许我们拟合一个包含任意数量转换步骤的模型,并将其应用于对新数据进行预测。

加载乳腺癌威斯康星数据集

在本章中,我们将使用乳腺癌威斯康星数据集,该数据集包含 569 个恶性和良性肿瘤细胞的示例。数据集中的前两列存储示例的唯一 ID 编号和相应的诊断结果(M = 恶性,B = 良性)。列 3-32 包含从细胞核数字化图像计算出的 30 个实值特征,可用于构建模型以预测肿瘤是良性还是恶性。乳腺癌威斯康星数据集已存放在 UCI 机器学习库中,有关该数据集的更详细信息可在 archive.ics.uci.edu/ml/datasets/Breast+Cancer+Wisconsin+(Diagnostic) 找到。

获取乳腺癌威斯康星数据集

您可以在本书的代码包中找到数据集的副本(以及本书中使用的所有其他数据集),如果您离线工作或者 UCI 服务器在 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. 我们将从 UCI 网站直接使用 pandas 读取数据集:

    >>> 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) 
    
  3. 在将类标签(诊断)编码为数组 y 后,恶性肿瘤现在表示为类 1,良性肿瘤表示为类 0。我们可以通过在两个虚拟类标签上调用已拟合的 LabelEncodertransform 方法来双重检查此映射:

    >>> le.transform(['M', 'B'])
    array([1, 0]) 
    
  4. 在下一小节构建我们的第一个模型管道之前,让我们将数据集分成一个单独的训练数据集(数据的 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) 
    

将转换器和估计器组合成管道

在前一章中,您学习到许多学习算法需要输入特征在相同的尺度上才能获得最佳性能。由于 Breast Cancer Wisconsin 数据集中的特征在不同的尺度上测量,因此在将它们提供给线性分类器(如 logistic 回归)之前,我们将标准化 Breast Cancer Wisconsin 数据集中的列。此外,假设我们希望通过 主成分分析PCA),这是一种介绍在 第五章 中的用于降维的特征提取技术,将我们的数据从初始的 30 维压缩到一个较低的二维子空间。

不需要分别对训练集和测试集进行模型拟合和数据转换步骤,我们可以将 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())
>>> pipe_lr.fit(X_train, y_train)
>>> y_pred = pipe_lr.predict(X_test)
>>> test_acc = pipe_lr.score(X_test, y_test)
>>> print(f'Test accuracy: {test_acc:.3f}')
Test accuracy: 0.956 

make_pipeline 函数接受任意数量的 scikit-learn 转换器(支持 fittransform 方法的对象)作为输入,后跟一个实现 fitpredict 方法的 scikit-learn 估计器。在我们前面的代码示例中,我们提供了两个 scikit-learn 转换器 StandardScalerPCA,以及一个 LogisticRegression 估计器作为 make_pipeline 函数的输入,该函数从这些对象构造了一个 scikit-learn Pipeline 对象。

我们可以将 scikit-learn 的 Pipeline 看作是那些独立转换器和估计器的元估计器或包装器。如果我们调用 Pipelinefit 方法,数据将通过一系列转换器,通过中间步骤上的 fittransform 调用传递,直到到达估计器对象(管道中的最后一个元素)。然后,估计器将被拟合到转换后的训练数据上。

当我们在前面的代码示例中对pipe_lr管道执行fit方法时,StandardScaler首先对训练数据执行了fittransform调用。其次,转换后的训练数据被传递给管道中的下一个对象PCA。类似于前面的步骤,PCA也对缩放后的输入数据执行了fittransform操作,并将其传递给管道的最后一个元素,即评估器。

最后,经过StandardScalerPCA转换后,LogisticRegression评估器被拟合到训练数据中。同样需要注意的是,在管道中的中间步骤数量没有限制;然而,如果我们想要将管道用于预测任务,最后一个管道元素必须是评估器。

与在管道上调用fit类似,如果管道的最后一步是评估器,管道也会实现predict方法。如果我们将数据集提供给Pipeline对象实例的predict调用,数据将通过中间步骤通过transform调用传递。在最后一步,评估器对象将返回对转换数据的预测。

scikit-learn 库的管道非常实用,是我们在本书的其余部分经常使用的包装工具。为了确保您对Pipeline对象的工作原理有深刻理解,请仔细观察图 6.1,该图总结了我们在前面段落中的讨论:

自动生成的图表说明

图 6.1:管道对象的内部工作原理

使用 k 折交叉验证评估模型性能

在本节中,您将了解常见的交叉验证技术留出交叉验证k 折交叉验证,这些技术可以帮助我们获得模型泛化性能的可靠估计,即模型在未见数据上的表现如何。

留出法

估计机器学习模型泛化性能的经典和流行方法是留出法。使用留出法,我们将初始数据集分成单独的训练和测试数据集——前者用于模型训练,后者用于估计其泛化性能。然而,在典型的机器学习应用中,我们还对调整和比较不同的参数设置感兴趣,以进一步提高在未见数据上的预测性能。这个过程称为模型选择,该名称指的是我们想要选择最佳值的调整参数(也称为超参数)给定分类问题的情况。然而,如果在模型选择过程中反复使用同一测试数据集,它将成为我们的训练数据的一部分,因此模型更可能过拟合。尽管存在这个问题,许多人仍然使用测试数据集进行模型选择,这不是一个好的机器学习实践。

使用留出法进行模型选择的更好方法是将数据分成三部分:训练数据集、验证数据集和测试数据集。训练数据集用于拟合不同的模型,然后利用验证数据集上的性能进行模型选择。测试数据集的优点在于,在训练和模型选择步骤中,模型之前未见过该数据,因此我们可以获得对其推广到新数据能力的较少偏见的估计。图 6.2说明了留出交叉验证的概念,在这里我们使用验证数据集重复评估使用不同超参数值进行训练后模型的性能。一旦我们对超参数值的调整感到满意,我们就可以估计模型在测试数据集上的泛化性能:

图 6.2:如何使用训练、验证和测试数据集

留出法的一个缺点是性能估计可能对如何将训练数据集分割成训练和验证子集非常敏感;估计值会因数据的不同示例而变化。在下一小节中,我们将看一下更健壮的性能估计技术,即 k 折交叉验证,在这种方法中,我们对训练数据的 k 个子集重复使用留出法k次。

k 折交叉验证

在 k 折交叉验证中,我们将训练数据集随机分成k个不重复的折叠。在这里,k – 1 折叠,即所谓的训练折叠,用于模型训练,而一个折叠,即所谓的测试折叠,用于性能评估。此过程重复k次,以便我们获得k个模型和性能估计。

有替换和无替换抽样

我们在第三章中查看了一个示例,以说明有放回和无放回的抽样。如果您还没有阅读该章节,或者需要复习,请参阅“组合多个决策树通过随机森林”章节中名为“有放回和无放回抽样”的信息框。

然后我们基于不同的、独立的测试折叠计算模型的平均表现,以获得对训练数据的子分区不太敏感的性能估计,与留出方法相比。通常情况下,我们使用 k 折交叉验证进行模型调整,即找到能产生满意泛化性能的最优超参数值,这些值是通过评估模型在测试折叠上的性能来估计的。

一旦我们找到令人满意的超参数值,我们可以在完整的训练数据集上重新训练模型,并使用独立的测试数据集获得最终的性能估计。在 k 折交叉验证之后将模型拟合到整个训练数据集的理由是,首先,我们通常对单个最终模型感兴趣(而不是k个单独的模型),其次,将更多的训练示例提供给学习算法通常会产生更精确和更健壮的模型。

由于 k 折交叉验证是一种无替换的重采样技术,这种方法的优势在于在每次迭代中,每个示例都将仅使用一次,并且训练和测试折叠是不重叠的。此外,所有测试折叠也是不重叠的;也就是说,测试折叠之间没有重叠。图 6.3总结了 k 折交叉验证背后的概念,其中k = 10. 训练数据集被分为 10 个折叠,在 10 次迭代期间,有 9 个折叠用于训练,1 个折叠将用作模型评估的测试数据集。

另外,每个折叠的估计表现,E[i](例如,分类准确度或误差),然后用于计算模型的估计平均表现,E

图 6.3:k 折交叉验证的工作原理

总之,k 折交叉验证比使用验证集的留出方法更有效地利用数据集,因为在 k 折交叉验证中,所有数据点都用于评估。

在 k 折交叉验证中,一个良好的标准值k是 10,正如经验证据所示。例如,Ron Kohavi 在各种真实世界数据集上的实验表明,10 折交叉验证在偏差和方差之间提供了最佳的权衡(关于准确度估计和模型选择的交叉验证和自举研究Ron Kohavi国际人工智能联合会议(IJCAI),14(12):1137-43,1995 年,www.ijcai.org/Proceedings/95-2/Papers/016.pdf)。

然而,如果我们使用的训练集比较小,增加折数可能是有用的。如果我们增加 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(f'Fold: {k+1:02d}, '
...           f'Class distr.: {np.bincount(y_train[train])}, '
...           f'Acc.: {score:.3f}')
Fold: 01, Class distr.: [256 153], Acc.: 0.935
Fold: 02, Class distr.: [256 153], Acc.: 0.935
Fold: 03, Class distr.: [256 153], Acc.: 0.957
Fold: 04, Class distr.: [256 153], Acc.: 0.957
Fold: 05, Class distr.: [256 153], Acc.: 0.935
Fold: 06, Class distr.: [257 153], Acc.: 0.956
Fold: 07, Class distr.: [257 153], Acc.: 0.978
Fold: 08, Class distr.: [257 153], Acc.: 0.933
Fold: 09, Class distr.: [257 153], Acc.: 0.956
Fold: 10, Class distr.: [257 153], Acc.: 0.956
>>> mean_acc = np.mean(scores)
>>> std_acc = np.std(scores)
>>> print(f'\nCV accuracy: {mean_acc:.3f} +/- {std_acc:.3f}')
CV accuracy: 0.950 +/- 0.014 

首先,我们使用 sklearn.model_selection 模块中的 StratifiedKFold 迭代器初始化了 y_train 训练数据集中的类标签,并通过 n_splits 参数指定了折数。当我们使用 kfold 迭代器循环遍历 k 个折时,我们使用返回的 train 索引来拟合本章开头设置的 logistic 回归流水线。通过 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(f'CV accuracy scores: {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(f'CV accuracy: {np.mean(scores):.3f} '
...       f'+/- {np.std(scores):.3f}')
CV accuracy: 0.950 +/- 0.014 

cross_val_score方法的一个极其有用的特性是,我们可以将不同折叠的评估任务分布到我们机器上的多个中央处理单元CPU)上。如果将n_jobs参数设为1,那么只会使用一个 CPU 来评估性能,就像我们之前的StratifiedKFold示例一样。然而,通过设置n_jobs=2,我们可以将 10 轮交叉验证任务分布到两个 CPU 上(如果机器上有的话),而通过设置n_jobs=-1,我们可以利用机器上所有可用的 CPU 并行计算。

估算泛化性能

请注意,本书不涵盖如何估计交叉验证中泛化性能的方差的详细讨论,但您可以参考关于模型评估和交叉验证的全面文章(《机器学习中的模型评估、模型选择和算法选择》S. Raschka),我们在arxiv.org/abs/1811.12808分享了这篇文章。此文章还讨论了替代的交叉验证技术,例如.632 和.632+自助法交叉验证方法。

此外,您可以在 M. Markatou 等人的优秀文章中找到详细讨论(《分析交叉验证估计泛化误差的方差分析》M. MarkatouH. TianS. BiswasG. M. Hripcsak机器学习研究杂志,6: 1127-1168,2005 年),该文章可在www.jmlr.org/papers/v6/markatou05a.html找到。

使用学习曲线和验证曲线调试算法

在本节中,我们将介绍两个非常简单但功能强大的诊断工具,可以帮助我们改善学习算法的性能:学习曲线验证曲线。在接下来的小节中,我们将讨论如何使用学习曲线来诊断学习算法是否存在过拟合(高方差)或拟合不足(高偏差)的问题。此外,我们还将看看验证曲线,它可以帮助我们解决学习算法的常见问题。

使用学习曲线诊断偏差和方差问题

如果一个模型对于给定的训练数据集过于复杂,例如,想象一下非常深的决策树,那么该模型倾向于过拟合训练数据,并且不能很好地泛化到未见过的数据。通常情况下,增加训练样本的数量可以帮助减少过拟合的程度。

然而,在实践中,收集更多数据往往非常昂贵,或者根本不可行。通过绘制模型训练和验证精度随训练数据集大小变化的曲线,我们可以轻松检测模型是否存在高方差或高偏差问题,以及收集更多数据是否有助于解决这一问题。

但在讨论如何在 scikit-learn 中绘制学习曲线之前,让我们通过以下示例来讨论这两个常见的模型问题:

图 6.4:常见的模型问题

左上图显示的图表显示了高偏差的模型。这个模型具有低训练和交叉验证准确性,表明它在训练数据上拟合不足。解决这个问题的常见方法包括增加模型参数的数量,例如通过收集或构建额外的特征,或通过减少正则化的程度,例如在支持向量机SVM)或逻辑回归分类器中。

右上图显示的图表显示模型存在高方差问题,这通过训练和交叉验证准确性之间的巨大差距来指示。为了解决这个过拟合问题,我们可以收集更多的训练数据,减少模型的复杂性,或增加正则化参数,例如。

对于非正则化模型,还可以通过特征选择(第四章)或特征提取(第五章)减少特征数量,从而减少过拟合程度。虽然收集更多的训练数据通常会减少过拟合的机会,但在训练数据非常嘈杂或模型已经非常接近最优情况时,这并不总是有帮助的。

在下一小节中,我们将看到如何使用验证曲线来解决这些模型问题,但让我们先看看如何使用 scikit-learn 中的学习曲线函数来评估模型:

>>> import matplotlib.pyplot as plt
>>> from sklearn.model_selection import learning_curve
>>> pipe_lr = make_pipeline(StandardScaler(),
...                         LogisticRegression(penalty='l2',
...                                            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 次迭代),以避免在较小的数据集大小或极端正则化参数值(在下一节中讨论)时出现收敛问题。执行上述代码成功后,我们将获得以下学习曲线图:

图表,折线图 自动生成的描述

图 6.5:显示训练和验证数据集准确性的学习曲线

通过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的验证曲线图:

图表,折线图  自动生成的描述

图 6.6:SVM 超参数 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。

GridSearchCV 使用 k 折交叉验证来比较使用不同超参数设置训练的模型。通过 cv=10 设置,它将进行十折交叉验证,并计算这十个折叠中的平均准确率(通过 scoring='accuracy')来评估模型性能。我们设置 n_jobs=-1,以便 GridSearchCV 可以利用所有处理核心并行地加速网格搜索,但如果您的计算机对此设置有问题,您可以将此设置更改为 n_jobs=None 以进行单处理。

在使用训练数据执行网格搜索后,我们通过 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(f'Test accuracy: {clf.score(X_test, y_test):.3f}')
Test accuracy: 0.974 

请注意,在完成网格搜索后,手动在训练集上用最佳设置(gs.best_estimator_)拟合模型是不必要的。GridSearchCV类有一个refit参数,如果我们设置refit=True(默认),它将自动将gs.best_estimator_重新拟合到整个训练集上。

通过随机搜索更广泛地探索超参数配置

由于网格搜索是一种穷举搜索,如果最佳超参数配置包含在用户指定的参数网格中,它肯定能找到最优配置。然而,指定大型超参数网格在实践中使网格搜索非常昂贵。采样不同参数组合的替代方法是随机搜索。在随机搜索中,我们从分布(或离散集合)中随机抽取超参数配置。与网格搜索不同,随机搜索不会对超参数空间进行穷举搜索。尽管如此,它仍然能够以更加经济和时间有效的方式探索更广泛的超参数值设置范围。这个概念在图 6.7 中有所体现,图示了通过网格搜索和随机搜索对九个超参数设置进行搜索的固定网格:

Shape  Description automatically generated

图 6.7:比较网格搜索和随机搜索各自采样九种不同的超参数配置

主要观点是,虽然网格搜索只探索离散的、用户指定的选择,但如果搜索空间太少,可能会错过好的超参数配置。有兴趣的读者可以在以下文章中找到关于随机搜索的详细信息,以及经验研究:《超参数优化的随机搜索》由J. BergstraY. Bengio机器学习研究杂志,第 281-305 页,2012 年,www.jmlr.org/papers/volume13/bergstra12a/bergstra12a

现在让我们看看如何利用随机搜索来调整 SVM。Scikit-learn 实现了一个RandomizedSearchCV类,类似于我们在前一小节中使用的GridSearchCV。主要区别在于我们可以在参数网格中指定分布,并指定要评估的超参数配置的总数。例如,让我们考虑在前一小节中网格搜索 SVM 时使用的几个超参数的范围:

>>> param_range = [0.0001, 0.001, 0.01, 0.1,
...                1.0, 10.0, 100.0, 1000.0] 

注意,虽然RandomizedSearchCV可以接受类似的离散值列表作为参数网格的输入,这在考虑分类超参数时非常有用,但它的主要优势在于我们可以用分布来采样这些列表。因此,例如,我们可以用 SciPy 中的以下分布替换前面的列表:

>>> param_range = scipy.stats.loguniform(0.0001, 1000.0) 

例如,使用对数均匀分布而不是常规均匀分布将确保在足够大数量的试验中,与[0.0001, 0.001]范围相比,将从[10.0, 100.0]范围中绘制相同数量的样本。要检查其行为,我们可以通过rvs(10)方法从该分布中绘制 10 个随机样本,如下所示:

>>> np.random.seed(1)
>>> param_range.rvs(10)
array([8.30145146e-02, 1.10222804e+01, 1.00184520e-04, 1.30715777e-02,
       1.06485687e-03, 4.42965766e-04, 2.01289666e-03, 2.62376594e-02,
       5.98924832e-02, 5.91176467e-01]) 

指定分布

RandomizedSearchCV支持任意分布,只要我们可以通过调用rvs()方法从中抽样。可以在这里找到scipy.stats当前可用的所有分布列表:docs.scipy.org/doc/scipy/reference/stats.html#probability-distributions

现在让我们看看RandomizedSearchCV如何运作,并像在前一节中使用GridSearchCV调整 SVM 一样:

>>> from sklearn.model_selection import RandomizedSearchCV
>>> pipe_svc = make_pipeline(StandardScaler(),
...                          SVC(random_state=1))
>>> param_grid = [{'svc__C': param_range,
...                'svc__kernel': ['linear']},
...               {'svc__C': param_range,
...                'svc__gamma': param_range,
...                'svc__kernel': ['rbf']}]
>>> rs = RandomizedSearchCV(estimator=pipe_svc,
...                         param_distributions=param_grid,
...                         scoring='accuracy',
...                         refit=True,
...                         n_iter=20,
...                         cv=10,
...                         random_state=1,
...                         n_jobs=-1)
>>> rs = rs.fit(X_train, y_train)
>>> print(rs.best_score_)
0.9670531400966184
>>> print(rs.best_params_)
{'svc__C': 0.05971247755848464, 'svc__kernel': 'linear'} 

根据此代码示例,我们可以看到其使用方式与GridSearchCV非常相似,不同之处在于我们可以使用分布来指定参数范围,并通过设置n_iter=20来指定迭代次数——20 次迭代。

更具资源效率的超参数搜索与连续减半

将随机搜索的思想进一步发展,scikit-learn 实现了一种称为HalvingRandomSearchCV的连续减半变体,使得寻找适合的超参数配置更加高效。连续减半是指,在一个大的候选配置集合中,逐步淘汰不太有希望的超参数配置,直到只剩下一个配置。我们可以通过以下步骤总结该过程:

  1. 通过随机抽样绘制大量候选配置

  2. 使用有限的资源训练模型,例如,训练数据的一个小子集(与使用整个训练集相对)

  3. 基于预测性能底部 50%的丢弃

  4. 回到步骤 2并增加可用资源量

直到只剩下一个超参数配置为止重复执行上述步骤。注意,还有一个用于网格搜索变体的连续减半实现称为HalvingGridSearchCV,在步骤 1中使用所有指定的超参数配置而不是随机样本。

在 scikit-learn 1.0 中,HalvingRandomSearchCV仍处于实验阶段,因此我们必须首先启用它:

>>> from sklearn.experimental import enable_halving_search_cv 

(上述代码可能在未来版本中不起作用或不被支持。)

启用实验支持后,我们可以像下面展示的那样使用带有连续减半的随机搜索:

>>> from sklearn.model_selection import HalvingRandomSearchCV
>>> hs = HalvingRandomSearchCV(pipe_svc,
...                            param_distributions=param_grid,
...                            n_candidates='exhaust',
...                            resource='n_samples',
...                            factor=1.5,
...                            random_state=1,
...                            n_jobs=-1) 

resource='n_samples'(默认)设置指定我们将训练集大小作为我们在各轮之间变化的资源。通过factor参数,我们可以确定每轮淘汰多少候选者。例如,设置factor=2会淘汰一半的候选者,而设置factor=1.5意味着只有 100%/1.5 ≈ 66% 的候选者进入下一轮。与在RandomizedSearchCV中选择固定迭代次数不同,我们设置n_candidates='exhaust'(默认),这将对超参数配置数量进行采样,以便在最后一轮使用最大数量的资源(在这里:训练样本)。

我们可以像RandomizedSearchCV那样进行搜索:

>>> hs = hs.fit(X_train, y_train)
>>> print(hs.best_score_)
0.9617647058823529
>>> print(hs.best_params_)
{'svc__C': 4.934834261073341, 'svc__kernel': 'linear'}
>>> clf = hs.best_estimator_
>>> print(f'Test accuracy: {hs.score(X_test, y_test):.3f}')
Test accuracy: 0.982 

如果我们将前两个子段中GridSearchCVRandomizedSearchCV的结果与HalvingRandomSearchCV中的模型进行比较,可以看到后者在测试集上表现略优(98.2% 的准确率,而不是 97.4%)。

使用 hyperopt 进行超参数调优

另一个流行的超参数优化库是 hyperopt (github.com/hyperopt/hyperopt),它实现了几种不同的超参数优化方法,包括随机搜索和树结构贝叶斯优化器TPE)方法。TPE 是一种基于概率模型的贝叶斯优化方法,根据过去的超参数评估和相关的性能分数不断更新模型,而不是将这些评估视为独立事件。您可以在超参数优化算法中了解更多关于 TPE 的信息。Bergstra J, Bardenet R, Bengio Y, Kegl B. NeurIPS 2011. pp. 2546–2554,dl.acm.org/doi/10.5555/2986459.2986743

虽然 hyperopt 提供了一个通用的超参数优化接口,但也有一个专门为 scikit-learn 设计的包叫做 hyperopt-sklearn,提供了额外的便利:github.com/hyperopt/hyperopt-sklearn

使用嵌套交叉验证进行算法选择

使用 k 折交叉验证结合网格搜索或随机搜索是通过变化超参数值来微调机器学习模型性能的有用方法,就像我们在前面的子节中看到的那样。如果我们想在不同的机器学习算法之间选择,那么另一个推荐的方法是嵌套交叉验证。在一项关于误差估计偏差的研究中,Sudhir Varma 和 Richard Simon 得出结论,当使用嵌套交叉验证时,相对于测试数据集,估计的真实误差几乎是无偏的(Bias in Error Estimation When Using Cross-Validation for Model Selection by S. Varma and R. Simon, BMC Bioinformatics, 7(1): 91, 2006, bmcbioinformatics.biomedcentral.com/articles/10.1186/1471-2105-7-91)。

在嵌套交叉验证中,我们有一个外部 k 折交叉验证循环将数据分成训练集和测试集,并且内部循环用于在训练集上使用 k 折交叉验证选择模型。在模型选择后,测试集用于评估模型性能。图 6.8 解释了只有五个外部和两个内部折叠的嵌套交叉验证概念,这对于大数据集在计算性能重要时可能会很有用;这种特定类型的嵌套交叉验证也被称为5×2 交叉验证

图 6.8:嵌套交叉验证的概念

在 scikit-learn 中,我们可以通过以下方式执行嵌套交叉验证和网格搜索:

>>> 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=2)
>>> scores = cross_val_score(gs, X_train, y_train,
...                          scoring='accuracy', cv=5)
>>> print(f'CV accuracy: {np.mean(scores):.3f} '
...       f'+/- {np.std(scores):.3f}')
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(f'CV accuracy: {np.mean(scores):.3f} '
...       f'+/- {np.std(scores):.3f}')
CV accuracy: 0.934 +/- 0.016 

正如我们所见,SVM 模型的嵌套交叉验证性能(97.4%)明显优于决策树的性能(93.4%),因此,我们预计它可能是分类来自与此特定数据集相同总体的新数据的更好选择。

查看不同的性能评估指标

在先前的部分和章节中,我们使用预测准确度评估不同的机器学习模型,这是一种用于总体上量化模型性能的有用指标。然而,还有几个其他性能指标可以用来衡量模型的相关性,例如精确度、召回率、F1 分数马修斯相关系数MCC)。

阅读混淆矩阵

在深入讨论不同评分指标的细节之前,让我们先看看混淆矩阵,这是一个展示学习算法性能的矩阵。

混淆矩阵只是一个简单的方阵,报告了分类器对真正预测(TP)、真负预测(TN)、假正预测(FP)和假负预测(FN)的计数,如 图 6.9 所示:

图 6.9:混淆矩阵

尽管这些指标可以通过比较实际和预测的类别标签手动计算,但 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 函数将这些信息映射到混淆矩阵图示中 图 6.9

>>> 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')
>>> ax.xaxis.set_ticks_position('bottom')
>>> plt.xlabel('Predicted label')
>>> plt.ylabel('True label')
>>> plt.show() 

现在,以下带有附加标签的混淆矩阵图应该会使结果稍微容易解释一些:

A picture containing application  Description automatically generated

图 6.10:我们数据的混淆矩阵

假设在这个例子中类别 1(恶性)是正类,我们的模型正确地分类了属于类别 0 的 71 个示例(TN),以及属于类别 1 的 40 个示例(TP)。然而,我们的模型还将两个类别 1 的示例错误地分类为类别 0(FN),并且它预测了一个示例是恶性尽管它是良性肿瘤(FP)。在下一小节中,我们将学习如何利用这些信息来计算各种误差指标。

优化分类模型的精确度和召回率

预测 错误率 (ERR) 和 准确率 (ACC) 都提供关于有多少示例被错误分类的一般信息。错误可以理解为所有错误预测的总和除以总预测数,而准确率分别计算为正确预测的总和除以总预测数:

预测准确率可以直接从误差中计算出来:

真正率 (TPR) 和 假正率 (FPR) 是性能指标,特别适用于不平衡类别问题:

在肿瘤诊断中,我们更关心恶性肿瘤的检测,以帮助患者进行适当的治疗。然而,减少将良性肿瘤错误分类为恶性肿瘤(FP)也同样重要,以免不必要地让患者担心。与 FPR 相反,TPR 提供了关于已正确识别的正(或相关)示例在总正例(P)池中的分数的有用信息。

性能指标 精确度 (PRE) 和 召回率 (REC) 与 TP 和 TN 率有关,实际上,REC 与 TPR 是同义词。

换句话说,召回率衡量了多少相关记录(阳性记录)被正确捕捉(真阳性)。精确率则衡量了预测为相关的记录中有多少确实是相关的(真阳性数与假阳性数之和):

再次以恶性肿瘤检测为例,优化召回率有助于最小化未检测到恶性肿瘤的风险。然而,这会导致在健康患者身上预测出恶性肿瘤(假阳性数较高)。另一方面,如果我们优化精确率,则强调当我们预测患者患有恶性肿瘤时的正确性。然而,这将导致更频繁地错过恶性肿瘤(假阴性数较高)。

为了平衡优化精确率和召回率的利弊,使用它们的调和平均数,即所谓的 F1 分数:

更多关于精确率和召回率的阅读

如果您对如精确率和召回率等不同性能指标的更详细讨论感兴趣,请阅读 David M. W. Powers 的技术报告《评估:从精确率、召回率和 F-Factor 到 ROC、Informedness、Markedness 和相关性》,该报告可以在arxiv.org/abs/2010.16061免费获取。

最后,总结混淆矩阵的一种度量是 MCC,特别受到生物研究背景中的欢迎。MCC 的计算方法如下:

与 PRE、REC 和 F1 分数相比,MCC 的范围在-1 到 1 之间,并且考虑了混淆矩阵的所有元素,例如 F1 分数不涉及 TN。虽然 MCC 值比 F1 分数更难解释,但它被认为是一个更优越的度量标准,正如D. ChiccoG. Jurman在文章《二分类评估中 Matthews 相关系数(MCC)优于 F1 分数和准确度的优势》中描述的那样,《BMC Genomics》。pp. 281-305, 2012, bmcgenomics.biomedcentral.com/articles/10.1186/s12864-019-6413-7

这些评分指标都已在 scikit-learn 中实现,并可以从sklearn.metrics模块中导入,如下片段所示:

>>> from sklearn.metrics import precision_score
>>> from sklearn.metrics import recall_score, f1_score
>>> from sklearn.metrics import matthews_corrcoef
>>> pre_val = precision_score(y_true=y_test, y_pred=y_pred)
>>> print(f'Precision: {pre_val:.3f}')
Precision: 0.976
>>> rec_val = recall_score(y_true=y_test, y_pred=y_pred)
>>> print(f'Recall: {rec_val:.3f}')
Recall: 0.952
>>> f1_val = f1_score(y_true=y_test, y_pred=y_pred)
>>> print(f'F1: {f1_val:.3f}')
F1: 0.964
>>> mcc_val = matthews_corrcoef(y_true=y_test, y_pred=y_pred)
>>> print(f'MCC: {mcc_val:.3f}')
MCC: 0.943 

此外,我们可以通过 scoring 参数在GridSearchCV中使用不同的评分指标,而不是精度。有关 scoring 参数接受的不同值的完整列表,请访问scikit-learn.org/stable/modules/model_evaluation.html

请记住,scikit-learn 中的正类是标记为类别1的类。如果我们想指定一个不同的正类标签,我们可以通过make_scorer函数构建自己的评分器,然后直接将其作为参数提供给GridSearchCV中的scoring参数(在本示例中使用f1_score作为度量标准)。

>>> from sklearn.metrics import make_scorer
>>> 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 相关。这些指标是通过改变分类器的决策阈值计算得出的。ROC 图的对角线可解释为随机猜测,而落在对角线以下的分类模型被认为比随机猜测还差。完美的分类器会落在图的左上角,TPR 为 1,FPR 为 0。根据 ROC 曲线,我们可以计算所谓的ROC 曲线下面积ROC AUC),以描述分类模型的性能。

类似于 ROC 曲线,我们可以计算分类器不同概率阈值下的精确度-召回率曲线。在 scikit-learn 中,还实现了绘制这些精确度-召回率曲线的函数,并在scikit-learn.org/stable/modules/generated/sklearn.metrics.precision_recall_curve.html进行了文档化。

执行以下代码示例,我们将绘制一个 ROC 曲线,该曲线用于预测乳腺癌威斯康星数据集中的肿瘤是良性还是恶性,仅使用两个特征。尽管我们将使用先前定义的相同逻辑回归管道,但这次只使用两个特征。这样做是为了使分类任务对分类器更具挑战性,因为我们保留了其他特征中的有用信息,从而使结果的 ROC 曲线更加有趣。出于类似原因,我们还将StratifiedKFold验证器中的折数减少到三。代码如下:

>>> from sklearn.metrics import roc_curve, auc
>>> from numpy 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).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=f'ROC fold {i+1} (area = {roc_auc:.2f})')
>>> plt.plot([0, 1],
...          [0, 1],
...          linestyle='--',
...          color=(0.6, 0.6, 0.6),
...          label='Random guessing (area=0.5)')
>>> mean_tpr /= len(cv)
>>> mean_tpr[-1] = 1.0
>>> mean_auc = auc(mean_fpr, mean_tpr)
>>> plt.plot(mean_fpr, mean_tpr, 'k--',
...          label=f'Mean ROC (area = {mean_auc:.2f})', lw=2)
>>> plt.plot([0, 0, 1],
...          [0, 1, 1],
...          linestyle=':',
...          color='black',
...          label='Perfect performance (area=1.0)')
>>> 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)之间。

图表,线图  自动生成描述

图 6.11:ROC 图

请注意,如果我们只对 ROC AUC 分数感兴趣,我们也可以直接从 sklearn.metrics 子模块中导入 roc_auc_score 函数,该函数可以类似于前面介绍的其他评分函数(例如 precision_score)进行使用。

报告分类器的性能作为 ROC AUC 可以进一步深入了解分类器对于不平衡样本的性能。然而,虽然准确度分数可以解释为 ROC 曲线上的单个截止点,A.P. Bradley 显示 ROC AUC 和准确度指标大部分情况下是一致的:机器学习算法评估中的 ROC 曲线下面积的使用,作者 A.P. Bradley,Pattern Recognition,30(7): 1145-1159,1997,reader.elsevier.com/reader/sd/pii/S0031320396001422

多类分类的评分指标

到目前为止,我们讨论的评分指标特定于二元分类系统。然而,scikit-learn 也通过一对所有(OvA)分类实现了宏平均和微平均方法,以将这些评分指标扩展到多类问题。微平均是从系统的各个 TP、TN、FP 和 FN 中计算出来的。例如,在 k 类系统中,精度得分的微平均可以计算如下:

宏平均简单地计算为不同系统的平均得分:

Micro-averaging 很有用,如果我们想平等地加权每个实例或预测,而宏平均则权衡所有类别,以评估分类器在最常见的类别标签方面的整体性能。

如果我们在 scikit-learn 中使用二元性能指标来评估多类分类模型,通常默认使用加权宏平均的规范化或加权变体。加权宏平均通过在计算平均值时将每个类别标签的得分按真实实例的数量加权来计算。如果我们处理类别不平衡,即每个标签具有不同数量的实例,加权宏平均非常有用。

虽然在 scikit-learn 中,加权宏平均是多类问题的默认设置,但我们可以通过 average 参数在从 sklearn.metrics 模块导入的不同评分函数中指定平均方法,例如 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 函数,可以通过从数据集中有放回地抽取新样本来帮助上采样少数类。以下代码将从我们不平衡的 Breast Cancer Wisconsin 数据集中获取少数类(这里是类别 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,反之亦然。

生成新的训练数据以解决类别不平衡问题

处理类别不平衡的另一种技术是生成合成训练示例,这超出了本书的范围。可能是最广泛使用的合成训练数据生成算法是Synthetic Minority Over-sampling TechniqueSMOTE),您可以在Nitesh Chawla等人的原始研究文章SMOTE: Synthetic Minority Over-sampling TechniqueJournal of Artificial Intelligence Research,16: 321-357,2002 年中了解更多信息,链接在www.jair.org/index.php/jair/article/view/10302。同时强烈建议查看 imbalanced-learn,这是一个完全专注于不平衡数据集的 Python 库,包括 SMOTE 的实现。您可以在github.com/scikit-learn-contrib/imbalanced-learn了解更多关于 imbalanced-learn 的信息。

摘要

在本章开头,我们讨论了如何在便捷的模型管道中串联不同的转换技术和分类器,这帮助我们更有效地训练和评估机器学习模型。然后我们使用这些管道执行了 k 折交叉验证,这是模型选择和评估的基本技术之一。使用 k 折交叉验证,我们绘制了学习曲线和验证曲线,以诊断学习算法的常见问题,如过拟合和欠拟合。

我们进一步通过网格搜索、随机搜索和逐步缩减法对我们的模型进行了精细调整。然后,我们使用混淆矩阵和各种性能指标来评估和优化模型在特定问题任务中的性能。最后,我们讨论了处理不平衡数据的不同方法,这在许多现实世界的应用中是一个常见问题。现在,您应该已经掌握了构建成功的监督机器学习分类模型所需的基本技术。

在下一章中,我们将探讨集成方法:这些方法允许我们结合多个模型和分类算法,进一步提高机器学习系统的预测性能。

加入我们书籍的 Discord 空间

每月与作者进行问答活动的书籍 Discord 工作空间:

packt.link/MLwPyTorch

第七章:结合不同模型进行集成学习

在上一章中,我们专注于调整和评估不同分类模型的最佳实践。在本章中,我们将基于这些技术,探讨构建一组分类器的不同方法,这些方法通常比其单个成员具有更好的预测性能。我们将学习如何执行以下操作:

  • 基于多数投票进行预测

  • 使用装袋(bagging)通过重复随机组合训练数据集来减少过拟合。

  • 应用提升(boosting)来从学习者的错误中构建强大的模型

使用集成学习

集成方法的目标是将不同的分类器组合成一个元分类器,其泛化性能比单独的每个分类器都更好。例如,假设我们从 10 位专家那里收集了预测结果,集成方法将允许我们通过策略性地结合这些预测结果,得出比每个单独专家预测更准确和更稳健的预测结果。正如您将在本章后面看到的,有几种不同的方法可以创建一组分类器的集成。本节将介绍集成如何工作的基本解释,以及为什么它们通常被认为能够产生良好的泛化性能。

在本章中,我们将专注于使用多数投票原则的最流行的集成方法。多数投票简单来说就是选择被多数分类器预测的类标签,即获得超过 50%投票的类标签。严格来说,“多数投票”这个术语仅适用于二元分类设置。然而,可以轻松将多数投票原则推广到多类别设置中,这被称为多数票投票(在英国,人们通过“绝对多数”和“相对多数”这两个术语来区分多数和多数投票)。

在这里,我们选择接收到最多票数的类标签(众数)。图 7.1说明了对于包含 10 个分类器的集成,多数投票和多数票投票的概念,其中每个唯一的符号(三角形、正方形和圆形)代表一个唯一的类标签:

图 7.1:不同的投票概念

使用训练数据集,我们首先训练m个不同的分类器(C[1], ..., C[m])。根据技术的不同,集成可以由不同的分类算法构建,例如决策树、支持向量机、逻辑回归分类器等。或者,我们也可以使用相同的基础分类算法,拟合训练数据集的不同子集。这种方法的一个显著例子是随机森林算法,它结合了不同的决策树分类器,我们在第三章《使用 Scikit-Learn 的机器学习分类器导览》中有所涵盖。图 7.2说明了使用多数投票的一般集成方法的概念:

图 7.2:一般集成方法

为了通过简单的多数投票来预测类标签,我们可以结合每个单独分类器C[j]的预测类标签,并选择获得最多票数的类标签,

(在统计学中,众数是集合中最频繁出现的事件或结果。例如,mode{1, 2, 1, 1, 2, 4, 5, 4} = 1。)

例如,在一个二分类任务中,其中 class1 = -1,class2 = +1,我们可以将多数投票预测写成如下形式:

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

在这里, 是二项式系数 n choose k。换句话说,我们计算集成预测错误的概率。现在,让我们看一个具体的例子,有 11 个基分类器(n = 11),每个分类器的错误率为 0.25 ():

二项式系数

二项式系数是指我们可以从大小为n的集合中选择k个无序元素的方式数;因此,它通常被称为“n choose k”。由于顺序在这里并不重要,二项式系数有时也被称为组合数组合数,在其完整形式中,它的写法如下:

这里,符号(!)表示阶乘,例如,3! = 3×2×1 = 6。

正如您所看到的,集成的错误率(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函数之后,我们可以计算一系列不同基础错误率的集成错误率,以可视化在一条线图中集成与基础错误之间的关系:

>>> 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 轴显示了基本错误(虚线)以及集成错误(实线):

图 7.3:集成错误与基本错误的绘图

通过多数投票组合分类器

在前一节对集成学习的简短介绍之后,让我们开始一个热身练习,并在 Python 中实现一个简单的多数投票集成分类器。

多数投票

尽管我们将在本节讨论的多数投票算法也通过多数投票一般化到多类设置中,但为了简单起见,文献中通常使用“多数投票”这个术语。

实现一个简单的多数投票分类器

我们将在本节实现的算法允许我们结合具有置信度的不同分类算法。我们的目标是构建一个更强大的元分类器,以平衡特定数据集上个别分类器的弱点。更精确地说,我们可以将加权多数投票写成如下形式:

这里,w[j] 是与基分类器 C[j] 相关联的权重; 是集成预测的类标签;A 是唯一类标签的集合;(希腊字母 chi)是特征函数或指示函数,如果第 j 个分类器的预测类与 i 匹配,则返回 1(Cj = i)。对于相等的权重,我们可以简化这个方程并写成如下形式:

为了更好地理解加权的概念,我们现在来看一个更具体的例子。假设我们有三个基分类器的集成,,我们想要预测给定示例x的类标签,。其中三个基分类器中的两个预测类标签为 0,而一个C[3]预测示例属于类 1。如果我们对每个基分类器的预测进行平等加权,多数投票预测该示例属于类 0:

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

更简单地说,因为 3×0.2 = 0.6,我们可以说C[3]的预测比C[1]或C[2]的预测重三倍,可以写成如下形式:

要将加权多数投票的概念翻译成 Python 代码,我们可以使用 NumPy 方便的argmaxbincount函数,其中bincount函数统计每个类标签的出现次数。然后argmax函数返回最高计数的索引位置,对应于多数类标签(假设类标签从 0 开始):

>>> import numpy as np
>>> np.argmax(np.bincount([0, 0, 1],
...           weights=[0.2, 0.2, 0.6]))
1 

正如您在第三章关于逻辑回归的讨论中所记得的那样,scikit-learn 中的某些分类器也可以通过predict_proba方法返回预测类标签的概率。如果我们集成中的分类器被很好地校准,使用预测的类概率而不是类标签进行多数投票可能会很有用。从概率中预测类标签的修改版本可以写成如下形式:

这里,p[ij]是第j个分类器对类标签i的预测概率。

继续我们之前的例子,假设我们有一个二元分类问题,类标签为,并且有三个分类器的集成,。假设分类器C[j]为特定示例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):
    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):
        if self.vote not in ('probability', 'classlabel'):
            raise ValueError(f"vote must be 'probability' "
                             f"or 'classlabel'"
                             f"; got (vote={self.vote})")
        if self.weights and
        len(self.weights) != len(self.classifiers):
            raise ValueError(f'Number of classifiers and'
                             f' weights must be equal'
                             f'; got {len(self.weights)} weights,'
                             f' {len(self.classifiers)} 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):
        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):
        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):
        if not deep:
            return super().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[f'{name}__{key}'] = value
            return out 

此外,请注意,我们定义了自己修改版的get_params方法来使用_name_estimators函数访问集成中各个分类器的参数;这一开始可能看起来有点复杂,但当我们在后续章节中使用网格搜索进行超参数调整时,这将变得非常合理。

scikit-learn 中的 VotingClassifier

尽管MajorityVoteClassifier的实现非常适合演示目的,但我们基于本书第一版的实现在 scikit-learn 中实现了一个更复杂的多数投票分类器版本。这个集成分类器在 scikit-learn 版本 0.17 及更高版本中可用作sklearn.ensemble.VotingClassifier。您可以在scikit-learn.org/stable/modules/generated/sklearn.ensemble.VotingClassifier.html找到更多关于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 分数。在第三章中,我们看到了逻辑回归模型中如何计算类概率。在决策树中,概率是从训练时为每个节点创建的频率向量中计算出来的。该向量收集每个类标签在该节点处从类标签分布计算出的频率值。然后,对频率进行归一化,使它们总和为 1。同样,k 最近邻居算法中的 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(f'ROC AUC: {scores.mean():.2f} '
...           f'(+/- {scores.std():.2f}) [{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 最近邻分类器作为管道的一部分进行训练。其背后的原因是,正如在第三章中讨论的那样,与决策树不同,逻辑回归和 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(f'ROC AUC: {scores.mean():.2f} '
...           f'(+/- {scores.std():.2f}) [{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=f'{label} (auc = {roc_auc:.2f})')
>>> 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)。然而,您可以看到逻辑回归分类器在相同数据集上表现同样良好,这可能是由于高方差(在这种情况下,我们拆分数据集的敏感性)造成的,因为数据集的大小较小:

图 7.4:不同分类器的 ROC 曲线

由于我们只选择了两个特征作为分类示例,看看集成分类器的决策区域实际上是什么样子会很有趣。

虽然在模型拟合前无需对训练特征进行标准化,因为我们的逻辑回归和 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() 

有趣的是,但也是预期的,集成分类器的决策区域似乎是从各个单个分类器的决策区域混合而来的。乍一看,多数投票的决策边界看起来很像决策树桩的决策边界,后者在sepal width ≥ 1 时与 y 轴正交。

然而,您还可以注意到混入了 k 近邻分类器的非线性:

图 7.5:不同分类器的决策边界

在为集成分类调整单个分类器的参数之前,让我们调用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,
...                     scoring='roc_auc')
>>> grid.fit(X_train, y_train) 

网格搜索完成后,我们可以打印不同的超参数值组合和通过 10 折交叉验证计算的平均 ROC AUC 分数如下:

>>> for r, _ in enumerate(grid.cv_results_['mean_test_score']):
...     mean_score = grid.cv_results_['mean_test_score'][r]
...     std_dev = grid.cv_results_['std_test_score'][r]
...     params = grid.cv_results_['params'][r]
...     print(f'{mean_score:.3f} +/- {std_dev:.2f} {params}')
0.983 +/- 0.05 {'decisiontreeclassifier__max_depth': 1,
                'pipeline-1__clf__C': 0.001}
0.983 +/- 0.05 {'decisiontreeclassifier__max_depth': 1,
                'pipeline-1__clf__C': 0.1}
0.967 +/- 0.10 {'decisiontreeclassifier__max_depth': 1,
                'pipeline-1__clf__C': 100.0}
0.983 +/- 0.05 {'decisiontreeclassifier__max_depth': 2,
                'pipeline-1__clf__C': 0.001}
0.983 +/- 0.05 {'decisiontreeclassifier__max_depth': 2,
                'pipeline-1__clf__C': 0.1}
0.967 +/- 0.10 {'decisiontreeclassifier__max_depth': 2,
                'pipeline-1__clf__C': 100.0}
>>> print(f'Best parameters: {grid.best_params_}')
Best parameters: {'decisiontreeclassifier__max_depth': 1,
                  'pipeline-1__clf__C': 0.001}
>>> print(f'ROC AUC : {grid.best_score_:.2f}')
ROC AUC: 0.98 

如您所见,当我们选择较低的正则化强度(C=0.001)时,我们获得了最佳的交叉验证结果,而树的深度似乎对性能没有影响,这表明决策树桩足以分离数据。为了提醒自己不要在模型评估中多次使用测试数据集是一种不良实践,在本节中我们不会估计调整后的超参数的泛化性能。我们将迅速转向集成学习的另一种方法:装袋法

使用堆叠构建集成

我们在本节中实施的多数投票方法不应与堆叠混淆。堆叠算法可以理解为一个两级集成,第一级包括个别分类器,它们将其预测传递给第二级,在第二级中,另一个分类器(通常是逻辑回归)适合于一级分类器的预测以进行最终预测。有关堆叠的更多信息,请参阅以下资源:

Bagging – 从自举样本中构建分类器的集成

Bagging 是一种与我们在前一节中实现的MajorityVoteClassifier密切相关的集成学习技术。但是,与在集成中使用相同的训练数据集来拟合个别分类器不同,我们从初始训练数据集中抽取自举样本(有替换地随机抽样),这就是为什么 bagging 也被称为bootstrap aggregating的原因。

Bagging 的概念总结在Figure 7.6中:

自动生成的图表说明

Figure 7.6: Bagging 的概念

在接下来的小节中,我们将手动进行一个简单的 bagging 示例,并使用 scikit-learn 来分类葡萄酒示例。

Bagging 简介

为了提供一个更具体的例子来说明一个 bagging 分类器的自举聚合是如何工作的,让我们考虑Figure 7.7中显示的例子。在这里,我们有七个不同的训练实例(标记为索引 1-7),在每一轮 bagging 中随机且有替换地抽样。然后每个自举样本用于拟合分类器C[j],这通常是一个未修剪的决策树:

自动生成的图表说明

Figure 7.7: Bagging 的一个例子

正如您从 图 7.7 中所看到的,每个分类器从训练数据集中接收一个随机子集。我们将这些通过 bagging 获得的随机样本标记为 Bagging round 1Bagging round 2 等等。每个子集包含一定比例的重复项,一些原始样本由于使用替换采样,可能根本不会出现在重新采样的数据集中。一旦个别分类器适合于自举样本,预测结果就会使用多数投票结合起来。

注意,bagging 还与我们在 第三章 中介绍的随机森林分类器相关。事实上,随机森林是 bagging 的一种特殊情况,我们在适合个别决策树时也使用随机特征子集。

使用 bagging 的模型集成

Bagging 最初由 Leo Breiman 在 1994 年的一份技术报告中提出;他还表明,bagging 可以提高不稳定模型的准确性并降低过拟合的程度。我们强烈建议您阅读 L. BreimanBagging predictors 中的研究,发表于 Machine Learning,24(2):123–140, 1996 年,这篇文章可以在网上免费获取,以了解有关 bagging 的更多细节。

应用 bagging 对 Wine 数据集中的示例进行分类

要看到 bagging 的实际效果,让我们使用在 第四章 构建良好的训练数据集 – 数据预处理 中介绍的 Wine 数据集创建一个更复杂的分类问题。在这里,我们只考虑 Wine 类别 2 和 3,并选择两个特征 – AlcoholOD280/OD315 of diluted wines

>>> 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) 

获取 Wine 数据集

您可以在本书的代码捆绑包中找到 Wine 数据集(以及本书中使用的所有其他数据集),如果您离线工作或 UCI 服务器在 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) 

在 scikit-learn 中已经实现了 BaggingClassifier 算法,我们可以从 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) 

接下来,我们将计算训练集和测试集上预测的准确率分数,以比较 bagging 分类器的性能与单个未修剪决策树的性能:

>>> 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(f'Decision tree train/test accuracies '
...       f'{tree_train:.3f}/{tree_test:.3f}')
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(f'Bagging train/test accuracies '
...       f'{bag_train:.3f}/{bag_test:.3f}')
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() 

如我们在得出的绘图中所见,三节点深度决策树的分段线性决策边界在装袋法集成中看起来更加平滑:

图 7.8:决策树的分段线性决策边界与装袋法的对比

在本节中,我们只讨论了一个非常简单的装袋示例。在实践中,更复杂的分类任务和数据集的高维度很容易导致单个决策树过拟合,这正是装袋算法可以发挥其优势的地方。最后,我们必须注意,装袋算法可以是降低模型方差的有效方法。然而,装袋在减少模型偏差方面效果不佳,也就是说,模型过于简单无法很好地捕捉数据中的趋势。这就是为什么我们希望在偏差较低的分类器集成上执行装袋,例如未修剪的决策树。

通过自适应提升利用弱学习者

在关于集成方法的最后一节中,我们将讨论提升,特别关注其最常见的实现方式:自适应提升AdaBoost)。

AdaBoost 的认知

AdaBoost 的原始理念由 Robert E. Schapire 在 1990 年的《弱可学习性的力量》,机器学习,5(2):197-227 中提出,详细可见于 rob.schapire.net/papers/strengthofweak.pdf。在 Robert Schapire 和 Yoav Freund 于 第十三届国际会议论文集(ICML 1996)中展示 AdaBoost 算法后,AdaBoost 成为随后几年中最广泛使用的集成方法之一(Experiments with a New Boosting AlgorithmY. FreundR. E. Schapire 等人,ICML,96 年卷,148-156,1996)。2003 年,Freund 和 Schapire 因其开创性工作获得 Gödel 奖,这是计算机科学领域中最杰出出版物的一项重要奖项。

在提升中,集成由非常简单的基本分类器组成,通常称为弱学习者,它们通常仅比随机猜测略有优势—一个典型的弱学习者示例是决策树桩。提升背后的关键概念是专注于难以分类的训练样本,也就是让弱学习者连续从误分类的训练样本中学习,以提高集成的性能。

以下各小节将介绍增强学习和 AdaBoost 概念背后的算法过程。最后,我们将使用 scikit-learn 进行一个实际的分类示例。

自适应 Boosting 的工作原理

与 Bagging 相反,Boosting 算法的初始形式使用从训练数据集中无放回抽取的随机训练例子子集;原始 Boosting 过程可以总结为以下四个关键步骤:

  1. 从训练数据集D中无放回地抽取一个随机子集(样本),d[1],以训练一个弱学习器,C[1]。

  2. 从训练数据集中无放回地抽取第二个随机训练子集,d[2],并添加之前错误分类的例子的 50%来训练一个弱学习器,C[2]。

  3. 找到训练数据集DC[1]和C[2]不同意的训练例子d[3],以训练第三个弱学习器C[3]。

  4. 通过多数投票组合弱学习器C[1]、C[2]和C[3]。

正如 Leo Breiman 在 1996 年所讨论的(偏差、方差和 arcing 分类器),Boosting 相对于 Bagging 模型可以导致偏差和方差的减少。然而,在实践中,Boosting 算法如 AdaBoost 也以其高方差而闻名,即倾向于过拟合训练数据(通过 G. Raetsch、T. Onoda 和 K. R. Mueller 的An improvement of AdaBoost to avoid overfittingProceedings of the International Conference on Neural Information ProcessingCiteSeer*,1998)。

与此处描述的原始 Boosting 过程相反,AdaBoost 使用完整的训练数据集来训练弱学习器,在每次迭代中重新加权训练例子,以构建一个从前一组合中的弱学习器错误中学习的强分类器。

在我们深入讨论 AdaBoost 算法的具体细节之前,让我们看一下Figure 7.9,以更好地理解 AdaBoost 背后的基本概念:

图 7.9:AdaBoost 概念改进弱学习器

为了逐步浏览 AdaBoost 插图,我们将从子图 1 开始,该子图代表了一个用于二元分类的训练数据集,所有训练例子被分配相同的权重。基于这个训练数据集,我们训练一个决策树桩(显示为虚线),试图分类两类(三角形和圆圈)的例子,同时可能最小化损失函数(或者在决策树集成的特殊情况下是杂质评分)。

对于下一轮(子图 2),我们给之前错误分类的两个例子(圆圈)分配更大的权重。此外,我们降低正确分类例子的权重。接下来的决策树桩现在将更加关注那些具有最大权重的训练例子,即那些据称难以分类的训练例子。

在子图 2 中显示的弱学习器错误分类了三个来自圆圈类的不同例子,然后在子图 3 中分配了更大的权重。

假设我们的 AdaBoost 集成仅包括三轮增强,然后通过加权多数投票结合在不同的重新加权训练子集上训练的三个弱学习器,如子图 4 所示。

现在我们对 AdaBoost 的基本概念有了更好的理解,让我们使用伪代码更详细地了解算法。为了清晰起见,我们将用乘号(×)表示元素级乘法,用点号(⋅)表示两个向量的点积:

  1. 将权重向量 w 设置为均匀权重,其中 .

  2. 对于第 j 轮的 m 次增强,执行以下操作:

    1. 训练一个加权弱学习器: C[j] = train(X, y, w).

    2. 预测类别标签: .

    3. 计算加权错误率: .

    4. 计算系数: .

    5. 更新权重: .

    6. 将权重归一化为总和为 1: .

  3. 计算最终预测: .

注意,在 步骤 2c 中,表达式 指的是一个由 1 和 0 组成的二进制向量,如果预测不正确则赋值为 1,否则赋值为 0。

尽管 AdaBoost 算法看起来非常直接,让我们通过一个具体的例子来详细介绍一下,该例子使用了包含 10 个训练样本的训练数据集,如 图 7.10 所示:

图 7.10: 通过 AdaBoost 算法运行 10 个训练样本

表的第一列显示了训练样本 1 到 10 的索引。第二列显示了个别样本的特征值,假设这是一个一维数据集。第三列显示了每个训练样本 x[i] 的真实类别标签 y[i],其中 。第四列显示了初始权重;我们使用均匀初始化权重,并将它们归一化为总和为 1。对于这个 10 个样本的训练数据集,我们因此将每个权重 w[i] 赋值为 0.1,放在权重向量 w 中。预测的类别标签 显示在第五列,假设我们的分割标准为 。表的最后一列根据我们在伪代码中定义的更新规则显示了更新后的权重。

由于权重更新的计算起初可能看起来有些复杂,我们现在将一步步地跟随计算过程。我们将从计算加权错误率 epsilon)开始,如 步骤 2c 中所述。

>>> y = np.array([1, 1, 1, -1, -1, -1,  1,  1,  1, -1])
>>> yhat = np.array([1, 1, 1, -1, -1, -1, -1, -1, -1, -1])
>>> correct = (y == yhat)
>>> weights = np.full(10, 0.1)
>>> print(weights)
[0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1]
>>> epsilon = np.mean(~correct)
>>> print(epsilon)
0.3 

注意,correct 是一个布尔数组,其中包含 TrueFalse 值,其中 True 表示预测是正确的。通过 ~correct,我们反转数组,使 np.mean(~correct) 计算不正确预测的比例(True 计为值 1,False 计为值 0),即分类错误率。

接下来,我们将计算系数 ——显示在 步骤 2d 中 —— 这将稍后用于 步骤 2e 中更新权重,以及在多数投票预测 (步骤 3) 中的权重:

>>> alpha_j = 0.5 * np.log((1-epsilon) / epsilon)
>>> print(alpha_j)
0.42364893019360184 

计算了系数 alpha_j)之后,我们现在可以使用以下方程更新权重向量:

在这里, 是预测和真实类标签向量的逐元素乘积。因此,如果预测 是正确的, 将具有正号,以便我们减少第 i 个权重,因为 也是一个正数:

>>> update_if_correct = 0.1 * np.exp(-alpha_j * 1 * 1)
>>> print(update_if_correct)
0.06546536707079771 

同样地,如果 预测的标签是错误的,我们将增加第 i 个权重,如下所示:

>>> update_if_wrong_1 = 0.1 * np.exp(-alpha_j * 1 * -1)
>>> print(update_if_wrong_1)
0.1527525231651947 

或者,可以这样:

>>> update_if_wrong_2 = 0.1 * np.exp(-alpha_j * 1 * -1)
>>> print(update_if_wrong_2) 

我们可以使用这些值来更新权重,如下所示:

>>> weights = np.where(correct == 1,
...                    update_if_correct,
...                    update_if_wrong_1)
>>> print(weights)
array([0.06546537, 0.06546537, 0.06546537, 0.06546537, 0.06546537,
       0.06546537, 0.15275252, 0.15275252, 0.15275252, 0.06546537]) 

上面的代码将 update_if_correct 值分配给所有正确预测,并将 update_if_wrong_1 值分配给所有错误预测。为简单起见,我们省略了使用 update_if_wrong_2,因为它与 update_if_wrong_1 类似。

在更新了权重向量中的每个权重后,我们会归一化这些权重,使它们总和为 1 (步骤 2f):

在代码中,我们可以这样实现:

>>> normalized_weights = weights / np.sum(weights)
>>> print(normalized_weights)
[0.07142857 0.07142857 0.07142857 0.07142857 0.07142857 0.07142857
 0.16666667 0.16666667 0.16666667 0.07142857] 

因此,对于每个正确分类的示例,其对应的权重将从初始值 0.1 减少到下一轮增强的 0.0714。同样地,错误分类示例的权重将从 0.1 增加到 0.1667。

使用 scikit-learn 应用 AdaBoost

前一小节简要介绍了 AdaBoost。跳过更实际的部分,现在让我们通过 scikit-learn 训练 AdaBoost 集成分类器。我们将使用与前一节中训练装袋元分类器相同的 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(f'Decision tree train/test accuracies '
...       f'{tree_train:.3f}/{tree_test:.3f}')
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(f'AdaBoost train/test accuracies '
...       f'{ada_train:.3f}/{ada_test:.3f}')
AdaBoost train/test accuracies 1.000/0.917 

在这里,你可以看到 AdaBoost 模型完全预测了训练数据集的所有类标签,并且与决策树桩相比,测试数据集的表现稍有改善。然而,我们在尝试减少模型偏差时引入了额外的方差,导致训练和测试性能之间的差距增大。

尽管我们使用了另一个简单的示例来进行演示,但我们可以看到 AdaBoost 分类器的性能略有改善,比决策桩实现了非常相似的准确性分数,就像我们在前一节中训练的装袋分类器一样。然而,我们必须注意,基于重复使用测试数据集来选择模型被认为是不良实践。我们在第六章 ,学习模型评估和超参数调整的最佳实践中更详细地讨论了广义性能估计可能过于乐观的问题。

最后,让我们看看决策区域的具体情况:

>>> 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 模型将特征空间分隔得与我们在前一节中训练的装袋分类器非常相似:

图 7.11:决策树与 AdaBoost 的决策边界

作为集成技术的总结,值得注意的是,与单个分类器相比,集成学习增加了计算复杂性。在实践中,我们需要仔细考虑是否愿意为预测性能的相对较小改进支付增加的计算成本。

这种权衡的一个常被引用的例子是著名的**1 百万美元 Netflix Prize **,它是使用集成技术赢得的。有关算法的详细信息已在The BigChaos Solution to the Netflix Grand Prize中由A. ToescherM. JahrerR. M. Bell发表,Netflix Prize documentation,2009 年,可在www.stat.osu.edu/~dmsl/GrandPrize2009_BPC_BigChaos.pdf找到。获奖团队获得了 100 万美元的大奖金;然而,由于其复杂性,Netflix 从未实施过他们的模型,这使得它在现实世界中难以应用:

“我们离线评估了一些新方法,但我们测量到的额外准确度增益似乎不足以证明将它们引入生产环境所需的工程投入是合理的。”

techblog.netflix.com/2012/04/netflix-recommendations-beyond-5-stars.html

梯度提升 - 基于损失梯度训练集成

梯度提升是前一节介绍的增强概念的另一种变体,即连续训练弱学习器以创建强大的集成。梯度提升是一个极其重要的主题,因为它构成了流行的机器学习算法(如 XGBoost)的基础,这些算法以在 Kaggle 竞赛中获胜而闻名。

梯度提升算法一开始可能显得有点令人畏惧。因此,在接下来的小节中,我们将逐步覆盖它,从一个总体概述开始。然后,我们将看到梯度提升是如何用于分类,并通过一个例子详细介绍。最后,在介绍了梯度提升的基本概念后,我们将简要介绍一些流行的实现,比如 XGBoost,并探讨如何在实践中应用梯度提升。

比较 AdaBoost 和梯度提升

从根本上讲,梯度提升与 AdaBoost 非常相似,我们在本章之前已经讨论过 AdaBoost。AdaBoost 基于前一个决策树桩的错误训练决策树桩。特别是,在每一轮中使用错误来计算样本权重,以及在将各个树桩组合成集合时为每个决策树桩计算分类器权重。一旦达到最大迭代次数(决策树桩数),我们就停止训练。与 AdaBoost 类似,梯度提升以迭代方式使用预测错误拟合决策树。然而,梯度提升树通常比决策树桩更深,通常具有 3 到 6 的最大深度(或 8 到 64 个叶节点的最大数量)。此外,与 AdaBoost 不同的是,梯度提升不使用预测错误来分配样本权重;它们直接用于形成下一个树的目标变量的拟合。此外,与 AdaBoost 不同的是,梯度提升使用全局学习率,该学习率对每棵树都是相同的,而不是每棵树都有一个单独的加权项。

如您所见,AdaBoost 和梯度提升在某些关键方面有相似之处,但也有所不同。在接下来的小节中,我们将概述梯度提升算法的一般轮廓。

描绘梯度提升算法的一般轮廓

在本节中,我们将研究用于分类的梯度提升。为简单起见,我们将看一个二分类的例子。对于感兴趣的读者,可以在 2001 年 Friedman 撰写的原始梯度提升论文中找到使用多类别设置和逻辑损失的一般化方法,即第 4.6 节,多类逻辑回归和分类,《贪婪函数逼近:梯度提升机》,projecteuclid.org/journals/annals-of-statistics/volume-29/issue-5/Greedy-function-approximation-A-gradient-boostingmachine/10.1214/aos/1013203451.full

梯度提升用于回归

注意,梯度提升背后的过程比 AdaBoost 稍微复杂一些。为了简洁起见,我们忽略了 Friedman 在论文中提供的一个更简单的回归示例,但鼓励感兴趣的读者也考虑观看我关于回归梯度提升的补充视频教程,该视频教程可在以下链接找到:www.youtube.com/watch?v=zblsrxc7XpM

实质上,梯度提升构建了一系列树,每棵树都适应于前一棵树的误差 —— 标签和预测值之间的差异。每一轮,树的集合都会因为我们通过小的更新将每棵树朝正确方向推动而得到改进。这些更新基于损失梯度,这也是梯度提升得名的原因。

接下来的步骤将介绍梯度提升背后的一般算法。在说明主要步骤后,我们将更详细地讨论其部分,并在接下来的小节中通过一个实际例子进行实操演示。

  1. 初始化一个模型以返回一个常数预测值。为此,我们使用决策树的根节点;也就是说,一个只有一个叶节点的决策树。我们将树返回的值表示为 ,并通过最小化我们稍后将定义的可微损失函数 L 来找到该值:

    这里,n 指的是数据集中的 n 个训练样本。

  2. 对于每棵树 m = 1, ..., M,其中 M 是用户指定的总树数,我们按下面 2a 到 2d 步骤进行如下计算:

    1. 计算预测值 与类标签 y[i] 之间的差异。这个值有时被称为伪响应或伪残差。更正式地说,我们可以将这个伪残差写成损失函数对预测值的负梯度:

      注意,在上述符号中,F(x) 是前一棵树的预测值,F[m]–1。因此,在第一轮中,这指的是步骤 1 中来自树(单叶节点)的常数值。

    2. 将树适配到伪残差 r[im] 上。我们使用符号 R[jm] 来表示在第 m 次迭代中生成的树的 j = 1 ... J[m] 叶节点。

    3. 对于每个叶节点 R[jm],我们计算以下输出值:

      在下一小节中,我们将深入探讨如何通过最小化损失函数计算 。此时,我们已经注意到叶节点 R[jm] 可能包含多个训练样本,因此需要求和。

    4. 通过将输出值 添加到前一棵树来更新模型:

      然而,我们不是将当前树的完整预测值 添加到上一棵树 ,而是通过学习率 缩放 ,该学习率通常是介于 0.01 和 1 之间的小值。换句话说,我们通过小步骤增量更新模型,这有助于避免过拟合。

现在,在查看梯度提升的一般结构后,我们将采用这些机制来研究用于分类的梯度提升。

解释分类梯度提升算法

在这一小节中,我们将详细介绍实施二元分类梯度提升算法的细节。在这种情况下,我们将使用我们在《第三章》,《使用 Scikit-Learn 的机器学习分类器之旅》中为逻辑回归介绍的逻辑损失函数。对于单个训练示例,我们可以指定逻辑损失如下:

在《第三章》中,我们还介绍了对数几率:

出于后续合理性考虑,我们将使用这些对数几率来重写逻辑函数,如下所示(此处省略中间步骤):

现在,我们可以定义损失函数对这些对数几率的偏导数,。该损失函数对对数几率的导数是:

在指定这些数学定义后,让我们现在重新审视前一节中一般梯度提升的步骤 12d,并为此二元分类场景重新制定它们。

  1. 创建一个最小化逻辑损失的根节点。结果表明,如果根节点返回对数几率,则损失将最小化,

  2. 对于每棵树 m = 1, ..., M,其中 M 是用户指定的总树数,我们按步骤 2a2d进行以下计算:

    1. 我们使用在逻辑回归中使用的熟悉逻辑函数将对数几率转换为概率:

      然后,我们计算伪残差,即损失对对数几率的负偏导数,其实是类标签与预测概率之间的差:

    2. 将新树适应于伪残差。

    3. 对于每个叶子节点 R[jm],计算一个值 ,该值最小化逻辑损失函数。这包括一个摘要步骤,用于处理包含多个训练示例的叶子节点:

      跳过中间数学细节,得到以下结果:

      请注意,此处的求和仅限于与叶子节点 R[jm] 对应的节点示例,而不是完整的训练集。

    4. 通过将学习率为 step 2c 中的 gamma 值添加到模型中进行更新:

输出对数几率与概率的差异

为什么树返回的是对数几率而不是概率?这是因为我们不能简单地将概率值相加并得出有意义的结果。(因此,严格来说,用于分类的梯度提升使用回归树。)

在本节中,我们采用了通用的梯度提升算法,并将其具体化为二元分类,例如,通过将通用损失函数替换为逻辑损失函数和预测值替换为对数几率。然而,许多个别步骤可能仍然显得非常抽象,在下一节中,我们将这些步骤应用到一个具体的示例中。

展示分类梯度提升

前两个小节介绍了用于二元分类的梯度提升算法的简化数学细节。为了更清楚地解释这些概念,让我们将其应用到一个小型玩具示例中,即以下三个示例的训练数据集,如 Figure 7.12 所示:

自动生成表格说明

图 7.12:用于解释梯度提升的玩具数据集

让我们从 step 1 开始,构建根节点并计算对数几率,以及 step 2a,将对数几率转换为类成员概率并计算伪残差。请注意,根据我们在 Chapter 3 中学到的内容,几率可以计算为成功的数量除以失败的数量。在这里,我们将标签 1 视为成功,标签 0 视为失败,因此几率计算为:几率 = 2/1。执行 12a 步骤后,我们得到以下结果,如 Figure 7.13 所示:

自动生成表格说明

图 7.13:应用步骤 1 和步骤 2a 第一轮的结果

接下来,在 step 2b 中,我们在伪残差 r 上拟合一个新的树。然后,在 step 2c 中,我们计算该树的输出值,,如 Figure 7.14 所示:

自动生成图示说明

图 7.14:展示步骤 2b 和 2c,即将决策树拟合到残差上,并计算每个叶节点的输出值

(请注意,我们人为地限制了树只有两个叶节点,这有助于说明如果一个叶节点包含多个示例会发生什么。)

然后,在最后的 step 2d 中,我们更新前一模型和当前模型。假设学习率为 ,第一个训练示例的预测结果如 Figure 7.15 所示:

自动生成图示说明

图 7.15:在第一个训练示例的背景下展示前一模型的更新

现在我们已经完成了第一轮的 步骤 2a2dm = 1,我们可以继续执行第二轮的 步骤 2a2dm = 2. 在第二轮中,我们使用更新模型返回的 log(odds),例如,,作为 步骤 2A 的输入。我们在第二轮获得的新值显示在 图 7.16 中:

自动生成的表描述

图 7.16:第二轮中的值与第一轮旁边的值

我们已经可以看到,对于正类别,预测的概率更高,而对于负类别,预测的概率更低。因此,残差也在变小。请注意,步骤 2a2d 的过程重复进行,直到我们拟合了 M 棵树或残差小于用户指定的阈值为止。然后,一旦梯度提升算法完成,我们可以使用它通过将最终模型的概率值 FM 阈值化为 0.5 来预测类标签,就像第三章中的逻辑回归一样。然而,与逻辑回归不同,梯度提升由多棵树组成,并产生非线性决策边界。在下一节中,我们将看看梯度提升是如何发挥作用的。

使用 XGBoost

在讲解了梯度提升背后的细枝末节之后,让我们最终看看如何使用梯度提升的代码实现。

在 scikit-learn 中,梯度提升被实现为 sklearn.ensemble.GradientBoostingClassifier(详见 scikit-learn.org/stable/modules/generated/sklearn.ensemble.GradientBoostingClassifier.html 获取更多详细信息)。值得注意的是,梯度提升是一个顺序过程,训练速度可能较慢。然而,在最近几年中,出现了更流行的梯度提升实现,即 XGBoost。

XGBoost 提出了几种技巧和近似方法,显著加快了训练过程。因此,XGBoost 的名称即为 extreme gradient boosting。此外,这些近似和技巧导致了非常好的预测性能。事实上,XGBoost 因为在许多 Kaggle 竞赛中取得了胜利而变得流行。

除了 XGBoost 外,还有其他流行的梯度提升实现,例如 LightGBM 和 CatBoost。受 LightGBM 的启发,scikit-learn 现在还实现了 HistGradientBoostingClassifier,比原始梯度提升分类器(GradientBoostingClassifier)性能更好。

您可以通过以下资源找到关于这些方法的更多细节:

然而,由于 XGBoost 仍然是最受欢迎的梯度提升实现之一,我们将看看如何在实践中使用它。首先,我们需要安装它,例如通过 pip

pip install xgboost 

安装 XGBoost

在本章中,我们使用了 XGBoost 版本 1.5.0,可以通过以下方式安装:

pip install XGBoost==1.5.0 

更多有关安装细节的信息可以在 xgboost.readthedocs.io/en/stable/install.html 找到。

幸运的是,XGBoost 的 XGBClassifier 遵循了 scikit-learn 的 API。因此,使用它相对比较简单:

>>> import xgboost as xgb
>>> model = xgb.XGBClassifier(n_estimators=1000, learning_rate=0.01,
...                           max_depth=4, random_state=1,
...                           use_label_encoder=False)
>>> gbm = model.fit(X_train, y_train)
>>> y_train_pred = gbm.predict(X_train)
>>> y_test_pred = gbm.predict(X_test)
>>> gbm_train = accuracy_score(y_train, y_train_pred)
>>> gbm_test = accuracy_score(y_test, y_test_pred)
>>> print(f'XGboost train/test accuracies '
...       f'{gbm_train:.3f}/{gbm_test:.3f}')
XGboost train/test accuracies 0.968/0.917 

在这里,我们使用了 1,000 棵树(回合)和学习率为 0.01 来拟合梯度提升分类器。通常建议学习率在 0.01 到 0.1 之间。但是,请记住,学习率用于缩放来自各个回合的预测。因此,直观地说,学习率越低,需要的估计器数量越多才能获得准确的预测。

接下来,我们有个体决策树的 max_depth,我们将其设置为 4。由于我们仍在增强弱学习器,因此在 2 到 6 之间选择一个值是合理的,但是根据数据集的不同,较大的值也可能表现良好。

最后,use_label_encoder=False 禁用了一条警告消息,告知用户 XGBoost 不再默认转换标签,并且期望用户以从标签 0 开始的整数格式提供标签。(这里没有什么可担心的,因为我们在本书中一直遵循这种格式。)

还有许多其他可用的设置,详细讨论超出了本书的范围。但是,有兴趣的读者可以在原始文档中找到更多细节,网址为 xgboost.readthedocs.io/en/latest/python/python_api.html#xgboost.XGBClassifier

总结

在本章中,我们研究了一些最流行和广泛使用的集成学习技术。集成方法结合了不同的分类模型,以消除它们各自的弱点,这通常会产生稳定且性能良好的模型,这对工业应用和机器学习竞赛非常有吸引力。

在本章的开头,我们用 Python 实现了MajorityVoteClassifier,它允许我们结合不同的分类算法。然后我们看了看 bagging,这是一种通过从训练数据集中随机抽取自举样本来减少模型方差的有用技术,并通过多数投票来结合各自训练的分类器。最后,我们学习了关于 AdaBoost 和梯度提升的 boosting 技术,这些算法基于训练弱学习器,并从错误中学习。

在前几章中,我们学到了关于不同学习算法、调优和评估技术的很多知识。在下一章中,我们将研究机器学习的一个特定应用,情感分析,这在互联网和社交媒体时代变得非常有趣。

加入我们书籍的 Discord 空间

加入书籍的 Discord 工作空间,与作者进行每月的问我任何事会话:

packt.link/MLwPyTorch

第八章:将机器学习应用于情感分析

在现代互联网和社交媒体时代,人们的意见、评论和推荐已成为政治科学和商业的宝贵资源。多亏了现代技术,我们现在能够最高效地收集和分析这些数据。在本章中,我们将深入探讨自然语言处理的一个子领域情感分析,并学习如何使用机器学习算法根据其情感(作者的态度)对文档进行分类。具体来说,我们将使用来自互联网电影数据库IMDb)的 50,000 条电影评论数据集,并构建一个可以区分正面和负面评论的预测器。

本章涵盖的主题包括以下内容:

  • 清理和准备文本数据

  • 从文本文档中构建特征向量

  • 训练一个机器学习模型来分类正面和负面电影评论

  • 使用外存学习处理大文本数据集

  • 从文档集合中推断主题以进行分类

准备 IMDb 电影评论数据进行文本处理

如前所述,情感分析,有时也称为意见挖掘,是自然语言处理的一个流行子学科;它涉及分析文档的情感。情感分析中的一个流行任务是根据作者对特定主题表达的观点或情绪对文档进行分类。

在本章中,我们将使用由 Andrew Maas 等人收集的 IMDb 电影评论大数据集(学习情感分析的单词向量,作者:A. L. MaasR. E. DalyP. T. PhamD. HuangA. Y. NgC. Potts第 49 届年度人类语言技术协会会议论文集:人类语言技术,页码 142-150,俄勒冈州波特兰市,美国人类语言技术协会,2011 年 6 月)。电影评论数据集包含 50,000 条极性电影评论,标记为正面或负面;在此,正面意味着电影在 IMDb 上评分超过六星,负面意味着电影在 IMDb 上评分低于五星。在接下来的几节中,我们将下载数据集,预处理成适合机器学习工具使用的格式,并从这些电影评论的子集中提取有意义的信息,以构建一个可以预测某个评论者是否喜欢或不喜欢某部电影的机器学习模型。

获得电影评论数据集

可以从ai.stanford.edu/~amaas/data/sentiment/下载电影评论数据集的压缩存档(84.1 MB),这是一个 gzip 压缩的 tarball 存档:

  • 如果您使用 Linux 或 macOS,可以打开一个新的终端窗口,cd 到下载目录,并执行 tar -zxf aclImdb_v1.tar.gz 解压缩数据集。

  • 如果您使用 Windows,可以下载一个免费的文件解压缩程序,比如 7-Zip (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 进度指示器PyPrindpypi.python.org/pypi/PyPrind/)包,该包多年前为此类目的开发。PyPrind 可通过执行 pip install pyprind 命令安装:

>>> import pyprind
>>> import pandas as pd
>>> import os
>>> import sys
>>> # change the 'basepath' to the directory of the
>>> # unzipped movie dataset
>>> basepath = 'aclImdb'
>>>
>>> labels = {'pos': 1, 'neg': 0}
>>> pbar = pyprind.ProgBar(50000, stream=sys.stdout)
>>> 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:00:25 

在上述代码中,我们首先使用 50,000 次迭代初始化了一个新的进度条对象 pbar,这是我们将要读取的文档数量。使用嵌套的 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')
>>> # the following column renaming is necessary on some computers:
>>> df = df.rename(columns={"0": "review", "1": "sentiment"})
>>> df.head(3) 

如果您在 Jupyter 笔记本中运行代码示例,现在应该看到数据集的前三个示例,如 图 8.1 所示:

图 8.1:电影评论数据集的前三行

作为健全性检查,在我们进入下一部分之前,请确保DataFrame包含所有 50,000 行:

>>> df.shape
(50000, 2) 

介绍词袋模型

您可能还记得第四章构建良好的训练数据集 - 数据预处理中,我们必须将分类数据(如文本或单词)转换为数值形式,然后才能传递给机器学习算法。在本节中,我们将介绍词袋模型,它允许我们将文本表示为数值特征向量。词袋模型背后的思想非常简单,可以总结如下:

  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方法,我们构建了词袋模型的词汇表,并将以下三个句子转换为稀疏特征向量:

  • 'The sun is shining'

  • 'The weather is sweet'

  • 'The sun is shining, the weather is sweet, and one and one is two'

现在,让我们打印词汇表的内容,以更好地理解其中的概念:

>>> 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词汇表中的整数值,这些值也称为原始词项频率tf(td) — 术语t在文档d中出现的次数。应注意,在词袋模型中,句子或文档中术语的顺序并不重要。特征向量中术语频率出现的顺序是从词汇索引派生的,通常按字母顺序分配。

N-gram 模型

我们刚刚创建的词袋模型中的项目序列也被称为 1-gram 或 unigram 模型,词汇表中的每个项目或标记代表一个单词。更一般地,在自然语言处理中,单词、字母或符号的连续序列也被称为n-grams。在 n-gram 模型中,数量 n 的选择取决于特定的应用程序;例如,Ioannis Kanaris 等人的研究显示,大小为 3 和 4 的 n-grams 在电子邮件的反垃圾邮件过滤中表现良好(Words versus character n-grams for anti-spam filtering作者是Ioannis Kanaris, Konstantinos Kanaris, Ioannis Houvardas, 和 Efstathios StamatatosInternational 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 表示,我们可以通过初始化新的CountVectorizer实例,并设置ngram_range=(2,2)切换到 2-gram 表示。

通过词项频率-逆文档频率评估词语相关性

当我们分析文本数据时,我们经常会遇到跨越两类文档中的多个文档出现的词语。这些频繁出现的词语通常不含有有用或歧视性信息。在本小节中,您将了解一个称为词项频率-逆文档频率tf-idf)的有用技术,可用于降低特征向量中这些频繁出现的词语的权重。tf-idf 可以定义为术语频率和逆文档频率的乘积:

tf-idf(td) = tf(td) × idf(td)

在这里,tf(td)是我们在上一节介绍的词频,而idf(td)是逆文档频率,可以计算如下:

在这里,n[d]是文档的总数,而df(dt)是包含术语t的文档d的数量。请注意,将常数 1 添加到分母是可选的,其目的是为未出现在任何训练示例中的术语分配一个非零值;log用于确保低文档频率不会被赋予太大的权重。

scikit-learn 库实现了另一个转换器,即TfidfTransformer类,它将来自CountVectorizer类的原始词项频率作为输入,并将其转换为 tf-idfs:

>>> 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 稍微偏离了我们之前定义的默认方程式:

tf-idf(t, d) = tf(t, d) × (idf(t, d) + 1)

注意,先前 idf 方程中的“+1”是由于在前一个代码示例中设置了 smooth_idf=True,这对于将所有文档中出现的术语分配为零权重(即 idf(t, d) = 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 解析器模块,该模块在 docs.python.org/3/library/html.parser.html 中有描述。在我们去除了 HTML 标记之后,我们使用稍微复杂一些的正则表达式来查找表情符号,然后将其临时存储为表情符号。接下来,我们通过正则表达式 [\W]+ 去除了文本中的所有非单词字符,并将文本转换为小写字符。

处理单词的大写

在这个分析的上下文中,我们假设一个词的大小写——例如,它是否出现在句子的开头——不包含语义相关的信息。然而,请注意也有例外;例如,我们去除了专有名词的标注。但同样地,在这个分析的上下文中,我们做出的简化假设是字母大小写不包含对情感分析有相关性的信息。

最终,我们将临时存储的表情符号添加到处理后的文档字符串的末尾。此外,我们还为了一致性从表情符号(- 在 😃) 中删除了 鼻子 字符。

正则表达式

尽管正则表达式提供了一种在字符串中搜索字符的高效和方便的方法,但它们也伴随着陡峭的学习曲线。不幸的是,深入讨论正则表达式超出了本书的范围。然而,你可以在 Google 开发者门户上找到一个很好的教程,网址为developers.google.com/edu/python/regular-expressions,或者你可以查看 Python re 模块的官方文档,网址为docs.python.org/3.9/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)。自然语言工具包NLTKwww.nltk.org)为 Python 实现了 Porter 词干算法,我们将在以下代码部分中使用它。要安装 NLTK,你可以简单地执行conda install nltkpip install nltk

NLTK 在线书籍

尽管本章的重点不在 NLTK 上,我强烈建议你访问 NLTK 网站并阅读官方的 NLTK 书籍,该书可以免费获取,网址为www.nltk.org/book/,如果你对 NLP 的高级应用感兴趣的话。

以下代码显示了如何使用 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 词干器因更为激进而出名,这意味着它将产生更短和更晦涩的单词。这些备选的词干算法也可通过 NLTK 包提供(www.nltk.org/api/nltk.stem.html)。

尽管词干化可以创建非真实单词,例如从'thus'变成'thu',如前面的例子所示,一种称为词形还原的技术旨在获取单词的规范(语法正确)形式——所谓的词形。然而,词形还原在计算上更为复杂且昂贵,与词干化相比,在实践中观察到,词干化和词形还原对文本分类的性能影响不大(词归一化对文本分类的影响,由米哈尔·托曼罗曼·泰萨尔卡雷尔·耶日克撰写,InSciT 会议论文集,2006 年,354–358 页)。

在我们跳转到下一节,在那里我们将使用词袋模型训练机器学习模型之前,让我们简要讨论另一个有用的主题,称为停用词去除。停用词简单地指那些在各种文本中极为常见且可能不包含(或只包含很少)有用信息的单词,这些单词用于区分不同类别的文档可能没有(或只有很少)有用信息。停用词的例子包括isandhaslike。如果我们处理原始或标准化的词频而不是 tf-idf 时,去除停用词可能是有用的。

要从电影评论中删除停用词,我们将使用 NLTK 库中提供的 127 个英语停用词集合,可以通过调用nltk.download函数获取:

>>> import nltk
>>> nltk.download('stopwords') 

在下载停用词集合后,我们可以加载并应用英语停用词集合如下:

>>> from nltk.corpus import stopwords
>>> stop = stopwords.words('english')
>>> [w for w in tokenizer_porter('a runner likes'
...  ' running and runs a lot')
...  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)
>>> small_param_grid = [
...     {
...         'vect__ngram_range': [(1, 1)],
...         'vect__stop_words': [None],
...         'vect__tokenizer': [tokenizer, tokenizer_porter],
...         'clf__penalty': ['l2'],
...         'clf__C': [1.0, 10.0]
...     },
...     {
...         'vect__ngram_range': [(1, 1)],
...         'vect__stop_words': [stop, None],
...         'vect__tokenizer': [tokenizer],
...         'vect__use_idf':[False],
...         'vect__norm':[None],
...         'clf__penalty': ['l2'],
...         'clf__C': [1.0, 10.0]
...     },
... ]
>>> lr_tfidf = Pipeline([
...     ('vect', tfidf),
...     ('clf', LogisticRegression(solver='liblinear'))
... ])
>>> gs_lr_tfidf = GridSearchCV(lr_tfidf, small_param_grid,
...                            scoring='accuracy', cv=5,
...                            verbose=2, n_jobs=1)
>>> gs_lr_tfidf.fit(X_train, y_train) 

请注意,对于逻辑回归分类器,我们使用 LIBLINEAR 求解器,因为它在相对较大的数据集上可能比默认选择('lbfgs')表现更好。

通过 n_jobs 参数进行多进程处理

请注意,我们强烈建议将n_jobs=-1(而不是n_jobs=1,如前面的代码示例中所示)设置为利用计算机上所有可用的核心并加快网格搜索速度。然而,一些 Windows 用户报告了在带有n_jobs=-1设置时运行先前代码时与在 Windows 上的多进程处理中tokenizertokenizer_porter函数序列化相关的问题。另一个解决方法是用[str.split]替换这两个函数[tokenizer, tokenizer_porter]。但是,请注意,简单的str.split替换不支持词干处理。

当我们使用上述代码初始化GridSearchCV对象及其参数网格时,由于特征向量数量以及庞大的词汇量,我们限制了参数组合的数量,这使得网格搜索在计算上相当昂贵。使用标准台式计算机,我们的网格搜索可能需要 5-10 分钟才能完成。

在前一个代码示例中,我们用TfidfVectorizer替换了上一小节中的CountVectorizerTfidfTransformerTfidfVectorizer结合了CountVectorizerTfidfTransformer的功能。我们的param_grid包含两个参数字典。在第一个字典中,我们使用了TfidfVectorizer的默认设置(use_idf=Truesmooth_idf=Truenorm='l2')来计算 tf-idf;在第二个字典中,我们将这些参数设置为use_idf=Falsesmooth_idf=Falsenorm=None,以便基于原始词频训练模型。此外,对于逻辑回归分类器本身,我们通过惩罚参数进行了 L2 正则化训练模型,并通过定义逆正则化参数C的值范围比较不同的正则化强度。作为可选练习,您也可以通过将'clf__penalty': ['l2']更改为'clf__penalty': ['l2', 'l1'],将 L1 正则化添加到参数网格中。

网格搜索完成后,我们可以打印出最佳参数集:

>>> print(f'Best parameter set: {gs_lr_tfidf.best_params_}')
Best parameter set: {'clf__C': 10.0, 'clf__penalty': 'l2', 'vect__ngram_range': (1, 1), 'vect__stop_words': None, 'vect__tokenizer': <function tokenizer at 0x169932dc0>} 

正如您在前面的输出中看到的,我们使用了常规的tokenizer而没有使用 Porter 词干处理,也没有使用停用词库,而是将 tf-idf 与使用 L2 正则化和正则化强度为10.0的逻辑回归分类器组合起来获得了最佳的网格搜索结果。

使用这个网格搜索中的最佳模型,让我们打印在训练数据集上的平均 5 折交叉验证准确率分数以及在测试数据集上的分类准确率:

>>> print(f'CV Accuracy: {gs_lr_tfidf.best_score_:.3f}')
CV Accuracy: 0.897
>>> clf = gs_lr_tfidf.best_estimator_
>>> print(f'Test Accuracy: {clf.score(X_test, y_test):.3f}')
Test Accuracy: 0.899 

结果显示,我们的机器学习模型能够以 90%的准确率预测电影评论是正面还是负面。

朴素贝叶斯分类器

在文本分类中仍然非常流行的分类器是朴素贝叶斯分类器,它在电子邮件垃圾邮件过滤的应用中广受欢迎。朴素贝叶斯分类器易于实现、计算效率高,并且在相对较小的数据集上表现特别好,与其他算法相比。虽然我们在本书中没有讨论朴素贝叶斯分类器,但感兴趣的读者可以在 arXiv 找到一篇关于朴素贝叶斯文本分类的文章(Naive Bayes and Text Classification I – Introduction and Theory by S. Raschka, Computing Research Repository (CoRR), abs/1410.5329, 2014, arxiv.org/pdf/1410.5329v3.pdf)。本文提到的不同版本朴素贝叶斯分类器在 scikit-learn 中有实现。你可以在这里找到一个概述页面,其中包含到相应代码类的链接:scikit-learn.org/stable/modules/naive_bayes.html

处理更大的数据——在线算法和离线学习

如果你在前一节中执行了代码示例,可能会注意到在进行网格搜索时,为这个 5 万电影评论数据集构建特征向量可能会非常昂贵。在许多现实应用中,与超过计算机内存的更大数据集一起工作并不罕见。

由于并非每个人都能使用超级计算机设施,我们现在将应用一种称为离线学习的技术,它允许我们通过在数据集的较小批次上逐步拟合分类器来处理这样大的数据集。

使用循环神经网络进行文本分类

第十五章使用循环神经网络建模序列数据,我们将重新访问这个数据集,并训练一个基于深度学习的分类器(循环神经网络),以对 IMDb 电影评论数据集中的评论进行分类。这个基于神经网络的分类器遵循相同的离线原则,使用随机梯度下降优化算法,但不需要构建词袋模型。

回顾 第二章训练简单的机器学习算法进行分类,介绍了随机梯度下降的概念;它是一种优化算法,通过逐个示例更新模型的权重。在本节中,我们将利用 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 函数使用哈希技巧(有关 MurmurHash 的更多信息,请参阅 en.wikipedia.org/wiki/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(f'Accuracy: {clf.score(X_test, y_test):.3f}')
Accuracy: 0.868 

NoneType 错误

请注意,如果遇到 NoneType 错误,可能是因为执行了两次 X_test, y_test = get_minibatch(...) 代码。通过前面的循环,我们有 45 次迭代,每次获取 1,000 份文档。因此,还剩下确切的 5,000 份文档用于测试,我们通过以下方式分配:

>>> X_test, y_test = get_minibatch(doc_stream, size=5000) 

如果我们执行此代码两次,则生成器中将没有足够的文档,X_test 返回 None。因此,如果遇到 NoneType 错误,则必须重新开始前面的 stream_docs(...) 代码。

正如您所看到的,模型的准确率约为 87%,略低于我们在前一节使用网格搜索进行超参数调整时达到的准确率。然而,离线学习非常节省内存,完成时间不到一分钟。

最后,我们可以使用最后的 5,000 份文档来更新我们的模型:

>>> clf = clf.partial_fit(X_test, y_test) 

word2vec 模型

一个现代化的替代词袋模型的选择是 word2vec,这是谷歌于 2013 年发布的一种算法(在向量空间中高效估计单词表示,由T. MikolovK. ChenG. CorradoJ. Dean撰写,arxiv.org/abs/1301.3781)。

word2vec 算法是基于神经网络的无监督学习算法,旨在自动学习单词之间的关系。word2vec 背后的想法是将意思相似的单词放入相似的集群中,并通过巧妙的向量间距,模型可以使用简单的向量数学重现某些单词,例如king – man + woman = queen

可以在code.google.com/p/word2vec/找到具有相关论文和替代实现的原始 C 实现。

使用潜在狄利克雷分配进行主题建模

主题建模描述了为未标记的文本文档分配主题的广泛任务。例如,在大量报纸文章的文本语料库中,典型的应用是对文档进行分类。在主题建模的应用中,我们的目标是为这些文章分配类别标签,例如体育、财经、世界新闻、政治和地方新闻。因此,在我们讨论的机器学习的广泛类别的背景下(第一章使计算机能够从数据中学习),我们可以将主题建模视为一项聚类任务,无监督学习的一个子类别。

在本节中,我们将讨论一种称为潜在狄利克雷分配LDA)的流行主题建模技术。然而,请注意,虽然潜在狄利克雷分配经常缩写为 LDA,但不要与线性判别分析混淆,后者是一种监督的降维技术,介绍在第五章通过降维压缩数据中。

使用 LDA 分解文本文档

由于 LDA 背后的数学内容相当复杂,并且需要贝叶斯推断的知识,我们将从实践者的角度来讨论这个话题,并用通俗的术语解释 LDA。然而,有兴趣的读者可以在以下研究论文中进一步阅读关于 LDA 的信息:潜在狄利克雷分配,由David M. BleiAndrew Y. NgMichael I. Jordan撰写,机器学习研究杂志第 3 卷,页码:993-1022,2003 年 1 月,www.jmlr.org/papers/volume3/blei03a/blei03a.pdf

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')
>>> # the following is necessary on some computers:
>>> df = df.rename(columns={"0": "review", "1": "sentiment"}) 

接下来,我们将使用已经熟悉的CountVectorizer创建词袋矩阵作为 LDA 的输入。

为了方便起见,我们将使用 scikit-learn 的内置英语停用词库,通过stop_words='english'

>>> 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),以排除在文档中出现太频繁的单词。移除频繁出现的单词的背后理由是这些单词可能是所有文档中常见的单词,因此不太可能与给定文档的特定主题类别相关联。此外,我们将要考虑的单词数量限制为最常出现的 5,000 个单词(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'类似于在线或小批量学习,在第二章,“用于分类的简单机器学习算法的训练”以及本章前面我们讨论过)。

期望最大化

scikit-learn 库对 LDA 的实现使用期望最大化EM)算法来迭代更新其参数估计。我们在本章中没有讨论 EM 算法,但如果你想了解更多,请查看维基百科上关于期望最大化算法的优秀概述(en.wikipedia.org/wiki/Expectation–maximization_algorithm)以及 Colorado Reed 的教程 潜在狄利克雷分配:迈向更深层理解 的详细教程,该教程可以在 obphio.us/pdfs/lda_tutorial.pdf 免费获取。

在拟合 LDA 后,我们现在可以访问 lda 实例的 components_ 属性,该属性存储了一个矩阵,按增加顺序包含了 10 个主题的单词重要性(此处为 5000):

>>> lda.components_.shape
(10, 5000) 

要分析结果,让我们打印出每个 10 个主题中最重要的五个单词。请注意,单词重要性值是按增加顺序排名的。因此,要打印出前五个单词,我们需要将主题数组按相反顺序排序:

>>> n_top_words = 5
>>> feature_names = count.get_feature_names_out()
>>> for topic_idx, topic in enumerate(lda.components_):
...     print(f'Topic {(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. 动作电影

要确认基于评论的分类是否合理,请绘制恐怖电影类别(恐怖电影属于索引位置 5 的第 6 类别)中的三部电影:

>>> horror = X_topics[:, 5].argsort()[::-1]
>>> for iter_idx, movie_idx in enumerate(horror[:3]):
...     print(f'\nHorror movie #{(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 个字符。尽管我们不知道它们确切属于哪部电影,但听起来像是恐怖电影的评论(但是,有人可能会认为 恐怖电影 #2 也可以很好地适应主题类别 1:通常的糟糕电影)。

摘要

在本章中,你学习了如何使用机器学习算法根据其极性对文本文档进行分类,这是自然语言处理领域情感分析的基本任务。你不仅学会了如何使用词袋模型将文档编码为特征向量,还学会了如何使用 tf-idf 权重术语频率。

处理文本数据可能会因为创建的大型特征向量而在计算上非常昂贵;在最后一节中,我们讨论了如何利用离线或增量学习来训练机器学习算法,而无需将整个数据集加载到计算机内存中。

最后,你被介绍了使用 LDA 进行主题建模的概念,以无监督方式将电影评论分类到不同的类别中。

到目前为止,在本书中,我们已经涵盖了许多机器学习概念、最佳实践以及用于分类的监督模型。在下一章中,我们将研究另一类监督学习的子类别,回归分析,它让我们能够预测连续尺度上的结果变量,与我们目前所使用的分类模型的分类类别标签形成对比。

加入我们书籍的 Discord 空间

加入书籍的 Discord 工作空间,与作者进行每月的问我任何事会话:

packt.link/MLwPyTorch

第九章:使用回归分析预测连续目标变量

在前几章中,您学到了关于监督学习背后的主要概念,并训练了许多不同的模型来执行分类任务,以预测组成员或分类变量。在本章中,我们将深入探讨另一类监督学习:回归分析

回归模型用于预测连续尺度上的目标变量,这使它们在解决科学中的许多问题时非常有吸引力。它们在工业中也有应用,例如理解变量之间的关系、评估趋势或进行预测。一个例子是预测公司未来几个月的销售额。

在本章中,我们将讨论回归模型的主要概念,并涵盖以下主题:

  • 探索和可视化数据集

  • 查看实现线性回归模型的不同方法

  • 训练对异常值鲁棒的回归模型

  • 评估回归模型并诊断常见问题

  • 将回归模型拟合到非线性数据

引入线性回归

线性回归的目标是建立一个或多个特征与连续目标变量之间的关系模型。与分类不同—监督学习的另一子类—回归分析旨在预测连续尺度上的输出,而不是分类类别标签。

在接下来的小节中,您将了解到最基本类型的线性回归,简单线性回归,并理解如何将其与更一般的多变量情况(具有多个特征的线性回归)联系起来。

简单线性回归

简单(单变量)线性回归的目标是建立单一特征(解释变量, x)和连续数值目标(响应变量, y)之间的关系模型。具有一个解释变量的线性模型方程定义如下:

在这里,参数(偏置单元)b表示y轴截距,w[1]是解释变量的权重系数。我们的目标是学习线性方程的权重,以描述解释变量和目标变量之间的关系,然后用于预测不属于训练数据集的新解释变量的响应。

基于我们之前定义的线性方程,线性回归可以理解为找到穿过训练示例的最佳拟合直线,如图 9.1所示:

图表描述自动生成

图 9.1:一个简单的单特征线性回归示例

这条最佳拟合线也称为回归线,从回归线到训练样本的垂直线称为偏移量残差—这是我们预测的误差。

多元线性回归

前一节介绍了简单线性回归,这是线性回归的一种特殊情况,仅涉及一个解释变量。当然,我们也可以推广线性回归模型以涵盖多个解释变量;这个过程称为多元线性回归

图 9.2展示了具有两个特征的多元线性回归模型的二维拟合超平面的样子:

图 9.2:一个两特征线性回归模型

如你所见,通过三维散点图中的多元线性回归超平面的可视化已经很难解释。由于我们无法在散点图中良好地可视化具有两个以上特征的数据集(适用于多个特征的多元线性回归模型),本章的示例和可视化主要集中在单变量情况下的简单线性回归。然而,简单和多元线性回归基于相同的概念和评估技术;我们将在本章讨论的代码实现也适用于两种类型的回归模型。

探索艾姆斯房屋数据集

在我们实施第一个线性回归模型之前,我们将讨论一个新数据集,即艾姆斯房屋数据集,其中包含了 2006 年到 2010 年间爱荷华州艾姆斯市的个别住宅物业信息。该数据集由迪恩·迪科克于 2011 年收集,更多信息可通过以下链接获得:

对于每个新数据集,通过简单的可视化来探索数据总是有帮助的,这样我们可以更好地了解我们正在处理的内容,这也是我们将在以下子节中进行的操作。

将艾姆斯房屋数据集加载到 DataFrame 中

在这一部分,我们将使用 pandas 的read_csv函数加载艾姆斯房屋数据集,这是一种快速且多功能的工具,推荐用于处理存储在纯文本格式中的表格数据。

艾姆斯房屋数据集包含 2,930 个示例和 80 个特征。为简单起见,我们将只使用特征的子集,如下列表所示。但是,如果你感兴趣,可以查看本节开头提供的完整数据集描述链接,并鼓励在阅读本章后探索该数据集中的其他变量。

我们将要处理的特征,包括目标变量,如下所示:

  • Overall Qual: 房屋整体材料和装饰的评分,范围从 1(非常差)到 10(优秀)

  • Overall Cond: 房屋整体条件的评分,范围从 1(非常差)到 10(优秀)

  • Gr Liv Area: 地面以上的居住面积,以平方英尺为单位

  • Central Air: 中央空调(N=否,Y=是)

  • Total Bsmt SF: 地下室总面积,以平方英尺为单位

  • SalePrice: 销售价格(美元)

在本章的其余部分,我们将把销售价格 (SalePrice) 视为我们的目标变量 —— 我们希望使用五个或更多的解释变量来预测的变量。在进一步探索这个数据集之前,让我们将其加载到一个 pandas DataFrame 中:

import pandas as pd
columns = ['Overall Qual', 'Overall Cond', 'Gr Liv Area',
           'Central Air', 'Total Bsmt SF', 'SalePrice']
df = pd.read_csv('http://jse.amstat.org/v19n3/decock/AmesHousing.txt', 
                 sep='\t',
                 usecols=columns)
df.head() 

为了确认数据集已成功加载,我们可以显示数据集的前五行,如 Figure 9.3 所示:

Table  Description automatically generated

Figure 9.3: 房屋数据集的前五行

加载数据集后,让我们还检查一下 DataFrame 的维度,以确保其包含预期数量的行:

>>> df.shape
(2930, 6) 

正如我们所看到的,DataFrame 包含了预期的 2,930 行。

我们还需要注意 'Central Air' 变量,它被编码为 string 类型,正如我们在 Figure 9.3 中看到的。正如我们在 Chapter 4 中学到的,在转换 DataFrame 列时,我们可以使用 .map 方法。以下代码将字符串 'Y' 转换为整数 1,字符串 'N' 转换为整数 0:

>>> df['Central Air'] = df['Central Air'].map({'N': 0, 'Y': 1}) 

最后,让我们检查数据框中是否有任何缺失值的列:

>>> df.isnull().sum()
Overall Qual     0
Overall Cond     0
Total Bsmt SF    1
Central Air      0
Gr Liv Area      0
SalePrice        0
dtype: int64 

正如我们所看到的,Total Bsmt SF 特征变量包含一个缺失值。由于我们有一个相对较大的数据集,处理这个缺失的特征值的最简单方法是从数据集中删除相应的示例(有关替代方法,请参见 Chapter 4):

>>> df = df.dropna(axis=0)
>>> df.isnull().sum()
Overall Qual     0
Overall Cond     0
Total Bsmt SF    0
Central Air      0
Gr Liv Area      0
SalePrice        0
dtype: int64 

可视化数据集的重要特征

探索性数据分析 (EDA) 是在训练机器学习模型之前的一个重要且推荐的第一步。在本节的其余部分,我们将使用一些简单但有用的技术来自可视化 EDA 工具箱,这些技术有助于我们在视觉上检测异常值的存在、数据的分布以及特征之间的关系。

首先,我们将创建一个 散点图矩阵,它允许我们在一个地方可视化数据集中不同特征之间的两两相关性。为了绘制散点图矩阵,我们将使用 mlxtend 库中的 scatterplotmatrix 函数(rasbt.github.io/mlxtend/),这是一个包含各种方便函数的 Python 库,用于机器学习和数据科学应用。

你可以通过 conda install mlxtendpip install mlxtend 安装 mlxtend 包。本章中,我们使用的是 mlxtend 版本 0.19.0。

安装完成后,您可以导入包并按如下方式创建散点图矩阵:

>>> import matplotlib.pyplot as plt
>>> from mlxtend.plotting import scatterplotmatrix
>>> scatterplotmatrix(df.values, figsize=(12, 10), 
...                   names=df.columns, alpha=0.5)
>>> plt.tight_layout()
plt.show() 

正如您在图 9.4中所看到的,散点图矩阵为我们提供了数据关系的有用图形总结:

一个包含图形用户界面描述的图片

图 9.4:我们数据的散点图矩阵

使用这个散点图矩阵,我们现在可以快速查看数据的分布情况及其是否包含异常值。例如,我们可以看到(底部行的第五列)地面以上生活区的大小(Gr Liv Area)与销售价格(SalePrice)之间存在某种线性关系。

此外,在直方图中(散点图矩阵的右下子图),我们可以看到SalePrice变量似乎受到几个异常值的影响。

线性回归的正态性假设

请注意,与普遍观念相反,训练线性回归模型并不要求解释变量或目标变量服从正态分布。正态性假设仅适用于某些超出本书范围的统计和假设检验(有关更多信息,请参阅道格拉斯 C. 蒙哥马利伊丽莎白 A. 佩克G. 杰弗里·文宁《线性回归分析导论》Wiley,2012 年,第 318-319 页)。

查看相关矩阵以探索关系

在前一节中,我们通过直方图和散点图的形式可视化了艾姆斯房屋数据集变量的数据分布情况。接下来,我们将创建一个相关矩阵,以量化和总结变量之间的线性关系。相关矩阵与我们在第五章 通过主成分分析进行无监督降维中讨论的协方差矩阵密切相关。我们可以将相关矩阵解释为从标准化特征计算的协方差矩阵的重新缩放版本。实际上,相关矩阵与从标准化特征计算的协方差矩阵相同。

相关矩阵是一个方阵,包含皮尔逊积矩相关系数(通常缩写为皮尔逊 r),用于衡量特征对之间的线性依赖关系。相关系数的取值范围是–1 到 1。如果r = 1,则两个特征具有完全正相关性;如果r = 0,则没有相关性;如果r = –1,则具有完全负相关性。如前所述,皮尔逊相关系数可以简单地计算为两个特征xy的协方差(分子)除以它们标准差的乘积(分母):

我们的数据的散点图矩阵

在这里, 表示相应特征的均值, 是特征 xy 之间的协方差, 是特征的标准差。

标准化特征的协方差与相关性

我们可以证明一对标准化特征之间的协方差实际上等于它们的线性相关系数。为了展示这一点,让我们首先对特征 xy 进行标准化,得到它们的 z 分数,分别记为 x’y’

请记住,我们计算两个特征之间(总体)协方差的方法如下:

由于标准化将特征变量居中于零均值,我们现在可以计算缩放特征之间的协方差如下:

通过代入法,我们得到以下结果:

最后,我们可以简化这个方程如下:

在以下代码示例中,我们将使用 NumPy 的 corrcoef 函数来计算我们先前在散点图矩阵中可视化的五个特征列的相关系数,并使用 mlxtend 的 heatmap 函数将相关矩阵数组绘制为热图:

>>> import numpy as np
>>> from mlxtend.plotting import heatmap
>>> cm = np.corrcoef(df.values.T)
>>> hm = heatmap(cm, row_names=df.columns, column_names=df.columns)
>>> plt.tight_layout()
>>> plt.show() 

正如在 图 9.5 中所示,相关矩阵为我们提供了另一个有用的摘要图形,可以帮助我们根据它们各自的线性相关性选择特征:

自动生成的图形用户界面应用程序说明

图 9.5:所选变量的相关矩阵

要拟合线性回归模型,我们对那些与目标变量 SalePrice 具有高相关性的特征感兴趣。从前面的相关矩阵可以看出,SalePriceGr Liv Area 变量(0.71)显示出最大的相关性,这似乎是引入简单线性回归模型概念的一个不错的选择。

实现普通最小二乘线性回归模型

在本章开头,我们提到线性回归可以理解为通过我们的训练数据示例获取最佳拟合直线。然而,我们既未定义“最佳拟合”的术语,也未讨论拟合这种模型的不同技术。在接下来的小节中,我们将使用 普通最小二乘法(OLS)方法(有时也称为 线性最小二乘法)来估计最小化与训练示例的平方垂直距离(残差或误差)的线性回归线的参数。

使用梯度下降解决回归参数的回归

考虑我们在 第二章 中对 自适应线性神经元 (Adaline) 的实现,用于分类的简单机器学习算法训练。您会记得,这种人工神经元使用线性激活函数。此外,我们定义了一个损失函数 L(w),通过优化算法(如 梯度下降 (GD) 和 随机梯度下降 (SGD))最小化该函数以学习权重。

在 Adaline 中,这个损失函数是 均方误差 (MSE),与我们用于 OLS 的损失函数相同:

在这里, 是预测值 (注意,术语 仅用于方便推导 GD 的更新规则)。本质上,OLS 回归可以理解为 Adaline 没有阈值函数,因此我们获得连续的目标值而不是类标签 01。为了演示这一点,让我们从 第二章 中取出 Adaline 的 GD 实现,并去掉阈值函数来实现我们的第一个线性回归模型:

class LinearRegressionGD:
    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):
        rgen = np.random.RandomState(self.random_state)
        self.w_ = rgen.normal(loc=0.0, scale=0.01, size=X.shape[1])
        self.b_ = np.array([0.])
        self.losses_ = []
        for i in range(self.n_iter):
            output = self.net_input(X)
            errors = (y - output)
            self.w_ += self.eta * 2.0 * X.T.dot(errors) / X.shape[0]
            self.b_ += self.eta * 2.0 * errors.mean()
            loss = (errors**2).mean()
            self.losses_.append(loss)
        return self
    def net_input(self, X):
        return np.dot(X, self.w_) + self.b_
    def predict(self, X):
        return self.net_input(X) 

使用梯度下降更新权重

如果您需要关于权重如何更新的复习 —— 即沿梯度相反方向迈出一步,请参阅 第二章 中的 自适应线性神经元和学习的收敛 部分。

要看看我们的 LinearRegressionGD 回归器如何运行,让我们使用 Ames 房屋数据集中的 Gr Living Area(地面以上的居住面积,以平方英尺为单位)特征作为解释变量,并训练一个能够预测 SalePrice 的模型。此外,我们将标准化变量以获得更好的 GD 算法收敛性。代码如下:

>>> X = df[['Gr Liv Area']].values
>>> y = df['SalePrice'].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(eta=0.1)
>>> lr.fit(X_std, y_std) 

注意关于 y_std 的解决方法,使用 np.newaxisflatten。scikit-learn 中大多数数据预处理类都希望数据存储在二维数组中。在前面的代码示例中,y[:, np.newaxis] 中使用 np.newaxis 添加了一个新的数组维度。然后,在 StandardScaler 返回缩放后的变量后,我们使用 flatten() 方法将其转换回原始的一维数组表示,以便我们使用时更方便。

我们在 第二章 中讨论过,当我们使用优化算法(如 GD)时,绘制损失作为训练数据集上的 epoch 数(完整迭代次数)函数,以检查算法是否收敛到损失最小值(这里是 全局 损失最小值)是一个很好的做法:

>>> plt.plot(range(1, lr.n_iter+1), lr.losses_)
>>> plt.ylabel('MSE')
>>> plt.xlabel('Epoch')
>>> plt.show() 

正如您在 图 9.6 中所见,GD 算法大约在第十个 epoch 后收敛:

形状描述自动生成

图 9.6:损失函数与 epoch 数的关系

接下来,让我们可视化线性回归线对训练数据的拟合程度。为此,我们将定义一个简单的辅助函数,用于绘制训练样本的散点图并添加回归线:

>>> 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) 

现在,我们将使用 lin_regplot 函数绘制居住面积与销售价格:

>>> lin_regplot(X_std, y_std, lr)
>>> plt.xlabel(' Living area above ground (standardized)')
>>> plt.ylabel('Sale price (standardized)')
>>> plt.show() 

正如您在图 9.7中所看到的,线性回归线反映了房屋价格倾向于随着居住面积的增加而增加的一般趋势:

图表,散点图  自动生成描述

图 9.7:销售价格与居住面积大小的线性回归图

尽管这种观察是有道理的,但数据还告诉我们,在许多情况下,居住面积大小并不能很好地解释房价。本章后面我们将讨论如何量化回归模型的性能。有趣的是,我们还可以观察到几个异常值,例如,对应于标准化后大于 6 的生活区的三个数据点。我们将在本章后面讨论如何处理异常值。

在某些应用中,报告预测结果变量在其原始比例上也可能很重要。要将预测的价格重新缩放到原始的美元价格尺度上,我们可以简单地应用 StandardScalerinverse_transform 方法:

>>> feature_std = sc_x.transform(np.array([[2500]]))
>>> target_std = lr.predict(feature_std)
>>> target_reverted = sc_y.inverse_transform(target_std.reshape(-1, 1))
>>> print(f'Sales price: ${target_reverted.flatten()[0]:.2f}')
Sales price: $292507.07 

在这个代码示例中,我们使用之前训练过的线性回归模型预测了一个地面以上居住面积为 2,500 平方英尺的房屋的价格。根据我们的模型,这样一栋房子价值 $292,507.07。

顺便提一句,值得一提的是,如果我们使用标准化的变量,我们在技术上不必更新截距参数(例如,偏置单元,b),因为在这些情况下 y 轴截距始终为 0。我们可以通过打印模型参数来快速确认这一点:

>>> print(f'Slope: {lr.w_[0]:.3f}')
Slope: 0.707
>>> print(f'Intercept: {lr.b_[0]:.3f}')
Intercept: -0.000 

通过 scikit-learn 估计回归模型的系数

在前一节中,我们实现了一个用于回归分析的工作模型;然而,在实际应用中,我们可能对更高效的实现感兴趣。例如,许多 scikit-learn 中用于回归的估计器使用了 SciPy 中的最小二乘实现 (scipy.linalg.lstsq),而 SciPy 又使用了基于线性代数包LAPACK)的高度优化代码。scikit-learn 中的线性回归实现也可以(更好地)处理非标准化的变量,因为它不使用(S)GD-based 优化,所以我们可以跳过标准化步骤:

>>> from sklearn.linear_model import LinearRegression
>>> slr = LinearRegression()
>>> slr.fit(X, y)
>>> y_pred = slr.predict(X)
>>> print(f'Slope: {slr.coef_[0]:.3f}')
Slope: 111.666
>>> print(f'Intercept: {slr.intercept_:.3f}')
Intercept: 13342.979 

正如您通过执行此代码所看到的,用未标准化的 Gr Liv AreaSalePrice 变量拟合的 scikit-learn 的 LinearRegression 模型产生了不同的模型系数,因为这些特征没有被标准化。然而,当我们将其与通过绘制 SalePriceGr Liv Area 进行的 GD 实现进行比较时,我们可以从质量上看到它与数据的拟合程度相似:

>>> lin_regplot(X, y, slr)
>>> plt.xlabel('Living area above ground in square feet')
>>> plt.ylabel('Sale price in U.S. dollars')
>>> plt.tight_layout()
>>> plt.show() 

例如,我们可以看到整体结果与我们的 GD 实现看起来是相同的:

图表,散点图  自动生成描述

图 9.8:使用 scikit-learn 的线性回归绘制的线性回归图

线性回归的解析解

与使用机器学习库不同的替代方法是,还存在一种用于解决 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(f'Slope: {w[1]:.3f}')
Slope: 111.666
>>> print(f'Intercept: {w[0]:.3f}')
Intercept: 13342.979 

这种方法的优点在于保证以解析方式找到最优解。然而,如果我们处理的是非常大的数据集,通过在这个公式中求逆矩阵可能会导致计算量过大(有时也称为正规方程),或者包含训练示例的矩阵可能是奇异的(不可逆的),因此在某些情况下我们可能更喜欢使用迭代方法。

如果您对如何获取正规方程更多信息感兴趣,请查看斯蒂芬·波洛克博士在莱斯特大学的讲座《经典线性回归模型》中的章节,该讲座可以免费获取,网址为www.le.ac.uk/users/dsgp1/COURSES/MESOMET/ECMETXT/06mesmet.pdf

此外,如果您想要比较通过 GD、SGD、闭式解、QR 分解和奇异值分解获得的线性回归解决方案,可以使用 mlxtend 中实现的LinearRegression类(rasbt.github.io/mlxtend/user_guide/regressor/LinearRegression/),该类允许用户在这些选项之间切换。另一个在 Python 中推荐的用于回归建模的优秀库是 statsmodels,它实现了更高级的线性回归模型,如在www.statsmodels.org/stable/examples/index.html#regression中展示的。

使用 RANSAC 拟合鲁棒回归模型

线性回归模型可能会受到异常值的严重影响。在某些情况下,我们数据的一个非常小的子集可能会对估计的模型系数产生重大影响。许多统计检验可以用来检测异常值,但这些超出了本书的范围。然而,去除异常值始终需要我们作为数据科学家自己的判断以及我们的领域知识。

作为抛弃异常值的一种替代方案,我们将介绍一种使用随机采样一致性RANdom SAmple ConsensusRANSAC)算法进行鲁棒回归的方法,该算法将回归模型拟合到数据的一个子集,即所谓的内点

我们可以总结迭代的 RANSAC 算法如下:

  1. 选择一定数量的例子作为内点并拟合模型。

  2. 将所有其他数据点与拟合模型进行测试,并将那些落在用户给定容差范围内的点添加到内点。

  3. 使用所有内点重新拟合模型。

  4. 估算与内点拟合模型之间的误差。

  5. 如果性能达到某个用户定义的阈值或达到固定迭代次数,则终止算法;否则返回到 步骤 1

现在让我们使用 scikit-learn 的 RANSACRegressor 类中实现的线性模型与 RANSAC 算法结合使用:

>>> from sklearn.linear_model import RANSACRegressor
>>> ransac = RANSACRegressor(
...     LinearRegression(), 
...     max_trials=100, # default value
...     min_samples=0.95, 
...     residual_threshold=None, # default value 
...     random_state=123)
>>> ransac.fit(X, y) 

我们将 RANSACRegressor 的最大迭代次数设置为 100,并且使用 min_samples=0.95,将随机选择的训练样本的最小数量设置为数据集的至少 95%。

默认情况下(通过 residual_threshold=None),scikit-learn 使用 MAD 估计来选择内点阈值,其中 MAD 表示目标值 y中位数绝对偏差。然而,选择适当的内点阈值的适用性问题特定,这是 RANSAC 的一个缺点。

近年来已开发了许多不同的方法来自动选择良好的内点阈值。您可以在 R. ToldoA. FusielloSpringer, 2009 年的 Image Analysis and Processing–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('Living area above ground in square feet')
>>> plt.ylabel('Sale price in U.S. dollars')
>>> plt.legend(loc='upper left')
>>> plt.tight_layout()
>>> plt.show() 

正如您在 图 9.9 中所看到的那样,线性回归模型是在检测到的内点集上拟合的,这些内点显示为圆圈:

图表,散点图  自动生成描述

图 9.9:通过 RANSAC 线性回归模型识别的内点和外点

当我们执行以下代码打印模型的斜率和截距时,线性回归线将与我们在之前未使用 RANSAC 时得到的拟合略有不同:

>>> print(f'Slope: {ransac.estimator_.coef_[0]:.3f}')
Slope: 106.348
>>> print(f'Intercept: {ransac.estimator_.intercept_:.3f}')
Intercept: 20190.093 

请记住,我们将 residual_threshold 参数设置为 None,因此 RANSAC 使用 MAD 来计算标记内点和外点的阈值。对于此数据集,MAD 可以计算如下:

>>> def mean_absolute_deviation(data):
...     return np.mean(np.abs(data - np.mean(data)))
>>> mean_absolute_deviation(y)
58269.561754979375 

因此,如果我们希望将较少的数据点识别为离群值,我们可以选择一个比前面的 MAD 更大的 residual_threshold 值。例如,图 9.10 展示了具有 65,000 的残差阈值的 RANSAC 线性回归模型的内点和外点:

图表,散点图  自动生成描述

图 9.10:由具有较大残差阈值的 RANSAC 线性回归模型确定的内点和外点

使用 RANSAC,我们减少了数据集中离群值的潜在影响,但我们不知道这种方法是否会对未见数据的预测性能产生积极影响。因此,在接下来的章节中,我们将探讨不同的方法来评估回归模型,这是构建预测建模系统的关键部分。

评估线性回归模型的性能

在前面的章节中,你学会了如何在训练数据上拟合回归模型。然而,你在之前的章节中发现,将模型在训练过程中未见过的数据上进行测试是至关重要的,以获得其泛化性能的更加无偏估计。

正如你可能记得的那样,来自第六章《学习模型评估和超参数调整的最佳实践》的内容,我们希望将数据集分成单独的训练和测试数据集,其中我们将使用前者来拟合模型,并使用后者来评估其在未见数据上的性能,以估计泛化性能。现在,我们不再使用简单的回归模型,而是使用数据集中的所有五个特征并训练多元回归模型:

>>> from sklearn.model_selection import train_test_split
>>> target = 'SalePrice'
>>> features = df.columns[df.columns != target]
>>> X = df[features].values
>>> y = df[target].values
>>> X_train, X_test, y_train, y_test = train_test_split(
...     X, y, test_size=0.3, random_state=123) 
>>> slr = LinearRegression()
>>> slr.fit(X_train, y_train)
>>> y_train_pred = slr.predict(X_train)
>>> y_test_pred = slr.predict(X_test) 

由于我们的模型使用多个解释变量,我们无法在二维图中可视化线性回归线(或者更精确地说是超平面),但我们可以绘制残差(实际值与预测值之间的差异或垂直距离)与预测值的图,来诊断我们的回归模型。残差图是诊断回归模型常用的图形工具,它们有助于检测非线性和异常值,并检查错误是否随机分布。

使用以下代码,我们将绘制一个残差图,其中我们简单地从预测响应中减去真实目标变量:

>>> x_max = np.max(
...     [np.max(y_train_pred), np.max(y_test_pred)])
>>> x_min = np.min(
...     [np.min(y_train_pred), np.min(y_test_pred)])
>>> fig, (ax1, ax2) = plt.subplots(
...     1, 2, figsize=(7, 3), sharey=True)
>>> ax1.scatter(
...     y_test_pred, y_test_pred - y_test,
...     c='limegreen', marker='s',
...     edgecolor='white',
...     label='Test data')
>>> ax2.scatter(
...     y_train_pred, y_train_pred - y_train,
...     c='steelblue', marker='o', edgecolor='white',
...     label='Training data')
>>> ax1.set_ylabel('Residuals')
>>> for ax in (ax1, ax2):
...     ax.set_xlabel('Predicted values')
...     ax.legend(loc='upper left')
...     ax.hlines(y=0, xmin=x_min-100, xmax=x_max+100,\
...         color='black', lw=2)
>>> plt.tight_layout()
>>> plt.show() 

执行代码后,我们应该能看到测试和训练数据集的残差图,其中有一条通过x轴原点的线,如图 9.11所示:

图形用户界面  低置信度自动生成描述

图 9.11:我们数据的残差图

在完美预测的情况下,残差将恰好为零,在现实和实际应用中,我们可能永远不会遇到这种情况。然而,对于一个良好的回归模型,我们期望错误是随机分布的,残差在中心线周围随机分散。如果在残差图中看到模式,这意味着我们的模型无法捕获某些解释信息,这些信息已泄漏到残差中,正如我们之前的残差图中可能看到的那样。此外,我们还可以使用残差图检测异常值,这些异常值由偏离中心线较大的点表示。

另一个衡量模型性能的有用量化指标是我们之前讨论过的均方误差MSE),它是我们用来最小化以拟合线性回归模型的损失函数。以下是不带缩放因子的 MSE 版本,通常用于简化梯度下降中的损失导数:

类似于分类环境中的预测准确性,我们可以使用 MSE 进行交叉验证和模型选择,如第六章讨论的那样。

类似于分类准确率,MSE 也根据样本大小n进行归一化。这使得我们可以跨不同的样本大小进行比较(例如,在学习曲线的背景下)。

现在让我们计算我们的训练和测试预测的 MSE:

>>> from sklearn.metrics import mean_squared_error
>>> mse_train = mean_squared_error(y_train, y_train_pred)
>>> mse_test = mean_squared_error(y_test, y_test_pred)
>>> print(f'MSE train: {mse_train:.2f}')
MSE train: 1497216245.85
>>> print(f'MSE test: {mse_test:.2f}')
MSE test: 1516565821.00 

我们可以看到训练数据集上的 MSE 大于测试集上的 MSE,这表明在这种情况下我们的模型稍微过拟合了训练数据。注意,以原始单位标度(这里是美元而不是美元平方)显示误差可能更直观,因此我们可能选择计算 MSE 的平方根,称为均方根误差,或者平均绝对误差MAE),稍微强调错误预测的重要性较小:

我们可以类似于 MSE 计算 MAE:

>>> from sklearn.metrics import mean_absolute_error
>>> mae_train = mean_absolute_error(y_train, y_train_pred)
>>> mae_test = mean_absolute_error(y_test, y_test_pred)
>>> print(f'MAE train: {mae_train:.2f}')
MAE train: 25983.03
>>> print(f'MAE test: {mae_test:.2f}')
MAE test: 24921.29 

基于测试集的平均绝对误差(MAE),我们可以说该模型的误差大约为$25,000。

当我们使用 MAE 或 MSE 来比较模型时,需要注意它们与例如分类准确率相比没有上限。换句话说,MAE 和 MSE 的解释取决于数据集和特征缩放。例如,如果销售价格以 1000 的倍数(带有 K 后缀)表示,同一个模型将产生比处理未缩放特征的模型更低的 MAE。为了进一步说明这一点,

因此,有时候更有用的是报告决定系数R²),它可以理解为 MSE 的标准化版本,以更好地解释模型的性能。换句话说,R²是模型捕捉的响应方差的比例。R²的值定义为:

这里,SSE 是平方误差的总和,类似于 MSE,但不包括样本大小n的归一化:

而 SST 则是总平方和:

换句话说,SST 只是响应的方差。

现在,让我们简要展示R²实际上只是 MSE 的重新缩放版本:

对于训练数据集,R²被限制在 0 到 1 之间,但对于测试数据集可能为负。负的R²意味着回归模型比表示样本均值的水平线拟合数据更差。(在实践中,这经常发生在极端过拟合的情况下,或者如果我们忘记以与训练集相同的方式缩放测试集。)如果R² = 1,则模型完全拟合数据,相应的MSE = 0。

在训练数据上评估,我们模型的R²为 0.77,这并不理想,但考虑到我们只使用了少量特征,也不算太糟糕。然而,测试数据集上的R²略小,为 0.75,这表明模型只稍微过拟合了:

>>> from sklearn.metrics import r2_score
>>> train_r2 = r2_score(y_train, y_train_pred)>>> test_r2 = r2_score(y_test, y_test_pred)
>>> print(f'R² train: {train_r2:.3f}, {test_r2:.3f}')
R² train: 0.77, test: 0.75 

使用正则化方法进行回归

正如我们在第三章使用 Scikit-Learn 进行机器学习分类器的一次旅行中讨论的那样,正则化是通过添加额外信息来解决过拟合问题的一种方法,从而缩小模型参数值以对复杂性施加惩罚。正则化线性回归的最流行方法是所谓的Ridge 回归最小绝对收缩和选择算子LASSO),以及弹性网络

Ridge 回归是一种 L2 惩罚模型,我们只需将权重的平方和添加到 MSE 损失函数中:

在这里,L2 项被定义如下:

通过增加超参数 的值,我们增加正则化强度,从而缩小模型的权重。请注意,正如第三章中提到的,偏置单元 b 没有经过正则化。

一种可以导致稀疏模型的替代方法是 LASSO。根据正则化强度,某些权重可以变为零,这也使得 LASSO 作为一种监督特征选择技术非常有用:

在这里,LASSO 的 L1 惩罚被定义为模型权重的绝对值的总和,如下所示:

然而,LASSO 的一个限制是,如果 m > n,它最多选择 n 个特征,其中 n 是训练示例的数量。在某些特征选择的应用中,这可能是不希望的。然而,在实践中,LASSO 的这种性质通常是优点,因为它避免了饱和模型。模型的饱和发生在训练示例数等于特征数时,这是一种过度参数化的形式。因此,饱和模型可以完美拟合训练数据,但仅仅是一种插值形式,因此不太可能很好地推广。

Ridge 回归和 LASSO 之间的折衷方案是弹性网络,它具有 L1 惩罚以生成稀疏性,并且具有 L2 惩罚,因此可以用于选择超过 n 个特征,如果 m > 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 回归。有关线性回归不同实现的详细信息,请参阅 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() 
    

在生成的图中,您可以看到多项式拟合比线性拟合更好地捕捉了响应和解释变量之间的关系:

图,线图,散点图  描述自动生成

图 9.12:线性和二次模型的比较

接下来,我们将计算 MSE 和 R² 评估指标:

>>> y_lin_pred = lr.predict(X)
>>> y_quad_pred = pr.predict(X_quad)
>>> mse_lin = mean_squared_error(y, y_lin_pred)
>>> mse_quad = mean_squared_error(y, y_quad_pred)
>>> print(f'Training MSE linear: {mse_lin:.3f}'
          f', quadratic: {mse_quad:.3f}')
Training MSE linear: 569.780, quadratic: 61.330
>>> r2_lin = r2_score(y, y_lin_pred)
>>> r2_quad = r2_score(y, y_quad_pred)
>>> print(f'Training R² linear: {r2_lin:.3f}'
          f', quadratic: {r2_quad:.3f}')
Training R² linear: 0.832, quadratic: 0.982 

在执行代码后,您可以看到 MSE 从 570(线性拟合)降至 61(二次拟合);此外,确定系数反映了二次模型的更紧密拟合(R² = 0.982),相对于特定的玩具问题中的线性拟合(R² = 0.832)。

在 Ames Housing 数据集中建模非线性关系

在前一小节中,您学习了如何构建多项式特征来拟合玩具问题中的非线性关系;现在让我们看一个更具体的例子,并将这些概念应用到 Ames Housing 数据集中的数据上。通过执行以下代码,我们将建模销售价格与地面以上居住面积之间的关系,使用二次(二次)和三次(三次)多项式,并将其与线性拟合进行比较。

我们首先移除三个具有大于 4,000 平方英尺的生活面积的离群值,这些离群值可以在之前的图表中看到,比如 图 9.8,以确保这些离群值不会影响我们的回归拟合:

>>> X = df[['Gr Liv Area']].values
>>> y = df['SalePrice'].values
>>> X = X[(df['Gr Liv Area'] < 4000)]
>>> y = y[(df['Gr Liv Area'] < 4000)] 

接下来,我们拟合回归模型:

>>> regr = LinearRegression()
>>> # create quadratic and cubic features
>>> quadratic = PolynomialFeatures(degree=2)
>>> cubic = PolynomialFeatures(degree=3)
>>> X_quad = quadratic.fit_transform(X)
>>> X_cubic = cubic.fit_transform(X)
>>> # fit to features
>>> X_fit = np.arange(X.min()-1, X.max()+2, 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=f'Linear (d=1), $R²$={linear_r2:.2f}',
...          color='blue', 
...          lw=2, 
...          linestyle=':')
>>> plt.plot(X_fit, y_quad_fit, 
...          label=f'Quadratic (d=2), $R²$={quadratic_r2:.2f}',
...          color='red', 
...          lw=2,
...          linestyle='-')
>>> plt.plot(X_fit, y_cubic_fit, 
...          label=f'Cubic (d=3), $R²$={cubic_r2:.2f}',
...          color='green', 
...          lw=2,
...          linestyle='--')
>>> plt.xlabel('Living area above ground in square feet')
>>> plt.ylabel('Sale price in U.S. dollars')
>>> plt.legend(loc='upper left')
>>> plt.show() 

结果图显示在图 9.13中:

散点图描述

图 9.13:不同曲线拟合与销售价格和生活区域数据比较

正如我们所见,使用二次或三次特征实际上没有影响。这是因为两个变量之间的关系似乎是线性的。因此,让我们看看另一个特征,即Overall QualOverall Qual变量评估房屋材料和装饰的整体质量,评分从 1 到 10,其中 10 为最佳:

>>> X = df[['Overall Qual']].values
>>> y = df['SalePrice'].values 

在指定了Xy变量之后,我们可以重复使用之前的代码,并获得图 9.14中的图表:

线性图描述

图 9.14:销售价格和房屋质量数据的线性、二次和三次拟合比较

如您所见,二次和三次拟合比线性拟合更能捕捉房屋销售价格与整体房屋质量之间的关系。但是,您应该注意,添加越来越多的多项式特征会增加模型的复杂性,从而增加过拟合的风险。因此,在实践中,始终建议在单独的测试数据集上评估模型的性能,以估计泛化性能。

处理非线性关系使用随机森林

在本节中,我们将研究与本章前述回归模型在概念上不同的随机森林回归。随机森林是多个决策树的集成,可以理解为分段线性函数的总和,与我们之前讨论的全局线性和多项式回归模型形成对比。换句话说,通过决策树算法,我们将输入空间分割成更小的区域,使其更易管理。

决策树回归

决策树算法的优点是,它可以处理任意特征,并且不需要对特征进行任何转换,如果我们处理的是非线性数据,因为决策树是一次分析一个特征,而不是考虑加权组合。(同样地,对决策树而言,归一化或标准化特征是不必要的。)如第三章中提到的,使用 Scikit-Learn 进行机器学习分类器的简介,我们通过迭代地分割节点来生长决策树,直到叶子节点纯净或满足停止条件。当我们用决策树进行分类时,我们定义熵作为不纯度的度量,以确定哪个特征分割最大化信息增益IG),对于二元分割,可以定义如下:

这里,x[i]是进行分裂的特征,N[p]是父节点中的训练样本数,I是不纯度函数,D[p]是父节点中训练样本的子集,D[left]和D[right]是分裂后左右子节点中的训练样本子集。记住我们的目标是找到最大化信息增益的特征分裂;换句话说,我们希望找到能够最大程度降低子节点中不纯度的特征分裂。在第三章中,我们讨论了基尼不纯度和熵作为不纯度的度量标准,它们对于分类非常有用。然而,为了将决策树用于回归,我们需要一个适合连续变量的不纯度度量,因此我们将节点t的不纯度度量定义为均方误差(MSE):

这里,N[t]是节点t上的训练样本数量,D[t]是节点t上的训练子集,是真实的目标值,而是预测的目标值(样本均值):

在决策树回归的背景下,均方误差(MSE)通常称为节点内方差,这也是为什么分裂准则更为人熟知为方差减少

要了解决策树回归的拟合线是什么样子,让我们使用 scikit-learn 中实现的DecisionTreeRegressor来建模SalePriceGr Living Area变量之间的关系。请注意,SalePriceGr Living Area不一定代表非线性关系,但这种特征组合仍然很好地展示了回归树的一般特性:

>>> from sklearn.tree import DecisionTreeRegressor
>>> X = df[['Gr Liv Area']].values
>>> y = df['SalePrice'].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('Living area above ground in square feet')
>>> plt.ylabel('Sale price in U.S. dollars')>>> plt.show() 

从生成的图中可以看出,决策树捕捉了数据的一般趋势。我们可以想象,回归树也可以相对较好地捕捉非线性数据的趋势。然而,这种模型的局限性在于它不能捕捉所需预测的连续性和可微性。此外,我们需要注意选择树深度的适当值,以避免数据过拟合或欠拟合;在这里,深度为三似乎是一个不错的选择。

Chart, scatter chart  Description automatically generated

图 9.15:决策树回归图

鼓励你尝试更深的决策树。请注意,Gr Living AreaSalePrice之间的关系相当线性,因此也鼓励你将决策树应用于Overall Qual变量。

在接下来的部分,我们将探讨更稳健的回归树拟合方法:随机森林。

随机森林回归

正如你在 第三章 中学到的,随机森林算法是一种集成技术,结合了多个决策树。随机森林通常比单个决策树具有更好的泛化性能,这归功于随机性,它有助于减少模型的方差。随机森林的其他优点是,在数据集中不太敏感于异常值,并且不需要太多的参数调整。在随机森林中,我们通常需要尝试不同的参数是集成中的树的数量。回归的基本随机森林算法几乎与我们在 第三章 中讨论的分类随机森林算法相同。唯一的区别是我们使用 MSE 准则来生长单个决策树,并且预测的目标变量是跨所有决策树的平均预测。

现在,让我们使用艾姆斯房屋数据集中的所有特征,在 70% 的示例上拟合一个随机森林回归模型,并在剩余的 30% 上评估其性能,就像我们在 评估线性回归模型性能 部分中所做的那样。代码如下:

>>> target = 'SalePrice'
>>> features = df.columns[df.columns != target]
>>> X = df[features].values
>>> y = df[target].values
>>> X_train, X_test, y_train, y_test = train_test_split(
...     X, y, test_size=0.3, random_state=123)
>>> from sklearn.ensemble import RandomForestRegressor
>>> forest = RandomForestRegressor(
...     n_estimators=1000, 
...     criterion='squared_error', 
...     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)
>>> mae_train = mean_absolute_error(y_train, y_train_pred)
>>> mae_test = mean_absolute_error(y_test, y_test_pred)
>>> print(f'MAE train: {mae_train:.2f}')
MAE train: 8305.18
>>> print(f'MAE test: {mae_test:.2f}')
MAE test: 20821.77
>>> r2_train = r2_score(y_train, y_train_pred)
>>> r2_test =r2_score(y_test, y_test_pred)
>>> print(f'R² train: {r2_train:.2f}')
R² train: 0.98
>>> print(f'R² test: {r2_test:.2f}')
R² test: 0.85 

不幸的是,你可以看到随机森林倾向于过度拟合训练数据。然而,它仍能相对良好地解释目标与解释变量之间的关系(在测试数据集上的 )。作为比较,在前一节 评估线性回归模型性能 中拟合到相同数据集的线性模型过度拟合较少,但在测试集上表现较差()。

最后,让我们也来看看预测的残差:

>>> x_max = np.max([np.max(y_train_pred), np.max(y_test_pred)])
>>> x_min = np.min([np.min(y_train_pred), np.min(y_test_pred)])
>>> fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(7, 3), sharey=True)
>>> ax1.scatter(y_test_pred, y_test_pred - y_test,
...             c='limegreen', marker='s', edgecolor='white',
...             label='Test data')
>>> ax2.scatter(y_train_pred, y_train_pred - y_train,
...             c='steelblue', marker='o', edgecolor='white',
...             label='Training data')
>>> ax1.set_ylabel('Residuals')
>>> for ax in (ax1, ax2):
...     ax.set_xlabel('Predicted values')
...     ax.legend(loc='upper left')
...     ax.hlines(y=0, xmin=x_min-100, xmax=x_max+100,
...               color='black', lw=2)
>>> plt.tight_layout()
>>> plt.show() 

正如 R² 系数已经总结的那样,你可以看到模型对训练数据的拟合比测试数据更好,这由 y 轴方向的异常值表示。此外,残差的分布似乎并不完全围绕零中心点随机,这表明模型无法捕捉所有的探索信息。然而,残差图表明相对于本章早期绘制的线性模型的残差图有了很大的改进。

散点图 描述自动生成

图 9.16:随机森林回归的残差

理想情况下,我们的模型误差应该是随机或不可预测的。换句话说,预测误差不应与解释变量中包含的任何信息相关;相反,它应反映真实世界分布或模式的随机性。如果我们在预测误差中发现模式,例如通过检查残差图,这意味着残差图包含预测信息。这种常见的原因可能是解释信息渗入到这些残差中。

遗憾的是,在残差图中处理非随机性并没有通用的方法,需要进行试验。根据我们可用的数据,我们可以通过变量转换、调整学习算法的超参数、选择更简单或更复杂的模型、去除异常值或包含额外变量来改进模型。

总结

在本章的开头,您学习了简单线性回归分析,用于建模单个解释变量与连续响应变量之间的关系。然后,我们讨论了一种有用的解释性数据分析技术,用于查看数据中的模式和异常值,这是预测建模任务中的重要第一步。

我们通过实现基于梯度的优化方法来构建了我们的第一个模型,使用了线性回归。然后,您看到了如何利用 scikit-learn 的线性模型进行回归,并实现了一种用于处理异常值的稳健回归技术(RANSAC)。为了评估回归模型的预测性能,我们计算了平均平方误差和相关的R²指标。此外,我们还讨论了诊断回归模型问题的有用图形方法:残差图。

在我们探讨了如何应用正则化到回归模型以减少模型复杂性和避免过拟合之后,我们还涵盖了几种建模非线性关系的方法,包括多项式特征转换和随机森林回归器。

在前几章中,我们详细讨论了监督学习、分类和回归分析。在下一章中,我们将学习另一个有趣的机器学习子领域,无监督学习,以及如何使用聚类分析在缺乏目标变量的情况下发现数据中的隐藏结构。

加入我们书籍的 Discord 空间

加入本书的 Discord 工作空间,每月进行一次与作者的问我任何事会话:

packt.link/MLwPyTorch

第十章:使用未标记数据进行工作——聚类分析

在前几章中,我们使用监督学习技术构建机器学习模型,使用的数据是已知答案的数据——在我们的训练数据中,类标签已经是可用的。在本章中,我们将转向探索聚类分析,这是一类无监督学习技术,允许我们在数据中发现隐藏的结构,我们并不预先知道正确答案。聚类的目标是在数据中找到自然的分组,使得同一聚类中的项彼此之间的相似性比与不同聚类中的项更高。

鉴于其探索性质,聚类是一个令人兴奋的话题,在本章中,您将学习以下概念,这些概念可以帮助我们将数据组织成有意义的结构:

  • 使用流行的k-means算法找到相似性中心

  • 采用自下而上的方法构建层次聚类树

  • 使用基于密度的聚类方法识别对象的任意形状

使用 k-means 将对象按相似性分组

在本节中,我们将学习其中一种最流行的聚类算法之一,即 k-means,它在学术界以及工业界广泛使用。聚类(或聚类分析)是一种技术,允许我们找到彼此相关性更高的相似对象组。聚类的业务应用示例包括按不同主题对文档、音乐和电影进行分组,或者基于共同购买行为找到具有相似兴趣的客户作为推荐引擎的基础。

使用 scikit-learn 进行 k-means 聚类

正如您将在接下来看到的,k-means 算法非常易于实现,但与其他聚类算法相比,在计算效率上也非常高,这也许解释了它的流行性。k-means 算法属于原型聚类的范畴。

我们稍后将讨论另外两种聚类方法,层次聚类基于密度的聚类,在本章的后面部分。

原型聚类意味着每个聚类由一个原型表示,通常是具有连续特征的相似点的质心平均值),或者在分类特征情况下的中心点(最具代表性或者最小化到属于特定聚类的所有其他点之间距离的点)。虽然 k-means 在识别球形聚类方面非常出色,但这种聚类算法的缺点之一是我们必须预先指定聚类数k。不恰当的k选择可能导致聚类性能不佳。本章后面,我们将讨论肘部方法和轮廓图,这些是评估聚类质量的有用技术,帮助我们确定最优聚类数k

虽然 k 均值聚类可以应用于高维数据,但出于可视化目的,我们将通过一个简单的二维数据集来演示以下示例:

>>> 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.xlabel('Feature 1')
>>> plt.ylabel('Feature 2')
>>> plt.grid()
>>> plt.tight_layout()
>>> plt.show() 

我们刚刚创建的数据集包含了 150 个随机生成的点,大致分成了三个密度较高的区域,这通过二维散点图进行了可视化:

Chart, scatter chart  Description automatically generated

图 10.1:我们未标记数据集的散点图

在聚类的实际应用中,我们没有任何关于这些示例的地面真实类别信息(作为实证证据而非推断提供的信息);如果我们有类标签,这个任务就属于监督学习的范畴。因此,我们的目标是根据它们的特征相似性对这些示例进行分组,这可以通过使用 k 均值算法来实现,如下所总结的四个步骤:

  1. 从示例中随机选择k个质心作为初始聚类中心

  2. 将每个示例分配到最近的质心,

  3. 将质心移动到分配给它的示例的中心

  4. 重复步骤 23直到簇分配不再改变或达到用户定义的容差或最大迭代次数

现在,下一个问题是,我们如何衡量对象之间的相似性?我们可以将相似性定义为距离的相反数,对于具有连续特征的聚类示例,常用的距离是欧氏距离的平方,即在m维空间中两点xy之间的距离:

注意,在前述方程中,指数j指的是示例输入的第j维(特征列),xy。在本节的其余部分,我们将使用上标ij来分别指代示例(数据记录)和簇索引的索引。

基于这个欧氏距离度量,我们可以将 k 均值算法描述为一个简单的优化问题,这是一种迭代方法,用于最小化簇内平方误差和SSE),有时也称为簇惯性

这里,是簇j的代表点(质心)。w(i^(, )j) = 1 如果示例x(i^)在簇j中,否则为 0。

现在您已经了解了简单的 k 均值算法的工作原理,让我们使用 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 均值算法中并不存在,我们稍后将在本节讨论这个算法。然而,在 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.xlabel('Feature 1')
>>> plt.ylabel('Feature 2')
>>> plt.legend(scatterpoints=1)
>>> plt.grid()
>>> plt.tight_layout()
>>> plt.show() 

图 10.2中,你可以看到 k-means 将三个质心放置在每个球体的中心位置,这看起来是对数据集合理的分组:

图表,散点图 自动生成的描述

图 10.2: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++:仔细种子的优势D. ArthurS. Vassilvitskii第十八届年度 ACM-SIAM 离散算法研讨会论文集中提出,页面 1027-1035. 工业和应用数学学会,2007 年)可以得到更好和更一致的结果。

k-means++中的初始化可以总结如下:

  1. 初始化一个空集合,M,用于存储正在选择的k个质心。

  2. 随机选择第一个质心,,从输入示例中,并将其分配给M

  3. 对于不在M中的每个示例,x(i),找到到**M**中任何质心的最小平方距离,*d*(**x**(i), M)²。

  4. 要随机选择下一个质心,,使用等于的加权概率分布。例如,我们将所有点收集到一个数组中,并选择加权随机抽样,使得距离平方较大的点更有可能被选择为质心。

  5. 重复步骤 34,直到选择k个质心。

  6. 继续使用经典的 k-means 算法。

要在 scikit-learn 的KMeans对象中使用 k-means++,我们只需将init参数设置为'k-means++'。实际上,'k-means++'init参数的默认参数,强烈推荐在实践中使用。之前的示例中没有使用它的唯一原因是为了不一次引入太多概念。本节的其余部分将使用 k-means++,但鼓励您更多地尝试这两种不同的方法(通过init='random'进行经典的 k-means 或通过init='k-means++'进行 k-means++)来放置初始簇质心。

硬聚类与软聚类

硬聚类描述了一个算法族,其中数据集中的每个示例被分配到一个且仅一个簇中,就像我们之前在本章讨论过的 k-means 和 k-means++ 算法一样。相反,软聚类(有时也称为模糊聚类)的算法将一个示例分配给一个或多个簇。软聚类的一个流行示例是模糊 C 均值FCM)算法(也称为软 k-means模糊 k-means)。最初的想法可以追溯到 20 世纪 70 年代,当时 Joseph C. Dunn 首次提出了模糊聚类的早期版本,以改进 k-means(A Fuzzy Relative of the ISODATA Process and Its Use in Detecting Compact Well-Separated Clusters, 1973)。几乎 10 年后,James C. Bezdek 发表了他关于改进模糊聚类算法的工作,现在被称为 FCM 算法(Pattern Recognition with Fuzzy Objective Function Algorithms, Springer Science+Business Media, 2013)。

FCM 过程与 k-means 非常相似。但是,我们用每个点属于每个簇的概率替换了硬聚类分配。在 k-means 中,我们可以用稀疏的二进制值向量表示示例x的簇成员资格。

在这里,索引位置为 1 的值表示簇质心,,示例被分配到(假设k = 3,)。相比之下,在 FCM 中,成员向量可以表示如下:

在这里,每个值都落在范围[0, 1]内,表示相应簇质心的成员概率。给定示例的成员总和等于 1。与 k-means 算法类似,我们可以用四个关键步骤总结 FCM 算法:

  1. 指定k个质心,并为每个点随机分配簇成员资格。

  2. 计算簇质心,

  3. 更新每个点的簇成员资格

  4. 重复步骤 23,直到成员系数不再改变或达到用户定义的容差或最大迭代次数。

FCM 的目标函数——我们将其缩写为J[m]——看起来与我们在 k-means 中最小化的簇内平方和误差(SSE)非常相似:

但是,请注意,成员指示器* w i(,j^)不像 k 均值中的二进制值(),而是一个表示聚类成员资格概率的实数值()。您可能还注意到,我们为 w i(,j^)添加了一个额外的指数;指数m,大于或等于 1(通常m=2),被称为模糊系数(或简称模糊化器),它控制模糊度*的程度。

m值越大,聚类成员资格* w i(,j^)*的值越小,这导致聚类变得更加模糊。聚类成员资格概率本身的计算方法如下:

例如,在前面的 k 均值示例中,如果我们选择了三个聚类中心,我们可以计算属于聚类的成员资格如下:

聚类中心本身是通过加权平均所有示例来计算的,加权系数是每个示例属于该聚类的程度():

光看计算聚类成员资格的方程,我们可以说 FCM 中的每次迭代比 k 均值中的迭代更昂贵。另一方面,FCM 通常需要更少的迭代才能达到收敛。然而,实际上发现,k 均值和 FCM 产生非常相似的聚类输出,正如一项研究(《比较分析 k 均值和模糊 c 均值算法》,由S. GhoshS. K. DubeyIJACSA,4: 35–38,2013 年)所描述的那样。不幸的是,目前 scikit-learn 中未实现 FCM 算法,但有兴趣的读者可以尝试来自 scikit-fuzzy 软件包的 FCM 实现,该软件包可在github.com/scikit-fuzzy/scikit-fuzzy获取。

使用肘方法找到最优聚类数

无监督学习的主要挑战之一是我们不知道确切的答案。在我们的数据集中,我们没有地面真实类标签,这些标签允许我们应用在第六章 学习模型评估和超参数调整的最佳实践 中使用的技术来评估监督模型的性能。因此,为了量化聚类的质量,我们需要使用内在度量标准,例如在集群内的 SSE(畸变)来比较不同 k 均值聚类模型的性能。

方便地,当我们使用 scikit-learn 时,我们不需要显式计算在集群内的 SSE,因为在拟合了KMeans模型后,它已经通过inertia_属性访问:

>>> print(f'Distortion: {km.inertia_:.2f}')
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() 

正如您在 图 10.3 中所看到的,肘部 位于 k = 3,这是支持 k = 3 对于此数据集确实是一个好选择的证据:

图 10.3:使用肘部法找到最佳聚类数

通过轮廓图量化聚类质量

另一个评估聚类质量的内在度量是 轮廓分析,它也可以应用于我们稍后将讨论的除 k-means 外的其他聚类算法。轮廓分析可用作绘制集群示例紧密程度的度量的图形工具。要计算数据集中单个示例的 轮廓系数,我们可以应用以下三个步骤:

  1. 计算 聚类内聚性 a(i^) ,作为示例 x(i^) 和同一聚类中所有其他点之间的平均距离。

  2. 计算 聚类分离度 b(i^) ,作为示例 x(i^) 和最近聚类中所有示例之间的平均距离。

  3. 计算轮廓系数 s(i^) ,如下所示,作为聚类内聚性和分离性之间差异的差值,除以两者中的较大者:

轮廓系数的范围为 -1 到 1。根据前述方程,我们可以看到如果聚类分离和内聚相等(b(i^) = a(i^)),则轮廓系数为 0。此外,如果 b(i^) >> a(i^),我们接近理想的轮廓系数 1,因为 b(i^) 量化了示例与其他聚类的不相似性,而 a(i^) 告诉我们它与同一聚类中其他示例的相似性。

silhouette_samples 是 scikit-learn 的 metric 模块提供的轮廓系数,可选地,为了方便起见,可以导入 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() 

通过对轮廓图的视觉检查,我们可以快速审查不同聚类的大小,并识别包含 异常值 的聚类:

图 10.4:一个良好聚类的轮廓图示例

然而,如前面的轮廓图所示,轮廓系数与平均轮廓分数并不接近,并且在本例中,这是聚类的指标。此外,为了总结我们聚类的好坏,我们将平均轮廓系数添加到图中(虚线)。

要查看相对糟糕聚类的轮廓图是什么样子,请用仅两个质心种子化 k 均值算法:

>>> 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.xlabel('Feature 1')
>>> plt.ylabel('Feature 2')
>>> plt.legend()
>>> plt.grid()
>>> plt.tight_layout()
>>> plt.show() 

如您在图 10.5中所见,三个球形数据组之间有一个质心。

尽管聚类看起来并非完全糟糕,但仍然次优:

图表,散点图  自动生成的描述

图 10.5: 一个聚类次优示例

请记住,在真实世界的问题中,我们通常没有奢侈地在二维散点图中可视化数据集,因为我们通常使用更高维度的数据。因此,接下来,我们将创建轮廓图来评估结果:

>>> 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() 

如您在图 10.6中所见,轮廓现在具有明显不同的长度和宽度,这表明相对糟糕或至少次优的聚类:

图 10.6: 一个聚类次优示例的轮廓图

现在,我们已经对聚类的工作原理有了很好的理解,接下来的部分将介绍层次聚类作为 k 均值的替代方法。

将群集组织成层次树

在本节中,我们将看一种基于原型的聚类的替代方法:层次聚类。层次聚类算法的一个优点是它允许我们绘制树状图(二叉层次聚类的可视化),这可以通过创建有意义的分类体系来帮助解释结果。这种层次方法的另一个优点是我们不需要预先指定群集的数量。

层次聚类的两种主要方法是聚合分裂层次聚类。在分裂层次聚类中,我们从包含完整数据集的一个群集开始,并迭代地将群集分成较小的群集,直到每个群集只包含一个示例。在本节中,我们将重点关注聚合聚类,它采用相反的方法。我们从每个示例作为单独的群集开始,并合并最接近的群集对,直到只剩下一个群集。

以自底向上的方式对群集进行分组

凝聚式层次聚类的两种标准算法是单链接完全链接。使用单链接时,我们计算每对聚类中最相似成员之间的距离,并合并两个距离最小的聚类。完全链接方法类似于单链接,但我们比较每对聚类中最不相似成员,以执行合并。这在 图 10.7 中显示:

图 10.7:完全链接方法

替代链接类型

用于凝聚式层次聚类的其他常用算法包括平均链接和 Ward 链接。在平均链接中,我们基于两个聚类中所有组成员的最小平均距离合并聚类对。在 Ward 链接中,合并导致总内部簇平方和增加最小的两个聚类。

在本节中,我们将专注于使用完全链接方法进行凝聚式聚类。层次完全链接聚类是一个迭代过程,可以总结为以下步骤:

  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 

在执行上述代码后,我们现在应该看到包含随机生成示例的以下 DataFrame

图 10.8:一个随机生成的数据样本

在距离矩阵上执行层次聚类

要计算作为层次聚类算法输入的距离矩阵,我们将使用 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 函数的输入,以创建成对距离的对称矩阵,如下所示:

图 10.9:我们数据的计算成对距离

接下来,我们将使用 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 笔记本中查看)如下所示:

>>> pd.DataFrame(row_clusters,
...              columns=['row label 1',
...                       'row label 2',
...                       'distance',
...                       'no. of items in clust.'],
...              index=[f'cluster {(i + 1)}' for i in
...                     range(row_clusters.shape[0])]) 

图 10.10所示,联接矩阵由多行组成,每行代表一个合并。第一列和第二列表示每个簇中最不相似的成员,第三列报告这些成员之间的距离。

最后一列返回每个簇中成员的计数:

img/B17582_10_10.png

图 10.10:联接矩阵

现在我们已经计算出联接矩阵,我们可以以树状图的形式可视化结果:

>>> 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 的颜色列表,这些颜色按照树状图中的距离阈值循环。例如,要将树状图显示为黑色,您可以取消注释前述代码中插入的相应部分:

img/B17582_10_11.png

图 10.11:我们数据的树状图

这样的树状图总结了在聚合层次聚类期间形成的不同簇;例如,您可以看到基于欧几里德距离度量,示例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 对象(实质上是一个 Python 字典)通过 leaves 键访问的聚类标签重新排序我们初始 DataFrame 中的数据。代码如下:

    >>> 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. 最后,我们通过移除轴刻度和隐藏轴脊梁来修改树状图的美学。此外,我们添加了一个色条,并将特征和数据记录名称分配给xy轴刻度标签:

    >>> 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() 
    

在执行前述步骤之后,热图应显示在附加的树状图上。

自动生成的图表描述

图 10.12:我们数据的热图和树状图

如您所见,热图中行的顺序反映了树状图中示例的聚类情况。除了简单的树状图外,热图中每个示例和特征的色彩编码值为我们提供了数据集的一个良好总结。

通过 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(f'Cluster labels: {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(f'Cluster labels: {labels}')
Cluster labels: [0 1 1 0 0] 

如您所见,在这个修剪的聚类层次结构中,标签 ID_3 被分配到与 ID_0ID_4 相同的簇中,正如预期的那样。

通过 DBSCAN 定位高密度区域

尽管我们无法在本章节中涵盖大量不同的聚类算法,但至少让我们再包括一种聚类方法:基于密度的空间聚类应用与噪声DBSCAN),它不像 k-means 那样假设球形簇,也不将数据集分割成需要手动切断点的层次结构。DBSCAN 根据点的密集区域分配聚类标签。在 DBSCAN 中,密度的概念被定义为指定半径内的点数,如图所示:

根据 DBSCAN 算法,根据以下标准为每个示例(数据点)分配特殊标签:

  • 如果在指定半径内有至少指定数量(MinPts)的相邻点,则称该点为核心点,如图所示:

  • 边界点是指在半径内具有少于 MinPts 相邻点,但位于核心点的半径内的点。

  • 所有既不是核心点也不是边界点的其他点被视为噪声点

将点标记为核心点、边界点或噪声点之后,DBSCAN 算法可以总结为两个简单步骤:

  1. 形成每个核心点或连接的核心点组的单独簇。(如果它们的距离不超过。)

  2. 将每个边界点分配到其对应核心点的簇中。

在跳入实施之前,为了更好地理解 DBSCAN 的结果可能如何,让我们总结一下关于核心点、边界点和噪声点的内容,参见图 10.13

图 10.13: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.xlabel('Feature 1')
>>> plt.ylabel('Feature 2')
>>> plt.tight_layout()
>>> plt.show() 

如图所示的结果绘图中,可以看到两个明显的半月形簇,每个簇包含 100 个示例(数据点):

图表,散点图  自动生成的描述

图 10.14:一个双特征半月形数据集

我们将首先使用 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')
>>> ax1.set_xlabel('Feature 1')
>>> ax1.set_ylabel('Feature 2')
>>> 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')
>>> ax2.set_xlabel('Feature 1')
>>> ax2.set_ylabel('Feature 2')
>>> plt.legend()
>>> plt.tight_layout()
>>> plt.show() 

根据可视化的聚类结果,我们可以看到 k-means 算法无法分离两个簇,而且层次聚类算法在面对这些复杂形状时也面临挑战:

图,箭头  由系统自动生成描述

图 10.15:半月形数据集上的 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.xlabel('Feature 1')
>>> plt.ylabel('Feature 2')
>>> plt.legend()
>>> plt.tight_layout()
>>> plt.show() 

DBSCAN 算法可以成功检测半月形状,这突显了 DBSCAN 的一个优势——可以对任意形状的数据进行聚类:

图,散点图  由系统自动生成描述

图 10.16:半月形数据集上的 DBSCAN 聚类

然而,我们也应该注意一些 DBSCAN 的缺点。随着数据集中特征数量的增加——假设训练示例数量固定——维度灾难的负面影响增加。如果我们使用欧氏距离度量尤其是问题。然而,维度灾难问题并不局限于 DBSCAN:它也影响其他使用欧氏距离度量的聚类算法,例如 k-means 和层次聚类算法。此外,DBSCAN 中有两个超参数(MinPts 和!)需要优化以产生良好的聚类结果。如果数据集中的密度差异相对较大,找到 MinPts 和!的良好组合可能会成为一个问题。

基于图的聚类

到目前为止,我们已经看到了三种最基本的聚类算法类别:基于原型的 k-means 聚类,聚合层次聚类和基于密度的 DBSCAN 聚类。然而,在本章中我们还没有涉及的是第四类更高级的聚类算法:基于图的聚类。可能是基于图的聚类家族中最显著的成员是光谱聚类算法。

尽管光谱聚类有许多不同的实现,它们的共同之处在于利用相似性或距离矩阵的特征向量来推导聚类关系。由于光谱聚类超出了本书的范围,您可以阅读乌尔里克·冯·吕克斯堡(Ulrike von Luxburg)的优秀教程以了解更多关于这个主题的内容(光谱聚类教程统计与计算,17(4):395-416,2007)。它可以在 arXiv 上免费获取:arxiv.org/pdf/0711.0189v1.pdf

注意,在实践中,不同聚类算法在给定数据集上表现最佳并不总是显而易见,特别是如果数据以多个维度呈现,这会使得可视化变得困难或不可能。此外,强调成功的聚类不仅取决于算法及其超参数;选择合适的距离度量标准以及使用能够帮助指导实验设置的领域知识可能更为重要。

在维度诅咒的背景下,因此在执行聚类之前常见的做法是应用降维技术。这些面向无监督数据集的降维技术包括主成分分析和 t-SNE,我们在第五章《通过降维压缩数据》中涵盖了这些技术。此外,将数据集压缩到二维子空间特别常见,这样可以使用二维散点图可视化聚类和分配的标签,这对于评估结果特别有帮助。

总结

在这一章中,您学习了三种不同的聚类算法,这些算法可以帮助我们发现数据中隐藏的结构或信息。我们从基于原型的方法开始,即 k-means 算法,该算法根据指定数量的聚类中心将示例聚类成球形。由于聚类是一种无监督方法,我们没有地面真实标签来评估模型的性能。因此,我们使用了内在性能度量标准,例如肘部法则或轮廓分析,试图量化聚类的质量。

然后,我们看了一种不同的聚类方法:凝聚层次聚类。层次聚类不需要预先指定聚类数,其结果可以在树形图表示中可视化,这有助于解释结果。我们在本章中介绍的最后一个聚类算法是 DBSCAN,这是一种根据局部密度分组点的算法,能够处理异常值并识别非球形形状。

在这次涉足无监督学习领域之后,现在是介绍一些最激动人心的监督学习机器学习算法的时候了:多层人工神经网络。随着它们最近的复苏,神经网络再次成为机器学习研究中最热门的话题。由于最近开发的深度学习算法,神经网络被认为是许多复杂任务的最先进技术,如图像分类、自然语言处理和语音识别。在第十一章从头开始实现多层人工神经网络中,我们将构建自己的多层神经网络。在第十二章使用 PyTorch 并行化神经网络训练中,我们将使用 PyTorch 库,该库专门用于利用图形处理单元高效训练多层神经网络模型。

加入我们书籍的 Discord 空间

加入本书的 Discord 工作空间,参与每月的问答活动与作者交流:

packt.link/MLwPyTorch

第十一章:从头开始实现多层人工神经网络

你可能已经知道,深度学习在媒体上受到了很多关注,毫无疑问,它是机器学习领域最热门的话题。深度学习可以理解为机器学习的一个子领域,其关注点是如何高效地训练具有多层的人工神经网络NNs)。在本章中,你将学习人工神经网络的基本概念,以便在接下来的章节中,我们将介绍专门用于图像和文本分析的基于 Python 的高级深度学习库和深度神经网络DNN)架构。

本章将涵盖的主题如下:

  • 对多层神经网络(Multilayer NNs)的概念性理解

  • 从头开始实现神经网络训练的基本反向传播算法

  • 为图像分类训练基本的多层神经网络

用人工神经网络对复杂函数建模

在本书的开头,我们从人工神经元开始了机器学习算法的旅程,这在《第二章》,《训练简单的分类机器学习算法》中有所介绍。人工神经元代表了我们将在本章讨论的多层人工神经网络的构建模块。

人工神经网络的基本概念建立在关于人脑如何解决复杂问题任务的假设和模型之上。尽管人工神经网络近年来越来越受欢迎,但早期对神经网络的研究可以追溯到 1940 年代,当时 Warren McCulloch 和 Walter Pitts 首次描述了神经元的工作方式,《神经活动中所含的思想的逻辑演算》,作者为 W. S. McCulloch 和 W. Pitts,发表于《数理生物物理学公报》(The Bulletin of Mathematical Biophysics),5(4):115–133,1943 年。

然而,在第一个麦卡洛克-皮茨神经元模型——罗森布拉特在 1950 年代提出的感知器——实现后的几十年间,许多研究人员和机器学习从业者逐渐失去了对神经网络的兴趣,因为没有人能有效地训练具有多层的神经网络。直到 1986 年,D.E. Rumelhart、G.E. Hinton 和 R.J. Williams 参与了反向传播算法的(重新)发现和推广,有效地训练神经网络,这一算法将在本章后面更详细地讨论《通过反向传播错误学习表示》,作者为 D.E. Rumelhart、G.E. Hinton 和 R.J. Williams,发表于《自然》(Nature),323(6088):533–536,1986 年。对人工智能AI)、机器学习和神经网络历史感兴趣的读者也建议阅读所谓的AI 寒冬的维基百科文章,这些是研究社区失去对神经网络研究兴趣的时期(en.wikipedia.org/wiki/AI_winter)。

然而,如今神经网络(NNs)比以往任何时候都更受欢迎,这要归功于上一个十年取得的许多突破,这导致了我们现在所称的深度学习算法和架构——由许多层组成的 NNs。NNs 不仅在学术研究中是热门话题,而且在大型技术公司(如 Facebook、Microsoft、Amazon、Uber、Google 等)中也是如此,这些公司在人工神经网络和深度学习研究上投入了大量资源。

到目前为止,由深度学习算法驱动的复杂神经网络被认为是解决诸如图像和语音识别等复杂问题的最先进解决方案。一些最近的应用包括:

单层神经网络概述

本章讨论的是多层神经网络,它们的工作原理以及如何训练它们来解决复杂问题。然而,在深入研究特定的多层神经网络架构之前,让我们简要重申我们在 第二章 中介绍的单层神经网络概念,即 ADAptive LInear NEuron(Adaline)算法,如 图 11.1 所示:

自动生成的图表说明

图 11.1:Adaline 算法

第二章 中,我们实现了 Adaline 算法来进行二元分类,并使用梯度下降优化算法来学习模型的权重系数。在每个 epoch(训练数据集的一次遍历)中,我们使用以下更新规则更新权重向量 w 和偏置单元 b

其中 分别代表偏置单元和权重向量 w 中的每个权重 w[j]。

换句话说,我们基于整个训练数据集计算梯度,并通过在损失梯度的反方向上迈出一步来更新模型的权重 。(为简单起见,我们将专注于权重并在以下段落中省略偏置单元;然而,正如你从 第二章 中记得的那样,相同的概念也适用。)为了找到模型的最优权重,我们优化了一个我们定义为均方误差MSE)损失函数 L(w) 的目标函数。此外,我们将梯度乘以一个因子,学习率 ,我们需要仔细选择以在学习速度与超过损失函数全局最小值的风险之间取得平衡。

在梯度下降优化中,我们在每个迭代后同时更新所有权重,并且我们定义了权重向量中每个权重 w[j] 的偏导数,w,如下所示:

在这里,y(i^) 是特定样本 x(i^) 的目标类标签,a(i^) 是神经元的激活,对于 Adaline 的特殊情况,它是一个线性函数。

此外,我们如下定义了激活函数

这里,净输入 z 是连接输入层与输出层的权重的线性组合:

当我们使用激活函数 来计算梯度更新时,我们实现了一个阈值函数,将连续值输出压缩为用于预测的二进制类标签:

单层命名约定

注意,尽管 Adaline 由两层组成,即一个输入层和一个输出层,但由于其输入层和输出层之间的单一连接,它被称为单层网络。

另外,我们了解了一种加速模型学习的特定技巧,即所谓的随机梯度下降SGD)优化。 SGD 从单个训练样本(在线学习)或一小部分训练示例(小批量学习)中近似损失。 在本章后面,当我们实现和训练多层感知机MLP)时,我们将使用这个概念。 除了由于梯度下降比梯度下降更频繁地更新权重导致更快的学习之外,其嘈杂的本质在训练具有非线性激活函数的多层神经网络时也被认为是有益的。 这里,添加的噪声可以帮助逃离局部损失最小值,但我们将在本章后面更详细地讨论这个主题。

引入多层神经网络架构

在本节中,您将学习如何将多个单神经元连接到多层前馈神经网络;这种特殊类型的全连接网络也称为MLP

图 11.2说明了由两层组成的 MLP 的概念:

图表,工程图纸  描述自动生成

图 11.2: 一个两层 MLP

除了数据输入之外,图 11.2中描述的 MLP 具有一个隐藏层和一个输出层。隐藏层中的单元与输入特征完全连接,输出层与隐藏层完全连接。如果这样的网络有多个隐藏层,我们也称其为深度神经网络。(请注意,在某些情况下,输入也被视为一层。然而,在这种情况下,将 Adaline 模型,即单层神经网络,视为两层神经网络可能会令人困惑。)

添加额外的隐藏层

我们可以在 MLP 中添加任意数量的隐藏层,以创建更深的网络结构。实际上,我们可以将 NN 中的层数和单位数视为额外的超参数,我们希望使用交叉验证技术为给定的问题任务进行优化,这些内容我们在第六章中讨论了学习模型评估和超参数调整的最佳实践

然而,随着网络添加更多层,用于更新网络参数的损失梯度(稍后我们将通过反向传播计算)将变得越来越小。这种梯度消失问题使得模型学习更具挑战性。因此,已经开发了特殊算法来帮助训练这种 DNN 结构;这就是深度学习,我们将在接下来的章节中更详细地讨论。

图 11.2所示,我们将第l层中的第i个激活单元表示为 。为了使数学和代码实现更直观,我们将不使用数字索引来引用层,而是使用in上标表示输入特征,h上标表示隐藏层,out上标表示输出层。例如, 表示第i个输入特征值, 表示隐藏层中的第i个单元, 表示输出层中的第i个单元。请注意,图 11.2中的b代表偏置单元。事实上,b(h)和**b**((out))是具有与其对应层中节点数相等的元素数量的向量。例如,b(h^)存储d个偏置单元,其中d是隐藏层中的节点数。如果这听起来令人困惑,不用担心。稍后查看代码实现,我们初始化权重矩阵和偏置单元向量将有助于澄清这些概念。

每个 l 层中的节点通过权重系数与 l + 1 层中的所有节点相连。例如,层 l 中第 k 个单元到层 l + 1 中第 j 个单元的连接将被写为 。回顾 图 11.2,我们将连接输入到隐藏层的权重矩阵称为 W(h^),并将连接隐藏层到输出层的矩阵称为 W((out)^)。

虽然输出层的一个单元足以完成二元分类任务,但在前述图中我们看到了更一般的神经网络形式,它允许我们通过一对所有OvA)技术的泛化来进行多类别分类。为了更好地理解其工作原理,请记住我们在第四章 构建良好的训练数据集 – 数据预处理 中介绍的分类变量的独热编码表示。

例如,我们可以将经典的鸢尾花数据集中的三类标签(0=山鸢尾,1=变色鸢尾,2=维吉尼亚鸢尾)进行如下编码:

这种独热向量表示使我们能够处理训练数据集中任意数量的独特类标签的分类任务。

如果你对神经网络表示还不熟悉,索引符号(下标和上标)可能一开始看起来有点令人困惑。但是,在后面的章节中,当我们对神经网络表示进行向量化时,这些看似过于复杂的内容将会变得更加合理。正如之前介绍的那样,我们通过一个 d×m 维度的矩阵 W(h^) 来总结连接输入和隐藏层的权重,其中 d 是隐藏单元的数量,m 是输入单元的数量。

通过前向传播激活神经网络

在本节中,我们将描述前向传播的过程,以计算 MLP 模型的输出。为了理解它如何融入到学习 MLP 模型的背景中,让我们简要总结 MLP 学习过程的三个简单步骤:

  1. 从输入层开始,我们通过网络将训练数据的模式进行前向传播,生成一个输出。

  2. 根据网络的输出,我们使用稍后将描述的损失函数计算我们希望最小化的损失。

  3. 我们通过反向传播损失,找到其对网络中每个权重和偏置单元的导数,并更新模型。

最后,在我们对多个时期重复执行这三个步骤并学习 MLP 的权重和偏置参数之后,我们使用前向传播来计算网络输出,并应用阈值函数以获得在独热表示中的预测类标签,这是我们在前一节中描述过的。

现在,让我们逐步进行前向传播的各个步骤,从训练数据模式中生成输出。由于隐藏层中的每个单元都与输入层中的所有单元连接,我们首先计算隐藏层激活单元 如下所示:

图 说明

在这里, 是净输入, 是激活函数,必须是可微的,以便使用基于梯度的方法学习连接神经元的权重。为了能够解决复杂问题,如图像分类,我们在 MLP 模型中需要非线性激活函数,例如我们在第三章“使用 Scikit-Learn 的机器学习分类器之旅”中记得的 sigmoid(logistic)激活函数:

图 说明

正如您可能记得的那样,Sigmoid 函数是一条S形曲线,将净输入 z 映射到 0 到 1 的 logistic 分布范围内,在 Figure 11.3 中显示 y 轴在 z = 0 处切割:

图 说明:自动低置信度生成

图 11.3:Sigmoid 激活函数

MLP 是前馈人工神经网络的典型例子。术语 feedforward 指的是每一层作为下一层的输入,没有循环,与递归神经网络形成对比——这是我们将在本章稍后讨论的架构,并在第十五章“使用递归神经网络建模顺序数据”中进行更详细的讨论。术语 multilayer perceptron 可能听起来有点混淆,因为这种网络架构中的人工神经元通常是 sigmoid 单元,而不是感知器。我们可以将 MLP 中的神经元视为 logistic 回归单元,返回在 0 到 1 的连续范围内的值。

为了代码的效率和可读性,我们将使用基本线性代数的概念,通过 NumPy 将激活写成更紧凑的形式,而不是编写多个嵌套和计算昂贵的 Python for 循环。

图 说明

这里,z(h^) 是我们的 1×m 维特征向量。W(h^) 是一个 d×m 维权重矩阵,其中 d 是隐藏层中的单元数;因此,转置矩阵 W(h)T 是 m×d 维的。偏置向量 b(h^) 包含 d 个偏置单元(每个隐藏节点一个偏置单元)。

在矩阵-向量乘法之后,我们得到 1×d 维的净输入向量 z(h^),用于计算激活 a(h^)(其中 )。

此外,我们可以将这一计算推广到训练数据集中的所有 n 个示例:

Z(h^) = X((in))**W**(h)^T + b(h^)

在这里,X((in))现在是一个*n*×*m*矩阵,矩阵乘法将得到一个*n*×*d*维度的净输入矩阵**Z**(h)。最后,我们对净输入矩阵中的每个值应用激活函数 ,以获得下一层(这里是输出层)的n×d激活矩阵:

同样地,我们可以为多个示例的输出层激活以向量化形式编写:

Z((out)^) = A(h)**W**((out))^T + b((out)^)

在这里,我们将t×d矩阵W((out))的转置(*t*是输出单元的数量)乘以*n*×*d*维度矩阵**A**(h),并加上t维度偏置向量b((out)),以获得*n*×*t*维度矩阵**Z**((out))(该矩阵中的列表示每个样本的输出)。

最后,我们应用 sigmoid 激活函数来获得网络的连续值输出:

类似于Z((out)),**A**((out))是一个n×t维度的矩阵。

分类手写数字

在前一节中,我们涵盖了关于 NN 的大量理论,如果您对此主题还不熟悉,可能会有点压倒性。在我们继续讨论 MLP 模型学习权重算法——反向传播之前,让我们从理论中稍作休息,看看 NN 的实际应用。

反向传播的额外资源

NN 理论可能非常复杂;因此,我们希望为读者提供更详细或不同视角覆盖本章讨论主题的其他资源:

在本节中,我们将实现并训练我们的第一个多层 NN 来分类来自流行的混合国家标准技术研究所MNIST)数据集的手写数字,该数据集由 Yann LeCun 和其他人构建,并作为机器学习算法的流行基准数据集(基于梯度的学习应用于文档识别,由Y. LeCunL. BottouY. BengioP. Haffner著,IEEE 会议论文集,86(11): 2278-2324,1998 年)。

获取和准备 MNIST 数据集

MNIST 数据集公开可用于 yann.lecun.com/exdb/mnist/,包括以下四个部分:

  1. 训练数据集图片train-images-idx3-ubyte.gz(9.9 MB,解压后 47 MB,共 60,000 个示例)

  2. 训练数据集标签train-labels-idx1-ubyte.gz(29 KB,解压后 60 KB,共 60,000 个标签)

  3. 测试数据集图片t10k-images-idx3-ubyte.gz(1.6 MB,解压后 7.8 MB,共 10,000 个示例)

  4. 测试数据集标签t10k-labels-idx1-ubyte.gz(5 KB,解压后 10 KB,共 10,000 个标签)

MNIST 数据集由美国国家标准与技术研究院NIST)的两个数据集构成。训练数据集包括来自 250 个不同人的手写数字,其中 50% 是高中学生,另外 50% 是人口普查局的员工。请注意,测试数据集包含了不同人群的手写数字,遵循相同的拆分。

我们不需要自己下载上述数据集文件并将它们预处理为 NumPy 数组,而是可以使用 scikit-learn 的新fetch_openml函数更方便地加载 MNIST 数据集:

>>> from sklearn.datasets import fetch_openml
>>> X, y = fetch_openml('mnist_784', version=1,
...                     return_X_y=True)
>>> X = X.values
>>> y = y.astype(int).values 

在 scikit-learn 中,fetch_openml 函数从 OpenML (www.openml.org/d/554) 下载 MNIST 数据集作为 pandas 的 DataFrame 和 Series 对象,因此我们使用 .values 属性来获取底层的 NumPy 数组。(如果你使用的是 scikit-learn 版本低于 1.0,fetch_openml 直接下载 NumPy 数组,因此可以省略使用 .values 属性。)X 数组的 n×m 维度由 70,000 张图片组成,每张图片有 784 个像素,y 数组存储了对应的 70,000 个类别标签,我们可以通过检查数组的维度来确认:

>>> print(X.shape)
(70000, 784)
>>> print(y.shape)
(70000,) 

MNIST 数据集中的图像由 28×28 像素组成,每个像素由灰度强度值表示。在这里,fetch_openml 已经将 28×28 像素展开为一维行向量,这些向量表示我们 X 数组中的行(每行或每张图像有 784 个像素)。fetch_openml 函数返回的第二个数组 y 包含手写数字的相应目标变量,即类别标签(整数 0-9)。

接下来,让我们通过以下代码行将 MNIST 中的像素值归一化到范围 -1 到 1(原始范围为 0 到 255):

>>> X = ((X / 255.) - .5) * 2 

这样做的原因是在这些条件下,基于梯度的优化更加稳定,正如 第二章 中所讨论的。请注意,我们是基于像素的缩放,这与我们在前几章中采取的特征缩放方法不同。

我们之前从训练数据集中推导出了缩放参数,并将其用于缩放训练数据集和测试数据集中的每一列。然而,当处理图像像素时,通常将它们居中在零点并重新缩放到 [-1, 1] 范围内,这也是常见且通常能很好地工作。

要了解 MNIST 中这些图像的样子,让我们通过 Matplotlib 的imshow函数将我们特征矩阵中的 784 像素向量重塑为原始的 28×28 图像,并进行可视化:

>>> 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[y == i][0].reshape(28, 28)
...     ax[i].imshow(img, cmap='Greys')
>>> ax[0].set_xticks([])
>>> ax[0].set_yticks([])
>>> plt.tight_layout()
>>> plt.show() 

现在我们应该看到一个由 2×5 个子图组成的图,显示每个唯一数字的代表性图像:

包含图形用户界面的图片,自动生成的描述

图 11.4:显示每个类别中随机选择的一个手写数字的图

此外,让我们也绘制同一数字的多个示例,以查看每个数字的手写风格有多不同:

>>> fig, ax = plt.subplots(nrows=5,
...                        ncols=5,
...                        sharex=True,
...                        sharey=True)
>>> ax = ax.flatten()
>>> for i in range(25):
...     img = X[y == 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 个变体:

包含日历的图片,自动生成的描述

图 11.5:手写数字 7 的不同变体

最后,让我们将数据集分为训练、验证和测试子集。以下代码将分割数据集,使得 55,000 张图像用于训练,5,000 张图像用于验证,以及 10,000 张图像用于测试:

>>> from sklearn.model_selection import train_test_split
>>> X_temp, X_test, y_temp, y_test = train_test_split(
...     X, y, test_size=10000, random_state=123, stratify=y
... )
>>> X_train, X_valid, y_train, y_valid = train_test_split(
...     X_temp, y_temp, test_size=5000,
...     random_state=123, stratify=y_temp
... ) 

实现多层感知器

在本小节中,我们现在将从头开始实现一个 MLP 来对 MNIST 数据集中的图像进行分类。为了保持简单,我们将只实现一个只有一个隐藏层的 MLP。由于这种方法一开始可能看起来有点复杂,建议你从 Packt Publishing 的网站或 GitHub (github.com/rasbt/machine-learning-book)下载本章的示例代码,以便查看带有注释和语法高亮的 MLP 实现,以提高可读性。

如果你没有从附带的 Jupyter Notebook 文件运行代码,或者无法访问互联网,可以将本章中的NeuralNetMLP代码复制到你当前工作目录下的 Python 脚本文件中(例如neuralnet.py),然后通过以下命令将其导入到当前的 Python 会话中:

from neuralnet import NeuralNetMLP 

代码将包含一些我们尚未讨论的部分,例如反向传播算法。如果代码中的某些部分目前对你来说并不完全理解,不必担心;我们稍后会对某些部分进行跟进。然而,在这个阶段检查代码可以使后续的理论更容易理解。

因此,让我们来看下面的多层感知器的实现,从计算逻辑 sigmoid 激活和将整数类标签数组转换为独热编码标签的两个辅助函数开始:

import numpy as np
def sigmoid(z):
    return 1\. / (1\. + np.exp(-z))
def int_to_onehot(y, num_labels):
    ary = np.zeros((y.shape[0], num_labels))
    for i, val in enumerate(y):
        ary[i, val] = 1
    return ary 

下面,我们实现了我们的多层感知器的主类,我们称之为NeuralNetMLP。有三个类方法,. __init__(), .forward(), 和 .backward(),我们将逐一讨论,从__init__构造函数开始:

class NeuralNetMLP:
    def __init__(self, num_features, num_hidden,
                 num_classes, random_seed=123):
        super().__init__()

        self.num_classes = num_classes

        # hidden
        rng = np.random.RandomState(random_seed)

        self.weight_h = rng.normal(
            loc=0.0, scale=0.1, size=(num_hidden, num_features))
        self.bias_h = np.zeros(num_hidden)

        # output
        self.weight_out = rng.normal(
            loc=0.0, scale=0.1, size=(num_classes, num_hidden))
        self.bias_out = np.zeros(num_classes) 

__init__ 构造函数实例化了隐藏层和输出层的权重矩阵和偏置向量。接下来,让我们看看这些如何在 forward 方法中用于进行预测:

 def forward(self, x):
        # Hidden layer

        # input dim: [n_hidden, n_features]
        #        dot [n_features, n_examples] .T
        # output dim: [n_examples, n_hidden]
        z_h = np.dot(x, self.weight_h.T) + self.bias_h
        a_h = sigmoid(z_h)
        # Output layer
        # input dim: [n_classes, n_hidden]
        #        dot [n_hidden, n_examples] .T
        # output dim: [n_examples, n_classes]
        z_out = np.dot(a_h, self.weight_out.T) + self.bias_out
        a_out = sigmoid(z_out)
        return a_h, a_out 

forward 方法接收一个或多个训练样本,并返回预测结果。实际上,它同时返回隐藏层和输出层的激活值,a_ha_out。而 a_out 表示类成员概率,我们可以将其转换为类标签,这是我们关心的内容,同时我们还需要隐藏层的激活值 a_h 来优化模型参数,即隐藏层和输出层的权重和偏置单元。

最后,让我们谈谈 backward 方法,它更新神经网络的权重和偏置参数:

 def backward(self, x, a_h, a_out, y):

        #########################
        ### Output layer weights
        #########################

        # one-hot encoding
        y_onehot = int_to_onehot(y, self.num_classes)
        # Part 1: dLoss/dOutWeights
        ## = dLoss/dOutAct * dOutAct/dOutNet * dOutNet/dOutWeight
        ## where DeltaOut = dLoss/dOutAct * dOutAct/dOutNet
        ## for convenient re-use

        # input/output dim: [n_examples, n_classes]
        d_loss__d_a_out = 2.*(a_out - y_onehot) / y.shape[0]
        # input/output dim: [n_examples, n_classes]
        d_a_out__d_z_out = a_out * (1\. - a_out) # sigmoid derivative
        # output dim: [n_examples, n_classes]
        delta_out = d_loss__d_a_out * d_a_out__d_z_out
        # gradient for output weights

        # [n_examples, n_hidden]
        d_z_out__dw_out = a_h

        # input dim: [n_classes, n_examples]
        #           dot [n_examples, n_hidden]
        # output dim: [n_classes, n_hidden]
        d_loss__dw_out = np.dot(delta_out.T, d_z_out__dw_out)
        d_loss__db_out = np.sum(delta_out, axis=0)

        #################################
        # Part 2: dLoss/dHiddenWeights
        ## = DeltaOut * dOutNet/dHiddenAct * dHiddenAct/dHiddenNet
        #    * dHiddenNet/dWeight

        # [n_classes, n_hidden]
        d_z_out__a_h = self.weight_out

        # output dim: [n_examples, n_hidden]
        d_loss__a_h = np.dot(delta_out, d_z_out__a_h)

        # [n_examples, n_hidden]
        d_a_h__d_z_h = a_h * (1\. - a_h) # sigmoid derivative

        # [n_examples, n_features]
        d_z_h__d_w_h = x

        # output dim: [n_hidden, n_features]
        d_loss__d_w_h = np.dot((d_loss__a_h * d_a_h__d_z_h).T,
                                d_z_h__d_w_h)
        d_loss__d_b_h = np.sum((d_loss__a_h * d_a_h__d_z_h), axis=0)
        return (d_loss__dw_out, d_loss__db_out,
                d_loss__d_w_h, d_loss__d_b_h) 

backward 方法实现了所谓的反向传播算法,计算损失相对于权重和偏置参数的梯度。与 Adaline 类似,这些梯度然后用于通过梯度下降更新这些参数。请注意,多层神经网络比它们的单层兄弟更复杂,我们将在后面的部分讨论代码后讨论如何计算梯度的数学概念。现在,只需将 backward 方法视为计算梯度以用于梯度下降更新的一种方法。为简单起见,此推导基于的损失函数与 Adaline 中使用的相同的 MSE 损失函数相同。在后续章节中,我们将看到替代损失函数,例如多类别交叉熵损失,它是二元逻辑回归损失向多个类别的泛化。

查看 NeuralNetMLP 类的此代码实现,您可能已经注意到,这种面向对象的实现与围绕 .fit().predict() 方法为中心的熟悉 scikit-learn API 有所不同。相反,NeuralNetMLP 类的主要方法是 .forward().backward() 方法。其背后的一个原因是,这样做可以使复杂的神经网络在信息流通过网络方面更容易理解一些。

另一个原因是,这种实现与诸如 PyTorch 等更高级深度学习库的运行方式相对类似,我们将在接下来的章节中介绍并使用这些库来实现更复杂的神经网络。

在我们实现了 NeuralNetMLP 类之后,我们使用以下代码来实例化一个新的 NeuralNetMLP 对象:

>>> model = NeuralNetMLP(num_features=28*28,
...                      num_hidden=50,
...                      num_classes=10) 

model接受将 MNIST 图像重塑为 784 维向量(格式为X_trainX_validX_test,我们之前定义过)的输入,用于 10 个整数类(数字 0-9)。隐藏层由 50 个节点组成。另外,如您可以从先前定义的.forward()方法中看到的那样,我们在第一个隐藏层和输出层之后使用了 sigmoid 激活函数,以保持简单。在后面的章节中,我们将学习关于隐藏层和输出层的替代激活函数。

图 11.6 总结了我们上面实例化的神经网络架构:

自动生成的图表描述

图 11.6:用于标记手写数字的 NN 架构

在下一小节中,我们将实现训练函数,通过反向传播在小批量数据上训练网络。

编写神经网络训练循环

现在我们已经在前一小节中实现了NeuralNetMLP类并初始化了一个模型,下一步是训练模型。我们将分步骤完成此过程。首先,我们将为数据加载定义一些辅助函数。其次,我们将这些函数嵌入到遍历多个时期的训练循环中。

我们要定义的第一个函数是小批量生成器,它接受我们的数据集并将其分成用于随机梯度下降训练的所需大小的小批量。代码如下:

>>> import numpy as np
>>> num_epochs = 50
>>> minibatch_size = 100
>>> def minibatch_generator(X, y, minibatch_size):
...     indices = np.arange(X.shape[0])
...     np.random.shuffle(indices)
...     for start_idx in range(0, indices.shape[0] - minibatch_size
...                            + 1, minibatch_size):
...         batch_idx = indices[start_idx:start_idx + minibatch_size]
...         yield X[batch_idx], y[batch_idx] 

在我们继续下一个函数之前,让我们确认小批量生成器按预期工作,并生成所需大小的小批量。以下代码将尝试遍历数据集,然后我们将打印小批量的维度。请注意,在以下代码示例中,我们将删除break语句。代码如下:

>>> # iterate over training epochs
>>> for i in range(num_epochs):
...     # iterate over minibatches
...     minibatch_gen = minibatch_generator(
...         X_train, y_train, minibatch_size)
...     for X_train_mini, y_train_mini in minibatch_gen:
...         break
...     break
>>> print(X_train_mini.shape)
(100, 784)
>>> print(y_train_mini.shape)
(100,) 

正如我们所看到的,网络按预期返回大小为 100 的小批量。

接下来,我们必须定义损失函数和性能度量,以便监控训练过程并评估模型。可以实现 MSE 损失和准确率函数如下:

>>> def mse_loss(targets, probas, num_labels=10):
...     onehot_targets = int_to_onehot(
...         targets, num_labels=num_labels
...     )
...     return np.mean((onehot_targets - probas)**2)
>>> def accuracy(targets, predicted_labels):
...     return np.mean(predicted_labels == targets) 

让我们测试前述函数并计算我们在上一节中实例化的模型的初始验证集 MSE 和准确率:

>>> _, probas = model.forward(X_valid)
>>> mse = mse_loss(y_valid, probas)
>>> print(f'Initial validation MSE: {mse:.1f}')
Initial validation MSE: 0.3
>>> predicted_labels = np.argmax(probas, axis=1)
>>> acc = accuracy(y_valid, predicted_labels)
>>> print(f'Initial validation accuracy: {acc*100:.1f}%')
Initial validation accuracy: 9.4% 

在此代码示例中,请注意model.forward()返回隐藏层和输出层的激活。请记住,我们有 10 个输出节点(每个对应一个唯一的类标签)。因此,在计算 MSE 时,我们首先在mse_loss()函数中将类标签转换为独热编码的类标签。在实践中,首先对平方差矩阵的行或列求平均值没有区别,因此我们只需调用np.mean()而不指定任何轴,这样它将返回一个标量。

由于我们使用了逻辑 sigmoid 函数,输出层的激活值处于 [0, 1] 范围内。对于每个输入,输出层产生的是在 [0, 1] 范围内的 10 个值,因此我们使用了 np.argmax() 函数来选择最大值的索引位置,这个索引位置即预测的类别标签。然后,我们将真实标签与预测的类别标签进行比较,通过我们定义的 accuracy() 函数来计算准确率。从前面的输出可以看出,准确率并不是很高。然而,考虑到我们有一个包含 10 个类别的平衡数据集,一个未经训练的模型产生随机预测的情况下,大约 10% 的预测准确率是可以预期的。

使用前面的代码,例如,如果我们将 y_train 提供为目标的输入,并将模型输入 X_train 进行预测,我们可以计算整个训练集的性能。然而,在实践中,由于计算机内存通常限制了模型一次正向传递可以接收多少数据(由于大矩阵乘法),因此我们根据我们之前的小批量生成器定义了我们的 MSE 和准确率计算。以下函数将逐个小批量地迭代整个数据集来更加高效地使用内存计算 MSE 和准确率:

>>> def compute_mse_and_acc(nnet, X, y, num_labels=10,
...                         minibatch_size=100):
...     mse, correct_pred, num_examples = 0., 0, 0
...     minibatch_gen = minibatch_generator(X, y, minibatch_size)
...     for i, (features, targets) in enumerate(minibatch_gen):
...         _, probas = nnet.forward(features)
...         predicted_labels = np.argmax(probas, axis=1)
...         onehot_targets = int_to_onehot(
...             targets, num_labels=num_labels
...         )
...         loss = np.mean((onehot_targets - probas)**2)
...         correct_pred += (predicted_labels == targets).sum()
...         num_examples += targets.shape[0]
...         mse += loss
...     mse = mse/i
...     acc = correct_pred/num_examples
...     return mse, acc 

在我们实现训练循环之前,让我们测试这个函数,并计算前面部分中实例化的模型的初始训练集均方误差(MSE)和准确率,并确保其按预期工作:

>>> mse, acc = compute_mse_and_acc(model, X_valid, y_valid)
>>> print(f'Initial valid MSE: {mse:.1f}')
Initial valid MSE: 0.3
>>> print(f'Initial valid accuracy: {acc*100:.1f}%')
Initial valid accuracy: 9.4% 

从结果中可以看出,我们的生成器方法产生了与先前定义的 MSE 和准确率函数相同的结果,除了 MSE 中的小舍入误差(0.27 对比 0.28),对我们的目的来说可以忽略不计。

现在让我们来到主要部分,实现训练我们的模型的代码:

>>> def train(model, X_train, y_train, X_valid, y_valid, num_epochs,
...           learning_rate=0.1):
...     epoch_loss = []
...     epoch_train_acc = []
...     epoch_valid_acc = []
...
...     for e in range(num_epochs):
...         # iterate over minibatches
...         minibatch_gen = minibatch_generator(
...             X_train, y_train, minibatch_size)
...         for X_train_mini, y_train_mini in minibatch_gen:
...             #### Compute outputs ####
...             a_h, a_out = model.forward(X_train_mini)
...             #### Compute gradients ####
...             d_loss__d_w_out, d_loss__d_b_out, \
...             d_loss__d_w_h, d_loss__d_b_h = \
...                 model.backward(X_train_mini, a_h, a_out,
...                                y_train_mini)
...
...             #### Update weights ####
...             model.weight_h -= learning_rate * d_loss__d_w_h
...             model.bias_h -= learning_rate * d_loss__d_b_h
...             model.weight_out -= learning_rate * d_loss__d_w_out
...             model.bias_out -= learning_rate * d_loss__d_b_out
...         
...         #### Epoch Logging ####
...         train_mse, train_acc = compute_mse_and_acc(
...             model, X_train, y_train
...         )
...         valid_mse, valid_acc = compute_mse_and_acc(
...             model, X_valid, y_valid
...         )
...         train_acc, valid_acc = train_acc*100, valid_acc*100
...         epoch_train_acc.append(train_acc)
...         epoch_valid_acc.append(valid_acc)
...         epoch_loss.append(train_mse)
...         print(f'Epoch: {e+1:03d}/{num_epochs:03d} '
...               f'| Train MSE: {train_mse:.2f} '
...               f'| Train Acc: {train_acc:.2f}% '
...               f'| Valid Acc: {valid_acc:.2f}%')
...
...     return epoch_loss, epoch_train_acc, epoch_valid_acc 

从高层次上来看,train() 函数迭代多个 epoch,在每个 epoch 中,它使用之前定义的 minibatch_generator() 函数以小批量进行随机梯度下降训练整个训练集。在小批量生成器的 for 循环内部,我们通过模型的 .forward() 方法获取模型的输出 a_ha_out。然后,我们通过模型的 .backward() 方法计算损失梯度——这个理论将在后面的部分中解释。利用损失梯度,我们通过学习率乘以负梯度来更新权重。这与我们之前为 Adaline 讨论的概念相同。例如,要更新隐藏层的模型权重,我们定义了以下行:

model.weight_h -= learning_rate * d_loss__d_w_h 

对于单个权重 w[j],这对应于以下基于偏导数的更新:

最后,前面代码的最后部分计算了训练集和测试集上的损失和预测准确率,以跟踪训练进展。

现在让我们执行此函数,训练我们的模型 50 个时期,可能需要几分钟才能完成:

>>> np.random.seed(123) # for the training set shuffling
>>> epoch_loss, epoch_train_acc, epoch_valid_acc = train(
...     model, X_train, y_train, X_valid, y_valid,
...     num_epochs=50, learning_rate=0.1) 

在训练过程中,我们应该看到以下输出:

Epoch: 001/050 | Train MSE: 0.05 | Train Acc: 76.17% | Valid Acc: 76.02%
Epoch: 002/050 | Train MSE: 0.03 | Train Acc: 85.46% | Valid Acc: 84.94%
Epoch: 003/050 | Train MSE: 0.02 | Train Acc: 87.89% | Valid Acc: 87.64%
Epoch: 004/050 | Train MSE: 0.02 | Train Acc: 89.36% | Valid Acc: 89.38%
Epoch: 005/050 | Train MSE: 0.02 | Train Acc: 90.21% | Valid Acc: 90.16%
...
Epoch: 048/050 | Train MSE: 0.01 | Train Acc: 95.57% | Valid Acc: 94.58%
Epoch: 049/050 | Train MSE: 0.01 | Train Acc: 95.55% | Valid Acc: 94.54%
Epoch: 050/050 | Train MSE: 0.01 | Train Acc: 95.59% | Valid Acc: 94.74% 

打印所有这些输出的原因是,在神经网络训练中,比较训练和验证精度真的很有用。这有助于我们判断网络模型在给定架构和超参数情况下的表现是否良好。例如,如果我们观察到低训练和验证精度,则训练数据集可能存在问题,或者超参数设置不理想。

总的来说,训练(深度)神经网络相对于我们到目前为止讨论的其他模型来说成本相对较高。因此,在某些情况下,我们希望及早停止,并使用不同的超参数设置重新开始。另一方面,如果我们发现它越来越倾向于过拟合训练数据(通过训练和验证数据集性能之间逐渐增加的差距可察觉),我们也可能希望提前停止训练。

在下一小节中,我们将更详细地讨论我们的神经网络模型的性能。

评估神经网络性能

在我们在下一节更详细讨论神经网络的反向传播(NNs)训练过程之前,让我们先看看我们在前一小节中训练的模型的性能。

train()中,我们收集了每个时期的训练损失以及训练和验证精度,以便可以使用 Matplotlib 可视化结果。让我们先看一下训练 MSE 损失:

>>> plt.plot(range(len(epoch_loss)), epoch_loss)
>>> plt.ylabel('Mean squared error')
>>> plt.xlabel('Epoch')
>>> plt.show() 

前述代码绘制了 50 个时期内的损失,如图 11.7所示:

形状,正方形 由自动生成的描述

图 11.7:MSE 与训练时期数量的图表

如我们所见,在前 10 个时期内损失大幅减少,并在后 10 个时期内缓慢收敛。然而,在第 40 到第 50 个时期之间的小斜率表明,随着额外的时期训练,损失还将进一步减少。

接下来,让我们来看看训练和验证精度:

>>> plt.plot(range(len(epoch_train_acc)), epoch_train_acc,
...          label='Training')
>>> plt.plot(range(len(epoch_valid_acc)), epoch_valid_acc,
...          label='Validation')
>>> plt.ylabel('Accuracy')
>>> plt.xlabel('Epochs')
>>> plt.legend(loc='lower right')
>>> plt.show() 

前述代码示例绘制了这些准确率值在 50 个训练时期内的情况,如图 11.8所示:

包含图形用户界面的图片 由自动生成的描述

图 11.8:训练时期分类准确率

绘图显示,随着训练的进行,训练和验证精度之间的差距逐渐增大。在大约第 25 个时期,训练和验证精度几乎相等,然后网络开始略微过拟合训练数据。

减少过拟合

减少过拟合效果的一种方法是通过 L2 正则化增加正则化强度,我们在 第三章使用 Scikit-Learn 进行机器学习分类器的简介 中介绍过。在 NN 中应对过拟合的另一种有用技术是 dropout,在 第十四章使用深度卷积神经网络对图像进行分类 中将进行介绍。

最后,让我们通过计算在测试数据集上的预测准确率来评估模型的泛化性能:

>>> test_mse, test_acc = compute_mse_and_acc(model, X_test, y_test)
>>> print(f'Test accuracy: {test_acc*100:.2f}%')
Test accuracy: 94.51% 

我们可以看到测试准确率非常接近验证集准确率,对应于最后一个 epoch(94.74%),我们在上一个小节的训练中报告过。此外,相应的训练准确率仅略高于 95.59%,再次确认我们的模型仅轻微过拟合训练数据。

要进一步微调模型,我们可以改变隐藏单元的数量、学习率,或者使用多年来开发的其他各种技巧,但这超出了本书的范围。在 第十四章使用深度卷积神经网络对图像进行分类,您将了解到一种不同的 NN 架构,以其在图像数据集上的良好性能而闻名。

此外,本章还将介绍其他增强性能的技巧,如自适应学习率、更复杂的基于 SGD 的优化算法、批归一化和 dropout。

其他常见的技巧超出了以下章节的范围:

  • 添加跳跃连接,这是残差神经网络的主要贡献(深度残差学习用于图像识别,作者 K. He, X. Zhang, S. RenJ. SunIEEE 计算机视觉与模式识别会议论文集,2016 年,第 770-778 页)

  • 使用学习率调度程序,在训练过程中改变学习率(用于训练神经网络的循环学习率,作者 L.N. Smith2017 IEEE 冬季计算机视觉应用会议,2017 年,第 464-472 页)

  • 将损失函数附加到网络中较早的层中,就像在流行的 Inception v3 架构中所做的那样(重新思考 Inception 架构用于计算机视觉,作者 C. Szegedy, V. Vanhoucke, S. Ioffe, J. ShlensZ. WojnaIEEE 计算机视觉与模式识别会议论文集,2016 年,第 2818-2826 页)

最后,让我们看一些我们的多层感知器在测试集中提取和绘制的前 25 个错误分类样本的图片:

>>> X_test_subset = X_test[:1000, :]
>>> y_test_subset = y_test[:1000]
>>> _, probas = model.forward(X_test_subset)
>>> test_pred = np.argmax(probas, axis=1)
>>> misclassified_images = \
...      X_test_subset[y_test_subset != test_pred][:25]
>>> misclassified_labels = test_pred[y_test_subset != test_pred][:25]
>>> correct_labels = y_test_subset[y_test_subset != test_pred][:25]
>>> fig, ax = plt.subplots(nrows=5, ncols=5,
...                        sharex=True, sharey=True,
...                        figsize=(8, 8))
>>> ax = ax.flatten()
>>> for i in range(25):
...     img = misclassified_images[i].reshape(28, 28)
...     ax[i].imshow(img, cmap='Greys', interpolation='nearest')
...     ax[i].set_title(f'{i+1}) '
...                     f'True: {correct_labels[i]}\n'
...                     f' Predicted: {misclassified_labels[i]}')
>>> ax[0].set_xticks([])
>>> ax[0].set_yticks([])
>>> plt.tight_layout()
>>> plt.show() 

现在我们应该看到一个 5×5 的子图矩阵,其中副标题中的第一个数字表示图表索引,第二个数字代表真实类标签(True),第三个数字表示预测类标签(Predicted):

包含文本、电子设备、键盘的图片 描述已自动生成

图 11.9:模型无法正确分类的手写数字

正如我们在图 11.9中所看到的,网络在包含水平线的 7 时会感到挑战,例如第 19 和第 20 个例子。回顾本章早期的一个图中,我们绘制了数字 7 的不同训练示例,我们可以假设,带有水平线的手写数字 7 在我们的数据集中很少出现,并且经常被错误分类。

训练人工神经网络

现在我们已经看到了一个神经网络的运行,并通过查看代码获得了对其工作方式的基本理解,让我们深入挖掘一些概念,如损失计算和我们实现的反向传播算法,以学习模型参数。

计算损失函数

如前所述,我们使用了 MSE 损失(如 Adaline 中的损失)来训练多层 NN,因为这样做可以更容易地推导出梯度。在后续章节中,我们将讨论其他损失函数,如多类别交叉熵损失(二元逻辑回归损失的一般化),这是训练 NN 分类器更常见的选择。

在前面的部分,我们实现了一个用于多类分类的 MLP,它返回一个t元素的输出向量,我们需要将其与一个t×1 维的目标向量(使用独热编码表示)进行比较。如果我们使用这个 MLP 来预测一个输入图像的类标签为 2,则第三层的激活和目标可能如下所示:

因此,我们的 MSE 损失不仅必须在网络中的t个激活单元上求和或平均,还必须在数据集或小批量中的n个示例上进行平均:

在这里,再次提到,上标[i]是我们训练数据集中特定示例的索引。

请记住,我们的目标是最小化损失函数L(W),因此我们需要计算网络中每一层的参数W相对于每个权重的偏导数:

在接下来的部分中,我们将讨论反向传播算法,它允许我们计算这些偏导数以最小化损失函数。

请注意,W由多个矩阵组成。在一个具有一个隐藏层的 MLP 中,我们有连接输入到隐藏层的权重矩阵W(h),以及连接隐藏层到输出层的权重矩阵**W**((out))。三维张量W的可视化如图 11.10所示:

图示,工程图 自动生成的说明

图 11.10:三维张量的可视化

在这个简化的图中,似乎W(h)和**W**((out))的行数和列数相同,但通常情况下并非如此,除非我们初始化一个具有相同隐藏单元数、输出单元数和输入特征的 MLP。

如果这听起来让人困惑,那么请继续关注下一节内容,在那里我们将更详细地讨论在反向传播算法的背景下 W(h^) 和 W((out)^) 的维度问题。此外,你被鼓励再次阅读 NeuralNetMLP 的代码,其中有关不同矩阵和向量转换维度的注释。

发展你对反向传播的理解

虽然反向传播是 30 多年前被引入神经网络社区的(*由 D.E. Rumelhart、G.E. Hinton 和 R.J. Williams 所著,《自然》323: 6088, 页码 533–536, 1986),但它仍然是训练人工神经网络非常高效的最广泛使用的算法之一。如果你对反向传播的历史有兴趣,Juergen Schmidhuber 写了一篇很好的调查文章,《谁发明了反向传播?》,你可以在这里找到:people.idsia.ch/~juergen/who-invented-backpropagation.html

在我们深入更多数学细节之前,本节将提供一个简短而清晰的总结,并展示这一迷人算法的整体图景。本质上,我们可以将反向传播视为一种非常高效的方法,用于计算多层神经网络中复杂非凸损失函数的偏导数。在这里,我们的目标是利用这些导数来学习参数化这样的多层人工神经网络的权重系数。在神经网络参数化中的挑战是,我们通常处理的是高维特征空间中的大量模型参数。与单层神经网络如 Adaline 或 logistic 回归的损失函数不同,这些神经网络损失函数的误差表面对参数不是凸的或光滑的。在这种高维损失表面上有许多隆起(局部最小值),我们必须克服这些隆起,以找到损失函数的全局最小值。

你可能还记得在你的初级微积分课程中提到过链式法则的概念。链式法则是计算复杂的嵌套函数(例如 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 年,可以在 arXiv 上免费获取,网址为arxiv.org/pdf/1404.7456.pdf

自动微分有两种模式,前向模式和反向模式;反向传播只是反向模式自动微分的一个特例。关键点在于,在前向模式中应用链式法则可能非常昂贵,因为我们需要为每一层(雅可比矩阵)乘以一个大矩阵,最终将其乘以一个向量以获得输出。

反向模式的诀窍在于,我们从右到左遍历链式法则。我们将一个矩阵乘以一个向量,得到另一个乘以下一个矩阵的向量,依此类推。矩阵向量乘法在计算上比矩阵矩阵乘法便宜得多,这就是为什么反向传播是 NN 训练中最流行的算法之一。

基础微积分复习

要完全理解反向传播,我们需要借鉴微分学的某些概念,这超出了本书的范围。但是,您可以参考一些最基本概念的复习章节,这在这种情况下可能会对您有所帮助。它讨论了函数导数、偏导数、梯度和雅可比矩阵。这本文在sebastianraschka.com/pdf/books/dlb/appendix_d_calculus.pdf上免费获取。如果您对微积分不熟悉或需要简要复习,请在阅读下一节之前考虑阅读此文作为额外支持资源。

通过反向传播训练神经网络

在这一部分中,我们将通过反向传播的数学来理解如何高效学习神经网络中的权重。根据您对数学表示的熟悉程度,以下方程可能一开始看起来相对复杂。

在前面的章节中,我们看到了如何计算损失,即最后一层的激活与目标类别标签之间的差异。现在,我们将看看反向传播算法如何从数学角度上更新我们的 MLP 模型中的权重,我们在NeuralNetMLP()类的.backward()方法中实现了这一点。正如我们在本章开头所提到的,我们首先需要应用前向传播来获取输出层的激活,我们将其表述如下:

简而言之,我们只需将输入特征通过网络中的连接进行前向传播,如图 11.11中显示的箭头所示,用于具有两个输入特征、三个隐藏节点和两个输出节点的网络:

图表 说明自动生成

图 11.11:前向传播 NN 的输入特征

在反向传播中,我们从右到左传播误差。我们可以将这看作是链式法则应用于计算前向传递以计算损失相对于模型权重(和偏置单元)的梯度。为简单起见,我们将用于更新输出层权重矩阵中第一个权重的偏导数的此过程进行说明。我们反向传播的计算路径通过下面的粗体箭头突出显示:

图表 说明自动生成

图 11.12:反向传播 NN 的误差

如果我们明确包含净输入z,则在上一个图中显示的偏导数计算扩展如下:

要计算这个偏导数,用于更新,我们可以计算三个单独的偏导数项并将结果相乘。为简单起见,我们将省略在小批量中对各个示例的平均,因此从以下方程中删除的平均项。

让我们从开始,这是 MSE 损失的偏导数(如果我们省略小批量维度,则简化为平方误差)相对于第一个输出节点的预测输出分数:

下一个项是我们在输出层中使用的 logistic sigmoid 激活函数的导数:

最后,我们计算净输入相对于权重的导数:

将所有这些放在一起,我们得到以下内容:

然后,我们使用此值通过学习率为的熟悉随机梯度下降更新权重:

在我们的NeuralNetMLP()代码实现中,我们以向量化形式在.backward()方法中实现了的计算:

 # Part 1: dLoss/dOutWeights
        ## = dLoss/dOutAct * dOutAct/dOutNet * dOutNet/dOutWeight
        ## where DeltaOut = dLoss/dOutAct * dOutAct/dOutNet for convenient re-use

        # input/output dim: [n_examples, n_classes]
        d_loss__d_a_out = 2.*(a_out - y_onehot) / y.shape[0]
        # input/output dim: [n_examples, n_classes]
        d_a_out__d_z_out = a_out * (1\. - a_out) # sigmoid derivative
        # output dim: [n_examples, n_classes]
        delta_out = d_loss__d_a_out * d_a_out__d_z_out # "delta (rule)
                                                       # placeholder"
        # gradient for output weights

        # [n_examples, n_hidden]
        d_z_out__dw_out = a_h

        # input dim: [n_classes, n_examples] dot [n_examples, n_hidden]
        # output dim: [n_classes, n_hidden]
        d_loss__dw_out = np.dot(delta_out.T, d_z_out__dw_out)
        d_loss__db_out = np.sum(delta_out, axis=0) 
 the following “delta” placeholder variable:

这是因为在计算隐藏层权重的偏导数(或梯度)时涉及到项;因此,我们可以重复使用

谈到隐藏层权重,图 11.13说明了如何计算与隐藏层第一个权重相关的损失的偏导数:

包含文本、时钟的图片 说明自动生成

图 11.13:计算与第一个隐藏层权重相关的损失的偏导数

值得强调的是,由于权重 连接到两个输出节点,我们必须使用多变量链式法则来求和用粗箭头突出显示的两条路径。像以前一样,我们可以扩展它以包括净输入 z,然后解决各个术语:

请注意,如果我们重复使用先前计算的 ,则可以将此方程简化如下:

由于之前已经单独解决了前述术语,因此相对容易解决,因为没有涉及新的导数。例如,是 S 形激活函数的导数,即,等等。我们将留给您作为可选练习来解决各个部分。

关于神经网络中的收敛问题

也许你会想知道,为什么我们没有使用常规梯度下降,而是使用小批量学习来训练我们的手写数字分类 NN。你可能还记得我们使用的在线学习 SGD 的讨论。在在线学习中,我们每次基于单个训练示例(k = 1)计算梯度以执行权重更新。虽然这是一种随机方法,但通常比常规梯度下降快得多地收敛到非常准确的解决方案。小批量学习是 SGD 的一种特殊形式,在这种形式中,我们基于n个训练示例中的子集k计算梯度,其中 1 < k < n。小批量学习比在线学习的优势在于,我们可以利用矢量化实现来提高计算效率。然而,我们可以比常规梯度下降更新权重得更快。直觉上,你可以将小批量学习视为预测总统选举中选民投票率的一种方式,通过询问人口的代表性子集,而不是询问整个人口(这等同于进行实际选举)。

多层神经网络比诸如 Adaline、逻辑回归或支持向量机等简单算法难训练得多。在多层神经网络中,我们通常需要优化成百上千甚至数十亿个权重。不幸的是,输出函数的曲面粗糙,优化算法很容易陷入局部最小值,如图 11.14所示:

Diagram  Description automatically generated

图 11.14:优化算法可能陷入局部最小值

请注意,由于我们的神经网络具有多个维度,这种表示极为简化,使得人眼无法可视化实际的损失曲面。在这里,我们只展示了单个权重在x轴上的损失曲面。然而,主要信息是我们不希望我们的算法陷入局部最小值。通过增加学习率,我们可以更容易地逃离这种局部最小值。另一方面,如果学习率过大,也会增加超越全局最优解的风险。由于我们随机初始化权重,因此我们从根本上开始解决优化问题的解通常是完全错误的。

关于神经网络实现的最后几句话

您可能会想知道为什么我们要通过所有这些理论来实现一个简单的多层人工网络,而不是使用开源的 Python 机器学习库。实际上,在接下来的章节中,我们将介绍更复杂的神经网络模型,我们将使用开源的 PyTorch 库进行训练(pytorch.org)。

虽然本章中的从零开始实现起初看起来有些乏味,但对理解反向传播和神经网络训练背后的基础知识是一个很好的练习。对算法的基本理解对适当和成功地应用机器学习技术至关重要。

现在您已经了解了前馈神经网络的工作原理,我们准备使用 PyTorch 探索更复杂的深度神经网络,这使得我们可以更高效地构建神经网络,正如我们将在第十二章使用 PyTorch 并行化神经网络训练中看到的。

PyTorch 最初发布于 2016 年 9 月,已经在机器学习研究人员中广受欢迎,他们使用它构建深度神经网络,因为它能够优化在多维数组上计算的数学表达式,利用图形处理单元GPU)。

最后,我们应该注意,scikit-learn 还包括一个基本的 MLP 实现,MLPClassifier,您可以在scikit-learn.org/stable/modules/generated/sklearn.neural_network.MLPClassifier.html找到。虽然这种实现非常方便用于训练基本的 MLP,但我们强烈推荐使用专门的深度学习库,如 PyTorch,来实现和训练多层神经网络。

摘要

在本章中,你已经学习了多层人工神经网络背后的基本概念,这是当前机器学习研究中最热门的话题。在第二章中,训练简单的机器学习算法进行分类,我们从简单的单层神经网络结构开始我们的旅程,现在我们已经将多个神经元连接到一个强大的神经网络架构中,以解决如手写数字识别等复杂问题。我们揭开了流行的反向传播算法的神秘面纱,这是许多深度学习模型的基石之一。在本章学习了反向传播算法之后,我们已经准备好探索更复杂的深度神经网络架构。在接下来的章节中,我们将涵盖更高级的深度学习概念以及 PyTorch,这是一个开源库,可以更有效地实现和训练多层神经网络。

加入我们书籍的 Discord 空间

加入本书作者的 Discord 工作区,每月进行问我任何事会话:

packt.link/MLwPyTorch

第十二章:使用 PyTorch 并行化神经网络训练

在本章中,我们将从机器学习和深度学习的数学基础转向 PyTorch。PyTorch 是目前最流行的深度学习库之一,它让我们比以前的任何 NumPy 实现更高效地实现神经网络NNs)。在本章中,我们将开始使用 PyTorch,看看它如何显著提升训练性能。

本章将开始我们进入机器学习和深度学习的下一阶段的旅程,我们将探讨以下主题:

  • PyTorch 如何提升训练性能

  • 使用 PyTorch 的 DatasetDataLoader 构建输入管道,实现高效的模型训练

  • 使用 PyTorch 编写优化的机器学习代码

  • 使用 torch.nn 模块方便地实现常见的深度学习架构

  • 选择人工神经网络的激活函数

PyTorch 和训练性能

PyTorch 可以显著加速我们的机器学习任务。要理解它是如何做到这一点的,请让我们首先讨论我们在执行昂贵计算时通常遇到的一些性能挑战。然后,我们将从高层次来看 PyTorch 是什么,以及本章中我们的学习方法会是什么样的。

性能挑战

当然,计算机处理器的性能在近年来一直在不断提升。这使得我们能够训练更强大和复杂的学习系统,这意味着我们可以提高机器学习模型的预测性能。即使是现在最便宜的桌面计算机硬件也配备有具有多个核心的处理单元。

在前几章中,我们看到 scikit-learn 中的许多函数允许我们将计算分布到多个处理单元上。然而,默认情况下,由于全局解释器锁GIL),Python 只能在一个核心上执行。因此,尽管我们确实利用 Python 的多进程库将计算分布到多个核心上,但我们仍然必须考虑,即使是最先进的桌面硬件也很少配备超过 8 或 16 个这样的核心。

你会回忆起第十一章从头开始实现多层人工神经网络,我们实现了一个非常简单的多层感知器MLP),只有一个包含 100 个单元的隐藏层。我们必须优化大约 80,000 个权重参数([784*100 + 100] + [100 * 10] + 10 = 79,510)来进行一个非常简单的图像分类任务。MNIST 数据集中的图像相当小(28×28),如果我们想要添加额外的隐藏层或者处理像素密度更高的图像,我们可以想象参数数量的激增。这样的任务很快就会对单个处理单元变得不可行。因此问题变成了,我们如何更有效地解决这些问题?

这个问题的显而易见的解决方案是使用图形处理单元GPUs),它们是真正的工作马。你可以把显卡想象成你的机器内部的一个小型计算机集群。另一个优势是,与最先进的中央处理单元CPUs)相比,现代 GPU 性价比非常高,如下面的概述所示:

图 12.1:现代 CPU 和 GPU 的比较

图 12.1中信息的来源是以下网站(访问日期:2021 年 7 月):

以现代 CPU 的价格的 2.2 倍,我们可以获得一个 GPU,它拥有 640 倍的核心数,并且每秒可以进行大约 46 倍的浮点计算。那么,是什么阻碍了我们利用 GPU 来进行机器学习任务?挑战在于编写目标为 GPU 的代码并不像在解释器中执行 Python 代码那么简单。有一些特殊的包,比如 CUDA 和 OpenCL,可以让我们针对 GPU 进行编程。然而,用 CUDA 或 OpenCL 编写代码可能不是实现和运行机器学习算法的最方便的方式。好消息是,这正是 PyTorch 开发的目的!

什么是 PyTorch?

PyTorch 是一个可扩展且多平台的编程接口,用于实现和运行机器学习算法,包括深度学习的便捷包装器。PyTorch 主要由来自Facebook AI ResearchFAIR)实验室的研究人员和工程师开发。其开发还涉及来自社区的许多贡献。PyTorch 最初发布于 2016 年 9 月,以修改的 BSD 许可证免费开源。许多来自学术界和工业界的机器学习研究人员和从业者已经采用 PyTorch 来开发深度学习解决方案,例如 Tesla Autopilot、Uber 的 Pyro 和 Hugging Face 的 Transformers(pytorch.org/ecosystem/)。

为了提高训练机器学习模型的性能,PyTorch 允许在 CPU、GPU 和 XLA 设备(如 TPU)上执行。然而,当使用 GPU 和 XLA 设备时,PyTorch 具有最优的性能能力。PyTorch 官方支持 CUDA 启用和 ROCm GPU。PyTorch 的开发基于 Torch 库(www.torch.ch)。顾名思义,Python 接口是 PyTorch 的主要开发重点。

PyTorch 围绕着一个计算图构建,由一组节点组成。每个节点表示一个可能有零个或多个输入或输出的操作。PyTorch 提供了一种即时评估操作、执行计算并立即返回具体值的命令式编程环境。因此,PyTorch 中的计算图是隐式定义的,而不是事先构建并在执行之后执行。

从数学上讲,张量可以理解为标量、向量、矩阵等的一般化。更具体地说,标量可以定义为秩为 0 的张量,向量可以定义为秩为 1 的张量,矩阵可以定义为秩为 2 的张量,而在第三维堆叠的矩阵可以定义为秩为 3 的张量。PyTorch 中的张量类似于 NumPy 的数组,但张量经过了优化以进行自动微分并能在 GPU 上运行。

要更清晰地理解张量的概念,请参考图 12.2,该图展示了第一行中秩为 0 和 1 的张量,以及第二行中秩为 2 和 3 的张量:

图 12.2:PyTorch 中不同类型的张量

现在我们知道了 PyTorch 是什么,让我们看看如何使用它。

我们将如何学习 PyTorch

首先,我们将介绍 PyTorch 的编程模型,特别是如何创建和操作张量。然后,我们将看看如何加载数据并利用torch.utils.data模块,这将允许我们高效地迭代数据集。此外,我们将讨论torch.utils.data.Dataset子模块中现有的即用即得数据集,并学习如何使用它们。

在学习了这些基础知识后,PyTorch 神经网络模块 torch.nn 将被介绍。然后,我们将继续构建机器学习模型,学习如何组合和训练这些模型,并了解如何将训练好的模型保存在磁盘上以供未来评估使用。

PyTorch 的首次使用步骤

在本节中,我们将初步了解使用低级别的 PyTorch API。在安装 PyTorch 后,我们将介绍如何在 PyTorch 中创建张量以及不同的操作方法,例如更改它们的形状、数据类型等。

安装 PyTorch

要安装 PyTorch,建议参阅官方网站 pytorch.org 上的最新说明。以下是适用于大多数系统的基本步骤概述。

根据系统设置的不同,通常您只需使用 Python 的 pip 安装程序,并通过终端执行以下命令从 PyPI 安装 PyTorch:

pip install torch torchvision 

这将安装最新的 稳定 版本,在撰写时是 1.9.0。要安装 1.9.0 版本,该版本确保与以下代码示例兼容,您可以按照以下方式修改前述命令:

pip install torch==1.9.0 torchvision==0.10.0 

如果您希望使用 GPU(推荐),则需要一台兼容 CUDA 和 cuDNN 的 NVIDIA 显卡。如果您的计算机符合这些要求,您可以按照以下步骤安装支持 GPU 的 PyTorch:

pip install torch==1.9.0+cu111 torchvision==0.10.0+cu111 -f https://download.pytorch.org/whl/torch_stable.html 

适用于 CUDA 11.1 或:

pip install torch==1.9.0 torchvision==0.10.0\  -f https://download.pytorch.org/whl/torch_stable.html 

目前为止适用于 CUDA 10.2。

由于 macOS 二进制版本不支持 CUDA,您可以从源代码安装:pytorch.org/get-started/locally/#mac-from-source

关于安装和设置过程的更多信息,请参阅官方建议,网址为pytorch.org/get-started/locally/

请注意,PyTorch 处于活跃开发阶段,因此每隔几个月就会发布带有重大更改的新版本。您可以通过终端验证您的 PyTorch 版本,方法如下:

python -c 'import torch; print(torch.__version__)' 

解决 PyTorch 安装问题

如果您在安装过程中遇到问题,请阅读有关特定系统和平台的推荐信息,网址为pytorch.org/get-started/locally/。请注意,本章中的所有代码都可以在您的 CPU 上运行;使用 GPU 完全是可选的,但如果您想充分享受 PyTorch 的好处,则建议使用 GPU。例如,使用 CPU 训练某些神经网络模型可能需要一周时间,而在现代 GPU 上,同样的模型可能只需几小时。如果您有显卡,请参考安装页面适当设置。此外,您可能会发现这篇设置指南有用,其中解释了如何在 Ubuntu 上安装 NVIDIA 显卡驱动程序、CUDA 和 cuDNN(虽然不是运行 PyTorch 在 GPU 上所需的必备条件,但推荐要求):sebastianraschka.com/pdf/books/dlb/appendix_h_cloud-computing.pdf。此外,正如您将在第十七章中看到的,生成对抗网络用于合成新数据,您还可以免费使用 Google Colab 通过 GPU 训练您的模型。

在 PyTorch 中创建张量

现在,让我们考虑几种不同的方式来创建张量,然后看看它们的一些属性以及如何操作它们。首先,我们可以使用torch.tensortorch.from_numpy函数从列表或 NumPy 数组创建张量,如下所示:

>>> import torch
>>> import numpy as np
>>> np.set_printoptions(precision=3)
>>> a = [1, 2, 3]
>>> b = np.array([4, 5, 6], dtype=np.int32)
>>> t_a = torch.tensor(a)
>>> t_b = torch.from_numpy(b)
>>> print(t_a)
>>> print(t_b)
tensor([1, 2, 3])
tensor([4, 5, 6], dtype=torch.int32) 

这导致了张量t_at_b,它们的属性为,shape=(3,)dtype=int32,这些属性是从它们的源头继承而来。与 NumPy 数组类似,我们也可以看到这些属性:

>>> t_ones = torch.ones(2, 3)
>>> t_ones.shape
torch.Size([2, 3])
>>> print(t_ones)
tensor([[1., 1., 1.],
        [1., 1., 1.]]) 

最后,可以如下方式创建随机值张量:

>>> rand_tensor = torch.rand(2,3)
>>> print(rand_tensor)
tensor([[0.1409, 0.2848, 0.8914],
        [0.9223, 0.2924, 0.7889]]) 

操作张量的数据类型和形状

学习如何操作张量以使它们适合模型或操作的输入是必要的。在本节中,您将通过几个 PyTorch 函数学习如何通过类型转换、重塑、转置和挤压(去除维度)来操作张量的数据类型和形状。

torch.to()函数可用于将张量的数据类型更改为所需类型:

>>> t_a_new = t_a.to(torch.int64)
>>> print(t_a_new.dtype)
torch.int64 

请查看pytorch.org/docs/stable/tensor_attributes.html获取所有其他数据类型的信息。

正如您将在接下来的章节中看到的,某些操作要求输入张量具有特定数量的维度(即秩),并与一定数量的元素(形状)相关联。因此,我们可能需要改变张量的形状,添加一个新维度或挤压一个不必要的维度。PyTorch 提供了一些有用的函数(或操作)来实现这一点,如torch.transpose()torch.reshape()torch.squeeze()。让我们看一些例子:

  • 转置张量:

    >>> t = torch.rand(3, 5)
    >>> t_tr = torch.transpose(t, 0, 1)
    >>> print(t.shape, ' --> ', t_tr.shape)
    torch.Size([3, 5])  -->  torch.Size([5, 3]) 
    
  • 重塑张量(例如,从 1D 向量到 2D 数组):

    >>> t = torch.zeros(30)
    >>> t_reshape = t.reshape(5, 6)
    >>> print(t_reshape.shape)
    torch.Size([5, 6]) 
    
  • 移除不必要的维度(即大小为 1 的维度):

    >>> t = torch.zeros(1, 2, 1, 4, 1)
    >>> t_sqz = torch.squeeze(t, 2)
    >>> print(t.shape, ' --> ', t_sqz.shape)
    torch.Size([1, 2, 1, 4, 1])  -->  torch.Size([1, 2, 4, 1]) 
    

对张量应用数学操作

应用数学运算,特别是线性代数运算,是构建大多数机器学习模型所必需的。在这个子节中,我们将介绍一些广泛使用的线性代数操作,例如逐元素乘积、矩阵乘法和计算张量的范数。

首先,让我们实例化两个随机张量,一个具有在[–1, 1)范围内均匀分布的值,另一个具有标准正态分布:

>>> torch.manual_seed(1)
>>> t1 = 2 * torch.rand(5, 2) - 1
>>> t2 = torch.normal(mean=0, std=1, size=(5, 2)) 

注意,torch.rand返回一个填充有从[0, 1)范围内均匀分布的随机数的张量。

注意,t1t2具有相同的形状。现在,要计算t1t2的逐元素乘积,可以使用以下方法:

>>> t3 = torch.multiply(t1, t2)
>>> print(t3)
tensor([[ 0.4426, -0.3114], 
        [ 0.0660, -0.5970], 
        [ 1.1249,  0.0150], 
        [ 0.1569,  0.7107], 
        [-0.0451, -0.0352]]) 

要沿着某个轴(或轴)计算均值、总和和标准偏差,可以使用torch.mean()torch.sum()torch.std()。例如,可以如下计算t1中每列的均值:

>>> t4 = torch.mean(t1, axis=0)
>>> print(t4)
tensor([-0.1373,  0.2028]) 

使用torch.matmul()函数可以计算t1t2的矩阵乘积(即,,其中上标 T 表示转置):

>>> t5 = torch.matmul(t1, torch.transpose(t2, 0, 1))
>>> print(t5)
tensor([[ 0.1312,  0.3860, -0.6267, -1.0096, -0.2943],
        [ 0.1647, -0.5310,  0.2434,  0.8035,  0.1980],
        [-0.3855, -0.4422,  1.1399,  1.5558,  0.4781],
        [ 0.1822, -0.5771,  0.2585,  0.8676,  0.2132],
        [ 0.0330,  0.1084, -0.1692, -0.2771, -0.0804]]) 

另一方面,通过对t1进行转置来计算,结果是一个大小为 2×2 的数组:

>>> t6 = torch.matmul(torch.transpose(t1, 0, 1), t2)
>>> print(t6)
tensor([[ 1.7453,  0.3392],
        [-1.6038, -0.2180]]) 

最后,torch.linalg.norm()函数对于计算张量的L^p 范数非常有用。例如,我们可以如下计算t1L²范数:

>>> norm_t1 = torch.linalg.norm(t1, ord=2, dim=1)
>>> print(norm_t1)
tensor([0.6785, 0.5078, 1.1162, 0.5488, 0.1853]) 
L2 norm of t1 correctly, you can compare the results with the following NumPy function: np.sqrt(np.sum(np.square(t1.numpy()), axis=1)).

分割、堆叠和连接张量

在这个子节中,我们将介绍 PyTorch 操作,用于将一个张量分割成多个张量,或者反过来,将多个张量堆叠和连接成一个单独的张量。

假设我们有一个单一的张量,并且我们想将它分成两个或更多的张量。为此,PyTorch 提供了一个便捷的torch.chunk()函数,它将输入张量分割成等大小的张量列表。我们可以使用chunks参数作为整数来确定所需的分割数,以dim参数指定沿所需维度分割张量。在这种情况下,沿指定维度的输入张量的总大小必须是所需分割数的倍数。另外,我们可以使用torch.split()函数在列表中提供所需的大小。让我们看看这两个选项的示例:

  • 提供分割数量:

    >>> torch.manual_seed(1)
    >>> t = torch.rand(6)
    >>> print(t)
    tensor([0.7576, 0.2793, 0.4031, 0.7347, 0.0293, 0.7999])
    >>> t_splits = torch.chunk(t, 3)
    >>> [item.numpy() for item in t_splits]
    [array([0.758, 0.279], dtype=float32),
     array([0.403, 0.735], dtype=float32),
     array([0.029, 0.8  ], dtype=float32)] 
    

    在这个例子中,一个大小为 6 的张量被分割成了一个包含三个大小为 2 的张量的列表。如果张量大小不能被chunks值整除,则最后一个块将更小。

  • 提供不同分割的大小:

    或者,可以直接指定输出张量的大小,而不是定义分割的数量。在这里,我们将一个大小为5的张量分割为大小为32的张量:

    >>> torch.manual_seed(1)
    >>> t = torch.rand(5)
    >>> print(t)
    tensor([0.7576, 0.2793, 0.4031, 0.7347, 0.0293])
    >>> t_splits = torch.split(t, split_size_or_sections=[3, 2])
    >>> [item.numpy() for item in t_splits]
    [array([0.758, 0.279, 0.403], dtype=float32),
     array([0.735, 0.029], dtype=float32)] 
    

有时,我们需要处理多个张量,并需要将它们连接或堆叠以创建一个单一的张量。在这种情况下,PyTorch 的函数如torch.stack()torch.cat()非常方便。例如,让我们创建一个包含大小为3的 1D 张量A,其元素全为 1,并且一个包含大小为2的 1D 张量B,其元素全为 0,然后将它们连接成一个大小为5的 1D 张量C

>>> A = torch.ones(3)
>>> B = torch.zeros(2)
>>> C = torch.cat([A, B], axis=0)
>>> print(C)
tensor([1., 1., 1., 0., 0.]) 

如果我们创建了大小为3的 1D 张量AB,那么我们可以将它们堆叠在一起形成一个 2D 张量S

>>> A = torch.ones(3)
>>> B = torch.zeros(3)
>>> S = torch.stack([A, B], axis=1)
>>> print(S)
tensor([[1., 0.],
        [1., 0.],
        [1., 0.]]) 

PyTorch API 具有许多操作,您可以用它们来构建模型、处理数据等。然而,覆盖每个函数超出了本书的范围,我们将专注于最基本的那些。有关所有操作和函数的完整列表,请参阅 PyTorch 文档页面:pytorch.org/docs/stable/index.html

在 PyTorch 中构建输入流水线

当我们训练深度神经网络模型时,通常使用迭代优化算法(例如随机梯度下降)逐步训练模型,正如我们在前几章中所看到的。

正如本章开头所提到的,torch.nn是用于构建神经网络模型的模块。在训练数据集相当小并且可以作为张量直接加载到内存中的情况下,我们可以直接使用这个张量进行训练。然而,在典型的使用情况下,当数据集过大以至于无法完全装入计算机内存时,我们将需要以批次的方式从主存储设备(例如硬盘或固态硬盘)加载数据。此外,我们可能需要构建一个数据处理流水线,对数据应用某些转换和预处理步骤,如均值中心化、缩放或添加噪声,以增强训练过程并防止过拟合。

每次手动应用预处理函数可能会相当繁琐。幸运的是,PyTorch 提供了一个特殊的类来构建高效和方便的预处理流水线。在本节中,我们将看到构建 PyTorch DatasetDataLoader 的不同方法的概述,并实现数据加载、洗牌和分批处理。

从现有张量创建 PyTorch DataLoader

如果数据已经以张量对象、Python 列表或 NumPy 数组的形式存在,我们可以很容易地使用torch.utils.data.DataLoader()类创建数据集加载器。它返回一个DataLoader类的对象,我们可以用它来迭代输入数据集中的各个元素。作为一个简单的例子,考虑下面的代码,它从值为 0 到 5 的列表创建一个数据集:

>>> from torch.utils.data import DataLoader
>>> t = torch.arange(6, dtype=torch.float32)
>>> data_loader = DataLoader(t) 

我们可以轻松地逐个遍历数据集的条目,如下所示:

>>> for item in data_loader:
...     print(item)
tensor([0.])
tensor([1.])
tensor([2.])
tensor([3.])
tensor([4.])
tensor([5.]) 

如果我们希望从该数据集创建批次,批次大小为3,我们可以使用batch_size参数如下进行操作:

>>> data_loader = DataLoader(t, batch_size=3, drop_last=False)
>>> for i, batch in enumerate(data_loader, 1):
...    print(f'batch {i}:', batch)
batch 1: tensor([0., 1., 2.])
batch 2: tensor([3., 4., 5.]) 

这将从该数据集创建两个批次,其中前三个元素进入批次 #1,其余元素进入批次 #2。可选的drop_last参数在张量中的元素数不能被所需批次大小整除时非常有用。我们可以通过将drop_last设置为True来丢弃最后一个不完整的批次。drop_last的默认值为False

我们可以直接迭代数据集,但正如您刚看到的,DataLoader提供了对数据集的自动和可定制的批处理。

将两个张量合并为联合数据集

通常情况下,我们可能有两个(或更多)张量的数据。例如,我们可以有一个特征张量和一个标签张量。在这种情况下,我们需要构建一个结合这些张量的数据集,这将允许我们以元组形式检索这些张量的元素。

假设我们有两个张量,t_xt_y。张量t_x保存我们的特征值,每个大小为3,而t_y存储类标签。对于这个例子,我们首先创建这两个张量如下:

>>> torch.manual_seed(1)
>>> t_x = torch.rand([4, 3], dtype=torch.float32)
>>> t_y = torch.arange(4) 

现在,我们希望从这两个张量创建一个联合数据集。我们首先需要创建一个Dataset类,如下所示:

>>> from torch.utils.data import Dataset
>>> class JointDataset(Dataset):
...     def __init__(self, x, y):
...         self.x = x
...         self.y = y
...        
...     def __len__(self):
...         return len(self.x)
...
...     def __getitem__(self, idx):
...         return self.x[idx], self.y[idx] 

自定义的Dataset类必须包含以下方法,以便稍后由数据加载器使用:

  • __init__(): 这是初始逻辑发生的地方,例如读取现有数组、加载文件、过滤数据等。

  • __getitem__(): 这将返回给定索引的对应样本。

然后,我们使用自定义的Dataset类从t_xt_y创建一个联合数据集,如下所示:

>>> joint_dataset = JointDataset(t_x, t_y) 

最后,我们可以如下打印联合数据集的每个示例:

>>> for example in joint_dataset:
...     print('  x: ', example[0], '  y: ', example[1])
  x:  tensor([0.7576, 0.2793, 0.4031])   y:  tensor(0)
  x:  tensor([0.7347, 0.0293, 0.7999])   y:  tensor(1)
  x:  tensor([0.3971, 0.7544, 0.5695])   y:  tensor(2)
  x:  tensor([0.4388, 0.6387, 0.5247])   y:  tensor(3) 

如果第二个数据集是张量形式的带标签数据集,我们也可以简单地利用torch.utils.data.TensorDataset类。因此,我们可以如下创建一个联合数据集,而不是使用我们自定义的DatasetJointDataset

>>> joint_dataset = JointDataset(t_x, t_y) 

注意,一个常见的错误来源可能是原始特征(x)和标签(y)之间的逐元素对应关系可能会丢失(例如,如果两个数据集分别被洗牌)。然而,一旦它们合并成一个数据集,就可以安全地应用这些操作。

如果我们从磁盘上的图像文件名列表创建了数据集,我们可以定义一个函数来从这些文件名加载图像。您将在本章后面看到将多个转换应用于数据集的示例。

洗牌、批处理和重复

正如第二章中提到的,用于分类的简单机器学习算法的训练,在使用随机梯度下降优化训练 NN 模型时,重要的是以随机打乱的批次方式提供训练数据。您已经看到如何使用数据加载器对象的batch_size参数指定批次大小。现在,除了创建批次之外,您还将看到如何对数据集进行洗牌和重新迭代。我们将继续使用之前的联合数据集。

首先,让我们从joint_dataset数据集创建一个打乱顺序的数据加载器:

>>> torch.manual_seed(1) 
>>> data_loader = DataLoader(dataset=joint_dataset, batch_size=2, shuffle=True) 

在这里,每个批次包含两个数据记录(x)和相应的标签(y)。现在我们逐条通过数据加载器迭代数据入口如下:

>>> for i, batch in enumerate(data_loader, 1):
...     print(f'batch {i}:', 'x:', batch[0],
              '\n         y:', batch[1])
batch 1: x: tensor([[0.4388, 0.6387, 0.5247],
        [0.3971, 0.7544, 0.5695]]) 
         y: tensor([3, 2])
batch 2: x: tensor([[0.7576, 0.2793, 0.4031],
        [0.7347, 0.0293, 0.7999]]) 
         y: tensor([0, 1]) 

行被随机打乱,但不会丢失xy条目之间的一一对应关系。

此外,在训练模型多个 epochs 时,我们需要按所需的 epochs 数量对数据集进行洗牌和迭代。因此,让我们对批处理数据集进行两次迭代:

>>> for epoch in range(2): 
>>>     print(f'epoch {epoch+1}')
>>>     for i, batch in enumerate(data_loader, 1):
...         print(f'batch {i}:', 'x:', batch[0], 
                  '\n         y:', batch[1])
epoch 1
batch 1: x: tensor([[0.7347, 0.0293, 0.7999],
        [0.3971, 0.7544, 0.5695]]) 
         y: tensor([1, 2])
batch 2: x: tensor([[0.4388, 0.6387, 0.5247],
        [0.7576, 0.2793, 0.4031]]) 
         y: tensor([3, 0])
epoch 2
batch 1: x: tensor([[0.3971, 0.7544, 0.5695],
        [0.7576, 0.2793, 0.4031]]) 
         y: tensor([2, 0])
batch 2: x: tensor([[0.7347, 0.0293, 0.7999],
        [0.4388, 0.6387, 0.5247]]) 
         y: tensor([1, 3]) 

这导致了两组不同的批次。在第一个 epoch 中,第一批次包含一对值[y=1, y=2],第二批次包含一对值[y=3, y=0]。在第二个 epoch 中,两个批次分别包含一对值[y=2, y=0][y=1, y=3]。对于每次迭代,批次内的元素也被打乱了。

从本地存储磁盘上的文件创建数据集

在本节中,我们将从存储在磁盘上的图像文件构建数据集。本章的在线内容与一个图像文件夹相关联。下载文件夹后,您应该能够看到六张猫和狗的 JPEG 格式图像。

这个小数据集将展示如何从存储的文件中构建数据集。为此,我们将使用两个额外的模块:PIL中的Image来读取图像文件内容和torchvision中的transforms来解码原始内容并调整图像大小。

PIL.Imagetorchvision.transforms模块提供了许多额外和有用的函数,这超出了本书的范围。建议您浏览官方文档以了解更多有关这些函数的信息:

pillow.readthedocs.io/en/stable/reference/Image.html提供了关于PIL.Image的参考文档

pytorch.org/vision/stable/transforms.html提供了关于torchvision.transforms的参考文档

在我们开始之前,让我们看一下这些文件的内容。我们将使用pathlib库生成一个图像文件列表:

>>> import pathlib
>>> imgdir_path = pathlib.Path('cat_dog_images')
>>> file_list = sorted([str(path) for path in
... imgdir_path.glob('*.jpg')])
>>> print(file_list)
['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
>>> import os
>>> from PIL import Image
>>> fig = plt.figure(figsize=(10, 5))
>>> for i, file in enumerate(file_list):
...     img = Image.open(file)
...     print('Image shape:', np.array(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) 

图 12.3显示了示例图像:

图 12.3:猫和狗的图像

仅通过这个可视化和打印的图像形状,我们就能看到这些图像具有不同的长宽比。如果打印这些图像的长宽比(或数据数组形状),您会看到一些图像高 900 像素,宽 1200 像素(900×1200),一些是 800×1200,还有一个是 900×742。稍后,我们将把这些图像预处理到一个统一的尺寸。另一个需要考虑的问题是这些图像的标签是作为它们的文件名提供的。因此,我们从文件名列表中提取这些标签,将标签1分配给狗,标签0分配给猫:

>>> labels = [1 if 'dog' in 
...              os.path.basename(file) else 0
...                      for file in file_list]
>>> print(labels)
[0, 0, 0, 1, 1, 1] 

现在,我们有两个列表:一个是文件名列表(或每个图像的路径),另一个是它们的标签列表。在前一节中,您学习了如何从两个数组创建一个联合数据集。在这里,我们将执行以下操作:

>>> class ImageDataset(Dataset):
...     def __init__(self, file_list, labels):
...         self.file_list = file_list
...         self.labels = labels
... 
...     def __getitem__(self, index):
...         file = self.file_list[index]
...         label = self.labels[index]
...         return file, label
...
...     def __len__(self):
...         return len(self.labels)
>>> image_dataset = ImageDataset(file_list, labels)
>>> for file, label in image_dataset:
...     print(file, label)
cat_dog_images/cat-01.jpg 0
cat_dog_images/cat-02.jpg 0
cat_dog_images/cat-03.jpg 0
cat_dog_images/dog-01.jpg 1
cat_dog_images/dog-02.jpg 1
cat_dog_images/dog-03.jpg 1 

联合数据集具有文件名和标签。

接下来,我们需要对这个数据集应用转换:从文件路径加载图像内容,解码原始内容,并将其调整为所需尺寸,例如 80×120。如前所述,我们使用torchvision.transforms模块将图像调整大小并将加载的像素转换为张量,操作如下:

>>> import torchvision.transforms as transforms 
>>> img_height, img_width = 80, 120
>>> transform = transforms.Compose([
...     transforms.ToTensor(),
...     transforms.Resize((img_height, img_width)),
... ]) 

现在,我们使用刚定义的transform更新ImageDataset类:

>>> class ImageDataset(Dataset):
...     def __init__(self, file_list, labels, transform=None):
...         self.file_list = file_list
...         self.labels = labels
...         self.transform = transform
...
...     def __getitem__(self, index):
...         img = Image.open(self.file_list[index])
...         if self.transform is not None:
...             img = self.transform(img)
...         label = self.labels[index]
...         return img, label
...
...     def __len__(self):
...         return len(self.labels)
>>> 
>>> image_dataset = ImageDataset(file_list, labels, transform) 

最后,我们使用 Matplotlib 可视化这些转换后的图像示例:

>>> fig = plt.figure(figsize=(10, 6))
>>> for i, example in enumerate(image_dataset):
...     ax = fig.add_subplot(2, 3, i+1)
...     ax.set_xticks([]); ax.set_yticks([])
...     ax.imshow(example[0].numpy().transpose((1, 2, 0)))
...     ax.set_title(f'{example[1]}', size=15)
...
>>> plt.tight_layout()
>>> plt.show() 

这导致检索到的示例图像以及它们的标签的以下可视化:

图 12.4:图像带有标签

ImageDataset类中的__getitem__方法将所有四个步骤封装到一个函数中,包括加载原始内容(图像和标签),将图像解码为张量并调整图像大小。然后,该函数返回一个数据集,我们可以通过数据加载器迭代,并应用前面章节中学到的其他操作,如随机排列和分批处理。

torchvision.datasets库获取可用数据集

torchvision.datasets库提供了一组精美的免费图像数据集,用于训练或评估深度学习模型。类似地,torchtext.datasets库提供了用于自然语言的数据集。在这里,我们以torchvision.datasets为例。

torchvision数据集(pytorch.org/vision/stable/datasets.html)的格式很好,并带有信息性的描述,包括特征和标签的格式及其类型和维度,以及数据集的原始来源的链接。另一个优点是这些数据集都是torch.utils.data.Dataset的子类,因此我们在前面章节中涵盖的所有功能都可以直接使用。那么,让我们看看如何在实际中使用这些数据集。

首先,如果您之前没有与 PyTorch 一起安装torchvision,则需要从命令行使用pip安装torchvision库:

pip install torchvision 

您可以查看pytorch.org/vision/stable/datasets.html上的可用数据集列表。

在接下来的段落中,我们将介绍获取两个不同数据集的方法:CelebA (celeb_a)和 MNIST 数字数据集。

让我们首先使用 CelebA 数据集 (mmlab.ie.cuhk.edu.hk/projects/CelebA.html),使用torchvision.datasets.CelebA (pytorch.org/vision/stable/datasets.html#celeba)。torchvision.datasets.CelebA的描述提供了一些有用的信息,帮助我们理解这个数据集的结构:

  • 数据库有三个子集,分别是'train''valid''test'。我们可以通过split参数选择特定的子集或加载它们全部。

  • 图像以PIL.Image格式存储。我们可以使用自定义的transform函数获得变换后的版本,例如transforms.ToTensortransforms.Resize

  • 我们可以使用不同类型的目标,包括'attributes''identity''landmarks''attributes'是图像中人物的 40 个面部属性,例如面部表情、化妆、头发属性等;'identity'是图像的人物 ID;而'landmarks'指的是提取的面部点字典,如眼睛、鼻子等位置。

接下来,我们将调用torchvision.datasets.CelebA类来下载数据,将其存储在指定文件夹中,并将其加载到torch.utils.data.Dataset对象中:

>>> import torchvision 
>>> image_path = './' 
>>> celeba_dataset = torchvision.datasets.CelebA(
...     image_path, split='train', target_type='attr', download=True
... )
1443490838/? [01:28<00:00, 6730259.81it/s]
26721026/? [00:03<00:00, 8225581.57it/s]
3424458/? [00:00<00:00, 14141274.46it/s]
6082035/? [00:00<00:00, 21695906.49it/s]
12156055/? [00:00<00:00, 12002767.35it/s]
2836386/? [00:00<00:00, 3858079.93it/s] 

您可能会遇到BadZipFile: File is not a zip file错误,或者RuntimeError: The daily quota of the file img_align_celeba.zip is exceeded and it can't be downloaded. This is a limitation of Google Drive and can only be overcome by trying again later;这意味着 Google Drive 的每日下载配额已超过 CelebA 文件的大小限制。为了解决这个问题,您可以从源地址手动下载文件:mmlab.ie.cuhk.edu.hk/projects/CelebA.html。在下载的celeba/文件夹中,您可以解压img_align_celeba.zip文件。image_path是下载文件夹celeba/的根目录。如果您已经下载过文件一次,您可以简单地将download=False设置为True。如需更多信息和指导,请查看附带的代码笔记本:github.com/rasbt/machine-learning-book/blob/main/ch12/ch12_part1.ipynb

现在我们已经实例化了数据集,让我们检查对象是否是torch.utils.data.Dataset类:

>>> assert isinstance(celeba_dataset, torch.utils.data.Dataset) 

如前所述,数据集已经分为训练集、测试集和验证集,我们只加载训练集。我们只使用'attributes'目标。为了查看数据示例的外观,我们可以执行以下代码:

>>> example = next(iter(celeba_dataset))
>>> print(example)
(<PIL.JpegImagePlugin.JpegImageFile image mode=RGB size=178x218 at 0x120C6C668>, tensor([0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 1, 0, 1, 0, 1, 0, 0, 1])) 

请注意,此数据集中的样本以 (PIL.Image, attributes) 的元组形式出现。如果我们希望在训练过程中将此数据集传递给监督深度学习模型,我们必须将其重新格式化为 (features tensor, label) 的元组形式。例如,我们将使用属性中的 'Smiling' 类别作为标签,这是第 31 个元素。

最后,让我们从中获取前 18 个示例,以其 'Smiling' 标签可视化它们:

>>> from itertools import islice
>>> fig = plt.figure(figsize=(12, 8))
>>> for i, (image, attributes) in islice(enumerate(celeba_dataset), 18):
...     ax = fig.add_subplot(3, 6, i+1)
...     ax.set_xticks([]); ax.set_yticks([])
...     ax.imshow(image)
...     ax.set_title(f'{attributes[31]}', size=15)
>>> plt.show() 

celeba_dataset 中检索到的示例及其标签显示在 Figure 12.5 中:

一个人的拼贴  自动以低置信度生成的描述

Figure 12.5: 模型预测微笑名人

这就是我们需要做的一切,以获取并使用 CelebA 图像数据集。

接下来,我们将继续使用 torchvision.datasets.MNIST (pytorch.org/vision/stable/datasets.html#mnist) 中的第二个数据集。让我们看看如何使用它来获取 MNIST 手写数字数据集:

  • 数据库有两个分区,分别是 'train''test'。我们需要选择特定的子集进行加载。

  • 图像以 PIL.Image 格式存储。我们可以使用自定义的 transform 函数获取其转换版本,例如 transforms.ToTensortransforms.Resize

  • 目标有 10 个类别,从 09

现在,我们可以下载 'train' 分区,将元素转换为元组,并可视化 10 个示例:

>>> mnist_dataset = torchvision.datasets.MNIST(image_path, 'train', download=True)
>>> assert isinstance(mnist_dataset, torch.utils.data.Dataset)
>>> example = next(iter(mnist_dataset))
>>> print(example)
(<PIL.Image.Image image mode=L size=28x28 at 0x126895B00>, 5)
>>> fig = plt.figure(figsize=(15, 6))
>>> for i, (image, label) in  islice(enumerate(mnist_dataset), 10):
...     ax = fig.add_subplot(2, 5, i+1)
...     ax.set_xticks([]); ax.set_yticks([])
...     ax.imshow(image, cmap='gray_r')
...     ax.set_title(f'{label}', size=15)
>>> plt.show() 

从这个数据集中检索到的示例手写数字如下所示:

Figure 12.6: 正确识别手写数字

这完成了我们关于构建和操作数据集以及从torchvision.datasets库获取数据集的覆盖。接下来,我们将看到如何在 PyTorch 中构建 NN 模型。

在 PyTorch 中构建 NN 模型

在本章中,到目前为止,您已经了解了 PyTorch 的基本实用组件,用于操作张量并将数据组织成可以在训练期间迭代的格式。在本节中,我们将最终在 PyTorch 中实现我们的第一个预测模型。由于 PyTorch 比 scikit-learn 等机器学习库更加灵活但也更加复杂,我们将从一个简单的线性回归模型开始。

PyTorch 神经网络模块(torch.nn)

torch.nn 是一个设计优雅的模块,旨在帮助创建和训练神经网络。它允许在几行代码中轻松进行原型设计和构建复杂模型。

要充分利用该模块的功能,并为您的问题定制它,您需要理解它在做什么。为了发展这种理解,我们将首先在一个玩具数据集上训练一个基本的线性回归模型,而不使用任何来自 torch.nn 模块的特性;我们只会使用基本的 PyTorch 张量操作。

然后,我们将逐步添加来自torch.nntorch.optim的特性。正如您将在接下来的小节中看到的,这些模块使得构建 NN 模型变得极其简单。我们还将利用 PyTorch 中支持的数据集流水线功能,例如DatasetDataLoader,这些您在前一节已经了解过。在本书中,我们将使用torch.nn模块来构建 NN 模型。

在 PyTorch 中构建 NN 的最常用方法是通过nn.Module,它允许将层堆叠起来形成网络。这使我们能够更好地控制前向传播。我们将看到使用nn.Module类构建 NN 模型的示例。

最后,正如您将在接下来的小节中看到的,训练好的模型可以保存并重新加载以供将来使用。

构建线性回归模型

在这个小节中,我们将构建一个简单的模型来解决线性回归问题。首先,让我们在 NumPy 中创建一个玩具数据集并可视化它:

>>> X_train = np.arange(10, dtype='float32').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], dtype='float32')
>>> plt.plot(X_train, y_train, 'o', markersize=10)
>>> plt.xlabel('x')
>>> plt.ylabel('y')
>>> plt.show() 

因此,训练样本将如下显示在散点图中:

图 12.7:训练样本的散点图

接下来,我们将标准化特征(平均中心化和除以标准差),并为训练集创建一个 PyTorch 的Dataset及其相应的DataLoader

>>> from torch.utils.data import TensorDataset
>>> X_train_norm = (X_train - np.mean(X_train)) / np.std(X_train)
>>> X_train_norm = torch.from_numpy(X_train_norm)
>>> y_train = torch.from_numpy(y_train)
>>> train_ds = TensorDataset(X_train_norm, y_train)
>>> batch_size = 1
>>> train_dl = DataLoader(train_ds, batch_size, shuffle=True) 

在这里,我们为DataLoader设置了批大小为1

现在,我们可以定义我们的线性回归模型为 z = wx + b。在这里,我们将使用torch.nn模块。它提供了预定义的层用于构建复杂的 NN 模型,但首先,您将学习如何从头开始定义一个模型。在本章的后面,您将看到如何使用这些预定义层。

对于这个回归问题,我们将从头开始定义一个线性回归模型。我们将定义我们模型的参数,weightbias,它们分别对应于权重和偏置参数。最后,我们将定义model()函数来确定这个模型如何使用输入数据生成其输出:

>>> torch.manual_seed(1)
>>> weight = torch.randn(1)
>>> weight.requires_grad_()
>>> bias = torch.zeros(1, requires_grad=True)
>>> def model(xb):
...     return xb @ weight + bias 

定义模型之后,我们可以定义损失函数,以便找到最优模型权重。在这里,我们将选择均方误差MSE)作为我们的损失函数:

>>> def loss_fn(input, target):
...     return (input-target).pow(2).mean() 

此外,为了学习模型的权重参数,我们将使用随机梯度下降。在这个小节中,我们将通过自己实现随机梯度下降过程来训练,但在下一个小节中,我们将使用优化包torch.optim中的SGD方法来做同样的事情。

要实现随机梯度下降算法,我们需要计算梯度。与手动计算梯度不同,我们将使用 PyTorch 的torch.autograd.backward函数。我们将涵盖torch.autograd及其不同的类和函数,用于实现自动微分在第十三章深入探讨 - PyTorch 的机制

现在,我们可以设置学习率并训练模型进行 200 个 epochs。训练模型的代码如下,针对数据集的批处理版本:

>>> learning_rate = 0.001
>>> num_epochs = 200
>>> log_epochs = 10
>>> for epoch in range(num_epochs):
...     for x_batch, y_batch in train_dl:
...         pred = model(x_batch)
...         loss = loss_fn(pred, y_batch)
...         loss.backward()
...     with torch.no_grad():
...         weight -= weight.grad * learning_rate
...         bias -= bias.grad * learning_rate
...         weight.grad.zero_() 
...         bias.grad.zero_()   
...     if epoch % log_epochs==0:
...         print(f'Epoch {epoch}  Loss {loss.item():.4f}')
Epoch 0  Loss 5.1701
Epoch 10  Loss 30.3370
Epoch 20  Loss 26.9436
Epoch 30  Loss 0.9315
Epoch 40  Loss 3.5942
Epoch 50  Loss 5.8960
Epoch 60  Loss 3.7567
Epoch 70  Loss 1.5877
Epoch 80  Loss 0.6213
Epoch 90  Loss 1.5596
Epoch 100  Loss 0.2583
Epoch 110  Loss 0.6957
Epoch 120  Loss 0.2659
Epoch 130  Loss 0.1615
Epoch 140  Loss 0.6025
Epoch 150  Loss 0.0639
Epoch 160  Loss 0.1177
Epoch 170  Loss 0.3501
Epoch 180  Loss 0.3281
Epoch 190  Loss 0.0970 

让我们查看训练好的模型并绘制它。对于测试数据,我们将创建一个在 0 到 9 之间均匀分布的值的 NumPy 数组。由于我们训练模型时使用了标准化特征,我们还将在测试数据上应用相同的标准化:

>>> print('Final Parameters:', weight.item(), bias.item())
Final Parameters:  2.669806480407715 4.879569053649902
>>> X_test = np.linspace(0, 9, num=100, dtype='float32').reshape(-1, 1)
>>> X_test_norm = (X_test - np.mean(X_train)) / np.std(X_train)
>>> X_test_norm = torch.from_numpy(X_test_norm)
>>> y_pred = model(X_test_norm).detach().numpy()
>>> 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)
>>> plt.show() 

图 12.8显示了训练示例的散点图和训练的线性回归模型:

图 12.8:线性回归模型很好地拟合了数据

通过torch.nntorch.optim模块进行模型训练

在前面的例子中,我们看到如何通过编写自定义损失函数loss_fn()来训练模型,并应用随机梯度下降优化。然而,编写损失函数和梯度更新可能是在不同项目中重复的任务。torch.nn模块提供了一组损失函数,而torch.optim支持大多数常用的优化算法,可以根据计算出的梯度来更新参数。为了看看它们是如何工作的,让我们创建一个新的均方误差(MSE)损失函数和一个随机梯度下降优化器:

>>> import torch.nn as nn
>>> loss_fn = nn.MSELoss(reduction='mean')
>>> input_size = 1
>>> output_size = 1
>>> model = nn.Linear(input_size, output_size)
>>> optimizer = torch.optim.SGD(model.parameters(), lr=learning_rate) 

请注意,这里我们使用torch.nn.Linear类来代替手动定义线性层。

现在,我们可以简单地调用optimizerstep()方法来训练模型。我们可以传递一个批处理的数据集(例如前面例子中创建的train_dl):

>>> for epoch in range(num_epochs):
...     for x_batch, y_batch in train_dl:
...         # 1\. Generate predictions
...         pred = model(x_batch)[:, 0]
...         # 2\. Calculate loss
...         loss = loss_fn(pred, y_batch)
...         # 3\. Compute gradients
...         loss.backward()
...         # 4\. Update parameters using gradients
...         optimizer.step()
...         # 5\. Reset the gradients to zero
...         optimizer.zero_grad()    
...     if epoch % log_epochs==0:
...         print(f'Epoch {epoch}  Loss {loss.item():.4f}') 

模型训练完成后,可视化结果并确保它们与以前方法的结果相似。要获取权重和偏置参数,我们可以执行以下操作:

>>> print('Final Parameters:', model.weight.item(), model.bias.item())
Final Parameters: 2.646660089492798 4.883835315704346 

构建一个用于分类鸢尾花数据集中花朵的多层感知机

在前面的例子中,您看到了如何从头开始构建模型。我们使用随机梯度下降优化来训练这个模型。虽然我们从最简单的可能示例开始我们的旅程,但是你可以看到,即使对于这样一个简单的案例来说,从头定义模型也既不吸引人,也不是良好的实践。相反,PyTorch 通过torch.nn提供了已定义的层,可以直接用作 NN 模型的构建块。在本节中,您将学习如何使用这些层来解决使用鸢尾花数据集(识别三种鸢尾花的物种)的分类任务,并使用torch.nn模块构建一个两层感知机。首先,让我们从sklearn.datasets获取数据:

>>> from sklearn.datasets import load_iris
>>> from sklearn.model_selection import train_test_split 
>>> iris = load_iris()
>>> X = iris['data']
>>> y = iris['target']
>>> X_train, X_test, y_train, y_test = train_test_split(
...    X, y, test_size=1./3, random_state=1) 

在这里,我们随机选择了 100 个样本(2/3)用于训练,以及 50 个样本(1/3)用于测试。

接下来,我们对特征进行标准化(均值中心化并除以标准差),并为训练集创建一个 PyTorch Dataset及其相应的DataLoader

>>> X_train_norm = (X_train - np.mean(X_train)) / np.std(X_train)
>>> X_train_norm = torch.from_numpy(X_train_norm).float()
>>> y_train = torch.from_numpy(y_train) 
>>> train_ds = TensorDataset(X_train_norm, y_train)
>>> torch.manual_seed(1)
>>> batch_size = 2
>>> train_dl = DataLoader(train_ds, batch_size, shuffle=True) 

在这里,我们将DataLoader的批处理大小设置为2

现在,我们准备使用torch.nn模块来高效地构建模型。特别地,使用nn.Module类,我们可以堆叠几层并建立一个神经网络。您可以在pytorch.org/docs/stable/nn.html查看所有已经可用的层列表。对于这个问题,我们将使用Linear层,也被称为全连接层或密集层,可以最好地表示为f(w × x + b),其中x代表包含输入特征的张量,wb是权重矩阵和偏置向量,f是激活函数。

神经网络中的每一层都从前一层接收其输入,因此其维度(秩和形状)是固定的。通常,我们只需要在设计神经网络架构时关注输出的维度。在这里,我们希望定义一个具有两个隐藏层的模型。第一层接收四个特征的输入,并将它们投影到 16 个神经元。第二层接收前一层的输出(其大小为16),并将其投影到三个输出神经元,因为我们有三个类标签。可以通过以下方式实现:

>>> class Model(nn.Module):
...     def __init__(self, input_size, hidden_size, output_size):
...         super().__init__()
...         self.layer1 = nn.Linear(input_size, hidden_size)
...         self.layer2 = nn.Linear(hidden_size, output_size)
...     def forward(self, x):
...         x = self.layer1(x)
...         x = nn.Sigmoid()(x)
...         x = self.layer2(x)
...         x = nn.Softmax(dim=1)(x)
...         return x
>>> input_size = X_train_norm.shape[1]
>>> hidden_size = 16
>>> output_size = 3 
>>> model = Model(input_size, hidden_size, output_size) 

这里,我们在第一层使用了 sigmoid 激活函数,在最后(输出)层使用了 softmax 激活函数。由于我们这里有三个类标签,softmax 激活函数在最后一层用于支持多类分类(这也是为什么输出层有三个神经元)。我们将在本章后面讨论不同的激活函数及其应用。

接下来,我们将损失函数指定为交叉熵损失,并将优化器指定为 Adam:

Adam 优化器是一种强大的基于梯度的优化方法,我们将在第十四章《使用深度卷积神经网络对图像进行分类》中详细讨论。

>>> learning_rate = 0.001
>>> loss_fn = nn.CrossEntropyLoss()
>>> optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate) 

现在,我们可以训练模型了。我们将指定 epoch 数为100。训练花卉分类模型的代码如下:

>>> num_epochs = 100
>>> loss_hist = [0] * num_epochs
>>> accuracy_hist = [0] * num_epochs
>>> for epoch in range(num_epochs):
...     for x_batch, y_batch in train_dl:
...         pred = model(x_batch)
...         loss = loss_fn(pred, y_batch)
...         loss.backward()
...         optimizer.step()
...         optimizer.zero_grad()
...         loss_hist[epoch] += loss.item()*y_batch.size(0)
...         is_correct = (torch.argmax(pred, dim=1) == y_batch).float()
...         accuracy_hist[epoch] += is_correct.mean()
...      loss_hist[epoch] /= len(train_dl.dataset)
...      accuracy_hist[epoch] /= len(train_dl.dataset) 

loss_histaccuracy_hist列表保存了每个 epoch 后的训练损失和训练精度。我们可以使用这些来可视化学习曲线,如下所示:

>>> fig = plt.figure(figsize=(12, 5))
>>> ax = fig.add_subplot(1, 2, 1)
>>> ax.plot(loss_hist, 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(accuracy_hist, 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() 

学习曲线(训练损失和训练精度)如下:

图 12.9:训练损失和准确率曲线

在测试数据集上评估训练好的模型。

现在,我们可以评估训练好的模型在测试数据集上的分类准确率了:

>>> X_test_norm = (X_test - np.mean(X_train)) / np.std(X_train)
>>> X_test_norm = torch.from_numpy(X_test_norm).float()
>>> y_test = torch.from_numpy(y_test) 
>>> pred_test = model(X_test_norm)
>>> correct = (torch.argmax(pred_test, dim=1) == y_test).float()
>>> accuracy = correct.mean()
>>> print(f'Test Acc.: {accuracy:.4f}')
Test Acc.: 0.9800 

由于我们使用标准化特征训练了模型,我们也将相同的标准化应用于测试数据。分类准确率为 0.98(即 98%)。

保存和重新加载训练好的模型

训练好的模型可以保存在磁盘上供将来使用。可以通过以下方式实现:

>>> path = 'iris_classifier.pt'
>>> torch.save(model, path) 

调用save(model)会保存模型架构和所有学到的参数。按照一般惯例,我们可以使用'pt''pth'文件扩展名保存模型。

现在,让我们重新加载保存的模型。由于我们已经保存了模型的结构和权重,我们可以只用一行代码轻松重建和重新加载参数:

>>> model_new = torch.load(path) 

尝试通过调用model_new.eval()来验证模型结构:

>>> model_new.eval()
Model(
  (layer1): Linear(in_features=4, out_features=16, bias=True)
  (layer2): Linear(in_features=16, out_features=3, bias=True)
) 

最后,让我们在测试数据集上评估这个重新加载的新模型,以验证结果与之前是否相同:

>>> pred_test = model_new(X_test_norm)
>>> correct = (torch.argmax(pred_test, dim=1) == y_test).float()
>>> accuracy = correct.mean() 
>>> print(f'Test Acc.: {accuracy:.4f}')
Test Acc.: 0.9800 

如果你只想保存已学习的参数,可以像下面这样使用save(model.state_dict())

>>> path = 'iris_classifier_state.pt'
>>> torch.save(model.state_dict(), path) 

要重新加载保存的参数,我们首先需要像之前一样构建模型,然后将加载的参数提供给模型:

>>> model_new = Model(input_size, hidden_size, output_size)
>>> model_new.load_state_dict(torch.load(path)) 

选择多层神经网络的激活函数

简单起见,到目前为止我们只讨论了在多层前馈神经网络中使用的 S 型激活函数;我们在 MLP 实现的隐藏层和输出层都使用了它(第十一章)。

请注意,在本书中,逻辑函数,,因其简洁性常被称为sigmoid函数,在机器学习文献中很常见。在接下来的小节中,您将学习更多关于实现多层神经网络时有用的替代非线性函数的内容。

从技术上讲,我们可以在多层神经网络中使用任何可微函数作为激活函数。我们甚至可以使用线性激活函数,例如在 Adaline(第二章用于分类的简单机器学习算法)中。然而,在实践中,对于隐藏层和输出层都使用线性激活函数并不是很有用,因为我们想要在典型的人工神经网络中引入非线性,以便能够解决复杂问题。多个线性函数的总和毕竟会产生一个线性函数。

我们在第十一章中使用的逻辑(S 型)激活函数可能最接近大脑中神经元的概念——我们可以将其视为神经元是否触发的概率。然而,如果输入非常负,则逻辑(S 型)激活函数的输出会接近于零。如果逻辑函数返回接近于零的输出,在训练过程中神经网络将学习速度非常慢,并且更容易陷入损失地形的局部最小值中。这就是为什么人们通常更喜欢在隐藏层中使用双曲正切作为激活函数的原因。

在讨论双曲正切函数的外观之前,让我们简要回顾一下逻辑函数的一些基础知识,并查看一个使其在多标签分类问题中更有用的泛化。

逻辑函数回顾

正如在本节的介绍中提到的,逻辑函数实际上是 S 形函数的一种特殊情况。您可以从第三章《使用 Scikit-Learn 进行机器学习分类器之旅》中的逻辑回归部分回忆起,我们可以使用逻辑函数来建模样本 x 属于正类(类 1)的概率。

给定的净输入,z,如下方程所示:

逻辑(sigmoid)函数将计算如下:

注意 w[0] 是偏置单元(y-轴截距,这意味着 x[0] = 1)。为了提供一个更具体的示例,让我们来看一个二维数据点 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(f'P(y=1|x) = {logistic_activation(X, w):.3f}')
P(y=1|x) = 0.888 

如果我们计算净输入(z)并使用它来激活具有特定特征值和权重系数的逻辑神经元,则得到一个值为 0.888,我们可以将其解释为这个特定样本 x 属于正类的概率为 88.8%。

第十一章 中,我们使用一热编码技术来表示多类别的真实标签,并设计了包含多个逻辑激活单元的输出层。然而,正如以下代码示例所示,由多个逻辑激活单元组成的输出层并不产生有意义的可解释概率值:

>>> # 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:', y_class) 
Predicted class label: 0 

在某些情境中,计算多类预测的有意义的类别概率可能会有所帮助。在下一节中,我们将看看逻辑函数的一般化,即 softmax 函数,它可以帮助我们完成这项任务。

通过 softmax 函数估计多类分类中的类别概率

在上一节中,您看到我们如何使用 argmax 函数获得类标签。在构建用于在鸢尾花数据集中分类花卉的多层感知机部分中,我们确定在 MLP 模型的最后一层使用 activation='softmax'softmax 函数是 argmax 函数的一种软形式;它不仅提供单一类别索引,还提供每个类别的概率。因此,它允许我们在多类别设置(多项逻辑回归)中计算有意义的类别概率。

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函数的结果视为在多类别设置中获取有意义的类成员预测的归一化输出。因此,当我们在 PyTorch 中构建多类别分类模型时,我们可以使用torch.softmax()函数来估计每个类别成员的概率,以查看我们如何在下面的代码中使用torch.softmax()激活函数,我们将Z转换为一个张量,并额外保留一个维度用于批处理大小:

>>> torch.softmax(torch.from_numpy(Z), dim=0)
tensor([0.4467, 0.1611, 0.3922], dtype=torch.float64) 

使用双曲正切扩展输出光谱

在人工神经网络的隐藏层中经常使用的另一个 Sigmoid 函数是双曲正切(通常称为tanh),可以解释为逻辑函数的重新缩放版本:

双曲正切函数相比逻辑函数的优势在于其具有更广的输出光谱,范围在开区间(–1, 1),这可以提高反向传播算法的收敛性(《神经网络模式识别》C. M. Bishop牛津大学出版社,页码:500-501,1995)。

相比之下,逻辑函数返回一个在开区间(0, 1)内的输出信号。为了简单比较逻辑函数和双曲正切函数,让我们绘制这两个 Sigmoid 函数:

>>> 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() 

如您所见,两个 Sigmoid 曲线的形状看起来非常相似;然而,tanh函数的输出空间是logistic函数的两倍:

图 12.10:双曲正切和逻辑函数的比较

请注意,我们之前详细实现了逻辑和双曲正切函数,仅用于说明目的。实际上,我们可以使用 NumPy 的tanh函数。

或者,在构建一个 NN 模型时,我们可以在 PyTorch 中使用torch.tanh(x)来实现相同的结果:

>>> np.tanh(z)
array([-0.9999092 , -0.99990829, -0.99990737, ...,  0.99990644,
        0.99990737,  0.99990829])
>>> torch.tanh(torch.from_numpy(z))
tensor([-0.9999, -0.9999, -0.9999,  ...,  0.9999,  0.9999,  0.9999],
       dtype=torch.float64) 

此外,逻辑函数在 SciPy 的special模块中可用:

>>> from scipy.special import expit
>>> expit(z)
array([0.00669285, 0.00672617, 0.00675966, ..., 0.99320669, 0.99324034,
       0.99327383]) 

类似地,我们可以在 PyTorch 中使用torch.sigmoid()函数执行相同的计算,如下所示:

>>> torch.sigmoid(torch.from_numpy(z))
tensor([0.0067, 0.0067, 0.0068,  ..., 0.9932, 0.9932, 0.9933],
       dtype=torch.float64) 

请注意,使用torch.sigmoid(x)产生的结果等同于torch.nn.Sigmoid()(x),我们之前使用过。torch.nn.Sigmoid是一个类,您可以通过传递参数来构建一个对象以控制其行为。相比之下,torch.sigmoid是一个函数。

激活函数 ReLU(Rectified linear unit activation)

修正线性单元ReLU)是另一种经常在深度神经网络中使用的激活函数。在深入研究 ReLU 之前,我们应该退后一步,了解 tanh 和逻辑激活函数的梯度消失问题。

要理解这个问题,让我们假设我们最初有净输入 z[1] = 20,这变成 z[2] = 25. 计算双曲正切激活函数时,我们得到 ,显示输出没有变化(由于双曲正切函数的渐近行为和数值误差)。

这意味着激活函数对净输入的导数随着 z 变大而减小。因此,在训练阶段学习权重变得非常缓慢,因为梯度项可能非常接近零。ReLU 激活解决了这个问题。数学上,ReLU 定义如下:

ReLU 仍然是一个非线性函数,非常适合用于学习具有复杂功能的神经网络。除此之外,对 ReLU 的导数,关于其输入,对于正输入值始终为 1。因此,它解决了梯度消失的问题,使其非常适合深度神经网络。在 PyTorch 中,我们可以如下应用 ReLU 激活 torch.relu()

>>> torch.relu(torch.from_numpy(z))
tensor([0.0000, 0.0000, 0.0000,  ..., 4.9850, 4.9900, 4.9950],
       dtype=torch.float64) 

在下一章中,我们将作为多层卷积神经网络的激活函数使用 ReLU 激活函数。

现在我们对人工神经网络中常用的不同激活函数有了更多了解,让我们总结一下本书中迄今为止遇到的不同激活函数:

Table  Description automatically generated

图 12.11:本书涵盖的激活函数

您可以在 pytorch.org/docs/stable/nn.functional.html#non-linear-activation-functions 找到torch.nn 模块中所有可用的激活函数列表。

概要

在本章中,您学习了如何使用 PyTorch,一个用于数值计算的开源库,专注于深度学习。虽然 PyTorch 使用起来比 NumPy 更不方便,因为它增加了支持 GPU 的复杂性,但它允许我们定义和高效训练大型、多层次的神经网络。

此外,您学习了如何使用 torch.nn 模块来构建复杂的机器学习和神经网络模型,并高效地运行它们。我们通过基本的 PyTorch 张量功能从零开始定义了一个模型来探索模型构建。当我们必须在矩阵-向量乘法的水平上编程并定义每个操作的每个细节时,实现模型可能会很乏味。然而,优点在于这允许我们作为开发者结合这些基本操作并构建更复杂的模型。然后,我们探索了torch.nn,这使得构建神经网络模型比从头开始实现它们要容易得多。

最后,您了解了不同的激活函数,并理解了它们的行为和应用。特别是在本章中,我们涵盖了 tanh、softmax 和 ReLU。

在下一章中,我们将继续我们的旅程,并深入研究 PyTorch,我们将与 PyTorch 计算图和自动微分包一起工作。沿途您将学习许多新概念,如梯度计算。

加入我们书籍的 Discord 空间

加入本书的 Discord 工作区,每月举行一次问答环节,与作者亲密互动:

packt.link/MLwPyTorch

第十三章:深入探讨 – PyTorch 的机制

第十二章中,使用 PyTorch 并行化神经网络训练,我们讨论了如何定义和操作张量,并使用torch.utils.data模块构建输入管道。我们进一步构建并训练了一个多层感知器,使用 PyTorch 神经网络模块(torch.nn)对鸢尾花数据集进行分类。

现在我们已经有了一些关于 PyTorch 神经网络训练和机器学习的实践经验,是时候深入探索 PyTorch 库,并探索其丰富的功能集,这将使我们能够在即将到来的章节中实现更高级的深度学习模型。

在本章中,我们将使用 PyTorch API 的不同方面来实现神经网络。特别是,我们将再次使用torch.nn模块,它提供了多层抽象,使得实现标准架构非常方便。它还允许我们实现自定义神经网络层,这在需要更多定制的研究项目中非常有用。稍后在本章中,我们将实现这样一个自定义层。

为了说明使用torch.nn模块构建模型的不同方法,我们还将考虑经典的异或XOR)问题。首先,我们将使用Sequential类构建多层感知器。然后,我们将考虑其他方法,例如使用nn.Module子类化来定义自定义层。最后,我们将处理两个涵盖从原始输入到预测的机器学习步骤的真实项目。

我们将涵盖的主题如下:

  • 理解并操作 PyTorch 计算图

  • 使用 PyTorch 张量对象进行操作

  • 解决经典的 XOR 问题并理解模型容量

  • 使用 PyTorch 的Sequential类和nn.Module类构建复杂的神经网络模型

  • 使用自动微分和torch.autograd计算梯度

PyTorch 的关键特性

在前一章中,我们看到 PyTorch 为我们提供了一个可扩展的、跨平台的编程接口,用于实现和运行机器学习算法。在 2016 年的初始发布以及 2018 年的 1.0 版本发布之后,PyTorch 已经发展成为两个最受欢迎的深度学习框架之一。它使用动态计算图,相比静态计算图具有更大的灵活性优势。动态计算图易于调试:PyTorch 允许在图声明和图评估步骤之间交错执行代码。您可以逐行执行代码,同时完全访问所有变量。这是一个非常重要的功能,使得开发和训练神经网络非常方便。

虽然 PyTorch 是一个开源库,可以免费使用,但其开发是由 Facebook 提供资金和支持的。这涉及到一个大型的软件工程团队,他们不断扩展和改进这个库。由于 PyTorch 是一个开源库,它也得到了来自 Facebook 以外其他开发者的强大支持,他们积极贡献并提供用户反馈。这使得 PyTorch 库对学术研究人员和开发者都更加有用。由于这些因素的影响,PyTorch 拥有广泛的文档和教程,帮助新用户上手。

PyTorch 的另一个关键特性,也在前一章节中提到过的,是其能够与单个或多个图形处理单元GPU)一起工作。这使得用户能够在大型数据集和大规模系统上高效训练深度学习模型。

最后但同样重要的是,PyTorch 支持移动部署,这也使它成为生产环境中非常合适的工具。

在下一节中,我们将看到在 PyTorch 中张量和函数如何通过计算图相互连接。

PyTorch 的计算图

PyTorch 根据有向无环图DAG)执行其计算。在本节中,我们将看到如何为简单的算术计算定义这些图。然后,我们将看到动态图的范例,以及如何在 PyTorch 中动态创建图。

理解计算图

PyTorch 的核心是构建计算图,它依赖于这个计算图来推导从输入到输出的张量之间的关系。假设我们有秩为 0(标量)的张量 abc,我们想要评估 z = 2 × (a – b) + c

此评估可以表示为一个计算图,如图 13.1所示:

图 13.1:计算图的工作原理

正如您所见,计算图只是一个节点网络。每个节点类似于一个操作,它对其输入张量或张量应用函数,并根据需要返回零个或多个张量作为输出。PyTorch 构建这个计算图并使用它来相应地计算梯度。在下一小节中,我们将看到如何使用 PyTorch 为这种计算创建图的一些示例。

在 PyTorch 中创建图

让我们看一个简单的例子,说明如何在 PyTorch 中创建一个用于评估 z = 2 × (a – b) + c 的图,如前图所示。变量 abc 是标量(单个数字),我们将它们定义为 PyTorch 张量。为了创建图,我们可以简单地定义一个常规的 Python 函数,其输入参数为 abc,例如:

>>> import torch
>>> def compute_z(a, b, c):
...     r1 = torch.sub(a, b)
...     r2 = torch.mul(r1, 2)
...     z = torch.add(r2, c)
...     return z 

现在,为了执行计算,我们可以简单地将此函数与张量对象作为函数参数调用。请注意,PyTorch 函数如addsub(或subtract)、mul(或multiply)也允许我们以 PyTorch 张量对象的形式提供更高秩的输入。在以下代码示例中,我们提供了标量输入(秩 0),以及秩 1 和秩 2 的输入,作为列表:

>>> print('Scalar Inputs:', compute_z(torch.tensor(1),
...       torch.tensor(2), torch.tensor(3)))
Scalar Inputs: tensor(1)
>>> print('Rank 1 Inputs:', compute_z(torch.tensor([1]),
...       torch.tensor([2]), torch.tensor([3])))
Rank 1 Inputs: tensor([1])
>>> print('Rank 2 Inputs:', compute_z(torch.tensor([[1]]),
...       torch.tensor([[2]]), torch.tensor([[3]])))
Rank 2 Inputs: tensor([[1]]) 

在这一节中,你看到了在 PyTorch 中创建计算图是多么简单。接下来,我们将看看可以用来存储和更新模型参数的 PyTorch 张量。

PyTorch 张量对象用于存储和更新模型参数

我们在第十二章《使用 PyTorch 并行化神经网络训练》中介绍了张量对象。在 PyTorch 中,需要计算梯度的特殊张量对象允许我们在训练期间存储和更新模型的参数。这样的张量可以通过在用户指定的初始值上简单地将requires_grad赋值为True来创建。请注意,截至目前(2021 年中),只有浮点和复杂dtype的张量可以需要梯度。在以下代码中,我们将生成float32类型的张量对象:

>>> a = torch.tensor(3.14, requires_grad=True)
>>> print(a)
tensor(3.1400, requires_grad=True)
>>> b = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)
>>> print(b)
tensor([1., 2., 3.], requires_grad=True) 

注意,默认情况下requires_grad被设置为False。可以通过运行requires_grad_()有效地将其设置为True

method_()是 PyTorch 中的一个原地方法,用于执行操作而不复制输入。

让我们看一下以下例子:

>>> w = torch.tensor([1.0, 2.0, 3.0])
>>> print(w.requires_grad)
False
>>> w.requires_grad_()
>>> print(w.requires_grad)
True 

你会记得,对于 NN 模型,使用随机权重初始化模型参数是必要的,以打破反向传播过程中的对称性——否则,一个多层 NN 将不会比单层 NN(如逻辑回归)更有用。在创建 PyTorch 张量时,我们也可以使用随机初始化方案。PyTorch 可以基于各种概率分布生成随机数(参见pytorch.org/docs/stable/torch.html#random-sampling)。在以下例子中,我们将看一些标准的初始化方法,这些方法也可以在torch.nn.init模块中找到(参见pytorch.org/docs/stable/nn.init.html)。

因此,让我们看看如何使用 Glorot 初始化创建一个张量,这是一种经典的随机初始化方案,由 Xavier Glorot 和 Yoshua Bengio 提出。为此,我们首先创建一个空张量和一个名为init的操作符,作为GlorotNormal类的对象。然后,通过调用xavier_normal_()方法按照 Glorot 初始化填充这个张量的值。在下面的例子中,我们初始化一个形状为 2×3 的张量:

>>> import torch.nn as nn
>>> torch.manual_seed(1)
>>> w = torch.empty(2, 3)
>>> nn.init.xavier_normal_(w)
>>> print(w)
tensor([[ 0.4183,  0.1688,  0.0390],
        [ 0.3930, -0.2858, -0.1051]]) 

Xavier(或 Glorot)初始化

在深度学习的早期开发中观察到,随机均匀或随机正态的权重初始化通常会导致训练过程中模型表现不佳。

2010 年,Glorot 和 Bengio 调查了初始化的效果,并提出了一种新颖、更健壮的初始化方案,以促进深层网络的训练。Xavier 初始化背后的主要思想是大致平衡不同层次梯度的方差。否则,一些层可能在训练过程中受到过多关注,而其他层则滞后。

根据 Glorot 和 Bengio 的研究论文,如果我们想要在均匀分布中初始化权重,我们应该选择此均匀分布的区间如下:

在这里,n[in] 是与权重相乘的输入神经元的数量,n[out] 是输入到下一层的输出神经元的数量。对于从高斯(正态)分布初始化权重,我们建议您选择这个高斯分布的标准差为:

PyTorch 支持在权重的均匀分布和正态分布中进行Xavier 初始化

有关 Glorot 和 Bengio 初始化方案的更多信息,包括其背景和数学动机,我们建议查阅原始论文(理解深层前馈神经网络的难度Xavier GlorotYoshua Bengio,2010),可以在 proceedings.mlr.press/v9/glorot10a/glorot10a.pdf 免费获取。

现在,为了将其放入更实际的用例背景中,让我们看看如何在基础 nn.Module 类内定义两个 Tensor 对象:

>>> class MyModule(nn.Module):
...     def __init__(self):
...         super().__init__()
...         self.w1 = torch.empty(2, 3, requires_grad=True)
...         nn.init.xavier_normal_(self.w1)
...         self.w2 = torch.empty(1, 2, requires_grad=True)
...         nn.init.xavier_normal_(self.w2) 

然后可以将这两个张量用作权重,其梯度将通过自动微分计算。

通过自动微分计算梯度

正如您已经知道的那样,优化神经网络需要计算损失相对于神经网络权重的梯度。这对于优化算法如随机梯度下降SGD)是必需的。此外,梯度还有其他应用,比如诊断网络以找出为什么神经网络模型对测试示例做出特定预测。因此,在本节中,我们将涵盖如何计算计算的梯度对其输入变量的梯度。

计算损失相对于可训练变量的梯度

PyTorch 支持自动微分,可以将其视为计算嵌套函数梯度的链式规则的实现。请注意,出于简化的目的,我们将使用术语梯度来指代偏导数和梯度。

偏导数和梯度

部分导数 可以理解为多变量函数(具有多个输入 f(x[1], x[2], ...)相对于其输入之一(此处为 x[1])的变化率。函数的梯度,,是由所有输入的偏导数 组成的向量。

当我们定义一系列操作以产生某些输出甚至是中间张量时,PyTorch 提供了一个计算梯度的上下文,用于计算这些计算张量相对于其在计算图中依赖节点的梯度。要计算这些梯度,我们可以从torch.autograd模块调用backward方法。它计算给定张量相对于图中叶节点(终端节点)的梯度之和。

让我们来看一个简单的例子,我们将计算 z = wx + b 并定义损失为目标 y 和预测 z 之间的平方损失,Loss = (y - z)²。在更一般的情况下,我们可能有多个预测和目标,我们将损失定义为平方误差的总和,。为了在 PyTorch 中实现这个计算,我们将定义模型参数 wb 为变量(具有requires_gradient属性设置为True的张量),输入 xy 为默认张量。我们将计算损失张量并用它来计算模型参数 wb 的梯度,如下所示:

>>> w = torch.tensor(1.0, requires_grad=True)
>>> b = torch.tensor(0.5, requires_grad=True)
>>> x = torch.tensor([1.4])
>>> y = torch.tensor([2.1])
>>> z = torch.add(torch.mul(w, x), b)
>>> loss = (y-z).pow(2).sum()
>>> loss.backward()
>>> print('dL/dw : ', w.grad)
>>> print('dL/db : ', b.grad)
dL/dw :  tensor(-0.5600)
dL/db :  tensor(-0.4000) 

计算值z是 NN 中的前向传递。我们在loss张量上使用backward方法来计算 。由于这是一个非常简单的例子,我们可以通过符号方式获得 来验证计算得到的梯度与我们在先前的代码示例中得到的结果是否匹配:

>>> # verifying the computed gradient
>>> print(2 * x * ((w * x + b) - y))
tensor([-0.5600], grad_fn=<MulBackward0>) 

我们留下对b的验证作为读者的练习。

理解自动微分

自动微分表示一组用于计算任意算术操作梯度的计算技术。在这个过程中,通过重复应用链式法则来积累计算(表示为一系列操作)的梯度。为了更好地理解自动微分背后的概念,让我们考虑一系列嵌套计算,y = f(g(h(x))),其中 x 是输入,y 是输出。这可以分解为一系列步骤:

  • u[0] = x

  • u[1] = h(x)

  • u[2] = g(u[1])

  • u[3] = f(u[2]) = y

导数 可以通过两种不同的方式计算:前向累积,从 开始,以及反向累积,从 开始。请注意,PyTorch 使用后者,即反向累积,这对于实现反向传播更有效率。

对抗样本

计算损失相对于输入示例的梯度用于生成对抗样本(或对抗攻击)。在计算机视觉中,对抗样本是通过向输入示例添加一些微小且难以察觉的噪声(或扰动)生成的示例,导致深度神经网络误分类它们。涵盖对抗样本超出了本书的范围,但如果您感兴趣,可以在 arxiv.org/pdf/1312.6199.pdf 找到Christian Szegedy et al.的原始论文神经网络的有趣属性

通过 torch.nn 模块简化常见架构的实现

您已经看到了构建前馈 NN 模型(例如,多层感知器)和使用 nn.Module 类定义层序列的一些示例。在我们深入研究 nn.Module 之前,让我们简要了解另一种通过 nn.Sequential 配置这些层的方法。

基于 nn.Sequential 实现模型

使用 nn.Sequential (pytorch.org/docs/master/generated/torch.nn.Sequential.html#sequential),模型内部存储的层以级联方式连接。在下面的示例中,我们将构建一个具有两个全连接层的模型:

>>> model = nn.Sequential(
...     nn.Linear(4, 16),
...     nn.ReLU(),
...     nn.Linear(16, 32),
...     nn.ReLU()
... )
>>> model
Sequential(
  (0): Linear(in_features=4, out_features=16, bias=True)
  (1): ReLU()
  (2): Linear(in_features=16, out_features=32, bias=True)
  (3): ReLU()
) 

我们指定了层并在将这些层传递给 nn.Sequential 类后实例化了 model。第一个全连接层的输出作为第一个 ReLU 层的输入。第一个 ReLU 层的输出成为第二个全连接层的输入。最后,第二个全连接层的输出作为第二个 ReLU 层的输入。

我们可以通过应用不同的激活函数、初始化器或正则化方法来进一步配置这些层的参数。大多数这些类别的所有可用选项的详细和完整列表可以在官方文档中找到:

在以下代码示例中,我们将通过指定权重的初始值分布来配置第一个全连接层。然后,我们将通过计算权重矩阵的 L1 惩罚项来配置第二个全连接层:

>>> nn.init.xavier_uniform_(model[0].weight)
>>> l1_weight = 0.01
>>> l1_penalty = l1_weight * model[2].weight.abs().sum() 

在这里,我们使用 Xavier 初始化来初始化第一个线性层的权重。然后,我们计算了第二个线性层权重的 L1 范数。

此外,我们还可以指定训练的优化器类型和损失函数。再次,您可以在官方文档中找到所有可用选项的全面列表。

选择损失函数

关于优化算法的选择,SGD 和 Adam 是最常用的方法。损失函数的选择取决于任务;例如,对于回归问题,您可能会使用均方误差损失。

交叉熵损失函数系列提供了分类任务的可能选择,在第十四章使用深度卷积神经网络分类图像中广泛讨论。

此外,您可以结合适用于问题的适当指标,利用您从先前章节学到的技术(如第六章中用于模型评估和超参数调优的技术)。例如,精度和召回率、准确率、曲线下面积AUC)、假阴性和假阳性分数是评估分类模型的适当指标。

在本例中,我们将使用 SGD 优化器和交叉熵损失进行二元分类:

>>> loss_fn = nn.BCELoss()
>>> optimizer = torch.optim.SGD(model.parameters(), lr=0.001) 

接下来,我们将看一个更实际的例子:解决经典的 XOR 分类问题。首先,我们将使用nn.Sequential()类来构建模型。在此过程中,您还将了解模型处理非线性决策边界的能力。然后,我们将讨论通过nn.Module构建模型,这将为我们提供更多灵活性和对网络层的控制。

解决 XOR 分类问题

XOR 分类问题是分析模型捕捉两类之间非线性决策边界能力的经典问题。我们生成了一个包含 200 个训练样本的玩具数据集,具有两个特征(x[0],x[1]),这些特征从均匀分布-1, 1)中抽取。然后,根据以下规则为训练样本i分配了地面真实标签:

![

我们将使用一半的数据(100 个训练样本)进行训练,剩余一半用于验证。生成数据并将其拆分为训练和验证数据集的代码如下:

>>> import matplotlib.pyplot as plt
>>> import numpy as np
>>> torch.manual_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
>>> n_train = 100
>>> x_train = torch.tensor(x[:n_train, :], dtype=torch.float32)
>>> y_train = torch.tensor(y[:n_train], dtype=torch.float32)
>>> x_valid = torch.tensor(x[n_train:, :], dtype=torch.float32)
>>> y_valid = torch.tensor(y[n_train:], dtype=torch.float32)
>>> 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() 

该代码生成了训练和验证样本的散点图,根据它们的类别标签使用不同的标记:

图 13.2:训练和验证样本的散点图

在前面的小节中,我们介绍了在 PyTorch 中实现分类器所需的基本工具。现在我们需要决定为这个任务和数据集选择什么样的架构。作为一个经验法则,我们拥有的层次越多,每层的神经元越多,模型的容量就越大。在这里,模型容量可以被看作是模型能够逼近复杂函数的能力的一个度量。虽然更多参数意味着网络可以拟合更复杂的函数,但更大的模型通常更难训练(且容易过拟合)。实际操作中,从一个简单模型开始作为基准总是一个好主意,例如单层神经网络如逻辑回归:

>>> model = nn.Sequential(
...     nn.Linear(2, 1),
...     nn.Sigmoid()
... )
>>> model
Sequential(
  (0): Linear(in_features=2, out_features=1, bias=True)
  (1): Sigmoid()
) 

定义模型后,我们将初始化用于二元分类的交叉熵损失函数和 SGD 优化器:

>>> loss_fn = nn.BCELoss()
>>> optimizer = torch.optim.SGD(model.parameters(), lr=0.001) 

接下来,我们将创建一个数据加载器,使用批大小为 2 来处理训练数据:

>>> from torch.utils.data import DataLoader, TensorDataset
>>> train_ds = TensorDataset(x_train, y_train)
>>> batch_size = 2
>>> torch.manual_seed(1)
>>> train_dl = DataLoader(train_ds, batch_size, shuffle=True) 

现在我们将训练模型 200 个 epoch,并记录训练过程的历史:

>>> torch.manual_seed(1)
>>> num_epochs = 200
>>> def train(model, num_epochs, train_dl, x_valid, y_valid):
...     loss_hist_train = [0] * num_epochs
...     accuracy_hist_train = [0] * num_epochs
...     loss_hist_valid = [0] * num_epochs
...     accuracy_hist_valid = [0] * num_epochs
...     for epoch in range(num_epochs):
...         for x_batch, y_batch in train_dl:
...             pred = model(x_batch)[:, 0]
...             loss = loss_fn(pred, y_batch)
...             loss.backward()
...             optimizer.step()
...             optimizer.zero_grad()
...             loss_hist_train[epoch] += loss.item()
...             is_correct = ((pred>=0.5).float() == y_batch).float()
...             accuracy_hist_train[epoch] += is_correct.mean()
...         loss_hist_train[epoch] /= n_train
...         accuracy_hist_train[epoch] /= n_train/batch_size
...         pred = model(x_valid)[:, 0]
...         loss = loss_fn(pred, y_valid)
...         loss_hist_valid[epoch] = loss.item()
...         is_correct = ((pred>=0.5).float() == y_valid).float()
...         accuracy_hist_valid[epoch] += is_correct.mean()
...     return loss_hist_train, loss_hist_valid, \
...            accuracy_hist_train, accuracy_hist_valid
>>> history = train(model, num_epochs, train_dl, x_valid, y_valid) 

注意,训练 epoch 的历史包括训练损失和验证损失,以及训练准确率和验证准确率,这对于训练后的可视化检查非常有用。在下面的代码中,我们将绘制学习曲线,包括训练和验证损失,以及它们的准确率。

以下代码将绘制训练性能:

>>> fig = plt.figure(figsize=(16, 4))
>>> ax = fig.add_subplot(1, 2, 1)
>>> plt.plot(history[0], lw=4)
>>> plt.plot(history[1], lw=4)
>>> plt.legend(['Train loss', 'Validation loss'], fontsize=15)
>>> ax.set_xlabel('Epochs', size=15)
>>> ax = fig.add_subplot(1, 2, 2)
>>> plt.plot(history[2], lw=4)
>>> plt.plot(history[3], lw=4)
>>> plt.legend(['Train acc.', 'Validation acc.'], fontsize=15)
>>> ax.set_xlabel('Epochs', size=15) 

这导致了下图,分别显示了损失和准确率的两个单独面板:

图 13.3:损失和准确率结果

正如您所见,一个没有隐藏层的简单模型只能得出线性决策边界,无法解决 XOR 问题。因此,我们可以观察到,训练集和验证集的损失项都非常高,分类准确率非常低。

为了得到非线性决策边界,我们可以添加一个或多个通过非线性激活函数连接的隐藏层。普遍逼近定理表明,具有单个隐藏层和相对较大隐藏单元数的前馈神经网络可以相对良好地逼近任意连续函数。因此,更有效地解决 XOR 问题的一种方法是添加一个隐藏层,并比较不同数量的隐藏单元,直到在验证数据集上观察到满意的结果。增加更多的隐藏单元相当于增加层的宽度。

或者,我们也可以添加更多隐藏层,这会使模型变得更深。将网络变得更深而不是更宽的优势在于,需要的参数更少,即使实现相似的模型容量。

然而,与宽模型相比,深模型的一个缺点是,深模型容易出现梯度消失和梯度爆炸,这使得它们更难训练。

作为练习,请尝试添加一层、两层、三层和四层,每层都有四个隐藏单元。在下面的例子中,我们将查看具有两个隐藏层的前馈神经网络的结果:

>>> model = nn.Sequential(
...     nn.Linear(2, 4),
...     nn.ReLU(),
...     nn.Linear(4, 4),
...     nn.ReLU(),
...     nn.Linear(4, 1),
...     nn.Sigmoid()
... )
>>> loss_fn = nn.BCELoss()
>>> optimizer = torch.optim.SGD(model.parameters(), lr=0.015)
>>> model
Sequential(
  (0): Linear(in_features=2, out_features=4, bias=True)
  (1): ReLU()
  (2): Linear(in_features=4, out_features=4, bias=True)
  (3): ReLU()
  (4): Linear(in_features=4, out_features=1, bias=True)
  (5): Sigmoid()
)
>>> history = train(model, num_epochs, train_dl, x_valid, y_valid) 

我们可以重复之前的代码进行可视化,产生以下结果:

图 13.4:添加两个隐藏层后的损失和准确率结果

现在,我们可以看到该模型能够为这些数据推导出非线性决策边界,并且该模型在训练数据集上达到 100%的准确率。验证数据集的准确率为 95%,这表明模型略微过拟合。

使用 nn.Module 使模型构建更加灵活

在前面的例子中,我们使用了 PyTorch 的Sequential类来创建一个具有多层的全连接神经网络。这是一种非常常见和方便的建模方式。然而,不幸的是,它不允许我们创建具有多个输入、输出或中间分支的复杂模型。这就是nn.Module派上用场的地方。

构建复杂模型的另一种方法是通过子类化nn.Module来实现。在这种方法中,我们创建一个派生自nn.Module的新类,并将__init__()方法定义为构造函数。forward()方法用于指定前向传播。在构造函数__init__()中,我们将层定义为类的属性,以便可以通过self引用属性访问这些层。然后,在forward()方法中,我们指定这些层在神经网络的前向传播中如何使用。定义实现前述模型的新类的代码如下:

>>> class MyModule(nn.Module):
...     def __init__(self):
...         super().__init__()
...         l1 = nn.Linear(2, 4)
...         a1 = nn.ReLU()
...         l2 = nn.Linear(4, 4)
...         a2 = nn.ReLU()
...         l3 = nn.Linear(4, 1)
...         a3 = nn.Sigmoid()
...         l = [l1, a1, l2, a2, l3, a3]
...         self.module_list = nn.ModuleList(l)
...
...     def forward(self, x):
...         for f in self.module_list:
...             x = f(x)
...         return x 

注意,我们将所有层放在nn.ModuleList对象中,这只是由nn.Module项组成的list对象。这样做可以使代码更易读,更易于理解。

一旦我们定义了这个新类的实例,我们就可以像之前一样对其进行训练:

>>> model = MyModule()
>>> model
MyModule(
  (module_list): ModuleList(
    (0): Linear(in_features=2, out_features=4, bias=True)
    (1): ReLU()
    (2): Linear(in_features=4, out_features=4, bias=True)
    (3): ReLU()
    (4): Linear(in_features=4, out_features=1, bias=True)
    (5): Sigmoid()
  )
)
>>> loss_fn = nn.BCELoss()
>>> optimizer = torch.optim.SGD(model.parameters(), lr=0.015)
>>> history = train(model, num_epochs, train_dl, x_valid, y_valid) 

接下来,除了训练历史记录外,我们将使用 mlxtend 库来可视化验证数据和决策边界。

可以通过以下方式通过condapip安装 mlxtend:

conda install mlxtend -c conda-forge
pip install mlxtend 

要计算我们模型的决策边界,我们需要在MyModule类中添加一个predict()方法:

>>>     def predict(self, x):
...         x = torch.tensor(x, dtype=torch.float32)
...         pred = self.forward(x)[:, 0]
...         return (pred>=0.5).float() 

它将为样本返回预测类(0 或 1)。

以下代码将绘制训练性能以及决策区域偏差:

>>> from mlxtend.plotting import plot_decision_regions
>>> fig = plt.figure(figsize=(16, 4))
>>> ax = fig.add_subplot(1, 3, 1)
>>> plt.plot(history[0], lw=4)
>>> plt.plot(history[1], 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[2], lw=4)
>>> plt.plot(history[3], 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.numpy(),
...                       y=y_valid.numpy().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() 

这导致图 13.5,其中包括损失、准确率的三个独立面板以及验证示例的散点图,以及决策边界:

图 13.5:包括散点图在内的结果

在 PyTorch 中编写自定义层

在我们想定义一个 PyTorch 尚未支持的新层的情况下,我们可以定义一个新类,该类派生自nn.Module类。当设计新层或自定义现有层时,这尤其有用。

为了说明实现自定义层的概念,让我们考虑一个简单的例子。假设我们想定义一个新的线性层,计算,其中表示作为噪声变量的随机变量。为了实现这个计算,我们定义一个新的类作为nn.Module的子类。对于这个新类,我们必须定义构造函数__init__()方法和forward()方法。在构造函数中,我们为自定义层定义变量和其他所需的张量。如果构造函数提供了input_size,我们可以在构造函数中创建变量并初始化它们。或者,我们可以延迟变量初始化(例如,如果我们事先不知道确切的输入形状),并将其委托给另一个方法进行延迟变量创建。

为了看一个具体的例子,我们将定义一个名为NoisyLinear的新层,实现了在前述段落中提到的计算

>>> class NoisyLinear(nn.Module):
...     def __init__(self, input_size, output_size,
...                  noise_stddev=0.1):
...         super().__init__()
...         w = torch.Tensor(input_size, output_size)
...         self.w = nn.Parameter(w)  # nn.Parameter is a Tensor
...                                   # that's a module parameter.
...         nn.init.xavier_uniform_(self.w)
...         b = torch.Tensor(output_size).fill_(0)
...         self.b = nn.Parameter(b)
...         self.noise_stddev = noise_stddev
...
...     def forward(self, x, training=False):
...         if training:
...             noise = torch.normal(0.0, self.noise_stddev, x.shape)
...             x_new = torch.add(x, noise)
...         else:
...             x_new = x
...         return torch.add(torch.mm(x_new, self.w), self.b) 
, was to be generated and added to the input during training only and not used for inference or evaluation.

在我们进一步使用我们自定义的NoisyLinear层将其应用于模型之前,让我们在一个简单示例的背景下测试它。

  1. 在下面的代码中,我们将定义此层的新实例,并在输入张量上执行它。然后,我们将在相同的输入张量上三次调用该层:

    >>> torch.manual_seed(1)
    >>> noisy_layer = NoisyLinear(4, 2)
    >>> x = torch.zeros((1, 4))
    >>> print(noisy_layer(x, training=True))
    tensor([[ 0.1154, -0.0598]], grad_fn=<AddBackward0>)
    >>> print(noisy_layer(x, training=True))
    tensor([[ 0.0432, -0.0375]], grad_fn=<AddBackward0>)
    >>> print(noisy_layer(x, training=False))
    tensor([[0., 0.]], grad_fn=<AddBackward0>) 
    

    请注意,前两次调用的输出不同,因为NoisyLinear层向输入张量添加了随机噪声。第三次调用输出[0, 0],因为我们通过指定training=False未添加噪声。

  2. 现在,让我们创建一个类似于以前用于解决 XOR 分类任务的新模型。与之前一样,我们将使用nn.Module类构建模型,但这次我们将把我们的NoisyLinear层作为多层感知机的第一个隐藏层。代码如下:

    >>> class MyNoisyModule(nn.Module):
    ...     def __init__(self):
    ...         super().__init__()
    ...         self.l1 = NoisyLinear(2, 4, 0.07)
    ...         self.a1 = nn.ReLU()
    ...         self.l2 = nn.Linear(4, 4)
    ...         self.a2 = nn.ReLU()
    ...         self.l3 = nn.Linear(4, 1)
    ...         self.a3 = nn.Sigmoid()
    ...
    ...     def forward(self, x, training=False):
    ...         x = self.l1(x, training)
    ...         x = self.a1(x)
    ...         x = self.l2(x)
    ...         x = self.a2(x)
    ...         x = self.l3(x)
    ...         x = self.a3(x)
    ...         return x
    ...
    ...     def predict(self, x):
    ...         x = torch.tensor(x, dtype=torch.float32)
    ...         pred = self.forward(x)[:, 0]
    ...         return (pred>=0.5).float()
    ...
    >>> torch.manual_seed(1)
    >>> model = MyNoisyModule()
    >>> model
    MyNoisyModule(
      (l1): NoisyLinear()
      (a1): ReLU()
      (l2): Linear(in_features=4, out_features=4, bias=True)
      (a2): ReLU()
      (l3): Linear(in_features=4, out_features=1, bias=True)
      (a3): Sigmoid()
    ) 
    
  3. 类似地,我们将像以前一样训练模型。此时,为了在训练批次上计算预测值,我们使用pred = model(x_batch, True)[:, 0]而不是pred = model(x_batch)[:, 0]

    >>> loss_fn = nn.BCELoss()
    >>> optimizer = torch.optim.SGD(model.parameters(), lr=0.015)
    >>> torch.manual_seed(1)
    >>> loss_hist_train = [0] * num_epochs
    >>> accuracy_hist_train = [0] * num_epochs
    >>> loss_hist_valid = [0] * num_epochs
    >>> accuracy_hist_valid = [0] * num_epochs
    >>> for epoch in range(num_epochs):
    ...     for x_batch, y_batch in train_dl:
    ...         pred = model(x_batch, True)[:, 0]
    ...         loss = loss_fn(pred, y_batch)
    ...         loss.backward()
    ...         optimizer.step()
    ...         optimizer.zero_grad()
    ...         loss_hist_train[epoch] += loss.item()
    ...         is_correct = (
    ...             (pred>=0.5).float() == y_batch
    ...         ).float()
    ...         accuracy_hist_train[epoch] += is_correct.mean()
    ...     loss_hist_train[epoch] /= 100/batch_size
    ...     accuracy_hist_train[epoch] /= 100/batch_size
    ...     pred = model(x_valid)[:, 0]
    ...     loss = loss_fn(pred, y_valid)
    ...     loss_hist_valid[epoch] = loss.item()
    ...     is_correct = ((pred>=0.5).float() == y_valid).float()
    ...     accuracy_hist_valid[epoch] += is_correct.mean() 
    
  4. 训练模型后,我们可以绘制损失、准确率和决策边界:

    >>> fig = plt.figure(figsize=(16, 4))
    >>> ax = fig.add_subplot(1, 3, 1)
    >>> plt.plot(loss_hist_train, lw=4)
    >>> plt.plot(loss_hist_valid, 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(accuracy_hist_train, lw=4)
    >>> plt.plot(accuracy_hist_valid, 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.numpy(),
    ...     y=y_valid.numpy().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() 
    
  5. 结果图如下:

    图 13.6:使用 NoisyLinear 作为第一个隐藏层的结果

在这里,我们的目标是学习如何定义一个新的自定义层,这个层是从nn.Module子类化而来,并且如同使用任何其他标准torch.nn层一样使用它。虽然在这个特定的例子中,NoisyLinear并没有帮助提高性能,请记住我们的主要目标是学习如何从头开始编写一个定制层。一般来说,编写一个新的自定义层在其他应用中可能会很有用,例如,如果您开发了一个依赖于现有层之外的新层的新算法。

项目一 – 预测汽车的燃油效率

到目前为止,在本章中,我们主要集中在torch.nn模块上。我们使用nn.Sequential来简化模型的构建。然后,我们使用nn.Module使模型构建更加灵活,并实现了前馈神经网络,其中我们添加了定制层。在本节中,我们将致力于一个真实世界的项目,即预测汽车的每加仑英里数(MPG)燃油效率。我们将涵盖机器学习任务的基本步骤,如数据预处理、特征工程、训练、预测(推理)和评估。

处理特征列

在机器学习和深度学习应用中,我们可能会遇到各种不同类型的特征:连续、无序分类(名义)和有序分类(序数)。您会回忆起,在第四章构建良好的训练数据集 – 数据预处理中,我们涵盖了不同类型的特征,并学习了如何处理每种类型。请注意,虽然数值数据可以是连续的或离散的,但在使用 PyTorch 进行机器学习的上下文中,“数值”数据特指浮点类型的连续数据。

有时,特征集合由不同类型的特征混合组成。例如,考虑一个具有七种不同特征的情景,如图 13.7所示:

图 13.7:汽车 MPG 数据结构

图中显示的特征(车型年份、汽缸数、排量、马力、重量、加速度和起源)来自汽车 MPG 数据集,这是一个常见的用于预测汽车燃油效率的机器学习基准数据集。完整数据集及其描述可从 UCI 的机器学习存储库获取:archive.ics.uci.edu/ml/datasets/auto+mpg

我们将从汽车 MPG 数据集中处理五个特征(汽缸数、排量、马力、重量和加速度),将它们视为“数值”(这里是连续)特征。车型年份可以被视为有序分类(序数)特征。最后,制造地可以被视为无序分类(名义)特征,具有三个可能的离散值,1、2 和 3,分别对应于美国、欧洲和日本。

让我们首先加载数据并应用必要的预处理步骤,包括删除不完整的行,将数据集分为训练和测试数据集,以及标准化连续特征:

>>> import pandas as pd
>>> url = 'http://archive.ics.uci.edu/ml/' \
...       'machine-learning-databases/auto-mpg/auto-mpg.data'
>>> column_names = ['MPG', 'Cylinders', 'Displacement', 'Horsepower',
...                 'Weight', 'Acceleration', 'Model Year', 'Origin']
>>> df = pd.read_csv(url, 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, random_state=1
... )
>>> 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() 

这导致了以下结果:

图 13.8:经过预处理的 Auto MG 数据

float. These columns will constitute the continuous features.

接下来,让我们将相当精细的模型年份(ModelYear)信息分组到桶中,以简化我们稍后将要训练的模型的学习任务。具体来说,我们将每辆车分配到四个年份桶中,如下所示:

注意,所选的间隔是任意选择的,用于说明“分桶”的概念。为了将车辆分组到这些桶中,我们首先定义三个截断值:[73, 76, 79],用于模型年份特征。这些截断值用于指定半开区间,例如,(-∞, 73),[73, 76),[76, 79),和[76, ∞)。然后,原始数值特征将传递给torch.bucketize函数(pytorch.org/docs/stable/generated/torch.bucketize.html)来生成桶的索引。代码如下:

>>> boundaries = torch.tensor([73, 76, 79])
>>> v = torch.tensor(df_train_norm['Model Year'].values)
>>> df_train_norm['Model Year Bucketed'] = torch.bucketize(
...     v, boundaries, right=True
... )
>>> v = torch.tensor(df_test_norm['Model Year'].values)
>>> df_test_norm['Model Year Bucketed'] = torch.bucketize(
...     v, boundaries, right=True
... )
>>> numeric_column_names.append('Model Year Bucketed') 

我们将此分桶特征列添加到 Python 列表numeric_column_names中。

接下来,我们将继续定义一个无序分类特征Origin的列表。在 PyTorch 中,处理分类特征有两种方法:使用通过nn.Embeddingpytorch.org/docs/stable/generated/torch.nn.Embedding.html)实现的嵌入层,或者使用独热编码向量(也称为指示器)。在编码方法中,例如,索引 0 将被编码为[1, 0, 0],索引 1 将被编码为[0, 1, 0],依此类推。另一方面,嵌入层将每个索引映射到一组随机数的向量,类型为float,可以进行训练。(您可以将嵌入层视为与可训练权重矩阵相乘的一种更有效的独热编码实现。)

当类别数量较多时,使用比类别数量少的嵌入层维度可以提高性能。

在下面的代码片段中,我们将使用独热编码方法处理分类特征,以便将其转换为密集格式:

>>> from torch.nn.functional import one_hot
>>> total_origin = len(set(df_train_norm['Origin']))
>>> origin_encoded = one_hot(torch.from_numpy(
...     df_train_norm['Origin'].values) % total_origin)
>>> x_train_numeric = torch.tensor(
...     df_train_norm[numeric_column_names].values)
>>> x_train = torch.cat([x_train_numeric, origin_encoded], 1).float()
>>> origin_encoded = one_hot(torch.from_numpy(
...     df_test_norm['Origin'].values) % total_origin)
>>> x_test_numeric = torch.tensor(
...     df_test_norm[numeric_column_names].values)
>>> x_test = torch.cat([x_test_numeric, origin_encoded], 1).float() 

将分类特征编码为三维密集特征后,我们将其与前一步骤中处理的数值特征串联起来。最后,我们将从地面实际 MPG 值创建标签张量如下:

>>> y_train = torch.tensor(df_train_norm['MPG'].values).float()
>>> y_test = torch.tensor(df_test_norm['MPG'].values).float() 

在本节中,我们介绍了 PyTorch 中预处理和创建特征的最常见方法。

训练 DNN 回归模型

现在,在构建必需的特征和标签之后,我们将创建一个数据加载器,用于训练数据的批量大小为 8:

>>> train_ds = TensorDataset(x_train, y_train)
>>> batch_size = 8
>>> torch.manual_seed(1)
>>> train_dl = DataLoader(train_ds, batch_size, shuffle=True) 

接下来,我们将建立一个具有两个全连接层的模型,其中一个具有 8 个隐藏单元,另一个具有 4 个:

>>> hidden_units = [8, 4]
>>> input_size = x_train.shape[1]
>>> all_layers = []
>>> for hidden_unit in hidden_units:
...     layer = nn.Linear(input_size, hidden_unit)
...     all_layers.append(layer)
...     all_layers.append(nn.ReLU())
...     input_size = hidden_unit
>>> all_layers.append(nn.Linear(hidden_units[-1], 1))
>>> model = nn.Sequential(*all_layers)
>>> model
Sequential(
  (0): Linear(in_features=9, out_features=8, bias=True)
  (1): ReLU()
  (2): Linear(in_features=8, out_features=4, bias=True)
  (3): ReLU()
  (4): Linear(in_features=4, out_features=1, bias=True)
) 

在定义模型后,我们将为回归定义 MSE 损失函数,并使用随机梯度下降进行优化:

>>> loss_fn = nn.MSELoss()
>>> optimizer = torch.optim.SGD(model.parameters(), lr=0.001) 

现在我们将训练模型 200 个 epoch,并在每 20 个 epoch 显示训练损失:

>>> torch.manual_seed(1)
>>> num_epochs = 200
>>> log_epochs = 20
>>> for epoch in range(num_epochs):
...     loss_hist_train = 0
...     for x_batch, y_batch in train_dl:
...         pred = model(x_batch)[:, 0]
...         loss = loss_fn(pred, y_batch)
...         loss.backward()
...         optimizer.step()
...         optimizer.zero_grad()
...         loss_hist_train += loss.item()
...     if epoch % log_epochs==0:
...         print(f'Epoch {epoch}  Loss '
...               f'{loss_hist_train/len(train_dl):.4f}')
Epoch 0  Loss 536.1047
Epoch 20  Loss 8.4361
Epoch 40  Loss 7.8695
Epoch 60  Loss 7.1891
Epoch 80  Loss 6.7062
Epoch 100  Loss 6.7599
Epoch 120  Loss 6.3124
Epoch 140  Loss 6.6864
Epoch 160  Loss 6.7648
Epoch 180  Loss 6.2156 

经过 200 个 epoch 后,训练损失约为 5。现在我们可以在测试数据集上评估训练模型的回归性能。为了预测新数据点上的目标值,我们可以将它们的特征提供给模型:

>>> with torch.no_grad():
...     pred = model(x_test.float())[:, 0]
...     loss = loss_fn(pred, y_test)
...     print(f'Test MSE: {loss.item():.4f}')
...     print(f'Test MAE: {nn.L1Loss()(pred, y_test).item():.4f}')
Test MSE: 9.6130
Test MAE: 2.1211 

测试集上的 MSE 为 9.6,平均绝对误差MAE)为 2.1。完成此回归项目后,我们将在下一部分进行分类项目。

项目二 – 对 MNIST 手写数字进行分类

对于这个分类项目,我们将对 MNIST 手写数字进行分类。在前一部分中,我们详细介绍了 PyTorch 中机器学习的四个基本步骤,我们将在本节中重复这些步骤。

您会记得,在第十二章中,您学习了如何从torchvision模块中加载可用的数据集的方法。首先,我们将使用torchvision模块加载 MNIST 数据集。

  1. 设置步骤包括加载数据集并指定超参数(训练集和测试集的大小,以及小批量的大小):

    >>> import torchvision
    >>> from torchvision import transforms
    >>> image_path = './'
    >>> transform = transforms.Compose([
    ...     transforms.ToTensor()
    ... ])
    >>> mnist_train_dataset = torchvision.datasets.MNIST(
    ...     root=image_path, train=True,
    ...     transform=transform, download=False
    ... )
    >>> mnist_test_dataset = torchvision.datasets.MNIST(
    ...     root=image_path, train=False,
    ...     transform=transform, download=False
    ... )
    >>> batch_size = 64
    >>> torch.manual_seed(1)
    >>> train_dl = DataLoader(mnist_train_dataset,
    ...                       batch_size, shuffle=True) 
    

    在这里,我们构建了一个每批 64 个样本的数据加载器。接下来,我们将预处理加载的数据集。

  2. 我们预处理输入特征和标签。在这个项目中,特征是我们从第 1 步读取的图像的像素。我们使用torchvision.transforms.Compose定义了一个自定义转换。在这种简单情况下,我们的转换仅包括一个方法ToTensor()ToTensor()方法将像素特征转换为浮点型张量,并将像素从[0, 255]范围归一化到[0, 1]范围。在第十四章使用深度卷积神经网络对图像进行分类中,当我们处理更复杂的图像数据集时,我们将看到一些额外的数据转换方法。标签是从 0 到 9 的整数,表示十个数字。因此,我们不需要进行任何缩放或进一步的转换。请注意,我们可以使用data属性访问原始像素,并不要忘记将它们缩放到[0, 1]范围内。

    在数据预处理完成后,我们将在下一步构建模型。

  3. 构建神经网络模型:

    >>> hidden_units = [32, 16]
    >>> image_size = mnist_train_dataset[0][0].shape
    >>> input_size = image_size[0] * image_size[1] * image_size[2]
    >>> all_layers = [nn.Flatten()]
    >>> for hidden_unit in hidden_units:
    ...     layer = nn.Linear(input_size, hidden_unit)
    ...     all_layers.append(layer)
    ...     all_layers.append(nn.ReLU())
    ...     input_size = hidden_unit
    >>> all_layers.append(nn.Linear(hidden_units[-1], 10))
    >>> model = nn.Sequential(*all_layers)
    >>> model
    Sequential(
      (0): Flatten(start_dim=1, end_dim=-1)
      (1): Linear(in_features=784, out_features=32, bias=True)
      (2): ReLU()
      (3): Linear(in_features=32, out_features=16, bias=True)
      (4): ReLU()
      (5): Linear(in_features=16, out_features=10, bias=True)
    ) 
    

    请注意,模型以一个展平层开始,将输入图像展平为一维张量。这是因为输入图像的形状是[1, 28, 28]。模型有两个隐藏层,分别为 32 和 16 个单元。最后是一个由十个单元组成的输出层,通过 softmax 函数激活,代表十个类别。在下一步中,我们将在训练集上训练模型,并在测试集上评估模型。

  4. 使用模型进行训练、评估和预测:

    >>> loss_fn = nn.CrossEntropyLoss()
    >>> optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
    >>> torch.manual_seed(1)
    >>> num_epochs = 20
    >>> for epoch in range(num_epochs):
    ...     accuracy_hist_train = 0
    ...     for x_batch, y_batch in train_dl:
    ...         pred = model(x_batch)
    ...         loss = loss_fn(pred, y_batch)
    ...         loss.backward()
    ...         optimizer.step()
    ...         optimizer.zero_grad()
    ...         is_correct = (
    ...             torch.argmax(pred, dim=1) == y_batch
    ...         ).float()
    ...         accuracy_hist_train += is_correct.sum()
    ...     accuracy_hist_train /= len(train_dl.dataset)
    ...     print(f'Epoch {epoch}  Accuracy '
    ...           f'{accuracy_hist_train:.4f}')
    Epoch 0  Accuracy 0.8531
    ...
    Epoch 9  Accuracy 0.9691
    ...
    Epoch 19  Accuracy 0.9813 
    

    我们使用了交叉熵损失函数进行多类别分类,使用 Adam 优化器进行梯度下降。我们将在第十四章讨论 Adam 优化器。我们对模型进行了 20 个 epochs 的训练,并且在每个 epoch 显示了训练准确率。训练后的模型在训练集上达到了 96.3%的准确率,并且我们将在测试集上进行评估:

    >>> pred = model(mnist_test_dataset.data / 255.)
    >>> is_correct = (
    ...     torch.argmax(pred, dim=1) ==
    ...     mnist_test_dataset.targets
    ... ).float()
    >>> print(f'Test accuracy: {is_correct.mean():.4f}')
    Test accuracy: 0.9645 
    

测试准确率为 95.6%。您已经学会了如何使用 PyTorch 解决分类问题。

更高级别的 PyTorch API:简介 PyTorch-Lightning

近年来,PyTorch 社区开发了几个不同的库和 API,这些库和 API 都是基于 PyTorch 构建的。值得注意的例子包括 fastai (docs.fast.ai/)、Catalyst (github.com/catalyst-team/catalyst)、PyTorch Lightning (www.pytorchlightning.ai)、(lightning-flash.readthedocs.io/en/latest/quickstart.html)以及 PyTorch-Ignite (github.com/pytorch/ignite)。

在本节中,我们将探讨 PyTorch Lightning(简称 Lightning),这是一个广泛使用的 PyTorch 库,通过消除大量样板代码,使训练深度神经网络变得更加简单。然而,尽管 Lightning 专注于简单性和灵活性,它也允许我们使用许多高级功能,例如多 GPU 支持和快速低精度训练,您可以在官方文档中了解更多信息:pytorch-lightning.rtfd.io/en/latest/

还有一个关于 PyTorch-Ignite 的额外介绍在github.com/rasbt/machine-learning-book/blob/main/ch13/ch13_part4_ignite.ipynb

在之前的一个章节中,项目二 - 分类 MNIST 手写数字,我们实现了一个多层感知器,用于在 MNIST 数据集中分类手写数字。在接下来的小节中,我们将使用 Lightning 重新实现这个分类器。

安装 PyTorch Lightning

您可以根据喜好通过 pip 或 conda 安装 Lightning。例如,通过 pip 安装 Lightning 的命令如下:

pip install pytorch-lightning 

以下是通过 conda 安装 Lightning 的命令:

conda install pytorch-lightning -c conda-forge 

下一小节的代码基于 PyTorch Lightning 1.5 版本,您可以通过在命令中将pytorch-lightning替换为pytorch-lightning==1.5来安装它。

设置 PyTorch Lightning 模型

我们首先实现模型,在接下来的子节中将对其进行训练。在 Lightning 中定义模型相对简单,因为它基于常规的 Python 和 PyTorch 代码。要实现 Lightning 模型,只需使用 LightningModule 替代常规的 PyTorch 模块即可。为了利用 PyTorch 的便利函数,如训练器 API 和自动日志记录,我们只需定义几个特定命名的方法,我们将在接下来的代码中看到:

import pytorch_lightning as pl
import torch 
import torch.nn as nn 
from torchmetrics import Accuracy
class MultiLayerPerceptron(pl.LightningModule):
    def __init__(self, image_shape=(1, 28, 28), hidden_units=(32, 16)):
        super().__init__()

        # new PL attributes:
        self.train_acc = Accuracy()
        self.valid_acc = Accuracy()
        self.test_acc = Accuracy()

        # Model similar to previous section:
        input_size = image_shape[0] * image_shape[1] * image_shape[2]
        all_layers = [nn.Flatten()]
        for hidden_unit in hidden_units: 
            layer = nn.Linear(input_size, hidden_unit) 
            all_layers.append(layer) 
            all_layers.append(nn.ReLU()) 
            input_size = hidden_unit 

        all_layers.append(nn.Linear(hidden_units[-1], 10))  
        self.model = nn.Sequential(*all_layers)
    def forward(self, x):
        x = self.model(x)
        return x

    def training_step(self, batch, batch_idx):
        x, y = batch
        logits = self(x)
        loss = nn.functional.cross_entropy(self(x), y)
        preds = torch.argmax(logits, dim=1)
        self.train_acc.update(preds, y)
        self.log("train_loss", loss, prog_bar=True)
        return loss

    def training_epoch_end(self, outs):
        self.log("train_acc", self.train_acc.compute())

    def validation_step(self, batch, batch_idx):
        x, y = batch
        logits = self(x)
        loss = nn.functional.cross_entropy(self(x), y)
        preds = torch.argmax(logits, dim=1)
        self.valid_acc.update(preds, y)
        self.log("valid_loss", loss, prog_bar=True)
        self.log("valid_acc", self.valid_acc.compute(), prog_bar=True)
        return loss

    def test_step(self, batch, batch_idx):
        x, y = batch
        logits = self(x)
        loss = nn.functional.cross_entropy(self(x), y)
        preds = torch.argmax(logits, dim=1)
        self.test_acc.update(preds, y)
        self.log("test_loss", loss, prog_bar=True)
        self.log("test_acc", self.test_acc.compute(), prog_bar=True)
        return loss

    def configure_optimizers(self):
        optimizer = torch.optim.Adam(self.parameters(), lr=0.001)
        return optimizer 

现在让我们逐一讨论不同的方法。正如你所见,__init__ 构造函数包含了我们在之前子节中使用的相同模型代码。新的内容是我们添加了诸如 self.train_acc = Accuracy() 等准确性属性。这些属性将允许我们在训练过程中跟踪准确性。Accuracy 是从 torchmetrics 模块导入的,它应该会随着 Lightning 的自动安装而被安装。如果无法导入 torchmetrics,你可以尝试通过 pip install torchmetrics 进行安装。更多信息可以在 torchmetrics.readthedocs.io/en/latest/pages/quickstart.html 找到。

forward 方法实现了一个简单的前向传递,当我们在输入数据上调用模型时,返回 logit(我们网络中 softmax 层之前的最后一个全连接层的输出)。通过调用 self(x)forward 方法计算的 logit 用于训练、验证和测试步骤,我们将在接下来描述这些步骤。

training_steptraining_epoch_endvalidation_steptest_stepconfigure_optimizers 方法是 Lightning 特别识别的方法。例如,training_step 定义了训练期间的单次前向传递,我们在此期间跟踪准确性和损失,以便稍后进行分析。请注意,我们通过 self.train_acc.update(preds, y) 计算准确性,但尚未记录。training_step 方法在训练过程中每个单独的批次上执行,而 training_epoch_end 方法在每个训练周期结束时执行,我们通过累积的准确性值计算训练集准确性。

validation_steptest_step 方法类似于 training_step 方法,定义了验证和测试评估过程的计算方式。与 training_step 类似,每个 validation_steptest_step 都接收一个批次数据,这就是为什么我们通过 torchmetric 的相应精度属性来记录准确性。但是,请注意,validation_step 仅在特定间隔调用,例如每次训练周期后。这就是为什么我们在验证步骤内记录验证准确性,而在训练准确性方面,我们会在每次训练周期后记录,否则,稍后检查的准确性图表将显得太过嘈杂。

最后,通过 configure_optimizers 方法,我们指定用于训练的优化器。接下来的两个小节将讨论如何设置数据集以及如何训练模型。

为 Lightning 设置数据加载器

有三种主要方法可以为 Lightning 准备数据集。我们可以:

  • 将数据集作为模型的一部分

  • 像往常一样设置数据加载器并将它们提供给 Lightning Trainer 的 fit 方法——Trainer 将在下一小节中介绍

  • 创建一个 LightningDataModule

在这里,我们将使用 LightningDataModule,这是最有组织的方法。LightningDataModule 包含五个主要方法,正如我们在接下来会看到的:

from torch.utils.data import DataLoader
from torch.utils.data import random_split
from torchvision.datasets import MNIST
from torchvision import transforms
class MnistDataModule(pl.LightningDataModule):
    def __init__(self, data_path='./'):
        super().__init__()
        self.data_path = data_path
        self.transform = transforms.Compose([transforms.ToTensor()])

    def prepare_data(self):
        MNIST(root=self.data_path, download=True) 
    def setup(self, stage=None):
        # stage is either 'fit', 'validate', 'test', or 'predict'
        # here note relevant
        mnist_all = MNIST( 
            root=self.data_path,
            train=True,
            transform=self.transform,  
            download=False
        ) 
        self.train, self.val = random_split(
            mnist_all, [55000, 5000], generator=torch.Generator().manual_seed(1)
        )
        self.test = MNIST( 
            root=self.data_path,
            train=False,
            transform=self.transform,  
            download=False
        ) 
    def train_dataloader(self):
        return DataLoader(self.train, batch_size=64, num_workers=4)
    def val_dataloader(self):
        return DataLoader(self.val, batch_size=64, num_workers=4)
    def test_dataloader(self):
        return DataLoader(self.test, batch_size=64, num_workers=4) 

prepare_data 方法中,我们定义了通用步骤,如下载数据集。在 setup 方法中,我们定义了用于训练、验证和测试的数据集。请注意,MNIST 没有专门的验证集拆分,这就是为什么我们使用 random_split 函数将包含 60,000 个示例的训练集分为 55,000 个示例用于训练和 5,000 个示例用于验证。

数据加载器的方法是不言自明的,并定义了如何加载各自的数据集。现在,我们可以初始化数据模块并在接下来的小节中用它来进行训练、验证和测试:

torch.manual_seed(1) 
mnist_dm = MnistDataModule() 

使用 PyTorch Lightning Trainer 类来训练模型

现在,我们可以从设置模型以及 Lightning 数据模块中具体命名的方法中受益。Lightning 实现了一个 Trainer 类,通过为我们处理所有中间步骤(如调用 zero_grad()backward()optimizer.step())使得训练模型非常方便。此外,作为一个额外的好处,它让我们可以轻松地指定一个或多个 GPU(如果可用)来使用:

mnistclassifier = MultiLayerPerceptron()
if torch.cuda.is_available(): # if you have GPUs
    trainer = pl.Trainer(max_epochs=10, gpus=1)
else:
    trainer = pl.Trainer(max_epochs=10)
trainer.fit(model=mnistclassifier, datamodule=mnist_dm) 

通过上述代码,我们为我们的多层感知器训练了 10 个 epochs。在训练过程中,我们可以看到一个便利的进度条,用于跟踪 epoch 和核心指标,如训练损失和验证损失:

Epoch 9: 100% 939/939 [00:07<00:00, 130.42it/s, loss=0.1, v_num=0, train_loss=0.260, valid_loss=0.166, valid_acc=0.949] 

在训练结束后,我们还可以更详细地检查我们记录的指标,正如我们将在下一小节中看到的那样。

使用 TensorBoard 评估模型

在前一节中,我们体验了 Trainer 类的便利性。Lightning 的另一个好处是其日志记录功能。回想一下,我们之前在 Lightning 模型中指定了几个 self.log 步骤。在训练期间,我们可以可视化它们在 TensorBoard 中的展示。 (注意,Lightning 还支持其他日志记录器;更多信息请参阅官方文档:pytorch-lightning.readthedocs.io/en/latest/common/loggers.html。)

安装 TensorBoard

可以通过 pip 或 conda 安装 TensorBoard,具体取决于您的偏好。例如,通过 pip 安装 TensorBoard 的命令如下:

pip install tensorboard 

以下是通过 conda 安装 Lightning 的命令:

conda install tensorboard -c conda-forge 

下一小节的代码基于 TensorBoard 版本 2.4,您可以通过在这些命令中替换 tensorboardtensorboard==2.4 来安装它。

默认情况下,Lightning 将训练结果保存在名为 lightning_logs 的子文件夹中。要可视化训练运行结果,您可以在命令行终端中执行以下代码,它将在您的浏览器中打开 TensorBoard:

tensorboard --logdir lightning_logs/ 

或者,如果您在 Jupyter 笔记本中运行代码,您可以将以下代码添加到 Jupyter 笔记本单元格中,以直接显示笔记本中的 TensorBoard 仪表板:

%load_ext tensorboard
%tensorboard --logdir lightning_logs/ 

图 13.9 展示了 TensorBoard 仪表板上记录的训练和验证准确率。请注意左下角显示的 version_0 切换。如果多次运行训练代码,Lightning 会将它们作为单独的子文件夹进行跟踪:version_0version_1version_2 等等:

自动生成的图表描述

图 13.9: TensorBoard 仪表板

通过观察 图 13.9 中的训练和验证准确率,我们可以假设再训练几个周期可以提高性能。

Lightning 允许我们加载已训练的模型,并方便地再训练多个周期。如前所述,Lightning 通过子文件夹追踪各个训练运行。在 图 13.10 中,我们看到了 version_0 子文件夹的内容,其中包括日志文件和重新加载模型的模型检查点:

自动生成的图形用户界面、文本、应用描述

图 13.10: PyTorch Lightning 日志文件

例如,我们可以使用以下代码从此文件夹加载最新的模型检查点,并通过 fit 方法训练模型:

if torch.cuda.is_available(): # if you have GPUs
    trainer = pl.Trainer(max_epochs=15, resume_from_checkpoint='./lightning_logs/version_0/checkpoints/epoch=8-step=7739.ckpt', gpus=1)
else:
    trainer = pl.Trainer(max_epochs=15, resume_from_checkpoint='./lightning_logs/version_0/checkpoints/epoch=8-step=7739.ckpt')
trainer.fit(model=mnistclassifier, datamodule=mnist_dm) 

在这里,我们将 max_epochs 设置为 15,这使得模型总共训练了 5 个额外的周期(之前训练了 10 个周期)。

现在,让我们看一下 图 13.11 中的 TensorBoard 仪表板,看看再训练几个周期是否值得:

自动生成的图表描述

图 13.11: 训练了五个额外周期后的 TensorBoard 仪表板

正如我们在 图 13.11 中所看到的,TensorBoard 允许我们展示额外训练周期(version_1)的结果与之前的(version_0)对比,这非常方便。确实,我们可以看到再训练五个周期提高了验证准确率。在这一点上,我们可以决定是否继续训练更多周期,这留给您作为练习。

一旦完成训练,我们可以使用以下代码在测试集上评估模型:

trainer.test(model=mnistclassifier, datamodule=mnist_dm) 

在总共训练 15 个周期后,得到的测试集性能约为 95%:

[{'test_loss': 0.14912301301956177, 'test_acc': 0.9499600529670715}] 

请注意,PyTorch Lightning 还会自动保存模型。如果您想稍后重用模型,您可以通过以下代码方便地加载它:

model = MultiLayerPerceptron.load_from_checkpoint("path/to/checkpoint.ckpt") 

了解更多关于 PyTorch Lightning 的信息

欲了解更多有关 Lightning 的信息,请访问官方网站,其中包含教程和示例,网址为 pytorch-lightning.readthedocs.io

Lightning 在 Slack 上也有一个活跃的社区,欢迎新用户和贡献者加入。要了解更多信息,请访问官方 Lightning 网站 www.pytorchlightning.ai

概要

在本章中,我们涵盖了 PyTorch 最重要和最有用的特性。我们首先讨论了 PyTorch 的动态计算图,这使得实现计算非常方便。我们还介绍了定义 PyTorch 张量对象作为模型参数的语义。

在我们考虑了计算任意函数的偏导数和梯度的概念后,我们更详细地讨论了 torch.nn 模块。它为我们提供了一个用户友好的接口,用于构建更复杂的深度神经网络模型。最后,我们通过使用迄今为止讨论的内容解决了回归和分类问题,从而结束了本章。

现在我们已经涵盖了 PyTorch 的核心机制,下一章将介绍深度学习中 卷积神经网络 (CNN) 架构的概念。CNN 是强大的模型,在计算机视觉领域表现出色。

加入我们书籍的 Discord 空间

加入本书的 Discord 工作区,与作者进行每月的 问答 会话:

packt.link/MLwPyTorch

第十四章:使用深度卷积神经网络进行图像分类

在上一章中,我们深入研究了 PyTorch 神经网络和自动微分模块的不同方面,您熟悉了张量和装饰函数,并学习了如何使用torch.nn。在本章中,您将学习有关用于图像分类的卷积神经网络CNNs)。我们将从底层开始讨论 CNN 的基本构建模块。接下来,我们将深入探讨 CNN 架构,并探讨如何在 PyTorch 中实现 CNN。本章将涵盖以下主题:

  • 一维和二维卷积操作

  • CNN 架构的构建模块

  • 在 PyTorch 中实现深度 CNN

  • 数据增强技术以提高泛化性能

  • 实现用于识别笑容的面部 CNN 分类器

CNN 的构建模块

CNN 是一个模型家族,最初受人类大脑视觉皮层在识别物体时的工作启发。CNN 的发展可以追溯到 1990 年代,当时 Yann LeCun 及其同事提出了一种新颖的神经网络架构,用于从图像中识别手写数字(Y. LeCun和同事在 1989 年发表在神经信息处理系统(NeurIPS)会议上的文章 Handwritten Digit Recognition with a Back-Propagation Network)。

人类视觉皮层

我们对大脑视觉皮层如何运作的最初发现是由 David H. Hubel 和 Torsten Wiesel 在 1959 年完成的,当时他们在麻醉猫的主视觉皮层插入了微电极。他们观察到,神经元在向猫前方不同模式的光投影后会产生不同的反应。这最终导致了对视觉皮层不同层次的发现。尽管主要层主要检测边缘和直线,但高阶层更专注于提取复杂的形状和图案。

由于 CNN 在图像分类任务中表现出色,这种特定类型的前馈神经网络引起了广泛关注,并在计算机视觉的机器学习领域取得了巨大进展。几年后的 2019 年,Yann LeCun 与其他两位研究者 Yoshua Bengio 和 Geoffrey Hinton 因其在人工智能AI)领域的贡献而共同获得了图灵奖(计算机科学中最负盛名的奖项)。

在接下来的几节中,我们将讨论 CNN 的更广泛概念以及为什么卷积架构通常被描述为“特征提取层”。然后,我们将深入探讨在 CNN 中常用的卷积操作类型的理论定义,并通过计算一维和二维卷积的示例来详细讨论。

理解 CNN 和特征层级

成功提取显著相关特征对于任何机器学习算法的性能至关重要,传统的机器学习模型依赖于可能来自领域专家或基于计算特征提取技术的输入特征。

某些类型的 NN,如 CNNs,可以自动从原始数据中学习对特定任务最有用的特征。因此,将 CNN 层视为特征提取器是很常见的:早期层(紧接着输入层的那些层)从原始数据中提取低级特征,后续层(通常是全连接层,如多层感知器(MLP))利用这些特征来预测连续目标值或类别标签。

某些类型的多层 NNs,特别是深度 CNNs,通过逐层组合低级特征以形成高级特征的方式构建所谓的特征层次结构。例如,如果我们处理图像,则从较早的层中提取低级特征(如边缘和斑点),然后将它们组合形成高级特征。这些高级特征可以形成更复杂的形状,例如建筑物、猫或狗等对象的一般轮廓。

正如您在图 14.1中看到的那样,CNN 从输入图像计算特征图,其中每个元素来自输入图像中的一个局部像素块:

图 14.1:从图像创建特征图(由 Alexander Dummer 在 Unsplash 上拍摄的照片)

这个局部像素块被称为局部感受野。CNN 在与图像相关的任务上通常表现非常出色,这在很大程度上归功于两个重要的思想:

  • 稀疏连接性:特征图中的单个元素仅连接到一个小的像素块。(这与 MLP 连接到整个输入图像非常不同。你可能会发现回顾并比较我们如何在第十一章中从头实现全连接网络的方式很有用。)

  • 参数共享:相同的权重用于输入图像的不同补丁。

由于这两个思想的直接结果,用卷积层替换传统的全连接 MLP 会大大减少网络中的权重(参数),我们将看到在捕获显著特征方面的改进。在图像数据的背景下,假设附近的像素通常比远离的像素更相关是有意义的。

典型地,CNN 由若干卷积和子采样层组成,最后跟随一个或多个完全连接层。全连接层本质上就是一个 MLP,其中每个输入单元i与每个输出单元j通过权重w[ij]连接(我们在第十一章中更详细地介绍过)。

请注意,常称为 池化层 的子采样层没有任何可学习的参数;例如,池化层中没有权重或偏置单元。然而,卷积层和全连接层都有在训练期间优化的权重和偏置。

在接下来的章节中,我们将更详细地研究卷积层和池化层,并了解它们的工作原理。为了理解卷积操作的工作原理,让我们从一维卷积开始,这在处理某些类型的序列数据(如文本)时有时会用到。在讨论一维卷积之后,我们将探讨通常应用于二维图像的二维卷积。

执行离散卷积

离散卷积(或简称为 卷积)是 CNN 中的一个基本操作。因此,理解这个操作如何工作非常重要。在本节中,我们将涵盖数学定义并讨论一些计算一维张量(向量)和二维张量(矩阵)卷积的朴素算法。

请注意,本节中的公式和描述仅用于理解 CNN 中的卷积操作。实际上,像 PyTorch 这样的软件包中已经存在更高效的卷积操作实现,你将在本章后面看到。

数学符号

在本章中,我们将使用下标表示多维数组(张量)的大小;例如, 是一个大小为 n[1]×n[2] 的二维数组。我们使用方括号 [ ] 表示多维数组的索引。例如,A[i, j] 指的是矩阵 A 中索引为 i, j 的元素。此外,请注意我们使用特殊符号 表示两个向量或矩阵之间的卷积运算,这与 Python 中的乘法运算符 * 不要混淆。

一维离散卷积

让我们从一些基本定义和符号开始。两个向量 xw 的离散卷积表示为 ,其中向量 x 是我们的输入(有时称为 信号),w 被称为 滤波器。离散卷积的数学定义如下:

正如前面提到的,方括号 [ ] 用于表示向量元素的索引。索引 i 遍历输出向量 y 的每个元素。在前述公式中有两件需要澄清的奇怪之处:负无穷到正无穷的指数和 x 的负索引。

看起来从–∞到+∞的索引和,主要因为在机器学习应用中,我们总是处理有限的特征向量。例如,如果x具有 10 个特征,索引为 0、1、2、...、8、9,那么索引–∞: –1 和 10: +∞对于x来说是超出范围的。因此,为了正确计算上述公式中的求和,假设xw填充了零。这将导致输出向量y也具有无限大小,并且也有很多零。由于在实际情况下这并不有用,因此x仅填充有限数量的零。

这个过程称为零填充或简称填充。这里,每一侧填充的零的数量由p表示。图 14.2 展示了一维向量x的一个填充示例:

图 14.2:填充的示例

假设原始输入x和滤波器w分别具有nm个元素,其中。因此,填充向量x^p 的大小为n + 2p。用于计算离散卷积的实际公式将变为:

现在我们已经解决了无限索引问题,第二个问题是用i + m – k索引x。这里需要注意的重要一点是,在这个求和中xw的索引方向不同。用一个索引反向的方法计算总和等同于在填充后翻转其中一个向量xw后,同时用正向的方法计算总和。然后,我们可以简单地计算它们的点积。假设我们翻转(旋转)滤波器w,得到旋转后的滤波器w^r。那么,点积x[i: i + m].w^r 得到一个元素y[i],其中x[i: i + m]是大小为mx的一个片段。这个操作像滑动窗口方法一样重复,以获取所有的输出元素。

以下图示给出了一个例子,其中x = [3 2 1 7 1 2 5 4]和,以便计算前三个输出元素:

图 14.3:计算离散卷积的步骤

在上述例子中,您可以看到填充大小为零(p =  0)。注意,旋转后的滤波器w^r 每次移动两个单元。这种移动是卷积的另一个超参数,步长s。在这个例子中,步长是二,s = 2。注意,步长必须是一个小于输入向量大小的正数。我们将在下一节详细讨论填充和步长。

交叉相关

输入向量和滤波器之间的交叉相关(或简称相关)用 表示,与卷积非常相似,唯一的区别在于:在交叉相关中,乘法是在相同方向上进行的。因此,在每个维度上不需要旋转滤波器矩阵 w。数学上,交叉相关定义如下:

对于交叉相关,填充和步长的规则也可以应用。请注意,大多数深度学习框架(包括 PyTorch)实现的是交叉相关,但在深度学习领域中通常称之为卷积,这是一种常见的约定。

填充输入以控制输出特征图的大小

到目前为止,我们只在卷积中使用了零填充来计算有限大小的输出向量。从技术上讲,填充可以应用于任何 。根据 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 的大小也取决于我们使用的填充策略。

在实际应用中,有三种常用的填充模式:fullsamevalid

在 full 模式下,填充参数 p 被设置为 p = m – 1. 全填充会增加输出的维度,因此在 CNN 架构中很少使用。

通常使用 same padding 模式来确保输出向量与输入向量 x 的大小相同。在这种情况下,填充参数 p 根据滤波器大小计算,同时要求输入大小和输出大小相同。

最后,在 valid 模式下进行卷积计算时,表示 p = 0(无填充)。

图 14.4 展示了一个简单的 5×5 像素输入与 3×3 的核大小和步长为 1 的三种不同填充模式:

图示  自动生成的描述

图 14.4:三种填充模式

CNN 中最常用的填充模式是 same padding。它与其他填充模式相比的优势之一是保持了向量的大小,或者在计算机视觉中处理图像相关任务时保持了输入图像的高度和宽度,这使得设计网络架构更加方便。

有效填充与全填充以及相同填充相比的一个巨大缺点是,在具有许多层的神经网络中,张量的体积会大幅减少,这可能对网络的性能有害。在实践中,应该保留卷积层的空间尺寸,并通过池化层或步长为 2 的卷积层减少空间尺寸,正如追求简单性:全卷积网络 ICLR(研讨会轨道),由约斯特·托比亚斯·施普林根伯格亚历克西·多索维茨基和其他人,2015 年所述 (arxiv.org/abs/1412.6806)。

至于全填充,其尺寸会导致输出大于输入尺寸。全填充通常用于信号处理应用中,重要的是最小化边界效应。然而,在深度学习环境中,边界效应通常不是问题,因此我们很少在实践中看到使用全填充。

确定卷积输出的尺寸

卷积输出的大小由我们沿着输入向量移动滤波器w的总次数决定。假设输入向量的大小为n,滤波器的大小为m,并且填充为p,步长为s,则从结果的输出大小将如下确定:

这里, 表示地板运算。

地板运算

地板运算返回小于或等于输入的最大整数,例如:

考虑以下两种情况:

  • 计算输入向量大小为 10,卷积核大小为 5,填充为 2,步长为 1 时的输出大小:

    (请注意,在这种情况下,输出尺寸与输入相同;因此,我们可以得出这是相同填充模式。)

  • 当我们有大小为 3 且步长为 2 的卷积核时,同一输入向量的输出大小会如何变化?

如果你对学习卷积输出的尺寸更感兴趣,我们推荐阅读深度学习卷积算术指南,作者是文森特·杜穆林弗朗切斯科·维辛,可以在 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_padded) - len(w_rot))) + 1, 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 情况开始,以便更容易理解概念。在接下来的部分,我们将更详细地讨论 2D 卷积,这是用于图像相关任务的 CNN 的构建模块。

进行 2D 离散卷积

你在前几节学到的概念很容易扩展到 2D。当我们处理 2D 输入,比如一个矩阵,,和滤波器矩阵,,其中,那么矩阵就是XW之间的 2D 卷积的结果。数学上定义如下:

注意,如果省略其中一个维度,剩余的公式与我们之前用于计算 1D 卷积的公式完全相同。事实上,所有先前提到的技术,如零填充、旋转滤波器矩阵和使用步长,也适用于 2D 卷积,只要它们分别扩展到两个维度。图 14.5展示了使用 3×3 大小的核对大小为 8×8 的输入矩阵进行的 2D 卷积。输入矩阵通过p = 1 进行了零填充。因此,2D 卷积的输出大小为 8×8:

图表说明自动生成

图 14.5:2D 卷积的输出

下面的例子演示了如何计算输入矩阵X[3×3]和核矩阵W[3×3]之间的 2D 卷积,使用填充p = (1, 1)和步长s = (2, 2)。根据指定的填充,每侧都添加了一层零,得到填充后的矩阵,如下所示:

图 14.6:计算输入和核矩阵之间的 2D 卷积

使用上述滤波器,旋转后的滤波器将是:

请注意,此旋转与转置矩阵不同。在 NumPy 中获取旋转滤波器,我们可以写成W_rot=W[::-1,::-1]。接下来,我们可以将旋转的滤波器矩阵沿着填充的输入矩阵X^(padded)移动,如滑动窗口一样,并计算元素乘积的和,这在图 14.7中由运算符表示:

图 14.7:计算元素乘积的和

结果将是 2×2 矩阵Y

让我们根据描述的朴素算法也实现 2D 卷积。scipy.signal包提供了通过scipy.signal.convolve2d函数计算 2D 卷积的方法:

>>> 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]] 

高效计算卷积的算法

我们提供了一个朴素的实现来计算 2D 卷积,以便理解这些概念。然而,这种实现在内存需求和计算复杂度方面非常低效。因此,在现实世界的神经网络应用中不应该使用它。

一个方面是,在大多数工具(如 PyTorch)中,滤波器矩阵实际上并没有旋转。此外,近年来已开发出更高效的算法,使用傅里叶变换来计算卷积。还要注意,在神经网络的背景下,卷积核的大小通常远小于输入图像的大小。

例如,现代 CNN 通常使用 1×1、3×3 或 5×5 的核大小,为此已设计出更高效的算法,可以更有效地执行卷积操作,如 Winograd 的最小过滤算法。这些算法超出了本书的范围,但如果您有兴趣了解更多,可以阅读 Andrew Lavin 和 Scott Gray 在 2015 年撰写的手稿Fast Algorithms for Convolutional Neural Networks,可在arxiv.org/abs/1509.09308免费获取。

在下一节中,我们将讨论 CNN 中经常使用的另一重要操作,即下采样或池化。

下采样层

在 CNN 中,最大池化平均池化(又称均值池化)通常以两种形式进行下采样操作。池化层通常用表示。此处的下标决定了邻域的大小(每个维度中相邻像素的数量),在这个邻域内进行最大或均值操作。我们将这样的邻域称为池化尺寸

操作在图 14.8中描述。在此,最大池化从像素邻域中取最大值,而平均池化计算它们的平均值:

图 14.8:最大池化和平均池化的示例

池化的优点是双重的:

  • 池化(最大池化)引入了局部不变性。这意味着局部邻域的小变化不会改变最大池化的结果。因此,它有助于生成对输入数据中噪声更加稳健的特征。请参考下面的示例,显示两个不同输入矩阵X[1]和X[2]的最大池化结果相同:

  • 池化减小了特征的大小,提高了计算效率。此外,减少特征的数量也可能降低过拟合的程度。

重叠与非重叠池化

传统上,池化被假定为非重叠的。池化通常在非重叠的邻域上执行,可以通过设置步幅参数等于池化大小来完成。例如,非重叠池化层,,需要一个步幅参数s = (n[1], n[2])。另一方面,如果步幅小于池化大小,则会发生重叠池化。描述重叠池化在卷积网络中使用的一个例子可见于A. KrizhevskyI. SutskeverG. Hinton于 2012 年的ImageNet Classification with Deep Convolutional Neural Networks,该文稿可以免费获取,网址为papers.nips.cc/paper/4824-imagenet-classification-with-deep-convolutional-neural-networks

尽管池化仍然是许多 CNN 架构的重要部分,但也已经开发出了几种不使用池化层的 CNN 架构。研究人员使用步幅为 2 的卷积层代替使用池化层来减少特征尺寸。

从某种意义上说,你可以将带有步幅 2 的卷积层视为带有可学习权重的池化层。如果你对使用和不使用池化层开发的不同 CNN 架构进行经验比较感兴趣,我们建议阅读Jost Tobias SpringenbergAlexey DosovitskiyThomas BroxMartin Riedmiller撰写的研究文章Striving for Simplicity: The All Convolutional Net。该文章可以免费获取,网址为arxiv.org/abs/1412.6806

将所有内容放在一起 - 实现一个 CNN

到目前为止,你已经学习了 CNN 的基本构建块。本章节中介绍的概念实际上并不比传统的多层神经网络更难。我们可以说,在传统 NN 中最重要的操作是矩阵乘法。例如,我们使用矩阵乘法来计算预激活(或净输入),如z = Wx + b。这里,x是一个列向量(矩阵),表示像素,而W是连接像素输入到每个隐藏单元的权重矩阵。

在 CNN 中,这个操作被卷积操作所取代,如,其中X是一个表示像素的矩阵,排列为高度×宽度。在这两种情况下,预激活被传递到激活函数以获取隐藏单元的激活,,其中是激活函数。此外,你会记得子采样是 CNN 的另一个构建块,可能以池化的形式出现,正如在前一节中所描述的。

使用多个输入或颜色通道

卷积层的输入可以包含一个或多个 2D 数组或矩阵,维度为N[1]×N[2](例如,像素的图像高度和宽度)。这些N[1]×N[2]矩阵称为通道。传统的卷积层实现期望输入为秩-3 张量表示,例如,三维数组,,其中C[in]是输入通道数。例如,让我们考虑图像作为 CNN 的第一层的输入。如果图像是彩色并使用 RGB 色彩模式,则C[in]=3(对应于 RGB 中的红色、绿色和蓝色通道)。然而,如果图像是灰度的,则C[in]=1,因为只有一个灰度像素强度通道。

读取图像文件

当我们处理图像时,可以使用uint8(无符号 8 位整数)数据类型将图像读入 NumPy 数组中,以减少内存使用,与 16 位、32 位或 64 位整数类型相比。

无符号 8 位整数的取值范围为[0, 255],足以存储 RGB 图像中的像素信息,其值也在同一范围内。

第十二章中,使用 PyTorch 并行化神经网络训练,你看到 PyTorch 提供了一个模块,用于通过torchvision加载/存储和操作图像。让我们回顾一下如何读取图像(本示例中的 RGB 图像位于本章提供的代码包文件夹中):

>>> import torch
>>> from torchvision.io import read_image
>>> img = read_image('example-image.png')
>>> print('Image shape:', img.shape)
Image shape: torch.Size([3, 252, 221])
>>> print('Number of channels:', img.shape[0])
Number of channels: 3
>>> print('Image data type:', img.dtype)
Image data type: torch.uint8
>>> print(img[:, 100:102, 100:102])
tensor([[[179, 182],
         [180, 182]],
        [[134, 136],
         [135, 137]],
        [[110, 112],
         [111, 113]]], dtype=torch.uint8) 

请注意,使用torchvision时,输入和输出的图像张量格式为Tensor[通道数, 图像高度, 图像宽度]

现在您熟悉输入数据的结构,接下来的问题是,我们如何在我们讨论的卷积操作中结合多个输入通道?答案非常简单:我们分别为每个通道执行卷积操作,然后使用矩阵求和将结果相加。与每个通道相关联的卷积(c)有其自己的核矩阵,例如W[:, :, c]。

总的预激活结果由以下公式计算:

最终结果A是一个特征映射。通常,CNN 的卷积层具有多个特征映射。如果使用多个特征映射,则核张量变为四维:宽度×高度×C[in]×C[out]。因此,现在让我们在前述公式中包含输出特征映射的数量,并进行更新,如下所示:

结束我们对在 NN 上下文中计算卷积的讨论,让我们看一下图 14.9 中的例子,展示了一个卷积层,后跟一个池化层。在这个例子中,有三个输入通道。卷积核张量是四维的。每个卷积核矩阵表示为m[1]×m[2],共有三个,每个输入通道一个。此外,有五个这样的卷积核,对应五个输出特征图。最后,有一个池化层用于对特征图进行子采样:

图 14.9:实现 CNN

在前面的例子中有多少个可训练参数?

为了说明卷积、参数共享和稀疏连接的优点,让我们通过一个例子来详细说明。网络中的卷积层是一个四维张量。因此,与卷积核相关联的参数数量为m[1]×m[2]×3×5。此外,每个卷积层输出特征图都有一个偏置向量。因此,偏置向量的尺寸是 5。池化层没有任何(可训练的)参数;因此,我们可以写成如下形式:

m[1] × m[2] × 3 × 5 + 5

如果输入张量的尺寸为n[1]×n[2]×3,假设使用同填充模式进行卷积,那么输出特征图的尺寸将是n[1] × n[2] × 5。

请注意,如果我们使用全连接层而不是卷积层,这个数字会大得多。在全连接层的情况下,达到相同输出单元数量的权重矩阵参数数量如下:

(n[1] × n[2] × 3) × (n[1] × n[2] × 5) = (n[1] × n[2])² × 3 × 5

此外,偏置向量的尺寸是n[1] × n[2] × 5(每个输出单元一个偏置元素)。鉴于m[1] < n[1]和m[2] < n[2],我们可以看到可训练参数数量的差异是显著的。

最后,正如之前提到的,卷积操作通常是通过将具有多个颜色通道的输入图像视为一堆矩阵来执行的;也就是说,我们分别对每个矩阵执行卷积,然后将结果相加,正如前面的图所示。但是,如果您处理的是 3D 数据集,例如,可以将卷积扩展到 3D 体积,如 Daniel Maturana 和 Sebastian Scherer 在 2015 年的论文《VoxNet:用于实时物体识别的 3D 卷积神经网络》中所示,可访问www.ri.cmu.edu/pub_files/2015/9/voxnet_maturana_scherer_iros15.pdf

在接下来的部分中,我们将讨论如何正则化一个神经网络。

使用 L2 正则化和 dropout 对 NN 进行正则化

选择网络的大小,无论是传统(全连接)NN 还是 CNN,一直都是一个具有挑战性的问题。例如,需要调整权重矩阵的大小和层数,以达到合理的性能。

您会从 第十三章深入了解 - PyTorch 的机制 中记得,一个没有隐藏层的简单网络只能捕捉线性决策边界,这对处理异或(或 XOR)或类似问题是不够的。网络的 容量 是指它可以学习逼近的函数复杂性级别。小网络或具有相对少参数的网络具有低容量,因此可能会 欠拟合,导致性能不佳,因为它们无法学习复杂数据集的底层结构。但是,非常大的网络可能会导致 过拟合,即网络会记住训练数据,并在训练数据集上表现极好,但在留置测试数据集上表现不佳。当我们处理现实中的机器学习问题时,我们不知道网络应该有多大 先验

解决这个问题的一种方法是构建一个具有相对较大容量的网络(在实践中,我们希望选择一个略大于必要的容量),以在训练数据集上表现良好。然后,为了防止过拟合,我们可以应用一个或多个正则化方案,以在新数据(如留置测试数据集)上实现良好的泛化性能。

第三章第四章 中,我们介绍了 L1 和 L2 正则化。这两种技术都可以通过在训练过程中对损失函数增加惩罚来缩小权重参数,从而防止或减少过拟合的影响。虽然 L1 和 L2 正则化都可以用于神经网络,L2 是其中更常见的选择,但对于神经网络的正则化还有其他方法,如我们在本节讨论的 dropout。但在讨论 dropout 之前,在卷积或全连接网络中使用 L2 正则化(回想一下,全连接层是通过 torch.nn.Linear 在 PyTorch 中实现的),你可以简单地将特定层的 L2 惩罚添加到损失函数中,如下所示:

>>> import torch.nn as nn
>>> loss_func = nn.BCELoss()
>>> loss = loss_func(torch.tensor([0.9]), torch.tensor([1.0]))
>>> l2_lambda = 0.001
>>> conv_layer = nn.Conv2d(in_channels=3,
...                        out_channels=5,
...                        kernel_size=5)
>>> l2_penalty = l2_lambda * sum(
...     [(p**2).sum() for p in conv_layer.parameters()]
... )
>>> loss_with_penalty = loss + l2_penalty
>>> linear_layer = nn.Linear(10, 16)
>>> l2_penalty = l2_lambda * sum(
...     [(p**2).sum() for p in linear_layer.parameters()]
... )
>>> loss_with_penalty = loss + l2_penalty 

权重衰减与 L2 正则化

通过在 PyTorch 优化器中将 weight_decay 参数设置为正值,可以替代使用 L2 正则化的另一种方法,例如:

optimizer = torch.optim.SGD(
    model.parameters(),
    weight_decay=l2_lambda,
    ...
) 

虽然 L2 正则化和 weight_decay 不严格相同,但可以证明它们在使用 随机梯度下降SGD)优化器时是等效的。感兴趣的读者可以在 Ilya LoshchilovFrank Hutter,2019 年的文章 Decoupled Weight Decay Regularization 中找到更多信息,该文章可以免费在 arxiv.org/abs/1711.05101 上获取。

近年来,dropout 已经成为一种流行的技术,用于正则化(深度)神经网络以避免过拟合,从而提高泛化性能(Dropout: A Simple Way to Prevent Neural Networks from Overfitting,作者为 N. SrivastavaG. HintonA. KrizhevskyI. SutskeverR. SalakhutdinovJournal of Machine Learning Research 15.1,页面 1929-1958,2014 年,www.jmlr.org/papers/volume15/srivastava14a/srivastava14a.pdf)。dropout 通常应用于更高层的隐藏单元,并按以下方式工作:在 NN 的训练阶段,每次迭代时随机丢弃一部分隐藏单元,丢弃概率为 p[drop](或保留概率 p[keep] = 1 - p[drop])。此丢弃概率由用户确定,常见选择为 p = 0.5,如前述的 Nitish Srivastava 等人在 2014 年的文章中讨论的那样。当丢弃一定比例的输入神经元时,与剩余神经元相关联的权重会重新缩放,以考虑缺失的(丢弃的)神经元。

这种随机 dropout 的效果是,网络被迫学习数据的冗余表示。因此,网络不能依赖于任何一组隐藏单元的激活,因为它们在训练过程中可能随时关闭,并被迫从数据中学习更一般和更稳健的模式。

这种随机 dropout 可以有效地防止过拟合。图 14.10 显示了在训练阶段应用 dropout 的示例,其中丢失概率为 p = 0.5,因此在每次训练的前向传递中,一半的神经元将随机失活。然而,在预测阶段,所有神经元将有助于计算下一层的预激活:

图 14.10:在训练阶段应用 dropout

正如所示,一个重要的记住的要点是,单位在训练过程中可能会随机丢失,而在评估(推断)阶段,所有隐藏单元必须处于活跃状态(例如,p[drop] = 0 或 p[keep] = 1)。为了确保训练和预测期间的总激活在同一尺度上,必须适当缩放活跃神经元的激活(例如,如果丢失概率设置为 p = 0.5,则通过减半激活来进行缩放)。

然而,由于在进行预测时始终缩放激活比较麻烦,因此 PyTorch 和其他工具在训练期间缩放激活(例如,如果丢失概率设置为 p = 0.5,则通过加倍激活)。这种方法通常被称为 逆 dropout

虽然关系并不立即明显,dropout 可以被解释为一组模型的共识(平均)。正如在第七章集成学习中的不同模型组合中讨论的那样,在集成学习中,我们独立训练几个模型。在预测期间,我们然后使用所有训练模型的共识。我们已经知道,模型集成比单个模型表现更好。然而,在深度学习中,训练多个模型、收集和平均多个模型的输出都是计算昂贵的。在这里,dropout 提供了一种解决方案,以一种有效的方式同时训练多个模型,并在测试或预测时计算它们的平均预测。

正如之前提到的,模型集成与 dropout 之间的关系并不立即明显。但是请考虑,在 dropout 中,由于在每次前向传递期间随机将权重设置为零,我们对每个小批量都有一个不同的模型。

然后,通过迭代小批量,我们本质上对M = 2^h 个模型进行采样,其中h是隐藏单元的数量。

与常规集成不同,dropout 的限制和方面在于我们在这些“不同模型”上共享权重,这可以看作是一种正则化形式。然后,在“推断”期间(例如,在测试数据集中预测标签),我们可以对训练过程中采样的所有这些不同模型进行平均。尽管如此,这是非常昂贵的。

然后,对模型进行平均化,即计算模型i返回的类成员概率的几何平均,可以如下计算:

现在,dropout 背后的技巧在于,这个模型集合(这里是M个模型)的几何平均可以通过在训练过程中采样的最后一个(或最终)模型的预测,乘以一个因子 1/(1 – p)来近似计算,这比显式计算使用上述方程求解几何平均要便宜得多。(事实上,如果我们考虑线性模型,这个近似等同于真正的几何平均。)

用于分类的损失函数

第十二章使用 PyTorch 并行化神经网络训练中,我们看到了不同的激活函数,如 ReLU、sigmoid 和 tanh。这些激活函数中的一些,如 ReLU,主要用于神经网络的中间(隐藏)层,以为我们的模型添加非线性。但其他的,如 sigmoid(用于二元)和 softmax(用于多类别),则添加在最后(输出)层,将结果作为模型的类成员概率输出。如果在输出层没有包含 sigmoid 或 softmax 激活函数,则模型将计算 logits 而不是类成员概率。

在这里关注分类问题,根据问题的类型(二元分类还是多类分类)和输出的类型(logits 还是概率),我们应选择适当的损失函数来训练我们的模型。二元交叉熵是二元分类(具有单个输出单元)的损失函数,分类交叉熵是多类分类的损失函数。在torch.nn模块中,分类交叉熵损失接受整数形式的真实标签(例如,y=2,对于三个类别 0、1 和 2)。

图 14.11描述了在torch.nn中可用的两个损失函数,用于处理二元分类和整数标签的多类分类。这两个损失函数中的每一个还可以选择以 logits 或类成员概率的形式接收预测值:

Table  Description automatically generated

图 14.11: PyTorch 中两个损失函数的示例

请注意,由于数值稳定性原因,通常通过提供 logits 而不是类成员概率来计算交叉熵损失更为可取。对于二元分类,我们可以将 logits 作为输入提供给损失函数nn.BCEWithLogitsLoss(),或者基于 logits 计算概率并将其馈送给损失函数nn.BCELoss()。对于多类分类,我们可以将 logits 作为输入提供给损失函数nn.CrossEntropyLoss(),或者基于 logits 计算对数概率并将其馈送给负对数似然损失函数nn.NLLLoss()

下面的代码将向您展示如何使用这些损失函数来处理两种不同的格式,其中损失函数的输入可以是 logits 或类成员概率:

>>> ####### Binary Cross-entropy
>>> logits = torch.tensor([0.8])
>>> probas = torch.sigmoid(logits)
>>> target = torch.tensor([1.0])
>>> bce_loss_fn = nn.BCELoss()
>>> bce_logits_loss_fn = nn.BCEWithLogitsLoss()
>>> print(f'BCE (w Probas): {bce_loss_fn(probas, target):.4f}')
BCE (w Probas): 0.3711
>>> print(f'BCE (w Logits): '
...       f'{bce_logits_loss_fn(logits, target):.4f}')
BCE (w Logits): 0.3711
>>> ####### Categorical Cross-entropy
>>> logits = torch.tensor([[1.5, 0.8, 2.1]])
>>> probas = torch.softmax(logits, dim=1)
>>> target = torch.tensor([2])
>>> cce_loss_fn = nn.NLLLoss()
>>> cce_logits_loss_fn = nn.CrossEntropyLoss()
>>> print(f'CCE (w Probas): '
...       f'{cce_logits_loss_fn(logits, target):.4f}')
CCE (w Probas): 0.5996
>>> print(f'CCE (w Logits): '
...       f'{cce_loss_fn(torch.log(probas), target):.4f}')
CCE (w Logits): 0.5996 

请注意,有时您可能会遇到使用分类交叉熵损失进行二元分类的实现。通常情况下,当我们有一个二元分类任务时,模型为每个示例返回单个输出值。我们将这个单一模型输出解释为正类(例如类 1)的概率 P(class = 1|x)。在二元分类问题中,我们隐含地有 P(class = 0|x)= 1 – P(class = 1|x),因此我们不需要第二个输出单元来获取负类的概率。然而,有时实践者选择为每个训练示例返回两个输出,并将其解释为每个类的概率:P(class = 0|x)与P(class = 1|x)。在这种情况下,建议使用 softmax 函数(而不是逻辑 sigmoid)来归一化输出(使它们总和为 1),并且分类交叉熵是适当的损失函数。

使用 PyTorch 实现深度 CNN

正如您可能还记得的第十三章中,我们使用torch.nn模块解决了手写数字识别问题。您可能还记得,我们使用具有两个线性隐藏层的 NN 达到了约 95.6%的准确率。

现在,让我们实现一个 CNN,看看它是否能够比之前的模型在分类手写数字方面实现更好的预测性能。请注意,在第十三章中看到的全连接层在此问题上表现良好。然而,在某些应用中,比如从手写数字中读取银行账号号码,即使是微小的错误也可能非常昂贵。因此,尽可能减少这种错误至关重要。

多层 CNN 架构

我们将要实现的网络架构如图 14.12所示。输入为 28×28 的灰度图像。考虑到通道数(对于灰度图像为 1)和输入图像的批量,输入张量的维度将是batchsize×28×28×1。

输入数据通过两个卷积层,卷积核大小为 5×5。第一个卷积层有 32 个输出特征图,第二个卷积层有 64 个输出特征图。每个卷积层后面跟着一个池化层,采用最大池化操作,P[2×2]。然后一个全连接层将输出传递给第二个全连接层,它作为最终的softmax输出层。我们将要实现的网络架构如图 14.12所示:

图 14.12:一个深度 CNN

每层张量的尺寸如下:

  • 输入:[batchsize×28×28×1]

  • 卷积 _1:[batchsize×28×28×32]

  • 池化 _1:[batchsize×14×14×32]

  • 卷积 _2:[batchsize×14×14×64]

  • 池化 _2:[batchsize×7×7×64]

  • FC_1:[batchsize×1024]

  • FC_2 和 softmax 层:[batchsize×10]

对于卷积核,我们使用stride=1以保持输入维度在生成的特征图中的尺寸不变。对于池化层,我们使用kernel_size=2来对图像进行子采样并缩小输出特征图的尺寸。我们将使用 PyTorch NN 模块来实现这个网络。

加载和预处理数据

首先,我们将使用torchvision模块加载 MNIST 数据集,并构建训练集和测试集,就像在第十三章中一样:

>>> import torchvision
>>> from torchvision import transforms
>>> image_path = './'
>>> transform = transforms.Compose([
...     transforms.ToTensor()
... ])
>>> mnist_dataset = torchvision.datasets.MNIST(
...     root=image_path, train=True,
...     transform=transform, download=True
... )
>>> from torch.utils.data import Subset
>>> mnist_valid_dataset = Subset(mnist_dataset,
...                              torch.arange(10000))
>>> mnist_train_dataset = Subset(mnist_dataset,
...                              torch.arange(
...                                  10000, len(mnist_dataset)
...                              ))
>>> mnist_test_dataset = torchvision.datasets.MNIST(
...     root=image_path, train=False,
...     transform=transform, download=False
... ) 

MNIST 数据集附带了一个预先指定的训练和测试数据集分割方案,但我们还想从训练分区创建一个验证集分割。因此,我们使用了前 10,000 个训练示例用于验证。注意,图像并未按类标签排序,因此我们不必担心这些验证集图像是否来自相同的类别。

接下来,我们将使用 64 个图像的批量构建数据加载器,分别用于训练集和验证集:

>>> from torch.utils.data import DataLoader
>>> batch_size = 64
>>> torch.manual_seed(1)
>>> train_dl = DataLoader(mnist_train_dataset,
...                       batch_size,
...                       shuffle=True)
>>> valid_dl = DataLoader(mnist_valid_dataset,
...                       batch_size,
...                       shuffle=False) 

我们读取的特征值范围是[0, 1]。此外,我们已经将图像转换为张量。标签是 0 到 9 的整数,表示十个数字。因此,我们不需要进行任何缩放或进一步的转换。

现在,在准备好数据集后,我们可以开始实现刚刚描述的 CNN 了。

使用torch.nn模块实现 CNN

在 PyTorch 中实现 CNN 时,我们使用torch.nnSequential类来堆叠不同的层,如卷积层、池化层、dropout 以及全连接层。torch.nn模块为每个类提供了具体的实现:nn.Conv2d用于二维卷积层;nn.MaxPool2dnn.AvgPool2d用于子采样(最大池化和平均池化);nn.Dropout用于使用 dropout 进行正则化。我们将详细介绍每个类。

配置 PyTorch 中的 CNN 层

使用Conv2d类构建层需要指定输出通道数(等同于输出特征图的数量或输出滤波器的数量)和内核大小。

此外,还有一些可选参数,我们可以用来配置卷积层。最常用的是步长(在xy维度上都默认为 1)和填充参数,它控制了两个维度上的隐式填充量。更多配置参数详见官方文档:pytorch.org/docs/stable/generated/torch.nn.Conv2d.html

值得一提的是,通常当我们读取一幅图像时,默认的通道维度是张量数组的第一维度(或考虑批处理维度时的第二维度)。这称为 NCHW 格式,其中N代表批处理中的图像数量,C代表通道数,HW分别代表高度和宽度。

请注意,默认情况下,Conv2D类假定输入数据采用 NCHW 格式。(其他工具如 TensorFlow 采用 NHWC 格式。)然而,如果你遇到一些数据,其通道放置在最后一个维度,你需要交换数据的轴,将通道移到第一维度(或考虑批处理维度时的第二维度)。构建完层之后,可以通过提供四维张量进行调用,其中第一维度保留给示例的批处理,第二维度对应通道,其余两个维度是空间维度。

如我们要构建的 CNN 模型的结构所示,每个卷积层后面都跟着一个池化层进行子采样(减小特征映射的大小)。MaxPool2dAvgPool2d 类分别构建最大池化层和平均池化层。kernel_size 参数确定将用于计算最大或均值操作的窗口(或邻域)的大小。此外,如前所述,stride 参数可用于配置池化层。

最后,Dropout 类将构建用于正则化的 dropout 层,其中参数 p 表示 p [dropout] 的丢弃概率,该概率用于在训练期间确定是否丢弃输入单元,正如我们之前讨论的那样。在调用该层时,可以通过 model.train()model.eval() 控制其行为,以指定该调用是在训练期间还是推断期间进行的。在使用 dropout 时,交替使用这两种模式至关重要,以确保其行为正确;例如,在训练期间仅随机丢弃节点,而在评估或推断期间则不会。

在 PyTorch 中构建 CNN

现在您已经了解了这些类,我们可以构建之前图示的 CNN 模型。在以下代码中,我们将使用 Sequential 类并添加卷积和池化层:

>>> model = nn.Sequential()
>>> model.add_module(
...     'conv1',
...     nn.Conv2d(
...         in_channels=1, out_channels=32,
...         kernel_size=5, padding=2
...     )
... )
>>> model.add_module('relu1', nn.ReLU())
>>> model.add_module('pool1', nn.MaxPool2d(kernel_size=2))
>>> model.add_module(
...     'conv2',
...     nn.Conv2d(
...         in_channels=32, out_channels=64,
...         kernel_size=5, padding=2
...     )
... )
>>> model.add_module('relu2', nn.ReLU())
>>> model.add_module('pool2', nn.MaxPool2d(kernel_size=2)) 

到目前为止,我们已向模型添加了两个卷积层。对于每个卷积层,我们使用了大小为 5×5 的核和 padding=2。正如前面讨论的,使用相同的填充模式保留了特征映射的空间尺寸(垂直和水平维度),使得输入和输出具有相同的高度和宽度(通道数量可能仅在使用的滤波器数量方面有所不同)。如前所述,输出特征映射的空间尺寸由以下计算得出:

其中 n 是输入特征映射的空间维度,pms 分别表示填充、核大小和步长。我们设置 p = 2 以实现 o = i

池化大小为 2×2,步长为 2 的最大池化层将空间尺寸减半。(请注意,如果在 MaxPool2D 中未指定 stride 参数,默认情况下设置为与池化核大小相同。)

虽然我们可以手动计算此阶段的特征映射大小,但 PyTorch 提供了一个方便的方法来为我们计算:

>>> x = torch.ones((4, 1, 28, 28))
>>> model(x).shape
torch.Size([4, 64, 7, 7]) 

通过将输入形状作为元组 (4, 1, 28, 28) 提供(在本示例中指定),我们计算输出形状为 (4, 64, 7, 7),表示具有 64 个通道和空间尺寸为 7×7 的特征映射。第一个维度对应于批处理维度,我们任意地使用了 4。

我们接下来要添加的下一层是一个完全连接的层,用于在我们的卷积和池化层之上实现分类器。这一层的输入必须具有秩为 2 的形状,即形状 [batchsize × input_units]。因此,我们需要展平先前层的输出,以满足完全连接层的需求:

>>> model.add_module('flatten', nn.Flatten())
>>> x = torch.ones((4, 1, 28, 28))
>>> model(x).shape
torch.Size([4, 3136]) 

正如输出形状所示,完全连接层的输入维度已经正确设置。接下来,我们将在中间添加两个完全连接层和一个 dropout 层:

>>> model.add_module('fc1', nn.Linear(3136, 1024))
>>> model.add_module('relu3', nn.ReLU())
>>> model.add_module('dropout', nn.Dropout(p=0.5))
>>> model.add_module('fc2', nn.Linear(1024, 10)) 

最后一个全连接层名为 'fc2',为 MNIST 数据集中的 10 个类标签具有 10 个输出单元。在实践中,我们通常使用 softmax 激活函数来获得每个输入示例的类成员概率,假设类别是互斥的,因此每个示例的概率总和为 1。然而,softmax 函数已经在 PyTorch 的 CrossEntropyLoss 实现内部使用,因此我们不必在上述输出层之后显式添加它。以下代码将为模型创建损失函数和优化器:

>>> loss_fn = nn.CrossEntropyLoss()
>>> optimizer = torch.optim.Adam(model.parameters(), lr=0.001) 

Adam 优化器

在这个实现中,我们使用了用于训练 CNN 模型的 torch.optim.Adam 类。Adam 优化器是一种强大的基于梯度的优化方法,适用于非凸优化和机器学习问题。两种受到 Adam 启发的流行优化方法是 RMSPropAdaGrad

Adam 的关键优势在于从梯度矩时的运行平均值中派生更新步长的选择。请随意阅读更多关于 Adam 优化器的内容,可参考 Diederik P. KingmaJimmy Lei Ba 在 2014 年的论文 Adam: A Method for Stochastic Optimization。该文章可以在 arxiv.org/abs/1412.6980 自由获取。

现在我们可以通过定义以下函数来训练模型:

>>> def train(model, num_epochs, train_dl, valid_dl):
...     loss_hist_train = [0] * num_epochs
...     accuracy_hist_train = [0] * num_epochs
...     loss_hist_valid = [0] * num_epochs
...     accuracy_hist_valid = [0] * num_epochs
...     for epoch in range(num_epochs):
...         model.train()
...         for x_batch, y_batch in train_dl:
...             pred = model(x_batch)
...             loss = loss_fn(pred, y_batch)
...             loss.backward()
...             optimizer.step()
...             optimizer.zero_grad()
...             loss_hist_train[epoch] += loss.item()*y_batch.size(0)
...             is_correct = (
...                 torch.argmax(pred, dim=1) == y_batch
...             ).float()
...             accuracy_hist_train[epoch] += is_correct.sum()
...         loss_hist_train[epoch] /= len(train_dl.dataset)
...         accuracy_hist_train[epoch] /= len(train_dl.dataset)
...
...         model.eval()
...         with torch.no_grad():
...             for x_batch, y_batch in valid_dl:
...                 pred = model(x_batch)
...                 loss = loss_fn(pred, y_batch)
...                 loss_hist_valid[epoch] += \
...                     loss.item()*y_batch.size(0)
...                 is_correct = (
...                     torch.argmax(pred, dim=1) == y_batch
...                 ).float()
...                 accuracy_hist_valid[epoch] += is_correct.sum()
...         loss_hist_valid[epoch] /= len(valid_dl.dataset)
...         accuracy_hist_valid[epoch] /= len(valid_dl.dataset)
...
...         print(f'Epoch {epoch+1} accuracy: '
...               f'{accuracy_hist_train[epoch]:.4f} val_accuracy: '
...               f'{accuracy_hist_valid[epoch]:.4f}')
...     return loss_hist_train, loss_hist_valid, \
...            accuracy_hist_train, accuracy_hist_valid 

注意,使用指定的训练设置 model.train() 和评估 model.eval() 将自动设置 dropout 层的模式并适当调整隐藏单元的比例,因此我们无需担心这些问题。接下来,我们将训练这个 CNN 模型,并使用我们为监控学习进度创建的验证数据集:

>>> torch.manual_seed(1)
>>> num_epochs = 20
>>> hist = train(model, num_epochs, train_dl, valid_dl)
Epoch 1 accuracy: 0.9503 val_accuracy: 0.9802
...
Epoch 9 accuracy: 0.9968 val_accuracy: 0.9892
...
Epoch 20 accuracy: 0.9979 val_accuracy: 0.9907 

当完成了 20 个周期的训练后,我们可以可视化学习曲线:

>>> import matplotlib.pyplot as plt
>>> x_arr = np.arange(len(hist[0])) + 1
>>> fig = plt.figure(figsize=(12, 4))
>>> ax = fig.add_subplot(1, 2, 1)
>>> ax.plot(x_arr, hist[0], '-o', label='Train loss')
>>> ax.plot(x_arr, hist[1], '--<', label='Validation loss')
>>> ax.legend(fontsize=15)
>>> ax = fig.add_subplot(1, 2, 2)
>>> ax.plot(x_arr, hist[2], '-o', label='Train acc.')
>>> ax.plot(x_arr, hist[3], '--<',
...         label='Validation acc.')
>>> ax.legend(fontsize=15)
>>> ax.set_xlabel('Epoch', size=15)
>>> ax.set_ylabel('Accuracy', size=15)
>>> plt.show() 

图 14.13:训练和验证数据的损失和准确率图表

现在,我们在测试数据集上评估训练好的模型:

>>> pred = model(mnist_test_dataset.data.unsqueeze(1) / 255.)
>>> is_correct = (
...     torch.argmax(pred, dim=1) == mnist_test_dataset.targets
... ).float()
>>> print(f'Test accuracy: {is_correct.mean():.4f}')
Test accuracy: 0.9914 

CNN 模型达到了 99.07% 的准确率。请记住,在 第十三章 中,我们仅使用全连接(而不是卷积)层时,准确率约为 95%。

最后,我们可以通过使用torch.argmax函数将类成员概率形式的预测结果转换为预测标签。我们将对一批 12 个示例执行此操作,并可视化输入和预测标签:

>>> 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 = mnist_test_dataset[i][0][0, :, :]
...     pred = model(img.unsqueeze(0).unsqueeze(1))
...     y_pred = torch.argmax(pred)
...     ax.imshow(img, cmap='gray_r')
...     ax.text(0.9, 0.1, y_pred.item(),
...             size=15, color='blue',
...             horizontalalignment='center',
...             verticalalignment='center',
...             transform=ax.transAxes)
>>> plt.show() 

图 14.14显示了手写输入及其预测标签:

图 14.14:手写数字的预测标签

在这组绘图示例中,所有预测标签都是正确的。

我们留给读者作为练习的任务是展示一些被错误分类的数字,就像我们在第十一章从头开始实现多层人工神经网络中所做的那样。

使用 CNN 从面部图像进行微笑分类

在本节中,我们将使用 CelebA 数据集实现一个 CNN,用于从面部图像进行微笑分类。正如您在第十二章中看到的那样,CelebA 数据集包含 202,599 张名人面部的图像。此外,每个图像还有 40 个二进制面部属性,包括名人是否微笑以及他们的年龄(年轻或老年)。

基于你目前所学的内容,本节的目标是构建并训练一个 CNN 模型,用于从这些面部图像中预测微笑属性。在这里,为了简化起见,我们将仅使用训练数据的一小部分(16,000 个训练示例)来加快训练过程。然而,为了提高泛化性能并减少在这样一个小数据集上的过拟合,我们将使用一种称为数据增强的技术。

加载 CelebA 数据集

首先,让我们加载数据,类似于我们在前一节中为 MNIST 数据集所做的方式。CelebA 数据集分为三个部分:训练数据集、验证数据集和测试数据集。接下来,我们将计算每个分区中的示例数量:

>>> image_path = './'
>>> celeba_train_dataset = torchvision.datasets.CelebA(
...     image_path, split='train',
...     target_type='attr', download=True
... )
>>> celeba_valid_dataset = torchvision.datasets.CelebA(
...     image_path, split='valid',
...     target_type='attr', download=True
... )
>>> celeba_test_dataset = torchvision.datasets.CelebA(
...     image_path, split='test',
...     target_type='attr', download=True
... )
>>>
>>> print('Train set:', len(celeba_train_dataset))
Train set:  162770
>>> print('Validation set:', len(celeba_valid_dataset))
Validation: 19867
>>> print('Test set:', len(celeba_test_dataset))
Test set:   19962 

下载 CelebA 数据集的替代方法

CelebA 数据集相对较大(约 1.5 GB),而 torchvision 的下载链接声名狼藉。如果您在执行前述代码时遇到问题,可以手动从官方 CelebA 网站下载文件(mmlab.ie.cuhk.edu.hk/projects/CelebA.html),或使用我们的下载链接:drive.google.com/file/d/1m8-EBPgi5MRubrm6iQjafK2QMHDBMSfJ/view?usp=sharing。如果您使用我们的下载链接,它将下载一个 celeba.zip 文件,您需要在运行代码的当前目录中解压此文件夹。此外,在下载并解压 celeba 文件夹后,您需要使用设置 download=False 而不是 download=True 重新运行上面的代码。如果您在使用此方法时遇到问题,请不要犹豫,打开一个新问题或在 github.com/rasbt/machine-learning-book 上开始讨论,以便我们为您提供额外的信息。

接下来,我们将讨论数据增强作为提高深度神经网络性能的一种技术。

图像转换和数据增强

数据增强总结了一系列技术,用于处理训练数据有限的情况。例如,某些数据增强技术允许我们修改或甚至人为合成更多数据,从而通过减少过拟合来提升机器或深度学习模型的性能。虽然数据增强不仅适用于图像数据,但有一组独特适用于图像数据的转换技术,例如裁剪图像的部分、翻转、以及调整对比度、亮度和饱和度。让我们看看一些这些转换,这些转换可以通过 torchvision.transforms 模块获得。在下面的代码块中,我们将首先从 celeba_train_dataset 数据集获取五个示例,并应用五种不同类型的转换:1)将图像裁剪到边界框,2)水平翻转图像,3)调整对比度,4)调整亮度,以及 5)中心裁剪图像并将结果图像调整回其原始大小(218, 178)。在下面的代码中,我们将可视化这些转换的结果,将每个结果显示在单独的列中进行比较:

>>> fig = plt.figure(figsize=(16, 8.5))
>>> ## Column 1: cropping to a bounding-box
>>> ax = fig.add_subplot(2, 5, 1)
>>> img, attr = celeba_train_dataset[0]
>>> ax.set_title('Crop to a \nbounding-box', size=15)
>>> ax.imshow(img)
>>> ax = fig.add_subplot(2, 5, 6)
>>> img_cropped = transforms.functional.crop(img, 50, 20, 128, 128)
>>> ax.imshow(img_cropped)
>>> 
>>> ## Column 2: flipping (horizontally)
>>> ax = fig.add_subplot(2, 5, 2)
>>> img, attr = celeba_train_dataset[1]
>>> ax.set_title('Flip (horizontal)', size=15)
>>> ax.imshow(img)
>>> ax = fig.add_subplot(2, 5, 7)
>>> img_flipped = transforms.functional.hflip(img)
>>> ax.imshow(img_flipped)
>>> 
>>> ## Column 3: adjust contrast
>>> ax = fig.add_subplot(2, 5, 3)
>>> img, attr = celeba_train_dataset[2]
>>> ax.set_title('Adjust constrast', size=15)
>>> ax.imshow(img)
>>> ax = fig.add_subplot(2, 5, 8)
>>> img_adj_contrast = transforms.functional.adjust_contrast(
...     img, contrast_factor=2
... )
>>> ax.imshow(img_adj_contrast)
>>> 
>>> ## Column 4: adjust brightness
>>> ax = fig.add_subplot(2, 5, 4)
>>> img, attr = celeba_train_dataset[3]
>>> ax.set_title('Adjust brightness', size=15)
>>> ax.imshow(img)
>>> ax = fig.add_subplot(2, 5, 9)
>>> img_adj_brightness = transforms.functional.adjust_brightness(
...     img, brightness_factor=1.3
... )
>>> ax.imshow(img_adj_brightness)
>>> 
>>> ## Column 5: cropping from image center
>>> ax = fig.add_subplot(2, 5, 5)
>>> img, attr = celeba_train_dataset[4]
>>> ax.set_title('Center crop\nand resize', size=15)
>>> ax.imshow(img)
>>> ax = fig.add_subplot(2, 5, 10)
>>> img_center_crop = transforms.functional.center_crop(
...     img, [0.7*218, 0.7*178]
... )
>>> img_resized = transforms.functional.resize(
...     img_center_crop, size=(218, 178)
... )
>>> ax.imshow(img_resized)
>>> plt.show() 

图 14.15 展示了结果:

图 14.15:不同的图像转换

图 14.15中,第一行显示了原始图像,第二行显示了它们的变换版本。请注意,对于第一个变换(最左侧列),边界框由四个数字指定:边界框的左上角坐标(这里 x=20,y=50)以及框的宽度和高度(宽度=128,高度=128)。还请注意,PyTorch(以及其他软件包如 imageio)加载的图像的原点(位于位置 (0, 0) 处的坐标)是图像的左上角。

前面代码块中的变换是确定性的。然而,建议在模型训练期间对所有这些变换进行随机化。例如,可以从图像中随机裁剪一个随机边界框(其中上左角的坐标被随机选择),可以以概率 0.5 随机沿水平或垂直轴翻转图像,或者可以随机更改图像的对比度,其中 contrast_factor 是从值范围内随机选择的,但服从均匀分布。此外,我们还可以创建这些变换的流水线。

例如,我们可以首先随机裁剪图像,然后随机翻转它,最后将其调整为所需大小。代码如下(由于涉及随机元素,我们设置了随机种子以确保可重现性):

>>> torch.manual_seed(1)
>>> fig = plt.figure(figsize=(14, 12))
>>> for i, (img, attr) in enumerate(celeba_train_dataset):
...     ax = fig.add_subplot(3, 4, i*4+1)
...     ax.imshow(img)
...     if i == 0:
...         ax.set_title('Orig.', size=15)
...
...     ax = fig.add_subplot(3, 4, i*4+2)
...     img_transform = transforms.Compose([
...         transforms.RandomCrop([178, 178])
...     ])
...     img_cropped = img_transform(img)
...     ax.imshow(img_cropped)
...     if i == 0:
...         ax.set_title('Step 1: Random crop', size=15)
...
...     ax = fig.add_subplot(3, 4, i*4+3)
...     img_transform = transforms.Compose([
...         transforms.RandomHorizontalFlip()
...     ])
...     img_flip = img_transform(img_cropped)
...     ax.imshow(img_flip)
...     if i == 0:
...         ax.set_title('Step 2: Random flip', size=15)
...
...     ax = fig.add_subplot(3, 4, i*4+4)
...     img_resized = transforms.functional.resize(
...         img_flip, size=(128, 128)
...     )
...     ax.imshow(img_resized)
...     if i == 0:
...         ax.set_title('Step 3: Resize', size=15)
...     if i == 2:
...         break
>>> plt.show() 

图 14.16 展示了三个示例图像的随机变换:

图 14.16: 随机图像变换

请注意,每次迭代这三个示例时,由于随机变换,我们会得到略有不同的图像。

为方便起见,我们可以定义变换函数以在数据集加载期间使用此流程进行数据增强。在下面的代码中,我们将定义函数get_smile,它将从 'attributes' 列表中提取笑脸标签:

>>> get_smile = lambda attr: attr[18] 

我们将定义transform_train函数,它将生成变换后的图像(我们将首先随机裁剪图像,然后随机翻转它,最后将其调整为所需大小 64×64):

>>> transform_train = transforms.Compose([
...     transforms.RandomCrop([178, 178]),
...     transforms.RandomHorizontalFlip(),
...     transforms.Resize([64, 64]),
...     transforms.ToTensor(),
... ]) 

我们仅对训练样本应用数据增强,而不应用于验证或测试图像。验证或测试集的代码如下(我们首先简单地裁剪图像,然后将其调整为所需大小 64×64):

>>> transform = transforms.Compose([
...     transforms.CenterCrop([178, 178]),
...     transforms.Resize([64, 64]),
...     transforms.ToTensor(),
... ]) 

现在,为了看到数据增强的效果,让我们将transform_train函数应用于我们的训练数据集,并迭代数据集五次:

>>> from torch.utils.data import DataLoader
>>> celeba_train_dataset = torchvision.datasets.CelebA(
...     image_path, split='train',
...     target_type='attr', download=False,
...     transform=transform_train, target_transform=get_smile
... )
>>> torch.manual_seed(1)
>>> data_loader = DataLoader(celeba_train_dataset, batch_size=2)
>>> fig = plt.figure(figsize=(15, 6))
>>> num_epochs = 5
>>> for j in range(num_epochs):
...     img_batch, label_batch = next(iter(data_loader))
...     img = img_batch[0]
...     ax = fig.add_subplot(2, 5, j + 1)
...     ax.set_xticks([])
...     ax.set_yticks([])
...     ax.set_title(f'Epoch {j}:', size=15)
...     ax.imshow(img.permute(1, 2, 0))
...
...     img = img_batch[1]
...     ax = fig.add_subplot(2, 5, j + 6)
...     ax.set_xticks([])
...     ax.set_yticks([])
...     ax.imshow(img.permute(1, 2, 0))
>>> plt.show() 

图 14.17 展示了两个示例图像的五种数据增强结果:

图 14.17: 五种图像变换的结果

接下来,我们将对验证和测试数据集应用transform函数:

>>> celeba_valid_dataset = torchvision.datasets.CelebA(
...     image_path, split='valid',
...     target_type='attr', download=False,
...     transform=transform, target_transform=get_smile
... )
>>> celeba_test_dataset = torchvision.datasets.CelebA(
...     image_path, split='test',
...     target_type='attr', download=False,
...     transform=transform, target_transform=get_smile
... ) 

此外,我们将不再使用所有可用的训练和验证数据,而是从中选择 16000 个训练示例和 1000 个验证示例,因为我们的目标是有意地使用小数据集来训练我们的模型:

>>> from torch.utils.data import Subset
>>> celeba_train_dataset = Subset(celeba_train_dataset,
...                               torch.arange(16000))
>>> celeba_valid_dataset = Subset(celeba_valid_dataset,
...                               torch.arange(1000))
>>> print('Train set:', len(celeba_train_dataset))
Train set: 16000
>>> print('Validation set:', len(celeba_valid_dataset))
Validation set: 1000 

现在,我们可以为三个数据集创建数据加载器:

>>> batch_size = 32
>>> torch.manual_seed(1)
>>> train_dl = DataLoader(celeba_train_dataset,
...                       batch_size, shuffle=True)
>>> valid_dl = DataLoader(celeba_valid_dataset,
...                       batch_size, shuffle=False)
>>> test_dl = DataLoader(celeba_test_dataset,
...                      batch_size, shuffle=False) 

现在数据加载器已经准备好,我们将在下一节中开发一个 CNN 模型,并进行训练和评估。

训练 CNN 笑容分类器

到目前为止,使用torch.nn模块构建模型并训练应该是很简单的。我们的 CNN 的设计如下:CNN 模型接收大小为 3×64×64 的输入图像(图像具有三个色彩通道)。

输入数据通过四个卷积层进行处理,使用 3×3 的核大小和 1 的填充以生成 32、64、128 和 256 个特征图,用于进行相同填充。前三个卷积层后面跟着最大池化,P[2×2]。还包括两个 dropout 层用于正则化:

>>> model = nn.Sequential()
>>> model.add_module(
...     'conv1',
...     nn.Conv2d(
...         in_channels=3, out_channels=32,
...         kernel_size=3, padding=1
...     )
... )
>>> model.add_module('relu1', nn.ReLU())
>>> model.add_module('pool1', nn.MaxPool2d(kernel_size=2))
>>> model.add_module('dropout1', nn.Dropout(p=0.5))
>>> 
>>> model.add_module(
...     'conv2',
...     nn.Conv2d(
...         in_channels=32, out_channels=64,
...         kernel_size=3, padding=1
...     )
... )
>>> model.add_module('relu2', nn.ReLU())
>>> model.add_module('pool2', nn.MaxPool2d(kernel_size=2))
>>> model.add_module('dropout2', nn.Dropout(p=0.5))
>>> 
>>> model.add_module(
...     'conv3',
...     nn.Conv2d(
...         in_channels=64, out_channels=128,
...         kernel_size=3, padding=1
...     )
... )
>>> model.add_module('relu3', nn.ReLU())
>>> model.add_module('pool3', nn.MaxPool2d(kernel_size=2))
>>> 
>>> model.add_module(
...     'conv4',
...     nn.Conv2d(
...         in_channels=128, out_channels=256,
...         kernel_size=3, padding=1
...     )
... )
>>> model.add_module('relu4', nn.ReLU()) 

让我们看看在使用一个玩具批次输入(任意四张图像)后,应用这些层后输出特征图的形状:

>>> x = torch.ones((4, 3, 64, 64))
>>> model(x).shape
torch.Size([4, 256, 8, 8]) 

有 256 个大小为 8×8 的特征图(或通道)。现在,我们可以添加一个全连接层以得到具有单个单元的输出层。如果我们将特征图进行 reshape(展平),这个全连接层的输入单元数将为 8 × 8 × 256 = 16,384。或者,让我们考虑一个新层,称为全局 平均池化,它分别计算每个特征图的平均值,从而将隐藏单元减少到 256。然后我们可以添加一个全连接层。虽然我们没有明确讨论全局平均池化,但它在概念上与其他池化层非常相似。实际上,全局平均池化可以被视为当池化大小等于输入特征图大小时平均池化的特殊情况。

要理解这一点,考虑图 14.18,显示了一个输入特征图的示例,形状为batchsize×8×64×64。通道编号为k =0, 1,  ..., 7。全局平均池化操作计算每个通道的平均值,因此输出将具有形状[batchsize×8]。在这之后,我们将挤压全局平均池化层的输出。

如果不压缩输出,形状将会是[batchsize×8×1×1],因为全局平均池化将 64×64 的空间维度减少到 1×1:

Une image contenant objet, antenne  Description générée automatiquement

图 14.18: 输入特征图

因此,在我们的情况下,该层之前的特征图形状为[batchsize×256×8×8],我们预计输出将会有 256 个单元,也就是输出的形状将为[batchsize×256]。让我们添加这一层并重新计算输出形状,以验证这一点是否正确:

>>> model.add_module('pool4', nn.AvgPool2d(kernel_size=8))
>>> model.add_module('flatten', nn.Flatten())
>>> x = torch.ones((4, 3, 64, 64))
>>> model(x).shape
torch.Size([4, 256]) 

最后,我们可以添加一个全连接层以获得单个输出单元。在这种情况下,我们可以指定激活函数为'sigmoid'

>>> model.add_module('fc', nn.Linear(256, 1))
>>> model.add_module('sigmoid', nn.Sigmoid())
>>> x = torch.ones((4, 3, 64, 64))
>>> model(x).shape
torch.Size([4, 1])
>>> model
Sequential(
  (conv1): Conv2d(3, 32, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
  (relu1): ReLU()
  (pool1): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
  (dropout1): Dropout(p=0.5, inplace=False)
  (conv2): Conv2d(32, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
  (relu2): ReLU()
  (pool2): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
  (dropout2): Dropout(p=0.5, inplace=False)
  (conv3): Conv2d(64, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
  (relu3): ReLU()
  (pool3): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
  (conv4): Conv2d(128, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
  (relu4): ReLU()
  (pool4): AvgPool2d(kernel_size=8, stride=8, padding=0)
  (flatten): Flatten(start_dim=1, end_dim=-1)
  (fc): Linear(in_features=256, out_features=1, bias=True)
  (sigmoid): Sigmoid()
) 

下一步是创建损失函数和优化器(再次使用 Adam 优化器)。对于具有单个概率输出的二元分类,我们使用BCELoss作为损失函数:

>>> loss_fn = nn.BCELoss()
>>> optimizer = torch.optim.Adam(model.parameters(), lr=0.001) 

现在我们可以通过定义以下函数来训练模型:

>>> def train(model, num_epochs, train_dl, valid_dl):
...     loss_hist_train = [0] * num_epochs
...     accuracy_hist_train = [0] * num_epochs
...     loss_hist_valid = [0] * num_epochs
...     accuracy_hist_valid = [0] * num_epochs
...     for epoch in range(num_epochs):
...         model.train()
...         for x_batch, y_batch in train_dl:
...             pred = model(x_batch)[:, 0]
...             loss = loss_fn(pred, y_batch.float())
...             loss.backward()
...             optimizer.step()
...             optimizer.zero_grad()
...             loss_hist_train[epoch] += loss.item()*y_batch.size(0)
...             is_correct = ((pred>=0.5).float() == y_batch).float()
...             accuracy_hist_train[epoch] += is_correct.sum()
...         loss_hist_train[epoch] /= len(train_dl.dataset)
...         accuracy_hist_train[epoch] /= len(train_dl.dataset)
...
...         model.eval()
...         with torch.no_grad():
...             for x_batch, y_batch in valid_dl:
...                 pred = model(x_batch)[:, 0]
...                 loss = loss_fn(pred, y_batch.float())
...                 loss_hist_valid[epoch] += \
...                     loss.item() * y_batch.size(0)
...                 is_correct = \
...                     ((pred>=0.5).float() == y_batch).float()
...                 accuracy_hist_valid[epoch] += is_correct.sum()
...         loss_hist_valid[epoch] /= len(valid_dl.dataset)
...         accuracy_hist_valid[epoch] /= len(valid_dl.dataset)
...
...         print(f'Epoch {epoch+1} accuracy: '
...               f'{accuracy_hist_train[epoch]:.4f} val_accuracy: '
...               f'{accuracy_hist_valid[epoch]:.4f}')
...     return loss_hist_train, loss_hist_valid, \
...            accuracy_hist_train, accuracy_hist_valid 

接下来,我们将对这个 CNN 模型进行 30 个 epochs 的训练,并使用我们创建的验证数据集来监控学习进度:

>>> torch.manual_seed(1)
>>> num_epochs = 30
>>> hist = train(model, num_epochs, train_dl, valid_dl)
Epoch 1 accuracy: 0.6286 val_accuracy: 0.6540
...
Epoch 15 accuracy: 0.8544 val_accuracy: 0.8700
...
Epoch 30 accuracy: 0.8739 val_accuracy: 0.8710 

现在让我们可视化学习曲线,并比较每个 epoch 后的训练和验证损失和准确率:

>>> x_arr = np.arange(len(hist[0])) + 1
>>> fig = plt.figure(figsize=(12, 4))
>>> ax = fig.add_subplot(1, 2, 1)
>>> ax.plot(x_arr, hist[0], '-o', label='Train loss')
>>> ax.plot(x_arr, hist[1], '--<', label='Validation loss')
>>> ax.legend(fontsize=15)
>>> ax = fig.add_subplot(1, 2, 2)
>>> ax.plot(x_arr, hist[2], '-o', label='Train acc.')
>>> ax.plot(x_arr, hist[3], '--<',
...         label='Validation acc.')
>>> ax.legend(fontsize=15)
>>> ax.set_xlabel('Epoch', size=15)
>>> ax.set_ylabel('Accuracy', size=15)
>>> plt.show() 

图 14.19:训练和验证结果的比较

一旦我们对学习曲线满意,我们可以在保留测试数据集上评估模型:

>>> accuracy_test = 0
>>> model.eval()
>>> with torch.no_grad():
...     for x_batch, y_batch in test_dl:
...         pred = model(x_batch)[:, 0]
...         is_correct = ((pred>=0.5).float() == y_batch).float()
...         accuracy_test += is_correct.sum()
>>> accuracy_test /= len(test_dl.dataset)
>>> print(f'Test accuracy: {accuracy_test:.4f}')
Test accuracy: 0.8446 

最后,我们已经知道如何在一些测试示例上获得预测结果。在接下来的代码中,我们将从预处理的测试数据集(test_dl)的最后一个批次中取出 10 个示例的小子集。然后,我们将计算每个示例属于类别 1 的概率(基于 CelebA 中提供的标签为smile),并将示例与它们的真实标签和预测概率可视化:

>>> pred = model(x_batch)[:, 0] * 100
>>> fig = plt.figure(figsize=(15, 7))
>>> for j in range(10, 20):
...     ax = fig.add_subplot(2, 5, j-10+1)
...     ax.set_xticks([]); ax.set_yticks([])
...     ax.imshow(x_batch[j].permute(1, 2, 0))
...     if y_batch[j] == 1:
...         label='Smile'
...     else:
...         label = 'Not Smile'
...     ax.text(
...         0.5, -0.15,
...         f'GT: {label:s}\nPr(Smile)={pred[j]:.0f}%',
...         size=16,
...         horizontalalignment='center',
...         verticalalignment='center',
...         transform=ax.transAxes
...     )
>>> plt.show() 

图 14.20中,你可以看到 10 个示例图像,以及它们的真实标签以及它们属于类别 1(笑脸)的概率:

图 14.20:图像标签及其属于类别 1 的概率

在每张图像下方提供了类别 1(即smile,根据 CelebA)的概率。如你所见,我们训练的模型在这组 10 个测试示例上完全准确。

作为一个可选练习,鼓励你尝试使用整个训练数据集,而不是我们创建的小子集。此外,你可以改变或修改 CNN 的架构。例如,你可以改变不同卷积层中的 dropout 概率和滤波器数量。此外,你可以用全连接层替换全局平均池化。如果你在本章使用我们训练的 CNN 架构和整个训练数据集,应该能够达到 90%以上的准确率。

概要

在本章中,我们学习了 CNN 及其主要组件。我们从卷积操作开始,看了 1D 和 2D 的实现。然后,我们涵盖了另一种常见 CNN 架构中的层类型:子采样或所谓的池化层。我们主要关注了两种最常见的池化形式:最大池化和平均池化。

接下来,将所有这些个别概念结合起来,我们使用torch.nn模块实现了深度 CNN。我们实现的第一个网络适用于已经熟悉的 MNIST 手写数字识别问题。

接着,我们在一个更复杂的数据集上实现了第二个卷积神经网络(CNN),其中包含面部图像,并训练了 CNN 进行微笑分类。在此过程中,您还了解了数据增强以及我们可以使用torchvision.transforms模块对面部图像应用的不同转换。

在接下来的章节中,我们将转向循环神经网络RNNs)。RNNs 用于学习序列数据的结构,并且它们具有一些迷人的应用,包括语言翻译和图像字幕。

加入我们书籍的 Discord 空间

加入本书的 Discord 工作空间,与作者进行每月的问答会话:

packt.link/MLwPyTorch

第十五章:使用循环神经网络对序列数据进行建模

在前一章中,我们专注于卷积神经网络CNNs)。我们涵盖了 CNN 架构的基本构建模块以及如何在 PyTorch 中实现深度 CNN。最后,您学习了如何使用 CNN 进行图像分类。在本章中,我们将探索循环神经网络RNNs)并看到它们在建模序列数据中的应用。

我们将涵盖以下主题:

  • 引入序列数据

  • 用于建模序列的 RNN

  • 长短期记忆(Long short-term memory)

  • 截断的时间反向传播

  • 在 PyTorch 中实现多层 RNN 进行序列建模

  • 项目一:对 IMDb 电影评论数据集进行 RNN 情感分析

  • 项目二:使用 LSTM 单元进行 RNN 字符级语言建模,使用朱尔斯·凡尔纳的《神秘岛》文本数据

  • 使用梯度裁剪以避免梯度爆炸

引入序列数据

让我们开始讨论 RNN,看看序列数据的性质,通常称为序列数据或序列。我们将探讨使序列数据与其他类型数据不同的独特属性。然后,我们将看如何表示序列数据并探索基于模型的输入和输出的各种序列数据模型类别。这将帮助我们在本章中探索 RNN 与序列之间的关系。

建模序列数据 - 顺序至关重要

与其他类型数据相比,使序列独特的是序列中的元素按一定顺序出现,并且彼此不独立。典型的监督学习机器学习算法假设输入数据为独立同分布IID)数据,这意味着训练样本是相互独立且具有相同的基础分布。在这方面,基于相互独立的假设,训练样本被输入模型的顺序是无关紧要的。例如,如果我们有一个由n个训练样本组成的样本,x^((1)), x^((2)), ..., x(n^),那么使用数据训练我们的机器学习算法的顺序就不重要。在我们之前处理的鸢尾花数据集中就是一个例子。在鸢尾花数据集中,每朵花的测量是独立进行的,一朵花的测量不会影响另一朵花的测量。

然而,当我们处理序列时,这种假设就不成立了——根据定义,顺序是重要的。例如,预测特定股票的市场价值就是这种情况的一个例子。假设我们有一个包含 n 个训练示例的样本,其中每个训练示例代表某一天某只股票的市场价值。如果我们的任务是预测接下来三天的股市价值,考虑以日期排序的先前股价以推断趋势会比随机顺序处理这些训练示例更合理。

顺序数据与时间序列数据的区别

时间序列数据是一种特殊的顺序数据类型,其中每个示例与时间维度相关联。在时间序列数据中,样本是在连续的时间戳上获取的,因此时间维度决定了数据点之间的顺序。例如,股票价格和语音记录就是时间序列数据的例子。

另一方面,并非所有的顺序数据都具有时间维度。例如,在文本数据或 DNA 序列中,示例是有序的,但文本或 DNA 并不符合时间序列数据的定义。正如你将在本章中看到的那样,我们将专注于自然语言处理 (NLP) 和文本建模的示例,这些不属于时间序列数据。然而,请注意,RNNs 也可以用于时间序列数据,这超出了本书的范围。

表示序列

我们已经确认在顺序数据中,数据点的顺序是重要的,因此我们接下来需要找到一种方法来利用这些顺序信息在机器学习模型中进行应用。在本章中,我们将把序列表示为 。上标索引表示实例的顺序,序列的长度为 T。作为序列的一个合理示例,考虑时间序列数据,其中每个示例点 x(t^) 都属于特定的时间 t图 15.1 展示了时间序列数据的一个示例,其中输入特征 (x's) 和目标标签 (y's) 都按照它们的时间轴自然地遵循顺序;因此,x's 和 y's 都是序列。

图 15.1:时间序列数据示例

正如我们之前提到的,迄今为止我们涵盖的标准神经网络模型,如多层感知机 (MLPs) 和用于图像数据的 CNNs,假定训练示例是相互独立的,因此不包括顺序信息。我们可以说这类模型没有对先前观察到的训练示例有所“记忆”。例如,样本通过前向传播和反向传播步骤,权重独立于处理训练示例的顺序而更新。

相比之下,RNNs 是专为建模序列设计的,能够记忆过去的信息并根据新事件进行处理,这在处理序列数据时是一个明显的优势。

序列建模的不同类别

序列建模有许多迷人的应用,例如语言翻译(例如,将文本从英语翻译成德语)、图像字幕和文本生成。然而,为了选择合适的架构和方法,我们必须理解并能够区分这些不同的序列建模任务。图 15.2基于 Andrey Karpathy 在 2015 年撰写的优秀文章循环神经网络的非理性有效性karpathy.github.io/2015/05/21/rnn-effectiveness/),总结了依赖于输入和输出数据关系类别的最常见序列建模任务。

图 15.2:最常见的序列任务

让我们详细讨论输入和输出数据之间不同的关系类别,这些类别在之前的图中已经描述过。如果输入和输出数据都不表示序列,那么我们处理的是标准数据,可以简单地使用多层感知器(或本书中先前介绍过的其他分类模型)来对这些数据进行建模。然而,如果输入或输出是序列之一,建模任务很可能属于以下某一类别:

  • 多对一:输入数据是一个序列,但输出是一个固定大小的向量或标量,而不是序列。例如,在情感分析中,输入是基于文本的(例如电影评论),而输出是一个类别标签(例如表示评论者是否喜欢电影)。

  • 一对多:输入数据是标准格式而不是序列,但输出是一个序列。这一类别的一个例子是图像字幕,输入是图像,输出是总结该图像内容的英文短语。

  • 多对多:输入和输出数组都是序列。这一类别可以进一步根据输入和输出是否同步进行划分。同步多对多建模任务的一个例子是视频分类,其中标记每个视频帧。延迟多对多建模任务的例子是语言翻译,例如,机器必须先读取并处理整个英语句子,然后才能生成其德语翻译。

现在,在总结了序列建模的三大类别之后,我们可以继续讨论 RNN 的结构。

用于序列建模的 RNN

在这一节中,在我们开始在 PyTorch 中实现 RNN 之前,我们将讨论 RNN 的主要概念。我们将首先查看典型 RNN 的结构,其中包括一个递归组件来建模序列数据。然后,我们将检查典型 RNN 中如何计算神经元的激活。这将为我们讨论训练 RNN 时面临的常见挑战创造一个背景,然后我们将讨论这些挑战的解决方案,例如 LSTM 和门控循环单元(GRUs)。

理解 RNN 中的数据流动

让我们从 RNN 的架构开始。图 15.3并排显示了标准前馈 NN 和 RNN 的数据流动,以便进行比较:

图 15.3:标准前馈 NN 和 RNN 的数据流动

这两个网络都只有一个隐藏层。在这个表示中,单位没有显示出来,但我们假设输入层(x),隐藏层(h)和输出层(o)都是包含许多单元的向量。

确定 RNN 的输出类型

这种通用的 RNN 架构可以对应两种序列建模类别,其中输入是一个序列。通常,递归层可以返回一个序列作为输出,,或者仅返回最后一个输出(在t = T时,即o(T))。因此,它可能是多对多,或者如果例如我们仅使用最后一个元素**o**(T)作为最终输出,那么它可能是多对一。

我们将在后面看到,当我们详细研究具有返回序列输出的递归层行为时,这是如何在 PyTorch 的torch.nn模块中处理的。

在标准前馈网络中,信息从输入层流向隐藏层,然后从隐藏层流向输出层。另一方面,在 RNN 中,隐藏层接收来自当前时间步的输入层和上一时间步隐藏层的输入。

隐藏层中相邻时间步的信息流使得网络能够记住过去的事件。这种信息流通常显示为一个循环,也称为图表中的递归边缘,这也是这种通用 RNN 架构得名的方式。

类似于多层感知器,RNN 可以由多个隐藏层组成。请注意,将只有一个隐藏层的 RNN 称为单层 RNN是一种常见约定,不应与没有隐藏层的单层 NN(如 Adaline 或逻辑回归)混淆。图 15.4展示了具有一个隐藏层(顶部)和具有两个隐藏层(底部)的 RNN:

图 15.4:具有一个和两个隐藏层的 RNN 示例

为了检验 RNN 的架构和信息流动,可以展开具有递归边缘的紧凑表示,您可以在图 15.4中看到。

正如我们所知,标准 NN 中的每个隐藏单元只接收一个输入——来自输入层的净激活。相比之下,RNN 中的每个隐藏单元接收两个不同的输入集——来自输入层的净激活以及前一个时间步t – 1 的相同隐藏层的激活。

在第一个时间步t = 0 时,隐藏单元被初始化为零或小的随机值。然后,在时间步t > 0 时,隐藏单元接收来自当前时间点数据点x(t)以及上一个时间步中的隐藏单元值**h**(t(–1))的输入。

类似地,在多层 RNN 的情况下,信息流可以总结如下:

  • layer = 1:在这里,隐藏层表示为,它从数据点x(t^)以及同一层中但前一个时间步的隐藏值接收输入。

  • layer = 2:第二隐藏层,,从当前时间步下方图层的输出接收输入(),以及其自身在前一个时间步的隐藏值,

由于在这种情况下,每个递归层必须接收一个序列作为输入,除了最后一个递归层必须返回一个序列作为输出(也就是说,我们稍后必须设置return_sequences=True)。最后一个递归层的行为取决于问题的类型。

在 RNN 中计算激活值

现在您已经理解了 RNN 中的结构和信息流动的一般流程,让我们更加具体地计算隐藏层的实际激活以及输出层。为简单起见,我们将只考虑单个隐藏层;然而,相同的概念也适用于多层 RNN。

在刚刚查看的 RNN 表示中,每条有向边(连接框之间的连接)都与一个权重矩阵相关联。这些权重不依赖于时间t,因此它们在时间轴上是共享的。单层 RNN 中的不同权重矩阵如下:

  • W[xh]:输入x(t^)与隐藏层h之间的权重矩阵

  • W[hh]:与递归边关联的权重矩阵

  • W[ho]:隐藏层与输出层之间的权重矩阵

这些权重矩阵如图 15.5 所示:

图 15.5:应用权重到单层 RNN

在某些实现中,您可能会注意到权重矩阵W[xh]和W[hh]被连接成一个组合矩阵W[h] = [W[xh]; W[hh]]。在本节后面,我们将也会使用这种表示法。

计算激活与标准的多层感知器和其他类型的前馈神经网络非常相似。对于隐藏层,净输入 z[h](预激活)通过线性组合计算;即,我们计算权重矩阵与相应向量的乘积的总和,并添加偏置单元:

然后,在时间步 t 处计算隐藏单元的激活如下:

在这里,b[h] 是隐藏单元的偏置向量, 是隐藏层的激活函数。

如果要使用连接的权重矩阵,W[h] = [W[xh]; W[hh]],则计算隐藏单元的公式将发生变化,如下所示:

一旦计算了当前时间步的隐藏单元的激活,那么输出单元的激活将按以下方式计算:

为了进一步澄清,Figure 15.6 显示了使用这两种形式计算这些激活的过程:

图 15.6:计算激活

使用时间反向传播(BPTT)训练 RNNs

RNNs 的学习算法首次在 1990 年引入:通过时间的反向传播:它的作用及实现方法Paul WerbosIEEE 会议录,78(10):1550-1560,1990)。

梯度的推导可能有些复杂,但基本思想是总体损失 L 是时间 t = 1 到 t = T 所有损失函数的总和:

由于时间 t 处的损失依赖于所有之前时间步骤 1 : t 的隐藏单元,梯度将按以下方式计算:

在这里, 是相邻时间步长的乘积计算:

隐藏层的递归与输出的递归

到目前为止,您已经看到具有隐藏层递归属性的递归网络。然而,请注意,还存在另一种模型,其中递归连接来自输出层。在这种情况下,从上一个时间步的输出层的净激活 ot(–1) 可以通过两种方式之一相加:

  • 到当前时间步的隐藏层,h^t(如在 Figure 15.7 中显示的输出到隐藏递归)

  • 到当前时间步的输出层,o^t(如在 Figure 15.7 中显示的输出到输出递归)

图 15.7:不同的递归连接模型

图 15.7所示,这些架构之间的差异在递归连接中可以清楚地看到。根据我们的符号约定,与递归连接相关的权重将由隐藏到隐藏的递归表示为 W[hh],由输出到隐藏的递归表示为 W[oh],由输出到输出的递归表示为 W[oo]。在一些文献中,递归连接相关的权重也被表示为 W[rec]。

为了看看这在实践中是如何工作的,让我们手动计算其中一种递归类型的前向传播。使用torch.nn模块,可以通过RNN定义一个递归层,它类似于隐藏到隐藏的递归。在以下代码中,我们将从RNN创建一个递归层,并对长度为 3 的输入序列执行前向传播以计算输出。我们还将手动计算前向传播并将结果与RNN的结果进行比较。

首先,让我们创建层,并为我们的手动计算分配权重和偏置:

>>> import torch
>>> import torch.nn as nn
>>> torch.manual_seed(1)
>>> rnn_layer = nn.RNN(input_size=5, hidden_size=2,
...                    num_layers=1, batch_first=True)
>>> w_xh = rnn_layer.weight_ih_l0
>>> w_hh = rnn_layer.weight_hh_l0
>>> b_xh = rnn_layer.bias_ih_l0
>>> b_hh = rnn_layer.bias_hh_l0
>>> print('W_xh shape:', w_xh.shape)
>>> print('W_hh shape:', w_hh.shape)
>>> print('b_xh shape:', b_xh.shape)
>>> print('b_hh shape:', b_hh.shape)
W_xh shape: torch.Size([2, 5])
W_hh shape: torch.Size([2, 2])
b_xh shape: torch.Size([2])
b_hh shape: torch.Size([2]) 

这一层的输入形状为(batch_size, sequence_length, 5),其中第一维是批处理维度(因为我们设置了batch_first=True),第二维对应于序列,最后一维对应于特征。请注意,我们将输出一个序列,对于长度为 3 的输入序列,将产生输出序列 。此外,RNN默认使用一层,您可以设置num_layers来堆叠多个 RNN 层以形成堆叠的 RNN。

现在,我们将在rnn_layer上调用前向传播,并手动计算每个时间步长的输出并进行比较:

>>> x_seq = torch.tensor([[1.0]*5, [2.0]*5, [3.0]*5]).float()
>>> ## output of the simple RNN:
>>> output, hn = rnn_layer(torch.reshape(x_seq, (1, 3, 5)))
>>> ## manually computing the output:
>>> out_man = []
>>> for t in range(3):
...     xt = torch.reshape(x_seq[t], (1, 5))
...     print(f'Time step {t} =>')
...     print('   Input           :', xt.numpy())
...     
...     ht = torch.matmul(xt, torch.transpose(w_xh, 0, 1)) + b_hh
...     print('   Hidden          :', ht.detach().numpy()
...     
...     if t > 0:
...         prev_h = out_man[t-1]
...     else:
...         prev_h = torch.zeros((ht.shape))
...     ot = ht + torch.matmul(prev_h, torch.transpose(w_hh, 0, 1)) \
...             + b_hh
...     ot = torch.tanh(ot)
...     out_man.append(ot)
...     print('   Output (manual) :', ot.detach().numpy())
...     print('   RNN output      :', output[:, t].detach().numpy())
...     print()
Time step 0 =>
   Input           : [[1\. 1\. 1\. 1\. 1.]]
   Hidden          : [[-0.4701929  0.5863904]]
   Output (manual) : [[-0.3519801   0.52525216]]
   RNN output      : [[-0.3519801   0.52525216]]
Time step 1 =>
   Input           : [[2\. 2\. 2\. 2\. 2.]]
   Hidden          : [[-0.88883156  1.2364397 ]]
   Output (manual) : [[-0.68424344  0.76074266]]
   RNN output      : [[-0.68424344  0.76074266]]
Time step 2 =>
   Input           : [[3\. 3\. 3\. 3\. 3.]]
   Hidden          : [[-1.3074701  1.886489 ]]
   Output (manual) : [[-0.8649416   0.90466356]]
   RNN output      : [[-0.8649416   0.90466356]] 

在我们的手动前向计算中,我们使用了双曲正切(tanh)激活函数,因为它也用于RNN(默认激活函数)。正如您从打印的结果中看到的那样,手动前向计算的输出在每个时间步长上与RNN层的输出完全匹配。希望这个实际任务能让您对递归网络的奥秘有所启发。

学习长程交互的挑战

BPTT(之前简要提到过)引入了一些新的挑战。由于梯度计算中的乘法因子,称为,导致了所谓的消失爆炸梯度问题的产生。

这些问题通过图 15.8中的例子进行了解释,显示了一个仅具有一个隐藏单元的简单 RNN:

图 15.8:计算损失函数梯度中的问题

基本上,t – k个乘法;因此,将权重w乘以它自己t – k次得到因子wt–^k。因此,如果|w| < 1,当t – k很大时,这个因子变得非常小。另一方面,如果递归边的权重是|w| > 1,则当t – k很大时wt–^k 变得非常大。请注意,大t – k指的是长程依赖性。我们可以看到,避免梯度消失或爆炸的一个朴素解决方案是确保|w| = 1。如果您有兴趣并且希望更详细地研究这一点,请阅读R. PascanuT. MikolovY. Bengio在 2012 年发表的论文《On the difficulty of training recurrent neural networks》arxiv.org/pdf/1211.5063.pdf)。

在实践中,至少有三种解决方案:

  • 梯度裁剪

  • 截断时间反向传播TBPTT

  • LSTM

使用梯度裁剪,我们为梯度指定了一个截断或阈值,并将超过此值的梯度赋予此截断值。相比之下,TBPTT 仅限制了每次前向传递后信号能够反向传播的时间步数。例如,即使序列有 100 个元素或步骤,我们也只能反向传播最近的 20 个时间步。

尽管梯度裁剪和 TBPTT 都可以解决梯度爆炸问题,但截断限制了梯度能够有效流动和适当更新权重的步数。另一方面,1997 年由 Sepp Hochreiter 和 Jürgen Schmidhuber 设计的 LSTM 通过使用记忆细胞在建模长程依赖性时更为成功地解决了梯度消失和爆炸问题。让我们更详细地讨论 LSTM。

长短期记忆细胞

正如之前所述,LSTM 最初是为了解决梯度消失问题而引入的(长短期记忆,由S. HochreiterJ. SchmidhuberNeural Computation,9(8):1735-1780,1997 年提出)。LSTM 的构建模块是一个记忆细胞,它本质上表示或替代标准 RNN 的隐藏层。

在每个记忆细胞中,都有一个具有理想权重w = 1 的递归边,正如我们讨论过的,用来解决梯度消失和爆炸问题。与这个递归边相关联的值被统称为细胞状态。现代 LSTM 细胞的展开结构如图15.9所示:

图 15.9:LSTM 细胞的结构

请注意,从上一个时间步骤的细胞状态 C(t^(–1)) 修改为获取当前时间步骤的细胞状态 C(t^),而不直接乘以任何权重因子。这个记忆单元中的信息流由几个计算单元(通常称为)控制,这些将在这里描述。在图中, 指代逐元素乘积 表示逐元素求和。此外,x(t^) 指时间 t 的输入数据,h(t^(–1)) 表示时间 t – 1 的隐藏单元。有四个框指示激活函数,可以是 sigmoid 函数()或 tanh,以及一组权重;这些框通过在它们的输入(h(t^(–1)) 和 x(t^))上执行矩阵-向量乘法来应用线性组合。这些具有 sigmoid 激活函数的计算单元,其输出单元通过 传递,称为门。

在 LSTM 单元中,有三种不同类型的门,称为遗忘门、输入门和输出门:

遗忘门f[t])允许记忆单元在不无限增长的情况下重置细胞状态。实际上,遗忘门决定了哪些信息允许通过,哪些信息被抑制。现在,f[t] 计算如下:

注意,遗忘门并不是最初的 LSTM 单元的一部分;几年后才添加,以改进原始模型(《忘记学习:连续预测与 LSTM》作者 F. GersJ. SchmidhuberF. Cummins神经计算 12,2451-2471,2000 年)。

输入门i[t])和候选值)负责更新细胞状态。它们的计算如下:

时间 t 的细胞状态计算如下:

输出门o[t])决定如何更新隐藏单元的值:

给定这一点,当前时间步骤的隐藏单元计算如下:

LSTM 单元的结构及其底层计算可能看起来非常复杂且难以实现。然而,好消息是 PyTorch 已经用优化的包装函数实现了一切,这使我们可以轻松高效地定义我们的 LSTM 单元。我们将在本章后面将 RNN 和 LSTM 应用于实际数据集。

其他高级 RNN 模型

LSTM 提供了一种基本方法来建模序列中的长期依赖性。然而,值得注意的是,文献中描述了许多 LSTM 的变体(Rafal JozefowiczWojciech ZarembaIlya SutskeverAn Empirical Exploration of Recurrent Network ArchitecturesICML 会议论文,2015 年,第 2342-2350 页)。还值得注意的是,2014 年提出了更近期的方法,门控循环单元GRU)。GRU 比 LSTM 具有更简单的架构,因此在计算上更高效,而在某些任务(如多声部音乐建模)中,它们的性能与 LSTM 相当。如果您有兴趣了解这些现代 RNN 架构的更多信息,请参考 Junyoung Chung 等人的论文,Empirical Evaluation of Gated Recurrent Neural Networks on Sequence Modeling,2014 年(arxiv.org/pdf/1412.3555v1.pdf)。

在 PyTorch 中实现序列建模的 RNN

现在我们已经讨论了 RNN 背后的基本理论,我们准备进入本章更加实际的部分:在 PyTorch 中实现 RNN。在本章的其余部分,我们将将 RNN 应用于两个常见的问题任务:

  1. 情感分析

  2. 语言建模

我们将在接下来的页面一起步入的这两个项目,既有趣又复杂。因此,我们将代码实现分成几个步骤,并详细讨论代码,而不是一次性提供所有代码。如果您想要有一个全局视图,并在深入讨论之前先看到所有代码,请首先查看代码实现。

第一个项目——预测 IMDb 电影评论的情感

您可能还记得在 第八章 将机器学习应用于情感分析 中,情感分析关注于分析句子或文本文档的表达意见。在本节和接下来的子节中,我们将使用多层 RNN 实现情感分析,采用多对一架构。

在接下来的部分,我们将实现一个用于语言建模的多对多 RNN 应用。虽然所选择的示例故意简单,以介绍 RNN 的主要概念,但语言建模有广泛的有趣应用,例如构建聊天机器人——让计算机直接与人类进行对话和交互。

准备电影评论数据

第八章 中,我们对评论数据集进行了预处理和清洗。现在我们将做同样的事情。首先,我们将导入必要的模块,并从 torchtext 中读取数据(我们将通过 pip install torchtext 进行安装;截至 2021 年末,版本为 0.10.0)如下:

>>> from torchtext.datasets import IMDB
>>> train_dataset = IMDB(split='train')
>>> test_dataset = IMDB(split='test') 

每个集合包含 25,000 个样本。每个数据集样本由两个元素组成,情感标签表示我们想要预测的目标标签(neg 表示负面情感,pos 表示正面情感),以及电影评论文本(输入特征)。这些电影评论的文本部分是单词序列,RNN 模型将每个序列分类为正面(1)或负面(0)评论。

在将数据输入到 RNN 模型之前,我们需要执行几个预处理步骤:

  1. 将训练数据集分割为单独的训练和验证分区。

  2. 识别训练数据集中的唯一单词

  3. 将每个唯一单词映射到唯一整数,并将评论文本编码为编码整数(每个唯一单词的索引)。

  4. 将数据集分割为小批量作为模型的输入。

让我们继续进行第一步:从我们之前读取的 train_dataset 创建训练和验证分区:

>>> ## Step 1: create the datasets
>>> from torch.utils.data.dataset import random_split
>>> torch.manual_seed(1)
>>> train_dataset, valid_dataset = random_split(
...     list(train_dataset), [20000, 5000]) 

原始训练数据集包含 25,000 个示例。随机选择 20,000 个示例用于训练,5,000 个用于验证。

为了准备数据以输入到 NN,我们需要将其编码为数字值,如 步骤 23 中提到的那样。为此,我们首先将找到训练数据集中的唯一单词(标记)。虽然查找唯一标记是一个可以使用 Python 数据集完成的过程,但使用 Python 标准库中的 collections 包中的 Counter 类可能更有效。

在下面的代码中,我们将实例化一个新的 Counter 对象(token_counts),它将收集唯一单词的频率。请注意,在这种特定的应用程序中(与词袋模型相反),我们只关注唯一单词集合,而不需要单词计数,这些计数是作为副产品创建的。为了将文本分割成单词(或标记),我们将重用在 第八章 中开发的 tokenizer 函数,该函数还会移除 HTML 标记以及标点符号和其他非字母字符:

收集唯一标记的代码如下:

>>> ## Step 2: find unique tokens (words)
>>> import re
>>> from collections import Counter, OrderedDict
>>> 
>>> def tokenizer(text):
...     text = re.sub('<[^>]*>', '', text)
...     emoticons = re.findall(
...         '(?::|;|=)(?:-)?(?:\)|\(|D|P)', text.lower()
...     )
...     text = re.sub('[\W]+', ' ', text.lower()) +\
...         ' '.join(emoticons).replace('-', '')
...     tokenized = text.split()
...     return tokenized
>>> 
>>> token_counts = Counter()
>>> for label, line in train_dataset:
...     tokens = tokenizer(line)
...     token_counts.update(tokens)
>>> print('Vocab-size:', len(token_counts))
Vocab-size: 69023 

如果您想了解更多关于 Counter 的信息,请参阅其文档:docs.python.org/3/library/collections.html#collections.Counter

接下来,我们将把每个唯一单词映射到一个唯一整数。这可以通过手动使用 Python 字典完成,其中键是唯一标记(单词),与每个键关联的值是唯一整数。然而,torchtext 包已经提供了一个名为 Vocab 的类,我们可以使用它来创建这样一个映射并对整个数据集进行编码。首先,我们将通过传递将标记映射到其相应出现频率的有序字典(有序字典是排序后的 token_counts)来创建一个 vocab 对象。其次,我们将在词汇表中添加两个特殊标记 – 填充和未知标记:

>>> ## Step 3: encoding each unique token into integers
>>> from torchtext.vocab import vocab
>>> sorted_by_freq_tuples = sorted(
...     token_counts.items(), key=lambda x: x[1], reverse=True
... )
>>> ordered_dict = OrderedDict(sorted_by_freq_tuples)
>>> vocab = vocab(ordered_dict)
>>> vocab.insert_token("<pad>", 0)
>>> vocab.insert_token("<unk>", 1)
>>> vocab.set_default_index(1) 

为了演示如何使用 vocab 对象,我们将把一个示例输入文本转换为整数值列表:

>>> print([vocab[token] for token in ['this', 'is',
...     'an', 'example']])
[11, 7, 35, 457] 

请注意,验证或测试数据中可能有些标记不在训练数据中,因此未包含在映射中。如果我们有 q 个标记(即传递给 Vocabtoken_counts 的大小,在本例中为 69,023),那么所有以前未见过的标记,因此未包含在 token_counts 中,将被分配整数 1(未知标记的占位符)。换句话说,索引 1 保留给未知词。另一个保留值是整数 0,用作调整序列长度的占位符,即所谓的 填充标记。稍后,在 PyTorch 中构建 RNN 模型时,我们将详细考虑这个占位符 0。

我们可以定义 text_pipeline 函数来相应地转换数据集中的每个文本,以及 label_pipeline 函数来将每个标签转换为 1 或 0:

>>> ## Step 3-A: define the functions for transformation
>>> text_pipeline =\
...      lambda x: [vocab[token] for token in tokenizer(x)]
>>> label_pipeline = lambda x: 1\. if x == 'pos' else 0. 

我们将使用 DataLoader 生成样本批次,并将先前声明的数据处理流水线传递给 collate_fn 参数。我们将文本编码和标签转换函数封装到 collate_batch 函数中:

>>> ## Step 3-B: wrap the encode and transformation function
... def collate_batch(batch):
...     label_list, text_list, lengths = [], [], []
...     for _label, _text in batch:
...         label_list.append(label_pipeline(_label))
...         processed_text = torch.tensor(text_pipeline(_text),
...                                       dtype=torch.int64)
...         text_list.append(processed_text)
...         lengths.append(processed_text.size(0))
...     label_list = torch.tensor(label_list)
...     lengths = torch.tensor(lengths)
...     padded_text_list = nn.utils.rnn.pad_sequence(
...         text_list, batch_first=True)
...     return padded_text_list, label_list, lengths
>>> 
>>> ## Take a small batch
>>> from torch.utils.data import DataLoader
>>> dataloader = DataLoader(train_dataset, batch_size=4,
...                         shuffle=False, collate_fn=collate_batch) 

到目前为止,我们已经将单词序列转换为整数序列,并将 posneg 的标签转换为 1 或 0。然而,我们需要解决一个问题——当前序列的长度不同(如在执行以下代码对四个示例进行操作后所示)。尽管通常 RNN 可以处理不同长度的序列,但我们仍然需要确保一个小批量中的所有序列具有相同的长度,以便在张量中有效地存储它们。

PyTorch 提供了一个高效的方法,pad_sequence(),它会自动使用占位值(0)填充要合并到批次中的连续元素,以便批次中的所有序列具有相同的形状。在前面的代码中,我们已经从训练数据集中创建了一个小批量大小的数据加载器,并应用了 collate_batch 函数,该函数本身包含了 pad_sequence() 调用。

然而,为了说明填充的工作原理,我们将取第一个批次并打印单个元素在合并这些元素成小批次之前的大小,以及生成的小批次的维度:

>>> text_batch, label_batch, length_batch = next(iter(dataloader))
>>> print(text_batch)
tensor([[   35,  1742,     7,   449,   723,     6,   302,     4,
...
0,     0,     0,     0,     0,     0,     0,     0]],
>>> print(label_batch)
tensor([1., 1., 1., 0.])
>>> print(length_batch)
tensor([165,  86, 218, 145])
>>> print(text_batch.shape)
torch.Size([4, 218]) 

正如您从打印的张量形状中可以观察到的那样,第一个批次的列数为 218,这是将前四个示例合并为单个批次并使用这些示例的最大大小得到的结果。这意味着该批次中的其他三个示例(它们的长度分别为 165、86 和 145)将根据需要进行填充,以匹配此大小。

最后,让我们将所有三个数据集分成批量大小为 32 的数据加载器:

>>> batch_size = 32
>>> train_dl = DataLoader(train_dataset, batch_size=batch_size,
...                       shuffle=True, collate_fn=collate_batch)
>>> valid_dl = DataLoader(valid_dataset, batch_size=batch_size,
...                       shuffle=False, collate_fn=collate_batch)
>>> test_dl = DataLoader(test_dataset, batch_size=batch_size,
...                      shuffle=False, collate_fn=collate_batch) 

现在,数据已经处于适合 RNN 模型的格式中,我们将在接下来的小节中实现它。但是,在下一小节中,我们首先讨论特征嵌入,这是一个可选但强烈推荐的预处理步骤,用于减少词向量的维度。

用于句子编码的嵌入层

在前一步骤的数据准备过程中,我们生成了相同长度的序列。这些序列的元素是整数,对应于唯一单词的索引。这些单词索引可以以多种方式转换为输入特征。一个天真的方法是应用独热编码,将索引转换为由零和一组成的向量。然后,每个单词将被映射到一个向量,其大小为整个数据集中唯一单词的数量。考虑到唯一单词的数量(词汇表的大小)可能在 10⁴ 至 10⁵的数量级,这也将是我们输入特征的数量,模型在这些特征上训练可能会受到维度诅咒的影响。此外,这些特征非常稀疏,因为除了一个之外,所有都是零。

更加优雅的方法是将每个单词映射到一个固定大小、具有实值元素(不一定是整数)的向量中。与独热编码向量相比,我们可以使用有限大小的向量来表示无限数量的实数。 (理论上,我们可以从给定区间(例如[-1, 1])中提取无限的实数。)

这就是嵌入的概念,它是一种特征学习技术,我们可以利用它来自动学习表示数据集中单词的显著特征。鉴于唯一单词的数量,n[words],我们可以选择嵌入向量的大小(即嵌入维度),远小于唯一单词的数量(embedding_dim << n[words]),以表示整个词汇表作为输入特征。

嵌入与独热编码相比的优势如下:

  • 通过减少特征空间的维度来减少维度诅咒的影响

  • 由于 NN 中的嵌入层可以被优化(或学习),所以可以提取显著特征

以下示意图表示了嵌入如何工作,通过将标记索引映射到可训练的嵌入矩阵:

图 15.10: 嵌入式工作原理的分解

给定大小为n + 2 的一组标记(n为标记集的大小,加上索引 0 保留为填充占位符,1 为不在标记集中的词),将创建一个大小为(n + 2)× embedding_dim的嵌入矩阵,其中矩阵的每一行表示与一个标记相关联的数值特征。因此,当整数索引i作为嵌入的输入时,它将查找矩阵中索引i对应的行,并返回数值特征。嵌入矩阵充当我们 NN 模型的输入层。在实践中,可以简单地使用nn.Embedding来创建一个嵌入层。让我们看一个例子,我们将创建一个嵌入层,并将其应用于一个包含两个样本的批处理,如下所示:

>>> embedding = nn.Embedding(
...     num_embeddings=10,
...     embedding_dim=3,
...     padding_idx=0)
>>> # a batch of 2 samples of 4 indices each
>>> text_encoded_input = torch.LongTensor([[1,2,4,5],[4,3,2,0]])
>>> print(embedding(text_encoded_input))
tensor([[[-0.7027,  0.3684, -0.5512],
         [-0.4147,  1.7891, -1.0674],
         [ 1.1400,  0.1595, -1.0167],
         [ 0.0573, -1.7568,  1.9067]],
        [[ 1.1400,  0.1595, -1.0167],
         [-0.8165, -0.0946, -0.1881],
         [-0.4147,  1.7891, -1.0674],
         [ 0.0000,  0.0000,  0.0000]]], grad_fn=<EmbeddingBackward>) 

此模型的输入(嵌入层)必须具有二维的 rank,维度为batchsize × input_length,其中input_length是序列的长度(这里为 4)。例如,小批量中的一个输入序列可以是<1, 5, 9, 2>,其中每个元素是唯一单词的索引。输出将具有维度batchsize × input_length × embedding_dim,其中embedding_dim是嵌入特征的大小(这里设置为 3)。提供给嵌入层的另一个参数num_embeddings对应于模型将接收的唯一整数值(例如,n + 2,在这里设置为 10)。因此,在这种情况下,嵌入矩阵的大小为 10×6。

padding_idx指示填充的标记索引(这里为 0),如果指定,将在训练期间不会对其进行梯度更新。在我们的例子中,第二个样本的原始序列长度为 3,我们用 1 个额外的元素 0 进行了填充。填充元素的嵌入输出为[0, 0, 0]。

构建一个 RNN 模型

现在我们可以构建一个 RNN 模型。使用nn.Module类,我们可以将嵌入层、RNN 的递归层和完全连接的非递归层组合在一起。对于递归层,我们可以使用以下任意一种实现:

  • RNN:常规的 RNN 层,即全连接递归层

  • LSTM:长短期记忆 RNN,用于捕捉长期依赖性

  • GRU:具有门控递归单元的递归层,作为 LSTM 的替代方案,由K. Cho等人在Learning Phrase Representations Using RNN Encoder–Decoder for Statistical Machine Translation中提出(2014 年)(arxiv.org/abs/1406.1078v3)

要查看如何使用这些递归层之一构建多层 RNN 模型,请看下面的例子,我们将创建一个包含两个RNN递归层的 RNN 模型。最后,我们将添加一个非递归完全连接层作为输出层,该层将返回单个输出值作为预测:

>>> class RNN(nn.Module):
...     def __init__(self, input_size, hidden_size):
...         super().__init__()
...         self.rnn = nn.RNN(input_size, hidden_size, num_layers=2,
...                           batch_first=True)
...         # self.rnn = nn.GRU(input_size, hidden_size, num_layers,
...         #                   batch_first=True)
...         # self.rnn = nn.LSTM(input_size, hidden_size, num_layers,
...         #                    batch_first=True)
...         self.fc = nn.Linear(hidden_size, 1)
...
...     def forward(self, x):
...         _, hidden = self.rnn(x)
...         out = hidden[-1, :, :] # we use the final hidden state
...                                # from the last hidden layer as
...                                # the input to the fully connected
...                                # layer
...         out = self.fc(out)
...         return out
>>>
>>> model = RNN(64, 32)
>>> print(model)
>>> model(torch.randn(5, 3, 64))
RNN(
  (rnn): RNN(64, 32, num_layers=2, batch_first=True)
  (fc): Linear(in_features=32, out_features=1, bias=True)
)
tensor([[ 0.0010],
        [ 0.2478],
        [ 0.0573],
        [ 0.1637],
        [-0.0073]], grad_fn=<AddmmBackward>) 

正如您所看到的,使用这些循环层构建 RNN 模型非常简单。在下一个小节中,我们将回到情感分析任务,并建立一个 RNN 模型来解决这个问题。

为情感分析任务构建 RNN 模型

因为我们有非常长的序列,所以我们将使用 LSTM 层来考虑长期效应。我们将创建一个用于情感分析的 RNN 模型,从产生特征大小为 20 的词嵌入的嵌入层开始(embed_dim=20)。然后,将添加类型为 LSTM 的递归层。最后,我们将添加一个全连接层作为隐藏层,另一个全连接层作为输出层,通过逻辑 sigmoid 激活返回单个类成员概率值作为预测:

>>> class RNN(nn.Module):
...     def __init__(self, vocab_size, embed_dim, rnn_hidden_size,
...                  fc_hidden_size):
...         super().__init__()
...         self.embedding = nn.Embedding(vocab_size,
...                                       embed_dim,
...                                       padding_idx=0)
...         self.rnn = nn.LSTM(embed_dim, rnn_hidden_size,
...                            batch_first=True)
...         self.fc1 = nn.Linear(rnn_hidden_size, fc_hidden_size)
...         self.relu = nn.ReLU()
...         self.fc2 = nn.Linear(fc_hidden_size, 1)
...         self.sigmoid = nn.Sigmoid()
...
...     def forward(self, text, lengths):
...         out = self.embedding(text)
...         out = nn.utils.rnn.pack_padded_sequence(
...             out, lengths.cpu().numpy(), enforce_sorted=False, batch_first=True
...         )
...         out, (hidden, cell) = self.rnn(out)
...         out = hidden[-1, :, :]
...         out = self.fc1(out)
...         out = self.relu(out)
...         out = self.fc2(out)
...         out = self.sigmoid(out)
...         return out
>>> 
>>> vocab_size = len(vocab)
>>> embed_dim = 20
>>> rnn_hidden_size = 64
>>> fc_hidden_size = 64
>>> torch.manual_seed(1)
>>> model = RNN(vocab_size, embed_dim,
                rnn_hidden_size, fc_hidden_size)
>>> model
RNN(
  (embedding): Embedding(69025, 20, padding_idx=0)
  (rnn): LSTM(20, 64, batch_first=True)
  (fc1): Linear(in_features=64, out_features=64, bias=True)
  (relu): ReLU()
  (fc2): Linear(in_features=64, out_features=1, bias=True)
  (sigmoid): Sigmoid()
) 

现在我们将开发 train 函数,在给定数据集上训练模型一个 epoch,并返回分类准确率和损失值:

>>> def train(dataloader):
...     model.train()
...     total_acc, total_loss = 0, 0
...     for text_batch, label_batch, lengths in dataloader:
...         optimizer.zero_grad()
...         pred = model(text_batch, lengths)[:, 0]
...         loss = loss_fn(pred, label_batch)
...         loss.backward()
...         optimizer.step()
...         total_acc += (
...             (pred >= 0.5).float() == label_batch
...         ).float().sum().item()
...         total_loss += loss.item()*label_batch.size(0)
...     return total_acc/len(dataloader.dataset), \
...            total_loss/len(dataloader.dataset) 

同样地,我们将开发 evaluate 函数来衡量模型在给定数据集上的表现:

>>> def evaluate(dataloader):
...     model.eval()
...     total_acc, total_loss = 0, 0
...     with torch.no_grad():
...         for text_batch, label_batch, lengths in dataloader:
...             pred = model(text_batch, lengths)[:, 0]
...             loss = loss_fn(pred, label_batch)
...             total_acc += (
...                 (pred>=0.5).float() == label_batch
...             ).float().sum().item()
...             total_loss += loss.item()*label_batch.size(0)
...     return total_acc/len(dataloader.dataset), \
...            total_loss/len(dataloader.dataset) 

下一步是创建损失函数和优化器(Adam 优化器)。对于具有单个类成员概率输出的二元分类,我们使用二元交叉熵损失 (BCELoss) 作为损失函数:

>>> loss_fn = nn.BCELoss()
>>> optimizer = torch.optim.Adam(model.parameters(), lr=0.001) 

现在我们将对模型进行 10 个 epochs 的训练,并显示训练和验证的表现:

>>> num_epochs = 10
>>> torch.manual_seed(1)
>>> for epoch in range(num_epochs):
...     acc_train, loss_train = train(train_dl)
...     acc_valid, loss_valid = evaluate(valid_dl)
...     print(f'Epoch {epoch} accuracy: {acc_train:.4f}'
...           f' val_accuracy: {acc_valid:.4f}')
Epoch 0 accuracy: 0.5843 val_accuracy: 0.6240
Epoch 1 accuracy: 0.6364 val_accuracy: 0.6870
Epoch 2 accuracy: 0.8020 val_accuracy: 0.8194
Epoch 3 accuracy: 0.8730 val_accuracy: 0.8454
Epoch 4 accuracy: 0.9092 val_accuracy: 0.8598
Epoch 5 accuracy: 0.9347 val_accuracy: 0.8630
Epoch 6 accuracy: 0.9507 val_accuracy: 0.8636
Epoch 7 accuracy: 0.9655 val_accuracy: 0.8654
Epoch 8 accuracy: 0.9765 val_accuracy: 0.8528
Epoch 9 accuracy: 0.9839 val_accuracy: 0.8596 

在对测试数据进行 10 个 epochs 的训练后,我们将对其进行评估:

>>> acc_test, _ = evaluate(test_dl)
>>> print(f'test_accuracy: {acc_test:.4f}')
test_accuracy: 0.8512 

它显示了 85% 的准确率。(请注意,与 IMDb 数据集上使用的最先进方法相比,这个结果并不是最好的。目标只是展示 PyTorch 中 RNN 的工作原理。)

关于双向 RNN 的更多信息

此外,我们将设置 LSTMbidirectional 配置为 True,这将使递归层通过输入序列的正向和反向两个方向进行传递:

>>> class RNN(nn.Module):
...     def __init__(self, vocab_size, embed_dim,
...                  rnn_hidden_size, fc_hidden_size):
...         super().__init__()
...         self.embedding = nn.Embedding(
...             vocab_size, embed_dim, padding_idx=0
...         )
...         self.rnn = nn.LSTM(embed_dim, rnn_hidden_size,
...                            batch_first=True, bidirectional=True)
...         self.fc1 = nn.Linear(rnn_hidden_size*2, fc_hidden_size)
...         self.relu = nn.ReLU()
...         self.fc2 = nn.Linear(fc_hidden_size, 1)
...         self.sigmoid = nn.Sigmoid()
...
...     def forward(self, text, lengths):
...         out = self.embedding(text)
...         out = nn.utils.rnn.pack_padded_sequence(
...             out, lengths.cpu().numpy(), enforce_sorted=False, batch_first=True
...         )
...         _, (hidden, cell) = self.rnn(out)
...         out = torch.cat((hidden[-2, :, :],
...                          hidden[-1, :, :]), dim=1)
...         out = self.fc1(out)
...         out = self.relu(out)
...         out = self.fc2(out)
...         out = self.sigmoid(out)
...         return out
>>> 
>>> torch.manual_seed(1)
>>> model = RNN(vocab_size, embed_dim,
...             rnn_hidden_size, fc_hidden_size)
>>> model
RNN(
  (embedding): Embedding(69025, 20, padding_idx=0)
  (rnn): LSTM(20, 64, batch_first=True, bidirectional=True)
  (fc1): Linear(in_features=128, out_features=64, bias=True)
  (relu): ReLU()
  (fc2): Linear(in_features=64, out_features=1, bias=True)
  (sigmoid): Sigmoid()
) 

双向 RNN 层使每个输入序列经过两次传递:正向传递和反向传递(请注意,这与反向传播的正向和反向传递的上下文不同)。这些正向和反向传递的隐藏状态通常被连接成一个单一的隐藏状态。其他合并模式包括求和、乘积(将两次传递的结果相乘)和平均值(取两者的平均值)。

我们还可以尝试其他类型的递归层,比如常规的RNN。然而,事实证明,使用常规递归层构建的模型无法达到良好的预测性能(即使在训练数据上)。例如,如果您尝试将前面代码中的双向 LSTM 层替换为单向的nn.RNN层(而不是nn.LSTM),并且在完整长度的序列上训练模型,您可能会观察到损失在训练过程中甚至不会减少。原因是数据集中的序列太长,因此具有RNN层的模型无法学习长期依赖关系,并可能遭遇梯度消失或梯度爆炸问题。

项目二 – 在 PyTorch 中进行字符级语言建模

语言建模是一种迷人的应用,它使机器能够执行与人类语言相关的任务,例如生成英文句子。在这个领域的一个有趣研究是Ilya SutskeverJames MartensGeoffrey E. Hinton的文章Generating Text with Recurrent Neural Networks,发表于 2011 年的第 28 届国际机器学习会议(ICML-11)(pdfs.semanticscholar.org/93c2/0e38c85b69fc2d2eb314b3c1217913f7db11.pdf)。

在我们即将构建的模型中,输入是一个文本文档,我们的目标是开发一个能够生成与输入文档风格类似的新文本的模型。这样的输入示例包括书籍或特定编程语言的计算机程序。

在字符级语言建模中,输入被分解为一个字符序列,逐个字符输入到我们的网络中。网络将每个新字符与先前看到的字符的记忆一起处理,以预测下一个字符。

图 15.11显示了字符级语言建模的一个示例(注意 EOS 代表“序列结束”):

图 15.11:字符级语言建模

我们可以将这个实现分解成三个单独的步骤:准备数据,构建 RNN 模型,以及进行下一个字符预测和抽样,以生成新的文本。

数据集预处理

在本节中,我们将为字符级语言建模准备数据。

要获取输入数据,请访问古腾堡计划网站 www.gutenberg.org/,该网站提供数千本免费电子书。例如,您可以从 www.gutenberg.org/files/1268/1268-0.txt 下载儒勒·凡尔纳(于 1874 年出版)的书籍《神秘岛》的纯文本格式版本。

请注意,此链接将直接带您到下载页面。如果您使用的是 macOS 或 Linux 操作系统,您可以使用终端中的以下命令下载文件:

curl -O https://www.gutenberg.org/files/1268/1268-0.txt 

如果将来此资源不可用,本章节代码目录中也包含了此文本的副本,位于书籍代码库的github.com/rasbt/machine-learning-book

一旦我们下载了数据集,我们可以将其作为纯文本读入 Python 会话。使用以下代码,我们将直接从下载的文件中读取文本,并删除开头和结尾的部分(这些部分包含 Gutenberg 项目的某些描述)。然后,我们将创建一个 Python 变量char_set,表示在这个文本中观察到的唯一字符集:

>>> import numpy as np
>>> ## Reading and processing text
>>> with open('1268-0.txt', 'r', encoding="utf8") 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 数组并索引该数组以将索引映射到这些唯一字符更为高效。图 15.12展示了将字符转换为整数以及对单词"Hello""world"进行反向映射的示例:

图 15.12: 字符和整数映射

构建将字符映射到整数的字典,并通过索引 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 

text_encoded NumPy 数组包含文本中所有字符的编码值。现在,我们将打印出这个数组中前五个字符的映射:

>>> for ex in text_encoded[:5]:
...     print('{} -> {}'.format(ex, char_array[ex]))
44 -> T
32 -> H
29 -> E
1 ->  
37 -> M 

现在,让我们退后一步,看看我们试图做的大局。对于文本生成任务,我们可以将问题描述为一个分类任务。

假设我们有一组不完整的文本字符序列,如图 15.13所示:

图 15.13: 预测文本序列的下一个字符

图 15.13中,我们可以将左侧框中显示的序列视为输入。为了生成新的文本,我们的目标是设计一个模型,该模型可以预测给定输入序列的下一个字符,其中输入序列代表不完整的文本。例如,看到“Deep Learn”后,模型应该预测下一个字符是“i”。鉴于我们有 80 个唯一字符,这个问题成为了一个多类别分类任务。

从长度为 1 的序列开始(即一个单一字母),我们可以根据这种多类别分类方法迭代地生成新的文本,如图 15.14所示:

图 15.14: 基于这种多类别分类方法生成下一个文本

要在 PyTorch 中实现文本生成任务,让我们首先将序列长度剪切为 40。这意味着输入张量 x 由 40 个标记组成。在实践中,序列长度影响生成文本的质量。较长的序列可能会导致更有意义的句子。然而,对于较短的序列,模型可能会更专注于正确捕捉单个单词,而忽略大部分上下文。虽然较长的序列通常会产生更有意义的句子,但正如前面提到的,对于长序列,RNN 模型可能难以捕捉长距离的依赖关系。因此,在实践中找到适当的序列长度是一个需要经验评估的超参数优化问题。在这里,我们选择 40,因为它提供了一个良好的折衷。

正如您在前面的图中所看到的,输入 x 和目标 y 相差一个字符。因此,我们将文本分割成大小为 41 的块:前 40 个字符将形成输入序列 x,最后的 40 个元素将形成目标序列 y

我们已经将整个编码文本按其原始顺序存储在 text_encoded 中。我们将首先创建由每个包含 41 个字符的文本块组成的文本块。如果最后一个块少于 41 个字符,我们将删除它。因此,被命名为 text_chunks 的新文本块数据集将始终包含大小为 41 的序列。这些 41 个字符的块将用于构建序列 x(即输入)和序列 y(即目标),它们都将包含 40 个元素。例如,序列 x 将由索引 [0, 1, ..., 39] 的元素组成。此外,由于序列 y 将相对于 x 向后移动一个位置,其对应的索引将是 [1, 2, ..., 40]。然后,我们将通过应用自定义的 Dataset 类将结果转换为 Dataset 对象:

>>> import torch
>>> from torch.utils.data import Dataset
>>> seq_length = 40
>>> chunk_size = seq_length + 1
>>> text_chunks = [text_encoded[i:i+chunk_size]
...                for i in range(len(text_encoded)-chunk_size)]
>>> from torch.utils.data import Dataset
>>> class TextDataset(Dataset):
...     def __init__(self, text_chunks):
...         self.text_chunks = text_chunks
...
...     def __len__(self):
...         return len(self.text_chunks)
...
...     def __getitem__(self, idx):
...         text_chunk = self.text_chunks[idx]
...         return text_chunk[:-1].long(), text_chunk[1:].long()
>>>
>>> seq_dataset = TextDataset(torch.tensor(text_chunks)) 

让我们来看看从转换后的数据集中提取的一些示例序列:

>>> for i, (seq, target) in enumerate(seq_dataset):
...     print(' Input (x): ',
...           repr(''.join(char_array[seq])))
...     print('Target (y): ',
...           repr(''.join(char_array[target])))
...     print()
...     if i == 1:
...         break
 Input (x): 'THE MYSTERIOUS ISLAND ***\n\n\n\n\nProduced b'
Target (y): 'HE MYSTERIOUS ISLAND ***\n\n\n\n\nProduced by'
 Input (x): 'HE MYSTERIOUS ISLAND ***\n\n\n\n\nProduced by'
Target (y): 'E MYSTERIOUS ISLAND ***\n\n\n\n\nProduced by ' 

最后,准备数据集的最后一步是将该数据集转换为小批次:

>>> from torch.utils.data import DataLoader
>>> batch_size = 64
>>> torch.manual_seed(1)
>>> seq_dl = DataLoader(seq_dataset, batch_size=batch_size,
...                     shuffle=True, drop_last=True) 

构建字符级 RNN 模型

现在数据集准备好了,构建模型将相对简单:

>>> import torch.nn as nn
>>> class RNN(nn.Module):
...     def __init__(self, vocab_size, embed_dim, rnn_hidden_size):
...         super().__init__()
...         self.embedding = nn.Embedding(vocab_size, embed_dim)
...         self.rnn_hidden_size = rnn_hidden_size
...         self.rnn = nn.LSTM(embed_dim, rnn_hidden_size,
...                            batch_first=True)
...         self.fc = nn.Linear(rnn_hidden_size, vocab_size)
...
...     def forward(self, x, hidden, cell):
...         out = self.embedding(x).unsqueeze(1)
...         out, (hidden, cell) = self.rnn(out, (hidden, cell))
...         out = self.fc(out).reshape(out.size(0), -1)
...         return out, hidden, cell
...
...     def init_hidden(self, batch_size):
...         hidden = torch.zeros(1, batch_size, self.rnn_hidden_size)
...         cell = torch.zeros(1, batch_size, self.rnn_hidden_size)
...         return hidden, cell 

请注意,我们需要将模型的输出设定为 logits,以便我们可以从模型预测中进行采样,以生成新的文本。我们稍后会涉及到这个采样部分。

然后,我们可以指定模型参数并创建一个 RNN 模型:

>>> vocab_size = len(char_array)
>>> embed_dim = 256
>>> rnn_hidden_size = 512
>>> torch.manual_seed(1)
>>> model = RNN(vocab_size, embed_dim, rnn_hidden_size)
>>> model
RNN(
  (embedding): Embedding(80, 256)
  (rnn): LSTM(256, 512, batch_first=True)
  (fc): Linear(in_features=512, out_features=80, bias=True)
  (softmax): LogSoftmax(dim=1)
) 

接下来的步骤是创建损失函数和优化器(Adam 优化器)。对于单个 logits 输出的多类别分类(我们有 vocab_size=80 类),我们使用 CrossEntropyLoss 作为损失函数:

>>> loss_fn = nn.CrossEntropyLoss()
>>> optimizer = torch.optim.Adam(model.parameters(), lr=0.001) 

现在我们将对模型进行 10,000 个周期的训练。在每个周期中,我们将从数据加载器 seq_dl 中随机选择一个批次进行训练。我们还将每 500 个周期显示一次训练损失:

>>> num_epochs = 10000
>>> torch.manual_seed(1)
>>> for epoch in range(num_epochs):
...     hidden, cell = model.init_hidden(batch_size)
...     seq_batch, target_batch = next(iter(seq_dl))
...     optimizer.zero_grad()
...     loss = 0
...     for c in range(seq_length):
...         pred, hidden, cell = model(seq_batch[:, c], hidden, cell)
...         loss += loss_fn(pred, target_batch[:, c])
...     loss.backward()
...     optimizer.step()
...     loss = loss.item()/seq_length
...     if epoch % 500 == 0:
...         print(f'Epoch {epoch} loss: {loss:.4f}')
Epoch 0 loss: 1.9689
Epoch 500 loss: 1.4064
Epoch 1000 loss: 1.3155
Epoch 1500 loss: 1.2414
Epoch 2000 loss: 1.1697
Epoch 2500 loss: 1.1840
Epoch 3000 loss: 1.1469
Epoch 3500 loss: 1.1633
Epoch 4000 loss: 1.1788
Epoch 4500 loss: 1.0828
Epoch 5000 loss: 1.1164
Epoch 5500 loss: 1.0821
Epoch 6000 loss: 1.0764
Epoch 6500 loss: 1.0561
Epoch 7000 loss: 1.0631
Epoch 7500 loss: 0.9904
Epoch 8000 loss: 1.0053
Epoch 8500 loss: 1.0290
Epoch 9000 loss: 1.0133
Epoch 9500 loss: 1.0047 

接下来,我们可以评估模型以生成新文本,从给定的短字符串开始。 在下一节中,我们将定义一个函数来评估训练好的模型。

评估阶段 – 生成新的文本段落

我们在前一节训练的 RNN 模型为每个独特字符返回大小为 80 的对数。 这些对数可以通过 softmax 函数轻松转换为概率,即特定字符将被遇到作为下一个字符的概率。 为了预测序列中的下一个字符,我们可以简单地选择具有最大对数值的元素,这相当于选择具有最高概率的字符。 但是,我们不希望总是选择具有最高可能性的字符,而是希望(随机)从输出中抽样;否则,模型将始终生成相同的文本。 PyTorch 已经提供了一个类,torch.distributions.categorical.Categorical,我们可以使用它从分类分布中绘制随机样本。 看看这是如何工作的,让我们从三个类别 [0, 1, 2] 中生成一些随机样本,使用输入对数 [1, 1, 1]:

>>> from torch.distributions.categorical import Categorical
>>> torch.manual_seed(1)
>>> logits = torch.tensor([[1.0, 1.0, 1.0]])
>>> print('Probabilities:',
...       nn.functional.softmax(logits, dim=1).numpy()[0])
Probabilities: [0.33333334 0.33333334 0.33333334]
>>> m = Categorical(logits=logits)
>>> samples = m.sample((10,))
>>> print(samples.numpy())
[[0]
 [0]
 [0]
 [0]
 [1]
 [0]
 [1]
 [2]
 [1]
 [1]] 

正如您所见,给定的对数,各类别具有相同的概率(即,等概率类别)。 因此,如果我们使用大样本量(num_samples → ∞),我们期望每个类别的出现次数达到样本大小的 ≈ 1/3。 如果我们将对数更改为 [1, 1, 3],那么我们预期会观察到更多类别 2 的出现次数(当从该分布中抽取大量示例时):

>>> torch.manual_seed(1)
>>> logits = torch.tensor([[1.0, 1.0, 3.0]])
>>> print('Probabilities:', nn.functional.softmax(logits, dim=1).numpy()[0])
Probabilities: [0.10650698 0.10650698 0.78698605]
>>> m = Categorical(logits=logits)
>>> samples = m.sample((10,))
>>> print(samples.numpy())
[[0]
 [2]
 [2]
 [1]
 [2]
 [1]
 [2]
 [2]
 [2]
 [2]] 

使用 Categorical,我们可以基于模型计算的对数生成示例。

我们将定义一个函数,sample(),接收一个短起始字符串,starting_str,并生成一个新字符串,generated_str,最初设置为输入字符串。 starting_str 被编码为一系列整数,encoded_inputencoded_input 逐个字符传递给 RNN 模型以更新隐藏状态。 encoded_input 的最后一个字符传递给模型以生成新字符。注意,RNN 模型的输出表示下一个字符的对数(这里是一个大小为 80 的向量,即可能字符的总数)。

在这里,我们仅使用 logits 输出(即,o(T^)),传递给 Categorical 类以生成一个新样本。 这个新样本被转换为一个字符,然后附加到生成的字符串 generated_text 的末尾,使其长度增加 1。 然后,此过程重复,直到生成字符串的长度达到所需值。 将生成序列作为生成新元素的输入消耗的过程称为自回归

sample() 函数的代码如下所示:

>>> def sample(model, starting_str,
...            len_generated_text=500,
...            scale_factor=1.0):
...     encoded_input = torch.tensor(
...         [char2int[s] for s in starting_str]
...     )
...     encoded_input = torch.reshape(
...         encoded_input, (1, -1)
...     )
...     generated_str = starting_str
...
...     model.eval()
...     hidden, cell = model.init_hidden(1)
...     for c in range(len(starting_str)-1):
...         _, hidden, cell = model(
...             encoded_input[:, c].view(1), hidden, cell
...         )
...    
...     last_char = encoded_input[:, -1]
...     for i in range(len_generated_text):
...         logits, hidden, cell = model(
...             last_char.view(1), hidden, cell
...         )
...         logits = torch.squeeze(logits, 0)
...         scaled_logits = logits * scale_factor
...         m = Categorical(logits=scaled_logits)
...         last_char = m.sample()
...         generated_str += str(char_array[last_char])
...
...     return generated_str 

现在让我们生成一些新文本:

>>> torch.manual_seed(1)
>>> print(sample(model, starting_str='The island'))
The island had been made
and ovylore with think, captain?" asked Neb; "we do."
It was found, they full to time to remove. About this neur prowers, perhaps ended? It is might be
rather rose?"
"Forward!" exclaimed Pencroft, "they were it? It seems to me?"
"The dog Top--"
"What can have been struggling sventy."
Pencroft calling, themselves in time to try them what proves that the sailor and Neb bounded this tenarvan's feelings, and then
still hid head a grand furiously watched to the dorner nor his only 

正如您所见,模型生成的大多数词汇是正确的,有时甚至部分句子是有意义的。您可以进一步调整训练参数,例如训练的输入序列长度和模型架构。

此外,为了控制生成样本的可预测性(即根据训练文本学习模式生成文本与增加更多随机性之间的权衡),RNN 模型计算的 logits 可以在传递给Categorical进行抽样之前进行缩放。缩放因子可以类比于物理学中的温度。较高的温度导致更多的熵或随机性,而较低的温度则导致更可预测的行为。通过缩放 logits,softmax 函数计算出的概率变得更加均匀,如下面的代码所示:

>>> logits = torch.tensor([[1.0, 1.0, 3.0]])
>>> print('Probabilities before scaling:        ',
...       nn.functional.softmax(logits, dim=1).numpy()[0])
>>> print('Probabilities after scaling with 0.5:',
...       nn.functional.softmax(0.5*logits, dim=1).numpy()[0])
>>> print('Probabilities after scaling with 0.1:',
...       nn.functional.softmax(0.1*logits, dim=1).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]。现在,我们可以将生成的文本与进行比较,如下所示:

  • >>> torch.manual_seed(1)
    >>> print(sample(model, starting_str='The island',
    ...              scale_factor=2.0))
    The island is one of the colony?" asked the sailor, "there is not to be able to come to the shores of the Pacific."
    "Yes," replied the engineer, "and if it is not the position of the forest, and the marshy way have been said, the dog was not first on the shore, and
    found themselves to the corral.
    The settlers had the sailor was still from the surface of the sea, they were not received for the sea. The shore was to be able to inspect the windows of Granite House.
    The sailor turned the sailor was the hor 
    
  • >>> torch.manual_seed(1)
    >>> print(sample(model, starting_str='The island',
    ...              scale_factor=0.5))
    The island
    deep incomele.
    Manyl's', House, won's calcon-sglenderlessly," everful ineriorouins., pyra" into
    truth. Sometinivabes, iskumar gave-zen."
    Bleshed but what cotch quadrap which little cedass
    fell oprely
    by-andonem. Peditivall--"i dove Gurgeon. What resolt-eartnated to him
    ran trail.
    Withinhe)tiny turns returned, after owner plan bushelsion lairs; they were
    know? Whalerin branch I
    pites, Dougg!-iteun," returnwe aid masses atong thoughts! Dak,
    Hem-arches yone, Veay wantzer? Woblding,
    Herbert, omep 
    

结果显示,使用(增加温度)来缩放 logits 会生成更随机的文本。生成的文本的新颖性与其正确性之间存在权衡。

在本节中,我们进行了字符级文本生成的工作,这是一个序列到序列(seq2seq)建模任务。虽然这个例子本身可能不是非常有用,但很容易想到这些模型的几个实用应用,例如,类似的 RNN 模型可以训练成为一个聊天机器人,以帮助用户解决简单的查询问题。

概要

在本章中,您首先了解了使序列不同于其他类型数据(如结构化数据或图像)的特性。然后,我们介绍了用于序列建模的 RNN 的基础知识。您了解了基本 RNN 模型的工作原理,并讨论了其在捕获序列数据中的长期依赖性方面的局限性。接下来,我们介绍了 LSTM 单元,它包括一个门控机制,用于减少基本 RNN 模型中常见的爆炸梯度和消失梯度问题的影响。

在讨论了 RNN 的主要概念之后,我们使用 PyTorch 实现了几个具有不同循环层的 RNN 模型。特别是,我们实现了用于情感分析的 RNN 模型,以及用于生成文本的 RNN 模型。

在下一章中,我们将看到如何通过引入注意力机制来增强 RNN,帮助其模拟翻译任务中的长距离依赖关系。然后,我们将介绍一种称为transformer的新深度学习架构,该架构最近在自然语言处理领域推动了技术前沿。

加入我们书籍的 Discord 空间

加入该书的 Discord 工作区,与作者进行每月的问我任何事会话:

packt.link/MLwPyTorch

第十六章:变压器 - 使用注意机制改进自然语言处理

在上一章中,我们了解了关于循环神经网络RNNs)及其在自然语言处理NLP)中的应用,通过一个情感分析项目。然而,最近出现了一种新的架构,已被证明在几个 NLP 任务中优于基于 RNN 的序列到序列seq2seq)模型。这就是所谓的变压器架构。

变压器已经彻底改变了自然语言处理,并在许多引人注目的应用中处于前沿,从自动语言翻译(ai.googleblog.com/2020/06/recent-advances-in-google-translate.html)到对蛋白质序列的基本属性建模(www.pnas.org/content/118/15/e2016239118.short)以及创建帮助人们编写代码的 AI(github.blog/2021-06-29-introducing-github-copilot-ai-pair-programmer)。

在本章中,您将了解注意力自注意力的基本机制,并看到它们如何在原始变压器架构中使用。然后,掌握了变压器的工作原理后,我们将探索从这种架构中出现的一些最有影响力的 NLP 模型,并学习如何在 PyTorch 中使用大规模语言模型,即所谓的 BERT 模型。

我们将涵盖以下主题:

  • 使用注意机制改进 RNN

  • 引入独立的自注意力机制

  • 理解原始变压器架构

  • 比较基于变压器的大规模语言模型

  • 为情感分类微调 BERT

将注意机制添加到 RNN 中

在本节中,我们讨论开发注意机制背后的动机,这有助于预测模型更专注于输入序列的某些部分,以及它最初是如何在 RNN 的背景下使用的。请注意,本节提供了一个历史视角,解释了为什么开发注意机制。如果个别数学细节显得复杂,可以放心跳过,因为这些对于接下来的章节并不需要,而后者将重点介绍变压器中的自注意力机制解释。

注意帮助 RNN 访问信息

要理解注意机制的发展,请考虑传统的 RNN 模型,例如用于语言翻译等seq2seq 任务,它在生成翻译之前会解析整个输入序列(例如一个或多个句子),如图 16.1所示:

Diagram  Description automatically generated

图 16.1:用于 seq2seq 建模任务的传统 RNN 编码器-解码器架构

为什么 RNN 在生成第一个输出之前要解析整个输入句子?这是因为逐字翻译句子很可能导致语法错误,如图 16.2 所示:

自动生成带有文字的图片

图 16.2:逐字翻译句子可能导致语法错误

然而,正如图 16.2 所示,这种 seq2seq 方法的一个局限性在于 RNN 试图通过一个单一的隐藏单元记住整个输入序列再进行翻译。将所有信息压缩到一个隐藏单元中可能会导致信息丢失,特别是对于长序列。因此,类似于人类翻译句子的方式,每个时间步骤都可以访问整个输入序列可能是有益的。

与普通的循环神经网络相比,注意力机制允许循环神经网络在每个时间步访问所有输入元素。然而,每个时间步访问所有输入序列元素可能会很复杂。因此,为了帮助循环神经网络集中精力处理输入序列中最相关的元素,注意力机制为每个输入元素分配不同的注意力权重。这些注意力权重指定了在特定时间步骤上给定输入序列元素的重要性或相关性。例如,重新审视图 16.2,单词“mir, helfen, zu”可能比“kannst, du, Satz”对生成输出词“help”更相关。

下一小节介绍了一种带有注意力机制的 RNN 架构,以帮助处理用于语言翻译的长序列。

用于 RNN 的原始注意力机制

在本小节中,我们将总结最初用于语言翻译的注意力机制的机制,并首次出现在以下论文中:Neural Machine Translation by Jointly Learning to Align and Translate,作者为Bahdanau, D., Cho, K., and Bengio, Y., 2014,arxiv.org/abs/1409.0473

给定一个输入序列 ,注意力机制为每个元素 (或者更具体地说,它的隐藏表示)分配一个权重,并帮助模型确定它应该专注于输入的哪一部分。例如,假设我们的输入是一个句子,具有较大权重的单词对我们理解整个句子更有贡献。图 16.3 中带有注意力机制的 RNN(模仿前述论文)说明了生成第二个输出词的整体概念:

自动生成描述的图解

图 16.3:带有注意力机制的 RNN

图中描述的基于注意力的架构由两个 RNN 模型组成,我们将在下一小节中解释。

使用双向 RNN 处理输入

图 16.3 中基于注意力的 RNN 中的第一个 RNN(RNN #1)是一个双向 RNN,生成上下文向量 。可以将上下文向量视为输入向量 的增强版本。换句话说,输入向量 还通过注意力机制从所有其他输入元素获取信息。如 图 16.3 所示,RNN #2 然后使用由 RNN #1 准备的这个上下文向量生成输出。在本小节的其余部分,我们将讨论 RNN #1 的工作原理,并在下一小节重新审视 RNN #2。

双向 RNN #1 处理输入序列 x 的正向()和反向()两个方向。以反向顺序解析序列与反转原始输入序列效果相同,可以将其理解为逆序阅读句子。这样做的原理是为了捕捉额外的信息,因为当前的输入可能依赖于句子中之前或之后的序列元素,或者两者都有。

因此,通过两次读取输入序列(即正向和反向),我们为每个输入序列元素得到两个隐藏状态。例如,对于第二个输入序列元素 ,我们从正向传递得到隐藏状态 ,从反向传递得到隐藏状态 。然后,这两个隐藏状态被拼接成隐藏状态 。例如,如果 都是 128 维向量,则拼接后的隐藏状态 将包含 256 个元素。我们可以将这个拼接的隐藏状态视为源词的“注释”,因为它包含了双向阅读中第 j 个词的信息。

在接下来的小节中,我们将看到如何进一步处理和使用第二个 RNN 生成输出的这些拼接隐藏状态。

从上下文向量生成输出

图 16.3 中,我们可以将 RNN #2 视为生成输出的主要 RNN。除了隐藏状态外,它还接收所谓的上下文向量作为输入。上下文向量 是拼接隐藏状态 的加权版本,这些隐藏状态是我们在前一小节从 RNN #1 获取的。我们可以计算第 i 个输入的上下文向量为加权求和:

在这里, 表示对输入序列 的注意力权重,用于第 i 个输入序列元素的上下文。注意,每个第 i 个输入序列元素都有一组唯一的注意力权重。我们将在下一小节讨论注意力权重 的计算方法。

在本小节的其余部分,让我们讨论通过前述图中的第二个 RNN(RNN #2)如何使用上下文向量。就像普通的 RNN 一样,RNN #2 也使用隐藏状态。考虑到前述的“注释”和最终输出之间的隐藏层,让我们将时间 处的隐藏状态表示为 。现在,RNN #2 在每个时间步 i 接收上述的上下文向量 作为输入。

图 16.3中,我们看到隐藏状态 取决于前一个隐藏状态 、前一个目标词 和上下文向量 ,这些用于生成目标词 在时间 i 处的预测输出。请注意,序列向量 指的是代表输入序列 的正确翻译的序列向量,在训练期间可用。在训练期间,真实标签(单词) 被馈送到下一个状态 ;由于这个真实标签信息在预测(推断)时不可用,我们改为馈送预测输出 ,如前图所示。

总结我们刚刚讨论的内容,基于注意力的 RNN 由两个 RNN 组成。RNN #1 从输入序列元素准备上下文向量,而 RNN #2 将上下文向量作为输入接收。上下文向量通过对输入进行加权求和来计算,其中权重是注意力权重 。下一小节讨论如何计算这些注意力权重。

计算注意力权重

最后,让我们访问我们谜题中的最后一块遗失的部分——注意力权重。因为这些权重成对连接输入(注释)和输出(上下文),每个注意力权重 都有两个下标:j 指的是输入的索引位置,i 对应输出的索引位置。注意力权重 是对齐分数 的归一化版本,其中对齐分数评估了位置 j 周围的输入与位置 i 处的输出匹配的程度。更具体地说,注意力权重通过以下方式计算归一化的对齐分数:

请注意,这个方程式类似于我们在第十二章PyTorch 中的神经网络训练并行化中讨论过的 softmax 函数,在通过 softmax 函数估计多类分类中的类概率小节。因此,注意力权重 ... 总和为 1。

现在,总结一下,我们可以将基于注意力的 RNN 模型结构化为三个部分。第一部分计算输入的双向注释。第二部分包括循环块,与原始 RNN 非常相似,只是使用上下文向量代替原始输入。最后一部分涉及注意力权重和上下文向量的计算,描述每对输入和输出元素之间的关系。

变压器架构也利用注意力机制,但与基于注意力的 RNN 不同,它仅依赖于自注意力机制,并且不包括 RNN 中发现的循环过程。换句话说,变压器模型一次处理整个输入序列,而不是逐个元素地读取和处理序列。在接下来的小节中,我们将介绍自注意力机制的基本形式,然后在下一小节中更详细地讨论变压器架构。

引入自注意力机制

在前一小节中,我们看到注意力机制可以帮助 RNN 在处理长序列时记住上下文。正如我们将在下一小节中看到的那样,我们可以有一个完全基于注意力而非循环部分的架构。这种基于注意力的架构被称为变压器,我们将在后面更详细地讨论它。

实际上,变压器一开始看起来可能有点复杂。因此,在我们在下一小节讨论变压器之前,让我们先深入探讨变压器中使用的自注意力机制。实际上,正如我们将看到的,这种自注意力机制只是我们在前一小节讨论的注意力机制的另一种形式。我们可以将前面讨论的注意力机制视为连接两个不同模块的操作,即 RNN 的编码器和解码器。正如我们将看到的,自注意力仅关注输入,并且仅捕捉输入元素之间的依赖关系,而不连接两个模块。

在第一小节中,我们将介绍一种没有任何学习参数的基本自注意力形式,这非常类似于输入的预处理步骤。然后在第二小节中,我们将介绍变压器架构中使用的常见自注意力版本,涉及可学习参数。

从基本的自注意力开始

为了介绍自注意力,让我们假设我们有一个长度为T的输入序列,,以及一个输出序列,。为避免混淆,我们将使用作为整个变压器模型的最终输出,作为自注意力层的输出,因为它是模型中的中间步骤。

这些序列中的每个第 i 个元素,,都是大小为 d 的向量(即 ),表示位置 i 处输入的特征信息,类似于循环神经网络。然后,对于 seq2seq 任务,自注意力的目标是建模当前输入元素与序列中所有其他输入元素之间的依赖关系。为了实现这一目标,自注意机制由三个阶段组成。首先,我们根据当前元素与序列中所有其他元素之间的相似性导出重要性权重。其次,我们对权重进行归一化,通常涉及使用已熟悉的 softmax 函数。第三,我们使用这些权重与相应的序列元素结合计算注意力值。

更正式地说,自注意力的输出,,是所有输入序列 T 的加权和,(其中 )。例如,对于第 i 个输入元素,相应的输出值计算如下:

因此,我们可以将 视为上下文感知的嵌入向量,在输入向量 中涉及所有其他输入序列元素,这些元素根据它们各自的注意力权重 计算。更具体地说,这种相似性是通过下文中解释的两个步骤来计算的。

首先,我们计算当前输入元素 与输入序列中另一个元素 的点积:

在我们归一化 值以获得注意力权重 之前,让我们通过代码示例说明如何计算 值。在这里,假设我们有一个输入句子“can you help me to translate this sentence”,该句子已经通过字典映射到整数表示,如 第十五章,使用循环神经网络建模顺序数据 中所述:

>>> import torch
>>> sentence = torch.tensor(
>>>     [0, # can
>>>      7, # you     
>>>      1, # help
>>>      2, # me
>>>      5, # to
>>>      6, # translate
>>>      4, # this
>>>      3] # sentence
>>> )
>>> sentence
tensor([0, 7, 1, 2, 5, 6, 4, 3]) 

假设我们已经通过嵌入层将这句话编码成实数向量表示。在这里,我们的嵌入大小是 16,并假设词典大小是 10。以下代码将产生我们八个单词的词嵌入:

>>> torch.manual_seed(123)
>>> embed = torch.nn.Embedding(10, 16)
>>> embedded_sentence = embed(sentence).detach()
>>> embedded_sentence.shape
torch.Size([8, 16]) 

现在,我们可以计算 作为第 i 和第 j 个词嵌入之间的点积。我们可以对所有 值进行如下计算:

>>> omega = torch.empty(8, 8)
>>> for i, x_i in enumerate(embedded_sentence):
>>>     for j, x_j in enumerate(embedded_sentence):
>>>         omega[i, j] = torch.dot(x_i, x_j) 

尽管上述代码易于阅读和理解,for循环可能非常低效,因此让我们改用矩阵乘法来计算:

>>> omega_mat = embedded_sentence.matmul(embedded_sentence.T) 

我们可以使用torch.allclose函数检查该矩阵乘法是否产生预期结果。如果两个张量包含相同的值,torch.allclose将返回True,如我们可以看到的那样:

>>> torch.allclose(omega_mat, omega)
True 

我们已经学习了如何计算基于相似性的第i个输入及其序列中所有输入的权重(从),“原始”权重(从)。我们可以通过常见的 softmax 函数对值进行标准化来获取注意力权重,如下所示:

注意分母涉及对所有输入元素的求和()。因此,应用此 softmax 函数后,权重在标准化后将总和为 1,即,

我们可以使用 PyTorch 的 softmax 函数计算注意力权重如下:

>>> import torch.nn.functional as F
>>> attention_weights = F.softmax(omega, dim=1)
>>> attention_weights.shape
torch.Size([8, 8]) 

注意attention_weights是一个矩阵,其中每个元素表示一个注意力权重,。例如,如果我们正在处理第i个输入单词,则此矩阵的第i行包含句子中所有单词的对应注意力权重。这些注意力权重指示每个单词与第i个单词的相关性。因此,此注意力矩阵中的列应该总和为 1,我们可以通过以下代码确认:

>>> attention_weights.sum(dim=1)
tensor([1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000]) 

现在我们已经看到了如何计算注意力权重,让我们回顾和总结自注意操作的三个主要步骤:

  1. 对于给定的输入元素,,以及集合{1, ..., T}中的每个第j个元素,计算点积,

  2. 通过使用 softmax 函数对点积进行标准化来获取注意力权重,

  3. 计算输出,,作为整个输入序列的加权和:

这些步骤在图 16.4 中进一步说明:

图表、图示、箱须图 描述自动生成

图 16.4:用于说明目的的基本自注意过程

最后,让我们看一个用于计算上下文向量的代码示例,作为输入的注意力加权和(图 16.4 中的步骤 3)。特别是,让我们假设我们正在计算第二个输入单词的上下文向量,即

>>> x_2 = embedded_sentence[1, :]
>>> context_vec_2 = torch.zeros(x_2.shape)
>>> for j in range(8):
...     x_j = embedded_sentence[j, :]
...     context_vec_2 += attention_weights[1, j] * x_j 
>>> context_vec_2
tensor([-9.3975e-01, -4.6856e-01,  1.0311e+00, -2.8192e-01,  4.9373e-01, -1.2896e-02, -2.7327e-01, -7.6358e-01,  1.3958e+00, -9.9543e-01,
-7.1288e-04,  1.2449e+00, -7.8077e-02,  1.2765e+00, -1.4589e+00,
-2.1601e+00]) 

同样,我们可以通过矩阵乘法更高效地实现这一点。使用以下代码,我们正在计算所有八个输入单词的上下文向量:

>>> context_vectors = torch.matmul(
...     attention_weights, embedded_sentence) 

与存储在embedded_sentence中的输入单词嵌入类似,context_vectors矩阵具有维度。此矩阵中的第二行包含第二个输入单词的上下文向量,并且我们可以再次使用torch.allclose()检查其实现:

>>> torch.allclose(context_vec_2, context_vectors[1])
True 

正如我们所见,第二个上下文向量的手动 for 循环和矩阵计算产生了相同的结果。

本节实现了自注意力的基本形式,而在下一节中,我们将修改这一实现,使用可在神经网络训练期间优化的可学习参数矩阵。

参数化自注意力机制:缩放点积注意力

现在您已经了解了自注意力背后的基本概念,本小节总结了更先进的自注意力机制,称为缩放点积注意力,这在变压器架构中被使用。请注意,在前一小节中,在计算输出时我们没有涉及任何可学习的参数。换句话说,使用先前介绍的基本自注意力机制时,变压器模型在如何在给定序列的模型优化过程中更新或更改注意力值方面是相当受限的。为了使自注意力机制更加灵活且有利于模型优化,我们将引入三个额外的权重矩阵,在模型训练过程中可以作为模型参数拟合。我们将这三个权重矩阵表示为 ,和 。它们用于将输入投影到查询、键和值序列元素,如下所示:

  • 查询序列 用于

  • 键序列 用于

  • 值序列 用于

图 16.5 展示了这些单独组件如何用于计算与第二输入元素对应的上下文感知嵌入向量:

Diagram  Description automatically generated

图 16.5:计算第二序列元素的上下文感知嵌入向量

查询、键和值术语

在原始变压器论文中使用的查询、键和值术语灵感来自信息检索系统和数据库。例如,如果我们输入一个查询,它将与键值匹配,其中某些值将被检索出来。

在这里, 都是大小为 的向量。因此,投影矩阵 的形状为 ,而 的形状为 。(注意, 是每个单词向量的维度,。)为简单起见,我们可以设计这些向量具有相同的形状,例如使用 。为了通过代码提供额外的直觉,我们可以初始化这些投影矩阵如下:

>>> torch.manual_seed(123)
>>> d = embedded_sentence.shape[1]
>>> U_query = torch.rand(d, d)
>>> U_key = torch.rand(d, d)
>>> U_value = torch.rand(d, d) 

使用查询投影矩阵,我们可以计算查询序列。对于此示例,将第二个输入元素,,作为我们的查询,如图 16.5所示:

>>> x_2 = embedded_sentence[1]
>>> query_2 = U_query.matmul(x_2) 

以类似的方式,我们可以计算关键序列和值序列,

>>> key_2 = U_key.matmul(x_2)
>>> value_2 = U_value.matmul(x_2) 

然而,正如我们从图 16.5中看到的那样,我们还需要计算所有其他输入元素的关键序列和值序列,计算方法如下:

>>> keys = U_key.matmul(embedded_sentence.T).T
>>> values = U_value.matmul(embedded_sentence.T).T 

在关键矩阵中,第 i 行对应于第 i 个输入元素的关键序列,值矩阵也是如此。我们可以再次使用 torch.allclose() 来确认这一点,它应该返回 True

>>> keys = U_key.matmul(embedded_sentence.T).T
>>> torch.allclose(key_2, keys[1])
>>> values = U_value.matmul(embedded_sentence.T).T
>>> torch.allclose(value_2, values[1]) 

在前一节中,我们计算了未归一化权重,,作为给定输入序列元素,,和第 j 个序列元素,,之间的成对点积。现在,在这个参数化的自注意力版本中,我们将 计算为查询和关键之间的点积:

例如,以下代码计算了未归一化的注意力权重,,即我们的查询与第三个输入序列元素之间的点积:

>>> omega_23 = query_2.dot(keys[2])
>>> omega_23
tensor(14.3667) 

由于我们稍后将需要这些,我们可以将此计算扩展到所有关键序列:

>>> omega_2 = query_2.matmul(keys.T)
>>> omega_2
tensor([-25.1623,   9.3602,  14.3667,  32.1482,  53.8976,  46.6626,  -1.2131, -32.9391]) 

自注意力的下一步是从未归一化的注意力权重,,转换为归一化的注意力权重,,使用 softmax 函数。然后我们可以进一步使用  来缩放 ,然后通过 softmax 函数进行归一化,如下所示:

请注意,通过缩放  乘以 ,其中通常 ,确保权重向量的欧几里得长度大致处于相同的范围内。

以下代码用于实现此归一化,以计算关于第二个输入元素作为查询的整个输入序列的注意力权重:

>>> attention_weights_2 = F.softmax(omega_2 / d**0.5, dim=0)
>>> attention_weights_2
tensor([2.2317e-09, 1.2499e-05, 4.3696e-05, 3.7242e-03, 8.5596e-01, 1.4025e-01, 8.8896e-07, 3.1936e-10]) 

最后,输出是值序列的加权平均值:,可以按以下方式实现:

>>> context_vector_2 = attention_weights_2.matmul(values)
>>> context_vector_2
tensor([-1.2226, -3.4387, -4.3928, -5.2125, -1.1249, -3.3041, 
-1.4316, -3.2765, -2.5114, -2.6105, -1.5793, -2.8433, -2.4142, 
-0.3998, -1.9917, -3.3499]) 

在本节中,我们介绍了一个带有可训练参数的自注意力机制,它让我们能够通过涉及所有输入元素的加权注意力分数来计算上下文感知嵌入向量。在接下来的一节中,我们将学习变压器架构,这是围绕本节介绍的自注意力机制的神经网络架构。

注意力是我们所需的一切:介绍原始变压器架构

有趣的是,最初的 Transformer 架构基于一个注意力机制,这个机制最初是在 RNN 中使用的。最初使用注意力机制的目的是在处理长句子时提高 RNN 的文本生成能力。然而,仅仅几年后,在为 RNN 尝试注意力机制后,研究人员发现,在删除循环层后,基于注意力的语言模型甚至更强大。这导致了 Transformer 架构的发展,这也是本章和后续部分的主题。

Transformer 架构最初是由 A. Vaswani 及其同事在 NeurIPS 2017 论文“Attention Is All You Need”中提出的(arxiv.org/abs/1706.03762)。由于自注意力机制,Transformer 模型能够捕捉输入序列中元素之间的长距离依赖关系,例如在 NLP 上下文中,这有助于模型更好地“理解”输入句子的含义。

尽管这种 Transformer 架构最初是为语言翻译设计的,但可以推广到其他任务,如英语成分解析、文本生成和文本分类。稍后,我们将讨论从这种原始 Transformer 架构衍生出的流行语言模型,如 BERT 和 GPT。我们从原始 Transformer 论文中修改的图 16.6,展示了我们将在本节讨论的主要架构和组件:

图 16.6:原始的 Transformer 架构

在接下来的小节中,我们逐步详细介绍这个原始 Transformer 模型,将其分解为两个主要模块:编码器和解码器。编码器接收原始顺序输入并使用多头自注意力模块编码嵌入。解码器接收处理后的输入,并使用掩码形式的自注意力输出结果序列(例如翻译后的句子)。

通过多头注意力编码上下文嵌入

编码器块的总体目标是接收顺序输入 ,并将其映射到连续表示 ,然后传递给解码器。

编码器是六个相同层的堆叠。这里的六不是一个魔法数字,而只是原始 Transformer 论文中的超参数选择。您可以根据模型性能调整层数。在这些相同层的每一层中,有两个子层:一个计算多头自注意力,我们将在下面讨论;另一个是全连接层,您在前几章已经遇到过。

让我们首先讨论多头自注意力,这是对前文中介绍的缩放点积注意力的简单修改。在缩放点积注意力中,我们使用三个矩阵(对应查询、值和键)来转换输入序列。在多头注意力的背景下,我们可以将这组三个矩阵看作一个注意力。正如其名称所示,在多头注意力中,我们现在有多个这样的头(一组查询、值和键矩阵),类似于卷积神经网络可以具有多个卷积核。

为了更详细地解释具有 个头的多头自注意力的概念,让我们分解为以下步骤。

首先,我们读取顺序输入 。假设每个元素被一个长度为 d 的向量嵌入。在这里,输入可以嵌入成一个 矩阵。然后,我们创建 组查询、键和值学习参数矩阵:

  • ...

因为我们使用这些权重矩阵来投影每个元素 以便在矩阵乘法中进行必要的维度匹配,因此 的形状都是 ,而 的形状是 。因此,生成的查询和键序列的长度均为 ,生成的值序列的长度为 。实际应用中,人们通常简化选择

要在代码中说明多头自注意力堆栈,首先考虑我们如何在前一小节中创建单一查询投影矩阵,参数化自注意力机制:缩放点积注意力

>>> torch.manual_seed(123)
>>> d = embedded_sentence.shape[1]
>>> one_U_query = torch.rand(d, d) 

现在假设我们有八个类似于原始变压器的注意力头,即

>>> h = 8
>>> multihead_U_query = torch.rand(h, d, d)
>>> multihead_U_key = torch.rand(h, d, d)
>>> multihead_U_value = torch.rand(h, d, d) 

正如代码中所示,可以通过简单地增加一个额外的维度来添加多个注意力头。

将数据分配到多个注意力头

实际上,转换器实现中并不是为每个注意力头单独使用一个矩阵,而是使用一个矩阵来处理所有注意力头。然后,这些注意力头在矩阵中被组织成逻辑上的独立区域,可以通过布尔掩码访问。这样可以更有效地实现多头注意力,因为多个矩阵乘法可以合并为单个矩阵乘法。然而,在本节中为简化起见,我们省略了这个实现细节。

初始化投影矩阵后,我们可以计算投影序列,类似于缩放点积注意力中的方式。现在,我们不是计算一组查询、键和值序列,而是需要计算 h 组。更正式地说,例如,涉及到第 i 个数据点在第 j 个头部的查询投影的计算可以写成如下形式:

然后,我们为所有头部 重复这个计算。

在代码中,这看起来像是对第二个输入词作为查询的情况:

>>> multihead_query_2 = multihead_U_query.matmul(x_2)
>>> multihead_query_2.shape
torch.Size([8, 16]) 

multihead_query_2 矩阵有八行,每行对应第 j 个注意力头。

类似地,我们可以计算每个头部的键和值序列:

>>> multihead_key_2 = multihead_U_key.matmul(x_2)
>>> multihead_value_2 = multihead_U_value.matmul(x_2)
>>> multihead_key_2[2]
tensor([-1.9619, -0.7701, -0.7280, -1.6840, -1.0801, -1.6778,  0.6763,  0.6547,
         1.4445, -2.7016, -1.1364, -1.1204, -2.4430, -0.5982, -0.8292, -1.4401]) 

代码输出显示了第三个注意力头中第二个输入元素的键向量。

但是,请记住,我们需要为所有输入序列元素重复键和值的计算,而不仅仅是 x_2 —— 我们需要这样做来后续计算自注意力。一个简单且生动的方法是将输入序列嵌入扩展到大小为 8 作为第一维度,即注意力头的数量。我们使用 .repeat() 方法来实现这一点:

>>> stacked_inputs = embedded_sentence.T.repeat(8, 1, 1)
>>> stacked_inputs.shape
torch.Size([8, 16, 8]) 

然后,我们可以通过 torch.bmm() 进行批次矩阵乘法,使用注意力头来计算所有键:

>>> multihead_keys = torch.bmm(multihead_U_key, stacked_inputs)
>>> multihead_keys.shape
torch.Size([8, 16, 8]) 

在这段代码中,我们现在有一个张量,其第一维度指向八个注意力头。第二和第三维度分别指向嵌入大小和单词数量。让我们交换第二和第三维度,以便键具有更直观的表示方式,即与原始输入序列 embedded_sentence 相同的维度:

>>> multihead_keys = multihead_keys.permute(0, 2, 1)
>>> multihead_keys.shape
torch.Size([8, 8, 16]) 

重新排列后,我们可以按如下方式访问第二个注意力头中的第二个键值:

>>> multihead_keys[2, 1] 
tensor([-1.9619, -0.7701, -0.7280, -1.6840, -1.0801, -1.6778,  0.6763,  0.6547,
         1.4445, -2.7016, -1.1364, -1.1204, -2.4430, -0.5982, -0.8292, -1.4401]) 

我们可以看到,这与我们之前通过 multihead_key_2[2] 得到的键值是相同的,这表明我们复杂的矩阵操作和计算是正确的。因此,让我们重复一下值序列的计算:

>>> multihead_values = torch.matmul(
        multihead_U_value, stacked_inputs)
>>> multihead_values = multihead_values.permute(0, 2, 1) 

我们按照单头注意力计算的步骤来计算上下文向量,如 自注意机制的参数化:缩放点积注意力 部分所述。出于简洁起见,我们将跳过中间步骤,并假设我们已经计算了第二个输入元素的上下文向量作为查询和八个不同注意力头,我们将其表示为 multihead_z_2,通过随机数据:

>>> multihead_z_2 = torch.rand(8, 16) 

注意,第一维度索引了八个注意力头,上下文向量类似于输入句子,是 16 维向量。如果这看起来很复杂,请将 multihead_z_2 视为 图 16.5 中显示的 的八个副本;也就是说,我们为每个注意力头有一个

然后,我们将这些向量连接成一个长度为 的长向量,并使用线性投影(通过全连接层)将其映射回长度为 的向量。这个过程在 图 16.7 中有所说明:

Diagram  Description automatically generated

图 16.7:将缩放点积注意力向量连接成一个向量并通过线性投影传递

在代码中,我们可以按以下方式实现连接和压缩:

>>> linear = torch.nn.Linear(8*16, 16)
>>> context_vector_2 = linear(multihead_z_2.flatten())
>>> context_vector_2.shape
torch.Size([16]) 

总结一下,多头自注意力是并行多次重复缩放点积注意力计算,并将结果合并。它在实践中表现非常好,因为多头帮助模型从输入的不同部分捕获信息,这与卷积网络中多个核产生多个通道以捕获不同特征信息的方式非常相似。最后,虽然多头注意力听起来计算量昂贵,但请注意计算可以全部并行进行,因为多头之间没有依赖关系。

学习语言模型:解码器和掩码多头注意力

与编码器类似,解码器也包含几个重复的层。除了我们在前述编码器部分介绍的两个子层(多头自注意力层和全连接层)之外,每个重复层还包含一个掩码多头注意力子层。

掩码注意力是原始注意力机制的一种变体,其中掩码注意力只通过“掩码”屏蔽掉一定数量的词来将有限的输入序列传递给模型。例如,在使用标记数据集构建语言翻译模型时,在训练过程中的序列位置 i,我们只传递来自位置 1 到 i-1 的正确输出词。所有其他词(例如,当前位置之后的词)对于模型是隐藏的,以防止模型“作弊”。这也与文本生成的性质一致:虽然在训练过程中我们知道真实的翻译词,但在实际应用中我们对地面真相一无所知。因此,我们只能将模型已经生成的解决方案传递给它,在位置 i

图 16.8 说明了解码器块中层的排列方式:

Diagram  Description automatically generated

图 16.8:解码器部分中的层排列

首先,将先前的输出词(输出嵌入)传递到掩码多头注意力层。然后,第二层同时接收来自编码器块的编码输入和掩码多头注意力层的输出,传递到多头注意力层。最后,我们将多头注意力的输出传递到一个全连接层,生成整体模型输出:与输出词对应的概率向量。

注意,我们可以使用 argmax 函数从这些单词概率中获取预测单词,这与我们在第十五章 使用递归神经网络建模序列数据中采用的整体方法类似。

将解码器与编码器块进行比较,主要区别在于模型可以关注的序列元素范围。在编码器中,对于每个给定的单词,都会计算整个句子中所有单词的注意力,这可以被视为一种双向输入解析形式。解码器还接收来自编码器的双向解析输入。然而,在输出序列方面,解码器仅考虑那些在当前输入位置之前的元素,这可以被解释为一种单向输入解析形式。

实现细节:位置编码和层归一化

在本小节中,我们将讨论一些转换器的实现细节,这些细节我们迄今为止只是粗略地提及,但是值得一提。

首先,让我们考虑原始转换器架构中的位置编码,这些编码是来自图 16.6的一部分。位置编码有助于捕获输入序列顺序信息,对于转换器而言至关重要,因为缩放的点积注意力层和全连接层都是置换不变的。这意味着,没有位置编码,单词的顺序会被忽略,并且对基于注意力的编码没有任何影响。然而,我们知道单词顺序对于理解一个句子是至关重要的。例如,考虑以下两个句子:

  1. 玛丽给约翰一朵花

  2. 约翰给玛丽一朵花

两个句子中出现的单词完全相同;然而,它们的含义却大不相同。

转换器通过在编码器和解码器块的开头向输入嵌入添加一个小值向量,使同一单词在不同位置具有略微不同的编码。特别地,原始的转换器架构使用所谓的正弦编码:

这里 是单词的位置,k 表示编码向量的长度,我们选择 k 与输入单词嵌入的维度相同,以便将位置编码和单词嵌入相加。使用正弦函数可以防止位置编码过大。例如,如果我们使用绝对位置 1,2,3... n 作为位置编码,它们会主导单词编码并使单词嵌入值变得可以忽略。

一般来说,有两种类型的位置编码,一种是绝对的(如前面的公式所示),另一种是相对的。前者记录单词的绝对位置,并对句子中的单词移动敏感。也就是说,绝对位置编码是每个给定位置的固定向量。另一方面,相对编码仅保持单词的相对位置,对句子移动是不变的。

接下来,让我们看看层归一化机制,这是由 J. Ba、J.R. Kiros 和 G.E. Hinton 在 2016 年同名论文 Layer Normalization(URL:arxiv.org/abs/1607.06450)中首次引入的。虽然批归一化在计算机视觉环境中很受欢迎,我们将在 第十七章《生成对抗网络用于合成新数据》中更详细地讨论它,但在自然语言处理(NLP)环境中,句子长度可能会变化,因此层归一化是首选。图 16.9 显示了层归一化和批归一化的主要区别:

Diagram  Description automatically generated

图 16.9:批归一化与层归一化的比较

虽然传统上层归一化是在每个特征的所有元素之间执行的,但在 transformers 中使用的层归一化扩展了这一概念,并为每个训练样本独立地计算所有特征值的归一化统计。

由于层归一化对每个训练样本计算均值和标准差,它放宽了小批量大小的限制或依赖关系。与批归一化相比,层归一化能够从具有小批量大小和不同长度数据中学习。但需要注意的是,原始的 Transformer 架构并没有变长输入(需要时会对句子进行填充),并且与 RNN 不同,模型中没有循环。那么,我们如何能够在这种情况下证明层归一化优于批归一化的使用呢?Transformers 通常在非常大的文本语料库上进行训练,这需要并行计算;这对于批归一化来说可能是具有挑战性的,因为训练样本之间存在依赖关系。层归一化没有这种依赖关系,因此对于 transformers 来说是一个更自然的选择。

利用未标记数据构建大规模语言模型

在这一部分中,我们将讨论从原始变压器中产生的流行大规模变压器模型。这些变压器之间的一个共同主题是它们都是在非常大的未标记数据集上进行预训练,然后针对各自的目标任务进行微调的。首先,我们将介绍基于变压器的模型的常见训练过程,并解释它如何与原始变压器不同。然后,我们将重点介绍流行的大规模语言模型,包括生成式预训练变压器GPT)、来自变压器的双向编码器表示BERT)和双向自回归变压器BART)。

预训练和微调变压器模型

在一个早期的章节中,注意力就是我们需要的:介绍原始变压器架构,我们讨论了如何利用原始变压器架构进行语言翻译。语言翻译是一个监督任务,需要一个带标签的数据集,这可能非常昂贵。缺乏大型的标记数据集是深度学习中一个长期存在的问题,特别是对于像变压器这样更加依赖数据的模型。然而,考虑到每天会产生大量的文本(书籍、网站和社交媒体帖子),一个有趣的问题是我们如何利用这些未标记的数据来改进模型训练。

我们是否可以利用变压器中的无标签数据的答案是yes,而技巧就是一个称为自监督学习的过程:我们可以从纯文本本身生成“标签”来进行监督学习。例如,给定一个大型的未标记文本语料库,我们训练模型执行下一个词预测,这使得模型能够学习单词的概率分布,并可以形成强大的语言模型的基础。

自监督学习传统上也被称为无监督预训练,对于现代基于变压器的模型的成功至关重要。无监督预训练中的“无监督”据说是指我们使用未标记的数据;然而,由于我们使用数据的结构生成标签(例如前面提到的下一个词预测任务),因此它仍然是一个监督学习过程。

进一步详细说明无监督预训练和下一个词预测的工作原理,如果我们有一个包含n个单词的句子,预训练过程可以分解为以下三个步骤:

  1. 步骤 1时,输入地面真实的单词 1,...,i-1。

  2. 要求模型预测位置i处的单词,并将其与地面真实单词i进行比较。

  3. 更新模型和时间步长,i := i + 1。回到步骤 1 并重复,直到所有单词都被处理完。

我们应该注意,在下一次迭代中,我们总是向模型提供正确的单词而不是上一轮模型生成的内容。

预训练的主要思想是利用普通文本,然后转移并微调模型,以执行某些具有(较小)标记数据集的特定任务。现在,有许多不同类型的预训练技术。例如,先前提到的下一个词预测任务可以被视为单向预训练方法。稍后,我们将介绍在不同语言模型中使用的其他预训练技术,以实现各种功能。

一个基于 Transformer 模型的完整训练过程包括两部分:(1)在大规模未标记数据集上进行预训练,以及(2)使用标记数据集对模型进行训练(即微调),以适应特定的下游任务。在第一步中,预训练模型并不针对特定任务设计,而是被训练为一个“通用”语言模型。随后,在第二步中,通过常规监督学习在标记数据集上,它可以泛化到任何定制任务中。

利用从预训练模型中获得的表示,主要有两种策略将模型转移并应用于特定任务:(1)基于特征的方法和(2)微调方法。(在这里,我们可以将这些表示视为模型最后层的隐藏层激活。)

特征驱动方法使用预训练表示作为标记数据集的附加特征。这要求我们学习如何从预训练模型中提取句子特征。一个早期以特征提取方法闻名的模型是 2018 年由 Peters 和同事在论文《深度上下文化的词表示》(URL:arxiv.org/abs/1802.05365)中提出的ELMo(从语言模型中嵌入)。ELMo 是一个预训练的双向语言模型,在预训练过程中以一定比例屏蔽单词。特别地,它在预训练期间随机屏蔽输入单词的 15%,建模任务是填补这些空白,即预测丢失(屏蔽)的单词。这与我们之前介绍的单向方法不同,后者在时间步i隐藏所有未来单词。双向屏蔽使模型能够从两端学习,因此可以捕获更全面的句子信息。预训练的 ELMo 模型能够生成高质量的句子表示,后续可作为特定任务的输入特征。换句话说,我们可以将特征驱动方法视为一种类似于主成分分析的基于模型的特征提取技术,我们在《第五章》,《通过降维压缩数据》中进行了讨论。

另一种微调方法是通过反向传播以常规监督方式更新预训练模型参数。与基于特征的方法不同,我们通常还会向预训练模型添加另一个完全连接的层,以完成诸如分类等特定任务,然后根据在标记训练集上的预测性能更新整个模型。遵循此方法的一个流行模型是 BERT,一个大规模的变压器模型,预先训练为双向语言模型。我们将在接下来的小节中更详细地讨论 BERT。此外,在本章的最后一节中,我们将看到一个代码示例,展示如何使用我们在第八章应用机器学习进行情感分析,和第十五章使用递归神经网络建模序列数据中使用的电影评论数据集,对预训练的 BERT 模型进行情感分类的微调。

在我们进入下一节并开始讨论基于 Transformer 的流行语言模型之前,以下图表总结了 Transformer 模型训练的两个阶段,并说明了基于特征和微调方法之间的区别:

­­­­自动生成的图表说明

图 16.10:采用预训练变压器进行下游任务的两种主要方式

利用 GPT 进行无标签数据的操作

生成预训练变压器GPT)是由 OpenAI 开发的一系列流行的大规模语言模型,用于生成文本。最近的模型 GPT-3,于 2020 年 5 月发布(语言模型是少样本学习者),正在产生令人惊讶的结果。GPT-3 生成的文本质量很难与人类生成的文本区分开。在本节中,我们将讨论 GPT 模型在高层次上的工作原理及其多年来的发展。

正如表 16.1中所列,GPT 模型系列中的一个明显演变是参数数量的增加:

模型 发布年份 参数数量 标题 论文链接
GPT-1 2018 1.1 亿 通过生成预训练来提升语言理解能力 链接
GPT-2 2019 15 亿 语言模型是无监督的多任务学习者 链接
GPT-3 2020 1750 亿 语言模型是少样本学习者 链接

表 16.1:GPT 模型概览

但是,让我们先不要过于超前,首先更仔细地看看 2018 年发布的 GPT-1 模型的情况,它的训练过程可以分解为两个阶段:

  1. 在大量未标记的纯文本上进行预训练

  2. 监督微调

正如图 16.11(改编自 GPT-1 论文)所示,我们可以将 GPT-1 视为一个由(1)解码器(没有编码器块)和(2)稍后添加的额外层组成的变压器,用于进行监督微调以完成特定任务:

图形用户界面  描述自动生成

图 16.11:GPT-1 变压器

在图中,请注意,如果我们的任务是文本预测(预测下一个词),那么模型在预训练步骤后就已经准备好了。否则,例如,如果我们的任务与分类或回归相关,则需要进行监督微调。

在预训练期间,GPT-1 利用变压器解码器结构,在给定的词位置,模型仅依赖于前面的词来预测下一个词。GPT-1 利用单向自注意机制,与 BERT 中的双向自注意机制相对,因为 GPT-1 专注于文本生成而不是分类。在文本生成期间,它以自然的从左到右的方向逐个生成单词。这里有一个值得强调的另一个方面:在训练过程中,对于每个位置,我们始终向模型提供来自前一位置的正确单词。但在推理过程中,我们只是向模型提供它已经生成的任何单词,以便生成新的文本。

在获得预训练模型(前一图中标记为Transformer的块)之后,我们将其插入到输入预处理块和线性层之间,其中线性层充当输出层(类似于本书前面讨论过的其他深度神经网络模型)。对于分类任务,微调就像首先对输入进行标记化,然后将其输入到预训练模型和新添加的线性层中,接着是 softmax 激活函数。然而,对于诸如问答之类的更复杂任务,输入以某种不一定匹配预训练模型的格式组织,这需要为每个任务定制的额外处理步骤。鼓励对特定修改感兴趣的读者阅读 GPT-1 论文以获取更多细节(链接在上表中提供)。

GPT-1 在零次任务上的表现也令人惊讶,这证明了它作为一个通用语言模型的能力,可以通过最少的任务特定微调定制不同类型的任务。零次学习通常描述的是机器学习中的一个特殊情况,在测试和推理过程中,模型需要对未在训练中观察到的类别的样本进行分类。在 GPT 的上下文中,零次设置指的是未见任务。

GPT 的适应能力激发了研究人员摒弃特定任务的输入和模型设置,从而推动了 GPT-2 的发展。与其前身不同,GPT-2 在输入或微调阶段不再需要任何额外的修改。模型不再需要重新排列序列以匹配所需格式,而是可以区分不同类型的输入,并在少量提示(所谓的“上下文”)下执行相应的下游任务。这是通过在输出概率上进行建模,条件是输入和任务类型,,而不仅仅是条件于输入。例如,如果上下文包括 translate to French, English text, French text,则期望模型能识别翻译任务。

这听起来比 GPT 更“人工智能”,实际上除了模型大小外,这是最显著的改进之一。正如其相应论文的标题所示(语言模型是无监督多任务学习者),无监督语言模型可能是零次学习的关键,而 GPT-2 充分利用了零次任务转移来构建这种多任务学习器。

与 GPT-2 相比,GPT-3 在某种意义上不太“雄心勃勃”,它将注意力从零次转移到一次和少次学习,通过上下文学习。虽然不提供特定任务的训练示例似乎过于严格,但少次学习不仅更现实,而且更像人类:人类通常需要看几个例子才能学会一个新任务。正如其名称所示,少次学习意味着模型看到少量任务示例,而一次学习则限于一个示例。

图 16.12 展示了零次、一次、少次和微调过程之间的区别:

图片描述,自动生成,中等置信度

图 16.12:零次、一次和少次学习的比较

GPT-3 的模型架构基本与 GPT-2 相同,只是参数规模增加了 100 倍,并使用了稀疏 transformer。在我们之前讨论过的原始(密集)注意力机制中,每个元素都关注输入中的所有其他元素,这会随着 的复杂性增加。稀疏注意力通过仅关注大小有限的元素子集来提高效率,通常与 成比例。有兴趣的读者可以通过访问稀疏 transformer 论文了解更多有关特定子集选择的信息:Generating Long Sequences with Sparse Transformers by Rewon Child 等人,2019(URL:arxiv.org/abs/1904.10509)。

使用 GPT-2 生成新的文本

在我们继续下一个 transformer 架构之前,让我们看看如何使用最新的 GPT 模型生成新的文本。请注意,GPT-3 目前仍然相对较新,并且目前仅通过 OpenAI API 的 beta 版本提供服务,网址为 openai.com/blog/openai-api/。但是,Hugging Face 提供了 GPT-2 的实现(一家知名的自然语言处理和机器学习公司;huggingface.co),我们将使用它。

我们将通过 transformers 访问 GPT-2,这是由 Hugging Face 创建的非常全面的 Python 库,提供各种基于 transformer 的模型进行预训练和微调。用户还可以在论坛上讨论和分享他们定制的模型。如果您有兴趣,请随时访问社区并参与其中:discuss.huggingface.co

安装 transformers 版本 4.9.1

由于这个软件包正在迅速发展,您可能无法在以下子章节中复制结果。作为参考,本教程使用的是 2021 年 6 月发布的 4.9.1 版本。要安装本书中使用的版本,您可以在终端中执行以下命令从 PyPI 安装它:

pip install transformers==4.9.1 

我们还建议查阅官方安装页面上的最新说明:

huggingface.co/transformers/installation.html

一旦我们安装了 transformers 库,我们可以运行以下代码来导入一个预训练的 GPT 模型,该模型可以生成新的文本:

>>> from transformers import pipeline, set_seed
>>> generator = pipeline('text-generation', model='gpt2') 

然后,我们可以用一个文本片段提示模型,并要求它基于该输入片段生成新的文本:

>>> set_seed(123)
>>> generator("Hey readers, today is",
...           max_length=20,
...           num_return_sequences=3)
[{'generated_text': "Hey readers, today is not the last time we'll be seeing one of our favorite indie rock bands"},
 {'generated_text': 'Hey readers, today is Christmas. This is not Christmas, because Christmas is so long and I hope'},
 {'generated_text': "Hey readers, today is CTA Day!\n\nWe're proud to be hosting a special event"}] 

从输出中可以看出,基于我们的文本片段,模型生成了三个合理的句子。如果您想探索更多示例,请随意更改随机种子和最大序列长度。

此外,正如在图 16.10中所示,我们可以使用 transformer 模型为训练其他模型生成特征。以下代码说明了如何使用 GPT-2 根据输入文本生成特征:

>>> from transformers import GPT2Tokenizer
>>> tokenizer = GPT2Tokenizer.from_pretrained('gpt2')
>>> text = "Let us encode this sentence"
>>> encoded_input = tokenizer(text, return_tensors='pt')
>>> encoded_input
{'input_ids': tensor([[ 5756,   514, 37773,   428,  6827]]), 'attention_mask': tensor([[1, 1, 1, 1, 1]])} 

这段代码将输入句子文本编码成了 GPT-2 模型的标记化格式。正如我们所见,它将字符串映射到整数表示,并将注意力掩码设置为全 1,这意味着在将编码输入传递给模型时将处理所有单词,如下所示:

>>> from transformers import GPT2Model
>>> model = GPT2Model.from_pretrained('gpt2')
>>> output = model(**encoded_input) 

变量output存储了最后的隐藏状态,即我们基于 GPT-2 的输入句子特征编码:

>>> output['last_hidden_state'].shape
torch.Size([1, 5, 768]) 

为了抑制冗长的输出,我们仅展示了张量的形状。其第一维是批处理大小(我们只有一个输入文本),其后是句子长度和特征编码的大小。在这里,每个五个单词被编码为一个 768 维向量。

现在,我们可以将这种特征编码应用于给定的数据集,并基于基于 GPT-2 的特征表示训练一个下游分类器,而不是使用如第八章“应用机器学习进行情感分析”中讨论的词袋模型。

此外,另一种使用大型预训练语言模型的方法是微调,正如我们之前讨论过的。在本章稍后我们将看到一个微调的例子。

如果您对使用 GPT-2 的详细信息感兴趣,我们建议查阅以下文档页面:

使用 BERT 进行双向预训练

BERT,全名为双向编码器表示转换器,由 Google 研究团队于 2018 年创建(BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding by J. Devlin, M. Chang, K. Lee, and K. Toutanova, arxiv.org/abs/1810.04805)。值得参考的是,虽然我们不能直接将 GPT 和 BERT 进行比较,因为它们是不同的架构,但 BERT 有 3.45 亿个参数(这使它比 GPT-1 略大一些,其大小仅为 GPT-2 的五分之一)。

正如其名,BERT 具有基于 transformer 编码器的模型结构,利用了双向训练过程。(更准确地说,我们可以认为 BERT 使用“非定向”训练,因为它一次性读取所有输入元素。)在此设置下,某个单词的编码取决于其前后的单词。回想一下,在 GPT 中,输入元素按自然的从左到右顺序读取,这有助于形成强大的生成语言模型。双向训练禁用了 BERT 逐词生成句子的能力,但提供了更高质量的输入编码,用于其他任务,如分类,因为该模型现在可以双向处理信息。

回想一下,在 Transformer 的编码器中,令牌编码是位置编码和令牌嵌入的总和。在 BERT 编码器中,还有一个额外的段嵌入,指示此令牌属于哪个段。这意味着每个令牌表示包含三个部分,正如图 16.13所示:

游戏截图 描述已由中度置信度自动生成

图 16.13:为 BERT 编码器准备输入

为什么我们需要 BERT 中的额外段信息?这段信息的需求源于 BERT 的特殊预训练任务,称为下一句预测。在此预训练任务中,每个训练示例包括两个句子,因此需要特殊的段符号来表示它是属于第一个还是第二个句子。

现在,让我们更详细地看一下 BERT 的预训练任务。与所有其他基于 Transformer 的语言模型类似,BERT 有两个训练阶段:预训练和微调。预训练包括两个无监督任务:掩码语言建模下一句预测

掩码语言模型MLM)中,标记被随机替换为所谓的掩码标记 [MASK],模型需要预测这些隐藏的单词。与 GPT 中的下一个单词预测相比,BERT 中的 MLM 更类似于“填空”,因为模型可以关注句子中的所有标记(除了掩码标记)。然而,简单地屏蔽单词可能导致预训练和微调之间的不一致,因为[MASK]标记不会出现在常规文本中。为了减轻这一问题,对于选定的要屏蔽的单词,还有进一步的修改。例如,在 BERT 中,15%的单词被标记为屏蔽。这 15%的随机选择单词接下来会进一步处理为:

  1. 10%的时间保持单词不变

  2. 10%的时间将原始词令牌替换为随机单词

  3. 80%的时间将原始词令牌替换为掩码令牌 [MASK]

除了在引入[MASK]令牌到训练过程中避免上述预训练和微调之间的不一致性之外,这些修改还有其他好处。首先,未更改的单词包括保持原始令牌信息的可能性;否则,模型只能从上下文中学习,而不是从掩码的单词中学习。其次,10%的随机单词防止模型变得懒惰,例如,仅仅返回所给的内容而没有学到任何东西。掩码、随机化和保持单词不变的概率由消融研究选择(参见 GPT-2 论文);例如,作者测试了不同的设置,并发现这种组合效果最好。

图 16.14 描述了一个示例,在这个示例中,单词fox被屏蔽,并且有一定概率保持不变,或者被替换为[MASK]coffee。然后,模型需要预测屏蔽(突出显示)的单词是什么,如图 16.14所示:

图形用户界面,文本,应用 自动生成描述

图 16.14:MLM 示例

下一个句子预测是对 BERT 的双向编码进行自然修改,考虑到下一个词预测任务的关系。实际上,许多重要的自然语言处理任务,如问答,依赖于文档中两个句子之间的关系。由于输入长度限制,通常单句预测训练很难捕捉这种关系。

在下一个句子预测任务中,模型会得到两个句子 A 和 B,格式如下:

[CLS] A [SEP] B [SEP]

[CLS] 是分类标记,用作解码器输出中预测标签的占位符,同时也是表示句子开头的标记。另一方面,[SEP] 标记附加在每个句子的末尾。然后,模型需要分类是否 B 是 A 的下一个句子(“IsNext”)。为了向模型提供平衡的数据集,50%的样本标记为“IsNext”,而剩余的样本标记为“NotNext”。

BERT 同时在这两个任务上进行预训练,即屏蔽句子和下一个句子预测。在这里,BERT 的训练目标是最小化这两个任务的组合损失函数。

从预训练模型开始,需要针对微调阶段中不同的下游任务进行特定的修改。每个输入示例都需要匹配特定的格式;例如,如果包含多个句子,则应以[CLS]标记开头并使用[SEP]标记分隔。

大致而言,BERT 可以在四类任务上进行微调:(a) 句对分类;(b) 单句分类;(c) 问答;(d) 单句标注。

其中,(a) 和 (b) 是序列级分类任务,只需在输出表示的[CLS]标记上添加额外的 softmax 层。而 (c) 和 (d) 则是标记级分类任务。这意味着模型将所有相关标记的输出表示传递给 softmax 层,以预测每个单独标记的类别标签。

问答

任务(c),即问答,似乎与其他流行的分类任务(如情感分类或语音标记)相比少有讨论。在问答中,每个输入示例可以分为两部分,问题和帮助回答问题的段落。模型需要指出段落中的起始和结束标记,形成一个合适的答案。这意味着模型需要为段落中的每个单词生成一个标记,指示该单词是起始标记、结束标记还是其他。值得一提的是,输出可能包含在起始标记之前出现的结束标记,这在生成答案时可能会导致冲突。这种输出将被识别为对问题的“无答案”。

图 16.15所示,模型的微调设置具有非常简单的结构:输入编码器连接到预训练的 BERT,添加一个 softmax 层用于分类。一旦模型结构设置完成,所有参数将随学习过程进行调整。

Diagram  Description automatically generated

图 16.15:使用 BERT 微调不同的语言任务

双赢之选:BART

双向自回归变换器,简称BART,由 Facebook AI Research 的研究人员在 2019 年开发:BART:用于自然语言生成、翻译和理解的去噪序列到序列预训练Lewis等人,arxiv.org/abs/1910.13461。回顾前文,我们提到 GPT 利用变换器的解码器结构,而 BERT 利用变换器的编码器结构。这两个模型因此能够很好地执行不同的任务:GPT 的特长是生成文本,而 BERT 在分类任务上表现更好。BART 可以看作是 GPT 和 BERT 的泛化。正如本节标题所示,BART 能够同时完成生成和分类文本的任务。它能够处理这两个任务的原因在于该模型配备了双向编码器和从左到右的自回归解码器。

您可能想知道这与原始变压器的区别在哪里。模型大小有一些变化,以及一些较小的更改,如激活函数的选择。然而,其中一个更有趣的变化是,BART 使用不同的模型输入。原始变压器模型是为语言翻译设计的,因此有两个输入:要翻译的文本(编码器的源序列)和翻译(解码器的目标序列)。此外,解码器还接收编码的源序列,如前文所述在图 16.6中。然而,在 BART 中,输入格式被泛化,只使用源序列作为输入。BART 可以执行更广泛的任务,包括语言翻译,在这种情况下仍然需要目标序列来计算损失并微调模型,但不需要直接将其馈送到解码器中。

现在让我们更详细地看一下 BART 的模型结构。如前所述,BART 由一个双向编码器和一个自回归解码器组成。在接收到一个纯文本的训练示例后,输入将首先被“污染”,然后由编码器编码。这些输入编码将随后传递给解码器,连同生成的标记一起。编码器输出与原始文本之间的交叉熵损失将被计算,然后通过学习过程进行优化。想象一个转换器,我们在解码器中有两种不同语言的文本作为输入:要翻译的初始文本(源文本)和目标语言中生成的文本。BART 可以被理解为用损坏的文本替换前者,用输入文本本身替换后者。

自动生成的图表描述

图 16.16:BART 的模型结构

更详细地解释一下损坏步骤,回想一下,BERT 和 GPT 是通过重构掩码单词进行预训练的:BERT 是“填补空白”,而 GPT 是“预测下一个单词”。这些预训练任务也可以被视为重构损坏的句子,因为掩盖单词是损坏句子的一种方式。BART 提供以下可以应用于清洁文本的损坏方法:

  • 标记掩盖

  • 标记删除

  • 文本填充

  • 句子排列

  • 文档旋转

以上列出的一种或多种技术可以应用于同一句子;在最坏的情况下,所有信息都被污染和破坏,文本变得毫无用处。因此,编码器的效用有限,只有解码器模块正常工作时,模型才会更像是单向语言。

BART 可以在广泛的下游任务中进行微调,包括(a)序列分类,(b)标记分类,(c)序列生成和(d)机器翻译。与 BERT 一样,需要对输入进行微小的更改以执行不同的任务。

在序列分类任务中,需要附加一个额外的令牌到输入中,作为生成的标签令牌,这类似于 BERT 中的 [CLS] 令牌。此外,不会破坏输入,而是将未损坏的输入同时馈送到编码器和解码器,以便模型能够充分利用输入。

对于令牌分类,额外的令牌变得不必要,模型可以直接使用每个令牌的生成表示进行分类。

BART 中的序列生成与 GPT 有所不同,这是因为存在编码器。通过 BART 进行序列生成任务不是从头开始生成文本,而更类似于摘要,模型被给定一组上下文并要求生成摘要或对特定问题的抽象回答。为此,整个输入序列被馈送到编码器,而解码器则自回归地生成输出。

最后,考虑到 BART 与原始变压器之间的相似性,自然而然地可以进行机器翻译。但是,与训练原始变压器的完全相同过程不同,研究人员考虑了将整个 BART 模型作为预训练解码器并将新的一组随机初始化参数作为新的附加编码器添加来完成翻译模型。然后,微调阶段可以分为两步:

  1. 首先,冻结除编码器外的所有参数。

  2. 然后,更新模型中的所有参数

BART 在几个基准数据集上进行了评估,用于各种任务,并与其他著名的语言模型(如 BERT)相比,取得了非常有竞争力的结果。特别是在生成任务中,包括抽象问答、对话回复和摘要任务中,BART 实现了最先进的结果。

在 PyTorch 中微调 BERT 模型

现在,我们已经介绍并讨论了所有必要的概念以及原始变压器和流行的基于变压器的模型背后的理论,现在是时候看看更实际的部分了!在本节中,您将学习如何在 PyTorch 中对 BERT 模型进行情感分类的微调。

请注意,虽然有许多其他选择的基于变压器的模型,但 BERT 在模型流行度和具有可管理模型大小之间提供了良好的平衡,因此可以在单个 GPU 上进行微调。还请注意,从头开始预训练 BERT 是痛苦且相当不必要的,考虑到 Hugging Face 提供的 transformers Python 包中包含了一堆准备好进行微调的预训练模型。

在接下来的几节中,您将看到如何准备和标记化 IMDb 电影评论数据集,并对精炼的 BERT 模型进行微调以执行情感分类。尽管有许多其他有趣的语言模型应用,我们故意选择情感分类作为一个简单但经典的例子。此外,通过使用熟悉的 IMDb 电影评论数据集,我们可以通过将其与逻辑回归模型在第八章 应用机器学习进行情感分析中和 RNN 在第十五章 使用递归神经网络建模顺序数据中进行比较,来获取 BERT 模型的预测性能。

加载 IMDb 电影评论数据集

在这个小节中,我们将首先加载所需的包和数据集,并将其分为训练集、验证集和测试集。

在本教程中与 BERT 相关的部分,我们将主要使用 Hugging Face 创建的开源transformers库(huggingface.co/transformers/),该库已在前一节中安装好,使用 GPT-2 生成新文本

本章中我们使用的DistilBERT模型是一个轻量级的 transformer 模型,是通过蒸馏预训练的 BERT 基础模型而来。原始的不区分大小写的 BERT 基础模型包含超过 1.1 亿个参数,而 DistilBERT 的参数少了 40%。此外,DistilBERT 运行速度快了 60%,同时还保留了 GLUE 语言理解基准测试中 BERT 性能的 95%。

以下代码导入了本章中将要使用的所有包,以准备数据并微调 DistilBERT 模型:

>>> import gzip
>>> import shutil
>>> import time
>>> import pandas as pd
>>> import requests
>>> import torch
>>> import torch.nn.functional as F
>>> import torchtext
>>> import transformers
>>> from transformers import DistilBertTokenizerFast
>>> from transformers import DistilBertForSequenceClassification 

接下来,我们指定一些通用设置,包括网络训练的时代数、设备规范和随机种子。为了复现结果,请确保设置一个特定的随机种子,例如123

>>> torch.backends.cudnn.deterministic = True
>>> RANDOM_SEED = 123
>>> torch.manual_seed(RANDOM_SEED)
>>> DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
>>> NUM_EPOCHS = 3 

我们将处理 IMDb 电影评论数据集,您已经在第八章第十五章中见过它。以下代码获取了压缩数据集并解压缩它:

>>> url = ("https://github.com/rasbt/"
...        "machine-learning-book/raw/"
...        "main/ch08/movie_data.csv.gz")
>>> filename = url.split("/")[-1]
>>> with open(filename, "wb") as f:
...     r = requests.get(url)
...     f.write(r.content)
>>> with gzip.open('movie_data.csv.gz', 'rb') as f_in:
...     with open('movie_data.csv', 'wb') as f_out:
...         shutil.copyfileobj(f_in, f_out) 

如果您仍然在硬盘上拥有第八章中的movie_data.csv文件,可以跳过此下载和解压缩过程。

然后,我们将数据加载到一个 pandas 的DataFrame中,并确保一切正常:

>>> df = pd.read_csv('movie_data.csv')
>>> df.head(3) 

表 自动描述生成

图 16.17:IMDb 电影评论数据集的前三行

下一步是将数据集分割为单独的训练、验证和测试集。在这里,我们使用 70%的评论作为训练集,10%作为验证集,剩余的 20%作为测试集:

>>> train_texts = df.iloc[:35000]['review'].values
>>> train_labels = df.iloc[:35000]['sentiment'].values
>>> valid_texts = df.iloc[35000:40000]['review'].values
>>> valid_labels = df.iloc[35000:40000]['sentiment'].values
>>> test_texts = df.iloc[40000:]['review'].values
>>> test_labels = df.iloc[40000:]['sentiment'].values 

对数据集进行标记化处理

到目前为止,我们已经获取了训练集、验证集和测试集的文本和标签。现在,我们将使用继承自预训练模型类的分词器实现,将文本标记化为单独的单词标记:

>>> tokenizer = DistilBertTokenizerFast.from_pretrained(
...     'distilbert-base-uncased'
... )
>>> train_encodings = tokenizer(list(train_texts), truncation=True, padding=True)
>>> valid_encodings = tokenizer(list(valid_texts), truncation=True, padding=True)
>>> test_encodings = tokenizer(list(test_texts), truncation=True, padding=True) 

选择不同的分词器

如果您有兴趣应用不同类型的分词器,请随时探索tokenizers包(huggingface.co/docs/tokenizers/python/latest/),该包也由 Hugging Face 构建和维护。然而,继承的分词器保持了预训练模型与数据集之间的一致性,这样可以节省我们找到与模型对应的特定分词器的额外工作。换句话说,如果您想要微调一个预训练模型,使用继承的分词器是推荐的方法。

最后,让我们将所有内容打包到一个名为IMDbDataset的类中,并创建相应的数据加载器。这样一个自定义的数据集类允许我们为我们自定义的电影评论数据集中的所有相关特征和函数进行定制:

>>> class IMDbDataset(torch.utils.data.Dataset):
...     def __init__(self, encodings, labels):
...         self.encodings = encodings
...         self.labels = labels
>>>     def __getitem__(self, idx):
...         item = {key: torch.tensor(val[idx]) 
...                 for key, val in self.encodings.items()}
...         item['labels'] = torch.tensor(self.labels[idx])
...         return item
>>>     def __len__(self):
...         return len(self.labels)
>>> train_dataset = IMDbDataset(train_encodings, train_labels)
>>> valid_dataset = IMDbDataset(valid_encodings, valid_labels)
>>> test_dataset = IMDbDataset(test_encodings, test_labels)
>>> train_loader = torch.utils.data.DataLoader(
...     train_dataset, batch_size=16, shuffle=True) 
>>> valid_loader = torch.utils.data.DataLoader(
...     valid_dataset, batch_size=16, shuffle=False) 
>>> test_loader = torch.utils.data.DataLoader(
...     test_dataset, batch_size=16, shuffle=False) 

尽管整体数据加载器设置应该与前几章类似,但一个值得注意的细节是 __getitem__ 方法中的 item 变量。我们之前生成的编码存储了关于标记化文本的大量信息。通过我们用于将字典分配给 item 变量的字典推导式,我们只提取了最相关的信息。例如,生成的字典条目包括 input_ids(词汇表中对应于标记的唯一整数)、labels(类标签)和 attention_mask。这里,attention_mask 是一个具有二进制值(0 和 1)的张量,指示模型应该关注哪些标记。特别地,0 对应于用于填充序列以达到相等长度的标记,并且被模型忽略;1 对应于实际文本标记。

加载和微调预训练的 BERT 模型

在处理数据准备后,在本小节中,您将看到如何加载预训练的 DistilBERT 模型并使用我们刚刚创建的数据集进行微调。加载预训练模型的代码如下所示:

>>> model = DistilBertForSequenceClassification.from_pretrained(
...     'distilbert-base-uncased')
>>> model.to(DEVICE)
>>> model.train()
>>> optim = torch.optim.Adam(model.parameters(), lr=5e-5) 

DistilBertForSequenceClassification 指定了我们希望在其上微调模型的下游任务,即在本例中进行的序列分类。正如前面提到的,'distilbert-base-uncased' 是一个轻量级的 BERT 小写基础模型,大小适中且性能良好。注意,“uncased”表示该模型不区分大小写字母。

使用其他预训练的 transformers

transformers 包还提供了许多其他预训练模型和各种下游任务供微调使用。请访问 huggingface.co/transformers/ 查看详情。

现在是训练模型的时候了。我们可以将其分为两部分。首先,我们需要定义一个准确率函数来评估模型性能。请注意,这个准确率函数计算传统的分类准确率。为什么这么啰嗦?在这里,我们按批次加载数据集以解决在使用大型深度学习模型时可能遇到的 RAM 或 GPU 内存(VRAM)限制:

>>> def compute_accuracy(model, data_loader, device):
...         with torch.no_grad():
...             correct_pred, num_examples = 0, 0
...             for batch_idx, batch in enumerate(data_loader):
...                 ### Prepare data
...                 input_ids = batch['input_ids'].to(device)
...                 attention_mask = \
...                     batch['attention_mask'].to(device)
...                 labels = batch['labels'].to(device)

...                 outputs = model(input_ids,
...                    attention_mask=attention_mask)
...                 logits = outputs['logits']
...                 predicted_labels = torch.argmax(logits, 1)
...                 num_examples += labels.size(0)
...                 correct_pred += \
...                     (predicted_labels == labels).sum()
...         return correct_pred.float()/num_examples * 100 

compute_accuracy函数中,我们加载一个给定的批次,然后从输出中获取预测标签。在这个过程中,我们通过num_examples变量跟踪总样本数。类似地,我们通过correct_pred变量跟踪正确预测的数量。最后,在完整数据集上迭代完成后,我们计算准确率作为正确预测标签的比例。

通过compute_accuracy函数,您已经可以大致了解如何使用 transformer 模型获取类标签。也就是说,我们将input_idsattention_mask信息(在这里表示一个标记是实际文本标记还是用于填充序列以达到相等长度的标记)馈送到模型中。然后,model调用返回一个特定于 transformer 库的SequenceClassifierOutput对象的输出。然后,我们通过argmax函数将这个对象中的 logits 转换为类标签,就像我们在前几章中所做的那样。

最后,让我们进入主要部分:训练(或者说,微调)循环。正如您将注意到的那样,从transformers库微调模型与从头开始在纯 PyTorch 中训练模型非常相似:

>>> start_time = time.time()
>>> for epoch in range(NUM_EPOCHS):

...     model.train()

...     for batch_idx, batch in enumerate(train_loader):

...         ### Prepare data
...         input_ids = batch['input_ids'].to(DEVICE)
...         attention_mask = batch['attention_mask'].to(DEVICE)
...         labels = batch['labels'].to(DEVICE)
...         ### Forward pass
...         outputs = model(input_ids, 
...                         attention_mask=attention_mask,
...                         labels=labels)
...         loss, logits = outputs['loss'], outputs['logits']

...         ### Backward pass
...         optim.zero_grad()
...         loss.backward()
...         optim.step()

...         ### Logging
...         if not batch_idx % 250:
...             print(f'Epoch: {epoch+1:04d}/{NUM_EPOCHS:04d}' 
...                     f' | Batch' 
...                     f'{batch_idx:04d}/'
...                     f'{len(train_loader):04d} | '
...                     f'Loss: {loss:.4f}')

...     model.eval()
...     with torch.set_grad_enabled(False):
...         print(f'Training accuracy: '
...              f'{compute_accuracy(model, train_loader, DEVICE):.2f}%'
...              f'\nValid accuracy: '
...              f'{compute_accuracy(model, valid_loader, DEVICE):.2f}%')

...     print(f'Time elapsed: {(time.time() - start_time)/60:.2f} min')

... print(f'Total Training Time: {(time.time() - start_time)/60:.2f} min')
... print(f'Test accuracy: {compute_accuracy(model, test_loader, DEVICE):.2f}%') 

上述代码生成的输出如下(请注意,代码并非完全确定性,因此您得到的结果可能略有不同):

Epoch: 0001/0003 | Batch 0000/2188 | Loss: 0.6771
Epoch: 0001/0003 | Batch 0250/2188 | Loss: 0.3006
Epoch: 0001/0003 | Batch 0500/2188 | Loss: 0.3678
Epoch: 0001/0003 | Batch 0750/2188 | Loss: 0.1487
Epoch: 0001/0003 | Batch 1000/2188 | Loss: 0.6674
Epoch: 0001/0003 | Batch 1250/2188 | Loss: 0.3264
Epoch: 0001/0003 | Batch 1500/2188 | Loss: 0.4358
Epoch: 0001/0003 | Batch 1750/2188 | Loss: 0.2579
Epoch: 0001/0003 | Batch 2000/2188 | Loss: 0.2474
Training accuracy: 96.32%
Valid accuracy: 92.34%
Time elapsed: 20.67 min
Epoch: 0002/0003 | Batch 0000/2188 | Loss: 0.0850
Epoch: 0002/0003 | Batch 0250/2188 | Loss: 0.3433
Epoch: 0002/0003 | Batch 0500/2188 | Loss: 0.0793
Epoch: 0002/0003 | Batch 0750/2188 | Loss: 0.0061
Epoch: 0002/0003 | Batch 1000/2188 | Loss: 0.1536
Epoch: 0002/0003 | Batch 1250/2188 | Loss: 0.0816
Epoch: 0002/0003 | Batch 1500/2188 | Loss: 0.0786
Epoch: 0002/0003 | Batch 1750/2188 | Loss: 0.1395
Epoch: 0002/0003 | Batch 2000/2188 | Loss: 0.0344
Training accuracy: 98.35%
Valid accuracy: 92.46%
Time elapsed: 41.41 min
Epoch: 0003/0003 | Batch 0000/2188 | Loss: 0.0403
Epoch: 0003/0003 | Batch 0250/2188 | Loss: 0.0036
Epoch: 0003/0003 | Batch 0500/2188 | Loss: 0.0156
Epoch: 0003/0003 | Batch 0750/2188 | Loss: 0.0114
Epoch: 0003/0003 | Batch 1000/2188 | Loss: 0.1227
Epoch: 0003/0003 | Batch 1250/2188 | Loss: 0.0125
Epoch: 0003/0003 | Batch 1500/2188 | Loss: 0.0074
Epoch: 0003/0003 | Batch 1750/2188 | Loss: 0.0202
Epoch: 0003/0003 | Batch 2000/2188 | Loss: 0.0746
Training accuracy: 99.08%
Valid accuracy: 91.84%
Time elapsed: 62.15 min
Total Training Time: 62.15 min
Test accuracy: 92.50% 

在此代码中,我们遍历多个 epochs。在每个 epoch 中,我们执行以下步骤:

  1. 将输入加载到我们正在工作的设备上(GPU 或 CPU)。

  2. 计算模型输出和损失。

  3. 通过反向传播损失来调整权重参数。

  4. 对训练集和验证集上的模型性能进行评估。

请注意,不同设备上的训练时间可能会有所不同。在三个 epochs 后,测试数据集的准确率达到约 93%,与 RNN 在第十五章中达到的 85%的测试准确率相比,这是一个显著的改进。

使用 Trainer API 更方便地微调 transformer 模型。

在前面的小节中,我们手动在 PyTorch 中实现了训练循环,以说明微调 transformer 模型与从头开始训练 RNN 或 CNN 模型实际上并没有太大不同。但是,请注意,transformers库包含一些额外的便利功能,例如我们将在本小节中介绍的 Trainer API。

Hugging Face 提供的 Trainer API 针对具有广泛的训练选项和各种内置功能的 Transformer 模型进行了优化。使用 Trainer API 时,我们可以跳过自己编写训练循环的工作,训练或微调 Transformer 模型就像调用函数(或方法)一样简单。让我们看看实际操作中是如何工作的。

在加载预训练模型之后:

>>> model = DistilBertForSequenceClassification.from_pretrained(
...     'distilbert-base-uncased')
>>> model.to(DEVICE)
>>> model.train(); 

可以用以下代码替换上一节的训练循环:

>>> optim = torch.optim.Adam(model.parameters(), lr=5e-5)
>>> from transformers import Trainer, TrainingArguments
>>> training_args = TrainingArguments(
...     output_dir='./results', 
...     num_train_epochs=3,     
...     per_device_train_batch_size=16, 
...     per_device_eval_batch_size=16,   
...     logging_dir='./logs',
...     logging_steps=10,
... )
>>> trainer = Trainer(
...    model=model,
...    args=training_args,
...    train_dataset=train_dataset,
...    optimizers=(optim, None) # optim and learning rate scheduler
... ) 

在前述代码片段中,我们首先定义了相对简单的训练参数设置,这些设置涉及输入和输出位置、epoch 数量和批处理大小。我们尽量保持设置尽可能简单;然而,还有许多其他可用的设置,建议查阅TrainingArguments文档页面以获取更多详细信息:huggingface.co/transformers/main_classes/trainer.html#trainingarguments

然后,我们将这些TrainingArguments设置传递给Trainer类,以实例化一个新的trainer对象。在用设置初始化了trainer之后,需要调用trainer.train()方法来训练模型(稍后我们将进一步使用此方法)。使用 Trainer API 就像前面的代码示例中展示的那样简单,不需要进一步的样板代码。

但是,您可能已经注意到这些代码片段中没有涉及测试数据集,并且在本小节中我们还未指定任何评估指标。这是因为 Trainer API 仅显示训练损失,并且默认情况下不提供模型在训练过程中的评估。有两种方法可以显示最终模型的性能,我们将在接下来进行说明。

评估最终模型的第一种方法是为另一个Trainer实例定义一个评估函数作为compute_metrics参数。compute_metrics函数操作模型测试预测的 logits(这是模型的默认输出)和测试标签。为了实例化此函数,建议通过pip install datasets安装 Hugging Face 的datasets库,并按以下方式使用:

>>> from datasets import load_metric
>>> import numpy as np
>>> metric = load_metric("accuracy")
>>> def compute_metrics(eval_pred):
...       logits, labels = eval_pred
...       # note: logits are a numpy array, not a pytorch tensor
...       predictions = np.argmax(logits, axis=-1)
...       return metric.compute(
...           predictions=predictions, references=labels) 

更新后的Trainer实例化(现在包括compute_metrics)如下所示:

>>> trainer=Trainer(
...     model=model,        
...     args=training_args,
...     train_dataset=train_dataset,
...     eval_dataset=test_dataset,
...     compute_metrics=compute_metrics,
...     optimizers=(optim, None) # optim and learning rate scheduler
... ) 

现在,让我们再次训练模型(请注意,代码不是完全确定性的,这就是为什么可能会得到稍微不同结果的原因):

>>> start_time = time.time()
>>> trainer.train()
***** Running training *****
  Num examples = 35000
  Num Epochs = 3
  Instantaneous batch size per device = 16
  Total train batch size (w. parallel, distributed & accumulation) = 16
  Gradient Accumulation steps = 1
  Total optimization steps = 6564
Step  Training Loss
10    0.705800
20    0.684100
30    0.681500
40    0.591600
50    0.328600
60    0.478300
...
>>> print(f'Total Training Time: ' 
...       f'{(time.time() - start_time)/60:.2f} min')
Total Training Time: 45.36 min 

训练完成后(具体时间取决于您的 GPU,可能需要长达一小时),我们可以调用trainer.evaluate()来获取模型在测试集上的性能:

>>> print(trainer.evaluate())
***** Running Evaluation *****
Num examples = 10000
Batch size = 16
100%|█████████████████████████████████████████| 625/625 [10:59<00:00,  1.06s/it]
{'eval_loss': 0.30534815788269043,
 'eval_accuracy': 0.9327,
 'eval_runtime': 87.1161,
 'eval_samples_per_second': 114.789,
 'eval_steps_per_second': 7.174,
 'epoch': 3.0} 

正如我们所见,评估准确率约为 94%,与我们之前使用的 PyTorch 训练循环相似。(请注意,我们已跳过训练步骤,因为在之前的trainer.train()调用后,model已经经过微调。)我们的手动训练方法与使用Trainer类有一些小差异,因为Trainer类使用了一些不同和一些额外的设置。

我们可以采用第二种方法来计算最终的测试集准确率,即重新使用我们在前一节中定义的compute_accuracy函数。我们可以通过运行以下代码直接评估经过微调的模型在测试数据集上的性能:

>>> model.eval()
>>> model.to(DEVICE)
>>> print(f'Test accuracy: {compute_accuracy(model, test_loader, DEVICE):.2f}%')
Test accuracy: 93.27% 

实际上,如果您想在训练过程中定期检查模型的性能,可以通过以下方式定义训练参数,要求训练器在每个 epoch 后打印模型评估:

>>> from transformers import TrainingArguments
>>> training_args = TrainingArguments("test_trainer", 
...     evaluation_strategy="epoch", ...) 

然而,如果您计划更改或优化超参数,并重复几次微调过程,我们建议使用验证集来实现此目的,以保持测试集的独立性。我们可以通过使用valid_dataset实例化Trainer来实现这一点:

>>> trainer=Trainer(
...     model=model,        
...     args=training_args,
...     train_dataset=train_dataset,
...     eval_dataset=valid_dataset,
...     compute_metrics=compute_metrics,
... ) 

在本节中,我们看到了如何为分类任务微调 BERT 模型。这与使用其他深度学习架构(如通常从头开始训练的 RNN)有所不同。然而,除非我们正在进行研究并试图开发新的 transformer 架构(这是一项非常昂贵的工作),否则预训练 transformer 模型并不是必要的。由于 transformer 模型是在通用的未标记数据集资源上训练的,自己进行预训练可能并不是一个很好的时间和资源利用方式;微调才是正确的方法。

总结

在本章中,我们介绍了自然语言处理的一种全新模型架构,即 transformer 架构。transformer 架构建立在一种称为自注意力的概念之上,并逐步介绍了这一概念。首先,我们研究了带有注意力机制的 RNN,以提高其对长句子的翻译能力。然后,我们温柔地引入了自注意力的概念,并解释了它在 transformer 中如何在多头注意力模块中使用。

自从 2017 年首次发布 transformer 架构以来,已经涌现出许多不同的衍生变体并不断演化。在本章中,我们重点介绍了一些最受欢迎的变体:GPT 模型系列、BERT 和 BART。GPT 是一个单向模型,特别擅长生成新的文本。BERT 采用双向方法,更适合其他类型的任务,例如分类。最后,BART 结合了 BERT 的双向编码器和 GPT 的单向解码器。有兴趣的读者可以通过以下两篇调研文章了解更多基于 transformer 的架构:

  1. 自然语言处理的预训练模型:一项调查,由Qiu和同事们,2020 年。可在 arxiv.org/abs/2003.08271 获取。

  2. AMMUS:自然语言处理中基于 Transformer 预训练模型的调查,由Kayan和同事们,2021 年。可在 arxiv.org/abs/2108.05542 获取。

Transformer 模型通常比 RNNs 更需要数据,并且需要大量数据进行预训练。预训练利用大量未标记数据构建一个通用语言模型,然后通过在较小的有标签的数据集上微调它,使其专门用于特定任务。

为了看到这在实践中是如何运作的,我们从 Hugging Face 的 transformers 库中下载了一个预训练的 BERT 模型,并对其在 IMDb 电影评论数据集上进行了情感分类微调。

在接下来的章节中,我们将讨论生成对抗网络。正如其名称所示,生成对抗网络是可以用于生成新数据的模型,类似于我们在本章讨论的 GPT 模型。然而,我们现在将自然语言建模的主题抛在身后,将在计算机视觉的背景下研究生成对抗网络并生成新图像——这正是这些网络最初设计用于的任务。

加入我们书籍的 Discord 空间

加入这本书的 Discord 工作区,与作者进行每月一次的问答会话:

packt.link/MLwPyTorch

第十七章:生成对抗网络用于合成新数据

在上一章中,我们专注于用于建模序列的循环神经网络。在本章中,我们将探讨生成对抗网络GANs)及其在合成新数据样本中的应用。GAN 被认为是深度学习中最重要的突破之一,允许计算机生成新数据(如新图像)。

本章将涵盖以下主题:

  • 引入生成模型用于合成新数据

  • 自编码器、变分自编码器及其与生成对抗网络(GANs)的关系

  • 理解 GAN 的构建模块

  • 实现一个简单的 GAN 模型来生成手写数字

  • 理解转置卷积和批归一化

  • 改进 GAN:深度卷积 GAN 和使用 Wasserstein 距离的 GAN

介绍生成对抗网络

让我们首先看看 GAN 模型的基础。GAN 的总体目标是合成具有与其训练数据集相同分布的新数据。因此,GAN 在其原始形式中被认为是机器学习任务中无监督学习类别的一部分,因为不需要标记数据。然而,值得注意的是,对原始 GAN 的扩展可以同时属于半监督和监督领域。

生成对抗网络(GAN)的一般概念最早由伊恩·古德费洛及其同事于 2014 年提出,作为利用深度神经网络NNs)合成新图像的方法(生成对抗网络,见I. Goodfellow, J. Pouget-Abadie, M. Mirza, B. Xu, D. Warde-Farley, S. Ozair, A. Courville, and Y. Bengio,《神经信息处理系统进展》,第 2672-2680 页,2014 年)。尽管该论文中最初的 GAN 架构基于全连接层,类似于多层感知器结构,并训练生成低分辨率的类似 MNIST 手写数字,但它更像是一个概念验证,旨在展示这种新方法的可行性。

然而,自其引入以来,原始作者及许多其他研究人员已提出了许多改进以及不同领域工程和科学中的各种应用。例如,在计算机视觉中,GAN 被用于图像到图像的转换(学习如何将输入图像映射到输出图像)、图像超分辨率(从低分辨率版本生成高分辨率图像)、图像修补(学习如何重构图像中丢失的部分)等多种应用。例如,最近 GAN 研究的进展导致了能够生成新的高分辨率人脸图像的模型。此类高分辨率图像的例子可以在www.thispersondoesnotexist.com/找到,展示了由 GAN 生成的合成人脸图像。

从自编码器开始

在讨论 GAN 的工作原理之前,我们首先从自编码器开始,它可以压缩和解压训练数据。虽然标准自编码器不能生成新数据,但理解它们的功能将有助于你在下一节中理解 GAN。

自编码器由两个串联的网络组成:一个编码器网络和一个解码器网络。编码器网络接收与示例x相关的d维输入特征向量(即 ),并将其编码成p维向量z(即 )。换句话说,编码器的作用是学习如何建模函数z = f(x)。编码后的向量z也称为潜在向量或潜在特征表示。通常,潜在向量的维度小于输入示例的维度;换句话说,p < d。因此,我们可以说编码器充当数据压缩函数。然后,解码器从低维潜在向量z中解压出 ,我们可以将解码器视为一个函数,图 17.1展示了一个简单的自编码器架构,其中编码器和解码器部分只包含一个完全连接的层:

图 17.1:自编码器的架构

自编码器与降维的联系

第五章通过降维压缩数据,你学习了一些降维技术,比如主成分分析PCA)和线性判别分析LDA)。自编码器也可以作为一种降维技术。事实上,当两个子网络(编码器和解码器)中没有非线性时,自编码器方法与 PCA 几乎完全相同

在这种情况下,如果我们假设单层编码器的权重(无隐藏层和非线性激活函数)用矩阵U表示,则编码器模型为z = U^Tx。类似地,单层线性解码器模型为 。将这两个组件放在一起,我们有 。这正是 PCA 所做的事情,唯一的区别在于 PCA 有一个额外的正交规范约束:UU^T = I[n][×][n]。

虽然图 17.1描绘了一个没有隐藏层的自编码器,在编码器和解码器中,我们当然可以添加多个带有非线性的隐藏层(如多层神经网络),以构建一个能够学习更有效数据压缩和重构函数的深度自编码器。此外,注意到本节提到的自编码器使用全连接层。然而,在处理图像时,我们可以用卷积层替换全连接层,正如你在第十四章使用深度卷积神经网络分类图像中学到的那样。

基于潜在空间大小的其他类型的自编码器

正如之前提到的,自编码器的潜在空间的维度通常比输入的维度低(p < d),这使得自编码器适用于降维。因此,潜在向量也经常被称为“瓶颈”,并且这种特定的自编码器配置也称为欠完备。然而,还有一种不同类别的自编码器,称为过完备,在这种情况下,潜在向量z的维度实际上大于输入示例的维度(p > d)。

在训练过程中,当训练一个过完备的自编码器时,存在一个平凡的解决方案,即编码器和解码器可以简单地学习复制(记忆)输入特征到它们的输出层。显然,这种解决方案并不是很有用。然而,通过对训练过程进行一些修改,过完备的自编码器可以用于噪声减少

在这种情况下,训练过程中,随机噪声被添加到输入示例中,网络学习从嘈杂的信号中重构出干净的例子x。然后,在评估时,我们提供自然嘈杂的新例子(即已经存在噪声,因此不需要额外的人工噪声)以便从这些例子中去除现有的噪声。这种特殊的自编码器架构和训练方法被称为去噪自编码器

如果你感兴趣,你可以通过Pascal Vincent和他的同事在 2010 年发表的研究文章Stacked denoising autoencoders: Learning useful representations in a deep network with a local denoising criterion了解更多。

生成模型用于合成新数据

自编码器是确定性模型,这意味着在自编码器训练后,给定一个输入x,它将能够从其在较低维空间中的压缩版本重新构建输入。因此,它不能在超出重构其输入之外生成新的数据。

另一方面,一个生成模型可以从一个随机向量z(对应于潜在表示)生成一个新的例子,。生成模型的示意图如下所示。随机向量z来自具有完全已知特性的分布,因此我们可以轻松地从这样的分布中进行抽样。例如,z的每个元素可以来自于范围为[–1, 1]的均匀分布(我们写成),或者来自于标准正态分布(这种情况下我们写成):

图 17.2:一个生成模型

当我们将注意力从自动编码器转向生成模型时,您可能已经注意到自动编码器的解码器部分与生成模型有些相似。特别是它们都接收潜在向量 z 作为输入,并返回与 x 相同空间的输出。(对于自动编码器, 是输入 x 的重构,对于生成模型, 是一个合成的样本。)

然而,两者之间的主要区别在于我们不知道自动编码器中 z 的分布,而在生成模型中,z 的分布是完全可描述的。虽然可以将自动编码器泛化为生成模型。一种方法是 变分自动编码器VAE)。

在接收输入示例 x 的 VAE 中,编码器网络被修改,以计算潜在向量分布的两个时刻:均值 和方差 。在训练 VAE 期间,网络被强制使这些时刻与标准正态分布(即零均值和单位方差)的时刻匹配。然后,在训练 VAE 模型后,编码器被丢弃,我们可以使用解码器网络通过从“学习到的”高斯分布中提供的随机 z 向量来生成新的示例,

除了 VAE,还有其他类型的生成模型,例如 自回归模型正规化流模型。然而,在本章中,我们将只关注 GAN 模型,它们是深度学习中最新和最流行的生成模型类型之一。

什么是生成模型?

请注意,生成模型通常被定义为模拟数据输入分布 p(x) 或输入数据及相关目标的联合分布 p(xy) 的算法。按照定义,这些模型也能够从某些特征 x[i] 中进行采样,条件是另一特征 x[j],这被称为 条件推理。然而,在深度学习的语境中,术语 生成模型 通常用来指代能够生成看起来真实的数据的模型。这意味着我们可以从输入分布 p(x) 中采样,但不一定能进行条件推理。

使用 GANs 生成新样本

要简单理解 GANs 的作用,我们首先假设有一个网络,接收来自已知分布的随机向量 z,并生成输出图像 x。我们将这个网络称为 生成器G),并使用符号 表示生成的输出。假设我们的目标是生成一些图像,例如人脸图像、建筑物图像、动物图像,甚至是手写数字如 MNIST。

正如以往一样,我们将使用随机权重初始化这个网络。因此,在调整这些权重之前,第一批输出图像看起来像是白噪声。现在,想象一下有一个能够评估图像质量的函数(我们称之为评估函数)。

如果存在这样的函数,我们可以利用该函数的反馈告诉生成器网络如何调整其权重以提高生成图像的质量。通过这种方式,我们可以根据评估函数的反馈训练生成器,使其学习改进其输出以生成看起来真实的图像。

正如上一段所描述的评估函数,如果存在这样一个通用函数来评估图像的质量,那么生成图像的任务将变得非常简单。问题是,是否存在这样一个可以评估图像质量的通用函数,如果存在,它是如何定义的。显然,作为人类,当我们观察网络的输出时,可以轻松评估输出图像的质量;尽管我们目前(还)无法将我们的大脑结果反向传播到网络中。现在,如果我们的大脑能够评估合成图像的质量,那么我们是否可以设计一个神经网络模型来做同样的事情?事实上,这正是 GAN 的一般想法。

图 17.3所示,GAN 模型包括一个名为鉴别器D)的附加神经网络,它是一个分类器,学习如何检测由生成器合成的图像,,与真实图像x的区别:

图 17.3:鉴别器区分真实图像和生成器创建的图像

在 GAN 模型中,生成器和鉴别器两个网络一起训练。一开始,初始化模型权重后,生成器创建的图像看起来不太真实。同样,鉴别器在区分真实图像和生成器合成图像方面表现不佳。但随着时间的推移(即训练过程中),这两个网络通过相互作用逐渐提升。事实上,这两个网络在进行对抗训练,生成器学习改进其输出以欺骗鉴别器。与此同时,鉴别器变得更加擅长检测合成图像。

理解 GAN 模型中生成器和鉴别器网络的损失函数

GAN 的目标函数,如I. Goodfellow及其同事在原始论文生成对抗网络papers.nips.cc/paper/5423-generative-adversarial-nets.pdf)中描述的那样:

在这里,被称为值函数,可以被解释为一种回报:我们希望在鉴别器(D)方面最大化其值,同时在生成器(G)方面最小化其值,即D(x)是指示输入示例x是真实还是生成的概率(即生成的)。表达式指的是对于来自数据分布(真实例子的分布)的示例期望值;是指对于输入向量z的分布的期望值。

GAN 模型的一个训练步骤需要两个优化步骤:(1)最大化鉴别器的回报,(2)最小化生成器的回报。训练 GAN 的一个实际方法是在这两个优化步骤之间交替进行:(1)固定一个网络的参数并优化另一个网络的权重,(2)固定第二个网络并优化第一个网络。这个过程应该在每个训练迭代中重复。假设生成器网络被固定,并且我们想要优化鉴别器。值函数中的两项都有助于优化鉴别器,其中第一项对应于真实例子的损失,第二项是虚假例子的损失。因此,当G固定时,我们的目标是最大化,这意味着使鉴别器更好地区分真实和生成的图像。

优化鉴别器使用真实和虚假样本的损失项后,我们固定鉴别器并优化生成器。在这种情况下,只有中的第二项对生成器的梯度起作用。因此,当D固定时,我们的目标是最小化,可以写成。正如 Goodfellow 及其同事在原始 GAN 论文中提到的那样,这个函数在早期训练阶段会出现梯度消失的问题。造成这一现象的原因是在学习过程早期,输出G(z)看起来与真实例子完全不同,因此D(G(z))会非常接近零并且有很高的置信度。这种现象被称为饱和。为了解决这个问题,我们可以通过重写将最小化目标重新表述为来重新制定。

这种替换意味着在训练生成器时,我们可以交换真实和虚假示例的标签,并执行常规的函数最小化。换句话说,尽管生成器合成的示例是虚假的,因此标记为 0,我们可以通过将这些示例分配标签 1 来反转标签,并最小化使用这些新标签的二元交叉熵损失,而不是最大化

现在我们已经介绍了训练 GAN 模型的一般优化过程,让我们探讨在训练 GAN 时可以使用的各种数据标签。鉴于判别器是二元分类器(虚假和真实图像的类标签分别为 0 和 1),我们可以使用二元交叉熵损失函数。因此,我们可以确定判别器损失的地面真实标签如下:

那么,训练生成器的标签如何呢?由于我们希望生成器合成逼真的图像,当鉴别器不将其输出分类为真实图像时,我们希望惩罚生成器。这意味着在计算生成器的损失函数时,我们将假设生成器输出的地面真实标签为 1。

将所有这些内容整合在一起,下图展示了简单 GAN 模型中的各个步骤:

图 17.4:构建 GAN 模型的步骤

在接下来的部分,我们将从零开始实现 GAN 以生成新的手写数字。

从零开始实现 GAN

在本节中,我们将介绍如何实现和训练 GAN 模型以生成新的图像,如 MNIST 数字。由于在普通中央处理单元CPU)上进行训练可能需要很长时间,因此在下一小节中,我们将介绍如何设置 Google Colab 环境,以便我们可以在图形处理单元GPU)上运行计算。

在 Google Colab 上训练 GAN 模型

本章中的某些代码示例可能需要超出传统笔记本电脑或工作站的常规计算资源。如果您已经有一个安装了 CUDA 和 cuDNN 库的 NVIDIA GPU 计算机,可以使用它来加快计算速度。

然而,由于许多人无法获得高性能计算资源,我们将使用 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 的一般信息,请查阅官方文档,网址为jupyter-notebook.readthedocs.io/en/stable/。本书中所有代码也以 Jupyter Notebook 形式提供,第一章的代码目录中还附有简短介绍。

最后,我们强烈推荐Adam Rule等人的文章《在 Jupyter Notebooks 中编写和共享计算分析的十个简单规则》,该文章对在科学研究项目中有效使用 Jupyter Notebook 提供了有价值的建议,可在journals.plos.org/ploscompbiol/article?id=10.1371/journal.pcbi.1007007免费获取。

访问 Google Colab 非常简单。您可以访问colab.research.google.com,该链接会自动跳转到一个提示窗口,您可以在其中看到现有的 Jupyter 笔记本。在这个提示窗口中,点击如图 17.5所示的Google Drive选项卡,这是您将笔记本保存到 Google Drive 的地方。

接下来,要创建一个新的笔记本,请点击提示窗口底部的New notebook链接:

图 17.5:在 Google Colab 中创建一个新的 Python 笔记本

这将为您创建并打开一个新的笔记本。您在此笔记本中编写的所有代码示例都将自动保存,稍后您可以从名为Colab Notebooks的目录中访问笔记本。

在下一步中,我们希望利用 GPU 来运行此笔记本中的代码示例。为此,请在此笔记本菜单栏的Runtime选项中,点击Change runtime type,然后选择GPU,如图 17.6所示:

图 17.6:在 Google Colab 中利用 GPU

在最后一步中,我们只需安装本章所需的 Python 包。Colab Notebooks 环境已经预装了某些包,如 NumPy、SciPy 和最新稳定版本的 PyTorch。撰写本文时,Google Colab 的最新稳定版本是 PyTorch 1.9。

现在,我们可以通过以下代码来测试安装并验证 GPU 是否可用:

>>> import torch
>>> print(torch.__version__)
1.9.0+cu111
>>> print("GPU Available:", torch.cuda.is_available())
GPU Available: True
>>> if torch.cuda.is_available():
...     device = torch.device("cuda:0")
... else:
...     device = "cpu"
>>> print(device)
cuda:0 

此外,如果你想要将模型保存到个人谷歌驱动器,或者转移或上传其他文件,你需要挂载谷歌驱动器。要做到这一点,在笔记本的新单元格中执行以下操作:

>>> from google.colab import drive
>>> drive.mount('/content/drive/') 

这将提供一个链接来对 Colab 笔记本访问你的谷歌驱动器进行身份验证。按照身份验证的说明操作后,它会提供一个身份验证代码,你需要复制并粘贴到刚刚执行的单元格下方的指定输入字段中。然后,你的谷歌驱动器将被挂载,并可在/content/drive/My Drive路径下访问。或者,你可以通过 GUI 界面挂载,如图 17.7所示:

自动生成的图形用户界面、应用程序、电子邮件说明

图 17.7:挂载你的谷歌驱动器

实现生成器和判别器网络

我们将从一个包含生成器和判别器的两个全连接网络的第一个 GAN 模型实现开始,如图 17.8所示:

自动生成的图解

图 17.8:一个包含生成器和判别器的 GAN 模型,两者都是全连接网络

图 17.8 描述了基于全连接层的原始 GAN,我们将其称为香草 GAN

在这个模型中,对于每个隐藏层,我们将应用泄漏线性整流单元(Leaky ReLU)激活函数。使用 ReLU 会导致梯度稀疏化,这在我们希望对输入值的全范围有梯度时可能不太合适。在判别器网络中,每个隐藏层之后还跟有一个 dropout 层。此外,生成器的输出层使用双曲正切(tanh)激活函数。(推荐在生成器网络中使用 tanh 激活函数,因为它有助于学习。)

判别器的输出层没有激活函数(即线性激活),以获取 logits。作为替代,我们可以使用 sigmoid 激活函数以获取概率作为输出。

泄漏修正线性单元(ReLU)激活函数

第十二章,《使用 PyTorch 并行化神经网络训练》,我们介绍了可以在 NN 模型中使用的不同非线性激活函数。如果你还记得,ReLU 激活函数被定义为,它抑制负(激活前)输入;也就是说,负输入被设置为零。因此,在反向传播过程中使用 ReLU 激活函数可能导致稀疏梯度。稀疏梯度并不总是有害的,甚至可以使分类模型受益。然而,在某些应用中,如 GANs,获得全范围输入值的梯度可能对模型有利。我们可以通过对 ReLU 函数进行轻微修改来实现这一点,使其对负输入输出小值。这种修改版本的 ReLU 函数也被称为泄露的 ReLU。简而言之,泄露的 ReLU 激活函数允许负输入的梯度非零,并因此使网络整体更具表现力。

泄露的 ReLU 激活函数定义如下:

A picture containing diagram  Description automatically generated

图 17.9:泄露的 ReLU 激活函数

在这里,决定了负(激活前)输入的斜率。

我们将为每个网络定义两个辅助函数,从 PyTorch 的nn.Sequential类中实例化模型,并按描述添加层。代码如下:

>>> import torch.nn as nn
>>> import numpy as np
>>> import matplotlib.pyplot as plt
>>> ## define a function for the generator:
>>> def make_generator_network(
...         input_size=20,
...         num_hidden_layers=1,
...         num_hidden_units=100,
...         num_output_units=784):
...     model = nn.Sequential()
...     for i in range(num_hidden_layers):
...         model.add_module(f'fc_g{i}',
...                          nn.Linear(input_size, num_hidden_units))
...         model.add_module(f'relu_g{i}', nn.LeakyReLU())
...         input_size = num_hidden_units
...     model.add_module(f'fc_g{num_hidden_layers}',
...                      nn.Linear(input_size, num_output_units))
...     model.add_module('tanh_g', nn.Tanh())
...     return model
>>> 
>>> ## define a function for the discriminator:
>>> def make_discriminator_network(
...         input_size,
...         num_hidden_layers=1,
...         num_hidden_units=100,
...         num_output_units=1):
...     model = nn.Sequential()
...     for i in range(num_hidden_layers):
...         model.add_module(
...             f'fc_d{i}',
...             nn.Linear(input_size, num_hidden_units, bias=False)
...         )
...         model.add_module(f'relu_d{i}', nn.LeakyReLU())
...         model.add_module('dropout', nn.Dropout(p=0.5))
...         input_size = num_hidden_units
...     model.add_module(f'fc_d{num_hidden_layers}',
...                      nn.Linear(input_size, num_output_units))
...     model.add_module('sigmoid', nn.Sigmoid())
...     return model 

接下来,我们将指定模型的训练设置。正如您从前几章中记得的那样,MNIST 数据集中的图像大小为 28×28 像素。(因为 MNIST 只包含灰度图像,所以只有一个颜色通道。)我们还将指定输入向量z的大小为 20。由于我们只是为了演示目的而实现了一个非常简单的 GAN 模型,并使用全连接层,我们只会使用每个网络中的一个单隐藏层,每层有 100 个单元。在下面的代码中,我们将指定和初始化这两个网络,并打印它们的摘要信息:

>>> image_size = (28, 28)
>>> z_size = 20
>>> gen_hidden_layers = 1
>>> gen_hidden_size = 100
>>> disc_hidden_layers = 1
>>> disc_hidden_size = 100
>>> torch.manual_seed(1)
>>> gen_model = make_generator_network(
...     input_size=z_size,
...     num_hidden_layers=gen_hidden_layers,
...     num_hidden_units=gen_hidden_size,
...     num_output_units=np.prod(image_size)
... )
>>> print(gen_model)
Sequential(
  (fc_g0): Linear(in_features=20, out_features=100, bias=False)
  (relu_g0): LeakyReLU(negative_slope=0.01)
  (fc_g1): Linear(in_features=100, out_features=784, bias=True)
  (tanh_g): Tanh()
)
>>> disc_model = make_discriminator_network(
...     input_size=np.prod(image_size),
...     num_hidden_layers=disc_hidden_layers,
...     num_hidden_units=disc_hidden_size
... )
>>> print(disc_model)
Sequential(
  (fc_d0): Linear(in_features=784, out_features=100, bias=False)
  (relu_d0): LeakyReLU(negative_slope=0.01)
  (dropout): Dropout(p=0.5, inplace=False)
  (fc_d1): Linear(in_features=100, out_features=1, bias=True)
  (sigmoid): Sigmoid()
) 

定义训练数据集

在下一步中,我们将从 PyTorch 加载 MNIST 数据集并应用必要的预处理步骤。由于生成器的输出层使用 tanh 激活函数,合成图像的像素值将在(-1,1)范围内。然而,MNIST 图像的输入像素在[0,255]范围内(数据类型为PIL.Image.Image)。因此,在预处理步骤中,我们将使用torchvision.transforms.ToTensor函数将输入图像张量转换为张量。因此,除了更改数据类型外,调用此函数还将改变输入像素强度的范围为[0,1]。然后,我们可以通过将它们移动-0.5 并缩放 0.5 的因子来将它们移动到[-1,1]范围内,从而改善基于梯度下降的学习:

>>> import torchvision
>>> from torchvision import transforms
>>> image_path = './'
>>> transform = transforms.Compose([
...     transforms.ToTensor(),
...     transforms.Normalize(mean=(0.5), std=(0.5)),
... ])
>>> mnist_dataset = torchvision.datasets.MNIST(
...     root=image_path, train=True,
...     transform=transform, download=False
... )
>>> example, label = next(iter(mnist_dataset))
>>> print(f'Min: {example.min()} Max: {example.max()}')
>>> print(example.shape)
Min: -1.0 Max: 1.0
torch.Size([1, 28, 28]) 

此外,我们还将根据所需的随机分布(在此代码示例中为均匀分布或正态分布,这是最常见的选择之一)创建一个随机向量z

>>> def create_noise(batch_size, z_size, mode_z):
...     if mode_z == 'uniform':
...         input_z = torch.rand(batch_size, z_size)*2 - 1
...     elif mode_z == 'normal':
...         input_z = torch.randn(batch_size, z_size)
...     return input_z 

让我们检查我们创建的数据集对象。在接下来的代码中,我们将取一个示例批次并打印这些输入向量和图像的数组形状。此外,为了理解我们的 GAN 模型的整体数据流,我们将在下面的代码中为我们的生成器和判别器进行前向传递。

首先,我们将批量输入向量z传递给生成器并获取其输出g_output。这将是一批假例子,将被馈送到判别器模型以获取假例子批次d_proba_fake的概率。此外,我们从数据集对象获取的处理后的图像将被馈送到判别器模型,这将导致真实例子批次d_proba_real的概率。代码如下:

>>> from torch.utils.data import DataLoader
>>> batch_size = 32
>>> dataloader = DataLoader(mnist_dataset, batch_size, shuffle=False)
>>> input_real, label = next(iter(dataloader))
>>> input_real = input_real.view(batch_size, -1)
>>> torch.manual_seed(1)
>>> mode_z = 'uniform'  # 'uniform' vs. 'normal'
>>> input_z = create_noise(batch_size, z_size, mode_z)
>>> print('input-z -- shape:', input_z.shape)
>>> print('input-real -- shape:', input_real.shape)
input-z -- shape: torch.Size([32, 20])
input-real -- shape: torch.Size([32, 784])
>>> g_output = gen_model(input_z)
>>> print('Output of G -- shape:', g_output.shape)
Output of G -- shape: torch.Size([32, 784])
>>> d_proba_real = disc_model(input_real)
>>> d_proba_fake = disc_model(g_output)
>>> print('Disc. (real) -- shape:', d_proba_real.shape)
>>> print('Disc. (fake) -- shape:', d_proba_fake.shape)
Disc. (real) -- shape: torch.Size([32, 1])
Disc. (fake) -- shape: torch.Size([32, 1]) 

两个概率d_proba_faked_proba_real将用于计算训练模型的损失函数。

训练 GAN 模型

作为下一步,我们将创建一个nn.BCELoss的实例作为我们的损失函数,并使用它来计算与我们刚刚处理的批次相关的生成器和判别器的二元交叉熵损失。为此,我们还需要每个输出的地面实况标签。对于生成器,我们将创建一个与包含生成图像预测概率的向量d_proba_fake形状相同的 1 向量。对于判别器损失,我们有两个项:涉及d_proba_fake的检测假例的损失和基于d_proba_real的检测真实例的损失。

对于假术语的地面实况标签将是一个 0 向量,我们可以通过torch.zeros()(或torch.zeros_like())函数生成。类似地,我们可以通过torch.ones()(或torch.ones_like())函数生成真实图像的地面实况值,该函数创建一个 1 向量:

>>> loss_fn = nn.BCELoss()
>>> ## Loss for the Generator
>>> g_labels_real = torch.ones_like(d_proba_fake)
>>> g_loss = loss_fn(d_proba_fake, g_labels_real)
>>> print(f'Generator Loss: {g_loss:.4f}')
Generator Loss: 0.6863
>>> ## Loss for the Discriminator
>>> d_labels_real = torch.ones_like(d_proba_real)
>>> d_labels_fake = torch.zeros_like(d_proba_fake)
>>> d_loss_real = loss_fn(d_proba_real, d_labels_real)
>>> d_loss_fake = loss_fn(d_proba_fake, d_labels_fake)
>>> print(f'Discriminator Losses: Real {d_loss_real:.4f} Fake {d_loss_fake:.4f}')
Discriminator Losses: Real 0.6226 Fake 0.7007 

前面的代码示例展示了逐步计算不同损失项的过程,以便理解训练 GAN 模型背后的整体概念。接下来的代码将设置 GAN 模型并实现训练循环,在其中我们将在for循环中包括这些计算。

我们将从为真实数据集设置数据加载器开始,包括生成器和判别器模型,以及两个模型各自的单独 Adam 优化器:

>>> batch_size = 64
>>> torch.manual_seed(1)
>>> np.random.seed(1)
>>> mnist_dl = DataLoader(mnist_dataset, batch_size=batch_size,
...                       shuffle=True, drop_last=True)
>>> gen_model = make_generator_network(
...     input_size=z_size,
...     num_hidden_layers=gen_hidden_layers,
...     num_hidden_units=gen_hidden_size,
...     num_output_units=np.prod(image_size)
... ).to(device)
>>> disc_model = make_discriminator_network(
...     input_size=np.prod(image_size),
...     num_hidden_layers=disc_hidden_layers,
...     num_hidden_units=disc_hidden_size
... ).to(device)
>>> loss_fn = nn.BCELoss()
>>> g_optimizer = torch.optim.Adam(gen_model.parameters())
>>> d_optimizer = torch.optim.Adam(disc_model.parameters()) 

另外,我们将计算损失相对于模型权重的梯度,并使用两个单独的 Adam 优化器优化生成器和判别器的参数。我们将编写两个实用函数来训练判别器和生成器,如下所示:

>>> ## Train the discriminator
>>> def d_train(x):
...     disc_model.zero_grad()
...     # Train discriminator with a real batch
...     batch_size = x.size(0)
...     x = x.view(batch_size, -1).to(device)
...     d_labels_real = torch.ones(batch_size, 1, device=device)
...     d_proba_real = disc_model(x)
...     d_loss_real = loss_fn(d_proba_real, d_labels_real)
...     # Train discriminator on a fake batch
...     input_z = create_noise(batch_size, z_size, mode_z).to(device)
...     g_output = gen_model(input_z)
...     d_proba_fake = disc_model(g_output)
...     d_labels_fake = torch.zeros(batch_size, 1, device=device)
...     d_loss_fake = loss_fn(d_proba_fake, d_labels_fake)
...     # gradient backprop & optimize ONLY D's parameters
...     d_loss = d_loss_real + d_loss_fake
...     d_loss.backward()
...     d_optimizer.step()
...     return d_loss.data.item(), d_proba_real.detach(), \
...            d_proba_fake.detach()
>>>
>>> ## Train the generator
>>> def g_train(x):
...     gen_model.zero_grad()
...     batch_size = x.size(0)
...     input_z = create_noise(batch_size, z_size, mode_z).to(device)
...     g_labels_real = torch.ones(batch_size, 1, device=device)
... 
...     g_output = gen_model(input_z)
...     d_proba_fake = disc_model(g_output)
...     g_loss = loss_fn(d_proba_fake, g_labels_real)
...     # gradient backprop & optimize ONLY G's parameters
...     g_loss.backward()
...     g_optimizer.step()
...     return g_loss.data.item() 

接下来,我们将在 100 个 epochs 中交替训练生成器和判别器。每个 epoch,我们将记录生成器的损失、判别器的损失以及真实数据和假数据的损失。此外,在每个 epoch 后,我们将使用当前生成器模型调用 create_samples() 函数从固定噪声输入生成一些示例。我们将合成的图像存储在一个 Python 列表中。代码如下:

>>> fixed_z = create_noise(batch_size, z_size, mode_z).to(device)
>>> def create_samples(g_model, input_z):
...     g_output = g_model(input_z)
...     images = torch.reshape(g_output, (batch_size, *image_size))
...     return (images+1)/2.0
>>> 
>>> epoch_samples = []
>>> all_d_losses = []
>>> all_g_losses = []
>>> all_d_real = []
>>> all_d_fake = []
>>> num_epochs = 100
>>> 
>>> for epoch in range(1, num_epochs+1):
...     d_losses, g_losses = [], []
...     d_vals_real, d_vals_fake = [], []
...     for i, (x, _) in enumerate(mnist_dl):
...         d_loss, d_proba_real, d_proba_fake = d_train(x)
...         d_losses.append(d_loss)
...         g_losses.append(g_train(x))
...         d_vals_real.append(d_proba_real.mean().cpu())
...         d_vals_fake.append(d_proba_fake.mean().cpu())
...         
...     all_d_losses.append(torch.tensor(d_losses).mean())
...     all_g_losses.append(torch.tensor(g_losses).mean())
...     all_d_real.append(torch.tensor(d_vals_real).mean())
...     all_d_fake.append(torch.tensor(d_vals_fake).mean())
...     print(f'Epoch {epoch:03d} | Avg Losses >>'
...           f' G/D {all_g_losses[-1]:.4f}/{all_d_losses[-1]:.4f}'
...           f' [D-Real: {all_d_real[-1]:.4f}'
...           f' D-Fake: {all_d_fake[-1]:.4f}]')
...     epoch_samples.append(
...         create_samples(gen_model, fixed_z).detach().cpu().numpy()
...     )

Epoch 001 | Avg Losses >> G/D 0.9546/0.8957 [D-Real: 0.8074 D-Fake: 0.4687]
Epoch 002 | Avg Losses >> G/D 0.9571/1.0841 [D-Real: 0.6346 D-Fake: 0.4155]
Epoch ...
Epoch 100 | Avg Losses >> G/D 0.8622/1.2878 [D-Real: 0.5488 D-Fake: 0.4518] 

在 Google Colab 上使用 GPU,我们在前面的代码块中实现的训练过程应该在一个小时内完成。(如果你有最新和强大的 CPU 和 GPU,甚至可能更快。)模型训练完成后,通常有助于绘制判别器和生成器的损失,分析两个子网络的行为并评估它们是否收敛。

还有助于绘制由判别器在每次迭代中计算的真实和假例子批次的平均概率。我们期望这些概率在 0.5 左右,这意味着判别器不能自信地区分真实和假的图像:

>>> import itertools
>>> fig = plt.figure(figsize=(16, 6))
>>> ## Plotting the losses
>>> ax = fig.add_subplot(1, 2, 1)
>>> plt.plot(all_g_losses, label='Generator loss')
>>> half_d_losses = [all_d_loss/2 for all_d_loss in all_d_losses]
>>> plt.plot(half_d_losses, label='Discriminator loss')
>>> plt.legend(fontsize=20)
>>> ax.set_xlabel('Iteration', size=15)
>>> ax.set_ylabel('Loss', size=15)
>>> 
>>> ## Plotting the outputs of the discriminator
>>> ax = fig.add_subplot(1, 2, 2)
>>> plt.plot(all_d_real, label=r'Real: $D(\mathbf{x})$')
>>> plt.plot(all_d_fake, label=r'Fake: $D(G(\mathbf{z}))$')
>>> plt.legend(fontsize=20)
>>> ax.set_xlabel('Iteration', size=15)
>>> ax.set_ylabel('Discriminator output', size=15)
>>> plt.show() 

图 17.10 显示了结果:

图 17.10:判别器的性能

正如您在前面的判别器输出中所见,训练早期,判别器能够快速学习准确区分真实和假例子;也就是说,假例子的概率接近 0,真实例子的概率接近 1。这是因为假例子与真实例子完全不同,因此很容易区分真伪。随着训练的进展,生成器将变得更擅长合成逼真图像,这将导致真实和假例子的概率都接近 0.5。

此外,我们还可以看到生成器输出,即合成图像在训练过程中的变化。在接下来的代码中,我们将可视化生成器为一些 epochs 选择生成的一些图像。

>>> 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, f'Epoch {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() 

图 17.11 展示了生成的图像:

图 17.11:生成器生成的图像

正如您从 图 17.11 中可以看到的那样,随着训练的进行,生成器网络生成的图像变得越来越逼真。然而,即使经过 100 个 epochs,生成的图像仍然与 MNIST 数据集中的手写数字非常不同。

在本节中,我们设计了一个非常简单的 GAN 模型,生成器和判别器仅有一个完全连接的隐藏层。在 MNIST 数据集上训练 GAN 模型后,我们能够获得有希望的,尽管还不完全满意的新手写数字结果。

正如我们在《第十四章》“使用深度卷积神经网络对图像进行分类”中学到的,具有卷积层的 NN 体系结构在图像分类时比全连接层具有几个优势。在类似的情况下,将卷积层添加到我们的 GAN 模型中以处理图像数据可能会改善结果。在下一节中,我们将实现一个深度卷积 GAN (DCGAN),它将使用卷积层来构建生成器和鉴别器网络。

使用卷积和 Wasserstein GAN 来改善合成图像的质量

在这一节中,我们将实现一个 DCGAN,这将使我们能够提高我们在前面 GAN 示例中看到的性能。此外,我们还将简要讨论一种额外的关键技术,Wasserstein GAN (WGAN)。

在本节中,我们将涵盖以下技术:

  • 转置卷积

  • 批量归一化(BatchNorm)

  • WGAN

DCGAN 是由A. RadfordL. MetzS. Chintala在他们的文章《无监督表示学习与深度卷积生成对抗网络》中提出的,该文章可以在arxiv.org/pdf/1511.06434.pdf免费获取。在这篇文章中,研究人员建议为生成器和鉴别器网络都使用卷积层。从一个随机向量z开始,DCGAN 首先使用全连接层将z投影到一个新的向量中,使其大小适当,以便可以将其重新塑造为空间卷积表示(h×w×c),这个表示比输出图像大小要小。然后,一系列卷积层(称为转置卷积)被用来将特征图上采样到所需的输出图像大小。

转置卷积

在《第十四章》中,你学习了在一维和二维空间中的卷积操作。特别是,我们看到了如何通过选择填充和步幅来改变输出特征图。虽然卷积操作通常用于对特征空间进行下采样(例如,通过将步幅设置为 2,或在卷积层后添加池化层),而转置卷积操作通常用于对特征空间进行上采样。

要理解转置卷积操作,让我们进行一个简单的思想实验。假设我们有一个大小为n×n的输入特征图。然后,我们对这个n×n的输入应用一个带有特定填充和步幅参数的 2D 卷积操作,得到一个大小为m×m的输出特征图。现在的问题是,我们如何可以应用另一个卷积操作来从这个m×m的输出特征图中获得一个具有初始维度n×n的特征图,同时保持输入和输出之间的连接模式?请注意,只恢复了n×n输入矩阵的形状,而不是实际的矩阵值。

图 17.12 所示,这就是转置卷积的工作原理:

图 17.12:转置卷积

转置卷积与反卷积

转置卷积也称为分数步幅卷积。在深度学习文献中,另一个常用的术语来指代转置卷积的是反卷积。但是需要注意的是,反卷积最初被定义为对特征映射 x 进行逆卷积操作 f,其权重参数为 w,生成特征映射 x′fw = x′。然后可以定义一个反卷积函数 f^(–1),如 。但需要注意的是,转置卷积仅仅专注于恢复特征空间的维度,而不是实际的数值。

使用转置卷积对特征映射进行上采样是通过在输入特征映射的元素之间插入 0 来实现的。图 17.13 展示了将转置卷积应用于一个大小为 4×4 的输入的示例,步幅为 2×2,卷积核大小为 2×2。中间的 9×9 矩阵显示了在输入特征映射中插入这样的 0 后的结果。然后,使用 2×2 卷积核和步幅为 1 进行普通卷积将产生一个大小为 8×8 的输出。我们可以通过在输出上执行步幅为 2 的常规卷积来验证反向方向,这将产生一个大小为 4×4 的输出特征映射,与原始输入大小相同:

图 17.13:将转置卷积应用于一个 4×4 的输入

图 17.13 概述了转置卷积的工作原理。输入大小、卷积核大小、步幅和填充方式的不同情况会影响输出结果。如果你想了解更多关于这些不同情况的内容,请参考 A Guide to Convolution Arithmetic for Deep Learning,由 Vincent DumoulinFrancesco Visin 撰写,2018 年 (arxiv.org/pdf/1603.07285.pdf.)

批归一化

BatchNorm 是由 Sergey Ioffe 和 Christian Szegedy 在文章 Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift 中于 2015 年提出的,您可以通过 arXiv 访问该文章 arxiv.org/pdf/1502.03167.pdf。BatchNorm 的主要思想之一是对层输入进行归一化,并在训练期间防止其分布的变化,从而加快和改善收敛速度。

BatchNorm 基于其计算出的统计信息来转换一个特征的小批量。假设我们有一个经过卷积层得到的四维张量 Z 的网络预激活特征映射,其形状为 [m×c×h×w],其中 m 是批量大小(即批量大小),h×w 是特征映射的空间维度,c 是通道数。BatchNorm 可以总结为三个步骤:

  1. 计算每个小批次的网络输入的均值和标准差:

    其中 都具有大小 c

  2. 对批次中所有示例的网络输入进行标准化:

    其中 是为了数值稳定性而设定的一个小数(即避免除以零)。

  3. 使用两个可学习参数向量 (大小为 c,即通道数)来缩放和偏移标准化的网络输入:

图 17.14 描述了这个过程:

图 17.14:批次归一化的过程

在 BatchNorm 的第一步中,计算小批次的均值 和标准差 。这两者都是大小为 c 的向量(其中 c 是通道数)。然后,这些统计数据在 第 2 步 中用于通过 z-score 标准化(标准化)来缩放每个小批次中的示例,得到标准化的网络输入 。因此,这些网络输入是以均值为中心并具有单位方差的,这通常是基于梯度下降的优化中的一个有用特性。另一方面,总是归一化网络输入,使它们在不同的小批次之间具有相同的属性,这些属性可能是多样的,可能会严重影响神经网络的表示能力。这可以通过考虑特征 ,在进行 sigmoid 激活后到达 ,对接近 0 的值具有线性区域来理解。因此,在第 3 步中,可学习的参数 ,这些参数是大小为 c 的向量(通道数),允许 BatchNorm 控制标准化特征的偏移和扩展。

训练期间计算运行平均值 和运行方差 ,这些值与调整后的参数 一起用于规范化评估时的测试示例。

为什么 BatchNorm 能帮助优化?

最初,BatchNorm 的开发目的是减少所谓的内部协变量漂移,即由于训练期间更新的网络参数而导致的层激活分布的变化。

举个简单例子来解释,考虑一个固定的批次在第 1 个 epoch 通过网络。我们记录该批次每层的激活。在遍历整个训练数据集并更新模型参数后,我们开始第二个 epoch,之前固定的批次再次通过网络。然后,我们比较第一和第二个 epoch 的层激活。由于网络参数已更改,我们观察到激活也已更改。这种现象称为内部协方差转移,据信会减缓神经网络的训练速度。

然而,2018 年,S. Santurkar, D. Tsipras, A. Ilyas 和 A. Madry 进一步研究了 BatchNorm 如此有效的原因。在他们的研究中,研究人员观察到 BatchNorm 对内部协方差转移的影响微小。基于他们实验的结果,他们假设 BatchNorm 的有效性取决于损失函数表面的平滑度,这使得非凸优化更加健壮。

如果你对了解这些结果更感兴趣,请阅读原始论文How Does Batch Normalization Help Optimization?,可以在papers.nips.cc/paper/7515-how-does-batch-normalization-help-optimization.pdf免费获取。

PyTorch API 提供了一个类nn.BatchNorm2d()(对于 1D 输入是nn.BatchNorm1d()),我们在定义模型时可以将其用作层;它会执行我们描述的所有 BatchNorm 步骤。请注意,更新可学习参数的行为取决于模型是否处于训练模式。这些参数仅在训练期间学习,然后在评估期间用于归一化。

实现生成器和鉴别器

到目前为止,我们已经介绍了 DCGAN 模型的主要组成部分,接下来我们将实现它。生成器和鉴别器网络的架构总结如下两个图示。

生成器接受大小为 100 的向量z作为输入。然后,使用nn.ConvTranspose2d()进行一系列转置卷积,直到生成的特征图的空间尺寸达到 28×28。每个转置卷积层在 BatchNorm 和 leaky ReLU 激活函数之后,最后一个仅使用一个输出滤波器以生成灰度图像。每个转置卷积层之后都跟随 BatchNorm 和 leaky ReLU 激活函数,最后一个转置卷积层使用 tanh 激活函数(不使用 BatchNorm)。

生成器的架构(每层后的特征图)如图 17.15所示:

图 17.15:生成器网络

鉴别器接收大小为 1×28×28 的图像,这些图像通过四个卷积层。前三个卷积层通过增加特征图的通道数同时减少空间维度。每个卷积层后面还跟有 BatchNorm 和泄漏 ReLU 激活函数。最后一个卷积层使用大小为 7×7 的核和单个滤波器,将输出的空间维度减少到 1×1×1。最后,卷积输出通过 sigmoid 函数并压缩为一维:

图 17.16:鉴别器网络

卷积 GAN 的架构设计考虑

请注意,特征图的数量在生成器和鉴别器之间遵循不同的趋势。在生成器中,我们从大量的特征图开始,并随着向最后一层的进展而减少它们。另一方面,在鉴别器中,我们从少量的通道开始,并向最后一层增加它们。这是设计 CNN 时特征图数量和特征图空间尺寸按相反顺序的重要点。当特征图的空间尺寸增加时,特征图的数量减少,反之亦然。

此外,请注意,通常不建议在跟随 BatchNorm 层的层中使用偏置单元。在这种情况下,使用偏置单元将是多余的,因为 BatchNorm 已经有一个偏移参数,。您可以通过在nn.ConvTranspose2dnn.Conv2d中设置bias=False来省略给定层的偏置单元。

用于创建生成器和鉴别器网络类的辅助函数代码如下:

>>> def make_generator_network(input_size, n_filters):
...     model = nn.Sequential(
...         nn.ConvTranspose2d(input_size, n_filters*4, 4,
...                            1, 0, bias=False),
...         nn.BatchNorm2d(n_filters*4),
...         nn.LeakyReLU(0.2),
...         nn.ConvTranspose2d(n_filters*4, n_filters*2,
...                            3, 2, 1, bias=False),
...         nn.BatchNorm2d(n_filters*2),
...         nn.LeakyReLU(0.2),
...         nn.ConvTranspose2d(n_filters*2, n_filters,
...                            4, 2, 1, bias=False),
...         nn.BatchNorm2d(n_filters),
...         nn.LeakyReLU(0.2),
...         nn.ConvTranspose2d(n_filters, 1, 4, 2, 1,
...                            bias=False),
...         nn.Tanh()
...     )
...     return model
>>> 
>>> class Discriminator(nn.Module):
...     def __init__(self, n_filters):
...         super().__init__()
...         self.network = nn.Sequential(
...             nn.Conv2d(1, n_filters, 4, 2, 1, bias=False),
...             nn.LeakyReLU(0.2),
...             nn.Conv2d(n_filters, n_filters*2,
...                       4, 2, 1, bias=False),
...             nn.BatchNorm2d(n_filters * 2),
...             nn.LeakyReLU(0.2),
...             nn.Conv2d(n_filters*2, n_filters*4,
...                       3, 2, 1, bias=False),
...             nn.BatchNorm2d(n_filters*4),
...             nn.LeakyReLU(0.2),
...             nn.Conv2d(n_filters*4, 1, 4, 1, 0, bias=False),
...             nn.Sigmoid()
...         )
... 
...     def forward(self, input):
...         output = self.network(input)
...         return output.view(-1, 1).squeeze(0) 

借助辅助函数和类,您可以使用我们在实现简单全连接 GAN 时初始化的相同 MNIST 数据集对象构建 DCGAN 模型并训练它。我们可以使用辅助函数创建生成器网络并打印其架构如下:

>>> z_size = 100
>>> image_size = (28, 28)
>>> n_filters = 32
>>> gen_model = make_generator_network(z_size, n_filters).to(device)
>>> print(gen_model)
Sequential(
  (0): ConvTranspose2d(100, 128, kernel_size=(4, 4), stride=(1, 1), bias=False)
  (1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
  (2): LeakyReLU(negative_slope=0.2)
  (3): ConvTranspose2d(128, 64, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
  (4): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
  (5): LeakyReLU(negative_slope=0.2)
  (6): ConvTranspose2d(64, 32, kernel_size=(4, 4), stride=(2, 2), padding=(1, 1), bias=False)
  (7): BatchNorm2d(32, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
  (8): LeakyReLU(negative_slope=0.2)
  (9): ConvTranspose2d(32, 1, kernel_size=(4, 4), stride=(2, 2), padding=(1, 1), bias=False)
  (10): Tanh()
) 

类似地,我们可以生成鉴别器网络并查看其架构:

>>> disc_model = Discriminator(n_filters).to(device)
>>> print(disc_model)
Discriminator(
  (network): Sequential(
    (0): Conv2d(1, 32, kernel_size=(4, 4), stride=(2, 2), padding=(1, 1), bias=False)
    (1): LeakyReLU(negative_slope=0.2)
    (2): Conv2d(32, 64, kernel_size=(4, 4), stride=(2, 2), padding=(1, 1), bias=False)
    (3): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    (4): LeakyReLU(negative_slope=0.2)
    (5): Conv2d(64, 128, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
    (6): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    (7): LeakyReLU(negative_slope=0.2)
    (8): Conv2d(128, 1, kernel_size=(4, 4), stride=(1, 1), bias=False)
    (9): Sigmoid()
  )
) 

另外,我们可以像在训练 GAN 模型小节中一样使用相同的损失函数和优化器:

>>> loss_fn = nn.BCELoss()
>>> g_optimizer = torch.optim.Adam(gen_model.parameters(), 0.0003)
>>> d_optimizer = torch.optim.Adam(disc_model.parameters(), 0.0002) 

我们将对训练过程进行几处小修改。用于生成随机输入的create_noise()函数必须更改为输出四维张量而不是向量:

>>> def create_noise(batch_size, z_size, mode_z):
...     if mode_z == 'uniform':
...         input_z = torch.rand(batch_size, z_size, 1, 1)*2 - 1
...     elif mode_z == 'normal':
...         input_z = torch.randn(batch_size, z_size, 1, 1)
...     return input_z 

用于训练鉴别器的d_train()函数不需要重新调整输入图像:

>>> def d_train(x):
...     disc_model.zero_grad()
...     # Train discriminator with a real batch
...     batch_size = x.size(0)
...     x = x.to(device)
...     d_labels_real = torch.ones(batch_size, 1, device=device)
...     d_proba_real = disc_model(x)
...     d_loss_real = loss_fn(d_proba_real, d_labels_real)
...     # Train discriminator on a fake batch
...     input_z = create_noise(batch_size, z_size, mode_z).to(device)
...     g_output = gen_model(input_z)
...     d_proba_fake = disc_model(g_output)
...     d_labels_fake = torch.zeros(batch_size, 1, device=device)
...     d_loss_fake = loss_fn(d_proba_fake, d_labels_fake)
...     # gradient backprop & optimize ONLY D's parameters
...     d_loss = d_loss_real + d_loss_fake
...     d_loss.backward()
...     d_optimizer.step()
...     return d_loss.data.item(), d_proba_real.detach(), \
...            d_proba_fake.detach() 

接下来,我们将在 100 个时期内交替训练生成器和鉴别器。每个时期结束后,我们将使用当前生成器模型调用create_samples()函数从固定噪声输入生成一些示例。代码如下:

>>> fixed_z = create_noise(batch_size, z_size, mode_z).to(device)
>>> epoch_samples = []
>>> torch.manual_seed(1)
>>> for epoch in range(1, num_epochs+1):
...     gen_model.train()
...     for i, (x, _) in enumerate(mnist_dl):
...         d_loss, d_proba_real, d_proba_fake = d_train(x)
...         d_losses.append(d_loss)
...         g_losses.append(g_train(x))
...     print(f'Epoch {epoch:03d} | Avg Losses >>'
...           f' G/D {torch.FloatTensor(g_losses).mean():.4f}'
...           f'/{torch.FloatTensor(d_losses).mean():.4f}')
...     gen_model.eval()
...     epoch_samples.append(
...         create_samples(
...             gen_model, fixed_z
...         ).detach().cpu().numpy()
...     )
Epoch 001 | Avg Losses >> G/D 4.7016/0.1035
Epoch 002 | Avg Losses >> G/D 5.9341/0.0438
...
Epoch 099 | Avg Losses >> G/D 4.3753/0.1360
Epoch 100 | Avg Losses >> G/D 4.4914/0.1120 

最后,让我们在一些时期可视化保存的示例,以查看模型的学习情况及合成示例的质量如何随学习过程变化:

>>> 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,  f'Epoch {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() 

图 17.17显示了结果:

图 17.17:DCGAN 生成的图像

我们使用与普通 GAN 部分相同的代码来可视化结果。比较新的示例表明,DCGAN 能够生成质量更高的图像。

你可能想知道我们如何评估 GAN 生成器的结果。最简单的方法是视觉评估,它涉及在目标域和项目目标的背景下评估合成图像的质量。此外,已经提出了几种更复杂的评估方法,这些方法不太主观,并且不受领域知识的限制。有关详细调查,请参阅GAN 评估指标的优缺点:新发展arxiv.org/abs/2103.09396)。该论文总结了生成器评估为定性和定量措施。

有一个理论论点认为,训练生成器应该致力于最小化真实数据观察到的分布与合成示例观察到的分布之间的差异。因此,当使用交叉熵作为损失函数时,我们当前的架构性能不会非常好。

在下一小节中,我们将介绍 WGAN,它使用基于所谓的 Wasserstein-1(或地球运动者)距离的修改损失函数,用于改进训练性能。

两个分布之间的差异度量

我们首先会看到不同的度量方法来计算两个分布之间的差异。然后,我们将看到这些方法中哪些已经嵌入到原始 GAN 模型中。最后,通过在 GAN 中切换这种度量,我们将实现 WGAN 的实现。

正如本章开头提到的,生成模型的目标是学习如何合成具有与训练数据集分布相同分布的新样本。让P(x)和Q(x)代表随机变量x的分布,如下图所示。

首先,让我们看一些测量两个分布PQ之间差异的方法,如图 17.18所示:

图 17.18:测量分布PQ之间差异的方法

总变差(TV)度量中使用的上确界函数sup(S),指的是大于S中所有元素的最小值。换句话说,sup(S)是S的最小上界。相反,用于 EM 距离中的下确界函数inf(S),指的是小于S中所有元素的最大值(最大下界)。

让我们通过简单的话语来了解这些度量试图实现什么:

  • 第一个是总变差(TV)距离,它测量每个点处两个分布之间的最大差异。

  • EM 距离可以解释为将一个分布转换为另一个分布所需的最小工作量。在 EM 距离中,infimum 函数取自,这是所有边缘为PQ的联合分布的集合。然后,是一个转移计划,指示我们如何将地球从位置u转移到v,在进行这些转移后维持有效的分布约束条件。计算 EM 距离本身就是一个优化问题,即找到最优的转移计划

  • Kullback-LeiblerKL)和Jensen-ShannonJS)散度测量来自信息论领域。请注意,KL 散度不是对称的,即,,相比之下,JS 散度是对称的。

图 17.18中提供的不相似度方程对应于连续分布,但可以扩展到离散情况。一个计算两个简单离散分布的不同不相似度测量的示例如图 17.19所示:

图 17.19:计算不同不相似度测量的示例

请注意,在 EM 距离的情况下,对于这个简单的例子,我们可以看到在x=2 处,Q(x)具有超过的额外值,而在其他两个x的值则低于 1/3。因此,在这个简单的例子中,将额外值从x=2 转移到x=1 和x=3 会产生最小的工作量,如图 17.19所示。然而,在更复杂的情况下,这可能是不可行的。

KL 散度与交叉熵之间的关系

KL 散度,,测量分布P相对于参考分布Q的相对熵。KL 散度的表述可以扩展为:

此外,对于离散分布,KL 散度可以写成:

可以类似地扩展为:

基于扩展的表述(无论是离散还是连续),KL 散度被视为PQ之间的交叉熵(上述方程的第一项)减去P的(自)熵(第二项),即,

现在,回到我们对 GAN 的讨论,让我们看看这些不同的距离度量与 GAN 的损失函数之间的关系。可以在数学上证明,在原始 GAN 中的损失函数确实最小化了真实和虚假示例分布之间的 JS 散度。但是,正如马丁·阿尔乔夫斯基及其同事在一篇文章中讨论的那样(《Wasserstein 生成对抗网络》,proceedings.mlr.press/v70/arjovsky17a/arjovsky17a.pdf),JS 散度在训练 GAN 模型时存在问题,因此为了改善训练,研究人员提出使用 EM 距离作为衡量真实和虚假示例分布不相似性的度量。

使用 EM 距离的优势是什么?

要回答这个问题,我们可以考虑马丁·阿尔乔夫斯基及其同事在之前提到的文章中给出的一个例子。简单来说,假设我们有两个分布,PQ,它们是两条平行线。一条线固定在x = 0 处,另一条线可以沿着x轴移动,但最初位于,其中

可以证明 KL、TV 和 JS 差异度量分别为。这些差异度量都不是参数的函数,因此不能相对于进行微分,以使分布PQ相似。另一方面,EM 距离是,其相对于的梯度存在并可以将Q推向P

现在,让我们专注于如何使用 EM 距离来训练 GAN 模型。假设P[r]是真实示例的分布,P[g]表示虚假(生成的)示例的分布。P[r]和P[g]在 EM 距离方程中替代PQ。正如之前提到的,计算 EM 距离本身就是一个优化问题;因此,这在 GAN 训练循环的每次迭代中重复计算变得计算上难以处理。幸运的是,EM 距离的计算可以通过称为Kantorovich-Rubinstein 对偶的定理简化,如下所示:

这里,最高值取自所有1-Lipschitz连续函数,表示为

Lipschitz continuity

基于 1-Lipschitz 连续性,函数f必须满足以下性质:

此外,一个实函数,f:RR,满足以下性质

被称为K-Lipschitz 连续

在 GAN 实践中使用 EM 距离

现在的问题是,我们如何找到一个 1-Lipschitz 连续函数来计算 GAN 中真实(P[r])和虚假(P[g])输出分布之间的 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 性质进行权重修剪。然而,在另一篇名为Ishaan Gulrajani及同事的 2017 年的文章Improved Training of Wasserstein GANs中,可以免费获取arxiv.org/pdf/1704.00028.pdf,Ishaan Gulrajani 及同事指出,修剪权重可能导致梯度爆炸和消失。此外,权重修剪也可能导致能力未被充分利用,这意味着评论家网络仅限于学习一些简单的函数,而不是更复杂的函数。因此,Ishaan Gulrajani 及同事提出了梯度惩罚GP)作为替代解决方案。其结果是带有梯度惩罚的WGANWGAN-GP)。

在每次迭代中添加的 GP 的过程可以总结如下步骤:

  1. 对于给定批次中每对真实和虚假示例 ,选择从均匀分布中随机采样的随机数 ,即

  2. 计算真实和虚假例子之间的插值:,得到一批插值例子。

  3. 计算所有插值例子的鉴别器(评论者)输出,

  4. 计算评论者关于每个插值例子的输出的梯度,即

  5. 计算 GP 如下:

那么鉴别器的总损失如下:

在这里,是一个可调参数。

实施 WGAN-GP 来训练 DCGAN 模型

我们已经定义了创建 DCGAN 生成器和鉴别器网络的辅助函数和类(make_generator_network()Discriminator())。建议在 WGAN 中使用层归一化而不是批归一化。层归一化在特征维度上归一化输入,而不是在批次维度上。

>>> def make_generator_network_wgan(input_size, n_filters):
...     model = nn.Sequential(
...         nn.ConvTranspose2d(input_size, n_filters*4, 4,
...                            1, 0, bias=False),
...         nn.InstanceNorm2d(n_filters*4),
...         nn.LeakyReLU(0.2),
... 
...         nn.ConvTranspose2d(n_filters*4, n_filters*2,
...                            3, 2, 1, bias=False),
...         nn.InstanceNorm2d(n_filters*2),
...         nn.LeakyReLU(0.2),
... 
...         nn.ConvTranspose2d(n_filters*2, n_filters, 4,
...                            2, 1, bias=False),
...         nn.InstanceNorm2d(n_filters),
...         nn.LeakyReLU(0.2),
... 
...         nn.ConvTranspose2d(n_filters, 1, 4, 2, 1, bias=False),
...         nn.Tanh()
...     )
...     return model
>>> 
>>> class DiscriminatorWGAN(nn.Module):
...     def __init__(self, n_filters):
...         super().__init__()
...         self.network = nn.Sequential(
...             nn.Conv2d(1, n_filters, 4, 2, 1, bias=False),
...             nn.LeakyReLU(0.2),
... 
...             nn.Conv2d(n_filters, n_filters*2, 4, 2, 1,
...                       bias=False),
...             nn.InstanceNorm2d(n_filters * 2),
...             nn.LeakyReLU(0.2),
... 
...             nn.Conv2d(n_filters*2, n_filters*4, 3, 2, 1,
...                       bias=False),
...             nn.InstanceNorm2d(n_filters*4),
...             nn.LeakyReLU(0.2),
... 
...             nn.Conv2d(n_filters*4, 1, 4, 1, 0, bias=False),
...             nn.Sigmoid()
...     )
... 
...     def forward(self, input):
...         output = self.network(input)
...         return output.view(-1, 1).squeeze(0) 

现在我们可以按以下方式初始化网络及其优化器:

>>> gen_model = make_generator_network_wgan(
...     z_size, n_filters
... ).to(device)
>>> disc_model = DiscriminatorWGAN(n_filters).to(device)
>>> g_optimizer = torch.optim.Adam(gen_model.parameters(), 0.0002)
>>> d_optimizer = torch.optim.Adam(disc_model.parameters(), 0.0002) 

接下来,我们将定义计算 GP 组件的函数如下:

>>> from torch.autograd import grad as torch_grad
>>> def gradient_penalty(real_data, generated_data):
...     batch_size = real_data.size(0)
... 
...     # Calculate interpolation
...     alpha = torch.rand(real_data.shape[0], 1, 1, 1,
...                        requires_grad=True, device=device)
...     interpolated = alpha * real_data + \
...                    (1 - alpha) * generated_data
... 
...     # Calculate probability of interpolated examples
...     proba_interpolated = disc_model(interpolated)
... 
...     # Calculate gradients of probabilities
...     gradients = torch_grad(
...         outputs=proba_interpolated, inputs=interpolated,
...         grad_outputs=torch.ones(proba_interpolated.size(),
...                                 device=device),
...         create_graph=True, retain_graph=True
...     )[0]
... 
...     gradients = gradients.view(batch_size, -1)
...     gradients_norm = gradients.norm(2, dim=1)
...     return lambda_gp * ((gradients_norm - 1)**2).mean() 

WGAN 版本的鉴别器和生成器训练函数如下:

>>> def d_train_wgan(x):
...     disc_model.zero_grad()
... 
...     batch_size = x.size(0)
...     x = x.to(device)
... 
...     # Calculate probabilities on real and generated data
...     d_real = disc_model(x)
...     input_z = create_noise(batch_size, z_size, mode_z).to(device)
...     g_output = gen_model(input_z)
...     d_generated = disc_model(g_output)
...     d_loss = d_generated.mean() - d_real.mean() + \
...              gradient_penalty(x.data, g_output.data)
...     d_loss.backward()
...     d_optimizer.step()
...     return d_loss.data.item()
>>> 
>>> def g_train_wgan(x):
...     gen_model.zero_grad()
...     
...     batch_size = x.size(0)
...     input_z = create_noise(batch_size, z_size, mode_z).to(device)
...     g_output = gen_model(input_z)
...     
...     d_generated = disc_model(g_output)
...     g_loss = -d_generated.mean()
... 
...     # gradient backprop & optimize ONLY G's parameters
...     g_loss.backward()
...     g_optimizer.step()
...     return g_loss.data.item() 

然后我们将模型训练 100 个 epochs,并记录固定噪声输入的生成器输出:

>>> epoch_samples_wgan = []
>>> lambda_gp = 10.0
>>> num_epochs = 100
>>> torch.manual_seed(1)
>>> critic_iterations = 5
>>> for epoch in range(1, num_epochs+1):
...     gen_model.train()
...     d_losses, g_losses = [], []
...     for i, (x, _) in enumerate(mnist_dl):
...         for _ in range(critic_iterations):
...             d_loss = d_train_wgan(x)
...         d_losses.append(d_loss)
...         g_losses.append(g_train_wgan(x))
...     
...     print(f'Epoch {epoch:03d} | D Loss >>'
...           f' {torch.FloatTensor(d_losses).mean():.4f}')
...     gen_model.eval()
...     epoch_samples_wgan.append(
...         create_samples(
...             gen_model, fixed_z
...         ).detach().cpu().numpy()
...     ) 

最后,让我们在一些 epochs 时可视化保存的例子,看看 WGAN 模型如何学习以及合成例子的质量在学习过程中如何变化。下图显示了结果,显示出比 DCGAN 模型生成的图像质量稍好:

图 17.20:使用 WGAN 生成的图像

模式崩溃

由于 GAN 模型的对抗性质,训练它们非常困难。训练 GAN 失败的一个常见原因是生成器陷入了一个小的子空间并学会生成类似的样本。这被称为模式崩溃,并且在图 17.21中有一个例子。

这个图中的合成例子并非精选。这表明生成器未能学习整个数据分布,而是采取了一种懒惰的方法,集中在一个子空间上:

图 17.21:模式崩溃的示例

除了我们之前看到的梯度消失和梯度爆炸问题外,还有一些其他因素也会使得训练 GAN 模型变得困难(事实上,这是一门艺术)。以下是一些来自 GAN 艺术家的建议技巧。

一种方法称为 小批量区分,它基于一个事实:只有真实或虚假示例组成的批次被分别馈送给鉴别器。在小批量区分中,我们让鉴别器跨这些批次比较示例,以确定一个批次是真实还是虚假。如果模型遭遇模式崩溃,只有真实示例组成的批次的多样性很可能比虚假批次的多样性更高。

另一种常用于稳定 GAN 训练的技术是 特征匹配。在特征匹配中,我们通过向生成器的目标函数添加一个额外项,该项通过鉴别器的中间表示(特征图)来最小化原始图像与合成图像之间的差异。我们鼓励您阅读 王廷春 及其同事撰写的原始文章 High Resolution Image Synthesis and Semantic Manipulation with Conditional GANs,可在 arxiv.org/pdf/1711.11585.pdf 免费获取。

在训练过程中,GAN 模型可能会陷入多个模式中,并在它们之间跳跃。为了避免这种行为,您可以存储一些旧样本,并将它们馈送给鉴别器,以防止生成器重新访问先前的模式。这种技术被称为 经验回放。此外,您还可以使用不同的随机种子训练多个 GAN 模型,使它们的组合覆盖数据分布的更大部分,而不是任何单个模型能够覆盖的部分。

其他 GAN 应用

在本章中,我们主要关注使用 GAN 生成示例,并探讨了一些技巧和方法来提高合成输出的质量。GAN 的应用正在迅速扩展,包括计算机视觉、机器学习甚至其他科学和工程领域。您可以在 github.com/hindupuravinash/the-gan-zoo 找到一个不错的 GAN 模型和应用领域的列表。

值得一提的是,我们以无监督的方式讨论了 GAN;也就是说,在本章涵盖的模型中没有使用类标签信息。然而,GAN 方法可以推广到半监督和监督任务。例如,Mehdi MirzaSimon Osindero 在论文《Conditional Generative Adversarial Nets》(2014)中提出的条件 GANcGAN)使用类标签信息,并学习在给定标签条件下合成新图像,即,arxiv.org/pdf/1411.1784.pdf—应用于 MNIST。这使我们能够有选择地生成 0-9 范围内的不同数字。此外,条件 GAN 还允许进行图像到图像的转换,即学习如何将给定域中的图像转换到另一个域中。在这个背景下,一个有趣的工作是 Pix2Pix 算法,由 Philip Isola 和同事在 2018 年的论文《Image-to-Image Translation with Conditional Adversarial Networks》中发布(arxiv.org/pdf/1611.07004.pdf)。值得一提的是,在 Pix2Pix 算法中,鉴别器为图像中多个补丁提供真/假预测,而不是整个图像的单一预测。

CycleGAN 是另一个建立在 cGAN 之上的有趣的 GAN 模型,也用于图像到图像的转换。然而,请注意,在 CycleGAN 中,来自两个域的训练示例是不配对的,这意味着输入和输出之间没有一对一的对应关系。例如,使用 CycleGAN,我们可以将夏天拍摄的图片改变为冬天的景色。在 Jun-Yan Zhu 和同事于 2020 年的论文《Unpaired Image-to-Image Translation Using Cycle-Consistent Adversarial Networks》中展示了一个令人印象深刻的例子,展示了将马转换为斑马的过程(arxiv.org/pdf/1703.10593.pdf)。

总结

在本章中,您首先学习了深度学习中生成模型及其总体目标:合成新数据。然后,我们讨论了 GAN 模型如何使用生成器网络和鉴别器网络,在对抗训练设置中相互竞争以改进彼此。接下来,我们实现了一个简单的 GAN 模型,仅使用全连接层作为生成器和鉴别器。

我们还讨论了如何改进 GAN 模型。首先,您看到了 DCGAN,它使用深度卷积网络作为生成器和鉴别器。在此过程中,您还学习了两个新概念:转置卷积(用于上采样特征映射的空间维度)和 BatchNorm(用于在训练过程中改善收敛性)。

然后,我们看了一个 WGAN,它使用 EM 距离来衡量真实样本和假样本分布之间的距离。最后,我们讨论了带 GP 的 WGAN,以维持 1-Lipschitz 属性,而不是修剪权重。

在接下来的章节中,我们将探讨图神经网络。之前,我们专注于表格和图像数据集。相比之下,图神经网络是为图结构数据设计的,这使我们能够处理社会科学、工程学和生物学中普遍存在的数据集。图结构数据的流行示例包括社交网络图和由共价键连接的原子组成的分子。

加入我们书籍的 Discord 空间

加入书籍的 Discord 工作区,每月进行Ask me Anything与作者的会话:

packt.link/MLwPyTorch

第十八章:用于捕获图结构数据中依赖关系的图神经网络

在本章中,我们将介绍一类深度学习模型,它们操作的是图数据,即图神经网络GNNs)。近年来,GNNs 已经迅速发展。根据 2021 年的AI 现状报告www.stateof.ai/2021-report-launch.html),GNNs 已经从一种小众领域发展成为 AI 研究中最热门的领域之一。

GNNs 已被应用于多个领域,包括以下几个方面:

虽然我们无法涵盖这个快速发展空间中的每一个新想法,但我们将提供一个理解 GNNs 如何运作及如何实施它们的基础。此外,我们还将介绍PyTorch Geometric库,该库提供了管理图数据用于深度学习的资源,以及许多不同种类的图层实现,供您在深度学习模型中使用。

本章将涵盖的主题如下:

  • 图数据介绍及其如何在深度神经网络中表示和使用

  • 图卷积的解释,这是常见图神经网络(GNNs)的主要构建模块

  • 一个教程展示如何使用 PyTorch Geometric 实现用于分子属性预测的 GNNs

  • GNN 领域尖端方法概述

图数据介绍

广义来说,图表达了我们描述和捕捉数据关系的某种方式。图是一种非线性和抽象的数据结构。由于图是抽象对象,因此需要定义具体表示形式,以便对图进行操作。此外,图可以定义具有某些属性,这可能需要不同的表示形式。图 18.1总结了常见类型的图表,我们将在接下来的小节中详细讨论它们:

自动生成的图表描述

图 18.1:常见类型的图表

无向图

无向图由通过边连接的节点(在图论中通常称为顶点)组成,其中节点的顺序及其连接的顺序并不重要。图 18.2示意了两个典型的无向图示例,一个是朋友关系图,另一个是由化学键连接的原子组成的化学分子图(我们将在后续章节中详细讨论此类分子图):

自动生成的图表描述

图 18.2:两个无向图示例

可以表示为无向图的其他常见数据示例包括图像、蛋白质相互作用网络和点云。

从数学上讲,无向图G是一个二元组(VE),其中V是图的节点集合,E是构成节点对的边的集合。然后,可以将图编码为|V|×|V|的邻接矩阵 A。矩阵A中的每个元素x[ij]要么是 1,要么是 0,其中 1 表示节点ij之间有边(反之,0 表示没有边)。由于图是无向的,A的另一个特性是x[ij] = x[ji]。

有向图

有向图与前一节讨论的无向图相比,通过有向边连接节点。在数学上,它们的定义方式与无向图相同,除了边集E是有序对的集合。因此,矩阵A的元素x[ij]不一定等于x[ji]。

有向图的一个示例是引用网络,其中节点是出版物,从一个节点到另一个节点的边是指一篇给定论文引用的其他论文的节点。

自动生成的图示描述

图 18.3:一个有向图的示例

标记图

我们感兴趣的许多图形都与每个节点和边相关的附加信息有关。例如,如果考虑前面显示的咖啡因分子,分子可以被表示为图形,其中每个节点都是化学元素(例如,O、C、N 或 H 原子),每条边都是连接其两个节点的键的类型(例如,单键或双键)。这些节点和边的特征需要以某种方式进行编码。给定图形G,由节点集和边集元组(VE)定义,我们定义一个|Vf[V]节点特征矩阵X,其中f[V]是每个节点标签向量的长度。对于边标签,我们定义一个|Ef[E]边特征矩阵X[E],其中f[E]是每个边标签向量的长度。

分子是可以表示为标记图的数据的一个很好的例子,在本章中我们将一直使用分子数据。因此,我们将利用这个机会在下一节详细讨论它们的表示。

将分子表示为图形

作为化学概述,分子可以被看作由化学键结合在一起的原子组。有不同的原子对应于不同的化学元素,例如常见的元素包括碳(C)、氧(O)、氮(N)和氢(H)。此外,有不同类型的化学键形成原子之间的连接,例如单键或双键。

我们可以将分子表示为具有节点标签矩阵的无向图,其中每行是相关节点原子类型的 one-hot 编码。此外,还有一条边标签矩阵,其中每行是相关边键类型的 one-hot 编码。为了简化这种表示,有时会隐含氢原子,因为它们的位置可以通过基本化学规则推断出来。考虑之前看到的咖啡因分子,一个具有隐含氢原子图形表示的示例如 图 18.4 所示:

具有中等置信度自动生成的图表描述

图 18.4:咖啡因分子的图形表示

理解图卷积

前一节展示了如何表示图数据。下一个逻辑步骤是讨论我们可以有效利用这些表示的工具。

在接下来的小节中,我们将介绍图卷积,它是构建 GNNs 的关键组成部分。在本节中,我们将看到为什么要在图上使用卷积,并讨论我们希望这些卷积具有哪些属性。然后,我们将通过一个实现示例介绍图卷积。

使用图卷积的动机

为了帮助解释图卷积,让我们简要回顾卷积神经网络(CNNs)中如何使用卷积,我们在 第十四章 使用深度卷积神经网络分类图像 中讨论过。在图像的上下文中,我们可以将卷积视为将卷积滤波器在图像上滑动的过程,其中每一步都在滤波器和接受域(它当前所在的图像部分)之间计算加权和。

如在 CNN 章节中讨论的那样,滤波器可以看作是特定特征的检测器。这种特征检测方法对图像非常适用,原因有几点,例如我们可以对图像数据施加以下先验:

  1. 平移不变性:我们可以在图像中任意位置识别特征(例如平移后)。猫可以被识别为猫,无论它位于图像的左上角、右下角还是其他部位。

  2. 局部性:附近的像素是密切相关的。

  3. 层次结构:图像的较大部分通常可以分解为相关较小部分的组合。猫有头和腿;头部有眼睛和鼻子;眼睛有瞳孔和虹膜。

对于对这些先验和 GNNs 所假设的先验有更正式描述感兴趣的读者,可以参考 2019 年文章 理解图神经网络在学习图拓扑中的表示能力,作者是 N. DehmamyA.-L. BarabasiR. Yu (arxiv.org/abs/1907.05008)。

另一个卷积适合处理图像的原因是可训练参数的数量不依赖于输入的维度。例如,你可以在 256×256 或 9×9 的图像上训练一系列 3×3 的卷积滤波器。(然而,如果同一图像以不同分辨率呈现,则感受野和因此提取的特征将不同。对于更高分辨率的图像,我们可能希望选择更大的核或添加额外的层以有效提取有用的特征。)

像图像一样,图也有自然的先验,可以证明卷积方法的合理性。图像数据和图数据都共享局部性先验。但是,我们如何定义局部性是不同的。在图像中,局部性是在二维空间中定义的,而在图中,它是结构上的局部性。直觉上,这意味着距离一个边的节点更有可能相关,而距离五个边的节点不太可能相关。例如,在引文图中,直接引用的出版物,即距离一个边的出版物,更有可能与相似主题的出版物相关,而与多度分离的出版物则不太相关。

图数据的一个严格先验是置换不变性,这意味着节点的排序不会影响输出。这在图 18.5中有所说明,改变图的节点排序不会改变图的结构:

包含图表的图片的描述 自动生成

图 18.5:表示同一图的不同邻接矩阵

由于同一图可以用多个邻接矩阵表示,正如图 18.5所示,因此任何图卷积都需要是置换不变的。

对于图来说,卷积方法也是理想的,因为它可以使用固定的参数集处理不同大小的图。这个性质对于图而言可能比图像更为重要。例如,有许多具有固定分辨率的图像数据集,可以使用全连接方法(例如使用多层感知机),正如我们在第十一章从头开始实现多层人工神经网络中所见。相反,大多数图数据集包含不同大小的图。

虽然图像卷积运算符是标准化的,但是图卷积有许多不同种类,并且新图卷积的开发是一个非常活跃的研究领域。我们的重点是提供一般的思路,以便读者可以理解他们希望利用的图神经网络。为此,接下来的小节将展示如何在 PyTorch 中实现基本的图卷积。然后,在下一节中,我们将从头开始在 PyTorch 中构建一个简单的 GNN。

实现基本的图卷积

在本小节中,我们将介绍一个基本的图卷积函数,并看看当它应用于一个图时会发生什么。考虑以下图及其表示:

图表,线图  自动生成描述

图 18.6: 图的表示

图 18.6 描述了一个无向图,节点标签由 n×n 邻接矩阵 An×f[in] 节点特征矩阵 X 指定,其中唯一的特征是每个节点的颜色的 one-hot 表示—绿色(G)、蓝色(B)或橙色(O)。

图形处理和可视化中最多功能的库之一是 NetworkX,我们将使用它来说明如何从标签矩阵 X 和节点矩阵 A 构建图形。

安装 NetworkX

NetworkX 是一个方便的 Python 库,用于处理和可视化图形。可以通过 pip 安装:

pip install networkx 

我们使用版本 2.6.2 来创建本章中的图形可视化。更多信息,请访问官方网站 networkx.org

使用 NetworkX,我们可以按如下方式构造图形:

>>> import numpy as np
>>> import networkx as nx
>>> G = nx.Graph()
... # Hex codes for colors if we draw graph
>>> blue, orange, green = "#1f77b4", "#ff7f0e", "#2ca02c"
>>> G.add_nodes_from([
...     (1, {"color": blue}),
...     (2, {"color": orange}),
...     (3, {"color": blue}),
...     (4, {"color": green})
... ])
>>> G.add_edges_from([(1,2), (2,3), (1,3), (3,4)])
>>> A = np.asarray(nx.adjacency_matrix(G).todense())
>>> print(A)
[[0 1 1 0]
[1 0 1 0]
[1 1 0 1]
[0 0 1 0]]
>>> def build_graph_color_label_representation(G, mapping_dict):
...     one_hot_idxs = np.array([mapping_dict[v] for v in
...         nx.get_node_attributes(G, 'color').values()])
>>>     one_hot_encoding = np.zeros(
...         (one_hot_idxs.size, len(mapping_dict)))
>>>     one_hot_encoding[
...         np.arange(one_hot_idxs.size), one_hot_idxs] = 1
>>>     return one_hot_encoding
>>> X = build_graph_color_label_representation(
...     G, {green: 0, blue: 1, orange: 2})
>>> print(X)
[[0., 1., 0.],
[0., 0., 1.],
[0., 1., 0.],
[1., 0., 0.]] 

要绘制前述代码构造的图形,我们可以使用以下代码:

>>> color_map = nx.get_node_attributes(G, 'color').values()
>>> nx.draw(G,with_labels=True, node_color=color_map) 

在上述代码示例中,我们首先从 NetworkX 初始化了一个新的 Graph 对象。然后,我们添加了节点 1 到 4,并指定了用于可视化的颜色规范。在添加节点后,我们指定了它们的连接(边)。使用 NetworkX 的 adjacency_matrix 构造函数,我们创建了邻接矩阵 A,并且我们的自定义 build_graph_color_label_representation 函数从我们之前添加到 Graph 对象的信息创建了节点标签矩阵 X

通过图卷积,我们可以将 X 的每一行解释为存储在相应节点上的信息的嵌入。图卷积根据其邻居节点和自身的嵌入更新每个节点的嵌入。对于我们的示例实现,图卷积将采用以下形式:

在这里, 是节点 i 的更新嵌入;W[1] 和 W[2] 是 f[in]×f[out] 的可学习滤波器权重矩阵;b 是长度为 f[out] 的可学习偏置向量。

两个权重矩阵 W[1] 和 W[2] 可以被视为滤波器组,其中每一列都是一个单独的滤波器。请注意,当图数据上的局部性先验成立时,这种滤波器设计是最有效的。如果一个节点的值与另一个节点的值高度相关,而这两个节点之间存在许多边,单个卷积将无法捕捉到这种关系。堆叠卷积将捕捉更远的关系,如图 18.7 所示(为简化起见,我们将偏置设为零):

图表  自动生成描述

图 18.7: 从图中捕获关系

图 18.7 中展示的图卷积的设计符合我们对图数据的先验假设,但如何以矩阵形式实现邻居节点之间的求和可能不是很明确。这就是我们利用邻接矩阵 A 的地方。这个卷积的矩阵形式是 XW[1] + AXW[2]。在 NumPy 中,初始化这一层并在前一个图上进行前向传播可以写成如下形式:

>>> f_in, f_out = X.shape[1], 6
>>> W_1 = np.random.rand(f_in, f_out)
>>> W_2 = np.random.rand(f_in, f_out)
>>> h = np.dot(X, W_1)+ np.dot(np.dot(A,X), W_2) 

计算图卷积的前向传播就是这么简单。

最终,我们希望图卷积层通过利用由 A 提供的结构信息(连接性)来更新编码在 X 中的节点信息的表示。有许多潜在的方法可以做到这一点,这在已开发的许多类型的图卷积中体现出来。

要讨论不同的图卷积,通常最好是它们具有一个统一的框架。幸运的是,Justin Gilmer和他的同事在 2017 年的《神经信息传递用于量子化学》中提出了这样一个框架,arxiv.org/abs/1704.01212

在这个消息传递框架中,图中的每个节点都有一个关联的隐藏状态 ,其中 i 是节点在时间步 t 的索引。初始值 定义为 X[i],即与节点 i 相关的 X 的行。

每个图卷积可以分为消息传递阶段和节点更新阶段。设 N(i) 为节点 i 的邻居。对于无向图,N(i) 是与节点 i 共享边的节点集合。对于有向图,N(i) 是具有以节点 i 为端点的边的节点集合。消息传递阶段可以表述如下:

在这里,M[t] 是一个消息函数。在我们的示例层中,我们将此消息函数定义为 。使用更新函数 U[t] 的节点更新阶段为 。在我们的示例层中,此更新为

图 18.8 展示了消息传递的思想并总结了我们实现的卷积:

自动生成的图示说明

图 18.8:在图上实现的卷积和消息形式

在下一节中,我们将把这个图卷积层整合到一个在 PyTorch 中实现的 GNN 模型中。

在 PyTorch 中从零开始实现 GNN

上一节重点介绍了理解和实现图卷积操作。在本节中,我们将通过一个基本的图神经网络实现来演示如何从头开始应用这些方法到图形中。如果这种方法看起来复杂,不用担心;GNN 是相对复杂的模型。因此,我们将在后面的章节中介绍 PyTorch Geometric,它提供了工具来简化图神经网络的实现和数据管理。

定义 NodeNetwork 模型

我们将从头开始展示一个 PyTorch 实现的 GNN 的部分。我们将采取自顶向下的方法,从主神经网络模型开始,我们称之为NodeNetwork,然后填充具体细节:

import networkx as nx
import torch
from torch.nn.parameter import Parameter
import numpy as np
import math
import torch.nn.functional as F
class NodeNetwork(torch.nn.Module):
    def __init__(self, input_features):
        super().__init__()
        self.conv_1 = BasicGraphConvolutionLayer (
            input_features, 32)
        self.conv_2 = BasicGraphConvolutionLayer(32, 32)
        self.fc_1 = torch.nn.Linear(32, 16)
        self.out_layer = torch.nn.Linear(16, 2)
    def forward(self, X, A, batch_mat):
        x = F.relu(self.conv_1(X, A))
        x = F.relu(self.conv_2(x, A))
        output = global_sum_pool(x, batch_mat)
        output = self.fc_1(output)
        output = self.out_layer(output)
        return F.softmax(output, dim=1) 

我们刚刚定义的NodeNetwork模型可以总结如下:

  1. 执行两个图卷积(self.conv_1self.conv_2

  2. 通过global_sum_pool汇总所有节点嵌入,稍后我们将定义

  3. 通过两个全连接层(self.fc_1self.out_layer)运行池化嵌入

  4. 通过 softmax 输出类成员概率

网络结构以及每个层所做的可视化总结在Figure 18.9中:

Diagram  Description automatically generated

图 18.9: 每个神经网络层的可视化

各个方面,如图卷积层和全局池化,将在接下来的小节中讨论。

编码 NodeNetwork 的图卷积层

现在,让我们定义图卷积操作(BasicGraphConvolutionLayer),这在之前的NodeNetwork类中使用过:

class BasicGraphConvolutionLayer(torch.nn.Module):
    def __init__(self, in_channels, out_channels):
        super().__init__()
        self.in_channels = in_channels
        self.out_channels = out_channels
        self.W2 = Parameter(torch.rand(
            (in_channels, out_channels), dtype=torch.float32))
        self.W1 = Parameter(torch.rand(
            (in_channels, out_channels), dtype=torch.float32))

        self.bias = Parameter(torch.zeros(
                out_channels, dtype=torch.float32))
    def forward(self, X, A):
        potential_msgs = torch.mm(X, self.W2)
        propagated_msgs = torch.mm(A, potential_msgs)
        root_update = torch.mm(X, self.W1)
        output = propagated_msgs + root_update + self.bias
        return output 

与全连接层和图像卷积层一样,我们添加偏置项,以便调整层输出的线性组合的截距(在应用非线性如 ReLU 之前)。forward()方法实现了前向传播的矩阵形式,我们在前一小节中讨论过,同时加入了一个偏置项。

要尝试BasicGraphConvolutionLayer,让我们将其应用到我们之前在实现基本图卷积节中定义的图和邻接矩阵上:

>>> print('X.shape:', X.shape)X.shape: (4, 3)
>>> print('A.shape:', A.shape)
A.shape: (4, 4)
>>> basiclayer = BasicGraphConvolutionLayer(3, 8)
>>> out = basiclayer(
...     X=torch.tensor(X, dtype=torch.float32),
...     A=torch.tensor(A, dtype=torch.float32)
... )
>>> print('Output shape:', out.shape)
Output shape: torch.Size([4, 8]) 

基于上述代码示例,我们可以看到我们的BasicGraphConvolutionLayer将由三个特征组成的四节点图转换为具有八个特征的表示形式。

添加全局池化层以处理不同大小的图

接下来,我们定义了在NodeNetwork类中使用的global_sum_pool()函数,其中global_sum_pool()实现了一个全局池化层。全局池化层将图的所有节点嵌入聚合为一个固定大小的输出。如图图 18.9所示,global_sum_pool()对图的所有节点嵌入求和。我们注意到,这种全局池化与 CNN 中使用的全局平均池化相对类似,后者在数据通过全连接层之前使用,正如我们在第十四章中看到的,用深度卷积神经网络对图像进行分类

对所有节点嵌入求和会导致信息丢失,因此更好的做法是重新整形数据,但由于图可以具有不同的大小,这是不可行的。全局池化可以使用任何排列不变的函数,例如summaxmean。这里是global_sum_pool()的实现:

def global_sum_pool(X, batch_mat):
    if batch_mat is None or batch_mat.dim() == 1:
        return torch.sum(X, dim=0).unsqueeze(0)
    else:
        return torch.mm(batch_mat, X) 

如果数据没有批处理或批处理大小为一,此函数只是对当前节点嵌入求和。否则,嵌入将与batch_mat相乘,其结构基于图数据的批处理方式。

当数据集中的所有数据具有相同的维度时,批处理数据就像通过堆叠数据添加一个维度一样简单。(附注:在 PyTorch 中默认的批处理函数中调用的函数实际上称为stack。)由于图的大小各不相同,除非使用填充,否则这种方法在处理图数据时是不可行的。然而,在图的大小差异显著时,填充可能效率低下。通常,处理不同大小的图的更好方法是将每个批次视为单个图,其中每个批次中的图是与其余图断开连接的子图。这在图 18.10中有所说明:

Diagram  Description automatically generated

图 18.10: 处理不同大小的图的方法

为了更正式地描述图 18.10,假设我们有大小为n[1],...,n[k]的图G[1],...,G[k],每个节点有f个特征。此外,我们还有相应的邻接矩阵A[1],...,A[k]和特征矩阵X[1],...,X[k]。设N为节点的总数,s[1] = 0,且对于s[i] = s[i-1] + n[i-1]。如图所示,我们定义了一个具有N×N邻接矩阵A[B]和N×f特征矩阵X[B]的图G[B]。使用 Python 索引表示,A[B][s[i]:s[i]+n[i], s[i]:s[i]+n[i]] = A[i],并且A[B]的其他元素在这些索引集之外都是 0。此外,X[B][s[i]:s[i]+n[i], :] = X[i]。

根据设计,断开连接的节点永远不会在图卷积的同一接收场中。因此,当通过图卷积反向传播G[B]的梯度时,批次中每个图附加的梯度将是独立的。这意味着,如果我们将一组图卷积视为函数f,如果h[B] = f(X[B], A[B])和h[i] = f(X[i], A[i]),那么h[B][s[i]:s[i] + n, :] = h[i]。如果总和全局池从h[B]中提取h[i]的各自向量的总和,并通过完全连接的层传递该向量堆栈,则在整个反向传播过程中将保持批次中每个项目的梯度独立。

这就是global_sum_pool()batch_mat的目的——作为一个图选择掩码,用于保持批次中的图分开。我们可以使用以下代码为大小为n[1], ..., n[k]的图生成此掩码:

def get_batch_tensor(graph_sizes):
    starts = [sum(graph_sizes[:idx])
              for idx in range(len(graph_sizes))]
    stops = [starts[idx] + graph_sizes[idx]
             for idx in range(len(graph_sizes))]
    tot_len = sum(graph_sizes)
    batch_size = len(graph_sizes)
    batch_mat = torch.zeros([batch_size, tot_len]).float()
    for idx, starts_and_stops in enumerate(zip(starts, stops)):
        start = starts_and_stops[0]
        stop = starts_and_stops[1]
        batch_mat[idx,start:stop] = 1
    return batch_mat 

因此,给定批次大小bbatch_mat是一个b×N矩阵,其中 batch_mat[i–1, s[i]:s[i] + n[i]] = 1 对于,并且在这些索引集之外的元素为 0。以下是构建某些G[B]表示和相应的批次矩阵的整理函数:

# batch is a list of dictionaries each containing
# the representation and label of a graph
def collate_graphs(batch):
    adj_mats = [graph['A'] for graph in batch]
    sizes = [A.size(0) for A in adj_mats]
    tot_size = sum(sizes)
    # create batch matrix
    batch_mat = get_batch_tensor(sizes)
    # combine feature matrices
    feat_mats = torch.cat([graph['X'] for graph in batch], dim=0)
    # combine labels
    labels = torch.cat([graph['y'] for graph in batch], dim=0)
    # combine adjacency matrices
    batch_adj = torch.zeros([tot_size, tot_size], dtype=torch.float32)
    accum = 0
    for adj in adj_mats:
        g_size = adj.shape[0]
        batch_adj[accum:accum+g_size,accum:accum+g_size] = adj
        accum = accum + g_size
    repr_and_label = {'A': batch_adj,
            'X': feat_mats, 'y': labels,
            'batch': batch_mat}
    return repr_and_label 

准备 DataLoader

在本节中,我们将看到前面各小节中的代码如何结合在一起。首先,我们将生成一些图并将它们放入 PyTorch Dataset中。然后,我们将在我们的 GNN 中使用collate函数在DataLoader中使用它。

但在我们定义图之前,让我们实现一个函数来构建一个字典表示,稍后我们将使用它:

def get_graph_dict(G, mapping_dict):
    # Function builds dictionary representation of graph G
    A = torch.from_numpy(
        np.asarray(nx.adjacency_matrix(G).todense())).float()
    # build_graph_color_label_representation()
    # was introduced with the first example graph
    X = torch.from_numpy(
      build_graph_color_label_representation(
               G, mapping_dict)).float()
    # kludge since there is not specific task for this example
    y = torch.tensor([[1,0]]).float()
    return {'A': A, 'X': X, 'y': y, 'batch': None} 

这个函数接受一个 NetworkX 图,并返回一个包含其邻接矩阵A,节点特征矩阵X和一个二进制标签y的字典。由于我们实际上不会在真实任务中训练这个模型,所以我们只是任意设置标签。然后,nx.adjacency_matrix()接受一个 NetworkX 图并返回一个稀疏表示,我们使用todense()将其转换为稠密的np.array形式。

现在我们将构造图,并使用get_graph_dict函数将 NetworkX 图转换为我们的网络可以处理的格式:

>>> # building 4 graphs to treat as a dataset
>>> blue, orange, green = "#1f77b4", "#ff7f0e","#2ca02c"
>>> mapping_dict= {green:0, blue:1, orange:2}
>>> G1 = nx.Graph()
>>> G1.add_nodes_from([
...     (1,{"color": blue}),
...     (2,{"color": orange}),
...     (3,{"color": blue}),
...     (4,{"color": green})
... ])
>>> G1.add_edges_from([(1, 2), (2, 3), (1, 3), (3, 4)])
>>> G2 = nx.Graph()
>>> G2.add_nodes_from([
...     (1,{"color": green}),
...     (2,{"color": green}),
...     (3,{"color": orange}),
...     (4,{"color": orange}),
...     (5,{"color": blue})
... ])
>>> G2.add_edges_from([(2, 3),(3, 4),(3, 1),(5, 1)])
>>> G3 = nx.Graph()
>>> G3.add_nodes_from([
...     (1,{"color": orange}),
...     (2,{"color": orange}),
...     (3,{"color": green}),
...     (4,{"color": green}),
...     (5,{"color": blue}),
...     (6,{"color":orange})
... ])
>>> G3.add_edges_from([(2,3), (3,4), (3,1), (5,1), (2,5), (6,1)])
>>> G4 = nx.Graph()
>>> G4.add_nodes_from([
...     (1,{"color": blue}),
...     (2,{"color": blue}),
...     (3,{"color": green})
... ])
>>> G4.add_edges_from([(1, 2), (2, 3)])
>>> graph_list = [get_graph_dict(graph, mapping_dict) for graph in
...     [G1, G2, G3, G4]] 

此代码生成的图在Figure 18.11中可视化:

图 18.11:生成的四个图

这段代码块构造了四个 NetworkX 图并将它们存储在一个列表中。在这里,nx.Graph()的构造函数初始化了一个空图,而add_nodes_from()从一个元组列表中将节点添加到空图中。每个元组中的第一个项目是节点的名称,第二个项目是该节点属性的字典。

图的add_edges_from()方法接受一个元组列表,其中每个元组定义其元素(节点)之间的边。现在,我们可以为这些图构建一个 PyTorch Dataset

from torch.utils.data import Dataset
class ExampleDataset(Dataset):
    # Simple PyTorch dataset that will use our list of graphs
    def __init__(self, graph_list):
        self.graphs = graph_list
    def __len__(self):
        return len(self.graphs)
    def __getitem__(self,idx):
        mol_rep = self.graphs[idx]
        return mol_rep 

尽管使用自定义Dataset可能看起来是不必要的工作,但它允许我们展示如何在DataLoader中使用collate_graphs()

>>> from torch.utils.data import DataLoader
>>> dset = ExampleDataset(graph_list)
>>> # Note how we use our custom collate function
>>> loader = DataLoader(
...     dset, batch_size=2, shuffle=False,
...     collate_fn=collate_graphs) 

使用 NodeNetwork 进行预测

在我们定义了所有必要的函数并设置了 DataLoader 后,我们现在初始化一个新的 NodeNetwork 并将其应用于我们的图数据:

>>> node_features = 3
>>> net = NodeNetwork(node_features)
>>> batch_results = []
>>> for b in loader:
...     batch_results.append(
...         net(b['X'], b['A'], b['batch']).detach()) 

注意,为简洁起见,我们没有包括训练循环;然而,可以通过计算预测和真实类标签之间的损失,通过 .backward() 反向传播损失,并通过基于梯度下降的优化器更新模型权重来以常规方式训练 GNN 模型。我们将此留作读者的可选练习。在下一节中,我们将展示如何使用 PyTorch Geometric 实现 GNN,该库实现了更复杂的 GNN 代码。

要继续我们之前的代码,现在让我们直接向模型提供一个单一的输入图,而不使用 DataLoader

>>> G1_rep = dset[1]
>>> G1_single = net(
...     G1_rep['X'], G1_rep['A'], G1_rep['batch']).detach() 

现在,我们可以比较将 GNN 应用于单个图 (G1_single) 和从 DataLoader 中获取的第一个图(也就是第一个图 G1,因为我们设置了 shuffle=False),以确保批处理加载器工作正常。通过使用 torch.isclose()(以考虑四舍五入误差),我们可以看到结果是等价的,这是我们希望看到的:

>>> G1_batch = batch_results[0][1]
>>> torch.all(torch.isclose(G1_single, G1_batch))
tensor(True) 

恭喜!现在您了解如何构建、设置和运行基本的 GNN。但是,从本介绍中,您可能意识到管理和操作图数据可能有些繁琐。而且,我们甚至没有构建使用边标签的图卷积,这将进一步复杂化事务。幸运的是,PyTorch Geometric 提供了许多 GNN 层的实现,使这一切变得更加简单。在下一小节中,我们将通过在分子数据上实现和训练更复杂的 GNN 的端到端示例来介绍这个库。

使用 PyTorch Geometric 库实现 GNN

在本节中,我们将使用 PyTorch Geometric 库实现 GNN,该库简化了训练 GNN 的过程。我们将 GNN 应用于 QM9 数据集,该数据集由小分子组成,以预测各向同性极化率,这是分子在电场中电荷畸变倾向的一种度量。

安装 PyTorch Geometric

可以通过 conda 或 pip 安装 PyTorch Geometric。我们建议您访问官方文档网站 pytorch-geometric.readthedocs.io/en/latest/notes/installation.html 选择适合您操作系统的安装命令。在本章中,我们使用 pip 安装了版本 2.0.2 以及其 torch-scattertorch-sparse 依赖:

pip install torch-scatter==2.0.9
pip install torch-sparse==0.6.12
pip install torch-geometric==2.0.2 

让我们从加载小分子数据集开始,并看看 PyTorch Geometric 如何存储数据:

>>> # For all examples in this section we use the following imports.
>>> # Note that we are using torch_geometric's DataLoader.
>>> import torch
>>> from torch_geometric.datasets import QM9
>>> from torch_geometric.loader import DataLoader
>>> from torch_geometric.nn import NNConv, global_add_pool
>>> import torch.nn.functional as F
>>> import torch.nn as nn
>>> import numpy as np
>>> # let's load the QM9 small molecule dataset
>>> dset = QM9('.')
>>> len(dset)
130831
>>> # Here's how torch geometric wraps data
>>> data = dset[0]
>>> data
Data(edge_attr=[8, 4], edge_index=[2, 8], idx=[1], name="gdb_1", pos=[5, 3], x=[5, 11], y=[1, 19], z=[5])
>>> # can access attributes directly
>>> data.z
tensor([6, 1, 1, 1, 1])
>>> # the atomic number of each atom can add attributes
>>> data.new_attribute = torch.tensor([1, 2, 3])
>>> data
Data(edge_attr=[8, 4], edge_index=[2, 8], idx=[1], name="gdb_1", new_attribute=[3], pos=[5, 3], x=[5, 11], y=[1, 19], z=[5])
>>> # can move all attributes between devices
>>> device = torch.device(
...     "cuda:0" if torch.cuda.is_available() else "cpu"
... )
>>> data.to(device)
>>> data.new_attribute.is_cuda
True 

Data对象是图数据的方便、灵活的包装器。请注意,许多 PyTorch Geometric 对象要求数据对象中包含某些关键字才能正确处理它们。具体来说,x应包含节点特征,edge_attr应包含边特征,edge_index应包括边列表,而y应包含标签。QM9 数据还包含一些值得注意的附加属性:pos,分子中每个原子在 3D 网格中的位置,以及z,分子中每个原子的原子序数。QM9 中的标签是分子的一些物理属性,如偶极矩、自由能、焓或各向同性极化率。我们将实现一个 GNN,并在 QM9 上训练它来预测各向同性极化率。

QM9 数据集

QM9 数据集包含 133,885 个小有机分子,标有几何、能量、电子和热力学性质。 QM9 是开发预测化学结构-性质关系和混合量子力学/机器学习方法的常见基准数据集。有关数据集的更多信息,请访问quantum-machine.org/datasets/

分子的键类型很重要;即通过某种键类型连接的哪些原子,例如单键或双键,都很重要。因此,我们将使用能够利用边特征的图卷积,例如torch_geometric.nn.NNConv层。 (如果您对实现细节感兴趣,可以在pytorch-geometric.readthedocs.io/en/latest/_modules/torch_geometric/nn/conv/nn_conv.html#NNConv找到其源代码。)

NNConv层中,这种卷积采用以下形式:

在这里,h是由一组权重参数化的神经网络,而W是节点标签的权重矩阵。这种图卷积与我们之前从头实现的非常相似:

唯一的实际区别在于等效的W[2],即神经网络h,是基于边标签参数化的,这允许权重因不同的边标签而变化。通过以下代码,我们实现了一个使用两个这样的图卷积层(NNConv)的 GNN:

class ExampleNet(torch.nn.Module):
    def __init__(self, num_node_features, num_edge_features):
        super().__init__()
        conv1_net = nn.Sequential(
            nn.Linear(num_edge_features, 32),
            nn.ReLU(),
            nn.Linear(32, num_node_features*32))
        conv2_net = nn.Sequential(
            nn.Linear(num_edge_features, 32),
            nn.ReLU(),
            nn.Linear(32, 32*16))
        self.conv1 = NNConv(num_node_features, 32, conv1_net)
        self.conv2 = NNConv(32,16, conv2_net)
        self.fc_1 = nn.Linear(16, 32)
        self.out = nn.Linear(32, 1)
    def forward(self, data):
        batch, x, edge_index, edge_attr = (
            data.batch, data.x, data.edge_index, data.edge_attr)
        # First graph conv layer
        x = F.relu(self.conv1(x, edge_index, edge_attr))
        # Second graph conv layer
        x = F.relu(self.conv2(x, edge_index, edge_attr))
        x = global_add_pool(x,batch)
        x = F.relu(self.fc_1(x))
        output = self.out(x)
        return output 

我们将训练这个 GNN 来预测分子的各向同性极化率,这是衡量分子电荷分布相对于外部电场扭曲倾向的相对指标。我们将把 QM9 数据集分为训练、验证和测试集,并使用 PyTorch Geometric DataLoader。请注意,这些不需要特殊的整理函数,但需要一个具有适当命名属性的Data对象。

接下来,让我们分割数据集:

>>> from torch.utils.data import random_split
>>> train_set, valid_set, test_set = random_split(
...     dset,[110000, 10831, 10000])
>>> trainloader = DataLoader(train_set, batch_size=32, shuffle=True)
>>> validloader = DataLoader(valid_set, batch_size=32, shuffle=True)
>>> testloader = DataLoader(test_set, batch_size=32, shuffle=True) 

下面的代码将在 GPU 上初始化并训练网络(如果可用):

>>> # initialize a network
>>> qm9_node_feats, qm9_edge_feats = 11, 4
>>> net = ExampleNet(qm9_node_feats, qm9_edge_feats)
>>> # initialize an optimizer with some reasonable parameters
>>> optimizer = torch.optim.Adam(
...     net.parameters(), lr=0.01)
>>> epochs = 4
>>> target_idx = 1 # index position of the polarizability label
>>> device = torch.device("cuda:0" if
...                       torch.cuda.is_available() else "cpu")
>>> net.to(device) 

训练循环如下面的代码所示,遵循我们在前几个 PyTorch 章节中遇到的熟悉模式,因此我们可以跳过详细的解释。然而,值得强调的一个细节是,这里我们计算的是均方误差(MSE)损失,而不是交叉熵,因为极化率是一个连续的目标,而不是一个类标签。

>>> for total_epochs in range(epochs):
...     epoch_loss = 0
...     total_graphs = 0
...     net.train()
...     for batch in trainloader:
...         batch.to(device)
...         optimizer.zero_grad()
...         output = net(batch)
...         loss = F.mse_loss(
...             output,batch.y[:, target_idx].unsqueeze(1))
...         loss.backward()
...         epoch_loss += loss.item()
...         total_graphs += batch.num_graphs
...         optimizer.step()
...     train_avg_loss = epoch_loss / total_graphs
...     val_loss = 0
...     total_graphs = 0
...     net.eval()
...     for batch in validloader:
...         batch.to(device)
...         output = net(batch)
...         loss = F.mse_loss(
...             output,batch.y[:, target_idx].unsqueeze(1))
...         val_loss += loss.item()
...         total_graphs += batch.num_graphs
...     val_avg_loss = val_loss / total_graphs
...     print(f"Epochs: {total_epochs} | "
...           f"epoch avg. loss: {train_avg_loss:.2f} | "
...           f"validation avg. loss: {val_avg_loss:.2f}")
Epochs: 0 | epoch avg. loss: 0.30 | validation avg. loss: 0.10
Epochs: 1 | epoch avg. loss: 0.12 | validation avg. loss: 0.07
Epochs: 2 | epoch avg. loss: 0.10 | validation avg. loss: 0.05
Epochs: 3 | epoch avg. loss: 0.09 | validation avg. loss: 0.07 

在前四个训练时期,训练和验证损失都在减少。数据集很大,在 CPU 上训练可能需要一点时间,因此我们在四个时期后停止训练。但是,如果进一步训练模型,损失将继续改善。您可以继续训练模型以查看如何改进性能。

下面的代码预测了测试数据上的值并收集了真实标签:

>>> net.eval()
>>> predictions = []
>>> real = []
>>> for batch in testloader:
...     output = net(batch.to(device))
...     predictions.append(output.detach().cpu().numpy())
...     real.append(
...             batch.y[:,target_idx] .detach().cpu().numpy())
>>> real = np.concatenate(real)
>>> predictions = np.concatenate(predictions) 

现在我们可以用测试数据的子集制作散点图。由于测试数据集相对较大(10,000 个分子),结果可能有些混乱,为简单起见,我们仅绘制前 500 个预测和目标:

>>> import matplotlib.pyplot as plt
>>> plt.scatter(real[:500], predictions[:500])
>>> plt.xlabel('Isotropic polarizability')
>>> plt.ylabel('Predicted isotropic polarizability') 

结果的图示如下:

图表,散点图 自动生成描述

图 18.12:预测的各向同性极化率与实际各向同性极化率的图示

根据图,考虑到点相对靠近对角线,我们简单的 GNN 似乎在预测各向同性极化值时表现不错,即使没有超参数调整。

TorchDrug – 基于 PyTorch 的药物发现库

PyTorch Geometric 是一个全面的通用图形库,用于处理图形,包括分子,正如您在本节中看到的。如果您对更深入的分子工作和药物发现感兴趣,我们还建议考虑最近开发的 TorchDrug 库,该库提供了许多方便的工具来处理分子。您可以在这里了解更多关于 TorchDrug 的信息:torchdrug.ai/

其他 GNN 层和最新发展

本节将介绍一些您可以在 GNN 中使用的额外层次,此外还将提供该领域最新发展的高层次概述。虽然我们将为这些层背后的直觉和实现提供背景,这些概念在数学上可能有些复杂,但不要气馁。这些是可选的主题,不必掌握所有这些实现的细微之处。理解层背后的一般思想将足以使用我们引用的 PyTorch Geometric 实现进行实验。

接下来的小节将介绍谱图卷积层、图池化层和图归一化层。最后,最终的小节将对一些更高级的图神经网络进行总览。

谱图卷积

到目前为止,我们使用的图卷积都是空间性质的。这意味着它们根据与图相关的拓扑空间聚合信息,这只是说空间卷积在节点的局部邻域上操作的一种花哨方式。因此,如果利用空间卷积的 GNN 需要捕捉图数据中复杂的全局模式,那么网络就需要堆叠多个空间卷积。在这些全局模式很重要但需要限制网络深度的情况下,谱图卷积是一种可以考虑的替代卷积类型。

谱图卷积的操作方式与空间图卷积不同。谱图卷积通过利用图的频谱—其特征值集合—通过计算称为图拉普拉斯的图的归一化版本的特征分解来操作。最后一句可能看起来很复杂,所以让我们逐步分解并讨论它。

对于无向图,图的拉普拉斯矩阵定义为 L = D - A,其中 A 是图的邻接矩阵,D 是度矩阵。度矩阵是一个对角矩阵,其中对角线上的元素在与邻接矩阵的第 i 行相关的节点的进出边数。

L 是一个实对称矩阵,已经证明实对称矩阵可以分解为 ,其中 Q 是正交矩阵,其列是 L 的特征向量, 是对角矩阵,其元素是 L 的特征值。你可以把 Q 看作提供了图结构的底层表示。与使用由 A 定义的图的局部邻域的空间卷积不同,谱卷积利用来自 Q 的替代结构表示来更新节点嵌入。

下面的谱卷积示例利用了 对称归一化图拉普拉斯 的特征分解,对于一个图,它定义如下:

这里,I 是单位矩阵。这是因为图拉普拉斯归一化可以帮助稳定基于梯度的训练过程,类似于特征标准化。

鉴于 L[sym] 的特征分解,图卷积定义如下:

这里,W 是一个可训练的权重矩阵。括号里的内容实质上是通过一个编码图结构关系的矩阵来乘以 XW。这里的 运算符表示内部项的逐元素乘法,而外部的 Q 将结果映射回原始基础。这种卷积有一些不良特性,因为计算图的特征分解具有 O(n³) 的计算复杂度。这意味着它速度较慢,并且如其结构所示,W 取决于图的大小。因此,谱卷积只能应用于相同大小的图。此外,该卷积的感受野是整个图,当前的配方不能进行调整。然而,已经开发出各种技术和卷积来解决这些问题。

例如,Bruna 和同事 (arxiv.org/abs/1312.6203) 引入了一种平滑方法,通过一组函数的逼近来解决 W 的大小依赖性,每个函数都乘以它们自己的标量参数,。也就是说,给定函数集 f[1], ..., f[n],。这组函数的维度可以变化。然而,由于 保持标量,卷积参数空间可以独立于图的大小。

值得一提的其他谱卷积包括 Chebyshev 图卷积 (arxiv.org/abs/1606.09375),它可以在更低的时间复杂度下近似原始谱卷积,并且可以具有不同大小的感受野。Kipf 和 Welling (arxiv.org/abs/1609.02907) 引入了一个与 Chebyshev 卷积类似的卷积,但减少了参数负担。这两种的实现都可以在 PyTorch Geometric 中找到,分别是 torch_geometric.nn.ChebConvtorch_geometric.nn.GCNConv,如果你想尝试谱卷积,这是个合理的起点。

池化

我们将简要讨论一些为图形开发的池化层的例子。虽然池化层提供的下采样在 CNN 架构中是有益的,但在 GNN 中下采样的好处并不是很明显。

图像数据的池化层滥用了空间局部性,而图形则没有。如果提供图中节点的聚类,我们可以定义图池化层如何对节点进行池化。然而,如何定义最佳聚类仍不明确,并且不同的聚类方法可能适合不同的情境。即使确定了聚类,如果节点被降采样,剩余节点如何连接仍不清楚。尽管这些问题仍然是开放性的研究问题,我们将介绍几种图池化层,并指出它们解决上述问题的方法。

与 CNN 类似,可以应用于 GNN 的均值和最大池化层。如图 18.13 所示,给定节点的聚类后,每个聚类成为新图中的一个节点:

Diagram  Description automatically generated

图 18.13:将最大池化应用于图形

每个聚类的嵌入等于该聚类中节点嵌入的均值或最大值。为了解决连接性问题,将为该聚类分配包含该聚类中所有边索引的联合。例如,如果将节点 i, j, k 分配给聚类 c[1],那么与 i, jk 共享边的任何节点或包含该节点的聚类,将与 c[1] 共享边。

更复杂的池化层 DiffPool (arxiv.org/abs/1806.08804) 试图同时解决聚类和降采样问题。该层学习一个软聚类分配矩阵 ,将 n 个节点嵌入分配到 c 个聚类中。(关于软聚类与硬聚类的区别,请参阅《第十章》,《使用未标记数据 - 聚类分析》中的章节《硬聚类与软聚类》。)通过这种方式,X 更新为 X′ = S^TXA 更新为 A′ = ST**A**TS。值得注意的是,A′ 不再包含离散值,可以视为一种边权重矩阵。随着时间的推移,DiffPool 收敛到几乎硬聚类分配,具有可解释的结构。

另一种池化方法,top-k 池化,不是聚合图中的节点,而是删除它们,从而避免了聚类和连接性问题。虽然看似会损失删除节点中的信息,但在网络的背景下,只要在池化之前进行卷积,网络就能学会避免这种情况。被删除的节点是根据可学习向量 p 的投影分数来选择的。计算(X′, A′) 的实际公式如在《向稀疏分层图分类器迈进》(arxiv.org/abs/1811.01287) 中所述:

在这里,top-k 选择y 的前 k 个值的索引,使用索引向量 i 来删除XA 的行。PyTorch Geometric 实现了 top-k 池化为 torch_geometric.nn.TopKPooling。此外,最大池化和平均池化分别实现为 torch_geometric.nn.max_pool_xtorch_geometric.nn.avg_pool_x

标准化

标准化技术被广泛应用于许多类型的神经网络中,以帮助稳定和/或加速训练过程。许多方法,如批标准化(在第十七章 生成对抗网络用于合成新数据中讨论),可以适用于具有适当记账的 GNNs。在本节中,我们将简要描述一些专为图数据设计的标准化层。

作为标准化的快速回顾,我们认为给定一组特征值 x[1], ..., x[n],我们使用 来更新这些值,其中 是均值, 是标准差。通常,大多数神经网络标准化方法采用通用形式 ,其中 是可学习参数,而标准化方法之间的差异在于应用标准化的特征集。

GraphNorm: 加速图神经网络训练的原则方法,由Tianle Cai及其同事在 2020 年提出(arxiv.org/abs/2009.03294),展示了在图卷积中聚合后的均值统计可能包含有意义的信息,因此完全丢弃它可能不是一个理想的选择。为了解决这个问题,他们引入了GraphNorm

借用原始手稿的符号,设h 为节点嵌入矩阵。设h[i][, ][j] 为节点v[i] 的第 j 个特征值,其中 i = 1, ..., nj = 1, ..., dGraphNorm 的形式如下:

在这里,。关键新增部分是可学习参数 ,它可以控制要丢弃的均值统计量 的程度。

另一种图标准化技术是MsgNorm,由Guohao Li及其同事在 2020 年的手稿DeeperGCN: All You Need to Train Deeper GCNs中描述(arxiv.org/abs/2006.07739)。MsgNorm 对应于前面章节中提到的图卷积的消息传递形式。使用消息传递网络命名法(在实施基本图卷积子节的末尾定义),在图卷积对M[t] 求和并产生m[i] 但在使用U[t] 更新节点嵌入之前,MsgNorm 通过以下公式对m[i] 进行标准化:

在这里,s是一个可学习的缩放因子,这种方法的直觉是对图卷积中聚合消息的特征进行标准化。虽然没有理论支持这种标准化方法,但在实践中效果显著。

我们讨论过的标准化层都通过 PyTorch Geometric 实现并可用,如BatchNormGroupNormMessageNorm。更多信息,请访问 PyTorch Geometric 文档:pytorch-geometric.readthedocs.io/en/latest/modules/nn.html#normalization-layers

与可能需要额外聚类设置的图池化层不同,图归一化层可以更容易地插入现有的 GNN 模型中。在模型开发和优化过程中测试各种标准化方法是一种合理且推荐的方法。

高级图神经网络文献的指引

着眼于图的深度学习领域正在迅速发展,有许多方法我们在这个入门章节无法详尽介绍。因此,在结束本章之前,我们希望为有兴趣的读者提供一些显著文献的指引,以便深入研究这一主题。

正如你可能还记得的在第十六章变压器——通过注意力机制改进自然语言处理中,注意力机制可以通过提供额外的上下文来改进模型的能力。在这方面,已开发出多种用于 GNN 的注意力方法。例如,Petar Veličković及其同事于 2017 年提出的图注意力网络arxiv.org/abs/1710.10903)以及Dan Busbridge及其同事于 2019 年提出的关系图注意力网络arxiv.org/abs/1904.05811)。

近年来,这些注意力机制还被 2020 年Seongjun Yun及其同事提出的图变换器和 2020 年Ziniu Hu及其同事提出的异构图变换器所利用(arxiv.org/abs/1911.06455arxiv.org/abs/2003.01332)。

除了上述的图转换器之外,还开发了其他专门用于图形的深度生成模型。例如,图变分自动编码器,如KipfWelling在 2016 年提出的《变分图自动编码器》(arxiv.org/abs/1611.07308),以及 2018 年刘琦等人提出的《分子设计的约束图变分自动编码器》(arxiv.org/abs/1805.09076),以及SimonovskyKomodakis在 2018 年提出的《GraphVAE: 使用变分自动编码器生成小型图形》(arxiv.org/abs/1802.03480)。另一个显著的应用于分子生成的图变分自动编码器是Wengong Jin和同事在 2019 年提出的《分子图生成的联结树变分自动编码器》(arxiv.org/abs/1802.04364)。

一些 GAN 已被设计用于生成图数据,尽管截至本文撰写时,GAN 在图领域的表现远不如在图像领域那般令人信服。例如,Hongwei Wang和同事在 2017 年提出的《GraphGAN: 使用生成对抗网络进行图表示学习》(arxiv.org/abs/1711.08267),以及CaoKipf在 2018 年提出的《MolGAN: 用于小分子图的隐式生成模型》(arxiv.org/abs/1805.11973)。

GNNs 也已纳入深度强化学习模型中——你将在下一章节详细学习强化学习。例如,Jiaxuan You和同事在 2018 年提出的《用于目标导向分子图生成的图卷积策略网络》(arxiv.org/abs/1806.02473),以及Zhenpeng Zhou和同事在 2018 年提出的《通过深度强化学习优化分子》(arxiv.org/abs/1810.08678),利用了应用于分子生成任务的 GNN。

最后,虽然技术上不属于图数据,但有时将 3D 点云表示为图数据,使用距离截断来创建边。图网络在这一领域的应用包括Weijing Shi和同事在 2020 年提出的《Point-GNN: 用于 LiDAR 点云中 3D 物体检测的图神经网络》(arxiv.org/abs/2003.01251),该网络可以在 LiDAR 点云中检测 3D 物体。此外,Can Chen和同事在 2019 年设计的《GAPNet: 基于图注意力的点神经网络,用于利用点云的局部特征》(arxiv.org/abs/1905.08705),旨在解决其他深度架构难以处理的点云局部特征检测问题。

总结

随着我们可以访问的数据量不断增加,我们需要理解数据内部的相互关系的需求也会增加。虽然我们会以多种方式来实现这一点,但图表作为这些关系的精炼表示,可用的图表数据量只会增加。

在本章中,我们通过从零开始实现图卷积层和 GNN 来逐步解释图神经网络。我们看到,由于图数据的性质,实现 GNN 实际上是非常复杂的。因此,为了将 GNN 应用于实际示例,例如预测分子极化,我们学习了如何利用 PyTorch Geometric 库,该库提供了我们需要的许多构建模块的实现。最后,我们回顾了一些深入研究 GNN 领域的重要文献。

希望本章介绍了如何利用深度学习来学习图形。这一领域的方法目前是研究的热点领域,我们提到的许多方法都是最近几年发表的。通过这本书作为起点,也许你可以在这个领域取得下一个进展。

在下一章中,我们将探讨强化学习,这是与本书迄今为止涵盖的机器学习完全不同的一类。

加入我们书籍的 Discord 空间

加入书籍的 Discord 工作空间,参加每月的问我任何事与作者的会话:

packt.link/MLwPyTorch

第十九章:决策复杂环境中的强化学习

在前几章中,我们专注于监督学习和无监督学习。我们还学习了如何利用人工神经网络和深度学习来解决这些类型机器学习所遇到的问题。回想一下,监督学习专注于从给定输入特征向量预测类别标签或连续值。无监督学习专注于从数据中提取模式,使其在数据压缩(第五章通过降维压缩数据)、聚类(第十章使用无标签数据进行聚类分析)或近似训练集分布以生成新数据(第十七章生成对抗网络用于合成新数据)等方面非常有用。

在本章中,我们将注意力转向另一类机器学习,强化学习RL),与以往的类别不同,它侧重于学习一系列动作以优化整体奖励,例如,在国际象棋游戏中取得胜利。总之,本章将涵盖以下主题:

  • 学习 RL 的基础知识,熟悉代理与环境的交互,理解奖励过程如何工作,以帮助在复杂环境中做出决策

  • 引入 RL 问题的不同类别,基于模型和无模型学习任务,蒙特卡洛和时序差分学习算法

  • 在表格格式中实现 Q 学习算法

  • 理解用于解决 RL 问题的函数逼近,通过实现深度 Q 学习算法结合 RL 与深度学习

RL 是一个复杂而广阔的研究领域,本章侧重于基础知识。由于本章作为介绍,为了集中注意力于重要的方法和算法,我们将主要使用能够说明主要概念的基础示例。然而,在本章末尾,我们将介绍一个更具挑战性的例子,并利用深度学习架构来实现一种称为深度 Q 学习的特定 RL 方法。

介绍——从经验中学习

在本节中,我们将首先介绍 RL 作为机器学习的一个分支的概念,并看到它与其他机器学习任务相比的主要区别。之后,我们将覆盖 RL 系统的基本组成部分。然后,我们将基于马尔可夫决策过程看 RL 的数学公式化。

理解强化学习

到目前为止,本书主要集中在监督无监督学习上。回顾一下,在监督学习中,我们依赖于由监督员或人类专家提供的标记训练样本,目标是训练一个能够很好地推广到未见过的未标记测试样本的模型。这意味着监督学习模型应该学会为给定的输入示例分配与监督人类专家相同的标签或值。另一方面,在无监督学习中,目标是学习或捕获数据集的潜在结构,例如在聚类和降维方法中;或者学习如何生成具有类似潜在分布的新的合成训练样本。强化学习与监督和无监督学习显著不同,因此强化学习通常被视为“机器学习的第三类别”。

区分强化学习与监督学习、无监督学习等其他机器学习子任务的关键元素是,强化学习围绕通过交互学习的概念。这意味着在强化学习中,模型通过与环境的交互学习来最大化一个奖励函数

在最大化奖励函数与监督学习中最小化损失函数的概念相关联时,用于学习一系列动作的正确标签在强化学习中并不事先已知或定义——相反,它们需要通过与环境的交互学习以达到某种期望的结果——比如在游戏中取得胜利。通过强化学习,模型(也称为代理)与其环境交互,从而生成一系列称为回合(episode)的交互序列。通过这些交互,代理收集由环境确定的一系列奖励。这些奖励可以是正面的也可以是负面的,并且有时候直到回合结束后才会向代理披露。

例如,想象我们想教计算机玩国际象棋并赢得人类玩家。计算机所做的每一步棋的标签(奖励)直到游戏结束都是未知的,因为在游戏过程中,我们不知道特定的一步棋是否会导致赢得或失去该游戏。直到游戏结束时才确定反馈。如果计算机赢得了比赛,那么反馈很可能是正面奖励,因为代理已经实现了整体期望的结果;反之,如果计算机输了比赛,那么很可能会给出负面奖励。

此外,考虑下象棋的例子,输入是当前的配置,例如棋盘上个别棋子的排列方式。考虑到可能的输入(系统状态的状态)的数量巨大,不可能标记每个配置或状态为正面或负面。因此,为了定义学习过程,我们在每局比赛结束时提供奖励(或惩罚),当我们知道是否达到了期望的结果——无论是赢得比赛还是没有。

这是强化学习的精髓。在强化学习中,我们不能或不会教导一个代理、计算机或机器人如何做事情;我们只能明确我们希望代理实现的目标是什么。然后,基于特定试验的结果,我们可以根据代理的成功或失败来确定奖励。这使得强化学习在复杂环境中作出决策特别具有吸引力,特别是当问题解决任务需要一系列步骤时,这些步骤是未知的,或者难以解释,或者难以定义。

除了在游戏和机器人技术中的应用之外,强化学习的例子也可以在自然界中找到。例如,训练一只狗就涉及到强化学习——当它执行某些理想的动作时,我们给它奖励(零食)。或者考虑一个训练有素的医疗狗,它被训练来警告它的伴侣即将发作癫痫。在这种情况下,我们不知道狗能够检测癫痫发作的确切机制,即使我们对这一机制有精确的了解,我们也无法定义一系列步骤来学习癫痫检测。然而,如果它成功检测到癫痫,我们可以给它一块零食来强化这种行为!

尽管强化学习为学习实现某个目标的任意系列动作提供了一个强大的框架,请记住强化学习仍然是一个相对年轻且活跃的研究领域,有许多未解决的挑战。使训练强化学习模型特别具有挑战性的一个方面是,后续模型输入取决于先前采取的行动。这可能导致各种问题,并通常导致不稳定的学习行为。此外,强化学习中的这种序列依赖性产生了所谓的延迟效应,这意味着在时间步骤t上采取的行动可能导致未来几个步骤后出现奖励。

定义强化学习系统的代理-环境接口

在所有强化学习的例子中,我们可以找到两个不同的实体:一个代理和一个环境。正式地说,代理被定义为一个学习如何做出决策并通过采取行动与其周围环境交互的实体。作为采取行动的后果,代理接收来自环境的观察和奖励信号。环境是指代理之外的任何东西。环境与代理进行通信,并确定代理行动的奖励信号以及其观察结果。

奖励信号是代理从与环境交互中接收到的反馈,通常以标量形式提供,可以是正或负。奖励的目的是告诉代理其表现如何。代理接收奖励的频率取决于给定的任务或问题。例如,在国际象棋游戏中,奖励将在整个游戏后根据所有移动的结果确定:胜利或失败。另一方面,我们可以定义一个迷宫,使得奖励在每个时间步骤后确定。在这样的迷宫中,代理尝试最大化其终身内的累积奖励——其中终身描述了一个事件的持续时间。

图 19.1说明了代理与环境之间的交互和通信:

图 19.1:代理与其环境之间的互动

代理的状态,如图 19.1所示,是其所有变量的集合(1)。例如,在机器人无人机的情况下,这些变量可能包括无人机的当前位置(经度、纬度和高度)、无人机剩余的电池寿命、每个风扇的速度等。在每个时间步骤,代理通过一组可用的动作A[t](2)与环境进行交互。基于代理在状态S[t]时采取的动作A[t],代理将收到奖励信号R[t][+1](3),并且其状态将变为S[t][+1](4)。

在学习过程中,代理必须尝试不同的动作(探索),以便逐步学习哪些动作更值得偏好和更频繁执行(利用),以最大化总累积奖励。为了理解这个概念,让我们考虑一个非常简单的例子:一个以软件工程为重点的新计算机科学研究生正在考虑是开始在一家公司工作(利用)还是继续攻读硕士或博士学位,学习更多关于数据科学和机器学习的知识(探索)。一般来说,利用将导致选择具有更大短期奖励的动作,而探索则可能在长期内实现更大的总奖励。探索和利用之间的权衡已被广泛研究,然而,在这种决策过程中没有普遍适用的答案。

强化学习的理论基础

在我们深入一些实际示例并开始训练强化学习模型之前(我们将在本章稍后进行),让我们先理解一些强化学习的理论基础。接下来的部分将首先讨论马尔可夫决策过程的数学表达式、阶段性与持续性任务、一些关键的强化学习术语,以及使用贝尔曼方程进行动态规划。让我们从马尔可夫决策过程开始。

马尔可夫决策过程

一般来说,RL 处理的问题类型通常被形式化为 Markov 决策过程(MDPs)。解决 MDP 问题的标准方法是使用动态规划,但是 RL 相比动态规划具有一些关键优势。

动态规划

动态规划指的是一组计算机算法和编程方法,由 Richard Bellman 在 1950 年代开发。从某种意义上说,动态规划是关于递归问题解决——通过将相对复杂的问题分解为更小的子问题来解决。

递归与动态规划的关键区别在于,动态规划存储子问题的结果(通常作为字典或其他形式的查找表),这样如果将来再次遇到这些子问题,可以在常数时间内访问结果,而不是重新计算它们。

一些著名的计算机科学问题的例子,这些问题通过动态规划解决,包括序列比对和计算从点 A 到点 B 的最短路径。

然而,当状态的大小(即可能配置的数量)相对较大时,动态规划并不是一种可行的方法。在这种情况下,RL 被认为是解决 MDPs 更高效和实际的替代方法。

Markov 决策过程的数学表述

需要学习互动和顺序决策过程的问题类型,其中时间步t的决策会影响后续情况,数学上形式化为 MDPs。

在 RL 中的代理/环境交互中,如果我们将代理的起始状态表示为S[0],代理与环境的交互会导致以下序列:

{S[0], A[0], R[1]},{S[1], A[1], R[2]},{S[2], A[2], R[3]},...

注意,大括号仅作为视觉辅助。这里,S[t]和A[t]代表时间步t处的状态和采取的动作。R[t][+1]表示执行动作A[t]后从环境获得的奖励。注意,S[t]、R[t][+1]和A[t]是依赖时间的随机变量,它们的取值来自预定义的有限集合,分别用表示。在 MDP 中,这些时间依赖的随机变量S[t]和R[t][+1]的概率分布仅依赖于它们在前一时间步t – 1 的值。S[t][+1] = s′和R[t][+1] = r的概率分布可以写成关于前一状态(S[t])和采取的动作(A[t])的条件概率如下:

这种概率分布完全定义了环境的动态(或环境模型),因为基于这种分布,可以计算环境的所有转移概率。因此,环境动态是对不同 RL 方法进行分类的一个核心标准。那些需要环境模型或试图学习环境模型(即环境动态)的 RL 方法被称为基于模型的方法,与无模型方法相对。

基于模型和无模型 RL

当已知概率 时,可以使用动态规划来解决学习任务。但是在许多现实世界问题中,由于环境动态未知,我们需要通过与环境交互来获取大量样本以补偿这种不确定性。

处理此问题的两种主要方法是蒙特卡洛MC)和时间差分TD)方法。以下图表展示了这两种方法的主要类别和各自的分支:

图 19.2:基于环境动态使用的不同模型

在本章中,我们将从理论到实际算法覆盖这些不同方法及其分支。

如果给定状态下特定的行动总是或从不被执行,那么可以认为环境动态是确定性的,即,。否则,在更一般的情况下,环境将表现出随机性。

要理解这种随机行为,让我们考虑在当前状态 S[t] = s 和执行的动作 A[t] = a 条件下观察未来状态 S[t][+1] = s′ 的概率。这可以表示为:

通过对所有可能的奖励进行求和,可以计算其作为边际概率的值:

这种概率称为状态转移概率。基于状态转移概率,如果环境动态是确定性的,那么当智能体在状态 S[t] = s 采取行动 A[t] = a 时,到达下一个状态 S[t][+1] = s′ 将是 100%确定的,即,

马尔可夫过程的可视化

马尔可夫过程可以表示为一个有向循环图,其中图中的节点表示环境的不同状态。图的边(即节点之间的连接)表示状态之间的转移概率。

例如,考虑一个学生在三种不同情况之间做出决定:(A)在家里复习考试,(B)在家里玩视频游戏,或者(C)在图书馆学习。此外,还有一个终止状态(T),即睡觉。每小时做出决定,并且做出决定后,学生将在选择的情况下度过那个特定小时。然后,假设当留在家里(状态 A)时,有 50%的可能性学生会切换到玩视频游戏。另一方面,当学生处于状态 B(玩视频游戏)时,有较高的概率(80%)会在接下来的小时继续玩视频游戏。

学生行为的动态被展示为图 19.3 中的马尔可夫过程,包括一个循环图和一个转移表:

图 19.3:学生的马尔可夫过程

图中边缘上的值表示学生行为的转移概率,并且它们的值也显示在右侧的表格中。在考虑表中的行时,请注意每个状态(节点)出来的转移概率总是等于 1。

情节性任务与持续任务

当代理与环境互动时,观察或状态的序列形成一个轨迹。有两种类型的轨迹。如果一个代理的轨迹可以分成子部分,每个部分从时间t = 0 开始,并在终止状态S[T](在时间t = T)结束,那么这个任务被称为情节性任务

另一方面,如果轨迹是无限连续的,没有终止状态,那么这个任务被称为持续任务

与国际象棋学习代理相关的任务是一个情节性任务,而保持房屋整洁的清洁机器人通常执行持续任务。在本章中,我们仅考虑情节性任务。

在情节任务中,一个情节是一个从起始状态S[0]到终止状态S[T]的序列或轨迹:

S[0]、A[0]、R[1]、S[1]、A[1]、R[2]、...、S[t]、A[t]、R[t][+1]、...、S[t][–1]、A[t][–1]、R[t]、S[t]

对于图 19.3 中展示的马尔可夫过程,描述了学生复习考试的任务,我们可能会遇到以下三个示例:

RL 术语:返回值、策略和价值函数

接下来,让我们定义本章余下部分所需的一些额外的 RL 专有术语。

返回值

所谓的时间t时的返回值是从整个情节中获得的累积奖励。回想一下,R[t][+1] = r 是在时间t执行动作A[t]后获得的即时奖励;随后的奖励是R[t][+2]、R[t][+3]等等。

然后可以根据即时奖励以及随后的奖励计算时间t时的返回值,如下所示:

在这里,是范围为[0, 1]的折现因子。参数表示在当前时刻(时间t)未来回报的“价值”。请注意,通过设置,我们将意味着我们不关心未来回报。在这种情况下,回报将等于即时奖励,忽略t + 1 之后的后续回报,并且代理将显得短视。另一方面,如果,回报将是所有后续回报的未加权和。

此外,请注意,可以通过使用递归将回报的方程简化如下:

这意味着时间t的回报等于即时奖励r加上时间t + 1 的折现未来回报。这是一个非常重要的属性,有助于计算回报。

折现因子背后的直觉

要理解折现因子,请考虑图 19.4,显示了今天赚取 100 美元与一年后赚取 100 美元的价值对比。在某些经济情况下,如通货膨胀,现在赚取这 100 美元可能比将来赚取它更有价值:

图 19.4:基于 100 美元的价值随时间变化的折现因子示例

因此,我们可以说,如果这张钞票现在值 100 美元,那么带有折现因子的一年后将值 90 美元。

让我们计算我们先前学生示例中不同时间步骤的回报。假设且唯一的奖励基于考试结果(通过考试+1,未通过-1)。中间时间步骤的奖励为 0。

  • ...

  • ...

我们把第三个情节的回报计算留给读者作为练习。

策略

一个通常由表示的策略是一个确定下一步动作的函数,可以是确定性或随机性(即,采取下一步动作的概率)。随机策略则在给定状态下有动作的概率分布:

在学习过程中,随着代理获得更多经验,策略可能会发生变化。例如,代理可以从随机策略开始,其中所有动作的概率都是均匀的;同时,代理希望学习优化其策略以达到最优策略。最优策略 是能够获得最高回报的策略。

值函数

值函数,也称为状态值函数,衡量每个状态的优劣,换句话说,处于特定状态的好坏程度。请注意,优良性的标准基于回报。

现在,基于回报 G[t],我们定义状态 s 的值函数为在遵循策略 后的期望回报(所有可能的情节的平均回报):

在实际实现中,我们通常使用查找表来估算值函数,这样就不必多次重新计算它。(这是动态规划的一个方面。)例如,在实践中,当我们使用这种表格方法估算值函数时,我们将所有状态值存储在一个名为 V(s) 的表中。在 Python 实现中,这可以是一个列表或 NumPy 数组,其索引引用不同的状态;或者,它可以是一个 Python 字典,其中字典键将状态映射到相应的值。

此外,我们还可以为每个状态-动作对定义一个值,称为动作值函数,用 表示。动作值函数指的是当代理处于状态 S[t] = s 并采取动作 A[t] = a 时的预期回报 G[t]。

将状态值函数的定义扩展到状态-动作对,我们得到以下内容:

这类似于将最优策略称为 , ,它们也表示最优状态值函数和动作值函数。

估算值函数是强化学习方法的一个重要组成部分。我们将在本章后面介绍计算和估算状态值函数和动作值函数的不同方法。

奖励、回报和值函数之间的区别

奖励是代理根据环境当前状态执行动作而获得的后果。换句话说,奖励是代理在执行动作以从一个状态过渡到下一个状态时收到的信号。但请记住,并非每个动作都会产生正面或负面的奖励——回想一下我们的象棋示例,在那里只有赢得比赛才会获得正面奖励,所有中间动作的奖励都为零。

一个状态本身具有一定的价值,我们将其分配给它,以衡量这个状态是好是坏—这就是价值函数发挥作用的地方。通常,具有“高”或“好”价值的状态是那些有高期望回报并且可能在特定策略下产生高奖励的状态。

例如,让我们再次考虑下象棋电脑。如果电脑赢得了比赛,最终才会得到正面奖励。如果电脑输掉比赛,则不会有(正面)奖励。现在,想象一下,电脑执行了一个特定的象棋移动,捕获了对手的皇后,而对电脑没有任何负面影响。由于电脑只有在赢得比赛时才会得到奖励,所以它不会因为捕获对手的皇后而立即获得奖励。然而,新状态(捕获皇后后的棋盘状态)可能具有很高的价值,这可能会产生奖励(如果之后赢得了比赛)。直觉上,我们可以说与捕获对手皇后相关联的高价值与捕获皇后通常导致赢得比赛—因此高期望回报或价值相关。然而,请注意,捕获对手的皇后并不总是导致赢得比赛;因此,代理可能会收到正面奖励,但不能保证。

简而言之,回报是整个情节中奖励的加权和,在我们的象棋示例中,这将等于折现的最终奖励(因为只有一个奖励)。价值函数是所有可能情节的期望,基本上计算某个移动平均来看,做出某个特定移动是多么“有价值”。

在我们直接进入一些强化学习算法之前,让我们简要回顾一下贝尔曼方程的推导,这可以用来实现策略评估。

使用贝尔曼方程的动态规划

贝尔曼方程是许多强化学习算法的核心要素之一。贝尔曼方程简化了价值函数的计算,不需要对多个时间步长进行求和,而是使用类似于计算回报的递归。

基于总回报的递归方程,,我们可以将价值函数重写如下:

注意,由于它是时间 t 的常量和已知数量,所以立即奖励 r 被从期望中除去。

同样地,对于动作值函数,我们可以写成:

我们可以利用环境动态来计算期望,通过对下一个状态 s′ 的所有可能性和相应的奖励 r 的概率进行求和:

现在,我们可以看到回报的期望,,本质上是状态值函数 。因此,我们可以将 写成关于 的函数:

这就是所谓的贝尔曼方程,它将一个状态s的价值函数与其后续状态s'的价值函数相关联。这极大地简化了价值函数的计算,因为它消除了沿时间轴的迭代循环。

强化学习算法

在本节中,我们将涵盖一系列学习算法。我们将从动态规划开始,它假设转移动态或环境动态即是已知的。然而,在大多数 RL 问题中,这并非如此。为了解决未知的环境动态,开发出了通过与环境交互学习的 RL 技术。这些技术包括蒙特卡洛MC)、时序差分TD)学习,以及越来越流行的 Q 学习和深度 Q 学习方法。

图 19.5描述了 RL 算法的进展过程,从动态规划到 Q 学习:

图 19.5:不同类型的 RL 算法

在本章的后续部分,我们将逐步介绍每个强化学习算法。我们将从动态规划开始,然后转向 MC,最后到 TD 及其分支,包括基于策略的SARSA状态-动作-奖励-状态-动作)和离策略的 Q 学习。同时,我们还将进入深度 Q 学习,构建一些实用模型。

动态规划

在本节中,我们将专注于解决以下假设下的 RL 问题:

  • 我们对环境动态有全面的了解;也就是说,所有转移概率——都是已知的。

  • 代理的状态具有马尔可夫性质,这意味着下一个动作和奖励仅依赖于当前状态及我们在此时刻或当前时间步骤所做的动作选择。

强化学习问题的数学表述使用马尔可夫决策过程MDP)在本章前面已经介绍过。如果您需要复习,请参阅名为马尔可夫决策过程的数学表述的部分,该部分介绍了在策略下,价值函数的正式定义,以及使用环境动态导出的贝尔曼方程。

我们应该强调,动态规划并不是解决 RL 问题的实用方法。使用动态规划的问题在于它假设对环境动态有全面了解,而这通常对大多数实际应用来说是不合理或不可行的。然而,从教育的角度来看,动态规划有助于以简单的方式介绍 RL,并激发使用更先进和复杂的 RL 算法。

通过下面小节描述的任务,有两个主要目标:

  1. 获得真实的状态值函数;这项任务也被称为预测任务,并且是通过策略评估完成的。

  2. 找到最优值函数,,这是通过广义策略迭代完成的。

策略评估 – 用动态规划预测值函数

基于贝尔曼方程,当环境动态已知时,我们可以用动态规划计算任意策略的值函数。为了计算这个值函数,我们可以采用迭代解法,其中我们从开始,它对于每个状态都初始化为零值。然后,在每次迭代i + 1 中,我们根据贝尔曼方程更新每个状态的值,这反过来又基于前一次迭代i中状态的值,如下所示:

可以证明,随着迭代次数无限增加,收敛到真实的状态值函数

此外,注意到这里我们不需要与环境交互。这是因为我们已经准确地了解了环境动态。因此,我们可以利用这些信息并轻松估计值函数。

计算值函数后,一个显而易见的问题是,如果我们的策略仍然是随机策略,那么该值函数如何对我们有用。答案是,我们实际上可以利用这个计算得到的来改进我们的策略,接下来我们将看到。

通过估计值函数改进策略

现在我们通过遵循现有的策略计算了值函数,我们想要使用来改进现有的策略。这意味着我们想要找到一个新的策略,对于每个状态s,遵循,将产生比使用当前策略更高或至少相等的值。用数学术语来说,我们可以表达这个改进策略的目标为:

首先,回想一下,策略决定了在状态s时选择每个动作a的概率。现在,为了找到一个,它在每个状态上都具有更好或至少相等的值,我们首先计算基于值函数的每个状态s和动作a的动作值函数。我们遍历所有状态,并且对于每个状态s,我们比较如果选择动作a会发生的下一个状态s′的值。

在通过 评估所有状态-动作对并得到最高状态值之后,我们可以将相应动作与当前政策选择的动作进行比较。如果当前政策建议的动作(即 )与动作值函数建议的动作(即 )不同,则可以通过重新分配动作的概率来更新政策,以匹配提供最高动作值的动作,即 。这被称为政策改进算法。

政策迭代

使用前文描述的政策改进算法可以证明,政策改进将严格产生更好的政策,除非当前政策已经是最优的(这意味着对每个 ,都有 )。因此,如果我们迭代执行政策评估然后执行政策改进,我们将保证找到最优政策。

注意,这种技术被称为广义策略迭代GPI),在许多强化学习方法中很常见。我们将在本章的后续部分中使用 GPI 进行 MC 和 TD 学习方法。

值迭代

通过重复政策评估(计算 )和政策改进(找到 以使 ),我们可以得到最优政策。然而,如果将政策评估和政策改进两个任务合并为一步会更加高效。以下方程根据最大化下一个状态值及其即时奖励的加权和更新了第 i + 1 次迭代的值函数(表示为 ):

在这种情况下,通过选择所有可能动作中的最佳动作来最大化 的更新值,而在政策评估中,更新值是通过所有动作的加权和来计算的。

状态值和动作值函数的表格估计符号

在大多数强化学习文献和教科书中,小写字母 被用来指代真实的状态值函数和动作值函数,作为数学函数。

与此同时,对于实际实现,这些值函数被定义为查找表。这些值函数的表格估计表示为 。在本章节中我们也将使用这种符号。

蒙特卡洛强化学习

如前所述,在前一节中,动态规划依赖于一个简化的假设,即环境的动态完全已知。离开动态规划方法,我们现在假设我们对环境动态一无所知。

也就是说,我们不知道环境的状态转移概率,而是希望代理通过与环境的交互来学习。使用 MC 方法,学习过程基于所谓的模拟体验

对于基于 MC 的 RL,我们定义一个遵循概率策略的代理类,根据这个策略,我们的代理在每一步采取一个动作。这导致一个模拟的情节。

早些时候,我们定义了状态值函数,该函数表明状态的值表示从该状态预期的回报。在动态规划中,这种计算依赖于对环境动态的知识,即图所示。

然而,从现在开始,我们将开发不需要环境动态的算法。基于蒙特卡罗(MC)的方法通过生成模拟的情节来解决这个问题,在这些模拟的情节中,一个代理与环境互动。通过这些模拟的情节,我们可以计算每个访问过的状态的平均回报。

使用 MC 进行状态值函数估计

在生成一组情节之后,对于每个状态 s,都考虑通过状态 s 的所有情节以计算状态 s 的值。假设用查找表来获取对应值函数的值,如图所示。用于估计值函数的 MC 更新基于从第一次访问状态 s 开始的该情节中获得的总回报。这种算法称为首次访问蒙特卡罗值预测。

使用 MC 方法估计动作值函数

当环境动态已知时,我们可以通过向前看一步来轻松推断出状态值函数的动作值函数,以找到给定状态的最大值的动作,正如动态规划部分所示。然而,如果环境动态未知,这是不可行的。

要解决这个问题,我们可以扩展算法以估计首次访问 MC 状态值预测。例如,我们可以使用动作值函数计算每个状态-动作对的估计回报。为了获得这种估计的回报,我们考虑对每个状态-动作对 (s, a) 的访问,这指的是访问状态 s 并采取动作 a

然而,由于某些动作可能永远不会被选择,导致探索不足。有几种方法可以解决这个问题。最简单的方法称为探索性起始,假设每个状态-动作对在情节开始时都有非零的概率。

处理探索不足问题的另一种方法称为 -贪婪策略,将在下一节关于策略改进中讨论。

使用 MC 控制找到最优策略

MC 控制 指的是改进策略的优化过程。与上一节的策略迭代方法(动态规划)类似,我们可以反复进行策略评估和策略改进,直到达到最优策略。因此,从随机策略开始,,交替进行策略评估和策略改进的过程可以如下所示:

策略改进 – 从动作值函数计算贪婪策略

给定动作值函数 q(sa),我们可以按以下方式生成贪婪(确定性)策略:

为了避免探索不足问题,并考虑之前讨论过的未访问状态-动作对,我们可以让非最优动作有一定几率 () 被选择。这被称为 -贪婪策略,根据这个策略,状态 s 下所有非最优动作的选择概率最小为 (而非 0),最优动作的选择概率为 (而非 1)。

时间差分学习

到目前为止,我们已经了解了两种基本的强化学习技术—动态规划和基于蒙特卡洛的学习。回想一下,动态规划依赖于对环境动态的完整和准确的知识。另一方面,基于蒙特卡洛的方法通过模拟体验学习。在本节中,我们现在将介绍第三种强化学习方法称为 TD 学习,它可以被视为基于蒙特卡洛的强化学习方法的改进或扩展。

类似于 MC 技术,TD 学习也是基于经验学习的,因此不需要了解环境动态和转移概率。TD 和 MC 技术的主要区别在于,在 MC 中,我们必须等到回合结束才能计算总回报。

然而,在 TD 学习中,我们可以利用一些学到的属性在到达该回合结束之前更新估计值。这被称为 自举(在强化学习的背景下,自举这一术语与我们在 第七章 集成学习 中使用的自举估计方法不同)。

类似于动态规划方法和基于蒙特卡洛的学习,我们将考虑两个任务:估计值函数(也称为值预测)和改进策略(也称为控制任务)。

TD 预测

让我们首先重新审视 MC 的值预测。在每个回合结束时,我们能够估计每个时间步t的回报G[t]。因此,我们可以更新我们对访问过状态的估计如下:

在这里,G[t]用作更新估计值V(S[t])的目标回报,(G[t] – V(S[t]))是添加到我们当前对值V(S[t])的估计的修正项。值是表示学习率的超参数,在学习期间保持不变。

注意,在 MC 中,修正项使用实际回报G[t],直到该回合结束时才知道。为了澄清这一点,我们可以将实际回报G[t]重新命名为G[t][:][T],其中下标t:T表示这是在时间步t获得的回报,考虑了从时间步t到最后时间步T发生的所有事件。

在 TD 学习中,我们用新的目标回报G[t][:][t][+1]替换实际回报G[t][:][T],这显著简化了值函数V(S[t])的更新。基于 TD 学习的更新公式如下:

在这里,目标回报使用观察到的奖励R[t][+1]和下一个立即步骤的估计值。注意 MC 和 TD 之间的区别。在 MC 中,G[t][:][T]直到回合结束才可用,所以我们应该执行尽可能多的步骤才能到达那里。相反,在 TD 中,我们只需要前进一步即可获得目标回报。这也被称为 TD(0)。

此外,TD(0)算法可以推广为所谓的n 步 TD算法,它包含更多未来步骤——准确地说,是n个未来步骤的加权和。如果我们定义n = 1,则 n 步 TD 过程与前一段中描述的 TD(0)相同。然而,如果,则 n 步 TD 算法将与 MC 算法相同。n 步 TD 的更新规则如下:

G[t][:][t][+][n]的定义如下:

MC 与 TD:哪种方法收敛更快?

尽管对这个问题的精确答案仍然不清楚,但实际上,实验证明 TD 可以比 MC 更快地收敛。如果你感兴趣,可以在《强化学习导论》一书中找到关于 MC 和 TD 收敛性的更多细节,作者是理查德·S·萨顿安德鲁·G·巴托

现在我们已经介绍了使用 TD 算法进行预测任务,我们可以继续进行控制任务。我们将涵盖两种 TD 控制算法:一种是在策略控制,另一种是离策略控制。在两种情况下,我们使用了 GPI,这在动态规划和 MC 算法中都有使用过。在在策略 TD 控制中,根据代理正在遵循的策略更新值函数;而在离策略算法中,值函数是根据当前策略之外的动作进行更新的。

在策略 TD 控制(SARSA)

为简单起见,我们仅考虑一步 TD 算法,或者 TD(0)。然而,在策略 TD 控制算法可以轻松推广到n-步 TD。我们将从扩展预测公式开始,以描述状态值函数,进而描述动作值函数。为此,我们使用一个查找表,即一个二维表格,Q(S[t], A[t]),它表示每个状态-动作对的动作值函数。在这种情况下,我们将会有以下情况:

这个算法通常称为 SARSA,指的是用于更新公式的五元组(S[t], A[t], R[t][+1], S[t][+1], A[t][+1])。

正如我们在描述动态规划和 MC 算法的前几节中看到的那样,我们可以使用 GPI 框架,从随机策略开始,重复估计当前策略的动作值函数,然后使用基于当前动作值函数的-贪婪策略来优化策略。

离策略 TD 控制(Q 学习)

我们看到,当使用前述的在策略 TD 控制算法时,如何估计动作值函数是基于模拟回合中使用的策略的。在更新动作值函数后,通过采取值更高的动作进行策略改进的一个独立步骤也会被执行。

另一种(更好的)方法是将这两个步骤结合起来。换句话说,想象一下,代理正在遵循策略,生成一个包含当前转移五元组(S[t], A[t], R[t][+1], S[t][+1], A[t][+1])的回合。与其使用代理实际选择的A[t][+1]的动作值来更新动作值函数,我们可以找到最佳动作,即使该动作并未由当前策略选择的代理实际执行。(这就是为什么这被认为是一个离策略算法。)

为此,我们可以修改更新规则,以考虑在下一个即时状态中通过不同动作变化的最大 Q 值。更新 Q 值的修改方程如下:

我们鼓励您比较这里的更新规则与 SARSA 算法的更新规则。正如您所见,我们在下一个状态S[t][+1]中找到最佳动作,并将其用于修正项,以更新我们对Q(S[t], A[t])的估计。

为了让这些材料更具体,下一节我们将看看如何实现 Q-learning 算法来解决网格世界问题

实现我们的第一个 RL 算法

在本节中,我们将介绍实现 Q-learning 算法来解决网格世界问题(网格世界是一个二维的、基于单元格的环境,代理在其中以四个方向移动以尽可能多地收集奖励)。为此,我们使用 OpenAI Gym 工具包。

引入 OpenAI Gym 工具包

OpenAI Gym 是一个专门用于促进 RL 模型开发的工具包。OpenAI Gym 预装了几个预定义的环境。一些基本示例是 CartPole 和 MountainCar,如它们的名称所示,任务是平衡杆和将车开上山。还有许多用于训练机器人从桌子上获取、推动和抓取物品或训练机器手操作方块、球或笔的高级机器人学环境。此外,OpenAI Gym 提供了一个便利的统一框架来开发新的环境。更多信息可以在其官方网站上找到:gym.openai.com/

要在接下来的章节中使用 OpenAI Gym 的代码示例,您需要安装gym库(在撰写本文时使用的是版本 0.20.0),可以通过pip轻松完成:

pip install gym==0.20 

如果您需要安装方面的额外帮助,请参考官方安装指南:gym.openai.com/docs/#installation

使用现有的 OpenAI Gym 环境

为了练习 Gym 环境,让我们从CartPole-v1中创建一个环境,这个环境已经存在于 OpenAI Gym 中。在这个示例环境中,有一个杆连接到一个可以水平移动的小车,如图 19.6所示:

图 19.6:Gym 中的 CartPole 示例

杆的运动受物理定律控制,RL 代理的目标是学会如何移动小车以稳定杆,并防止其向任一方向倾斜。

现在,让我们在 RL 的背景下看一些 CartPole 环境的属性,例如其状态(或观察)空间、动作空间以及如何执行动作:

>>> import gym
>>> env = gym.make('CartPole-v1')
>>> env.observation_space
Box(-3.4028234663852886e+38, 3.4028234663852886e+38, (4,), float32)
>>> env.action_space
Discrete(2) 

在上述代码中,我们为 CartPole 问题创建了一个环境。该环境的观察空间是Box(4,)(具有从-infinf的浮点值),表示四维空间对应四个实数:小车的位置、小车的速度、杆的角度和杆顶的速度。动作空间是离散空间,Discrete(2),有两种选择:将小车向左推或向右推。

我们之前通过调用 gym.make('CartPole-v1') 创建的环境对象 env 具有一个 reset() 方法,我们可以在每个回合之前使用它来重新初始化环境。调用 reset() 方法基本上会设置杆的起始状态(S[0]):

>>> 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 = 5 和n = 6,我们可以总结这个环境如图 19.7 所示:

图 19.7:一个网格世界环境的示例

在这个环境中,有 30 个不同的可能状态。其中四个状态是终止状态:在状态 16 有一个金子和三个陷阱在状态 10、15 和 22. 着陆在这四个终止状态中的任何一个将结束本集,但在金子状态和陷阱状态之间有所不同。着陆在金子状态会产生正面奖励,+1,而将代理移动到其中一个陷阱状态会产生负面奖励,–1. 所有其他状态的奖励为 0. 代理始终从状态 0 开始。因此,每次我们重置环境时,代理将返回状态 0. 行动空间包括四个方向:向上、向下、向左和向右。

当代理位于网格的外边界时,选择可能导致离开网格的动作不会改变状态。

接下来,我们将看到如何使用 OpenAI Gym 包在 Python 中实现此环境。

在 OpenAI Gym 中实现网格世界环境

对于通过 OpenAI Gym 进行网格世界环境实验,强烈建议使用脚本编辑器或 IDE,而不是交互式执行代码。

首先,我们创建一个名为 gridworld_env.py 的新 Python 脚本,然后导入必要的包和我们为构建环境可视化定义的两个辅助函数。

为了可视化环境,OpenAI Gym 库使用 pyglet 库并为我们提供了包装类和函数。我们将使用这些包装类来可视化网格世界环境。有关这些包装类的更多详细信息,请参阅:github.com/openai/gym/blob/58ed658d9b15fd410c50d1fdb25a7cad9acb7fa4/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 

使用 Gym 0.22 或更新版本

注意,gym 目前正在进行一些内部重构。在版本 0.22 及更新版本中,您可能需要更新之前的代码示例(来自 gridworld_env.py),并替换以下行

from gym.envs.classic_control import rendering 

使用以下代码:

from gym.utils import pyglet_rendering 

更多细节请参阅代码仓库:github.com/rasbt/machine-learning-book/tree/main/ch19

第一个辅助函数,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().__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() 

执行脚本后,您应该能看到网格世界环境的可视化,如图 19.8所示:

Une image contenant capture d’écran  Description générée automatiquement

图 19.8:我们网格世界环境的可视化

使用 Q 学习解决网格世界问题

在专注于强化学习算法的理论和开发过程,并通过 OpenAI Gym 工具包设置环境之后,我们现在将实现当前最流行的强化学习算法,Q-learning。为此,我们将使用已在脚本gridworld_env.py中实现的网格世界示例。

现在,我们创建一个新的脚本并命名为agent.py。在这个agent.py脚本中,我们定义了一个与环境交互的代理如下:

## Script: agent.py
from collections import defaultdict
import numpy as np
class Agent:
    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 epsilon
        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(f'Episode {episode}: Reward {final_reward:.2} '
              f'#Moves {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) 

执行此脚本将运行 50 个回合的 Q-learning 程序。代理的行为将被可视化,您可以看到在学习过程的开始阶段,代理大部分时间都会陷入陷阱状态。但随着时间的推移,它从失败中学习,最终找到黄金状态(例如,在第 7 个回合第一次)。图 19.9显示了代理的步数和奖励:

图 19.9:代理的步数和奖励

在前一个图中显示的绘制学习历史表明,代理在 30 个 episode 后学会了一条通往金状态的短路径。因此,30 个 episode 后的 episode 长度基本相同,由于贪婪策略的微小偏差。

一瞥深度 Q-learning

在前面的代码中,我们看到了一个用于网格世界示例的流行 Q-learning 算法的实现。这个示例由大小为 30 的离散状态空间组成,在 Python 字典中存储 Q 值足以满足要求。

但是,需要注意的是,有时状态数量可能非常大,可能几乎无限大。此外,我们可能会处理连续状态空间,而不是处理离散状态。此外,在训练过程中可能会有一些状态根本没有被访问到,这在后续将代理泛化到这些未见状态时可能会有问题。

为了解决这些问题,我们不再像V(S[t])或Q(S[t], A[t])中那样以表格格式表示值函数,而是采用函数逼近方法。在这里,我们定义一个参数化函数vw,它可以学习逼近真实的值函数,即,其中x[s]是一组输入特征(或“特征化”状态)。

当近似函数qw 是一个深度神经网络DNN)时,生成的模型被称为深度 Q 网络DQN)。对于训练 DQN 模型,权重根据 Q-learning 算法进行更新。DQN 模型的示例如图 19.10所示,其中状态被表示为传递给第一层的特征:

图 19.10: 一个 DQN 的示例

现在,让我们看看如何使用深度 Q-learning算法训练 DQN。总体上,主要方法与表格 Q-learning 方法非常相似。主要区别在于,我们现在有一个多层次神经网络来计算动作值。

根据 Q-learning 算法训练 DQN 模型

在本节中,我们描述了使用 Q-learning 算法训练 DQN 模型的过程。深度 Q-learning 方法要求我们对之前实现的标准 Q-learning 方法进行一些修改。

其中一个修改是在代理的choose_action()方法中,这个方法在前一节 Q-learning 的代码中只是访问存储在字典中的动作值。现在,这个函数应该更改为执行 NN 模型的前向传播以计算动作值。

深度 Q-learning 算法所需的其他修改在以下两个子节中进行了描述。

重放内存

使用先前的 Q 学习表格方法,我们可以更新特定状态-动作对的值而不影响其他值。然而,现在我们用 NN 模型来近似q(s, a),更新状态-动作对的权重可能会影响其他状态的输出。当使用随机梯度下降训练 NN 进行监督任务(例如分类任务)时,我们使用多个 epochs 多次迭代训练数据直到收敛。

这在 Q 学习中是不可行的,因为在训练过程中,剧集会发生变化,结果是一些在训练早期访问的状态在后期变得不太可能再被访问。

此外,另一个问题是,在训练 NN 时,我们假设训练样本是IID(独立同分布的)。然而,从代理的一个剧集中取样得到的样本并不是 IID,因为它们形成一个过渡序列。

为了解决这些问题,当代理与环境交互并生成一个过渡五元组qw`方法来移除列表的第一个元素)。然后,从内存缓冲区中随机选择一小批示例,用于计算损失和更新网络参数。图 19.11说明了这个过程:

图 19.11:重放记忆过程

实现重放记忆

可以使用 Python 列表实现重放记忆,每次向列表添加新元素时,需要检查列表的大小,并在需要时调用pop(0)

或者,我们可以使用 Python collections库中的deque数据结构,这样我们可以指定一个可选参数max_len。通过指定max_len参数,我们将拥有一个有界的 deque。因此,当对象满时,追加新元素会自动移除其中的一个元素。

注意,这比使用 Python 列表更高效,因为使用pop(0)来移除列表的第一个元素的复杂度是 O(n),而 deque 的运行时复杂度是 O(1)。您可以从官方文档中了解更多关于 deque 实现的信息,网址是docs.python.org/3.9/library/collections.html#collections.deque

确定用于计算损失的目标值

与表格 Q-learning 方法的另一个必要变化是如何调整用于训练 DQN 模型参数的更新规则。回想一下,存储在示例批次中的转移五元组 T 包含

图 19.12 所示,我们执行 DQN 模型的两次前向传递。第一次前向传递使用当前状态 (x[s]) 的特征。然后,第二次前向传递使用下一个状态的特征 ()。因此,我们将得到估计的动作值,,分别来自第一次和第二次前向传递。(这里, 表示所有动作的 Q 值向量在 中。)从转移五元组中,我们知道代理选择了动作 a

因此,根据 Q-learning 算法,我们需要更新对应于状态-动作对(x[s], a)的动作值,使用标量目标值 。而不是形成一个标量目标值,我们将创建一个目标动作值向量,保留其他动作的动作值,,如 图 19.12 所示:

图 19.12: 使用 DQN 确定目标值

我们将其视为一个回归问题,使用以下三个量:

  • 目前预测的值为,

  • 描述的目标值向量

  • 标准的均方误差MSE)损失函数

因此,对于除了 a 之外的每个动作,损失都为零。最后,计算得到的损失将反向传播到更新网络参数。

实施深度 Q-learning 算法

最后,我们将使用所有这些技术来实现一个深度 Q-learning 算法。这次,我们使用我们之前介绍的 OpenAI Gym 环境中的 CartPole 环境。回想一下,CartPole 环境具有大小为 4 的连续状态空间。在下面的代码中,我们定义一个名为 DQNAgent 的类,该类构建模型并指定各种超参数。

与基于表格 Q-learning 的先前代理相比,此类有两个额外的方法。remember() 方法将一个新的转移五元组追加到内存缓冲区中,而 replay() 方法将创建一个示例转移的小批量,并将其传递给 _learn() 方法来更新网络的权重参数:

import gym
import numpy as np
import torch
import torch.nn as nn
import random
import matplotlib.pyplot as plt
from collections import namedtuple
from collections import deque
np.random.seed(1)
torch.manual_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.env = 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):
        self.model = nn.Sequential(nn.Linear(self.state_size, 256),
                          nn.ReLU(),
                          nn.Linear(256, 128),
                          nn.ReLU(),
                          nn.Linear(128, 64),
                          nn.ReLU(),
                          nn.Linear(64, self.action_size))
        self.loss_fn = nn.MSELoss()
        self.optimizer = torch.optim.Adam(
                                self.model.parameters(), self.lr)
    def remember(self, transition):
        self.memory.append(transition)
    def choose_action(self, state):
        if np.random.rand() <= self.epsilon:
            return np.random.choice(self.action_size)
        with torch.no_grad():
            q_values = self.model(torch.tensor(state,
                              dtype=torch.float32))[0]
        return torch.argmax(q_values).item()  # returns action
    def _learn(self, batch_samples):
        batch_states, batch_targets = [], []
        for transition in batch_samples:
            s, a, r, next_s, done = transition
            with torch.no_grad():
                if done:
                    target = r
                else:
                    pred = self.model(torch.tensor(next_s,
                                  dtype=torch.float32))[0]
                    target = r + self.gamma * pred.max()
            target_all = self.model(torch.tensor(s,
                                    dtype=torch.float32))[0]
            target_all[a] = target
            batch_states.append(s.flatten())
            batch_targets.append(target_all)
            self._adjust_epsilon()
            self.optimizer.zero_grad()
            pred = self.model(torch.tensor(batch_states,
                              dtype=torch.float32))
            loss = self.loss_fn(pred, torch.stack(batch_targets))
            loss.backward()
            self.optimizer.step()
        return loss.item()
    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)
        return self._learn(samples) 

最后,使用以下代码,我们训练模型进行 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))+1
    plt.plot(episodes, history, 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(f'Episode: {e}/{EPISODES}, Total reward: {i}')
                break
            loss = agent.replay(batch_size)
            losses.append(loss)
    plot_learning_history(total_rewards) 

在对代理进行了 200 个周期的训练后,我们看到代理确实学会了随时间增加总奖励,如 图 19.13 所示:

图 19.13: 代理的奖励随时间增加

注意,在一个 episode 中获得的总奖励等于代理能够平衡杆子的时间量。本图中显示的学习历史表明,在大约 30 个 episode 之后,代理学会如何平衡杆子并保持超过 200 个时间步长。

章节和书籍总结

在本章中,我们涵盖了 RL 的基本概念,从最基础的开始,并探讨了 RL 如何支持复杂环境中的决策制定。

我们学习了关于代理-环境交互和马尔可夫决策过程MDPs)的知识,并考虑了三种主要的 RL 问题解决方法:动态规划、MC 学习和 TD 学习。我们讨论了动态规划算法假设环境动态的全部知识是可用的,这种假设通常不适用于大多数真实世界的问题。

接着,我们看到了基于 MC 和 TD 的算法是通过允许代理与环境交互并生成模拟经验来学习的。在讨论了基础理论之后,我们将 Q-learning 算法实现为 TD 算法的一种离策略子类,用于解决网格世界示例。最后,我们介绍了函数逼近的概念,特别是深度 Q-learning,可以用于具有大型或连续状态空间的问题。

希望您喜欢本书的最后一章《Python 机器学习》和我们对机器学习和深度学习的精彩探索。在这本书的旅程中,我们涵盖了该领域提供的基本主题,现在您应该已经掌握了将这些技术应用于解决实际问题的能力。

我们从《第一章》,《让计算机从数据中学习能力》开始我们的旅程,简要概述了不同类型的学习任务:监督学习、强化学习和无监督学习。

然后,我们讨论了几种不同的学习算法,您可以在分类中使用,从简单的单层 NNs 开始,见《第二章》,《分类的简单机器学习算法训练》。

我们在《第三章》,《使用 Scikit-Learn 进行机器学习分类器之旅》中继续讨论了高级分类算法,并在《第四章》,《构建良好的训练数据集 - 数据预处理》和《第五章》,《通过降维压缩数据》中学习了机器学习流程的最重要方面。

请记住,即使是最先进的算法也受其从训练数据中获取的信息的限制。因此,在《第六章》,《学习模型评估和超参数调优的最佳实践》中,我们学习了构建和评估预测模型的最佳实践,这是机器学习应用中的另一个重要方面。

如果单一的学习算法未能达到我们期望的性能,有时创建一组专家来进行预测可能会有所帮助。我们在第七章组合不同模型进行集成学习中进行了探索。

接着,在第八章应用机器学习进行情感分析,我们应用机器学习分析了现代社交媒体平台上最流行和有趣的数据形式之一——文本文档。

大部分时间我们关注的是分类算法,这可能是机器学习最流行的应用之一。然而,我们的旅程并未止步于此!在第九章使用回归分析预测连续目标变量,我们探索了几种回归分析算法来预测连续目标变量。

机器学习的另一个令人兴奋的子领域是聚类分析,可以帮助我们发现数据中的隐藏结构,即使我们的训练数据没有正确答案可供学习。我们在第十章处理未标记数据——聚类分析中探讨了这一点。

我们随后将注意力转向了整个机器学习领域中最激动人心的算法之一——人工神经网络。我们首先在第十一章从头开始实现多层感知器,使用 NumPy 从头开始实现了多层人工神经网络。

第十二章使用 PyTorch 并行化神经网络训练,我们发现了 PyTorch 在深度学习中的明显优势,使用 PyTorch 简化了构建神经网络模型的过程,操作了 PyTorch 的Dataset对象,并学习了如何对数据集应用预处理步骤。

我们在第十三章深入理解 PyTorch 的机制中,更深入地探讨了 PyTorch 的机制,包括张量对象、计算梯度以及神经网络模块torch.nn

第十四章使用深度卷积神经网络对图像进行分类,我们深入研究了卷积神经网络,这在当前计算机视觉领域中被广泛应用,因其在图像分类任务中表现出色。

第十五章使用循环神经网络对序列数据建模,我们学习了使用 RNN 对序列进行建模。

第十六章使用注意力机制改进自然语言处理——Transformer,我们引入了注意力机制来解决 RNN 的一个弱点,即在处理长序列时记住先前的输入元素。随后我们探索了各种 Transformer 架构,这些深度学习架构以自注意机制为核心,是创建大规模语言模型的最新技术。

第十七章生成对抗网络用于合成新数据 中,我们看到了如何使用 GAN 生成新图像,同时也学习了关于自编码器、批归一化、转置卷积和 Wasserstein GAN 的知识。

前几章围绕表格数据集以及文本和图像数据展开。在 第十八章用于捕获图结构数据中的依赖关系的图神经网络 中,我们专注于图结构数据的深度学习,这是社交网络和分子(化学化合物)常用的数据表示。此外,我们还学习了所谓的图神经网络,这是与此类数据兼容的深度神经网络。

最后,在本章中,我们涵盖了一类单独的机器学习任务,并看到了如何开发通过奖励过程与其环境进行交互学习的算法。

虽然深入研究深度学习远远超出了本书的范围,但我们希望已经引起了您对追踪这一领域最新进展的兴趣。

如果您考虑从事机器学习的职业,或者只是想跟上这一领域的最新进展,我们建议您关注最近在该领域发表的文献。以下是一些我们认为特别有用的资源:

最后,您可以在以下网站了解我们,即作者,正在做些什么:

如果您对本书有任何问题,或者需要一些关于机器学习的一般提示,随时欢迎联系我们。

加入我们书籍的 Discord 空间

加入书籍的 Discord 工作空间,与作者进行每月的 问答 会话:

packt.link/MLwPyTorch

posted @ 2024-07-23 14:53  绝不原创的飞龙  阅读(0)  评论(0编辑  收藏  举报