Python-深度学习第三版-全-
Python 深度学习第三版(全)
原文:
annas-archive.org/md5/c9f2178832e6a58ea7603b6cecb80b22
译者:飞龙
序言
本书将从深度神经网络(NN)的理论基础开始,深入探讨最流行的网络架构——变压器(transformers)、基于变压器的大型语言模型(LLMs)和卷积网络。它将通过各种计算机视觉和自然语言处理(NLP)的实例来介绍这些模型,包括最先进的应用,如文本生成图像和聊天机器人。
每一章都包括一个全面的理论介绍,作为主体内容。接着是代码示例,用于验证所展示的理论,提供读者实际的动手经验。示例使用 PyTorch、Keras 或 Hugging Face Transformers 执行。
本书适用人群
本书面向已经熟悉编程的读者——软件开发人员/工程师、学生、数据科学家、数据分析师、机器学习工程师、统计学家,以及任何有 Python 编程经验并对深度学习感兴趣的人。它的设计适合那些对深度学习有最小预备知识的读者,语言清晰简洁。
本书内容
第一章,机器学习——简介,讨论了基本的机器学习范式。它将探讨各种机器学习算法,并介绍第一个神经网络,使用 PyTorch 实现。
第二章,神经网络,首先介绍与神经网络相关的数学分支——线性代数、概率论和微积分。它将重点讲解神经网络的构建模块和结构。还将讨论如何使用梯度下降法和反向传播训练神经网络。
第三章,深度学习基础,介绍了深度学习的基本范式。它将从经典网络过渡到深度神经网络,概述开发和使用深度网络的挑战,并讨论如何解决这些问题。
第四章,卷积网络与计算机视觉,介绍了卷积网络——计算机视觉应用的主要网络架构。它将详细讨论卷积网络的特性和构建模块,并介绍当今最流行的卷积网络模型。
第五章,高级计算机视觉应用,讨论了卷积网络在高级计算机视觉任务中的应用——目标检测和图像分割。它还将探讨如何使用神经网络生成新图像。
第六章,自然语言处理与循环神经网络,介绍了 NLP 的主要范式和数据处理流程。它还将探讨循环神经网络及其两种最流行的变种——长短期记忆(LSTM)和门控循环单元(GRU)。
第七章,注意力机制与变换器,介绍了近年来深度学习领域最重要的进展之一——注意力机制以及围绕它构建的变换器模型。
第八章,深入探索大型语言模型,介绍了基于变换器的大型语言模型(LLM)。它将讨论这些模型的特点以及它们与其他 NN 模型的区别,并将介绍 Hugging Face Transformers 库。
第九章,大型语言模型的高级应用,讨论了使用 LLM 进行计算机视觉任务。它将专注于经典任务,如图像分类和目标检测,同时也会探索最前沿的应用,如图像生成。它还将介绍 LangChain 框架,帮助开发基于 LLM 的应用。
第十章,机器学习操作(MLOps),将介绍各种库和技术,帮助更轻松地开发和部署 NN 模型到生产环境中。
为了最大化本书的价值
本书中的许多代码示例需要 GPU 的支持。如果你没有 GPU,也不用担心。为了避免硬件限制,所有的代码示例都提供了 Jupyter 笔记本,并可以在 Google Colab 上执行。因此,即使你的硬件不足以运行示例,你仍然可以在 Colab 上运行它们。
本书涵盖的 软件/硬件 | 操作系统要求 |
---|---|
PyTorch 2.0.1 | Windows、macOS 或 Linux |
TensorFlow 2.13 | Windows(遗留支持)、macOS 或 Linux |
Hugging Face Transformers 4.33 | Windows、macOS 或 Linux |
本书中的某些代码示例可能使用了表格中未列出的额外包。你可以在书籍的 GitHub 仓库中的 requirements.txt 文件里查看完整的包列表(包括版本号)。
如果你使用的是本书的电子版,我们建议你自己输入代码,或者访问本书的 GitHub 仓库获取代码(下一个章节会提供链接)。这样做可以帮助你避免复制粘贴代码时可能出现的错误。
下载示例代码文件
你可以从 GitHub 下载本书的示例代码文件,地址为 github.com/PacktPublishing/Python-Deep-Learning-Third-Edition/
。如果代码有更新,GitHub 仓库中会及时更新。
我们还提供了来自我们丰富图书和视频目录的其他代码包,访问 github.com/PacktPublishing/
查看!
使用的约定
本书中使用了多种文本约定。
文中的代码
:表示文中的代码词汇、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟网址、用户输入和 Twitter 用户名。例如:“使用 opencv-python
读取位于 image_file_path
的 RGB 图像。”
一段代码块如下所示:
def build_fe_model():
""""Create feature extraction model from the pre-trained model ResNet50V2"""
# create the pre-trained part of the network, excluding FC layers
base_model = tf.keras.applications.MobileNetV3Small(
任何命令行输入或输出如下所示:
import tensorflow.keras
当我们希望您关注代码块的某一部分时,相关的行或项目会用粗体标出:
import io
image = Image.open(io.BytesIO(response.content))
image.show()
提示或重要注意事项
以这种方式显示。
联系我们
我们始终欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请发送电子邮件至 customercare@packtpub.com,并在邮件主题中提及书名。
勘误:虽然我们已尽最大努力确保内容的准确性,但难免会有错误。如果您在本书中发现了错误,我们将不胜感激,您可以将其报告给我们。请访问 www.packtpub.com/support/errata 并填写表格。
盗版:如果您在互联网上遇到任何形式的非法复制作品,我们将非常感激您能提供该材料的位置或网站名称。请通过 copyright@packt.com 与我们联系,并附上相关链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识并且有兴趣撰写或贡献书籍,请访问 authors.packtpub.com.
分享您的想法
阅读完Python 深度学习(第三版)后,我们很想听听您的想法!请点击这里直接访问本书的亚马逊评价页面并分享您的反馈。
您的评价对我们和技术社区非常重要,它将帮助我们确保提供优质的内容。
下载本书的免费 PDF 副本
感谢您购买本书!
您喜欢随时随地阅读,但无法随身携带纸质书籍吗?
您的电子书购买是否与您选择的设备不兼容?
不用担心,现在每本 Packt 书籍您都能免费获得该书的无 DRM 限制的 PDF 版本。
在任何地方,任何设备上阅读。搜索、复制并将您最喜欢的技术书籍中的代码直接粘贴到您的应用程序中。
福利还不止这些,您可以独享折扣、新闻简报和每天通过电子邮件收到的精彩免费内容。
按照这些简单步骤来获得福利:
- 扫描二维码或访问以下链接
packt.link/free-ebook/9781837638505
-
提交您的购买凭证
-
就这样!我们会直接将您的免费 PDF 和其他福利发送到您的电子邮件。
第一部分:
介绍
到神经网络
我们将从介绍基本的机器学习理论和概念开始,然后深入介绍神经网络——一种特殊类型的机器学习算法。
我们将讨论它们背后的数学原理,并学习如何训练它们。最后,我们将从浅层网络过渡到深层网络。
本部分包含以下章节:
-
第一章, 机器学习导论
-
第二章, 神经网络
-
第三章, 深度学习基础
第一章:机器学习——简介
机器学习(ML)技术正在广泛应用于各个领域,数据科学家在许多不同的行业中需求量很大。通过机器学习,我们识别出从数据中获得不易察觉的知识以做出决策的过程。机器学习技术的应用领域各不相同,涵盖了医学、金融、广告等多个学科。
在本章中,我们将介绍不同的机器学习方法、技术及其在实际问题中的一些应用,同时也将介绍 Python 中一个主要的开源机器学习包——PyTorch。这为后续章节奠定了基础,在这些章节中,我们将专注于使用神经网络(NNs)的某种类型的机器学习方法,特别是我们将重点讲解深度学习(DL)。
深度学习(DL)采用了比以往更先进的神经网络(NN)。这不仅是近年来理论发展的结果,也是计算机硬件进步的体现。本章将总结机器学习(ML)的基本概念及其应用,帮助你更好地理解深度学习(DL)如何区别于传统的流行机器学习技术。
本章我们将讨论以下主要内容:
-
机器学习简介
-
不同的机器学习方法
-
神经网络
-
PyTorch 简介
技术要求
我们将在本章中使用 Python 和 PyTorch 实现示例。如果你还没有配置好相关环境,也不必担心——示例代码已作为 Jupyter 笔记本提供在 Google Colab 上。你可以在本书的 GitHub 仓库中找到代码示例:github.com/PacktPublishing/Python-Deep-Learning-Third-Edition/tree/main/Chapter01
。
机器学习简介
机器学习(ML)通常与大数据和人工智能(AI)等术语相关联。然而,这两者与机器学习是完全不同的。要理解机器学习是什么以及它为何有用,首先需要了解大数据是什么,以及机器学习如何应用于大数据。
大数据是一个用来描述由大量数据积累和存储所产生的大型数据集的术语。例如,这些数据可能来源于摄像头、传感器或社交网络网站。
我们每天创造多少数据?
据估计,仅谷歌每天处理的信息量就超过 20PB,而且这个数字还在不断增加。几年前,福布斯估计,每天创造的数据量为 2.5 万亿字节,而全球 90%的数据是在过去两年内生成的。
人类无法独立掌握,更不用说分析如此庞大的数据量,机器学习技术就是用来理解这些非常大的数据集的工具。机器学习是用于大规模数据处理的工具。它非常适合具有大量变量和特征的复杂数据集。许多机器学习技术,尤其是深度学习的优势之一是,它们在大数据集上使用时表现最佳,从而提高了它们的分析和预测能力。换句话说,机器学习技术,特别是深度学习神经网络,最擅长在能够访问大量数据集的情况下进行学习,在这些数据集中它们能够发现隐藏的模式和规律。
另一方面,机器学习的预测能力可以成功地应用到人工智能(AI)系统中。机器学习可以被认为是人工智能系统的大脑。人工智能可以定义为(尽管这种定义可能不唯一)一种能够与其环境进行交互的系统。此外,AI 机器配备了传感器,使它们能够了解所处的环境,并配有工具来与环境进行交互。因此,机器学习就是让机器分析通过传感器获取的数据并得出合适答案的大脑。一个简单的例子是 iPhone 上的 Siri。Siri 通过麦克风接收命令,并通过扬声器或显示屏输出答案,但为了做到这一点,它需要理解收到的命令。类似地,自动驾驶汽车将配备摄像头、GPS 系统、声纳和激光雷达,但所有这些信息都需要经过处理才能提供正确的答案。这可能包括是否加速、刹车或转弯。机器学习是这种信息处理方法,能够得出最终答案。
我们已经解释了什么是机器学习(ML),那深度学习(DL)呢?目前,我们暂且说深度学习是机器学习的一个子领域。深度学习方法具有一些特殊的共同特征。最著名的代表方法就是深度神经网络(NN)。
不同的机器学习方法
如我们所见,机器学习(ML)这一术语被广泛使用,它指的是从大量数据集中推断模式的通用技术,或者是基于分析已知数据来对新数据进行预测的能力。机器学习技术大致可以分为两大核心类别,此外通常还会添加一个类别。以下是这些类别:
-
监督学习
-
无监督学习
-
强化学习
让我们来仔细看看。
监督学习
监督学习算法是一类机器学习算法,它们使用之前标注过的数据来学习其特征,以便能够对类似但未标注的数据进行分类。让我们通过一个例子来更好地理解这个概念。
假设一个用户每天会收到许多电子邮件,其中一些是重要的商务邮件,而另一些则是未经请求的垃圾邮件,也就是垃圾邮件。监督式机器学习算法会接收到大量已经由“教师”标注为垃圾邮件或非垃圾邮件的电子邮件(这叫做训练数据)。对于每个样本,机器将尝试预测该邮件是否是垃圾邮件,并将预测结果与原始目标标签进行比较。如果预测结果与目标不符,机器将调整其内部参数,以便下次遇到该样本时,能够正确分类。相反,如果预测正确,参数将保持不变。我们给算法提供的训练数据越多,它的表现就会越好(但这一规则也有例外,稍后我们会讨论)。
在我们使用的例子中,电子邮件只有两个类别(垃圾邮件或非垃圾邮件),但是相同的原则适用于具有任意类别(或分类)的任务。例如,Google 提供的免费电子邮件服务 Gmail 允许用户选择最多五个类别,分类标签如下:
-
主要:包括人与人之间的对话
-
促销:包括营销邮件、优惠和折扣
-
社交:包括来自社交网络和媒体共享网站的消息
-
更新:包括账单、银行对账单和收据
-
论坛:包括来自在线小组和邮件列表的消息
总结来说,机器学习任务是将一组输入值映射到有限数量的类别,这个任务叫做分类。
在某些情况下,结果可能不一定是离散的,我们可能没有有限数量的类别来对数据进行分类。例如,我们可能尝试根据预定的健康参数预测一组人的预期寿命。在这种情况下,结果是一个数值,我们不再谈论分类,而是谈论回归。
一种看待监督学习的方法是想象我们正在构建一个函数,f,该函数定义在一个数据集上,数据集由特征组织的信息组成。在电子邮件分类的例子中,特征可以是一些在垃圾邮件中出现频率高于其他单词的特定词汇。显式的性别相关词汇最有可能识别出垃圾邮件,而不是商务/工作邮件。相反,像meeting、business或presentation这样的词更有可能描述工作邮件。如果我们可以访问元数据,我们也可以使用发件人的信息作为特征。每封电子邮件将具有一组相关的特征,每个特征将有一个值(在这种情况下,就是特定单词在电子邮件正文中出现的次数)。然后,机器学习算法将尝试将这些值映射到一个离散的范围,该范围代表一组类别,或者在回归的情况下,映射到一个实数值。f函数的定义如下:
在后续章节中,我们将看到一些分类或回归问题的示例。我们将讨论的一个问题是对修改后的国家标准与技术研究所(MNIST)数据库中的手写数字进行分类(yann.lecun.com/exdb/mnist/
)。当给定一个表示 0 到 9 的图像集合时,机器学习算法将尝试将每个图像分类到 10 个类别之一,每个类别对应 10 个数字中的一个。每个图像的大小为 28×28 (= 784) 像素。如果我们把每个像素看作一个特征,那么算法将使用 784 维的特征空间来分类这些数字。
下图展示了来自 MNIST 数据集的手写数字:
图 1.1 – 来自 MNIST 数据集的手写数字示例
在接下来的章节中,我们将讨论一些最受欢迎的经典监督学习算法。以下内容绝非详尽无遗,也不是每种机器学习方法的深入描述。我们建议参考 Sebastian Raschka 所著的《Python 机器学习》一书(www.packtpub.com/product/python-machine-learning-third-edition/9781789955750
)。这是一本简单的回顾,旨在为你提供不同机器学习技术的概览,特别是在 Python 中的应用。
线性回归与逻辑回归
回归算法是一种监督学习算法,它利用输入数据的特征来预测一个数值,例如在给定房屋的大小、年龄、浴室数量、楼层数和位置等特征的情况下,预测房屋的价格。回归分析试图找到使输入数据集最符合的函数参数值。
在线性回归算法中,目标是通过在输入数据上找到适当的参数,使得函数最小化代价函数,从而最好地逼近目标值。代价函数是误差的函数——即,我们离正确结果有多远。一个常见的代价函数是均方误差(MSE),它通过取预期值与预测结果之间差值的平方来计算。所有输入示例的和给出了算法的误差,并代表了代价函数。
假设我们有一栋 100 平方米的房子,它建于 25 年前,有三个浴室和两层楼。我们还假设这座城市分为 10 个不同的社区,我们用 1 到 10 的整数来表示这些社区,假设这座房子位于编号为 7 的区域。我们可以用一个五维向量来表示这栋房子,。假设我们还知道这栋房子的估计价值为$100,000(在今天的世界里,这可能只够在北极附近买一座小屋,但我们假设如此)。我们想要创建一个函数,f,使得
。
鼓励的话
如果你没有完全理解本节中的某些术语,不用担心。我们将在第二章中更详细地讨论向量、成本函数、线性回归和梯度下降。我们还将看到,训练神经网络(NN)和线性/逻辑回归有很多相似之处。现在,你可以将向量看作一个数组。我们将用粗体字表示向量——例如,。我们将用斜体字和下标表示向量的元素——例如,
。
在线性回归中,这意味着找到一个权重向量,,使得向量的点积,
,将是
或者
。如果我们有 1000 栋房子,我们可以对每栋房子重复相同的过程,理想情况下,我们希望找到一个单一的向量,w,能够预测每栋房子足够接近的正确值。训练线性回归模型的最常见方法可以在以下伪代码块中看到:
Initialize the vector w with some random values
repeat:
E = 0 # initialize the cost function E with 0
for every pair of the training set:
# here is the real house price
# Mean Square Error
use gradient descent to update the weights w based on MSE until MSE falls below threshold
首先,我们遍历训练数据来计算代价函数 MSE。一旦我们知道了 MSE 的值,就会使用 梯度下降 算法来更新向量的权重,w。为此,我们将计算代价函数关于每个权重的导数,。通过这种方式,我们就能知道代价函数如何相对于
发生变化(增加或减少)。然后,我们将相应地更新该权重的值。
之前,我们演示了如何使用线性回归解决回归问题。现在,让我们来处理一个分类任务:尝试判断一座房子是被高估还是低估。在这种情况下,目标数据将是分类的[1, 0]——1 代表高估,0 代表低估。房子的价格将作为输入参数,而不再是目标值。为了解决这个问题,我们将使用逻辑回归。这与线性回归类似,但有一个区别:在线性回归中,输出是 。然而,在这里,输出将是一个特殊的逻辑函数 (
en.wikipedia.org/wiki/Logistic_function
),。这将把
的值压缩到 (0:1) 区间。你可以将逻辑函数看作是一个概率,结果越接近 1,房子被高估的可能性越大,反之亦然。训练过程与线性回归相同,但函数的输出在 (0:1) 区间内,标签要么是 0,要么是 1。
逻辑回归不是一种分类算法,但我们可以将其转化为分类算法。我们只需引入一条规则,根据逻辑函数的输出确定类别。例如,如果值为 时,我们可以认为一所房子被高估,否则它被低估。
多元回归
本节中的回归示例具有单一的数值输出。回归分析可以有多个输出。我们将这种分析称为多元回归。
支持向量机
支持向量机(SVM)是一种监督式机器学习算法,主要用于分类。它是核方法算法类中最流行的成员。SVM 试图找到一个超平面,将数据集中的样本分隔开。
超平面
超平面是一个高维空间中的平面。例如,单维空间中的超平面是一个点,二维空间中的超平面是直线。在三维空间中,超平面是一个平面,而在四维空间中我们无法直观地想象超平面,但我们知道它的存在。
我们可以将分类看作是寻找一个超平面的过程,该超平面能够分隔不同的点集。一旦我们定义了特征,数据集中的每个样本(在我们的例子中是一个电子邮件)都可以看作是特征多维空间中的一个点。该空间的一维代表一个特征的所有可能值。一个点(一个样本)的坐标是该样本每个特征的具体值。机器学习算法的任务是绘制一个超平面,将不同类别的点分开。在我们的例子中,超平面将垃圾邮件与非垃圾邮件分开。
在下图中,顶部和底部展示了两类点(红色和蓝色),这些点位于二维特征空间中(x 和 y 轴)。如果一个点的 x 和 y 值都低于 5,则该点为蓝色。其他情况下,该点为红色。在这种情况下,类别是线性可分的,这意味着我们可以用一个超平面将它们分开。相反,右图中的类别是线性不可分的:
图 1.2 – 一组线性可分的点集(左)和一组线性不可分的点集(右)
支持向量机(SVM)尝试找到一个超平面,使得它与各个点之间的距离最大。换句话说,在所有可以分开样本的超平面中,SVM 找到的是那个与所有点的距离最大的超平面。此外,SVM 还可以处理非线性可分的数据。有两种方法可以实现这一点:引入软间隔或使用核技巧。
软间隔通过允许少数被误分类的元素,同时保持算法的最强预测能力来工作。在实际应用中,最好避免对机器学习模型过拟合,我们可以通过放宽一些支持向量机(SVM)的假设来做到这一点。
核技巧通过不同的方式解决相同的问题。假设我们有一个二维特征空间,但类别是线性不可分的。核技巧使用一个核函数,通过为数据添加更多的维度来对其进行转换。在我们的例子中,经过转换后,数据将变成三维的。二维空间中线性不可分的类别在三维空间中将变得线性可分,我们的问题得以解决:
图 1.3 – 在应用核技巧之前的非线性可分数据集(左),以及应用核技巧后的同一数据集,数据变得线性可分(右)
让我们来看看列表中的最后一个,决策树。
决策树
另一个流行的监督算法是决策树,它创建一个树形的分类器。决策树由决策节点组成,在这些节点上进行特征测试,以及叶节点,表示目标属性的值。为了分类一个新样本,我们从树的根节点开始,沿着节点向下导航,直到到达叶节点。
该算法的一个经典应用是鸢尾花数据集(archive.ics.uci.edu/ml/datasets/Iris
),其中包含来自三种鸢尾花的 50 个样本的数据。
(鸢尾花 Setosa, 鸢尾花 Virginica, 和 鸢尾花 Versicolor)。创建该数据集的罗纳德·费舍尔(Ronald Fisher)测量了这三种花的四个不同特征:
-
它们萼片的长度
-
它们萼片的宽度
-
它们花瓣的长度
-
它们花瓣的宽度
基于这些特征的不同组合,可以创建一个决策树来决定每朵花属于哪个物种。在下图中,我们定义了一个决策树,它将仅使用这两个特征——花瓣长度和宽度,正确地分类几乎所有的花:
图 1.4 – 用于分类鸢尾花数据集的决策树
要对一个新样本进行分类,我们从树的根节点(花瓣长度)开始。如果样本满足条件,我们就向左走到叶子节点,表示 Iris Setosa 类别。如果不满足条件,我们就向右走到一个新节点(花瓣宽度)。这个过程会一直进行,直到我们到达叶子节点。
近年来,决策树经历了两项重大改进。第一项是随机森林,这是一种集成方法,它结合了多个决策树的预测结果。第二项是一个叫做梯度提升的算法类,它通过创建多个连续的决策树,每棵树都试图改进前一棵树所犯的错误。由于这些改进,决策树在处理某些类型的数据时变得非常流行。例如,它们是 Kaggle 比赛中最常用的算法之一。
无监督学习
第二类机器学习算法是无监督学习。在这里,我们不事先标注数据;相反,我们让算法自行得出结论。无监督学习算法相对于监督学习算法的一个优势是,我们不需要标签数据。为监督算法生成标签可能既昂贵又缓慢。解决这个问题的一种方法是修改监督算法,使其使用更少的标注数据;对此有不同的技术。但另一种方法是使用一种根本不需要标签的算法。在本节中,我们将讨论一些无监督算法。
聚类
无监督学习最常见、也许是最简单的例子之一是聚类。这是一种尝试将数据分成多个子集的技术。
为了说明这一点,让我们将垃圾邮件与非垃圾邮件的电子邮件分类看作一个无监督学习问题。在监督学习的情况下,对于每封电子邮件,我们都有一组特征和一个标签(垃圾邮件或非垃圾邮件)。在这里,我们将使用相同的特征集,但电子邮件不会被标注。相反,我们会让算法在给定特征集的情况下,将每个样本分配到两个独立的组(或簇)之一。然后,算法会尝试以一种方式组合这些样本,使得同一簇内的相似度(即同一簇内样本的相似性)很高,而不同簇之间的相似度很低。不同的聚类算法使用不同的度量标准来衡量相似度。对于一些更高级的算法,你甚至不需要指定簇的数量。
以下图示显示了如何将一组点分类为三个子集:
图 1.5 – 将一组点聚类为三个子集
K-means
K-means 是一种聚类算法,它将数据集中的元素分成 k 个不同的簇(因此名称中有一个 k)。它的工作原理如下:
-
从特征空间中选择 k 个随机点,称为质心,它们将代表每个 k 个簇的中心。
-
将数据集中的每个样本(即特征空间中的每个点)分配给与其最接近的质心所在的簇。
-
对于每个簇,我们通过计算簇内所有点的均值来重新计算新的质心。
-
使用新的质心,我们重复执行 步骤 2 和 步骤 3,直到满足停止准则。
上述方法对初始随机质心的选择非常敏感,可能需要用不同的初始选择重复进行。也有可能某些质心离数据集中的任何点都很远,从而减少簇的数量,低于 k。最后值得一提的是,如果我们在鸢尾花数据集上使用 k-means 并设置 k=3,我们可能会得到与之前介绍的决策树分布不同的样本分布。再次强调,这突显了为每个问题选择和使用正确的机器学习方法的重要性。
现在,让我们讨论一个使用 k-means 聚类的实际例子。假设一个披萨配送点想在城市中开设四个新加盟店,并需要选择站点的位置。我们可以通过 k-means 来解决这个问题:
-
找出披萨订单最多的地点,这些地点将作为我们的数据点。
-
选择四个随机点作为新站点的位置。
-
通过使用 k-means 聚类,我们可以识别出四个最佳位置,最小化与每个配送地点的距离:
图 1.6 – 披萨配送最多的地点分布(左);圆点表示新加盟店应选择的位置及其相应的配送区域(右)
自监督学习
自监督学习指的是通过一组合适的问题和数据集,自动生成(即无需人工干预)标注数据。一旦我们拥有这些标签,就可以训练一个监督算法来解决我们的任务。为了更好地理解这个概念,让我们讨论一些应用场景:
-
时间序列预测:假设我们需要基于时间序列的最新历史数据来预测其未来的值。此类例子包括股票(以及现在的加密货币)价格预测和天气预测。为了生成一个标注的数据样本,我们将选取一个长度为 k 的历史数据窗口,窗口以过去时刻 t 作为终点。我们将使用时间区间 [t – k; t] 内的历史值作为监督算法的输入,同时取时刻 t + 1 的历史值作为给定输入样本的标签。我们可以对其余的历史值应用这种划分方式,自动生成标注的训练数据集。
-
自然语言处理(NLP):类似于时间序列,自然文本代表了一个单词(或标记)序列。我们可以训练一个 NLP 算法,基于前面的k个单词来预测下一个单词,方式与时间序列类似。然而,自然文本不像时间序列那样有严格的过去/未来划分。正因为如此,我们可以使用目标单词周围的整个上下文作为输入——也就是说,不仅仅是前面的单词,还包括目标单词后面的单词。如我们将在第六章中看到的,这种技术是当代 NLP 算法的基础。
-
自编码器:这是一种特殊类型的神经网络,旨在重建其输入。换句话说,自编码器的目标值(标签)等于输入数据,
,其中i是样本索引。我们可以正式地说,它试图学习一个恒等函数(即重复其输入的函数)。由于我们的标签只是输入数据,自编码器是一个无监督算法。你可能会想,试图预测其输入的算法有什么意义。自编码器分为两部分——编码器和解码器。首先,编码器尝试将输入数据压缩成比输入本身更小的向量。接着,解码器根据这个较小的内部状态向量重建原始输入。通过设定这个限制,自编码器被迫仅提取输入数据中最重要的特征。自编码器的目标是学习一种比原始表示更高效或更紧凑的数据表示,同时尽可能保留原始信息。
自监督学习的另一个有趣应用是生成模型,而非判别模型。让我们讨论这两者之间的区别。给定输入数据,判别模型会将其映射到一个特定的标签(换句话说,就是分类或回归)。一个典型的例子是将 MNIST 图像分类为 10 个数字类别中的一个,其中神经网络将输入数据特征(像素强度)映射到数字标签。我们也可以用另一种方式来表达:判别模型给出的是在给定* x (输入)时, y *(类别)的概率 —— 。在 MNIST 的情况下,这是在给定图像的像素强度时,数字的概率。
另一方面,生成模型学习类的分布情况。你可以将它视为与判别模型的相反。它不是在给定某些输入特征的情况下预测类的概率 y,而是尝试在给定某个类 y 时预测输入特征的概率 – 。例如,生成模型能够在给定数字类别时生成手写数字的图像。由于我们只有 10 个类别,它将仅能生成 10 张图像。然而,我们只是用这个例子来说明这一概念。实际上,类别可以是一个任意的张量值,模型将能够生成无限数量的具有不同特征的图像。如果你现在不理解这一点,别担心;我们将在第五章中再次讨论这个话题。在第八章和第九章中,我们将讨论变换器(新型神经网络)如何被用于生成一些令人印象深刻的生成模型。由于它们产生的吸引人结果,变换器在研究界和主流公众中都获得了很大的关注。最受欢迎的两种视觉模型是稳定扩散(
github.com/CompVis/stable-diffusion
),由 Stability AI(stability.ai/
)开发,以及DALL-E(openai.com/dall-e-2/
),由 OpenAI 开发,它们可以从自然语言描述生成逼真的或艺术性的图像。当提示文本为弹吉他的音乐青蛙时,稳定扩散生成以下图像:
图 1.7 – 稳定扩散对“弹吉他的音乐青蛙”提示的输出
另一个有趣的生成模型是 OpenAI 的ChatGPT(GPT 代表生成预训练变换器),它(正如其名字所示)充当一个智能聊天机器人。ChatGPT 能够回答后续问题、承认错误、挑战不正确的前提,并拒绝不当的请求。
强化学习
第三类机器学习技术叫做强化学习(RL)。我们将通过一个强化学习的最流行应用来说明这一点:教机器如何玩游戏。机器(或智能体)与游戏(或环境)进行互动。智能体的目标是赢得游戏。为此,智能体采取能够改变环境状态的行动。环境对智能体的行动做出反应,并为其提供奖励(或惩罚)信号,帮助智能体决定下一步的行动。赢得游戏将提供最大的奖励。用正式的术语来说,智能体的目标是最大化它在整个游戏过程中获得的总奖励:
图 1.8 – 强化学习系统中不同元素之间的交互
让我们把国际象棋游戏看作一个强化学习(RL)问题。在这里,环境将包括棋盘以及棋子的具体位置。我们的智能体的目标是击败对手。当智能体捕获对方的棋子时,它会获得奖励,如果将对方将死,则会获得最大奖励。相反,如果对方捕获了智能体的棋子或将其将死,奖励将是负数。然而,作为更大策略的一部分,玩家们必须做出既不捕获对方棋子也不将死对方国王的行动。在这种情况下,智能体将不会收到任何奖励。如果这是一个监督学习问题,我们将需要为每一步提供标签或奖励。但在强化学习中并非如此。在强化学习框架中,智能体将通过试错法即兴决定其下一步行动。
让我们再举一个例子,有时候我们不得不牺牲一个兵,以实现更重要的目标(比如棋盘上的更好位置)。在这种情况下,我们的聪明智能体必须足够聪明,将短期的损失视为长期的收益。在一个更极端的例子中,假设我们很不幸地与现任世界象棋冠军丁立人对弈。毫无疑问,在这种情况下,智能体会输。然而,我们如何知道哪些步伐是错误的,并导致了智能体的失败呢?国际象棋属于一种问题类型,其中需要将整个游戏视为一个整体来寻找成功的解决方案,而不是仅仅看每个行动的即时后果。强化学习将为我们提供框架,帮助智能体在这个复杂的环境中导航并进行学习。
这个新获得的行动自由带来了一个有趣的问题。假设代理已经学会了一种成功的棋类策略(或者用 RL 术语说,是策略)。经过若干场比赛后,对手可能猜到这一策略并设法战胜我们。现在代理将面临一个困境,需要做出以下决策:要么继续遵循当前的策略,冒着变得可预测的风险,要么尝试新的棋步来让对手吃惊,但这也带来了可能更糟糕的风险。一般来说,代理使用一种给他们带来某种奖励的策略,但他们的终极目标是最大化总奖励。修改后的策略可能带来更多的奖励,如果代理不尝试找到这样的策略,他们将无效。RL 的一个挑战是如何在利用(遵循当前策略)和探索(尝试新动作)之间进行权衡。
到目前为止,我们只使用了游戏作为例子;然而,许多问题可以归入 RL 领域。例如,你可以将自动驾驶汽车看作一个 RL 问题。如果汽车保持在车道内并遵守交通规则,它会获得正奖励;如果发生碰撞,则会获得负奖励。RL 的另一个有趣的应用是管理股票投资组合。代理的目标是最大化投资组合的价值,奖励直接来源于投资组合中股票的价值。
本版中没有 RL 的内容
本书的第二版包含了两章关于强化学习(RL)的内容。在这一版中,我们将省略这些章节,而是讨论变换器及其应用。一方面,RL 是一个有前景的领域,但目前训练 RL 模型的速度较慢,且其实际应用有限。因此,RL 研究主要集中在资金充足的商业公司和学术机构。另一方面,变换器代表了机器学习领域的下一个重大进步,就像 GPU 训练的深度网络在 2009-2012 年间激发了该领域的兴趣一样。
Q 学习
Q-learning 是一种脱离策略的时序差分强化学习(RL)算法。听起来有点复杂!但不用担心,我们不必纠结这些术语的具体含义,而是直接看一下这个算法是如何工作的。为此,我们将使用前一节介绍的国际象棋游戏。提醒一下,棋盘的配置(棋子的位置)就是环境的当前状态。在这里,智能体可以通过移动棋子来采取动作,a,从而将状态转变为新的状态。我们将国际象棋游戏表示为一个图,其中不同的棋盘配置是图的顶点,而每种配置下可能的棋步则是边。要进行一次移动,智能体从当前状态 s 按照边移动到新状态 s’。基本的 Q-learning 算法使用 Q-表 来帮助智能体决定要采取哪些行动。Q-表为每个棋盘配置提供一行,而表格的列则是智能体可以采取的所有可能动作(棋步)。表格的一个单元格 q(s, a) 存储的是累积的期望回报,也称为 Q 值。这是智能体从当前状态 s 执行一个动作 a 后,在接下来的游戏中可能获得的总回报。起初,Q-表会用一个任意的值进行初始化。掌握了这些知识后,让我们看看 Q-learning 是如何工作的:
Initialize the Q table with some arbitrary value
for each episode:
Observe the initial state s
for each step of the episode:
Select new action a using Q-table based policy
Observe reward r and go to the new state s'
Use Bellman eq to update q(s, a) in the Q-table
until we reach a terminal state for the episode
一局游戏从一个随机初始状态开始,并在达到终止状态时结束。在我们的案例中,一局游戏就是一场完整的国际象棋比赛。
由此产生的问题是,智能体的策略如何决定下一步行动?为此,策略必须考虑当前状态下所有可能动作的 Q 值。Q 值越高,动作的吸引力越大。然而,策略有时会忽略 Q-表(即利用现有知识),并选择另一个随机的动作来寻找更高的潜在回报(探索)。在开始时,智能体会采取随机动作,因为 Q-表中并没有太多信息(采用试错法)。随着时间的推移,Q-表逐渐填充,智能体在与环境交互时将变得更加智能。
每次新的动作后,我们都会使用 贝尔曼方程 更新 q(s, a)。贝尔曼方程超出了本介绍的范围,但知道更新后的值 q(s, a) 是基于新获得的回报 r 以及新状态 s’ 的最大可能 Q 值 q(s’, a’)* 即可。
这个例子旨在帮助你理解 Q 学习的基本原理,但你可能注意到一个问题。我们将所有可能的棋盘配置和动作的组合存储在 Q 表中。这将使得表格非常庞大,无法容纳在今天的计算机内存中。幸运的是,这里有一个解决方案:我们可以用神经网络替代 Q 表,神经网络将告诉智能体在每种状态下最优的动作是什么。近年来,这一发展使得强化学习算法在围棋、Dota 2、Doom 和星际争霸等任务中取得了超越人类的表现。
机器学习解决方案的组成部分
到目前为止,我们讨论了三大类机器学习算法。然而,要解决一个机器学习问题,我们需要一个系统,其中机器学习算法只是其中的一部分。这样的系统最重要的方面如下:
-
学习者:该算法与其学习哲学一起使用。选择该算法是由我们试图解决的问题决定的,因为不同的问题可能更适合某些机器学习算法。
-
训练数据:这是我们感兴趣的原始数据集。它可以是有标签的或无标签的。拥有足够的样本数据对学习者理解问题结构非常重要。
-
表示:这是我们如何通过选择的特征来表达数据,以便将其输入给学习者。例如,为了分类手写数字图像,我们将图像表示为一个二维数组,其中每个单元格包含一个像素的颜色值。数据表示的良好选择对于获得更好的结果非常重要。
-
目标:这代表了我们从数据中学习的原因。它与目标密切相关,帮助我们定义学习者应该使用什么以及如何使用什么表示。例如,目标可能是清理我们的邮箱中的垃圾邮件,这个目标定义了我们学习者的目标。在这种情况下,它是垃圾邮件的检测。
-
目标值:这代表了正在学习的内容以及最终的输出。目标值可以是对无标签数据的分类,是根据隐藏的模式或特征对输入数据的表示,是未来预测的模拟器,或是对外部刺激或策略的回应(在强化学习中)。
这一点永远不能被过分强调:任何机器学习算法只能接近目标,而不是完美的数值描述。机器学习算法不是问题的精确数学解答——它们只是近似值。在监督学习部分中,我们将学习定义为从特征空间(输入)到一系列类别的函数。之后,我们将看到某些机器学习算法,如神经网络,理论上可以近似任何函数到任意精度。这被称为通用逼近定理(en.wikipedia.org/wiki/Universal_approximation_theorem
),但这并不意味着我们能为问题找到精确的解决方案。此外,通过更好地理解训练数据,可以更好地解决问题。
通常,经典机器学习技术能够解决的问题可能需要在部署之前对训练数据进行深入理解和处理。解决机器学习问题的步骤如下:
-
数据收集:这包括尽可能多地收集数据。在监督学习的情况下,这还包括正确的标签。
-
数据处理:这包括清理数据,如去除冗余或高度相关的特征,甚至填补缺失数据,并理解定义训练数据的特征。
-
测试用例的创建:通常,数据可以分为三个集合:
-
训练集:我们使用该集合来训练机器学习算法。在大多数情况下,我们将通过多次迭代整个训练集来训练算法。我们将称每次完整训练集迭代的次数为训练轮次。
-
验证集:我们使用该集合来评估算法在训练过程中对未知数据的准确性。我们会在训练集上训练算法一段时间,然后使用验证集来检查其性能。如果结果不令人满意,我们可以调整算法的超参数并重复该过程。验证集还可以帮助我们确定何时停止训练。我们将在本节后面进一步学习这一点。
-
测试集:当我们完成训练或验证周期并调优算法后,我们只会使用测试集进行最终评估一次。测试集与验证集类似,因为算法在训练过程中没有使用它。然而,当我们努力在验证数据上改善算法时,可能会无意中引入偏差,从而导致结果偏向验证集,不能真实反映实际性能。由于我们只使用一次测试集,这将提供对算法更客观的评估。
-
注意
深度学习(DL)算法成功的原因之一是它们通常需要比经典方法更少的数据处理。对于经典算法,你需要对每个问题应用不同的数据处理并提取不同的特征。而对于深度学习,你可以对大多数任务应用相同的数据处理流程。通过深度学习,你可以提高生产力,并且与经典的机器学习算法相比,你不需要太多的领域知识来完成当前任务。
创建测试和验证数据集有很多合理的理由。如前所述,机器学习(ML)技术只能产生所需结果的近似值。通常,我们只能包括有限的、有限数量的变量,且可能有许多变量超出我们的控制范围。如果我们只使用一个数据集,我们的模型可能最终会记住数据,并在它记住的数据上产生极高的准确度。然而,这个结果可能无法在其他相似但未知的数据集上复现。机器学习算法的一个关键目标是它们的泛化能力。这就是为什么我们既要创建用于训练过程中调整模型选择的验证集,又要在过程结束时仅用于确认所选算法有效性的最终测试集。
为了理解选择有效特征的重要性,并避免记住数据(在文献中也称为过拟合,我们从现在开始使用这个术语),让我们用一则来自 XKCD 漫画的笑话作为例子 (xkcd.com/1122):
😃
“直到 1996 年,任何没有作战经验且为现任总统的美国民主党总统候选人都从未击败过任何名字在《拼字游戏》中得分更高的人。”
很明显,这样的规则是没有意义的,但它强调了选择有效特征的重要性,以及问题“一个名字在《拼字游戏》中的得分值有多高?”在选择美国总统时可能有任何相关性。此外,这个例子对未知数据没有任何预测能力。我们将其称为过拟合(overfitting),即指做出适应手头数据完美的预测,但这些预测不能推广到更大的数据集。过拟合是试图理解我们称之为噪声(没有实际意义的信息),并试图将模型拟合到小的扰动中的过程。
为了进一步解释这一点,让我们尝试使用机器学习(ML)预测一个从地面抛向空中的球的轨迹(不是垂直方向),直到它再次落地。物理学教导我们,轨迹的形状像一个抛物线。我们也期望一个好的机器学习算法,观察到成千上万次这样的抛掷后,能够得出一个抛物线作为解决方案。然而,如果我们放大观察球体,注意到由于空气湍流引起的最小波动,我们可能会发现球体并没有保持稳定的轨迹,而是会受到小的扰动影响,这些扰动在这种情况下就是噪声。试图对这些小扰动进行建模的机器学习算法会忽略大局,从而得出不令人满意的结果。换句话说,过拟合是让机器学习算法只看到树木却忘记森林的过程:
图 1.9 – 一个好的预测模型(左)和一个差的(过拟合的)预测模型,展示了一个从地面抛出的球的轨迹(右)
这就是为什么我们要将训练数据与验证数据和测试数据分开的原因;如果算法在测试数据上的准确度与训练数据上的准确度不相似,那将是一个很好的迹象,表明模型存在过拟合问题。我们需要确保不犯相反的错误——即模型欠拟合。然而,在实际操作中,如果我们旨在使我们的预测模型在训练数据上尽可能准确,欠拟合的风险要小得多,而且我们会特别注意避免过拟合。
下图展示了欠拟合:
图 1.10 – 欠拟合也可能是一个问题
神经网络
在前面的章节中,我们介绍了一些流行的经典机器学习算法。在本章节中,我们将讨论神经网络(NN),这是本书的重点。
神经网络的第一个例子叫做感知器,这是由 Frank Rosenblatt 在 1957 年发明的。感知器是一种分类算法,非常类似于逻辑回归。与逻辑回归类似,它有一个权重向量 w,其输出是一个函数,,即点积,
(或者
),表示权重和输入的点积。
唯一的区别是 f 是一个简单的阶跃函数——也就是说,如果,则
,否则
,其中我们对逻辑函数的输出应用类似的逻辑回归规则。感知器是一个简单的单层前馈神经网络示例:
图 1.11 – 一个简单的感知器,具有三个输入和一个输出
感知器非常有前景,但很快就被发现它有严重的局限性,因为它只适用于线性可分的类别。在 1969 年,Marvin Minsky 和 Seymour Paper 证明了它甚至无法学习像 XOR 这样的简单逻辑函数。这导致了对感知器的兴趣大幅下降。
然而,其他神经网络可以解决这个问题。经典的多层感知器(MLP)包含多个相互连接的感知器,例如组织在不同顺序层中的单元(输入层、一个或多个隐藏层以及输出层)。每一层的单元都与下一层的所有单元相连接。首先,信息呈现给输入层,然后我们用它计算输出(或激活值),,用于第一个隐藏层的每个单元。我们向前传播,输出作为下一层输入(因此叫做前向传播),以此类推,直到达到输出层。训练神经网络最常见的方法是结合使用梯度下降和反向传播。我们将在第二章中详细讨论这个过程。
以下图示描绘了一个具有隐藏层的神经网络:
图 1.12 – 一个具有隐藏层的神经网络
将隐藏层视为输入数据的抽象表示。这是神经网络用其内部逻辑理解数据特征的方式。然而,神经网络是不可解释的模型。这意味着,如果我们观察隐藏层的激活值,我们是无法理解它们的。对我们来说,它们只是一个数值向量。我们需要输出层来弥合网络表示与我们实际关注的数据之间的差距。你可以把它当作一个翻译器;我们用它来理解网络的逻辑,同时也能将其转换为我们关注的实际目标值。
通用逼近定理告诉我们,一个具有一个隐藏层的前馈网络可以表示任何函数。值得注意的是,理论上,具有一个隐藏层的网络没有限制,但在实际应用中,使用这种架构的成功是有限的。在第三章中,我们将讨论如何通过深度神经网络来实现更好的性能,并与浅层神经网络相比的优势。目前,让我们通过解决一个简单的分类任务来应用我们的知识。
引入 PyTorch
在这一部分,我们将介绍 PyTorch——一个由 Facebook 开发的开源 Python 深度学习框架,近年来获得了广泛关注。它提供图形处理单元(GPU)加速的多维数组(或张量)运算和计算图,我们可以用它来构建神经网络。在本书中,我们将使用 PyTorch 和 Keras,并将在第三章中详细讨论和比较这两个库。
我们将创建一个简单的神经网络来分类鸢尾花数据集。步骤如下:
-
首先加载数据集:
import pandas as pd dataset = pd.read_csv('https://archive.ics.uci.edu/ml/machine-learning-databases/iris/iris.data', names=['sepal_length', 'sepal_width', 'petal_length', 'petal_width', 'species']) dataset['species'] = pd.Categorical(dataset['species']).codes dataset = dataset.sample(frac=1, random_state=1234) train_input = dataset.values[:120, :4] train_target = dataset.values[:120, 4] test_input = dataset.values[120:, :4] test_target = dataset.values[120:, 4]
上述代码是下载鸢尾花数据集 CSV 文件并将其加载到
dataset
中的模板代码。然后,我们将 DataFrame 的行进行洗牌,并将代码拆分为 NumPy 数组,train_input
/train_target
(花卉特征/花卉类别)用于训练数据,test_input
/test_target
用于测试数据。我们将使用 120 个样本进行训练,30 个样本用于测试。如果你不熟悉 pandas,可以把它当作 NumPy 的高级版本。 -
接下来,定义我们的第一个神经网络。我们将使用一个前馈网络,包含一个具有五个单元的隐藏层,一个ReLU激活函数(这只是另一种激活函数,简单定义为f(x) = max(0, x)),以及一个具有三个单元的输出层。输出层有三个单元,每个单元对应一种鸢尾花的类别。以下是 PyTorch 定义的网络:
import torch torch.manual_seed(1234) hidden_units = 5 net = torch.nn.Sequential( torch.nn.Linear(4, hidden_units), torch.nn.ReLU(), torch.nn.Linear(hidden_units, 3) )
我们将使用
Iris Setosa = [1, 0, 0]
,Iris Versicolor = [0, 1, 0]
,和Iris Virginica = [0, 0, 1]
,其中数组中的每个元素将是输出层单元的目标。当网络对新样本进行分类时,我们通过选择激活值最高的单元来确定类别。torch.manual_seed(1234)
使我们每次都能使用相同的随机数据种子,从而保证结果的可重复性。 -
选择
loss
函数:criterion = torch.nn.CrossEntropyLoss()
使用
criterion
变量,我们将损失函数定义为交叉熵损失。损失函数将衡量网络输出与目标数据之间的差异。 -
定义随机梯度下降(SGD)优化器(梯度下降算法的一种变体),学习率为 0.1,动量为 0.9(我们将在第二章中讨论 SGD 及其参数):
optimizer = torch.optim.SGD(net.parameters(), lr=0.1, momentum=0.9)
-
训练网络:
epochs = 50 for epoch in range(epochs): inputs = torch.autograd.Variable( torch.Tensor(train_input).float()) targets = torch.autograd.Variable( torch.Tensor(train_target).long()) optimizer.zero_grad() out = net(inputs) loss = criterion(out, targets) loss.backward() optimizer.step() if epoch == 0 or (epoch + 1) % 10 == 0: print('Epoch %d Loss: %.4f' % (epoch + 1, loss.item()))
我们将训练 50 个周期,也就是对训练数据集迭代 50 次:
-
从 NumPy 数组创建
torch
变量——即train_input
和train_target
。 -
将优化器的梯度归零,以防止上一次迭代的梯度累积。我们将训练数据输入到神经网络中,
net(inputs)
,并计算损失函数的criterion
(out
,targets
),即网络输出和目标数据之间的差异。 -
将
loss
值反向传播通过网络。我们这么做是为了计算每个网络权重如何影响损失函数。 -
优化器以一种能够减少未来损失函数值的方式更新网络的权重。
当我们运行训练时,输出将如下所示:
Epoch 1 Loss: 1.2181 Epoch 10 Loss: 0.6745 Epoch 20 Loss: 0.2447 Epoch 30 Loss: 0.1397 Epoch 40 Loss: 0.1001 Epoch 50 Loss: 0.0855
下图显示了损失函数随着每个训练周期的减少。这展示了网络是如何逐渐学习训练数据的:
-
图 1.13 – 损失函数随着每个训练周期的减少
-
让我们看看模型的最终准确率是多少:
import numpy as np inputs = torch.autograd.Variable(torch.Tensor(test_input).float()) targets = torch.autograd.Variable(torch.Tensor(test_target).long()) optimizer.zero_grad() out = net(inputs) _, predicted = torch.max(out.data, 1) error_count = test_target.size - np.count_nonzero((targets == predicted).numpy()) print('Errors: %d; Accuracy: %d%%' % (error_count, 100 * torch.sum(targets == predicted) / test_target.size))
我们通过将测试集输入网络并手动计算误差来实现这一点。输出如下:
Errors: 0; Accuracy: 100%
我们成功地正确分类了所有 30 个测试样本。
我们还必须尝试不同的网络超参数,并观察准确率和损失函数的变化。你可以尝试改变隐藏层中的单元数、训练周期数以及学习率。
总结
本章介绍了什么是机器学习(ML)以及它为什么如此重要。我们讨论了机器学习技术的主要类别以及一些最受欢迎的经典机器学习算法。我们还介绍了一种特定类型的机器学习算法,称为神经网络(NNs),它是深度学习(DL)的基础。然后,我们看了一个编码示例,展示了如何使用流行的机器学习库来解决一个特定的分类问题。
在下一章,我们将更详细地讨论神经网络(NNs)并探讨它们的理论依据。
第二章:神经网络
在第一章中,我们介绍了许多基本的机器学习(ML)概念和技术。我们讲解了主要的机器学习范式以及一些经典的流行机器学习算法,最后介绍了神经网络(NN)。在本章中,我们将正式介绍神经网络是什么,讨论它们的数学基础,详细描述其构建模块如何工作,看看我们如何堆叠多个层来创建深度前馈神经网络,并学习如何训练它们。
在本章中,我们将涵盖以下主要内容:
-
神经网络的需求
-
神经网络的数学
-
神经网络简介
-
训练神经网络
神经网络与人类大脑的联系
最初,神经网络(NN)是受生物大脑的启发(因此得名)。然而,随着时间的推移,我们已不再尝试模仿大脑的工作方式,而是集中精力寻找适用于特定任务的正确配置,包括计算机视觉、自然语言处理和语音识别。你可以这样理解——长时间以来,我们受到了鸟类飞行的启发,但最终我们创造了飞机,这与鸟类飞行是截然不同的。我们仍然远未能与大脑的潜力相匹配。也许未来的机器学习算法会更像大脑,但现在情况并非如此。因此,在本书的其余部分,我们将不再试图在大脑和神经网络之间做类比。为了沿着这个思路进行,我们将把最小的神经网络构建模块称为单元,而不是最初的“神经元”。
技术要求
我们将在本章中使用 Python 实现示例。如果你尚未设置好相关工具的环境,不用担心——该示例可以在 Google Colab 上作为 Jupyter notebook 使用。你可以在本书的 GitHub 仓库中找到代码示例:github.com/PacktPublishing/Python-Deep-Learning-Third-Edition/tree/main/Chapter02
。
神经网络的需求
神经网络已经存在了许多年,并经历了多个阶段,期间它们的受欢迎程度时高时低。然而,最近,它们在许多其他竞争的机器学习算法中稳步占据了主导地位。这一复兴归因于计算机速度的提升,图形处理单元(GPU)的使用取代了最传统的中央处理单元(CPU)使用,算法和神经网络设计的改进,以及日益增大的数据集,这些我们将在本书中探讨。为了让你了解它们的成功,让我们来看看 ImageNet 大规模视觉识别挑战赛(image-net.org/challenges/LSVRC/
,或者简称ImageNet)。参与者通过使用 ImageNet 数据库来训练他们的算法。该数据库包含了超过 100 万张高分辨率彩色图像,涵盖了 1,000 多个类别(一个类别可能是汽车的图像,另一个类别是人的图像、树木的图像等等)。该挑战赛中的一个任务是将未知的图像分类到这些类别中。2011 年,获胜者实现了 74.2%的前五名准确率。2012 年,Alex Krizhevsky 及其团队带着卷积网络(深度网络的一种特殊类型)参加了比赛。那年,他们以 84.7%的前五名准确率赢得了比赛。从那时起,获胜的算法总是神经网络,而当前的前五名准确率大约为 99%。然而,深度学习算法在其他领域也表现出色——例如,谷歌的 Google Now 和苹果的 Siri 助手依赖深度网络进行语音识别,谷歌也利用深度学习进行翻译引擎的开发。最近的图像和文本生成系统,如 Stability AI 的 Stable Diffusion 和 OpenAI 的 DALL-E 与 ChatGPT,都是使用神经网络实现的。
我们将在接下来的章节中讨论这些激动人心的进展,但现在,我们将专注于神经网络的数学基础。为了帮助我们完成这项任务,我们将使用具有一两层的简单网络。你可以将这些视为玩具示例,它们不是深度网络,但理解它们的工作原理非常重要。原因如下:
-
了解神经网络的理论将帮助你理解本书的其余部分,因为如今大多数使用中的神经网络都共享一些共同的原理。理解简单的网络意味着你也能理解深度网络。
-
拥有一些基础知识总是有益的。当你面对一些新的材料时(即使是本书未涉及的材料),它会对你大有帮助。
我希望这些论点能说服你本章的重要性。作为一个小小的安慰,我们将在第三章中深入讨论深度学习(这个双关语用得很恰当)。
神经网络的数学
在接下来的几个部分中,我们将讨论神经网络的数学原理。通过这种方式,我们将能够以一种基础且结构化的方式,通过这些原理来解释神经网络。
线性代数
线性代数处理的对象包括向量、矩阵、线性变换和线性方程,如 。
线性代数识别以下数学对象:
-
标量:一个单独的数字。
-
向量:一个一维的数字数组(也称为分量或标量),每个元素都有一个索引。我们可以用上标箭头 (
) 或加粗字体 (x) 来表示向量,但在本书中我们将主要使用加粗符号。以下是一个向量的示例:
我们可以将一个 n 维向量表示为一个点在 n 维欧几里得空间中的坐标,。可以将欧几里得空间视为一个坐标系——向量从该坐标系的中心开始,每个向量的元素表示沿其相应坐标轴的点的坐标。下图显示了三维坐标系中的一个向量,
:
图 2.1 – 三维空间中的向量表示
该图还帮助我们定义了向量的两个额外属性:
- 大小(或长度):
可以将大小视为勾股定理在 n 维空间中的推广。
-
方向:向量与向量空间中各轴之间的角度。
-
矩阵:一个二维标量数组,每个元素通过行和列来标识。我们用粗体大写字母表示矩阵—例如,A。相反,我们用小写字母表示矩阵元素,并将行和列作为下标—例如,
。我们可以在下面的公式中看到矩阵符号的例子:
我们可以将向量表示为单行的 1×n 矩阵(行矩阵)或单列的 n×1 矩阵(列矩阵)。通过这种转换,向量可以参与不同的矩阵运算。
-
张量:张量 这一术语来源于数学和物理学,在我们开始在机器学习中使用它之前,它就已经存在。这些领域中的定义与机器学习中的定义不同。幸运的是,在机器学习的背景下,张量仅仅是一个具有以下属性的多维数组:
-
秩:数组的维度数量。向量和矩阵是张量的特殊情况。秩为 0 的张量是标量,秩为 1 的张量是向量,秩为 2 的张量是矩阵。维度数量没有限制,某些类型的神经网络可以使用秩为 4 或更高的张量。
-
形状:张量每个维度的大小。
-
张量值的 数据类型。在实际应用中,数据类型包括 16 位、32 位和 64 位浮动数,以及 8 位、16 位、32 位和 64 位整数。
-
张量是像 PyTorch、Keras 和 TensorFlow 等库的主要数据结构。
张量的性质
你可以在这里找到关于张量性质的详细讨论:stats.stackexchange.com/questions/198061/why-the-sudden-fascination-with-tensors
。你还可以将此与 PyTorch (pytorch.org/docs/stable/tensors.html
) 和 TensorFlow (www.tensorflow.org/guide/tensor
) 的张量定义进行对比。
既然我们已经介绍了向量、矩阵和张量,让我们继续讨论它们能参与的某些线性运算。
向量和矩阵运算
我们将重点讨论与神经网络(NNs)相关的运算,从向量开始:
- 向量加法:将两个或更多 n 维向量 a 和 b(等等)加在一起,得到一个新的向量:
- 点积(或标量积):将两个 n 维向量 a 和 b 合并为一个 标量值:
这里,两个向量之间的夹角是θ,而 和
是它们的大小。例如,如果这些向量是二维的,且它们的分量为
,则上面的公式变为如下:
以下图示说明了a和b的点积:
图 2.2 – 向量的点积 – 上方:向量分量,和下方:两个向量的点积
我们可以将点积视为两个向量之间的相似度度量,其中角度θ表示它们的相似程度。如果θ很小(即向量方向相似),那么它们的点积将更高,因为会趋近于 1。在这种情况下,我们可以定义两个向量之间的余弦相似度如下:
- 叉积(或向量积):两个向量a和b的组合,得到一个新的向量,该向量垂直于两个初始向量。输出向量的大小等于以下公式:
我们可以在下图中看到二维向量叉积的例子:
图 2.3 – 两个二维向量的叉积
输出向量垂直(或法向)于包含输入向量的平面。输出向量的大小等于由a和b向量(在前面的图中以浅蓝色表示)构成的平行四边形的面积。
现在,让我们关注矩阵运算:
- 矩阵转置:沿着矩阵的主对角线翻转矩阵,表示为矩阵中所有元素的集合,
,其中 i=j。我们用
来表示转置操作。A 的单元格
等于
的单元格,且该单元格属于
。
m×n 矩阵的转置是一个 n×m 矩阵,以下例子可以帮助理解:
- 矩阵-标量乘法:将矩阵A与标量y相乘,生成一个与原矩阵相同大小的新矩阵:
- 矩阵加法:两个或多个矩阵(A 和 B 等)逐元素相加,得到一个新的矩阵。所有输入矩阵必须具有相同的大小:
- 矩阵-向量乘法:将矩阵A与向量x相乘,得到一个新的向量:
矩阵的列数必须与向量的大小相等。一个 m×n 矩阵与一个 n 维向量相乘的结果是一个 m 维向量。我们可以假设矩阵的每一行都是 n 维向量。那么,输出向量的每一个值都是对应矩阵行向量与x的点积。
- 矩阵乘法:一种二元运算,表示两个矩阵 A 和 B 的乘积,得到一个单一的输出矩阵。我们可以将其看作是多个矩阵-向量乘法,其中第二个矩阵的每一列都是一个向量:
![<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" display="block"><mml:mi mathvariant="bold">A</mml:mi><mml:mi mathvariant="bold">B</mml:mi>mml:mo=</mml:mo><mml:mfenced open="[" close="]" separators="|">mml:mrowmml:mtablemml:mtrmml:mtdmml:msubmml:mrowmml:mia</mml:mi></mml:mrow>mml:mrowmml:mn11</mml:mn></mml:mrow></mml:msub></mml:mtd>mml:mtdmml:msubmml:mrowmml:mia</mml:mi></mml:mrow>mml:mrowmml:mn12</mml:mn></mml:mrow></mml:msub></mml:mtd>mml:mtdmml:msubmml:mrowmml:mia</mml:mi></mml:mrow>mml:mrowmml:mn13</mml:mn></mml:mrow></mml:msub></mml:mtd></mml:mtr>mml:mtrmml:mtdmml:msubmml:mrowmml:mia</mml:mi></mml:mrow>mml:mrowmml:mn21</mml:mn></mml:mrow></mml:msub></mml:mtd>mml:mtdmml:msubmml:mrowmml:mia</mml:mi></mml:mrow>mml:mrowmml:mn22</mml:mn></mml:mrow></mml:msub></mml:mtd>mml:mtdmml:msubmml:mrowmml:mia</mml:mi></mml:mrow>mml:mrowmml:mn23</mml:mn></mml:mrow></mml:msub></mml:mtd></mml:mtr></mml:mtable></mml:mrow></mml:mfenced><mml:mfenced open="[" close="]" separators="|">mml:mrowmml:mtablemml:mtrmml:mtdmml:msubmml:mrowmml:mib</mml:mi></mml:mrow>mml:mrowmml:mn11</mml:mn></mml:mrow></mml:msub></mml:mtd>mml:mtdmml:msubmml:mrowmml:mib</mml:mi></mml:mrow>mml:mrowmml:mn12</mml:mn></mml:mrow></mml:msub></mml:mtd></mml:mtr>mml:mtrmml:mtdmml:msubmml:mrowmml:mib</mml:mi></mml:mrow>mml:mrowmml:mn21</mml:mn></mml:mrow></mml:msub></mml:mtd>mml:mtdmml:msubmml:mrowmml:mib</mml:mi></mml:mrow>mml:mrowmml:mn22</mml:mn></mml:mrow></mml:msub></mml:mtd></mml:mtr>mml:mtrmml:mtdmml:msubmml:mrowmml:mib</mml:mi></mml:mrow>mml:mrowmml:mn31</mml:mn></mml:mrow></mml:msub></mml:mtd>mml:mtdmml:msubmml:mrowmml:mib</mml:mi></mml:mrow>mml:mrowmml:mn32</mml:mn></mml:mrow></mml:msub></mml:mtd></mml:mtr></mml:mtable></mml:mrow></mml:mfenced>mml:mo=</mml:mo><mml:mfenced open="[" close="]" separators="|">mml:mrowmml:mtablemml:mtrmml:mtdmml:msubmml:mrowmml:mia</mml:mi></mml:mrow>mml:mrowmml:mn11</mml:mn></mml:mrow></mml:msub>mml:msubmml:mrowmml:mib</mml:mi></mml:mrow>mml:mrowmml:mn11</mml:mn></mml:mrow></mml:msub>mml:mo+</mml:mo>mml:msubmml:mrowmml:mia</mml:mi></mml:mrow>mml:mrowmml:mn12</mml:mn></mml:mrow></mml:msub>mml:msubmml:mrowmml:mib</mml:mi></mml:mrow>mml:mrowmml:mn21</mml:mn></mml:mrow></mml:msub>mml:mo+</mml:mo>mml:msubmml:mrowmml:mia</mml:mi></mml:mrow>mml:mrowmml:mn13</mml:mn></mml:mrow></mml:msub>mml:msubmml:mrowmml:mib</mml:mi></mml:mrow>mml:mrowmml:mn31</mml:mn></mml:mrow></mml:msub></mml:mtd>mml:mtdmml:msubmml:mrowmml:mia</mml:mi></mml:mrow>mml:mrowmml:mn11</mml:mn></mml:mrow></mml:msub>mml:msubmml:mrowmml:mib</mml:mi></mml:mrow>mml:mrowmml:mn12</mml:mn></mml:mrow></mml:msub>mml:mo+</mml:mo>mml:msubmml:mrowmml:mia</mml:mi></mml:mrow>mml:mrowmml:mn12</mml:mn></mml:mrow></mml:msub>mml:msubmml:mrowmml:mib</mml:mi></mml:mrow>mml:mrowmml:mn22</mml:mn></mml:mrow></mml:msub>mml:mo+</mml:mo>mml:msubmml:mrowmml:mia</mml:mi></mml:mrow>mml:mrowmml:mn13</mml:mn></mml:mrow></mml:msub>mml:msubmml:mrowmml:mib</mml:mi></mml:mrow>mml:mrowmml:mn32</mml:mn></mml:mrow></mml:msub></mml:mtd></mml:mtr>mml:mtrmml:mtdmml:msubmml:mrowmml:mia</mml:mi></mml:mrow>mml:mrowmml:mn21</mml:mn></mml:mrow></mml:msub>mml:msubmml:mrowmml:mib</mml:mi></mml:mrow>mml:mrowmml:mn11</mml:mn></mml:mrow></mml:msub>mml:mo+</mml:mo>mml:msubmml:mrowmml:mia</mml:mi></mml:mrow>mml:mrowmml:mn22</mml:mn></mml:mrow></mml:msub>mml:msubmml:mrowmml:mib</mml:mi></mml:mrow>mml:mrowmml:mn21</mml:mn></mml:mrow></mml:msub>mml:mo+</mml:mo>mml:msubmml:mrowmml:mia</mml:mi></m
如果我们将这两个向量表示为矩阵,它们的点积,,等同于矩阵与矩阵的乘法。
现在你可以松一口气了,因为我们已经结束了线性代数的介绍。虽然所有的困难还没有结束,接下来我们将重点讨论概率论。
概率论简介
在这一节中,我们将介绍一些概率论的基本概念。它们将帮助我们在本书后面讨论神经网络训练算法和自然语言处理时更好地理解相关内容。
我们将从统计实验的概念开始,统计实验有以下特点:
-
它由多个独立的试验组成
-
每次试验的结果是由偶然决定的(它是非确定性的)
-
它有多个可能的结果,称为事件
-
我们提前知道所有可能的实验结果
统计实验的例子包括硬币抛掷(有两个可能结果:正面或反面)和掷骰子(有六个可能结果:1 到 6)。
某个事件e发生的可能性被称为概率P(e)。它的值在[0, 1]之间。P(e) = 0.5 表示该事件有 50%的可能性发生,P(e) = 0 表示该事件不可能发生,P(e) = 1 表示该事件必定发生。
我们可以通过两种方式来处理概率:
- 理论:所有事件的发生概率是相等的,我们感兴趣的事件(结果)的概率如下:
硬币抛掷的理论概率是 P(正面) = P(反面) = 1/2。在掷骰子的例子中,我们有 P(每一面) = 1/6。
- 经验:这是事件e发生的次数与所有试验次数的比值:
实验的经验结果可能表明事件的发生并非等概率。例如,如果我们抛硬币 100 次,正面朝上的次数是 47 次,则正面朝上的经验概率为 P(正面) = 47 / 100 = 0.47。大数法则告诉我们,试验次数越多,计算的概率就越准确。
现在,我们将在集合的背景下讨论概率。
概率与集合
在本节中,我们将介绍集合及其属性。我们还将看到如何在概率论中应用这些属性。首先从一些定义开始:
-
样本空间:实验的所有可能事件(结果)的集合。我们用大写字母表示它。像 Python 一样,我们用{}列出样本空间中的所有事件。例如,掷硬币和掷骰子的样本空间分别是
和
。
-
样本点:样本空间中的单个事件(例如,反面)。
-
事件:样本空间中的单个样本点或样本点的组合(子集)。例如,掷骰子落在奇数上的联合事件是
。
假设我们有一个集合(样本空间),S = {1, 2, 3, 4, 5},和两个子集(联合事件),A = {1, 2, 3} 和 B = {3, 4, 5}。我们将用它们来定义以下集合运算:
- 交集:A 和 B 中同时存在的元素的集合:
如果 A 和 B 的交集是空集{},则它们是不相交的。
- 补集:A 或 B 中未包含的所有元素的集合:
- 并集:包含 A 或 B 中所有元素的集合:
下面的维恩图展示了这些操作:
图 2.4 – 可能集合关系的维恩图
现在,让我们看看如何在概率领域中进行集合操作。我们将处理独立事件——也就是说,一个事件的发生不会影响另一个事件发生的概率。例如,不同硬币投掷的结果彼此独立。因此,让我们根据概率和事件定义集合操作:
- 两个事件的交集:存在于两个事件中的样本点的集合。交集的概率称为联合概率:
假设我们想要计算一张卡牌同时是黑桃和一张 A 牌(更有诗意地说,黑桃 A)。黑桃的概率为 P(黑桃) = 13/52 = ¼,而 A 牌的概率为 P(A) = 4/52 = 1/13。两者的联合概率为 P(A, 黑桃) = (1/13) 
- 联合穷尽事件:此类事件的子集相互之间包含整个样本空间。例如,事件 A = {1, 2, 3} 和 B = {4, 5, 6} 是联合穷尽的,因为它们共同覆盖了整个样本空间 S = {1, 2, 3, 4, 5, 6}。对于联合穷尽事件,其概率有如下性质:
-
互斥事件:两个或多个事件彼此没有共同的结果,并且它们是联合穷尽的。例如,掷骰子的奇数和偶数事件是互斥的。
-
事件的并集:一组来自 A 或 B(不一定都在两个集合中)的事件。该并集的概率是:
到目前为止,我们讨论了独立事件。现在,让我们看看如果事件不是独立的会发生什么。
条件概率和贝叶斯定理
如果事件 A 发生在 B 之前,并且 A 的发生改变了 B 发生的概率,那么这两个事件是相关的。为了理解这一点,假设我们从一副牌中抽取连续的牌。当牌堆满时,抽到黑桃的概率是 P(黑桃) = 13/52 = 0.25。然而,一旦我们抽到了第一张黑桃,第二次抽到黑桃的概率就会发生变化。现在,我们只有 51 张牌,而且黑桃减少了 1 张。第二次抽到黑桃的概率被称为条件概率,P(B|A)。这是指在事件 A(第一次抽到黑桃)已经发生的情况下,事件 B(第二次抽到黑桃)发生的概率。第二次抽到黑桃的概率是 P(黑桃 2|黑桃 1) = 12/51 = 0.235。
我们可以扩展前面章节中介绍的联合概率公式,来适应相关事件:
然而,这个公式只是两个事件的特例。我们可以进一步扩展它以适用于多个事件,。这个新的通用公式被称为概率链式法则:
例如,三个事件的链式法则如下所示:
我们可以利用这个性质来推导条件概率本身的公式:
让我们来讨论一下其中的直觉:
-
P(A ∩ B) 表示我们只关心事件 B 的发生,前提是 A 已经发生——也就是说,我们关注事件的联合发生,因此是联合概率。
-
P(A) 表示我们只关注事件 A 发生时的结果子集。我们已经知道 A 发生了,因此我们将观察范围限制在这些结果上。
以下对于依赖事件是成立的:
我们可以使用这个规则来替换条件概率公式中的 P(A∩B) 值,从而推导出被称为 贝叶斯规则 的公式:
贝叶斯定理使我们能够计算条件概率 P(B|A),如果我们知道反向的条件概率 P(A|B)。P(A)和 P(B|A)分别被称为先验概率和后验概率。
我们可以通过一个经典的医学检测例子来说明贝叶斯定理。一位病人接受了某种疾病的医学检测,结果显示为阳性。大多数检测都有一个灵敏度值,即当对患有特定疾病的人进行检测时,测试为阳性的概率。利用这些信息,我们将应用贝叶斯定理来计算在测试为阳性的情况下,病人患病的实际概率。我们得到如下公式:
我们可以将 P(患病)看作是一般人群中患病的概率。
现在,我们将对疾病和测试的灵敏度做一些假设:
-
该测试的敏感度为 98%——也就是说,它只能检测出 98%的所有阳性病例:P(测试=阳性|患病) = 0.98
-
50 岁以下的 2%的人患有这种疾病:P(患病) = 0.02
-
对 50 岁以下的人群进行测试时,3.9%的人群测试结果为阳性:P(测试=阳性) = 0.039
我们可以问这样一个问题:如果一个测试的敏感度是 98%,并且对一位 45 岁的人进行测试,结果为阳性,那么他患病的概率是多少?我们可以用贝叶斯规则来计算:
这个例子可以作为下一节的引入,在这一节中我们将介绍混淆矩阵。
混淆矩阵
混淆矩阵用于评估二分类算法的性能,类似于我们在条件概率和贝叶斯 规则 部分介绍的医学测试:
图 2.5 – 混淆矩阵
实际情况(P 和 N)与预测结果(PP 和 PN)之间的关系使我们能够将预测结果归类为四种类别之一:
-
真阳性 (TP):实际值和预测值都是正
-
真阴性 (TN):实际值和预测值都是假
-
假阳性 (FP):实际值为负,但分类算法预测为正
-
假阴性(FN):实际值为正,但算法预测为负值。
基于这些分类,我们将引入一些衡量算法性能不同方面的指标:
-
准确率 =
:所有案例中正确预测的比例。
-
精确率 =
:所有正类预测中实际正确的比例。
-
召回率(或 敏感度)=
:实际正类案例中被正确预测的比例。
-
特异性 =
:实际负类案例中被正确预测的比例。
-
F1 分数 =
:表示精确率和召回率之间的平衡。由于这两个指标的相乘,当这两个值都较高时,F1 分数也会较高。
在接下来的部分中,我们将讨论微积分领域,这将帮助我们训练神经网络。
微积分
我们可以将机器学习算法视为一个具有输入和参数的数学函数(神经网络也是如此)。我们的目标是调整这些参数,以便让机器学习函数能够尽可能接近某个目标函数。为了做到这一点,我们需要知道当我们改变某些参数(称为权重)时,机器学习函数的输出如何变化。幸运的是,微积分可以帮助我们——它研究函数对于一个变量(参数)变化的变化率。为了理解这一点,我们将从一个具有单一参数 x 的函数 f(x) 开始,其图形如下:
图 2.6 – 一个具有单一参数 x 的函数 f(x) 的示例图。函数图通过连续的蓝色线表示;斜率通过断开的红色线表示。
我们可以通过计算该值处函数的斜率,来近似 f(x) 相对于 x 的变化。如果斜率为正,则函数增加;如果斜率为负,则函数减少。斜率的陡峭程度表示该值处函数的变化速率。我们可以用以下公式来计算斜率:
这里的思路很简单——我们计算 f 在 x 和 x+Δx 处的两个值之差(Δx 是一个非常小的值)——Δy = f(x + Δx) - f(x)。Δy 和 Δx 的比值给我们斜率。那么,为什么 Δx 必须很小呢?如果 Δx 太大,x 和 x+Δx 之间的函数图形部分可能会发生较大变化,斜率测量将不准确。当 Δx 收敛到 0 时,我们假设我们的斜率近似于图形上某一点的实际斜率。在这种情况下,我们称斜率为 f(x) 的一阶导数。我们可以通过以下公式用数学语言表示:
这里,是极限的数学概念(Δx 趋近于 0),而 f’(x) 和 dy/dx 分别是拉格朗日和莱布尼茨表示导数的符号。求 f 的导数的过程称为微分。下图展示了不同 x 值的斜率:
图 2.7 – 不同 x 值的斜率
当 f 在变化 x 时既不增加也不减少的点,称为鞍点。在鞍点处的 f 值称为局部最小值和局部最大值。相反,鞍点处的 f 的斜率为 0。
到目前为止,我们已经讨论了只有一个参数的函数 x。现在,让我们关注一个具有多个参数的函数,。f 相对于任意参数的导数,
,被称为 偏导数,并表示为
。计算偏导数时,我们将假设其他所有参数,
,是常数。我们将用
来表示向量组件的偏导数。
最后,让我们讨论一些有用的求导规则:
- 链式法则:f 和 g 是函数,且 h(x)= f(g(x))。对于任意 x,f 相对于 x 的导数如下:
- 求和法则:f 和 g 是一些函数,h(x) = f(x) + g(x)。求和法则表明以下内容:
-
常见函数:
-
-
, 其中a是一个标量
-
, 其中a是一个标量
-
-
-
神经网络及其数学基础构成了一种知识层次结构。把我们在神经网络的数学部分讨论的内容看作是神经网络的构建块。它们代表了理解神经网络的一个重要步骤,这将有助于我们在本书以及未来的学习中。现在,我们已经做好了学习完整神经网络的必要准备。
神经网络简介
我们可以将神经网络描述为一种信息处理的数学模型。正如在第一章中讨论的那样,这是描述任何机器学习算法的一个良好方式,但在本章中,它在神经网络的上下文中具有特定含义。神经网络不是一个固定的程序,而是一个模型,一个处理信息或输入的系统。神经网络的特征如下:
-
信息处理以最简单的形式发生,作用于被称为单元的简单元素上。
-
单元相互连接,并通过连接链路交换信号。
-
单元之间的连接链路可以更强或更弱,这决定了信息的处理方式。
-
每个单元都有一个内部状态,该状态由所有来自其他单元的输入连接决定。
-
每个单元都有不同的激活函数,该函数根据单元的状态计算,并决定其输出信号。
对神经网络的更一般描述可以是数学运算的计算图,但我们将在后面进一步学习这个内容。
我们可以识别神经网络的两个主要特征:
-
神经网络架构:这描述了单元之间的连接方式——即前馈、递归、多层或单层等——层数和每层单元的数量。
-
学习:这描述了通常定义为训练的过程。训练神经网络最常见但并非唯一的方式是使用梯度下降(GD)和反向传播(BP)。
我们将从神经网络的最小构建块——单元开始讨论。
单元——神经网络的最小构建块
单元是可以定义为以下数学函数:
在这里,我们做以下操作:
-
我们计算加权和
(也称为激活值)。让我们聚焦于这个和的组成部分:
-
输入
是表示网络中其他单元输出或输入数据本身值的数值。
-
权重
是表示输入强度或单元间连接强度的数值。
-
权重 b 是一个特殊的权重,称为偏置,它表示一个始终开启的输入单元,值为 1。
或者,我们可以用它们的向量表示来替代
和
,其中 x = → x = [x 1, x 2, … , x n] 和
。这里,公式将使用两个向量的点积:
-
- 总和
作为 激活函数 f(也称为 传输函数)的输入。f 的输出是一个单一的 数值,代表单元本身的输出。激活函数具有以下特性:
-
非线性:f 是神经网络(NN)中非线性的来源——如果神经网络完全线性,它只会逼近其他线性函数
-
可微分:这使得可以通过梯度下降(GD)和反向传播(BP)来训练网络
如果你并不完全理解,不用担心——我们将在本章稍后详细讨论激活函数。
下图(左侧)展示了一个单元:
图 2.8 – 左:一个单元及其等效公式,右:感知机的几何表示
输入向量x与权重向量w垂直时,满足。因此,所有满足
的x向量定义了一个超平面,该超平面位于向量空间
,其中n是x的维度。在二维输入情况下
,我们可以将超平面表示为一条直线。这可以通过感知器(或二分类器)来说明——一个带有阈值激活函数的单元,
,它将输入分类为两个类别中的一个。带有两个输入的感知器的几何表示
是一条直线(或决策边界),它将两个类别分开(如前图所示,位于右侧)。
在前面的图示中,我们也可以看到偏置 b 的作用是使超平面能够从坐标系的中心移动。如果我们不使用偏置,单元的表示能力将受到限制。
单元是我们在第一章中介绍的几种算法的概括:
-
一个具有恒等激活函数 f(x) = x 的单元相当于多元 线性回归。
-
一个具有sigmoid 激活函数的单元相当于逻辑回归。
-
一个具有阈值激活函数的单元相当于感知机。
我们已经从第一章中了解到,感知机(因此也包括单元)只对线性可分的类别有效,而现在我们知道这是因为它定义了一个超平面。这对单元来说是一个严重的限制,因为它无法处理线性不可分的问题——即使是像异或(XOR)这样简单的问题。为了克服这个限制,我们需要将单元组织成神经网络(NN)。然而,在讨论完整的神经网络之前,我们将重点讨论下一个神经网络构建块——层。
层作为操作。
神经网络(NN)可以有无限数量的单元,这些单元按层次组织并互相连接。每一层具有以下特性:
-
它将多个单元的标量输出组合成一个单一的输出向量。单元的输出是标量,因此它只能传达有限的信息。通过组合单元的输出,而不是单一的激活函数,我们现在可以整体考虑向量。这样,我们就能传递更多的信息,不仅因为向量有多个值,还因为它们之间的相对比率承载了额外的含义。
-
一层的单元可以与其他层的单元连接,但不能与同一层的其他单元连接。由于这一点,我们可以并行计算单层所有单元的输出(从而提高计算速度)。这种能力是近年来深度学习(DL)成功的主要原因之一。
-
我们可以将多变量回归推广到一层,而不仅仅是将线性回归或逻辑回归应用于单一单元。换句话说,我们可以用一层来近似多个值,而不是用单一单元来近似单个值。这在分类输出的情况下尤为适用,其中每个输出单元代表输入属于某个类别的概率。
在经典神经网络(即深度学习之前的神经网络,那时它们只是众多机器学习算法中的一种)中,主要的层类型是全连接(FC)层。在这一层中,每个单元都会从输入向量的所有组件接收加权输入,x。这可以表示网络中另一个层的输出或输入数据集的一个样本。假设输入向量的大小为m,且 FC 层有n个单元和一个激活函数f,这个激活函数对所有单元都是相同的。每个n个单元会有m个权重——每个m个输入对应一个权重。下面是我们可以用来表示 FC 层单个单元j输出的公式。它与我们在单元 - 最小神经网络构建块部分定义的公式相同,但这里我们将包括单元索引:
这里, 是第 j层单元与输入向量的第 i个值之间的权重,x。我们可以将连接x的各个元素与单元的权重表示为一个m×n的矩阵W。W的每一列代表该层单个单元的所有输入的权重向量。在这种情况下,层的输出向量y是矩阵-向量乘法的结果。
我们还可以将多个输入样本向量,,组合成一个输入矩阵X,其中每个输入数据向量,
通过X中的一行表示。该矩阵本身被称为批次。然后,我们将同时计算所有输出向量,
,对应于输入样本,
。在这种情况下,我们将进行矩阵-矩阵乘法,XW,并且层的输出也是一个矩阵,Y。
以下图展示了一个全连接层的示例,以及在批次和单个样本场景下的等效公式:
图 2.9 – 一个带有向量/矩阵输入输出的全连接层及其等效公式
我们已经明确分开了偏置和输入权重矩阵,但实际上,底层实现可能使用共享的权重矩阵,并将一行额外的 1 添加到输入数据中。
到目前为止,我们将输入数据样本表示为向量,能够将它们组合成矩阵。然而,输入数据可以有更多的维度。例如,我们可以用三维来表示 RGB 图像——三个二维通道(每个颜色一个通道)。为了将多个图像组合在一个批次中,我们需要一个第四维度。在这种情况下,我们可以使用输入/输出张量而不是矩阵。
我们还将使用不同类型的层来处理多维数据。其中一种类型是卷积层,我们将在第四章中讨论。我们有许多其他类型的层,如注意力层、池化层等。有些层具有可训练的权重(全连接层、注意力层、卷积层),而有些则没有(池化层)。我们也可以将“函数”或“操作”与“层”互换使用。例如,在 TensorFlow 和 PyTorch 中,我们刚才描述的全连接层是两个顺序操作的组合。首先,我们对权重和输入进行加权求和,然后将结果作为输入传递给激活函数操作。在实际应用中(即在使用深度学习库时),神经网络的基本构建块不是单元,而是一个操作,它接受一个或多个张量作为输入,并输出一个或多个张量:
图 2.10 – 一个带有输入和输出张量的函数(或操作)
最后,我们拥有了讨论神经网络(NNs)完整细节所需的所有信息。
多层神经网络
如我们多次提到的,单层神经网络只能分类线性可分的类别。然而,实际上并没有什么阻止我们在输入和输出之间引入更多的层。这些额外的层称为隐藏层。下图展示了一个具有两个隐藏层的三层全连接神经网络:
图 2.11 – 多层前馈网络
输入层有 k 个输入单元,第一个隐藏层有 n 个隐藏单元,第二个隐藏层有 m 个隐藏单元。最上面是一个始终开启的偏置单元。输出在这个例子中是两个单元, 和
,其中每个单元表示两个可能类别之一。具有最高激活值的输出单元将决定神经网络对给定输入样本的类别预测。每个隐藏单元都有一个非线性激活函数,而输出层则具有一种特别的激活函数叫做softmax,我们将在激活函数部分中讨论。一个层中的单元与前一层和后一层的所有单元相连(因此是全连接的)。每个连接都有自己的权重,w,为了简化图示未显示出来。
正如我们在第一章中提到的,我们可以将隐藏层看作是神经网络对输入数据的内部表示。这是神经网络用自己的内部逻辑理解输入样本的方式。然而,这种内部表示是人类无法解释的。为了弥合网络表示和我们关注的实际数据之间的差距,我们需要输出层。你可以将它看作一个翻译器;我们用它来理解网络的逻辑,同时也能将其转换为我们关心的实际目标值。
然而,我们并不局限于具有顺序层的单路径网络,如前图所示。这些层(或一般操作)构成有向无环图。在这样的图中,信息不能通过同一层两次(没有循环),且信息仅沿一个方向流动,从输入到输出。前面的网络图只是一个特殊的图例,其中的层按顺序连接。下图也展示了一个有效的神经网络,具有两个输入层、一个输出层以及随机互联的隐藏层。这些层被表示为操作 (i 是帮助我们区分多个操作的索引):
图 2.12 – 神经网络作为操作图
递归网络
有一种特殊类型的神经网络,称为递归网络,它们表示有向循环图(可以有循环)。我们将在第六章中详细讨论它们。
在这一节中,我们介绍了最基本的神经网络类型——即单元——并逐步扩展到层级,然后将其概括为操作图。我们也可以从另一个角度来思考它。这些操作有着精确的数学定义。因此,神经网络作为函数的组合,实际上也是一个数学函数,其中输入数据代表函数的参数,网络权重集合θ(即所有权重矩阵的集合,W)则是其参数。我们可以用 或者
来表示它。假设当一个操作接收到来自多个来源(输入数据或其他操作)的输入时,我们使用逐元素求和来合并多个输入张量。然后,我们可以将神经网络表示为一系列嵌套的函数/操作。左侧前馈网络的等效公式如下:
![<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" display="block">mml:msubsupmml:mrowmml:mif</mml:mi></mml:mrow>mml:mrowmml:miθ</mml:mi></mml:mrow>mml:mrow<mml:mfenced separators="|">mml:mrowmml:mif</mml:mi>mml:mif</mml:mi></mml:mrow></mml:mfenced></mml:mrow></mml:msubsup><mml:mfenced separators="|">mml:mrow<mml:mi mathvariant="bold">x</mml:mi></mml:mrow></mml:mfenced>mml:mo=</mml:mo>mml:msupmml:mrowmml:mif</mml:mi></mml:mrow>mml:mrow<mml:mfenced separators="|">mml:mrowmml:mn5</mml:mn></mml:mrow></mml:mfenced></mml:mrow></mml:msup><mml:mfenced separators="|">mml:mrowmml:msupmml:mrowmml:mif</mml:mi></mml:mrow>mml:mrow<mml:mfenced separators="|">mml:mrowmml:mn3</mml:mn></mml:mrow></mml:mfenced></mml:mrow></mml:msup><mml:mfenced separators="|">mml:mrowmml:msupmml:mrowmml:mif</mml:mi></mml:mrow>mml:mrow<mml:mfenced separators="|">mml:mrowmml:mn1</mml:mn></mml:mrow></mml:mfenced></mml:mrow></mml:msup><mml:mfenced separators="|">mml:mrowmml:msubmml:mrowmml:mix</mml:mi></mml:mrow>mml:mrowmml:mn1</mml:mn></mml:mrow></mml:msub></mml:mrow></mml:mfenced>mml:mo+</mml:mo>mml:msupmml:mrowmml:mif</mml:mi></mml:mrow>mml:mrow<mml:mfenced separators="|">mml:mrowmml:mn4</mml:mn></mml:mrow></mml:mfenced></mml:mrow></mml:msup><mml:mfenced separators="|">mml:mrowmml:msupmml:mrowmml:mif</mml:mi></mml:mrow>mml:mrow<mml:mfenced separators="|">mml:mrowmml:mn1</mml:mn></mml:mrow></mml:mfenced></mml:mrow></mml:msup><mml:mfenced separators="|">mml:mrowmml:msubmml:mrowmml:mix</mml:mi></mml:mrow>mml:mrowmml:mn1</mml:mn></mml:mrow></mml:msub></mml:mrow></mml:mfenced>mml:mo+</mml:mo>mml:msupmml:mrowmml:mif</mml:mi></mml:mrow>mml:mrow<mml:mfenced separators="|">mml:mrowmml:mn2</mml:mn></mml:mrow></mml:mfenced></mml:mrow></mml:msup><mml:mfenced separators="|">mml:mrowmml:msubmml:mrowmml:mix</mml:mi></mml:mrow>mml:mrowmml:mn2</mml:mml:msub></mml:mrow></mml:mfenced></mml:mrow></mml:mfenced>现在我们已经了解了完整的神经网络架构,让我们来讨论不同类型的激活函数。## 激活函数我们现在知道,多层网络可以对线性不可分的类进行分类,但为此它们还需要满足一个条件。如果单元没有激活函数,它们的输出将是输入的加权和,,这就是一个线性函数。那么,整个神经网络——也就是单元的组合——将变成线性函数的组合,这也是一个线性函数。这意味着即使我们加入隐藏层,网络仍然等价于一个简单的线性回归模型,具有所有的局限性。为了将网络转变为非线性函数,我们将使用非线性激活函数来处理这些单元。通常,同一层的所有单元都使用相同的激活函数,但不同层之间可以使用不同的激活函数。我们将从三种常见的激活函数开始,前两种来自神经网络的经典时期,第三种则是当代的:
- Sigmoid:它的输出值被限制在 0 和 1 之间,可以在随机过程中解释为该单元被激活的概率。由于这些特性,sigmoid 曾是最受欢迎的激活函数。然而,它也有一些不太理想的属性(稍后会详细讨论),这导致了它的使用逐渐减少。下图展示了 sigmoid 函数及其导数的图形(导数将在我们讨论反向传播时派上用场):
图 2.13 – sigmoid 激活函数
- 双曲正切函数(tanh):其名称就已说明了其含义。与 sigmoid 的主要区别在于,tanh 的取值范围是(-1, 1)。下图展示了 tanh 函数及其导数的图形:
图 2.14 – 双曲正切激活函数
- 修正线性单元(ReLU):这是新兴的技术(与前辈们相比)。ReLU 首次成功应用于 2011 年(参见《深度稀疏修正神经网络》)。下图展示了 ReLU 函数及其导数的图形:
图 2.15 – ReLU 激活函数
如我们所见,当 x > 0 时,ReLU 会重复其输入,而在其他情况下保持为 0。这种激活函数相较于 sigmoid 和 tanh 具有几个重要的优势,使得训练拥有更多隐藏层(即更深层网络)的神经网络成为可能。我们将在第三章中讨论这些优势以及其他类型的激活函数。
在下一部分,我们将展示神经网络如何逼近任何函数。
泛化逼近定理
在多层神经网络(NNs)部分,我们将神经网络定义为一个函数,,其中x是输入数据(通常是一个向量或张量),θ是神经网络的权重。相反,训练数据集是输入样本和标签的集合,表示另一个现实世界中的函数!<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">mml:mig</mml:mi><mml:mfenced separators="|">mml:mrow<mml:mi mathvariant="bold">x</mml:mi></mml:mrow></mml:mfenced></mml:math>。神经网络函数
近似了函数
:
普遍逼近定理指出,任何在有限子集上的连续函数 都可以通过一个至少包含一个隐含层、有限数量单元和非线性激活的前馈神经网络(NN)来逼近到任意精度。这是重要的,因为它告诉我们,在神经网络的理论上没有不可逾越的限制。实际上,虽然只有一个隐含层的神经网络在许多任务中表现不佳,但至少我们可以期待神经网络的光明前景。我们可以通过一个直观的例子来理解普遍逼近定理。
注意
以下例子的灵感来源于 Michael A. Nielsen 的书籍 神经网络与深度 学习 (neuralnetworksanddeeplearning.com/
)。
我们将设计一个带有单一隐含层的神经网络,用于逼近 箱型函数(见下图右侧)。这是一种阶梯函数,在所有输入值上都为 0,只有在一个狭窄范围内,它才取常数值 A。一系列 平移 的阶梯函数可以逼近任何在有限子集上的连续函数 ,如下图左侧所示:
图 2.16 – 左:使用一系列阶梯函数来逼近连续函数,右:一个单一的箱型阶梯函数
我们将从构建一个带有单一单元的箱型神经网络开始,该单元具有一个标量输入 x,并采用 sigmoid 激活函数。下图展示了该单元及其等效公式:
图 2.17 – 带有单一输入和 sigmoid 激活的单元
在接下来的图示中,我们可以看到在输入 x 范围为 [-10: 10] 时,不同 b 和 w 值下的单元输出:
图 2.18 – 基于不同 w 和 b 值的单元输出。网络输入 x 显示在 x 轴上。
我们可以看到权重 w 决定了 sigmoid 函数的陡峭程度。我们还可以看到公式 t = -b/w 决定了函数沿 x 轴的平移。让我们看看前面图示中的不同图形:
-
左上角:常规 sigmoid
-
右上角:一个较大的权重,w,放大输入,x,直到 sigmoid 输出类似于阈值激活
-
左下角:偏置,b,沿 x 轴平移单元激活
-
右下角:我们可以同时通过偏置,b,沿 x 轴平移激活,并通过负值权重,w,反转激活
我们可以直观地看到,这个单元可以实现箱形函数的所有部分。然而,为了创建一个完整的箱形函数,我们需要在一个有一个隐藏层的神经网络中结合两个这样的单元。以下图显示了神经网络架构,以及单元的权重和偏置,还有网络生成的箱形函数:
图 2.19 – 箱形函数近似神经网络
其工作原理如下:
-
:神经网络输出为 0。
-
5:顶层单元对函数的上升阶梯激活,并且在所有值为
时保持激活。
-
0:底层单元对函数的下阶梯激活,并且在所有
时保持激活。由于输出层的权重相同但符号相反,两个隐藏单元的输出相互抵消。
-
输出层的权重决定了箱形函数的常数值,A = 5。
该网络的输出在[-5:5]区间内为 5,其他输入则输出 0。我们可以通过以类似的方式向隐藏层添加更多单元来近似其他箱形函数。
现在我们已经熟悉了神经网络(NN)的结构,让我们关注它们的训练过程。
训练神经网络
函数 近似 函数
:
。训练的目标是找到参数 θ,使得
能够最佳地近似
。首先,我们将看到如何为单层网络使用一个名为 GD 的优化算法。
然后,我们将把它扩展到一个深度前馈网络,借助 BP 的帮助。
Note
我们应该注意,NN 和其训练算法是两个独立的东西。这意味着我们可以通过除了 GD 和 BP 之外的某种方式调整网络的权重,但这是目前在实践中使用的最流行和高效的方式。
GD
在本节中,我们将使用均方误差(MSE)成本函数训练一个简单的 NN。它衡量了网络输出与所有训练样本的训练数据标签之间的差异(称为误差)。
起初,这可能看起来有些吓人,但别担心!背后其实是非常简单直接的数学(我知道这听起来更吓人!)。让我们来讨论它的组成部分:
-
: 神经网络的输出,其中 θ 是所有网络权重的集合。在本节的其余部分,我们将使用
来表示各个单独的权重(与其他部分使用的 w 符号不同)。
-
n: 训练集中的样本总数。
-
:训练样本的向量表示,其中上标i表示数据集的第i个样本。我们使用上标是因为
是一个向量,且下标通常保留给每个向量分量。例如,
是第i个训练样本的第j个分量。
-
:与训练样本相关联的标签,
。
注
在这个例子中,我们将使用均方误差(MSE),但实际上有不同类型的代价函数。我们将在第三章中讨论它们。
首先,梯度下降(GD)计算代价函数J(θ)对所有网络权重的导数(梯度)。梯度告诉我们J(θ)如何随着每个权重的变化而变化。然后,算法利用这些信息以更新权重的方式,使得将来相同输入/目标对的J(θ)值最小化。目标是逐步达到代价函数的全局最小值,此时梯度为 0。以下是对单个神经网络权重的 MSE 梯度下降(GD)可视化:
图 2.20 – MSE 图示
让我们逐步了解 GD 的执行过程:
-
初始化网络权重θ,赋予随机值。
-
重复以下过程,直到代价函数J(θ)降到某个阈值以下:
-
前向传播:使用前述公式计算训练集所有样本的 MSE J(θ) 成本函数。
-
反向传播:使用链式法则计算 J(θ) 对所有网络权重
的偏导数(梯度)。
-
让我们分析一下偏导数 。J 是一个关于
的函数,因为它是网络输出的函数。因此,它也是 NN 函数本身的函数——即 J(f(θ))。然后,按照链式法则,我们得到以下公式:
- 使用这些梯度值来更新每个网络权重:
这里,η 是学习率,它决定了优化器在训练过程中更新权重时的步长。需要注意的是,随着我们逐渐接近全局最小值,梯度会变得越来越小,我们将以更细的步伐更新权重。
为了更好地理解梯度下降(GD)是如何工作的,我们以线性回归为例。我们可以回顾一下,线性回归相当于一个具有恒等激活函数的单一神经网络单元,f(x) = x:
-
线性回归由以下函数表示
,其中 m 是输入向量的维度(等于权重的数量)。
-
然后,我们有 MSE 代价函数 –
-
接下来,我们计算偏导数
相对于单个网络权重
,使用链式法则和求和法则:
- 现在我们得到了梯度,
,我们可以使用学习率η来更新权重
到目前为止,我们讨论的是一个适用于具有多个权重的神经网络(NN)的梯度下降算法。然而,为了简化起见,前面的图示说明了成本函数与单一权重神经网络之间的关系。让我们通过引入一个具有两个权重的更复杂的神经网络成本函数来解决这个问题,分别为 和
:
图 2.21 – 关于两个权重的成本函数 J
该函数有局部最小值和全局最小值。没有什么可以阻止梯度下降(GD)收敛到局部最小值,而不是全局最小值,从而找到一个次优的目标函数近似。我们可以通过增大学习率η来尝试缓解这个问题。其想法是,即使 GD 收敛到局部最小值,较大的η也能帮助我们跳跃过鞍点并收敛到全局最小值。风险在于,反之也可能发生——如果 GD 正确地收敛到全局最小值,较大的学习率可能会使其跳跃到局部最小值。
防止此问题的一个更优雅的方法是使用动量。这通过调整当前权重更新与之前权重更新的值来扩展了普通的梯度下降——也就是说,如果步骤 t-1 的权重更新很大,它也会增加步骤 t 的权重更新。我们可以通过类比来解释动量。可以将损失函数表面看作是一座山的表面。现在,假设我们把一个球放在山顶(最大值)上。如果我们放开球,依靠地球的重力,它会开始滚向山谷底部(最小值)。它行进的距离越远,速度就越快。换句话说,它会获得动量(因此优化方法也得名)。
现在,让我们看看如何在权重更新规则中实现动量 。我们假设现在处于训练过程中的第 t 步:
-
首先,我们将计算当前的权重更新值
,并包括上次更新的速度
:
。这里,μ是一个范围在[0:1]之间的超参数,称为动量率。
在第一次迭代时初始化为 0。
-
然后,我们执行实际的权重更新 –
。
找到最佳的学习率 η 和动量率 μ 是一项经验任务。它们可能依赖于神经网络的架构、数据集的类型和大小以及其他因素。此外,我们可能需要在训练过程中调整它们。由于神经网络的权重是随机初始化的,我们通常从较大的 η 开始,以便梯度下降(GD)可以快速推进,尤其是在成本函数(误差)初始值较大时。一旦成本函数的下降开始趋于平稳,我们就可以降低学习率。通过这种方式,梯度下降可以找到那些在较大学习率下可能跳过的最小值。
另外,我们可以使用一种自适应学习率算法,如 Adam(请参阅 arxiv.org/abs/1412.6980
中的 Adam: A Method for Stochastic Optimization)。它根据之前的权重更新(动量)为每个权重计算个性化和自适应的学习率。
我们刚刚描述的 GD 称为 批量梯度下降,因为它在 所有 训练样本上累积误差,然后进行一次权重更新。这对于小型数据集来说没问题,但对于大型数据集来说可能变得不切实际,因为这种偶尔更新的方式会导致训练时间很长。在实际应用中,我们会使用两种修改:
-
随机(或在线)梯度下降(SGD):在每个训练样本之后更新权重。
-
小批量梯度下降:在每个小批量(称为小批量)的 k 个样本上累积误差,并在每个小批量之后进行一次权重更新。它是在线 GD 和批量 GD 之间的一种混合方式。在实践中,我们几乎总是会使用小批量 GD,而不是其他修改。
我们学习旅程的下一步是了解如何将 GD 应用到多层网络的训练中。
反向传播
在本节中,我们将讨论如何将 GD 与 BP 算法结合,以更新多层网络的权重。正如我们在 GD 部分演示的那样,这意味着求出成本函数 J(θ) 对每个网络权重的导数。我们已经借助链式法则在这方面迈出了第一步:
在这里,f(θ) 是网络的输出,![+ 为了简化起见,我们将使用顺序前馈神经网络(NN)。顺序意味着每一层都从前一层接收输入,并将其输出发送到下一层。+ 我们将定义 作为层 l 中第 i 个单元与下一层 l+1 中第 j 个单元之间的权重。在多层网络中,l 和 l+1 可以是任何两个连续的层,包括输入层、隐藏层和输出层。
-
我们将用
来表示层 l 中第 i 个单元的输出,用
来表示层 l+1 中第 j 个单元的输出。
-
我们将用
来表示层 l 中单元 j 激活函数的输入(即激活前输入的加权和)。
以下图示展示了我们介绍的所有符号:
图 2.22 – 层 l 表示输入层,层 l+1 表示输出层,w 连接了层 l 中的 y 激活到层 l+1 中第 j 个单元的输入。
掌握了这些有用的知识后,我们开始正式讨论:
- 首先,我们假设 l 和 l+1 分别是倒数第二层和最后一层(输出层)。了解这一点后,J 关于
的导数如下所示:
-
让我们聚焦于
。这里,我们计算的是层 l 输出的加权和对其中一个权重的偏导数,![因此,我们得出以下结论:
-
第 1 点中的公式适用于网络中任何两个连续的隐藏层,l 和 l+1。我们知道,
,同时我们还知道,
是激活函数的导数,我们可以计算它(参见 激活函数 部分)。我们需要做的就是计算导数
(回想一下,这里,l+1 是一个隐藏层)。我们可以注意到,这是相对于 l+1 层中激活函数的误差导数。现在我们可以从最后一层开始,逐步向后计算所有的导数,因为以下内容成立:
-
我们可以计算最后一层的这个导数。
-
我们有一个公式,可以让我们计算一个层的导数,前提是我们能够计算下一层的导数。
- 牢记这些要点后,我们通过应用链式法则得到以下公式:
在网络的前馈部分中,输出被馈送到第 l+1 层的所有单元。因此,它们在向后传播错误时都会对
有所贡献。
注意
在层作为操作部分,我们讨论了在前向传递中,如何通过矩阵-矩阵乘法同时计算出层l+1的所有输出 。在这里,
是层l的层输出,
是层l和层l+1之间的权重矩阵。在前向传递中,
的一列代表了从输入层l的所有单元到输出层l+1的某个单元的权重。在反向传递中,我们也可以通过使用转置权重矩阵来表示矩阵-矩阵乘法!<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">mml:msupmml:mrow<mml:mi mathvariant="bold">W</mml:mi></mml:mrow>mml:mrow<mml:mfenced separators="|">mml:mrowmml:mil</mml:mi>mml:mo+</mml:mo>mml:mn1</mml:mn>mml:mo,</mml:mo>mml:mil</mml:mi></mml:mrow></mml:mfenced></mml:mrow></mml:msup>mml:mo=</mml:mo>mml:msupmml:mrow<mml:mfenced open="[" close="]" separators="|">mml:mrowmml:msupmml:mrow<mml:mi mathvariant="bold">W</mml:mi></mml:mrow>mml:mrow<mml:mfenced separators="|">mml:mrowmml:mil</mml:mi>mml:mo,</mml:mo>mml:mil</mml:mi>mml:mo+</mml:mo>mml:mn1</mml:mn></mml:mrow></mml:mfenced></mml:mrow></mml:msup></mml:mrow></mml:mfenced></mml:mrow>mml:mrowmml:mi⊤</mml:mi></mml:mrow></mml:msup></mml:math>。转置矩阵的一列代表了所有来自l的单元权重,这些权重在前向传递阶段贡献给了l+1的某个特定单元。
我们再次计算 。 使用我们在步骤 3中遵循的相同逻辑,我们可以计算出
。 因此,一旦我们知道
,我们可以计算出
。
由于我们可以计算 用于最后一层,我们可以向后移动并计算
对于任何层,并因此计算
对于任何层。
- 总结一下,假设我们有一系列层,其中适用以下内容:
这里,我们有以下基本方程式:
通过这两个方程,我们可以计算出每一层的成本函数的导数。
- 如果我们设置
,那么
表示相对于激活值的成本变化,我们可以将
看作是单元
的误差。我们可以将这些方程改写如下:
接下来,我们可以写出以下方程:
这两个方程为我们提供了 BP 的另一种视角,因为成本随着激活值的变化而变化。它们为我们提供了一种计算任意层* l 变化的方法,只要我们知道下一层 l+1 *的变化。
- 我们可以将这些方程组合起来显示如下:
- 每层权重的更新规则由以下方程给出:
现在我们熟悉了 GD 和 BP,让我们在 Python 中实现它们。
XOR 函数的 NN 的代码示例
在本节中,我们将创建一个具有一个隐藏层的简单网络,该网络解决 XOR 函数问题。我们回顾一下,XOR 是一个线性不可分的问题,因此需要一个隐藏层。源代码将允许你轻松修改层数和每层的单元数,便于你尝试不同的场景。我们将不使用任何机器学习库,而是从零开始实现它们,仅借助numpy
。我们还将使用matplotlib
来可视化结果:
-
我们首先导入这些库:
import matplotlib.pyplot as plt import numpy as np from matplotlib.colors import ListedColormap
-
然后,我们将定义激活函数及其导数。在这个例子中,我们将使用
tanh(x)
:def tanh(x): return (1.0 - np.exp(-2 * x)) / (1.0 + np.exp(-2 * x)) def tanh_derivative(x): return (1 + tanh(x)) * (1 - tanh(x))
-
然后,我们将开始定义
NeuralNetwork
类及其构造函数(请注意,所有的方法和属性都必须正确缩进):class NeuralNetwork: # net_arch consists of a list of integers, indicating # the number of units in each layer def __init__(self, net_arch): self.activation_func = tanh self.activation_derivative = tanh_derivative self.layers = len(net_arch) self.steps_per_epoch = 1000 self.net_arch = net_arch # initialize the weights with random values in the range (-1,1) self.weights = [] for layer in range(len(net_arch) - 1): w = 2 * np.random.rand(net_arch[layer] + 1, net_arch[layer + 1]) - 1 self.weights.append(w)
这里,
net_arch
是一个一维数组,包含每一层的单元数。例如,[2, 4, 1]
表示输入层有两个单元,隐藏层有四个单元,输出层有一个单元。由于我们正在研究 XOR 函数,输入层将有两个单元,输出层只有一个单元。然而,隐藏单元的数量可以变化。为了结束构造函数,我们将使用范围为(-1, 1)的随机值来初始化网络权重。
-
现在,我们需要定义
fit
函数,它将训练我们的网络:def fit(self, data, labels, learning_rate=0.1, epochs=10):
-
我们将通过将
bias
与训练data
连接到一个新的变量input_data
来开始实现(源代码在方法定义内进行了缩进):bias = np.ones((1, data.shape[0])) input_data = np.concatenate((bias.T, data), axis=1)
-
然后,我们将运行训练若干
epochs
:for k in range(epochs * self.steps_per_epoch):
-
在循环中,我们将在每个 epoch 开始时可视化 epoch 编号和神经网络的预测输出:
print('epochs: {}'.format(k / self.steps_per_epoch)) for s in data: print(s, nn.predict(s))
-
在循环中,我们从训练集随机选择一个样本,并将其通过隐藏网络层进行前向传播:
sample = np.random.randint(data.shape[0]) y = [input_data[sample]] for i in range(len(self.weights) - 1): activation = np.dot(y[i], self.weights[i]) activation_f = self.activation_func(activation) # add the bias for the next layer activation_f = np.concatenate((np.ones(1), np.array(activation_f))) y.append(activation_f)
-
在循环外,我们将计算最后一层的输出和误差:
# last layer activation = np.dot(y[-1], self.weights[-1]) activation_f = self.activation_func(activation) y.append(activation_f) # error for the output layer error = y[-1] - labels[sample] delta_vec = [error * self.activation_derivative(y[-1])]
-
然后,我们将反向传播误差(反向传播):
# we need to begin from the back from the next to last layer for i in range(self.layers - 2, 0, -1): error = delta_vec[-1].dot(self.weights[i][1:].T) error = error * self.activation_derivative(y[i][1:]) delta_vec.append(error) # reverse # [level3(output)->level2(hidden)] => [level2(hidden)->level3(output)] delta_vec.reverse()
-
最后,我们将根据我们刚刚计算的误差更新权重。我们将用输出的 delta 与输入激活相乘,得到权重的梯度。然后,我们将使用学习率更新权重:
for i in range(len(self.weights)): layer = y[i].reshape(1, nn.net_arch[i] + 1) delta = delta_vec[i].reshape(1, nn.net_arch[i + 1]) self.weights[i] -= learning_rate * layer.T.dot(delta)
这就完成了
fit
方法的实现。 -
我们现在将编写一个
predict
函数来检查结果,该函数返回给定输入的网络输出:def predict(self, x): val = np.concatenate((np.ones(1).T, np.array(x))) for i in range(0, len(self.weights)): val = self.activation_func( np.dot(val, self.weights[i])) al = np.concatenate((np.ones(1).T, np.array(val))) return val[1]
-
最后,我们将编写
plot_decision_regions
方法,该方法根据输入变量绘制分隔类的超平面(在我们的例子中表示为直线)。我们将创建一个二维网格,每个输入变量对应一个轴。我们将绘制神经网络对整个网格的所有输入值组合的预测结果。我们会将网络输出大于 0.5 的视为true
,否则视为false
(我们将在本节的最后看到这些图):def plot_decision_regions(self, X, y, points=200): markers = ('o', '^') colors = ('red', 'blue') cmap = ListedColormap(colors) x1_min, x1_max = X[:, 0].min() - 1, X[:, 0].max() + 1 x2_min, x2_max = X[:, 1].min() - 1, X[:, 1].max() + 1 resolution = max(x1_max - x1_min, x2_max - x2_min) / float(points) xx1, xx2 = np.meshgrid(np.arange(x1_min, x1_max, resolution), np.arange(x2_min, x2_max, resolution)) input = np.array([xx1.ravel(), xx2.ravel()]).T Z = np.empty(0) for i in range(input.shape[0]): val = nn.predict(np.array(input[i])) if val < 0.5: val = 0 if val >= 0.5: val = 1 Z = np.append(Z, val) Z = Z.reshape(xx1.shape) plt.pcolormesh(xx1, xx2, Z, cmap=cmap) plt.xlim(xx1.min(), xx1.max()) plt.ylim(xx2.min(), xx2.max()) # plot all samples classes = ["False", "True"] for idx, cl in enumerate(np.unique(y)): plt.scatter(x=X[y == cl, 0], y=X[y == cl, 1], alpha=1.0, c=colors[idx], edgecolors='black', marker=markers[idx], s=80, label=classes[idx]) plt.xlabel('x1) plt.ylabel('x2') plt.legend(loc='upper left') plt.show()
这就完成了
NeuralNetwork
类的实现。 -
最后,我们可以使用以下代码运行程序:
np.random.seed(0) # Initialize the NeuralNetwork with 2 input, 2 hidden, and 1 output units nn = NeuralNetwork([2, 2, 1]) X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]]) y = np.array([0, 1, 1, 0]) nn.fit(X, y, epochs=10) print("Final prediction") for s in X: print(s, nn.predict(s)) nn.plot_decision_regions(X, y)
我们将构建默认的网络,
nn = NeuralNetwork([2,2,1])
。第一个和最后一个值(2
和1
)代表输入层和输出层,不能修改,但我们可以添加不同数量的隐藏层和不同数量的单元。例如,([2,4,3,1])
将表示一个三层的神经网络,第一个隐藏层有四个单元,第二个隐藏层有三个单元。我们将使用
numpy.random.seed(0)
来确保权重初始化在每次运行时的一致性,以便我们能够比较它们的结果。这是在训练神经网络时的常见做法。
现在,我们将分别在x
和y
中定义训练 XOR 数据和标签。我们将训练 10 个周期。最后,我们将绘制结果。
在接下来的图示中,你可以看到nn.plot_decision_regions
函数方法如何绘制超平面,这些超平面将不同的类别分开。圆圈表示(true, true)
和(false, false)
输入的网络输出,而三角形表示 XOR 函数的(true, false)
和(false, true)
输入。在左边,我们可以看到一个具有两个隐藏单元的神经网络的超平面,而在右边,我们可以看到一个具有四个隐藏单元的神经网络:
图 2.23 – 左:具有两个隐藏单元的神经网络所学习到的超平面,右:具有四个隐藏单元的神经网络的超平面
具有不同架构的网络可以产生不同的分离区域。在前面的图中,我们可以看到,尽管网络找到了正确的解决方案,但分隔区域的曲线会有所不同,这取决于所选择的架构。
我们现在准备开始更深入地了解深度神经网络及其应用。
总结
在本章中,我们详细介绍了神经网络(NNs),并提到了它们相对于其他竞争算法的成功。神经网络由互相连接的单元组成,连接的权重决定了不同单元之间通信的强度。我们讨论了不同的网络架构,神经网络如何拥有多个层次,以及为什么内部(隐藏)层非常重要。我们解释了信息是如何从输入流向输出的,经过每一层的传递,基于权重和激活函数。最后,我们展示了如何训练神经网络——也就是如何通过梯度下降(GD)和反向传播(BP)调整它们的权重。
在接下来的章节中,我们将继续讨论深度神经网络(NNs)。我们将特别解释深度在深度学习中的含义,这不仅指网络中隐藏层的数量,还指网络学习的质量。为此,我们将展示神经网络如何学习识别特征并将其组合成更大对象的表示。我们还将介绍一些重要的深度学习库,最后提供一个具体的示例,展示如何将神经网络应用于手写数字识别。
第三章:深度学习基础
在这一章中,我们将介绍深度学习(DL)和深度神经网络(DNNs)——即具有多个隐藏层的神经网络(NNs)。你可能会疑惑,既然有通用逼近定理,为什么还需要使用多个隐藏层?这个问题并非天真,长期以来,神经网络确实是以这种方式使用的。
不需要过多细节解释,原因之一是,逼近一个复杂的函数可能需要隐藏层中大量的单元,这使得使用它变得不切实际。还有另一个更为重要的原因,虽然它与隐藏层的数量无关,但与学习的层次相关。一个深度网络不仅仅是学习如何根据输入X预测输出Y;它还能够理解输入的基本特征。它能够学习输入样本特征的抽象,理解样本的基本特性,并基于这些特性进行预测。这种抽象层次在其他基本的机器学习(ML)算法和浅层神经网络中是缺失的。
在这一章中,我们将讨论以下主要主题:
-
深度学习简介
-
深度学习的基本概念
-
深度神经网络
-
训练深度网络
-
深度学习的应用
-
介绍流行的深度学习库
技术要求
我们将在本章中使用 Python、PyTorch 和 Keras 作为TensorFlow(TF)的一部分来实现示例。如果你还没有设置好这些工具的环境,别担心——示例代码已经作为 Jupyter 笔记本文件提供在 Google Colab 上。你可以在本书的 GitHub 仓库中找到代码示例:github.com/PacktPublishing/Python-Deep-Learning-Third-Edition/tree/main/Chapter03
。
深度学习简介
在 2012 年,Alex Krizhevsky、Ilya Sutskever 和 Geoffrey Hinton 发表了一篇具有里程碑意义的论文,题为 使用深度卷积神经网络进行 ImageNet 分类 (papers.nips.cc/paper/4824-imagenet-classification-with-deep-convolutional-neural-networks.pdf
)。该论文描述了他们使用神经网络(NN)赢得同年 ImageNet 竞赛的过程,这一点我们在第二章中提到过。论文结尾指出,即使移除单一层,网络的性能也会下降。他们的实验表明,移除任何中间层都会导致模型大约 2%的 Top-1 准确率损失。他们得出结论,网络的深度对性能至关重要。那么,基本问题是:是什么让网络的深度如此重要呢?
一个典型的英文谚语是“一图胜千言”。让我们用这种方法来理解什么是深度学习(DL)。我们将使用来自广泛引用的论文《卷积深度信念网络:可扩展的无监督学习层次表示》中的图像(ai.stanford.edu/~ang/papers/icml09-ConvolutionalDeepBeliefNetworks.pdf
)。在这篇论文中,作者用不同类别的物体或动物图片训练了一个神经网络(NN)。下图展示了网络的不同层如何学习输入数据的不同特征。在第一层,网络学习检测一些小的基础特征,例如线条和边缘,这些特征在所有类别的所有图像中都是常见的:
图 3.1 – 第一层权重(上)和第二层权重(下)训练后的结果
但是接下来的层,如下图所示,将这些线条和边缘结合起来,组成了更复杂的特征,这些特征对每个类别都是特定的。在左下角图像的第一行中,我们可以看到网络如何检测到人脸的不同特征,例如眼睛、鼻子和嘴巴。对于汽车而言,这些特征可能是车轮、车门等等,如下图中的第二张图所示。这些特征是抽象的——即,网络已经学会了一个特征(如嘴巴或鼻子)的通用形状,并且能够在输入数据中检测到这个特征,尽管它可能具有不同的变化:
图 3.2 – 第 1 至第 4 列表示为特定物体类别(类)学习的第二层(上)和第三层(下)权重。第 5 列表示为四个物体类别(人脸、汽车、飞机和摩托车)混合学习的权重
在前述图像的第二行中,我们可以看到,在更深的层次中,网络将这些特征组合成更复杂的特征,如人脸和完整的汽车。深度神经网络(DNN)的一个优势是,它们能够自主地学习这些高层次的抽象表示,并从训练数据中推导出这些表示。
接下来,让我们更详细地讨论 DNN 的这些特性。
深度学习的基本概念
1801 年,Joseph Marie Charles 发明了贾卡尔织机。Charles 不是一名科学家,而仅仅是一个商人。贾卡尔织机使用了一套打孔卡片,每张卡片代表着织机上要复制的图案。同时,每张卡片也是该图案的抽象表示。例如,打孔卡片曾被用在 1890 年由 Herman Hollerith 发明的统计机器中,或者作为第一代计算机输入代码的一种方式。在统计机器中,卡片仅仅是要输入机器以计算人口统计数据的样本的抽象。然而,在贾卡尔织机中,卡片的使用更加微妙,每张卡片代表了可以与其他卡片组合起来形成更复杂图案的模式抽象。打孔卡片是现实特征的抽象表示,最终编织出来的设计就是这一抽象的体现。
在某种程度上,贾卡尔织机为今天的深度学习(DL)播下了种子,它通过对特征的表示定义了现实的含义。深度神经网络(DNN)不仅仅是识别猫是什么,或者松鼠是什么,它更理解猫和松鼠分别具备哪些特征。它学会了如何利用这些特征来设计一只猫或一只松鼠。如果我们要用贾卡尔织机设计一个猫形状的织物图案,我们需要使用具有猫鼻子上的胡须,且有优雅而纤细的身体的打孔卡片。相反,如果我们要设计一只松鼠,则需要使用能够表现松鼠毛茸茸尾巴的打孔卡片。一个学习了基本特征表示的深度网络,可以根据它所做的假设进行分类。例如,如果没有毛茸茸的尾巴,它可能就不是松鼠,而是猫。通过这种方式,网络学习到的信息更为完整且更具鲁棒性,最令人兴奋的是,深度神经网络能够自动完成这一学习过程。
特征学习
为了说明深度学习是如何工作的,我们来考虑一个简单的几何图形识别任务,例如识别一个立方体,见下图所示:
图 3.3 – 一个表示立方体的神经网络(NN)的抽象图。不同的层次编码了具有不同抽象层级的特征。
这个立方体由边(或线)组成,这些边在顶点处相交。假设三维空间中的每一个可能点都与一个单位相关联(暂时忽略这将需要无穷多个单位)。所有这些点/单位都位于多层前馈网络的第一层(输入层)。如果相应的点位于一条线上,则输入点/单位是激活的。位于同一条线(边)上的点/单位与下一层中的单一公共边/单位之间有强的正向连接。相反,它们与下一层中所有其他单位之间有负向连接。唯一的例外是位于顶点上的单位。每个这样的单位同时位于三条边上,并与下一层中的三个对应单位相连接。
现在,我们有了两个隐藏层,分别具有不同的抽象层次——第一个层次处理点,第二个层次处理边。然而,这还不足以在网络中编码一个完整的立方体。我们尝试通过增加一个顶点层来解决这个问题。在这里,第二层中每三个激活的边/单位(形成一个顶点)与第三层中的单一公共顶点/单位之间有显著的正向连接。由于立方体的每条边形成两个顶点,因此每条边/单位将与两个顶点/单位有正向连接,并与所有其他单位有负向连接。最后,我们将引入最后一个隐藏层(立方体)。形成立方体的四个顶点/单位将与来自立方体/层的单一立方体/单位之间有正向连接。
这个立方体表示的例子过于简化,但我们可以从中得出几个结论。其中之一是深度神经网络(DNN)非常适合层次化组织的数据。例如,一张图像由像素组成,这些像素形成线条、边缘、区域等。同样,语音也如此,其中的基本构件叫做音素,而文本则有字符、单词和句子。
在前面的例子中,我们有意为特定的立方体特征分配了层,但实际上,我们不会这么做。相反,深度网络会在训练过程中自动“发现”特征。这些特征可能不会立刻显现出来,而且通常人类也无法解释它们。此外,我们无法知道网络中不同层所编码的特征的层次。我们的例子更像是经典的机器学习(ML)算法,在这些算法中,用户必须凭借自己的经验选择他们认为最好的特征。这一过程被称为特征工程,它可能既费力又耗时。让网络自动发现特征不仅更容易,而且这些特征通常非常抽象,从而使它们对噪声的敏感度较低。例如,人类视觉可以识别不同形状、大小、光照条件下的物体,甚至在物体的视角部分被遮挡时也能识别。我们能认出不同发型和面部特征的人,即使他们戴着帽子或围巾遮住嘴巴。类似地,网络学习到的抽象特征将帮助它更好地识别面孔,即使在更具挑战性的条件下。
在下一节中,我们将讨论深度学习(DL)变得如此流行的一些原因。
深度学习流行的原因
如果你已经关注机器学习(ML)一段时间,你可能会注意到许多深度学习(DL)算法并不新鲜。多层感知机(MLPs)已经存在近 50 年。反向传播算法曾多次被发现,但最终在 1986 年获得了认可。著名计算机科学家 Yann LeCun 在 1990 年代完善了他的卷积网络工作。在 1997 年,Sepp Hochreiter 和 Jürgen Schmidhuber 发明了长短期记忆网络(LSTM),这是一种至今仍在使用的递归神经网络(RNN)。在这一节中,我们将尝试理解为什么如今我们迎来了 AI 的春天,而之前只有 AI 的冬天(en.wikipedia.org/wiki/AI_winter
)。
第一个原因是,今天我们拥有的数据比过去多得多。互联网和软件在各个行业的兴起生成了大量可以通过计算机访问的数据。我们还拥有更多的基准数据集,例如 ImageNet。随着这些数据的增加,人们也希望通过分析数据来提取价值。正如我们稍后会看到的,深度学习(DL)算法在使用大量数据进行训练时表现得更好。
第二个原因是计算能力的提升。最显著的表现就是图形处理单元(GPU)的处理能力大幅提高。神经网络的组织方式使得它能够充分利用这种并行架构。让我们看看为什么。正如我们在第二章中所学到的,网络层的单元与同一层的单元没有直接连接。我们还学到了,许多层的操作可以表示为矩阵乘法。矩阵乘法是显著并行的(相信我,这是一个术语——你可以去 Google 查找!)。每个输出单元的计算与其他输出单元的计算无关。因此,我们可以并行计算所有的输出。并且,GPU 非常适合执行这样的高并行操作。一方面,GPU 的计算核心数量远超中央处理单元(CPU)。尽管 CPU 的核心速度比 GPU 核心更快,但我们仍然可以在 GPU 上并行计算更多的输出单元。但更重要的是,GPU 在内存带宽方面进行了优化,而 CPU 则优化了延迟。这意味着 CPU 可以非常快速地获取小块内存,但当需要获取大块内存时则会变得较慢。而 GPU 则相反。对于一个深度网络中有许多宽层的矩阵乘法,带宽成为瓶颈,而不是延迟。此外,GPU 的 L1 缓存比 CPU 的 L1 缓存更快,而且更大。L1 缓存代表了程序下一步可能使用的信息存储,存储这些数据可以加速处理过程。在深度神经网络(DNN)中,大量的内存会被重复使用,这也是 L1 缓存非常重要的原因。
在下一节《深度神经网络》中,我们将给出神经网络关键架构的更精确定义,并将在接下来的章节中详细介绍这些架构。
深度神经网络
我们可以将深度学习(DL)定义为机器学习(ML)技术的一类,其中信息通过分层处理,以逐步深入的方式理解数据中的表示和特征,复杂度逐渐增加。实际上,所有深度学习算法都是神经网络(NN),它们共享一些基本的共同特性。它们都由一个互联操作的图构成,操作过程使用输入/输出张量。它们的不同之处在于网络架构(或网络中单元的组织方式),有时也体现在训练方法上。考虑到这一点,让我们来看看神经网络的主要类型。以下列表并不详尽,但它代表了今天大多数使用中的神经网络类型:
-
多层感知器(MLP):一种具有前馈传播、全连接层且至少有一个隐藏层的神经网络。我们在第二章中介绍了 MLP。
-
卷积神经网络(CNN):CNN 是一种前馈神经网络,具有几种特殊类型的层。例如,卷积层通过滑动滤波器到输入图像(或声音)上,从而应用该滤波器,生成n维激活图。有证据表明,CNN 中的单元以类似生物细胞在大脑视觉皮层中的组织方式进行组织。到目前为止,我们已经提到过 CNN 很多次,这并非巧合——今天,CNN 在许多计算机视觉和自然语言处理任务中优于所有其他机器学习算法。我们将在第四章中讨论 CNN。
-
循环神经网络(RNN):这种类型的神经网络具有一个内部状态(或记忆),该状态基于已输入网络的所有或部分数据。循环网络的输出是其内部状态(输入的记忆)和最新输入样本的组合。同时,内部状态会发生变化,以纳入新输入的数据。由于这些特性,循环网络非常适合处理顺序数据任务,例如文本或时间序列数据。我们将在第六章中讨论循环网络。
-
Transformer:与 RNN 类似,transformer 适合处理序列数据。它使用一种叫做注意力机制的方式,使得模型能够直接同时访问输入序列中的所有元素。这与 RNN 不同,后者是逐个处理序列元素,并在每个元素之后更新其内部状态。正如我们将在第七章中看到的,注意力机制相较于经典的 RNN 具有多个重要优势。正因为如此,近年来,transformer 已经在许多任务中取代了 RNN。
-
自编码器:正如我们在第一章中提到的,自编码器是一类无监督学习算法,其输出形状与输入相同,这使得网络能够更好地学习基本表示。
现在我们已经概述了主要的深度神经网络类型,让我们讨论一下如何训练它们。
训练深度神经网络
历史上,科学界一直认为,深度网络相比浅层网络具有更强的表示能力。然而,训练拥有多个隐藏层的网络曾面临许多挑战。我们现在知道,结合梯度下降和反向传播,我们可以成功训练深度神经网络(DNN),正如我们在第二章中讨论过的那样。在本节中,我们将看到如何改进这些网络,以解决一些只在深度神经网络中出现的问题,而不是浅层神经网络。
本书的第一版包括了如限制玻尔兹曼机(RBMs)和深度信念网络(DBNs)等网络。它们由加拿大科学家 Geoffrey Hinton 推广,他是最著名的深度学习研究者之一。早在 1986 年,他也是反向传播算法的发明者之一。RBM 是一种特殊类型的生成性神经网络,其中单元被组织成两个层次,即可见层和隐藏层。与前馈网络不同,RBM 中的数据可以双向流动——从可见单元到隐藏单元,反之亦然。2002 年,Hinton 教授引入了对比散度,这是一种用于训练 RBM 的无监督算法。2006 年,他引入了深度信念网络(DBNs),这些是通过堆叠多个 RBM 形成的深度神经网络。由于其创新的训练算法,DBN 可以拥有比以前更多的隐藏层。但即便有了对比散度,训练一个 DBN 也不是件容易的事。这是一个两步过程:
-
首先,我们必须使用对比散度训练每个 RBM,并逐渐将它们堆叠在一起。这个阶段叫做预训练。
-
实际上,预训练作为下一阶段的一个复杂的权重初始化算法,叫做微调。通过微调,我们将 DBN 转化为一个常规的 MLP,并继续使用监督反向传播和梯度下降训练它,就像我们在第二章中看到的那样。
由于一些算法的进展,现在可以使用传统的反向传播算法训练深度网络,从而有效地消除了预训练阶段。这些进展使得 DBN 和 RBM 变得过时。它们无疑在研究中非常有趣,但在实践中已经很少使用,我们将在本版本中省略它们。
接下来,让我们讨论一些使得使用反向传播训练神经网络成为可能的算法进展。
改进的激活函数
那么,为什么训练深度网络如此困难呢?预训练解决的主要挑战之一就是所谓的梯度消失问题。为了理解这一点,我们假设使用反向传播训练一个普通的多层感知机(MLP),该网络具有多个隐藏层,并在每个层使用逻辑 sigmoid 激活函数。我们先聚焦于 sigmoid 函数(tanh 函数的情况也是如此)。提醒一下,sigmoid 函数的计算公式为 :
图 3.4 – 逻辑 sigmoid(未中断)及其导数(中断)(左);连续的 sigmoid 激活,将数据“压缩”(右)
梯度消失现象表现为以下几种方式:
-
在前向传播阶段,第一个 sigmoid 层的输出由前面图像中的蓝色不中断线表示,且其值位于(0, 1)范围内。右侧图像中的虚线表示每一层连续层之后的 sigmoid 激活值。即使经过三层,我们也可以看到激活值在一个狭窄的范围内“压缩”,并且无论输入值如何,它都趋向于约 0.66。例如,如果第一层的输入值为 2,那么
,
,
,等等。sigmoid 函数的这一特性相当于擦除从前一层传来的信息。
-
我们现在知道,要训练一个神经网络(NN),我们需要计算激活函数的导数(以及所有其他的导数)以供反向传播阶段使用。在前面的图像中,左侧的绿色中断线表示了 sigmoid 函数的导数。我们可以看到,它在一个非常窄的区间内有显著的值,且该区间围绕 0 居中,而在其他所有情况下则趋向于 0。在有多个层的网络中,当导数传播到网络的前几层时,它很可能会趋向于 0。实际上,这意味着我们无法将误差传播到这些层,也无法以有意义的方式更新它们的权重。
幸运的是,我们在第二章中引入的ReLU激活函数可以一举解决这两个问题。回顾一下,下面的图像展示了 ReLU 图形及其导数:
图 3.5 – ReLU 激活函数(不中断)及其导数(中断)
ReLU 具有以下理想特性:
-
它是 幂等的。如果我们通过任意次数的 ReLU 激活传递一个值,它将保持不变;例如,ReLU(2) = 2,ReLU(ReLU(2)) = 2,依此类推。这对于 sigmoid 函数来说并不成立。ReLU 的幂等性使得理论上可以创建比 sigmoid 更深层的网络。
-
我们还可以看到,它的导数无论反向传播的值如何,都是 0 或 1。通过这种方式,我们还可以避免在反向传播中梯度消失的问题。严格来说,ReLU 在值为 0 时的导数是未定义的,这使得 ReLU 只在半微分意义下有效(关于这一点的更多信息可以参考
en.wikipedia.org/wiki/Semi-differentiability
)。但在实践中,它足够有效。 -
它产生稀疏的激活。假设网络的权重通过正态分布随机初始化。在这种情况下,每个 ReLU 单元的输入有 0.5 的概率小于 0。因此,大约一半的激活输出也将为 0。这种稀疏激活有几个优势,我们可以粗略地总结为在神经网络中的奥卡姆剃刀原则——用更简单的数据表示来实现相同的结果,比复杂的表示方式更好。
-
它在前向和反向传播中计算速度更快。
尽管 ReLU 有这些优势,但在训练过程中,网络权重可能会被更新到某些 ReLU 单元总是接收小于 0 的输入,从而导致它们始终输出 0。这种现象被称为 死亡 ReLU(dying ReLUs)。为了解决这个问题,已经提出了几种 ReLU 的改进方法。以下是一个非详尽的列表:
- Leaky ReLU:当输入大于 0 时,Leaky ReLU 与普通 ReLU 相同,直接输出输入值。然而,当 x < 0 时,Leaky ReLU 输出 x 与某个常数 α (0 < α < 1) 的乘积,而不是 0。下图展示了 Leaky ReLU 的公式、它的导数以及它们的图形,α = 0.2:
图 3.6 – Leaky ReLU 激活函数
-
Parametric ReLU (PReLU;参见 深入探讨激活函数:超越人类水平的 ImageNet 分类,
arxiv.org/abs/1502.01852
):该激活函数与 Leaky ReLU 相同,但 α 是可调的,并且在训练过程中会进行调整。 -
指数线性单元 (ELU;见 通过指数线性单元(ELUs)进行快速准确的深度网络学习,
arxiv.org/abs/1511.07289
): 当输入大于 0 时,ELU 与 ReLU 的工作方式相同。然而,当 x < 0 时,ELU 的输出变为,其中 α 是一个可调参数。下图展示了 ELU 公式、它的导数及其图像,适用于 α = 0.2:
图 3.7 – ELU 激活函数
-
缩放指数线性单元 (SELU;见 自归一化神经网络,
arxiv.org/abs/1706.02515
): 该激活函数类似于 ELU,除了输出(大于或小于 0)通过一个附加的训练参数 λ 进行缩放。SELU 是一个更大概念的组成部分,称为 自归一化神经网络 (SNNs),这一概念在源论文中有所描述。 -
Sigmoid 线性单元 (SiLU),高斯误差线性单元 (GELU;见 高斯误差线性单元 (GELUs),
arxiv.org/abs/1606.08415
),以及 Swish(见 激活函数搜索,arxiv.org/abs/1710.05941
): 这是一个由三个相似(但不完全相同)函数组成的集合,它们与 ReLU 非常相似,但在 0 点处是可微的。为了简化,我们只展示 SiLU 的图像(σ 是 Sigmoid 函数):
图 3.8 – SiLU 激活函数
最后,我们有了 softmax,它是分类问题中输出层的激活函数。假设最终网络层的输出是一个向量,。每个 n 元素代表可能属于的 n 个类别之一。为了确定网络的预测结果,我们将取最大值的索引 i,
,并将输入样本分配给它所代表的类别。然而,我们也可以将网络的输出解释为离散随机变量的概率分布——即,每个值,
,代表输入样本属于特定类别的概率。为了帮助我们实现这一点,我们将使用 softmax 激活函数:
它具有以下属性:
-
公式中的分母充当了归一化器。这对于我们刚才介绍的概率解释非常重要:
-
每个值,
,都被限制在[0, 1]的范围内,这使我们可以将其视为一个概率。
-
值的总和
等于 1:
,这也与概率解释相一致。
-
-
一个额外的(实际上是强制性的)条件是该函数是可微的。
-
softmax 激活函数还有一个更微妙的属性。在我们对数据进行归一化之前,我们对每个向量组件进行指数变换,变换公式为
。假设两个向量组件为
和
。在这种情况下,我们会得到 exp(1) = 2.7 和 exp(2) = 7.39。如我们所见,变换前后组件的比率有很大不同——0.5 和 0.36。实际上,softmax 函数增强了较高分数的概率,相对于较低的分数。
在实际应用中,softmax 常常与 交叉熵损失 函数结合使用。它比较估计的类别概率与实际类别分布之间的差异(这种差异称为交叉熵)。我们可以将单个训练样本的交叉熵损失定义如下:
这里, 是输出属于类 j(从 n 个类中) 的估计概率,
和
是实际的概率。实际分布 P(X) 通常是一个独热编码向量,其中真实的类具有 1 的概率,其他所有类的概率为 0。在这种情况下,交叉熵损失函数将仅捕捉目标类的误差,并忽略其他类的误差。
现在我们已经学会了如何防止梯度消失,并且能够将神经网络输出解释为概率分布,我们将重点关注 DNN 面临的下一个挑战——过拟合。
DNN 正则化
到目前为止,我们已经了解到神经网络(NN)可以逼近任何函数。但强大的能力伴随着巨大的责任。神经网络可能会学习逼近目标函数的噪声,而不是其有用的部分。例如,假设我们正在训练一个神经网络来分类图像是否包含汽车,但由于某种原因,训练集大多数是红色的汽车。结果可能是,神经网络会将红色与汽车关联,而不是其形状。现在,如果网络在推理模式下看到一辆绿色的汽车,它可能无法识别为汽车,因为颜色不匹配。正如我们在第一章中讨论的那样,这个问题被称为过拟合,它是机器学习(ML)的核心问题(在深度网络中尤为严重)。在本节中,我们将讨论几种防止过拟合的方法。这些技术统称为正则化。
在神经网络的上下文中,这些正则化技术通常会在训练过程中施加一些人工的限制或障碍,以防止网络过度逼近目标函数。它们试图引导网络学习目标函数的一般性而非特定的逼近方式,期望这种表示能够在之前未见过的测试数据集示例上良好地泛化。让我们先从应用于输入数据的正则化技术开始,然后再将其输入到神经网络中:
-
最小-最大归一化:
。这里,x 是输入向量的单个元素,
是训练数据集中最小的元素,
是最大的元素。此操作将所有输入缩放到 [0, 1] 范围内。例如,一个灰度图像的最小颜色值为 0,最大颜色值为 255。然后,一个强度为 125 的像素,其缩放值为
。最小-最大归一化快速且易于实现。此归一化的一个问题是数据中的异常值可能对整个数据集的结果产生过大的影响。例如,如果一个单一的错误元素有非常大的值,它会进入公式计算中,并成为
,这将使所有归一化后的数据集值趋近于 0。
-
标准分数(或z 分数):
。它比最小-最大方法更好地处理数据中的异常值。为了理解其原因,让我们专注于这个公式:
-
是数据集中所有元素的均值,其中
是输入向量中的单个元素,N 是数据集的总大小。
-
是所有数据集元素的标准差。它衡量数据集的值与均值的偏离程度。还有方差,
,它去除了标准差中的平方根。方差在理论上是正确的,但比标准差不那么直观,因为标准差与原始数据的单位相同,x。
另外,如果在整个数据集上计算μ和σ不实际,我们也可以按样本计算它们。标准分数保持数据集的均值接近 0,标准差接近 1。
-
-
数据增强:这是通过在将训练样本输入到网络之前,对其进行随机修改,从而人为地增加训练集的大小。在图像的情况下,这些修改可能包括旋转、倾斜、缩放等。
下一类正则化技术应用于 DNN 结构本身:
- 丢弃法:在这里,我们随机且周期性地从网络中去除某些层的单元(连同它们的输入和输出连接)。在每个训练小批量中,每个单元都有一个概率 p,使其随机丢弃。这样做是为了确保没有单元过度依赖其他单元,而是“学习”对神经网络有用的内容。丢弃法仅在训练阶段应用,所有单元在推理阶段都会完全参与。在下图中,我们可以看到全连接层的丢弃法:
图 3.9 – 全连接层丢弃法示例
-
批量归一化(BN;参见 Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift,
arxiv.org/abs/1502.03167
):这是一种对网络隐藏层应用数据处理的方法,类似于标准分数。它对每个小批量(因此得名)隐藏层的输出进行归一化,使其均值接近 0(重新中心化),标准差接近 1(重新缩放)。其直观理解是,随着信息在各层之间传播,这些值可能会偏离期望值。假设小批量由 m×n 矩阵 X 表示。X 的每一行,,表示一个单独的输入向量(该向量是前一层的输出)。
是第 i 个向量的第 j 个元素。我们可以通过以下方式计算每个矩阵元素的 BN:
-
: 这是小批量均值。我们通过对小批量矩阵的所有单元格计算一个单一的μ值。
-
: 这是小批量方差。我们通过对小批量矩阵的所有单元格计算一个单一的
值。
-
:我们对矩阵的每个单元格进行归一化处理。ε 是为了数值稳定性而添加的常数,这样分母就不会变为 0。
-
:该公式表示原始数据的尺度和偏移。γ 和 β 是可学习的参数,我们在每个位置上计算它们,ij (
和
),在整个小批量矩阵的所有单元格上计算。
-
-
层归一化 (LN;见 层归一化,
arxiv.org/abs/1607.06450
):LN 类似于 BN,但有一个关键区别:均值和方差是分别在每个小批量样本上计算的。这与 BN 不同,BN 是在整个小批量上计算这些值。与 BN 一样,小批量是一个 m×n 矩阵,X,每一行向量,,是前一层的输出,且
是第 i 个向量的第 j 个元素。那么,我们对于第 i 个输入向量有如下公式:
-
-
均方根层归一化 (RMSNorm; 参见
arxiv.org/abs/1910.07467
): RMSNorm 的作者认为,LN 的主要好处仅来自重新缩放,而不是重新中心化和重新缩放的结合。因此,RMSNorm 是 LN 的简化且更快速的版本,它只使用均方根统计量进行重新缩放。我们将使用与 LN 相同的符号。因此,我们可以将 RMSNorm 定义如下:-
.
-
: 这里,
是增益参数,用于重新缩放标准化的输入和求和(初始值为 1)。它等同于 BN 中的 γ 参数。
-
下图说明了 BN 和 LN 之间的差异。在左侧,我们计算整个小批量中单个 μ 和 σ 值。右侧,我们可以看到每行分别为 和
:
图 3.10 – BN 和 LN 计算 μ 和 σ
我们将介绍的最终正则化类型是L2 正则化。该技术在成本函数中添加一个特殊的正则化项。为了理解它,让我们以 MSE 成本为例。我们可以通过以下方式将 L2 正则化添加到其中(公式中的下划线部分):
这里, 是 m 个总网络权重之一,λ 是权重衰减系数。其原理是,如果网络权重,
,较大,那么代价函数也会增大。实际上,权重衰减会惩罚大权重(因此得名)。这可以防止网络过度依赖与这些权重相关的少数特征。当网络被迫使用多个特征时,过拟合的机会会减少。实际操作中,当我们计算权重衰减代价函数(前面的公式)对每个权重的导数并将其传播到权重本身时,权重更新规则发生如下变化:
到
通过对 DNN 正则化的讨论,我们已经涵盖了理论基础。接下来,让我们看看 DNN 的实际应用是什么。
深度学习的应用
一般来说,机器学习(ML),尤其是深度学习(DL),在预测质量、特征检测和分类方面取得了越来越令人惊叹的结果。许多这些最新的成果已经成为新闻头条。进步的速度如此之快,以至于一些专家担心机器很快会比人类更聪明。但我希望你在读完这本书后,能够缓解你可能有的任何这种担忧。无论如何,机器仍然远未达到人类级别的智能。
在第二章中,我们提到过深度学习算法如何占据了 ImageNet 竞赛的领先位置。它们的成功足以从学术界跳跃到工业界。
让我们来看看深度学习的一些实际应用案例:
-
如今,新的汽车配备了一系列安全性和便利性功能,旨在使驾驶体验更安全、更轻松。其中一项功能是自动紧急刹车系统,当汽车看到障碍物时会自动刹车。另一个功能是车道保持辅助,它可以让车辆在不需要驾驶员操控方向盘的情况下保持当前车道。为了识别车道标记、其他车辆、行人和骑行者,这些系统使用了
前置摄像头。该领域最著名的供应商之一,Mobileye (
www.mobileye.com/
),已经生产了定制芯片,使用卷积神经网络(CNN)来检测前方道路上的物体。为了让你了解这个行业的重要性,2017 年,英特尔以 153 亿美元收购了 Mobileye。这并非个案,特斯拉著名的自动驾驶系统也依赖 CNN 来实现相同的效果。特斯拉前 AI 总监安德烈·卡帕西(Andrej Karpathy)(karpathy.ai/
)是深度学习领域的知名研究者。我们可以推测,未来的自动驾驶汽车也将使用深度网络进行计算机视觉。 -
谷歌的视觉 API (
cloud.google.com/vision/
) 和亚马逊的Rekognition (aws.amazon.com/rekognition/
) 服务都使用深度学习模型提供各种计算机视觉能力。这些功能包括图像中的物体和场景识别、文本识别、人脸识别、内容审核等。 -
如果这些 API 不够,你还可以在云端运行自己的模型。例如,你可以使用亚马逊的 AWS DL AMI(即亚马逊机器镜像;见
aws.amazon.com/machine-learning/amis/
),这些是预配置了流行深度学习库的虚拟机。谷歌也提供类似的服务,通过其 Cloud AI(cloud.google.com/products/ai/
),但他们更进一步。他们创建了张量处理单元(TPUs;见 https://cloud.google.com/tpu/)——这是一种专为快速神经网络操作(如矩阵乘法和激活函数)优化的微处理器。 -
深度学习(DL)在医学应用中具有很大的潜力。然而,严格的监管要求以及患者数据的保密性限制了其普及。尽管如此,我们仍然可以识别出深度学习在以下几个领域可能产生重大影响:
-
医学影像学是指多种非侵入性方法,用于创建身体内部的视觉表现。其中包括磁共振影像(MRIs)、超声、计算机断层扫描(CAT)扫描、X 射线和组织学影像。通常,这些图像需要由医学专业人员分析,以确定患者的病情。
-
计算机辅助诊断,特别是计算机视觉,可以通过检测和突出显示图像中的重要特征来帮助专家。例如,为了确定结肠癌的恶性程度,病理学家需要使用组织学影像分析腺体的形态学。这是一项具有挑战性的任务,因为形态学可能有很大的变化。深度神经网络(DNN)可以自动从图像中分割出腺体,剩下的工作由病理学家来验证结果。这将减少分析所需的时间,使得分析更加廉价且更易获得。
-
另一个可以受益于深度学习的医学领域是病历记录的分析。当医生诊断患者的病情并开具治疗方案时,他们首先会查阅患者的病历。深度学习算法可以从这些记录中提取最相关和最重要的信息,即使它们是手写的。这样,医生的工作将变得更轻松,同时也减少了错误的风险。
-
深度神经网络(DNNs)已在一个领域取得了显著的影响——蛋白质折叠。蛋白质是大型复杂分子,其功能取决于其三维结构。蛋白质的基本构件是氨基酸,其序列决定了蛋白质的形状。蛋白质折叠问题旨在理解初始氨基酸序列与蛋白质最终三维结构之间的关系。DeepMind 的AlphaFold 2模型(据信基于变换器架构;见
www.deepmind.com/blog/alphafold-reveals-the-structure-of-the-protein-universe
)成功预测了 2 亿种蛋白质结构,这几乎涵盖了所有已知的已编目蛋白质。
-
-
Google 的神经机器翻译 API (
arxiv.org/abs/1609.08144
) 使用了——你猜对了——深度神经网络(DNNs)进行机器翻译。 -
Siri (
machinelearning.apple.com/2017/10/01/hey-siri.html
)、Google 助手和 Amazon Alexa (aws.amazon.com/deep-learning/
) 依赖深度网络进行语音识别。 -
AlphaGo 是基于深度学习(DL)的人工智能机器,它在 2016 年通过战胜世界围棋冠军李世石而成为新闻焦点。AlphaGo 在 2016 年 1 月就已经引起了媒体的关注,当时它击败了欧洲围棋冠军范睿。然而,当时似乎不太可能击败世界冠军。几个月后,AlphaGo 以 4-1 的胜利系列取得了这一非凡成就。这是一个重要的里程碑,因为围棋有比其他棋类(如国际象棋)更多的可能变化,而且在事先很难考虑所有可能的走法。此外,与国际象棋不同,在围棋中,即便是判断棋盘上单颗棋子的当前局势或价值也非常困难。2017 年,DeepMind 发布了 AlphaGo 的更新版AlphaZero (
arxiv.org/abs/1712.01815
),而在 2019 年,他们发布了一个进一步更新的版本,名为MuZero (arxiv.org/abs/1911.08265
)。 -
像 GitHub Copilot (
github.com/features/copilot
) 和 ChatGPT (chat.openai.com/
)这样的工具利用生成型深度神经网络模型将自然语言请求转化为源代码片段、函数或完整的程序。我们之前提到的 Stable Diffusion (stability.ai/blog/stable-diffusion-public-release
) 和 DALL-E (openai.com/dall-e-2/
),则能够根据文本描述生成逼真的图像。
在这个简短的列表中,我们旨在涵盖深度学习(DL)应用的主要领域,如计算机视觉、自然语言处理(NLP)、语音识别和强化学习(RL)。然而,这个列表并不详尽,因为深度学习算法还有许多其他的应用。不过,我希望这些内容足以激发你的兴趣。接下来,我们将正式介绍两个最受欢迎的深度学习库——PyTorch 和 Keras。
介绍流行的深度学习库
我们已经在第一章中实现了一个使用 PyTorch 的简单示例。在本节中,我们将更系统地介绍该库以及 Keras。我们从大多数深度神经网络(DNN)库的共同特点开始:
-
所有库都使用 Python。
-
数据存储的基本单元是张量。从数学角度来看,张量的定义更加复杂,但在深度学习库的语境中,张量是多维的(具有任意数量的轴)基本值数组。
-
神经网络(NN)表示为计算图,图中的节点代表操作(加权求和、激活函数等)。边缘代表数据流动,数据如何从一个操作的输出作为下一个操作的输入。操作的输入和输出(包括网络的输入和输出)都是张量。
-
所有库都包含自动微分功能。这意味着你只需要定义网络架构和激活函数,库将自动计算训练过程中反向传播所需的所有导数。
-
到目前为止,我们提到了 GPU,但实际上,绝大多数深度学习项目仅使用 NVIDIA GPU。这是因为 NVIDIA 提供了更好的软件支持。这些库也不例外——为了实现 GPU 操作,它们依赖于 CUDA 工具包(
developer.nvidia.com/cuda-toolkit
)和 cuDNN 库(developer.nvidia.com/cudnn
)。cuDNN 是 CUDA 的扩展,专为深度学习应用构建。如在深度学习应用部分所提到的,你也可以在云端运行你的深度学习实验。
PyTorch 是一个独立的库,而 Keras 则建立在 TF 之上,作为一个用户友好的 TF 接口。接下来,我们将使用 PyTorch 和 Keras 实现一个简单的分类示例。
使用 Keras 进行数字分类
Keras 作为独立库存在,其中 TF 作为后端,也可以作为 TF 本身的子组件使用。你可以选择这两种方式之一。若要将 Keras 作为 TF 的一部分使用,我们只需安装 TF 本身。安装完成后,我们可以通过以下导入使用该库:
import tensorflow.keras
独立版 Keras 支持除 TF 外的不同后端,如 Theano。在这种情况下,我们可以安装 Keras 本身,然后通过以下导入来使用它:
import keras
Keras 的大多数使用都基于 TF 后端。Keras 的作者推荐将该库作为 TF 的一个组件使用(即第一种方式),我们将在本书的其余部分遵循这个方式。
在这一节中,我们将通过 TF 使用 Keras 来分类 MNIST 数据集的图像。该数据集包含了 70,000 个由不同人手写的数字示例。前 60,000 个通常用于训练,剩下的 10,000 个用于测试:
图 3.11 – 从 MNIST 数据集中提取的数字样本
我们将构建一个简单的多层感知机(MLP),并且只包含一个隐藏层。让我们开始:
-
Keras 的一个优点是它可以为你导入这个数据集,而不需要你显式地从网上下载(它会为你自动下载):
import tensorflow as tf (X_train, Y_train), (X_validation, Y_validation) = \ tf.keras.datasets.mnist.load_data()
这里,
(X_train, Y_train)
是训练图像和标签,(X_validation, Y_validation)
是测试图像和标签。 -
我们需要修改数据,以便将其输入到神经网络中。
X_train
包含 60,000 张 28×28 像素的图像,X_validation
包含 10,000 张。为了将它们作为输入提供给网络,我们希望将每个样本重塑为一个 784 像素长度的数组,而不是 28×28 的二维矩阵。我们还会将其归一化到[0:1]的范围内。我们可以通过以下两行代码来实现:X_train = X_train.reshape(60000, 784) / 255 X_validation = X_validation.reshape(10000, 784) / 255
-
标签表示图像中数字的值。我们希望将其转换为一个包含 0 和一个 1 的 10 维独热编码向量,其中 1 出现在与数字对应的索引位置。例如,4 被映射为[0, 0, 0, 0, 1, 0, 0, 0, 0, 0]。相应地,我们的网络将有 10 个输出单元:
classes = 10 Y_train = tf.keras.utils.to_categorical(Y_train, classes) Y_validation = tf.keras.utils.to_categorical( Y_validation, classes)
-
定义神经网络。在这个例子中,我们将使用
Sequential
模型,其中每一层都是下一层的输入。在 Keras 中,Dense
表示全连接层。我们将使用一个包含 100 个单元、BN、ReLU 激活函数和 softmax 输出的隐藏层:from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Dense, BatchNormalization, Activation input_size = 784 hidden_units = 100 model = Sequential([ Dense( hidden_units, input_dim=input_size), BatchNormalization(), Activation('relu'), Dense(classes), Activation('softmax') ])
-
现在,我们可以定义梯度下降的参数。我们将使用 Adam 优化器和分类交叉熵损失函数(这是针对 softmax 输出优化的交叉熵):
model.compile( loss='categorical_crossentropy', metrics=['accuracy'], optimizer='adam')
-
接下来,运行 100 轮训练,批次大小为 100。在 Keras 中,我们可以使用
fit
方法,它会在内部遍历整个数据集。Keras 默认使用 GPU 进行训练,但如果没有可用的 GPU,它会回退到 CPU:model.fit(X_train, Y_train, batch_size=100, epochs=20, verbose=1)
-
剩下的就是添加代码以评估网络在测试数据上的准确性:
score = model.evaluate(X_validation, Y_validation, verbose=1) print('Validation accuracy:', score[1])
就这样。验证准确率大约为 97.7%,虽然结果不算非常优秀,但这个示例在 CPU 上运行不到 30 秒。我们可以做一些简单的改进,比如增加更多的隐藏单元,或者增加更多的训练轮数。我们将把这些实验留给你,以便你能熟悉代码。
-
为了查看网络学到了什么,我们可以可视化隐藏层的权重。以下代码可以帮助我们获取它们:
weights = model.layers[0].get_weights()
-
将每个单元的权重重塑为 28×28 的二维数组,然后显示它们:
import matplotlib.pyplot as plt import matplotlib.cm as cm import numpy fig = plt.figure() w = weights[0].T for unit in range(hidden_units): ax = fig.add_subplot(10, 10, unit + 1) ax.axis("off") ax.imshow(numpy.reshape(w[unit], (28, 28)), cmap=cm.Greys_r) plt.show()
我们可以在下图中看到结果:
图 3.12 – 所有隐藏单元学习到的复合图
现在,让我们看一下 PyTorch 的示例。
使用 PyTorch 进行数字分类
在这一部分,我们将实现与使用 Keras 进行数字分类部分中相同的示例,但这次使用 PyTorch。让我们开始:
-
首先,我们将选择我们正在使用的设备(CPU 或 GPU)。我们将首先尝试 GPU,如果 GPU 不可用,则回退到 CPU:
import torch device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
-
和 Keras 一样,PyTorch 也开箱即用支持 MNIST。以下是如何实例化训练集和验证集的方法:
from torchvision import datasets from torchvision.transforms import ToTensor, Lambda, Compose train_data = datasets.MNIST( root='data', train=True, transform=Compose( [ToTensor(), Lambda(lambda x: torch.flatten(x))]), download=True, ) validation_data = datasets.MNIST( root='data', train=False, transform=Compose( [ToTensor(), Lambda(lambda x: torch.flatten(x))]), )
数据集会自动下载并分为训练集和验证集。
ToTensor()
转换将图像从numpy
数组转换为 PyTorch 张量,并将其标准化到[0:1]范围内(而非原来的[0:255])。torch.flatten
变换将二维 28×28 的图像展平成一维的 784 个元素,以便我们将其传递给神经网络。 -
接下来,我们将数据集封装在
DataLoader
实例中:from torch.utils.data import DataLoader train_loader = DataLoader( dataset=train_data, batch_size=100, shuffle=True) validation_loader = DataLoader( dataset=validation_data, batch_size=100, shuffle=True)
数据
DataLoader
实例负责创建小批量并随机打乱数据。它们也是迭代器,一次提供一个小批量。 -
然后,我们将定义神经网络
model
。我们将使用与 Keras 示例中相同的具有单一隐藏层的 MLP:torch.manual_seed(1234) hidden_units = 100 classes = 10 model = torch.nn.Sequential( torch.nn.Linear(28 * 28, hidden_units), torch.nn.BatchNorm1d(hidden_units), torch.nn.ReLU(), torch.nn.Linear(hidden_units, classes), )
这个定义类似于 Keras 中的定义。唯一的区别是,
Linear
(全连接)层需要输入和输出维度,因为它们无法自动提取前一层的输出维度。激活函数被定义为独立的操作。 -
接下来,让我们定义交叉熵损失和 Adam 优化器:
cost_func = torch.nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters())
-
现在,我们可以定义
train_model
函数,正如其名字所示,该函数负责训练模型。它接受我们预定义的model
、cost_function
、optimizer
和data_loader
并运行一个 epoch 的训练:def train_model(model, cost_function, optimizer, data_loader): # send the model to the GPU model.to(device) # set model to training mode model.train() current_loss = 0.0 current_acc = 0 # iterate over the training data for i, (inputs, labels) in enumerate(data_loader): # send the input/labels to the GPU inputs = inputs.to(device) labels = labels.to(device) # zero the parameter gradients optimizer.zero_grad() with torch.set_grad_enabled(True): # forward outputs = model(inputs) _, predictions = torch.max(outputs, 1) loss = cost_function(outputs, labels) # backward loss.backward() optimizer.step() # statistics current_loss += loss.item() * inputs.size(0) current_acc += torch.sum(predictions == labels.data) total_loss = current_loss / len(data_loader.dataset) total_acc = current_acc.double() / len(data_loader.dataset) print('Train Loss: {:.4f}; Accuracy: / {:.4f}'.format(total_loss, total_acc))
与 Keras 及其
fit
函数不同,我们需要自己实现 PyTorch 的训练过程。train_model
会遍历由train_loader
提供的所有小批量数据。对于每个小批量,optimizer.zero_grad()
会重置前一次迭代的梯度。然后,我们开始前向传播和反向传播,最后进行权重更新。 -
我们还将定义
test_model
函数,它将在推理模式下运行模型以检查其结果:def test_model(model, cost_function, data_loader): # send the model to the GPU model.to(device) # set model in evaluation mode model.eval() current_loss = 0.0 current_acc = 0 # iterate over the validation data for i, (inputs, labels) in enumerate(data_loader): # send the input/labels to the GPU inputs = inputs.to(device) labels = labels.to(device) # forward with torch.set_grad_enabled(False): outputs = model(inputs) _, predictions = torch.max(outputs, 1) loss = cost_function(outputs, labels) # statistics current_loss += loss.item() * inputs.size(0) current_acc += torch.sum(predictions == labels.data) total_loss = current_loss / len(data_loader.dataset) total_acc = current_acc.double() / len(data_loader.dataset) print('Test Loss: {:.4f}; Accuracy: / {:.4f}'.format(total_loss, total_acc)) return total_loss, total_acc
BN 和 dropout 层在评估时不会使用(只在训练时使用),因此
model.eval()
会关闭它们。我们遍历验证集,启动前向传播,并汇总验证损失和准确率。 -
让我们运行训练 20 个 epoch:
for epoch in range(20): train_model(model, cost_func, optimizer, train_loader) test_model(model, cost_func, validation_loader)
该模型实现了 97.6%的准确率。
总结
在这一章中,我们解释了什么是深度学习(DL),以及它与深度神经网络(DNNs)之间的关系。我们讨论了不同类型的 DNN 及其训练方法,并特别关注了帮助训练过程的各种正则化技术。我们还提到了许多深度学习的实际应用,并尝试分析它们为何如此高效。最后,我们介绍了两种最流行的深度学习库——PyTorch 和 Keras,并用这两个库实现了相同的 MNIST 分类示例。
在下一章,我们将讨论如何借助卷积神经网络解决更复杂的图像数据集上的分类任务——这是最流行且最有效的深度网络模型之一。我们将探讨其结构、构建模块,以及是什么让它们特别适合于计算机视觉任务。为了激发你的兴趣,我们回顾一下,自 2012 年以来,卷积神经网络一直在热门的 ImageNet 挑战赛中获胜,连续多年保持前五名的准确率,从 74.2%提升到 99%。
第二部分:
深度神经网络在计算机视觉中的应用
在这一部分,我们将介绍卷积神经网络(CNNs)——一种适用于计算机视觉应用的神经网络类型。在前面三章的基础上,我们将讨论 CNN 的基本原理、构建模块以及其架构。我们还将概述当前最流行的 CNN 模型。最后,我们将重点讲解 CNN 的高级应用——目标检测、图像分割和图像生成。
这一部分包含以下章节:
-
第四章,使用卷积网络进行计算机视觉
-
第五章,计算机视觉的高级应用
第四章:使用卷积网络进行计算机视觉
在第二章和第三章中,我们对深度学习(DL)和计算机视觉设定了很高的期望。首先,我们提到了 ImageNet 竞赛,然后讨论了它的一些令人兴奋的现实世界应用,例如半自动驾驶汽车。在本章及接下来的两章中,我们将实现这些期望。
视觉可以说是人类最重要的感官。我们几乎在进行的每一个动作中都依赖于它。但图像识别(并且在某些方面仍然是)长期以来一直是计算机科学中最困难的问题之一。历史上,很难向机器解释构成特定对象的特征,以及如何检测它们。但正如我们所见,在深度学习中,神经网络(NN)可以自己学习这些特征。
在本章中,我们将讨论以下主题:
-
卷积神经网络(CNNs)的直觉和理论依据
-
卷积层
-
池化层
-
卷积网络的结构
-
使用 PyTorch 和 Keras 对图像进行分类
-
卷积的高级类型
-
高级 CNN 模型
技术要求
我们将在本章中使用 Python、PyTorch 和 Keras 来实现这个示例。如果你还没有设置这些工具的环境,不必担心——这个示例已经作为 Jupyter Notebook 在 Google Colab 上提供。你可以在本书的 GitHub 仓库中找到代码示例:github.com/PacktPublishing/Python-Deep-Learning-Third-Edition/tree/main/Chapter04
。
CNN 的直觉和理论依据
我们从感官输入中提取的信息通常取决于它们的上下文。对于图像,我们可以假设相邻的像素是密切相关的,当将它们作为一个整体来看时,它们的集合信息更为重要。相反,我们可以假设单独的像素并不传递相互之间相关的信息。例如,在识别字母或数字时,我们需要分析相邻像素之间的依赖关系,因为它们决定了元素的形状。通过这种方式,我们能够区分,例如,0 和 1 之间的区别。图像中的像素被组织成二维网格,如果图像不是灰度图,我们还会有一个用于颜色通道的第三维度。
另外,磁共振成像(MRI)也使用三维空间。你可能还记得,直到现在,如果我们想要将图像输入到神经网络中,我们必须将它从二维数组转换为一维数组。卷积神经网络就是为了解决这个问题而构建的:如何使得来自更近单元的信息比来自更远单元的信息更相关。在视觉问题中,这意味着让单元处理来自彼此接近的像素的信息。通过 CNNs,我们将能够输入一维、二维或三维数据,网络也将输出相同维度的数据。正如我们稍后会看到的,这将为我们带来几个优势。
你可能还记得,在上一章的结尾,我们成功地对 MNIST 图像进行了分类(准确率约为 98%),使用的神经网络包括了飞机
、汽车
、鸟
、猫
、鹿
、狗
、青蛙
、马
、船
和卡车
。如果我们尝试使用一个具有一个或多个隐藏层的全连接神经网络(FC NN)来对 CIFAR-10 进行分类,其验证准确率大概会只有 50%左右(相信我,我们在本书的上一版中确实这么做过)。与接近 98% 准确率的 MNIST 结果相比,这是一个显著的差异,即使 CIFAR-10 也是一个简单的玩具问题。因此,全连接神经网络对于计算机视觉问题的实际应用价值较小。为了理解原因,我们来分析一下我们假设中的 CIFAR-10 网络的第一个隐藏层,该层有 1,000 个单元。图像的输入大小是
32 * 32 * 3 = 3,072。因此,第一个隐藏层总共有 2,072 * 1,000 = 2,072,000 个权重。这可不是一个小数字!不仅如此,这么大的网络容易过拟合,而且在内存上也效率低下。
更为重要的是,每个输入单元(或像素)都与隐藏层中的每个单元相连。正因如此,网络无法利用像素的空间接近性,因为它无法知道哪些像素是彼此接近的。相比之下,卷积神经网络(CNNs)具有一些特性,能够有效地解决这些问题:
-
它们仅连接与图像相邻像素对应的单元。通过这种方式,这些单元被“迫使”只从空间上接近的其他单元那里获取输入。这样也减少了权重的数量,因为并非所有单元都是互相连接的。
-
卷积神经网络(CNNs)使用参数共享。换句话说,层中的所有单元共享有限数量的权重。这进一步减少了权重的数量,并有助于防止过拟合。虽然这可能听起来有些混乱,但在下一节中会变得更加清晰。
注意
在本章中,我们将在计算机视觉的背景下讨论 CNN,因为计算机视觉是其最常见的应用。然而,CNN 也成功应用于语音识别和自然语言处理(NLP)等领域。我们在此描述的许多解释同样适用于这些领域——即,无论应用领域如何,CNN 的原理都是相同的。
为了理解 CNN,我们首先讨论它们的基本构建块。一旦完成这部分,我们将展示如何将它们组装成一个完整的神经网络。接着,我们将展示该网络足够好,能够以高精度分类 CIFAR-10。最后,我们将讨论高级 CNN 模型,这些模型可以应用于实际的计算机视觉任务。
卷积层
卷积层是卷积神经网络(CNN)最重要的组成部分。它由一组滤波器(也称为内核或特征检测器)组成,每个滤波器都应用于输入数据的所有区域。滤波器由一组可学习的权重定义。
为了给这个简洁的定义增加一些意义,我们将从以下图开始:
图 4.1 – 卷积操作开始
上图展示了 CNN 的二维输入层。为了简化说明,我们假设这是输入层,但它也可以是网络的任何一层。我们还假设输入是一个灰度图像,每个输入单元代表一个像素的颜色强度。这个图像由一个二维张量表示。
我们将通过在图像的左上角应用一个 3×3 权重滤波器(同样是一个二维张量)来开始卷积操作。每个输入单元与滤波器的一个权重相关联。因为有九个输入单元,所以权重有九个,但一般来说,大小是任意的(例如 2×2、4×4、5×5,等等)。卷积操作被定义为以下加权和:
这里,row 和 col 表示输入层的位置,在此处应用滤波器(row=1 和 col=1 在前述图中); 和
是滤波器大小(3×3)的高度和宽度; i 和 j 是每个滤波器权重的索引,
; b 是偏置权重。参与输入的单元组,
,参与输入的单元组称为感受野。
我们可以看到,在卷积层中,单元的激活值与我们在第二章中定义的单元激活值的计算方式相同——即输入的加权和。但在这里,单元的输入仅来自其周围有限数量的输入单元(感受野)。这与全连接(FC)层不同,在全连接层中,输入来自所有输入单元。这个区别很重要,因为滤波器的目的是突出输入中的某个特定特征,比如图像中的边缘或线条。在神经网络的上下文中,滤波器的输出代表下一层单元的激活值。如果该特征在此空间位置存在,单元将处于激活状态。在层次结构的数据中,如图像,邻近像素会形成有意义的形状和物体,如边缘或线条。然而,图像一端的像素与另一端的像素不太可能存在关系。因此,使用全连接层将所有输入像素与每个输出单元连接,就像让网络在大海捞针。它无法知道某个输入像素是否与输出单元相关(是否位于周围区域),还是与图像另一端的像素无关。因此,卷积层有限的感受野更适合突出输入数据中的有意义特征。
我们已经计算了一个单元的激活值,但其他单元呢?很简单!对于每个新单元,我们会将滤波器滑动到输入图像上,并计算其输出(加权和),每次使用一组新的输入单元。下图展示了如何计算接下来两个位置的激活值(右移一个像素):
图 4.2 – 卷积操作的前三个步骤
所谓“滑动”,是指滤波器的权重在整个图像上保持不变。实际上,我们会使用相同的九个滤波器权重和一个偏置权重来计算所有输出单元的激活值,每次使用不同的输入单元集。我们称之为参数共享,并且这样做有两个原因:
-
通过减少权重的数量,我们减少了内存占用并防止了过拟合。
-
滤波器突出了图像中的特定视觉特征。我们可以假设该特征是有用的,无论它在图像中的位置如何。由于我们在整个图像中应用相同的滤波器,因此卷积具有平移不变性;也就是说,它可以检测到相同的特征,无论该特征在图像中的位置如何。然而,卷积既不是旋转不变的(如果特征被旋转,它不一定能检测到该特征),也不是尺度不变的(它不能保证在不同的尺度下检测到相同的特征)。
为了计算所有输出激活值,我们将重复滑动过程,直到覆盖整个输入。空间排列的输入和输出单元被称为深度切片(特征图或通道),意味着不仅仅有一个切片。切片和图像一样,是由张量表示的。切片张量可以作为网络中其他层的输入。最后,就像常规层一样,我们可以在每个单元后使用激活函数,如修正线性单元(ReLU)。
注意
有趣的是,每个输入单元都是多个输出单元的输入。例如,当我们滑动滤波器时,上图中的绿色单元将作为九个输出单元的输入。
我们可以用一个简单的例子来说明迄今为止所学的内容。以下图示说明了如何对单个 3×3 切片应用 2×2 滤波器进行 2D 卷积:
图 4.3 – 使用 2×2 滤波器对单个 3×3 切片进行 2D 卷积,以获得 2×2 输出切片
这个例子还向我们展示了输入和输出特征图的尺寸是不同的。假设我们有一个大小为(width_i, height_i)
的输入层和一个尺寸为(filter_w, filter_h)
的滤波器。应用卷积后,输出层的尺寸为width_o = width_i - filter_w + 1
和height_o = height_i - filter_h + 1
。
在这个例子中,我们有width_o = height_o = 3 – 2 + 1 =
2
。
在接下来的部分,我们将通过一个简单的编码示例来说明卷积操作。
卷积操作的代码示例
我们现在已经描述了卷积层是如何工作的,但通过一个可视化的例子,我们会更好地理解。让我们通过对图像应用几个滤波器来实现卷积操作。为了清晰起见,我们将手动实现滤波器在图像上的滑动,且不使用任何深度学习库。我们只会包括相关部分,而不是完整程序,但你可以在本书的 GitHub 仓库中找到完整示例。让我们开始:
-
导入
numpy
:import numpy as np
-
定义
conv
函数,它对图像应用卷积。conv
接受两个参数,都是二维 numpy 数组:image
表示灰度图像本身的像素强度,im_filter
表示硬编码的滤波器:-
首先,我们将计算输出图像的大小,它取决于输入
image
和im_filter
的大小。我们将利用它来实例化输出图像im_c
。 -
然后,我们将对
image
的所有像素进行迭代,在每个位置应用im_filter
。此操作需要四个嵌套循环:前两个循环处理image
的维度,后两个循环用于迭代二维滤波器。 -
我们将检查是否有任何值超出[0, 255]的区间,并在必要时进行修正。
如下例所示:
def conv(image, im_filter): # input dimensions height = image.shape[0] width = image.shape[1] # output image with reduced dimensions im_c = np.zeros((height - len(im_filter) + 1, width - len(im_filter) + 1)) # iterate over all rows and columns for row in range(len(im_c)): for col in range(len(im_c[0])): # apply the filter for i in range(len(im_filter)): for j in range(len(im_filter[0])): im_c[row, col] += image[row + i, / col + j] * im_filter[i][j] # fix out-of-bounds values im_c[im_c > 255] = 255 im_c[im_c < 0] = 0 return im_c
-
-
在图像上应用不同的滤波器。为了更好地说明我们的观点,我们将使用一个 10×10 的模糊滤波器,以及 Sobel 边缘检测器,如下例所示(
image_grayscale
是一个二维的numpy
数组,表示灰度图像的像素强度):# blur filter blur = np.full([10, 10], 1\. / 100) conv(image_grayscale, blur) # sobel filters sobel_x = [[-1, -2, -1], [0, 0, 0], [1, 2, 1]] conv(image_grayscale, sobel_x) sobel_y = [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]] conv(image_grayscale, sobel_y)
完整程序将产生以下输出:
图 4.4 – 第一张图是灰度输入图像。第二张图是 10×10 模糊滤波器的结果。第三和第四张图使用了检测器和垂直 Sobel 边缘检测器
在这个例子中,我们使用了带有硬编码权重的滤波器来可视化卷积操作是如何在神经网络中工作的。实际上,滤波器的权重将在网络训练过程中设置。我们只需要定义网络架构,比如卷积层的数量、输出体积的深度以及滤波器的大小。网络将在训练过程中自动确定每个滤波器突出显示的特征。
注意
正如我们在这个例子中看到的,我们需要实现四个嵌套循环来实现卷积。然而,通过一些巧妙的转换,卷积操作可以通过矩阵乘法实现。这样,它可以充分利用 GPU 并行计算。
在接下来的几节中,我们将讨论卷积层的一些细节。
跨通道和深度卷积
到目前为止,我们已经描述了一个一对一的切片关系,其中我们在单个输入切片上应用单个滤波器,产生单个输出切片。但这种安排有以下局限性:
-
单个输入切片适用于灰度图像,但对于具有多个通道的彩色图像或任何其他多维输入则不起作用
-
单个滤波器可以检测切片中的单个特征,但我们希望检测多个不同的特征
如何解决这些局限性呢?很简单:
- 对于输入,我们将图像分割成颜色通道。对于 RGB 图像来说,这将是三个通道。我们可以将每个颜色通道看作一个深度切片,其中的值是给定颜色(R、G 或 B)的像素强度,如下例所示:
图 4.5 – 一个深度为 3 的输入切片示例
输入切片的组合被称为输入体积,深度为 3。RGB 图像由三层 2D 切片(每个颜色通道一个)组成的 3D 张量表示。
- CNN 卷积可以拥有多个滤波器,突出显示不同的特征,从而产生多个输出特征图(每个滤波器一个),这些特征图被合并成一个输出体积。
假设我们有输入(大写的C)和
输出切片。根据输入和输出切片的关系,我们可以得到跨通道卷积和深度卷积,如下图所示:
图 4.6 – 跨通道卷积(左);深度卷积(右)
让我们讨论它们的性质:
- 跨通道卷积:一个输出切片接收所有输入切片的输入 (
关系)。有了多个输出切片,关系变为
。换句话说,每个输入切片都为每个输出切片的输出做出贡献。每一对输入/输出切片使用一个独立的过滤器切片,这个过滤器切片对该对切片是独有的。我们用
(小写 c)表示输入切片的索引;用
表示输出切片的索引;用
和
表示过滤器的维度。然后,单个输出切片中一个输出单元的跨通道二维卷积定义为以下加权和:
请注意,我们有一个独特的偏置, 每个输出切片都有一个。
我们还可以通过以下公式计算交叉通道 2D 卷积中的权重总数 W:
这里,+1 表示每个滤波器的偏置权重。假设我们有三个输入切片,并且想要应用四个 5×5 的滤波器。如果这样做,卷积滤波器将有总共 (3 * 5 * 5 + 1) * 4 = 304 个权重,四个输出切片(深度为 4 的输出体积),每个切片有一个偏置。每个输出切片的滤波器将有三个 5×5 的滤波器块,分别对应三个输入切片,并且有一个偏置,总共有 3 * 5 * 5 + 1 = 76 个权重。
- 深度卷积:一个输出切片仅接收来自单个输入切片的信息。这是对前述情况的某种反转。在最简单的形式下,我们对单个输入切片应用滤波器,生成一个输出切片。在这种情况下,输入和输出的体积具有相同的深度——即 C。我们还可以指定一个通道倍增器(一个整数 M),即对单个输出切片应用 M 个滤波器,生成每个输入切片的 M 个输出切片。在这种情况下,输出切片的总数为
。深度卷积 2D 被定义为以下加权和:
我们可以通过以下公式计算二维深度卷积中的权重W:
这里,+M 代表每个输出片的偏置。
接下来,我们将讨论卷积操作的一些更多属性。
卷积层中的步幅和填充
到目前为止,我们假设滤波器每次滑动一个像素,但并非总是如此。我们可以让滤波器滑动多个位置。卷积层的这个参数叫做步幅。通常,步幅在输入的所有维度上是相同的。在下图中,我们可以看到一个步幅 = 2的卷积层(也叫做步幅卷积):
图 4.7 – 步幅 = 2 时,滤波器每次移动两个像素
更大步幅的主要效果是增加输出单元的感受野,代价是输出切片的大小本身会变小。为了理解这一点,我们回顾一下在上一节中,我们介绍了一个输出大小的简单公式,其中包括输入和卷积核的大小。现在,我们将它扩展到包括步幅:width_o = (width_i - filter_w) / stride_w + 1
和 height_o = 1 + (height_i - filter_h) / stride_h
。例如,一个由 28×28 的输入图像生成的方形切片,与一个 3×3 的滤波器进行卷积,且步幅 = 1,输出大小为 1 + 28 - 3 = 26。 但是如果步幅 = 2,则为 1 + (28 - 3) / 2 = 13。因此,如果我们使用步幅 = 2,输出切片的大小大约是输入的四分之一。换句话说,一个输出单元将“覆盖”一个面积,比输入单元大四倍。接下来层的单元将逐渐捕捉来自输入图像更大区域的输入信息。这一点非常重要,因为它将允许它们检测输入图像中更大、更复杂的特征。
到目前为止,我们讨论的卷积操作产生的输出小于输入(即使步幅 = 1)。但是,在实践中,通常希望控制输出的大小。我们可以通过填充输入切片的边缘,用零的行和列进行填充,来解决这个问题。在以下图示中,我们可以看到一个填充大小为 1 且步幅 = 1的卷积层:
图 4.8 – 填充大小为 1 的卷积层
白色单元表示填充。输入和输出切片的维度相同(深色单元)。新填充的零将在卷积操作中与切片一起参与,但它们不会影响结果。原因是,即使填充区域与权重连接到下一层,我们始终会将这些权重乘以填充值,而填充值为 0。同时,滑动滤波器经过填充的输入切片时,会产生与未填充输入相同维度的输出切片。
现在我们已经了解了步幅和填充的概念,我们可以引入输出切片大小的完整公式:
height_o = 1 + (height_i + 2*padding_h – filter_h) / stride
width_o = 1 + (width_i + 2*padding_w – filter_w) / stride
我们现在已经对卷积有了基本的了解,可以继续学习卷积神经网络(CNN)的下一个基本组成部分——池化层。一旦我们了解了池化层的原理,我们将介绍第一个完整的 CNN,并通过实现一个简单的任务来巩固我们的知识。接着,我们将聚焦于更高级的 CNN 话题。
池化层
在上一节中,我们解释了如何通过使用步长 > 1来增大单元的感受野。但我们也可以借助池化层来实现这一点。池化层将输入切片分割成一个网格,每个网格单元代表多个单元的感受野(就像卷积层一样)。然后,在网格的每个单元上应用池化操作。池化层不会改变卷积深度,因为池化操作是独立地在每个切片上进行的。池化层由两个参数定义:步长和感受野大小,就像卷积层一样(池化层通常不使用填充)。
在本节中,我们将讨论三种类型的池化层——最大池化、平均池化和全局平均池化(GAP)。这三种池化类型在下图中有所展示:
图 4.9 – 最大池化、平均池化和全局平均池化
最大池化是最常见的池化方法。最大池化操作会选择每个局部感受野(网格单元)中激活值最高的单元,并仅将该值传递给下一层。在上面的图示(左图)中,我们可以看到一个 2×2 感受野和步长 = 2的最大池化示例。该操作丢弃了输入单元的 3/4。池化层没有权重。在最大池化的反向传播过程中,梯度只会传递给前向传播时激活值最高的单元。其他单元的梯度为零。
平均池化是另一种池化方式,其中每个感受野的输出是该区域内所有激活值的均值。在上面的图示(中图)中,我们可以看到一个 2×2 感受野和步长 = 2的平均池化示例。
GAP 与平均池化相似,但一个池化区域覆盖整个输入切片。我们可以将 GAP 视为一种极端的降维方法,因为它输出一个代表整个切片均值的单一值。这种池化方式通常应用于 CNN 的卷积部分结束时。在上面的图示(右图)中,我们可以看到 GAP 操作的示例。步长和感受野大小不适用于 GAP 操作。
在实际应用中,通常只有两种步长和感受野大小的组合被使用。第一种是 2×2 的感受野,步长 = 2,第二种是 3×3 的感受野,步长 = 2(重叠)。如果我们为任一参数使用较大的值,网络将丧失过多的信息。或者,如果步长为 1,层的大小不会变小,感受野也不会增大。
基于这些参数,我们可以计算池化层的输出大小:
height_o = 1 + (height_i – filter_h) / stride
width_o = 1 + (width_i – filter_w) / stride
池化层仍然被广泛使用,但通常,我们可以通过简单地使用步长较大的卷积层来实现相似甚至更好的结果。(例如,参见 J. Springerberg, A. Dosovitskiy, T. Brox, 和 M. Riedmiller, Striving for Simplicity: The All Convolutional Net, (**2015), arxiv.org/abs/1412.6806
.)
我们现在有足够的知识来介绍我们的第一个完整的 CNN。
卷积网络的结构
下图展示了一个基本分类 CNN 的结构:
图 4.10 – 一个基本的卷积网络,包含卷积层、全连接层和池化层
大多数 CNN 共享一些基本特性。以下是其中的一些:
-
我们通常会将一个或多个卷积层与一个池化层(或步长卷积)交替使用。通过这种方式,卷积层可以在每个感受野的层级上检测特征。更深层的感受野的聚合大小大于网络开始时的感受野,这使得它们能够从更大的输入区域中捕捉到更复杂的特征。我们通过一个例子来说明这一点。假设网络使用 3×3 的卷积,步长 = 1,以及 2×2 的池化,步长 = 2:
-
第一卷积层的单元将接收来自图像的 3×3 像素输入。
-
第一层的 2×2 输出单元组的合并感受野大小为 4×4(由于步长的原因)。
-
在第一次池化操作后,这些组将合并成池化层的一个单元。
-
第二次卷积操作将从 3×3 的池化单元接收输入。因此,它将接收来自一个边长为 3×4 = 12(或总共 12×12 = 144)像素的方形区域的输入图像。
-
-
我们使用卷积层从输入中提取特征。最深层检测到的特征非常抽象,但它们也不适合人类阅读。为了解决这个问题,我们通常会在最后一个卷积/池化层后添加一个或多个全连接层。在这个例子中,最后一个全连接层(输出层)将使用 softmax 来估算输入的类别概率。你可以把全连接层看作是网络语言(我们无法理解)和我们语言之间的翻译器。
-
与初始卷积层相比,较深的卷积层通常具有更多的滤波器(因此卷积深度更大)。网络开始时的特征检测器在较小的感受野上工作。它只能检测到有限数量的特征,例如在所有类别中共享的边缘或线条。另一方面,较深的层则能够检测到更复杂和更多的特征。例如,如果我们有多个类别,如汽车、树木或人物,每个类别都会有一组独特的特征,如轮胎、车门、树叶和面孔等。这就需要更多的特征检测器。
现在我们已经了解了 CNN 的结构,接下来让我们用 PyTorch 和 Keras 实现一个 CNN。
使用 PyTorch 和 Keras 分类图像
在本节中,我们将尝试用 PyTorch 和 Keras 分类 CIFAR-10 数据集的图像。它由 60,000 张 32x32 的 RGB 图像组成,分为 10 个类别的物体。为了理解这些示例,我们将首先关注到目前为止我们还没有覆盖的两个前提条件:图像在深度学习库中的表示方式和数据增强训练技术。
深度学习库中的卷积层
PyTorch、Keras 和 TensorFlow(TF)支持 1D、2D 和 3D 卷积。卷积操作的输入和输出是张量。一个具有多个输入/输出切片的 1D 卷积将有 3D 的输入和输出张量。它们的轴可以是 SCW 或 SWC 顺序,我们有如下定义:
-
S:小批量中的样本索引
-
C:深度切片在体积中的索引
-
W:切片的内容
同样,一个 2D 卷积将由 SCHW 或 SHWC 顺序的张量表示,其中 H 和 W 分别是切片的高度和宽度。一个 3D 卷积将有 SCDHW 或 SDHWC 顺序,其中 D 代表切片的深度。
数据增强
最有效的正则化技术之一就是数据增强。如果训练数据太小,网络可能会开始过拟合。数据增强通过人工增加训练集的大小,帮助防止这种情况。在 CIFAR-10 示例中,我们将训练一个 CNN,并进行多轮训练。网络每轮都会“看到”数据集中的每个样本。为了防止这种情况,我们可以在将图像输入到 CNN 训练之前,先对它们进行随机增强,标签保持不变。以下是一些最常用的图像增强方法:
-
旋转
-
水平和垂直翻转
-
放大/缩小
-
裁剪
-
偏斜
-
对比度和亮度调整
加粗的增强方法将在以下示例中展示:
图 4.11 – 不同图像增强的示例
有了这些,我们可以继续进行示例了。
使用 PyTorch 分类图像
我们先从 PyTorch 开始:
-
选择设备,最好是 GPU。这个神经网络比 MNIST 的网络要大,使用 CPU 训练会非常慢:
import torch from torchsummary import summary device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
-
加载训练数据集(然后是验证数据集):
import torchvision.transforms as transforms from torchvision import datasets from torch.utils.data import DataLoader # Training dataset train_transform = transforms.Compose([ transforms.RandomHorizontalFlip(), transforms.RandomVerticalFlip(), transforms.ToTensor(), transforms.Normalize( [0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) train_data = datasets.CIFAR10( root='data', train=True, download=True, transform=train_transform) batch_size = 50 train_loader = DataLoader( dataset=train_data, batch_size=batch_size, shuffle=True, num_workers=2)
train_transform
是特别重要的。它执行随机水平和垂直翻转,并使用transforms.Normalize
通过 z-score 标准化来规范化数据集。这些硬编码的数值表示 CIFAR-10 数据集手动计算的逐通道均值和std
值。train_loader
负责提供训练的小批量数据。 -
加载验证数据集。请注意,我们使用训练数据集的均值和
std
值对验证集进行标准化:validation_transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize( [0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) validation_data = datasets.CIFAR10( root='data', train=False, download=True, transform=validation_transform) validation_loader = DataLoader( dataset=validation_data, batch_size=100, shuffle=True)
-
使用
Sequential
类定义我们的 CNN。它具有以下特性:-
三个块,每个块由两个卷积层(3×3 滤波器)和一个最大池化层组成。
-
每个卷积层后进行批量归一化。
-
前两个块对卷积操作应用
padding=1
,因此不会减少特征图的尺寸。 -
Linear
(全连接)层有 10 个输出(每个类别一个)。最终激活函数是 softmax。
让我们看看定义:
from torch.nn import Sequential, Conv2d, BatchNorm2d, GELU, MaxPool2d, Dropout2d, Linear, Flatten model = Sequential( Conv2d(in_channels=3, out_channels=32, kernel_size=3, padding=1), BatchNorm2d(32), GELU(), Conv2d(in_channels=32, out_channels=32, kernel_size=3, padding=1), BatchNorm2d(32), GELU(), MaxPool2d(kernel_size=2, stride=2), Dropout2d(0.2), Conv2d(in_channels=32, out_channels=64, kernel_size=3, padding=1), BatchNorm2d(64), GELU(), Conv2d(in_channels=64, out_channels=64, kernel_size=3, padding=1), BatchNorm2d(64), GELU(), MaxPool2d(kernel_size=2, stride=2), Dropout2d(p=0.3), Conv2d(in_channels=64, out_channels=128, kernel_size=3), BatchNorm2d(128), GELU(), Conv2d(in_channels=128, out_channels=128, kernel_size=3), BatchNorm2d(128), GELU(), MaxPool2d(kernel_size=2, stride=2), Dropout2d(p=0.5), Flatten(), Linear(512, 10), )
-
-
运行训练和验证。我们将使用与 第三章 中的 MNIST PyTorch 示例中实现的相同的
train_model
和test_model
函数。因此,我们在此不会实现它们,但完整的源代码可以在本章的 GitHub 仓库中找到(包括 Jupyter Notebook)。我们可以期待以下结果:在 1 轮时准确率为 51%,在 5 轮时准确率为 70%,在 75 轮时准确率约为 82%。
这就是我们的 PyTorch 示例的结尾。
使用 Keras 进行图像分类
我们的第二个示例是相同的任务,但这次使用 Keras 实现:
-
首先下载数据集。我们还将把数字标签转换为独热编码的张量:
import tensorflow as tf (X_train, Y_train), (X_validation, Y_validation) = \ tf.keras.datasets.cifar10.load_data() Y_train = tf.keras.utils.to_categorical(Y_train, 10) Y_validation = \ tf.keras.utils.to_categorical(Y_validation, 10)
-
创建一个
ImageDataGenerator
实例,它对训练集图像的每个通道应用 z 标准化。在训练过程中,它还提供数据增强(随机水平和垂直翻转)。另外,请注意,我们将训练集的均值和标准差应用于测试集,以获得最佳性能:from tensorflow.keras.preprocessing.image import ImageDataGenerator data_generator = ImageDataGenerator( featurewise_center=True, featurewise_std_normalization=True, horizontal_flip=True, vertical_flip=True) # Apply z-normalization on the training set data_generator.fit(X_train) # Standardize the validation set X_validation = \ data_generator.standardize( \ X_validation.astype('float32'))
-
然后,我们可以使用
Sequential
类定义我们的 CNN。我们将使用在 使用 PyTorch 进行图像分类 部分中定义的相同架构。以下是该模型的 Keras 定义:from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Conv2D, Dense, MaxPooling2D, Dropout, BatchNormalization, Activation, Flatten model = Sequential(layers=[ Conv2D(32, (3, 3), padding='same', input_shape=X_train.shape[1:]), BatchNormalization(), Activation('gelu'), Conv2D(32, (3, 3), padding='same'), BatchNormalization(), Activation('gelu'), MaxPooling2D(pool_size=(2, 2)), Dropout(0.2), Conv2D(64, (3, 3), padding='same'), BatchNormalization(), Activation('gelu'), Conv2D(64, (3, 3), padding='same'), BatchNormalization(), Activation('gelu'), MaxPooling2D(pool_size=(2, 2)), Dropout(0.3), Conv2D(128, (3, 3)), BatchNormalization(), Activation('gelu'), Conv2D(128, (3, 3)), BatchNormalization(), Activation('gelu'), MaxPooling2D(pool_size=(2, 2)), Dropout(0.5), Flatten(), Dense(10, activation='softmax') ])
-
定义训练参数(我们还将打印模型总结以便于理解):
model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['accuracy']) print(model.summary())
-
运行 50 轮的训练:
batch_size = 50 model.fit( x=data_generator.flow(x=X_train, y=Y_train, batch_size=batch_size), steps_per_epoch=len(X_train) // batch_size, epochs=100, verbose=1, validation_data=(X_validation, Y_validation), workers=4)
根据轮数的不同,这个模型将产生以下结果:在 1 轮时准确率为 50%,在 5 轮时准确率为 72%,在 45 轮时准确率约为 85%。我们的 Keras 示例与 PyTorch 示例相比,准确率略高,尽管它们应该是相同的。也许我们在某处有一个 bug。我们可能永远不会知道,但我们仍然能从中学到一课:机器学习模型并不容易调试,因为它们可能只是性能稍微下降,而不是完全报错。找到这种性能下降的确切原因可能很难。
既然我们已经实现了第一次完整的 CNN 两次,我们将重点讨论一些更高级的卷积类型。
高级卷积类型
到目前为止,我们讨论了“经典”卷积操作。在本节中,我们将介绍几种新的变体及其属性。
1D、2D 和 3D 卷积
在本章中,我们使用了2D 卷积,因为二维图像的计算机视觉是最常见的 CNN 应用。但我们也可以有 1D 和 3D 卷积,其中单位分别排列在一维或三维空间中。在所有情况下,滤波器的维度与输入的维度相同,并且权重在输入上是共享的。例如,我们会对时间序列数据使用 1D 卷积,因为这些值是沿单一时间轴排列的。在下图的左侧,我们可以看到一个 1D 卷积的示例:
图 4.12 – 1D 卷积(左);3D 卷积(右)
相同虚线的权重共享相同的值。1D 卷积的输出也是 1D。如果输入是 3D,例如 3D MRI,我们可以使用 3D 卷积,它也会产生 3D 输出。这样,我们可以保持输入数据的空间排列。在上面的图示中,我们可以看到右侧的 3D 卷积示例。输入的维度是 H/W/L,滤波器在所有维度上有一个单一的大小,F。输出也是 3D。
1×1 卷积
1×1(点卷积)卷积是卷积的一种特殊情况,其中卷积滤波器的每个维度大小为 1(在 2D 卷积中是 1×1,在 3D 卷积中是 1×1×1)。起初,这看起来没有意义——一个 1×1 的滤波器并不会增加输出单元的感受野大小。这样的卷积结果只是逐点缩放。但它在另一个方面可能会有用——我们可以用它们来改变输入和输出体积之间的深度。为了理解这一点,让我们回顾一下,通常我们有一个深度为 的输入体积和
的滤波器,生成
输出体积的切片。每个输出切片都是通过对所有输入切片应用一个独特的滤波器来生成的。如果我们使用 1×1 滤波器且
,我们会得到相同大小的输出切片,但深度不同。同时,输入和输出之间的感受野大小不会发生改变。最常见的用例是减少输出体积,或者
(降维),也被称为“瓶颈层”。
深度可分离卷积
在跨通道卷积中,一个输出切片从所有输入切片中接收输入,并使用一个滤波器。滤波器试图学习 3D 空间中的特征,其中两个维度是空间的(切片的高度和宽度),第三个维度是通道。因此,滤波器同时映射空间和跨通道的相关性。
深度可分离卷积(DSC,Xception: 深度学习与深度可分离卷积, arxiv.org/abs/1610.02357
)可以完全解耦
跨通道和空间相关性。深度可分离卷积(DSC)结合了两种操作:深度卷积和 1×1 卷积。在深度卷积中,单个输入切片生成单个输出切片,因此它只映射空间相关性(而非跨通道相关性)。而在 1×1 卷积中,则恰好相反。以下图示表示 DSC:
图 4.13 – 一种深度可分离卷积
深度可分离卷积(DSC)通常在第一次(深度方向)操作后不加非线性处理。
注意
让我们比较标准卷积和深度可分离卷积。假设我们有 32 个输入和输出通道,滤波器大小为 3×3。在标准卷积中,一个输出切片是将每个 32 个输入切片应用一个滤波器的结果,总共是 32 * 3 * 3 = 288
权重(不包括偏差)。在一个类似的深度方向卷积中,滤波器只有 3 * 3 = 9 个权重,而 1×1 卷积的滤波器则有 32 * 1 * 1 = 32 个权重。权重的总数为 32 + 9 = 41。因此,与标准卷积相比,深度可分离卷积速度更快,且内存效率更高。
膨胀卷积
常规卷积在 n×n 感受野上应用 n×n 滤波器。而膨胀卷积则稀疏地在大小为 (n * l - 1) × (n * l - 1) 的感受野上应用相同的滤波器,其中 l 是 膨胀因子。我们仍然将每个滤波器的权重与一个输入切片单元相乘,但这些单元之间的距离为 l。常规卷积是膨胀卷积的特例,l = 1。以下图示最好说明这一点:
图 4.14 – 一种膨胀卷积,膨胀因子为 l=2。这里展示了操作的前两步。底层是输入,顶层是输出。来源:github.com/vdumoulin/conv_arithmetic
膨胀卷积可以在不丧失分辨率或覆盖范围的情况下指数级地增大感受野的大小。我们也可以通过步幅卷积或池化来增大感受野,但会以分辨率和/或覆盖范围的损失为代价。为了理解这一点,我们假设有一个步幅大于 1 的步幅卷积s > 1。在这种情况下,输出切片的大小是输入的s倍(分辨率损失)。如果我们进一步增大s > F(F是池化或卷积核的大小),我们会失去覆盖范围,因为输入切片的某些区域根本不参与输出。此外,膨胀卷积不会增加计算和内存成本,因为滤波器使用的权重数量与常规卷积相同。
转置卷积
在我们到目前为止讨论的卷积操作中,输出维度要么与输入维度相同,要么小于输入维度。相比之下,转置卷积(最初由Matthew D. Zeiler、Dilip Krishnan、Graham W. Taylor 和 Rob Fergus 提出的 Deconvolutional Networks: www.matthewzeiler.com/mattzeiler/deconvolutionalnetworks.pdf
)允许我们对输入数据进行上采样(其输出大于输入)。这种操作也被称为反卷积、分数步幅卷积或子像素卷积。这些名字有时会导致混淆。为了解释清楚,请注意,转置卷积实际上是一个常规卷积,只是输入切片或卷积滤波器略有修改。
对于更长的解释,我们将从一个 1D 常规卷积开始,处理单个输入和输出切片:
图 4.15 – 1D 常规卷积
它使用一个大小为size = 4,步幅为stride = 2,填充为padding = 2的滤波器(在前面的图示中用灰色表示)。输入是大小为 6 的向量,输出是大小为 4 的向量。滤波器是一个向量,始终是相同的,但每次应用时会用不同的颜色表示它的位置。对应的输出单元格也用相同的颜色表示。箭头指示哪些输入单元格贡献了一个输出单元格。
注意
本节讨论的示例灵感来源于论文 Is the deconvolution layer the same as a convolutional layer? (arxiv.org/abs/1609.07009
)。
接下来,我们将讨论相同的例子(1D,单输入输出切片,以及一个大小为size = 4,padding = 2,stride = 2的滤波器),但是是转置卷积。下图展示了我们可以实现它的两种方式:
图 4.16 – 使用步幅 = 2 的卷积,通过转置滤波器 f 应用。输出开始和结束时的两个像素被裁剪(左);步幅为 0.5 的卷积,应用于输入数据,且用子像素进行填充。输入填充了值为 0 的像素(灰色)(右)
让我们详细讨论一下:
-
在第一种情况下,我们有一个常规的卷积,步幅为stride = 2,滤波器表示为转置的行矩阵(相当于列矩阵),大小为 4:
(如前图所示,左边)。请注意,步幅应用于输出层,而不是常规卷积中的输入层。在设置步幅大于 1 时,相比于输入,我们可以增加输出的大小。在这里,输入切片的大小是I,滤波器的大小是F,步幅是S,输入填充是P。因此,转置卷积的输出切片大小O由以下公式给出:
。在这种情况下,大小为 4 的输入会产生大小为 2 * (4 - 1) + 4 - 2 * 2 = 6 的输出。我们还会裁剪输出向量开始和结束时的两个单元格,因为它们只收集来自单个输入单元格的输入。
-
在第二种情况下,输入填充了存在像素之间的虚拟 0 值子像素(如前面的图所示,右侧)。这就是子像素卷积名称的由来。可以将其视为在图像内部进行的填充,而不仅仅是在边界上进行填充。一旦输入以这种方式变换,就会应用常规卷积。
让我们比较两个输出单元, 和
,在这两种情况下,如前面的图所示,在任何情况下,
都接收来自第一个和第二个输入单元的输入,而
接收来自第二个和第三个单元的输入。这两种情况的唯一区别是参与计算的权重索引。然而,权重在训练过程中会被学习到,因此,索引并不重要。因此,这两个操作是等价的。
接下来,让我们从子像素的角度来看 2D 转置卷积。与 1D 情况类似,我们在输入切片中插入值为 0 的像素和填充,以实现上采样(输入位于底部):
图 4.17 – 带填充 = 1 和步幅 = 2 的 2D 转置卷积的前三个步骤。来源: github.com/vdumoulin/conv_arithmetic
, https://arxiv.org/abs/1603.07285
常规卷积的反向传播操作是转置卷积。
这就结束了我们对各种类型卷积的扩展介绍。在下一部分,我们将学习如何利用迄今为止学到的高级卷积构建一些高级 CNN 架构。
高级 CNN 模型
在这一节中,我们将讨论一些复杂的 CNN 模型。它们在 PyTorch 和 Keras 中都有提供,并且在 ImageNet 数据集上进行了预训练。你可以直接导入并使用它们,而不是从头开始构建。不过,作为替代方法,讨论它们的核心思想也是值得的,而不是把它们当作黑盒使用。
这些模型大多共享一些架构原理:
-
它们从“入口”阶段开始,利用步幅卷积和/或池化的组合将输入图像的大小减少至少两到八倍,然后将其传播到网络的其余部分。这使得 CNN 在计算和内存上更加高效,因为更深层的网络处理的是较小的切片。
-
主网络体位于入口阶段之后。它由多个重复的复合模块组成。每个模块都利用填充卷积,使其输入和输出的切片大小相同。这使得可以根据需要堆叠任意数量的模块,以达到所需的深度。相比早期的模块,更深的模块在每次卷积中使用更多的过滤器(输出切片)。
-
主体中的下采样由具有步幅卷积和/或池化操作的特殊模块处理。
-
卷积阶段通常以对所有切片进行 GAP 操作结束。
-
GAP 操作的输出可以作为各种任务的输入。例如,我们可以添加一个全连接(FC)层进行分类。
我们可以在下图中看到一个基于这些原理构建的典型 CNN:
图 4.18 – 一个典型的 CNN
有了这个,让我们更深入地了解深度 CNN(懂了吗?)。
引入残差网络
残差网络(ResNets,深度残差学习用于图像识别,arxiv.org/abs/1512.03385
)于 2015 年发布,当年他们在 ImageNet 挑战赛中赢得了所有五个类别的冠军。在第二章中,我们讨论了神经网络的层不是按顺序排列的,而是形成一个有向图。这是我们将要学习的第一种利用这种灵活性的架构。这也是第一种成功训练超过 100 层深度网络的架构。
由于更好的权重初始化、新的激活函数以及归一化层,现在可以训练深度网络。然而,论文的作者进行了实验,观察到一个 56 层的网络在训练和测试中出现的错误比一个 20 层的网络要高。他们认为这种情况不应发生。从理论上讲,我们可以采用一个浅层网络,并在其上堆叠恒等层(这些层的输出只是重复输入)来生成一个更深的网络,使其行为与浅层网络相同。然而,他们的实验未能匹配浅层网络的表现。
为了解决这个问题,他们提出了由残差模块构成的网络。残差模块由两到三个连续的卷积层和一个单独的并行恒等(重复器)快捷连接组成,该连接将第一个层的输入与最后一层的输出相连。我们可以在下面的图中看到三种类型的残差模块:
图 4.19 – 从左到右 – 原始残差模块;原始瓶颈残差模块;预激活残差模块;预激活瓶颈残差模块
每个模块都有两条并行路径。左侧路径与我们见过的其他网络类似,由连续的卷积层和批量归一化组成。右侧路径包含恒等快捷连接(也称为跳跃连接)。这两条路径通过元素级求和进行合并——也就是说,左右两边的张量形状相同,第一个张量的一个元素加到第二个张量中相同位置的元素上。输出是一个与输入形状相同的单一张量。实际上,我们将模块学到的特征向前传播,同时保留了原始的未修改信号。这样,我们就能更接近原始场景,正如作者所描述的那样。由于跳跃连接,网络可以选择跳过一些卷积层,实际上减少了其深度。残差模块使用填充,使得输入和输出的形状相同。得益于此,我们可以堆叠任意数量的模块,构建具有任意深度的网络。
现在,让我们看看图中的模块有何不同:
-
第一个模块包含两个 3×3 卷积层。这是原始的残差模块,但如果层数过宽,堆叠多个模块会变得计算开销很大。
-
第二个模块与第一个模块相同,但它使用了瓶颈层。首先,我们使用 1×1 卷积来下采样输入的深度(我们在1×1 卷积章节中讨论过这个)。然后,我们对减少后的输入应用一个 3×3 的(瓶颈)卷积。最后,我们通过另一个 1×1 上采样卷积将输出扩展回所需的深度。这个层的计算开销比第一个要小。
-
第三个块是这一概念的最新修订版,由同一作者于 2016 年发布(Identity Mappings in Deep Residual Networks,
arxiv.org/abs/1603.05027
)。它使用了预激活,并且批归一化和激活函数位于卷积层之前。这一设计可能看起来有些奇怪,但得益于这一设计,跳跃连接路径可以在整个网络中不间断地运行。这与其他残差块不同,在那些块中,至少有一个激活函数位于跳跃连接的路径上。堆叠的残差块组合依然保持了正确的层次顺序。 -
第四个块是第三层的瓶颈版本。它遵循与瓶颈残差层 v1 相同的原理。
在下表中,我们可以看到论文作者提出的网络家族:
图 4.20 – 最流行的残差网络家族。残差块以圆角矩形表示。灵感来源于 arxiv.org/abs/1512.03385
它们的一些属性如下:
-
它们以一个 7×7 卷积层开始,stride = 2,接着是 3×3 最大池化。这个阶段作为一个下采样步骤,使得网络的其余部分可以在一个更小的 56×56 的切片上工作,相比于输入的 224×224。
-
网络其余部分的下采样是通过一个修改过的残差块实现的,stride = 2。
-
GAP 在所有残差块之后、1,000 单元全连接 softmax 层之前进行下采样。
-
各种 ResNet 的参数数量从 2560 万到 6040 万不等,网络的深度从 18 层到 152 层不等。
ResNet 网络家族之所以流行,不仅因为它们的准确性,还因为它们相对简洁且残差块具有很大的通用性。正如我们之前提到的,残差块的输入和输出形状由于填充可以相同。我们可以以不同的配置堆叠残差块,以解决具有广泛训练集规模和输入维度的各种问题。
Inception 网络
Inception 网络 (Going Deeper with Convolutions, arxiv.org/abs/1409.4842
) 于 2014 年首次提出,并赢得了当年的 ImageNet 挑战(似乎有一个规律)。此后,作者们发布了该架构的多个改进版本。
有趣的事实
Inception 这一名称部分来源于 We need to go deeper 这一互联网迷因,后者与电影《盗梦空间》相关。
inception 网络的思想源于这样一个基本前提:图像中的物体具有不同的尺度。一个远距离的物体可能只占据图像中的一小部分,但同一个物体靠近时,可能会占据图像的大部分。这对于标准的 CNN 来说是一个难题,因为不同层中的单元具有固定的感受野大小,这一大小被强加到输入图像上。一个常规的网络可能在某一尺度下能很好地检测物体,但在其他尺度下可能会错过物体。为了解决这个问题,论文的作者提出了一种新颖的架构:由 inception 块组成。inception 块以一个共同的输入开始,然后将其分割成不同的并行路径(或塔)。每条路径包含具有不同尺寸滤波器的卷积层或池化层。通过这种方式,我们可以对相同的输入数据应用不同的感受野。在 Inception 块的末端,来自不同路径的输出会被连接起来。
在接下来的几节中,我们将讨论 Inception 网络的不同变体。
Inception v1
以下图示展示了GoogLeNet网络架构中 inception 块的第一个版本(arxiv.org/abs/1409.4842
):
图 4.21 – Inception v1 块;灵感来源于 https://arxiv.org/abs/1409.4842
v1 块有四条路径:
-
1×1 卷积,它作为输入的某种重复器
-
1×1 卷积,后接 3×3 卷积
-
1×1 卷积,后接 5×5 卷积
-
3×3 最大池化,步幅 = 1
块中的层使用填充,使得输入和输出具有相同的形状(但深度不同)。填充也是必要的,因为每个路径的输出形状会根据滤波器大小不同而有所不同。这适用于所有版本的 inception 块。
该 inception 块的另一个主要创新是使用降采样 1×1 卷积。它们是必需的,因为所有路径的输出将被连接以产生该块的最终输出。连接的结果是输出深度被四倍增大。如果下一个块继续跟随当前块,它的输出深度将再次四倍增长。为了避免这种指数增长,块使用 1×1 卷积来减少每条路径的深度,从而降低块的输出深度。这使得我们可以创建更深的网络,而不至于耗尽资源。
完整的 GoogLeNet 具有以下特性:
-
与 ResNets 类似,它首先进行降采样阶段,利用两个卷积层和两个最大池化层将输入尺寸从 224×224 降至 56×56,然后才开始使用 inception 块。
-
该网络有九个 inception v1 块。
-
卷积阶段以全局平均池化结束。
-
网络利用了辅助分类器——即在不同的中间层有两个额外的分类输出(具有相同的真实标签)。在训练过程中,损失的总值是辅助损失和真实损失的加权和。
-
该模型共有 690 万个参数,深度为 22 层。
Inception v2 和 v3
Inception v2 和 v3 是一起发布的,并提出了比原始 v1(Rethinking the Inception Architecture for Computer Vision, arxiv.org/abs/1512.00567
)更改进的多个 Inception 块。我们可以在以下图示中看到第一个新 Inception 块 A:
图 4.22 – Inception 块 A,灵感来自于 https://arxiv.org/abs/1512.00567
块 A 的第一个新特性是将 5×5 卷积因式分解为两个堆叠的 3×3 卷积。这种结构有几个优点。
最后一层堆叠单元的感受野等同于一个大卷积滤波器的单层感受野。与使用单层大滤波器相比,堆叠的层能够以更少的参数实现相同的感受野大小。例如,我们可以将一个单独的 5×5 层替换为两个堆叠的 3×3 层。为了简便起见,我们假设有单一的输入和输出切片。5×5 层的总权重(不包括偏差)是 5 * 5 = 25。
另一方面,单个 3×3 层的总权重是 3 * 3 = 9,两个层的权重则是 2 * (3 * 3) = 18,这使得这种安排比单层的 5×5 层更高效 28%(18/25 = 0.72)。即便在输入和输出切片为多个的情况下,这种效率提升也得以保持。接下来的改进是将一个 n×n 卷积因式分解为两个堆叠的不对称 1×n 和 n×1 卷积。例如,我们可以将一个单一的 3×3 卷积分解为两个 1×3 和 3×1 卷积,其中 3×1 卷积应用于 1×3 卷积的输出。在第一个情况下,滤波器大小是 3 * 3 = 9,而在第二种情况下,组合后的大小是 (3 * 1) + (1 * 3) = 3 + 3 = 6,达到了 33% 的效率提升,如下图所示:
图 4.23 – 3×3 卷积的因式分解为 1×3 和 3×1 卷积;灵感来自于 https://arxiv.org/abs/1512.00567
作者引入了两个利用因式分解卷积的新块。其中第一个块(也是总的第二个块),Inception 块 B,等同于 Inception 块 A:
图 4.24 – Inception 块 B。当 n=3 时,它等同于块 A;灵感来源于 arxiv.org/abs/1512.00567
第二个(总共是第三个)Inception 块 C 类似,但非对称卷积是并行的,从而导致更高的输出深度(更多的连接路径)。这里的假设是,网络拥有更多特征(不同的滤波器)时,它学习得更快。另一方面,更宽的层会占用更多的内存和计算时间。作为折衷,这个块仅在网络的较深部分使用,在其他块之后:
图 4.25 – Inception 块 C;灵感来源于 arxiv.org/abs/1512.00567
该版本的另一个重大改进是使用批量归一化,这一技术由同一作者提出。
这些新模块创建了两个新的 Inception 网络:v2 和 v3。Inception v3 使用批量归一化,并且是这两者中更为流行的一个。它具有以下特点:
-
网络从下采样阶段开始,利用步幅卷积和最大池化将输入大小从 299×299 降低到 35×35,然后再进入 Inception 模块。
-
层被组织为三个 Inception 块 A,五个 Inception 块 B 和两个 Inception 块 C。
-
卷积阶段以全局平均池化结束。
-
它具有 2390 万个参数,深度为 48 层。
Inception v4 和 Inception-ResNet
最新的 Inception 网络修订版引入了三个新的简化 Inception 块(Inception-v4,Inception-v4, Inception-ResNet 和残差连接对学习的影响,arxiv.org/abs/1602.07261
)。更具体地说,新版本引入了 7×7 非对称因式分解卷积、平均池化代替最大池化,以及具有残差连接的新 Inception-ResNet 块。我们可以在以下图示中看到其中一个这样的块:
图 4.26 – 带有残差跳跃连接的 Inception 块(任何类型)
Inception-ResNet 系列模型具有以下特点:
-
网络从下采样阶段开始,利用步幅卷积和最大池化将输入大小从 299×299 降低到 35×35,然后再进入 Inception 模块。
-
模型的主体由三组四个残差 Inception-A 块、七个残差 Inception-B 块、三个残差 Inception-B 块,以及各组之间的特殊缩减模块组成。不同的模型使用这些块的略微不同变体。
-
卷积阶段以全局平均池化结束。
-
模型约有 5600 万个权重。
本节中,我们讨论了不同类型的 inception 网络以及各个 inception 块中使用的不同原理。接下来,我们将介绍一种较新的 CNN 架构,它将 inception 概念带到了一个新的深度(或者更准确地说,是宽度)。
引入 Xception
到目前为止,我们讨论的所有 inception 块都从将输入拆分成若干并行路径开始。每条路径接着执行一个降维的 1×1 跨通道卷积,然后是常规的跨通道卷积。一方面,1×1 连接映射跨通道相关性,但不映射空间相关性(因为 1×1 滤波器大小)。另一方面,后续的跨通道卷积映射两种类型的相关性。回想一下,在本章前面,我们介绍了 DSC,它结合了以下两种操作:
-
深度卷积:在深度卷积中,一个输入切片产生一个输出切片,因此它仅映射空间(而非跨通道)相关性。
-
1×1 跨通道卷积:使用 1×1 卷积时,正好相反——它们只映射跨通道相关性。
Xception 的作者(Xception: Deep Learning with Depthwise Separable Convolutions, arxiv.org/abs/1610.02357
)认为,我们可以将 DSC 视为 inception 块的极端(因此得名)版本,其中每一对深度卷积输入/输出切片代表一条并行路径。我们有多少条并行路径,就有多少个输入切片。下图展示了一个简化版的 inception 块及其转化为 Xception 块的过程:
图 4.27 – 一个简化的 inception 模块(左);一个 Xception 块(右);灵感来源于 https://arxiv.org/abs/1610.02357
Xception 块和 DSC 之间有两个区别:
-
在 Xception 中,1×1 卷积位于最前面,而在 DSC 中位于最后。然而,这些操作本应被堆叠,因此我们可以假设顺序并不重要。
-
Xception 块在每个卷积后使用 ReLU 激活,而 DSC 在跨通道卷积之后不使用非线性激活。根据作者的实验结果,缺少非线性激活的深度卷积网络收敛速度更快且更为准确。
完整的 Xception 网络具有以下特点:
-
它从卷积和池化操作的输入流开始,将输入大小从 299×299 缩小到 19×19。
-
它包含 14 个 Xception 模块,所有模块周围都有线性残差连接,除了第一个和最后一个模块。
-
所有卷积和 DSC 操作后都紧跟着批量归一化。所有 DSC 的深度乘数为 1(没有深度扩展)。
-
卷积阶段以全局平均池化结束。
-
总共 2300 万个参数,36 层卷积层的深度。
本节总结了基于 inception 的模型系列。在下一节中,我们将重点讨论一个新的神经网络架构元素。
压缩与激发网络
压缩与激发网络(SENet,压缩与激发网络,arxiv.org/abs/1709.01507
)引入了一个新的神经网络架构单元,作者称之为——你猜对了——压缩与激发(SE)块。我们回顾一下,卷积操作对输入通道应用多个滤波器以生成多个输出特征图(或通道)。SENet 的作者观察到,当这些通道作为输入传递给下一层时,它们具有“相等的权重”。然而,有些通道可能比其他通道更有信息。为了强调它们的重要性,作者提出了内容感知的 SE 块,它能够自适应地对每个通道进行加权。我们也可以将 SE 块看作是一种注意力机制。为了理解它是如何工作的,让我们从以下图像开始:
图 4.28 – 压缩与激发块
该块引入了一个与主神经网络数据流平行的路径。让我们来看一下它的步骤:
-
压缩阶段:在各个通道上执行 GAP 操作。GAP 的输出是每个通道的一个标量值。例如,如果输入是 RGB 图像,那么在 R、G 和 B 通道上执行的独特 GAP 操作将生成一个大小为 3 的一维张量。可以将这些标量值看作是通道的精炼状态。
-
全连接层 -> ReLU -> 全连接层 -> sigmoid
。它类似于自编码器,因为第一个隐藏层减少了输入张量的大小,第二个隐藏层将其扩展到原始大小(对于 RGB 输入来说是 3)。最终的 sigmoid 激活函数确保输出值位于 (0:1) 范围内。 -
缩放:兴奋神经网络的输出值作为原始输入张量通道的缩放系数。一个通道的所有值都由兴奋阶段生成的对应系数进行缩放(或激发)。通过这种方式,兴奋神经网络能够强调某个通道的重要性。
作者将 SE 块添加到不同的现有模型中,提升了它们的准确性。在下图中,我们可以看到如何将 SE 块添加到 inception 和残差模块中:
图 4.29 – 一个 SE-inception 模块(左)和一个 SE-ResNet 模块(右)
在下一节中,我们将看到 SE 块应用于一个优先考虑小尺寸和计算效率的模型。
介绍 MobileNet
本节将讨论一种轻量级卷积神经网络模型,名为MobileNet(MobileNetV3: Searching for MobileNetV3,arxiv.org/abs/1905.02244
)。我们将重点介绍这个想法的第三个版本(MobileNetV1 在MobileNets: Efficient Convolutional Neural Networks for Mobile Vision Applications中介绍,arxiv.org/abs/1704.04861
;MobileNetV2 在MobileNetV2: Inverted Residuals and Linear Bottlenecks中介绍,arxiv.org/abs/1801.04381
)。
MobileNet 的目标是面向内存和计算能力有限的设备,例如手机(名字本身就揭示了这一点)。该神经网络引入了一种新的反向残差块(或称MBConv),具有较小的占用空间。MBConv 使用了深度可分离卷积(DSC)、线性瓶颈和反向残差。V3 版本还引入了 SE 模块。为了理解这些内容,下面是 MBConv 模块的结构:
图 4.30 – MobileNetV3 构建块。只有当步幅 s=1 时,才存在快捷连接
让我们讨论一下它的属性:
-
线性瓶颈:我们假设输入是一个 RGB 图像。当图像通过神经网络(NN)传播时,每一层都会生成一个带有多个通道的激活张量。一直以来,人们认为这些张量中编码的信息可以在所谓的“感兴趣流形”中压缩,这个流形的张量比原始张量要小。强迫神经网络寻找这种流形的一种方法是使用 1×1 瓶颈卷积。然而,论文的作者认为,如果该卷积后接非线性激活函数,如 ReLU,这可能会导致由于 ReLU 消失问题而丧失流形信息。为了解决这个问题,MobileNet 使用没有非线性激活的 1×1 瓶颈卷积。
-
input -> 1×1 瓶颈卷积 -> 3×3 卷积 -> 1×1 反采样卷积
。换句话说,它遵循一个宽 -> 窄 -> 宽
的数据表示。另一方面,反向残差块遵循窄 -> 宽 -> 窄
的表示方式。在这里,瓶颈卷积通过扩展 因子 t 扩展其输入。作者认为瓶颈包含了所有必要的信息,而扩展层仅作为一个实现细节,伴随着张量的非线性变换。因此,他们提出在瓶颈连接之间使用快捷连接。
-
DSC:我们在本章早些时候已经介绍过这个操作。MobileNet V3 在 DSC 中引入了H-swish激活函数。H-swish 类似于我们在第二章中介绍的 swish 函数。V3 架构包括交替使用 ReLU 和 H-swish 激活函数。
-
SE 模块:我们已经熟悉这个模块。这里的区别是硬 Sigmoid激活函数,它近似 Sigmoid 函数,但在计算上更高效。该模块位于扩展深度卷积后,因此可以将注意力应用于最大的表示。SE 模块是 V3 的新增加项,在 V2 中并不存在。
-
步幅 s:该模块使用步幅卷积实现下采样。当s=1 时,才存在捷径连接。
MobileNetV3 引入了网络的大小变体,具有以下特点:
-
两个网络都以步幅卷积开始,将输入从 224×224 下采样到 112×112。
-
小型和大型变体分别有 11 个和 15 个 MBConv 模块。
-
卷积阶段结束时,两个网络都会进行全局平均池化。
-
小型和大型网络分别有 300 万和 500 万个参数。
在下一节中,我们将讨论 MBConv 模块的改进版。
EfficientNet
EfficientNet(EfficientNet: Rethinking Model Scaling for Convolutional Neural Networks,arxiv.org/abs/1905.11946
)和EfficientNetV2: Smaller Models and Faster Training,arxiv.org/abs/2104.00298
)引入了复合缩放的概念。它从一个小的基线模型开始,然后在三个方向上同时扩展:深度(更多层)、宽度(每层更多特征图)以及更高的输入分辨率。复合缩放会生成一系列新模型。EfficientNetV1 的基线模型使用了 MobileNetV2 的 MBConv 模块。EfficientNetV2 引入了新的融合 MBConv模块,它用单一的扩展 3×3 跨通道卷积替代了 MBConv 中的扩展 1×1 瓶颈卷积和 3×3 深度卷积:
图 4.31 – 融合 MBConv 模块
新的 3×3 卷积同时处理扩展(通过因子t)和步幅(1 或 2)。
EfficientNetV2 的作者观察到,使用融合 MBConv 和 MBConv 模块组合的 CNN,比仅使用 MBConv 模块的 CNN 训练速度更快。然而,
融合 MBConv 模块在计算上比普通 MBConv 模块更为昂贵。因此,EfficientNetV2 逐步替换这些模块,从早期阶段开始。这是有道理的,因为早期的卷积使用了更少的滤波器(因此切片较少),所以在这一阶段,内存和计算代价不那么显著。找到两种模块的正确组合并不简单,因此需要复合缩放。这一过程产生了多个具有以下特点的模型:
-
这两个网络都以步幅卷积开始,将输入下采样两次。
-
主体的早期阶段使用融合 MBConv 模块,后期阶段使用 MBConv 模块。
-
所有网络的卷积阶段最终都会通过全局平均池化结束
-
参数的数量范围从 530 万到 1.19 亿不等
这篇文章介绍了高级 CNN 模型的内容。我们没有讨论所有可用的模型,而是重点介绍了其中一些最受欢迎的模型。我希望你现在已经掌握了足够的知识,可以自行探索新的模型。在下一节中,我们将演示如何在 PyTorch 和 Keras 中使用这些高级模型。
使用预训练模型与 PyTorch 和 Keras
PyTorch 和 Keras 都有一套预训练的即用型模型。我们在高级网络模型部分讨论的所有模型都可以通过这种方式使用。这些模型通常是在分类 ImageNet 数据集上进行预训练的,可以作为各种计算机视觉任务的骨干网络,正如我们在 第五章 中将看到的那样。
我们可以使用以下代码在 PyTorch 中加载一个预训练的模型:
from torchvision.models import mobilenet_v3_large, MobileNet_V3_Large_Weights
# With pretrained weights:
model = mobilenet_v3_large(
weights=MobileNet_V3_Large_Weights.IMAGENET1K_V1)
model = mobilenet_v3_large(weights="IMAGENET1K_V1")
# Using no weights:
model = mobilenet_v3_large(weights=None)
权重将会自动下载。此外,我们可以使用以下代码列出所有可用的模型并加载任意模型:
from torchvision.models import list_models, get_model
# List available models
all_models = list_models()
model = get_model(all_models[0], weights="DEFAULT")
Keras 也支持类似的功能。我们可以使用以下代码加载一个预训练的模型:
from keras.applications.mobilenet_v3 import MobileNetV3Large
model = MobileNetV3Large(weights='imagenet')
这些简短但非常有用的代码示例总结了本章的内容。
总结
在本章中,我们介绍了 CNN。我们讨论了它们的主要组成部分——卷积层和池化层,并讨论了它们的架构和特征。我们特别关注了不同类型的卷积。我们还演示了如何使用 PyTorch 和 Keras 实现 CIFAR-10 分类 CNN。最后,我们讨论了目前一些最受欢迎的 CNN 模型。
在下一章中,我们将在新的计算机视觉知识基础上进行扩展,加入一些令人兴奋的内容。我们将讨论如何通过将知识从一个问题转移到另一个问题来加速网络训练。我们还将超越简单的分类,进行目标检测,或者如何找到图像中目标的位置。我们甚至会学习如何对图像的每个像素进行分割。
第五章:高级计算机视觉应用
在第四章中,我们介绍了用于计算机视觉的卷积网络(CNN)以及一些最受欢迎和表现最好的 CNN 模型。在本章中,我们将继续探讨类似的内容,但会深入到更高级的层次。到目前为止,我们的操作方式一直是提供简单的分类示例,以支持你对神经网络(NN)的理论知识。在计算机视觉任务的宇宙中,分类是相对直接的,因为它为图像分配一个单一的标签。这也使得手动创建大型标签化训练数据集成为可能。在本章中,我们将介绍迁移学习(TL),一种技术,它将使我们能够将预训练的神经网络的知识迁移到一个新的、无关的任务中。我们还将看到,迁移学习如何使得解决两个有趣的计算机视觉任务成为可能——目标检测和语义分割。我们可以说,这些任务相对于分类更为复杂,因为模型需要对图像有更全面的理解。它不仅要能够检测出不同的物体,还要知道它们在图像中的位置。同时,这些任务的复杂性也为更具创意的解决方案提供了空间。
最后,我们将介绍一种新型算法,称为生成模型,它将帮助我们生成新的图像。
本章将涵盖以下主题:
-
迁移 学习(TL)
-
目标检测
-
语义分割
-
使用扩散模型生成图像
技术要求
我们将在本章中使用 Python、PyTorch、Keras 和 Ultralytics YOLOv8(github.com/ultralytics/ultralytics
)实现示例。如果你没有配置好这些工具的环境,不用担心——示例可以在 Google Colab 上的 Jupyter notebook 中找到。你可以在本书的 GitHub 仓库中找到代码示例:github.com/PacktPublishing/Python-Deep-Learning-Third-Edition/tree/main/Chapter05
。
迁移学习(TL)
到目前为止,我们已经在玩具数据集上训练了小型模型,训练时间不超过一个小时。但如果我们想要处理大规模数据集,比如 ImageNet,我们将需要一个更大的网络,且训练时间会更长。更重要的是,大规模数据集并不总是能满足我们感兴趣任务的需求。请记住,除了获取图像之外,它们还需要被标注,而这可能是既昂贵又费时的。那么,当工程师想用有限资源解决实际的机器学习问题时,应该怎么办呢?这时,迁移学习(TL)就派上用场了。
迁移学习(TL)是将一个已经训练好的机器学习(ML)模型应用于一个新的但相关的问题的过程。例如,我们可以将一个在 ImageNet 上训练过的网络重新用于分类杂货店物品。或者,我们可以使用一个驾驶模拟游戏来训练神经网络(NN)驾驶一辆模拟汽车,然后用这个网络来驾驶真实的汽车(但请不要在家尝试!)。迁移学习是一个适用于所有机器学习算法的通用概念——我们将在第八章中也使用迁移学习。但在本章中,我们将讨论卷积神经网络(CNN)中的迁移学习。它是如何工作的,下面解释。
我们从一个现有的预训练网络开始。最常见的场景是使用一个在 ImageNet 上预训练的网络,但它也可以是任何数据集。PyTorch、TensorFlow(TF)和 Keras 都提供了流行的 ImageNet 预训练神经网络架构,我们可以使用。或者,我们也可以选择一个数据集来训练自己的网络。
在第四章中,我们提到过 CNN 最后的 全连接层(FC)如何作为网络语言(训练过程中学到的抽象特征表示)与我们的语言(每个样本的类别)之间的转换器。你可以将迁移学习看作是对另一种语言的翻译。我们从网络的特征开始,这些特征是最后一个卷积层或池化层的输出。然后,我们将它们翻译成新任务的不同类别。我们可以通过去除现有预训练网络的最后几层,并用一组新的层替换它们,这些新层代表了新问题的类别。以下是迁移学习场景的示意图:
图 5.1 – 一个迁移学习(TL)场景,其中我们替换了一个
预训练网络,并将其重新用于新的问题
然而,我们不能机械地进行这种操作并期望新网络能够正常工作,因为我们仍然需要用与新任务相关的数据来训练新层。我们有两种方式可以做到这一点:
-
使用网络的原始部分作为特征提取器,只训练新的层:首先,我们将新的数据批次输入网络,进行前向和反向传播,查看网络的输出和误差梯度。这部分的工作方式就像常规训练一样。但在权重更新阶段,我们会锁定原始网络的权重,只更新新层的权重。这是当我们对新问题的数据有限时推荐的做法。通过锁定大部分网络权重,我们可以防止在新数据上过拟合。
-
微调整个网络:我们训练整个网络,而不仅仅是最后添加的层。可以更新所有网络权重,但我们也可以锁定一些第一层的权重。这里的想法是,初始层用于检测一般特征——与特定任务无关——因此重复使用它们是合理的。另一方面,较深的层可能会检测任务特定的特征,因此更新它们会更好。当我们拥有更多训练数据并且不需要担心过拟合时,可以使用这种方法。
在继续之前,我们需要指出,迁移学习不仅限于分类到分类的问题。正如我们在本章后面看到的,我们可以使用预训练的卷积神经网络(CNN)作为目标检测和语义分割任务的主干神经网络。现在,让我们看看如何在实践中实现迁移学习。
使用 PyTorch 进行迁移学习
在本节中,我们将应用一个先进的 ImageNet 预训练网络到 CIFAR-10 图像上。我们将实现两种类型的迁移学习。最好在 GPU 上运行这个示例:
-
要定义训练数据集,我们需要考虑几个因素:
-
使用大小为 50 的 mini-batch。
-
CIFAR-10 图像的大小为 32×32,而 ImageNet 网络期望输入为 224×224。由于我们使用的是基于 ImageNet 的网络,我们将使用
transforms.``Resize
将 32×32 的 CIFAR 图像上采样到 224×224。 -
使用 ImageNet 的均值和标准差来标准化 CIFAR-10 数据,因为网络期望的是这种格式。
-
添加轻微的数据增强(翻转)。
我们可以通过以下代码完成所有这些操作:
import torch from torch.utils.data import DataLoader from torchvision import datasets from torchvision import transforms batch_size = 50 # training data train_data_transform = transforms.Compose([ transforms.Resize(224), transforms.RandomHorizontalFlip(), transforms.RandomVerticalFlip(), transforms.ToTensor(), transforms.Normalize( [0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) train_set = datasets.CIFAR10( root='data', train=True, download=True, transform=train_data_transform) train_loader = DataLoader( dataset=train_set, batch_size=batch_size, shuffle=True, num_workers=2)
-
-
按照相同的步骤使用验证数据(除了数据增强之外):
val_data_transform = transforms.Compose([ transforms.Resize(224), transforms.ToTensor(), transforms.Normalize( [0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) val_set = datasets.CIFAR10( root='data', train=False, download=True, transform=val_data_transform) val_order = DataLoader( dataset=val_set, batch_size=batch_size, shuffle=False, num_workers=2)
-
选择一个设备——最好是 GPU,如果没有可退回到 CPU:
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
-
为了训练和验证模型,我们将使用
train_model(model, loss_function, optimizer, data_loader)
和test_model(model, loss_function, data_loader)
函数。我们在第三章中首先实现了这些函数,因此这里不再重复实现(完整的源代码示例可以在 GitHub 上找到)。 -
定义第一个迁移学习场景,其中我们将预训练的网络用作特征提取器:
-
我们将使用一个流行的网络,
epochs
,并在每个 epoch 后评估网络的准确度。 -
使用
plot_accuracy
准确度函数,它在matplotlib
图表上绘制验证准确度。我们不会在这里包含完整的实现,但可以在 GitHub 上找到。
以下是
tl_feature_extractor
函数,它实现了所有这些:import torch.nn as nn import torch.optim as optim from torchvision.models import MobileNet_V3_Small_Weights, mobilenet_v3_small def tl_feature_extractor(epochs=5): # load the pre-trained model model = mobilenet_v3_small( weights=MobileNet_V3_Small_Weights.IMAGENET1K_V1) # exclude existing parameters from backward pass # for performance for param in model.parameters(): param.requires_grad = False # newly constructed layers have requires_grad=True by default num_features = model.classifier[0].in_features model.classifier = nn.Linear(num_features, 10) # transfer to GPU (if available) model = model.to(device) loss_function = nn.CrossEntropyLoss() # only parameters of the final layer are being optimized optimizer = optim.Adam(model.classifier.parameters()) # train test_acc = list() # collect accuracy for plotting for epoch in range(epochs): print('Epoch {}/{}'.format(epoch + 1, epochs)) train_model(model, loss_function, optimizer, train_loader) _, acc = test_model(model, loss_function, val_order) test_acc.append(acc.cpu()) plot_accuracy(test_acc)
-
-
使用
tl_fine_tuning
函数实现微调方法。此函数与tl_feature_extractor
类似,但现在我们将训练整个网络:def tl_fine_tuning(epochs=5): # load the pre-trained model model = mobilenet_v3_small( weights=MobileNet_V3_Small_Weights.IMAGENET1K_V1) # replace the last layer num_features = model.classifier[0].in_features model.classifier = nn.Linear(num_features, 10) # transfer the model to the GPU model = model.to(device) # loss function loss_function = nn.CrossEntropyLoss() # We'll optimize all parameters optimizer = optim.Adam(model.parameters()) # train test_acc = list() # collect accuracy for plotting for epoch in range(epochs): print('Epoch {}/{}'.format(epoch + 1, epochs)) train_model(model, loss_function, optimizer, train_loader) _, acc = test_model(model, loss_function, val_order) test_acc.append(acc.cpu()) plot_accuracy(test_acc)
-
我们可以通过两种方式运行整个过程:
-
调用
tl_fine_tuning(epochs=5)
来使用微调方法训练五个 epoch。 -
调用
tl_feature_extractor(epochs=5)
来使用特征提取方法训练网络五个 epoch。
-
使用网络作为特征提取器时,我们的准确率大约为 81%,而通过微调后,准确率可以达到 89%。但如果我们在更多的训练周期中进行微调,网络将开始出现过拟合。接下来,我们来看一个相同的例子,但使用的是 Keras。
使用 Keras 进行迁移学习
在本节中,我们将再次实现这两种迁移学习(TL)场景,但这次使用的是 Keras 和 TF。通过这种方式,我们可以比较这两个库。我们仍然使用MobileNetV3Small
架构。除了 Keras 外,本示例还需要 TF Datasets 包(www.tensorflow.org/datasets
),它是一个包含各种流行机器学习数据集的集合。让我们开始:
注意
本示例部分基于github.com/tensorflow/docs/blob/master/site/en/tutorials/images/transfer_learning.ipynb
。
-
定义小批量和输入图像大小(图像大小由网络架构决定):
IMG_SIZE = 224 BATCH_SIZE = 50
-
使用 TF 数据集的帮助加载 CIFAR-10 数据集。
repeat()
方法允许我们在多个周期中重复使用数据集:import tensorflow as tf import tensorflow_datasets as tfds data, metadata = tfds.load('cifar10', with_info=True, as_supervised=True) raw_train, raw_test = data['train'].repeat(), data['test'].repeat()
-
定义
train_format_sample
和test_format_sample
函数,这些函数会将初始图像转换为适合 CNN 输入的格式。这些函数扮演了与我们在使用 PyTorch 实现迁移学习一节中定义的transforms.Compose
对象相同的角色。输入转换如下:-
图像会被调整为 224×224 的大小,这是网络预期的输入尺寸
-
每张图片都会通过转换其值来进行标准化,使其处于(-1;1)区间内
-
标签被转换为独热编码
-
训练图像会随机地水平和垂直翻转
让我们看看实际的实现:
def train_format_sample(image, label): """Transform data for training""" image = tf.cast(image, tf.float32) image = tf.image.resize(image, (IMG_SIZE, IMG_SIZE)) image = tf.image.random_flip_left_right(image) image = tf.image.random_flip_up_down(image) label = tf.one_hot(label, metadata.features['label'].num_classes) return image, label def test_format_sample(image, label): """Transform data for testing""" image = tf.cast(image, tf.float32) image = tf.image.resize(image, (IMG_SIZE, IMG_SIZE)) label = tf.one_hot(label, metadata.features['label'].num_classes) return image, label
-
-
接下来是一些模板代码,将这些转换器分配到训练/测试数据集,并将其拆分成小批量:
# assign transformers to raw data train_data = raw_train.map(train_format_sample) test_data = raw_test.map(test_format_sample) # extract batches from the training set train_batches = train_data.shuffle(1000).batch(BATCH_SIZE) test_batches = test_data.batch(BATCH_SIZE)
-
定义特征提取模型:
-
由于 Keras 是 TF 的核心部分,因此使用 Keras 来定义预训练网络和模型
-
加载
MobileNetV3Small
预训练网络,排除最后的全连接层 -
调用
base_model.trainable = False
,这会冻结所有网络权重,防止它们被训练 -
添加一个
GlobalAveragePooling2D
操作,然后在网络的末端添加一个新的、可训练的全连接层
以下代码实现了这一点:
def build_fe_model(): """"Create feature extraction model from the pre-trained model ResNet50V2""" # create the pre-trained part of the network, excluding FC layers base_model = tf.keras.applications.MobileNetV3Small( input_shape=(IMG_SIZE, IMG_SIZE, 3), include_top=False, classes=10, weights='imagenet', include_preprocessing=True) # exclude all model layers from training base_model.trainable = False # create new model as a combination of the pre-trained net # and one fully connected layer at the top return tf.keras.Sequential([ base_model, tf.keras.layers.GlobalAveragePooling2D(), tf.keras.layers.Dense( metadata.features['label'].num_classes, activation='softmax') ])
-
-
定义微调模型。它与特征提取的唯一区别是,我们只冻结一些底层的预训练网络层(而不是全部层)。以下是实现代码:
def build_ft_model(): """"Create fine tuning model from the pre-trained model MobileNetV3Small""" # create the pre-trained part of the network, excluding FC layers base_model = tf.keras.applications.MobileNetV3Small( input_shape=(IMG_SIZE, IMG_SIZE, 3), include_top=False, weights='imagenet', include_preprocessing=True ) # Fine tune from this layer onwards fine_tune_at = 100 # Freeze all the layers before the `fine_tune_at` layer for layer in base_model.layers[:fine_tune_at]: layer.trainable = False # create new model as a combination of the pre-trained net # and one fully connected layer at the top return tf.keras.Sequential([ base_model, tf.keras.layers.GlobalAveragePooling2D(), tf.keras.layers.Dense( metadata.features['label'].num_classes, activation='softmax') ])
-
实现
train_model
函数,它用于训练和评估由build_fe_model
或build_ft_model
函数创建的模型。plot_accuracy
函数在此未实现,但可在 GitHub 上找到:def train_model(model, epochs=5): """Train the model. This function is shared for both FE and FT modes""" # configure the model for training model.compile( optimizer=tf.keras.optimizers.Adam( learning_rate=0.0001), loss='categorical_crossentropy', metrics=['accuracy']) # train the model history = model.fit( train_batches, epochs=epochs, steps_per_epoch=metadata.splits['train'].num_examples / BATCH_SIZE, validation_data=test_batches, validation_steps=metadata.splits['test'].num_examples / BATCH_SIZE, workers=4) # plot accuracy plot_accuracy(history.history['val_accuracy'])
-
我们可以使用以下代码运行特征提取或微调迁移学习:
-
train_model(build_ft_model())
-
train_model(build_fe_model())
-
使用网络作为特征提取器时,我们可以获得约 82%的准确率,而通过微调后,准确率可达到 89%。这些结果与 PyTorch 示例类似。
接下来,让我们关注物体检测——这是一个我们可以通过 TL 来解决的任务。
物体检测
物体检测是指在图像或视频中找到某一类别的物体实例,例如人、车、树木等。与分类不同,物体检测不仅可以检测多个物体,还可以识别它们在图像中的位置。
物体检测器会返回一份包含每个物体以下信息的检测对象列表:
-
物体的类别(例如:人、车、树木等)。
-
一个概率值(或物体性得分),范围在[0, 1]之间,表示检测器对该位置存在物体的信心。这类似于常规二分类器的输出。
-
图像中物体所在矩形区域的坐标。这个矩形被称为边界框。
我们可以在下图中看到物体检测算法的典型输出。物体类型和物体性得分位于每个边界框的上方:
图 5.2 – 物体检测器的输出。来源:en.wikipedia.org/wiki/File:2011_FIA_GT1_Silverstone_2.jpg
接下来,我们将概述解决物体检测任务的不同方法。
物体检测方法
在这一部分中,我们将概述三种方法:
-
经典滑动窗口:在这里,我们将使用常规分类网络(分类器)。这种方法可以与任何类型的分类算法一起使用,但它相对较慢且容易出错。
-
构建图像金字塔:这是将同一图像的不同尺度组合在一起(见下图)。例如,每个缩放后的图像可以比前一个小两倍。通过这种方式,我们能够检测到原始图像中不同尺寸的物体。
-
在整个图像上滑动分类器:我们将图像的每个位置作为输入传递给分类器,结果将确定该位置的物体类型。位置的边界框就是我们用作输入的图像区域。
-
每个物体的多个重叠边界框:我们将使用一些启发式方法将它们合并为一个单一的预测。
-
这里有一张展示滑动窗口方法的图示:
图 5.3 – 滑动窗口加图像金字塔物体检测
-
两阶段检测方法:这些方法非常准确,但相对较慢。顾名思义,它涉及两个步骤:
-
一种特殊类型的 CNN,称为区域提议网络 (RPN),扫描图像并提出多个可能的边界框,或兴趣区域 (RoI),用于检测物体可能的位置。然而,该网络并不检测物体的类型,仅仅是判断该区域是否包含物体。
-
将 RoI 送到第二阶段进行物体分类,从而确定每个边界框中的实际物体。
-
-
一阶段(或单次)检测方法:在这种方法中,单个 CNN 同时输出物体类型和边界框。这些方法通常比两阶段方法速度更快,但准确度较低。
在接下来的部分,我们将介绍YOLO——一种精确高效的一阶段检测算法。
使用 YOLO 进行物体检测
YOLO 是最受欢迎的一阶段检测算法之一。其名称来源于流行的格言“你只活一次(You Only Live Once)”,这反映了算法的一阶段特性。自其首次发布以来,YOLO 已经历了多个版本,不同的作者参与其中。为了便于理解,我们将在此列出所有版本:
-
你只看一次:统一的实时物体检测 (
arxiv.org/abs/1506.02640
),作者:Joseph Redmon、Santosh Divvala、Ross Girshick 和 Ali Farhadi。 -
YOLO9000: 更好、更快、更强 (
arxiv.org/abs/1612.08242
),作者:Joseph Redmon 和 Ali Farhadi。 -
YOLOv3: 增量式改进 (
arxiv.org/abs/1804.02767
,github.com/pjreddie/darknet
),作者:Joseph Redmon 和 Ali Farhadi。 -
YOLOv4: 物体检测的最佳速度与精度 (
arxiv.org/abs/2004.10934
,github.com/AlexeyAB/darknet
),作者:Alexey Bochkovskiy、Chien-Yao Wang 和 Hong-Yuan Mark Liao。 -
YOLOv5 和 YOLOv8 (
github.com/ultralytics/yolov5
,github.com/ultralytics/ultralytics
),由 Ultralitics 提供 (ultralytics.com/
)。V5 和 v8 没有正式论文。 -
YOLOv6 v3.0: 全面重新加载 (
arxiv.org/abs/2301.05586
,github.com/meituan/YOLOv6
),作者:Chuyi Li、Lulu Li、Yifei Geng、Hongliang Jiang、Meng Cheng、Bo Zhang、Zaidan Ke、Xiaoming Xu 和 Xiangxiang Chu。 -
YOLOv7: 可训练的“免费赠品”集合创造了实时物体检测的新技术前沿 (
arxiv.org/abs/2207.02696
, Mark Lgithub.com/WongKinYiu/yolov7
),作者:Chien-Yao Wang、Alexey Bochkovskiy 和 Hong-Yuan Mark Liao。
注意
v3 是最后一个由算法的原作者发布的版本。v4 是 v3 的一个分支,由 v1-v3 的主要作者 Joseph Redmon(twitter.com/pjreddie/status/1253891078182199296
)支持发布。另一方面,v5 是一个独立的实现,灵感来自于 YOLO。这引发了关于 v5 名称的争议。你可以查看一些讨论,访问github.com/AlexeyAB/darknet/issues/5920
,其中 v4 的作者 Alexey Bochkovskiy 也进行了发帖。v5 的作者也在这里解决了争议:blog.roboflow.com/yolov4-versus-yolov5/
。不管这些讨论如何,v5 和 v8 已经证明其有效性,并且它们在各自的领域中是受欢迎的检测算法。
我们将讨论所有版本共享的 YOLO 特性,并指出其中的一些差异。
我们从 YOLO 架构开始:
图 5.4 – YOLO 架构
它包含以下组件:
-
主干网络:这是一个 CNN 模型,负责从输入图像中提取特征。这些特征随后会传递给下一个组件进行目标检测。通常,主干网络是一个在 ImageNet 上预训练的 CNN,类似于我们在第四章中讨论的高级模型。
主干网络是迁移学习(TL)的一个例子——我们将一个用于分类的 CNN 拿来重新用于目标检测。不同版本的 YOLO 使用不同的主干网络。例如,v3 使用一个名为 DarkNet-53 的特殊全卷积 CNN,它有 53 层。随后的 YOLO 版本在此架构上进行了一些改进,而其他版本则使用了自己独特的主干网络。
-
颈部:这是模型的中间部分,连接主干网络和头部。它在将组合结果发送到下一个组件(头部)之前,将主干特征图在不同阶段的输出进行串联。这是标准方法的替代方案,标准方法是仅发送最后一个主干卷积的输出以进行进一步处理。为了理解颈部的必要性,让我们回顾一下我们的目标是围绕检测到的物体边缘创建一个精确的边界框。物体本身的大小可能相对图像来说很大或很小。然而,主干的深层接收域较大,因为它汇聚了所有前面层的接收域。因此,深层检测到的特征包含了输入图像的大部分。这与我们的精细物体检测目标相悖,无论物体的大小如何。为了解决这个问题,颈部在不同主干阶段结合特征图,从而使得不同尺度的物体都能被检测到。然而,每个主干阶段的特征图维度不同,不能直接组合。颈部应用不同的技术,例如上采样或下采样,以平衡这些维度,使它们能够串联。
-
头部:这是模型的最终组件,输出检测到的物体。每个检测到的物体通过其边界框坐标和类别来表示。
通过这些步骤,我们已经获得了 YOLO 架构的概览。但这并没有回答一些不太方便(但又令人好奇)的问题,例如模型如何在同一图像上检测多个物体,或者当两个或更多物体重叠且其中一个仅部分可见时会发生什么。为了找到这些问题的答案,我们引入下面的示意图,其中包含两个重叠的物体:
图 5.5 – 一个物体检测 YOLO 示例,包含两个重叠的物体及其边界框
这是 YOLO 实现物体检测的步骤:
-
将输入图像分割成S×S个单元格(前面的示意图使用了一个 3×3 的网格):
-
一个单元格的中心代表一个区域的中心,该区域可能包含一个物体。
-
模型可以检测跨越多个单元格的物体,也可以检测完全位于单元格内的物体。每个物体都与一个单元格相关联,即使它跨越了多个单元格。在这种情况下,我们将物体与其边界框中心所在的单元格关联。例如,图中的两个物体跨越了多个单元格,但它们都分配给中央单元格,因为它们的中心位于其中。
-
一个单元格可以包含多个物体(1 对 n关系)或完全没有物体。我们只关注包含物体的单元格。
-
-
该模型为每个网格单元输出多个可能的检测物体。每个检测物体由以下值数组表示:
。我们来讨论它们:
-
描述了物体的边界框。
是边界框中心相对于整张图像的坐标。它们被归一化到[0, 1]的范围内。例如,如果图像大小为 100×100,且边界框中心位于[40, 70]的位置,那么
.
表示相对于整个图像的归一化边界框高度和宽度。如果边界框的尺寸是 80×50,那么
适用于相同的 100×100 图像。在实际操作中,YOLO 实现通常包括帮助方法,允许我们获取边界框的绝对坐标。
-
是一个物体性分数,表示模型对单元格中是否存在物体的置信度(范围为 [0, 1])。如果
趋近于 1,则表示模型确信单元格中存在物体,反之亦然。
-
是检测到的物体类别的独热编码。例如,如果我们有自行车、花、人物和鱼类,并且当前物体是人物,则它的编码将是 [0, 0, 1, 0]。
-
-
到目前为止,我们已经展示了模型可以检测同一图像上的多个物体。接下来,让我们聚焦于一个更复杂的情况——同一单元格中有多个物体。YOLO 在这个问题上提供了一个优雅的解决方案——锚框(也称为先验框)。为了理解这个概念,我们从下图开始,图中展示了网格单元(方形,实线)和两个锚框——垂直和水平(虚线):
图 5.6 – 一个网格单元(方形,实线)与两个锚框(虚线)
对于每个单元格,我们会有多个候选锚框,具有不同的尺度和纵横比。如果同一单元格内有多个物体,我们将每个物体与一个单独的锚框关联。如果某个锚框没有关联物体,它的物体性得分将为零 ()。我们可以检测到每个单元格内的锚框数量相等的物体。例如,我们的 3×3 网格,每个单元格有两个锚框,可以检测到总共 332 = 18 个物体。因为我们有固定数量的单元格 (S×S) 和每个单元格固定数量的锚框,网络输出的大小不会随着检测到的物体数量而变化。相反,我们会输出所有可能锚框的结果,但我们只会考虑那些物体性得分为
的锚框。
- YOLO 算法在训练和推理过程中都使用交并比(IoU)技术来提高性能:
图 5.7 – 交并比(IoU)
IoU 是检测到的对象的边界框与真实标签(或其他对象)的边界框交集的面积与并集的面积之比。
在训练过程中,我们可以计算锚框与真实框之间的 IoU。然后,我们可以将每个真实对象分配给其与之重叠度最高的锚框,从而生成标注的训练数据。此外,我们还可以计算检测到的边界框与真实框(标签框)之间的 IoU。IoU 值越高,表示真实值与预测值的重叠越好。这可以帮助我们评估检测器。
在推理过程中,模型的输出包括每个单元格的所有可能的锚框,无论其中是否存在对象。许多框会重叠并预测相同的对象。我们可以通过 IoU 和非最大抑制(NMS)来过滤这些重叠的对象。它是如何工作的:
-
丢弃所有对象性分数低于
的边界框。
-
选择剩余框中具有最高对象性分数的框,
。
-
丢弃所有与我们在上一步中选择的框的 IoU >= 0.5 的框。
现在我们(希望)已经熟悉 YOLO,接下来让我们学习如何在实际中使用它。
使用 Ultralytics YOLOv8
在本节中,我们将演示如何使用由 Ultralytics 开发的 YOLOv8 算法。对于此示例,您需要安装ultralytics
Python 包。让我们开始:
-
导入 YOLO 模块。我们将加载一个预训练的 YOLOv8 模型:
from ultralytics import YOLO model = YOLO("yolov8n.pt")
-
使用
model
在 Wikipedia 图片上检测对象:results = model.predict('https://raw.githubusercontent.com/ivan-vasilev/Python-Deep-Learning-3rd-Edition/main/Chapter05/wikipedia-2011_FIA_GT1_Silverstone_2.jpg')
results
是一个列表,包含一个ultralytics.yolo.engine.results.Results
类的实例。该实例包含检测到的对象列表:它们的边界框、类别和对象性分数。 -
我们可以通过
results[0].plot()
方法来显示结果,它会将检测到的对象叠加在输入图像上。这个操作的结果就是我们在目标检测简介部分开始时展示的第一张图像:from PIL import Image Image.fromarray(results[0].plot()).show()
这就是我们对 YOLO 系列单次检测模型的介绍。接下来,我们将重点讲解一个流行的两次检测算法的示例。
使用 Faster R-CNN 进行目标检测
在本节中,我们将讨论Faster R-CNN(Faster R-CNN: Towards Real-Time Object Detection with Region Proposal Networks,arxiv.org/abs/1506.01497
)的两阶段目标检测算法。它是早期两阶段检测器的演变,Fast R-CNN(Fast R-CNN,arxiv.org/abs/1504.08083
)和R-CNN(Rich feature hierarchies for accurate object detection and semantic segmentation,arxiv.org/abs/1311.2524
)。
Faster R-CNN 模型的一般结构在下图中概述:
图 5.8 – Faster R-CNN 的结构。来源:arxiv.org/abs/1506.01497
在解释算法时,我们要记住这个图。像 YOLO 一样,Faster R-CNN 首先使用一个在 ImageNet 上训练的主干分类网络,它作为模型不同模块的基础。最初,论文的作者尝试了经典的主干架构,如VGG-16(Very Deep Convolutional Networks for Large-Scale Image Recognition,arxiv.org/abs/1409.1556
)和ZFNet(Visualizing and Understanding Convolutional Networks,arxiv.org/abs/1311.2901
)。如今,该模型已提供更多现代化的主干,如 ResNet 和 MobileNet。
与 YOLO 不同,Faster R-CNN 没有颈部模块,只使用最后一个主干卷积层的特征图作为输入,供算法的下一个组件使用。更具体地说,主干网络作为模型其他两个组件(因此是两阶段)的支撑——区域提议网络(RPN)和检测网络。我们先来讨论一下 RPN。
区域提议网络
在第一阶段,RPN 将图像(任意大小)作为输入,并输出一组矩形 RoI,表示可能存在物体的位置。RoI 相当于 YOLO 中的边界框。RPN 本身是通过采用主干模型的前p个卷积层(参见前面的图示)来创建的。一旦输入图像传播到最后一个共享的卷积层,算法就会取该层的特征图,并在特征图的每个位置滑动另一个小型网络。这个小型网络输出是否在任意一个k个锚框中存在物体(锚框的概念与 YOLO 中的相同),以及其潜在的边界框坐标。下图左侧的图像展示了 RPN 在最后一个卷积层的特征图上滑动的一个位置:
图 5.9 – 单一位置的 RPN 提议。来源:https://arxiv.org/abs/1506.01497
小型网络将跨所有输入特征图,在相同位置的n×n区域作为输入。
(n = 3,根据论文)。例如,如果最终卷积层有 512 个特征图,那么在某个位置上,小型网络的输入大小为 51233 = 4608。512 个 3×3 的特征图被展平为一个 4608 维的向量。这个向量作为输入传递给一个全连接层,该层将其映射到一个较低维度(通常为 512)的向量。这个向量本身作为输入传递给接下来的两个并行的全连接层:
-
一个具有2k单元的分类层,这些单元组织成k个 2 单元的二元 softmax 输出。像 YOLO 一样,每个 softmax 的输出表示在每个k个锚框中是否存在物体的目标性分数(在[0, 1]范围内)。在训练过程中,物体是根据 IoU 公式分配给锚框的,和 YOLO 中的方式一样。
-
一个回归层,具有4k单元,组织成k个 4 单元的 RoI 数组。像 YOLO 一样,第一个数组元素表示 RoI 中心的坐标,范围为[0:1],相对于整个图像。其他两个元素表示区域的高度和宽度,相对于整个图像(再次类似 YOLO)。
论文的作者实验了三种尺度和三种长宽比,结果在每个位置上得到了九种可能的锚框。最终特征图的典型H×W大小大约是 2,400,这样就得到了 2,400*9 = 21,600 个锚框。
RPN 作为跨通道卷积
理论上,我们将小型网络滑动到最后一层卷积的特征图上。然而,小型网络的权重在所有位置之间共享。因为这个原因,滑动可以被实现为跨通道卷积。因此,网络可以在一次图像传递中为所有锚框生成输出。这是对 Fast R-CNN 的改进,后者需要为每个锚框单独进行网络传递。
RPN 通过反向传播和随机梯度下降进行训练(真是令人惊讶!)。共享卷积层的权重使用预训练的骨干网络权重进行初始化,其余的权重则是随机初始化的。每个小批量的样本都从一张图片中提取。每个小批量包含相同数量的正样本(物体)和负样本(背景)锚框。有两种正标签的锚框:与真实框 IoU 重叠最高的锚框,和与任何真实框的 IoU 重叠超过 0.7 的锚框。如果锚框的 IoU 比率低于 0.3,则该框被分配为负标签。既不是正标签也不是负标签的锚框不会参与训练。
由于 RPN 具有两个输出层(分类和回归),因此训练使用以下复合代价函数,其中包含分类 () 和回归 (
) 部分:
让我们讨论它的组成部分:
-
i:小批量中锚点的索引。
-
:分类输出,表示锚点 i 是物体还是背景的预测物体性得分。
是相同目标的实际数据(0 或 1)。
-
:回归输出向量,大小为 4,表示 RoI 参数。
-
:相同目标的目标向量。
-
:分类层的交叉熵损失。
-
:归一化项,等于小批量大小。
-
:回归损失,
,其中 R 是平均绝对误差(
en.wikipedia.org/wiki/Mean_absolute_error
)。 -
:一个归一化项,等于锚点位置的总数(大约 2400)。
-
λ:这有助于将分类和回归组件结合到代价函数中。由于
和
,λ 被设置为 10,以保持两者损失的平衡。
现在我们已经讨论了 RPN,让我们集中注意力于检测网络。
检测网络
让我们回到在基于 Faster R-CNN 的目标检测部分开头展示的图示。回想一下,在第一阶段,RPN 已经生成了 RoI 坐标及其目标性分数。检测网络是一个常规分类器,用于确定当前 RoI 中物体的类别。RPN 和检测网络共享它们的第一卷积层,这些层借用了背骨网络。此外,检测网络还整合了来自 RPN 的提议区域以及最后共享层的特征图。
但是,我们如何将背骨特征图和提议的区域以统一的输入格式结合起来呢?我们可以通过RoI 池化来实现,这也是检测网络第二部分的第一层:
图 5.10 – 一个 2×2 RoI 池化示例,使用 10×7 的特征图和一个 5×5 的 RoI(粗体矩形)
为了理解 RoI 池化的工作原理,假设我们有一个 10×7 的特征图和一个 RoI。如同在区域提议网络部分中所学,RoI 是由其中心坐标、宽度和高度定义的。RoI 池化首先将这些参数转换为特征图上的实际坐标。在这个示例中,区域大小是 h×w = 5×5。RoI 池化进一步通过其输出的高度和宽度 H 和 W 来定义。在这个例子中,H×W = 2×2,但在实际中,这些值可能更大,比如 7×7。该操作将 h×w 的 RoI 分割成大小不同的子区域网格(图中通过不同的背景颜色显示)。完成此操作后,每个子区域通过获取该区域的最大值来下采样为单个输出单元。换句话说,RoI 池化可以将任意大小的输入转换为固定大小的输出窗口。这样,转换后的数据可以以一致的格式通过网络传播。
正如我们在基于 Faster R-CNN 的目标检测部分中提到的,RPN 和检测网络共享它们的初始层。然而,它们一开始是作为独立的网络存在的。训练在两者之间交替进行,采用四步过程:
-
训练 RPN,它使用背骨的 ImageNet 权重进行初始化。
-
训练检测网络,使用来自步骤 1 中刚训练好的 RPN 的提议。训练也从 ImageNet 背骨网络的权重开始。此时,两个网络并不共享权重。
-
使用检测网络共享的层来初始化 RPN 的权重。然后,再次训练 RPN,但冻结共享层,只微调特定于 RPN 的层。现在,两个网络共享它们的权重。
-
通过冻结共享层并仅微调特定于检测网络的层来训练检测网络。
现在我们已经介绍了 Faster R-CNN,接下来让我们讨论如何使用预训练的 PyTorch 模型来实际应用它。
使用 PyTorch 进行 Faster R-CNN
在本节中,我们将使用一个带有 ResNet50 骨干网的预训练 PyTorch Faster R-CNN 模型进行物体检测。PyTorch 原生支持 Faster R-CNN,这使得我们很容易使用它。本示例已使用 PyTorch 实现。此外,它还使用了torchvision
和opencv-python
包。我们只会包括代码的相关部分,但你可以在本书的 GitHub 仓库中找到完整版本。让我们开始:
-
使用最新的权重加载预训练模型。确保使用
DEFAULT
选项:from torchvision.models.detection import \ FasterRCNN_ResNet50_FPN_V2_Weights, \ fasterrcnn_resnet50_fpn_v2 model = fasterrcnn_resnet50_fpn_v2( weights=FasterRCNN_ResNet50_FPN_V2_Weights.DEFAULT)
-
我们将使用模型进行推断而不是训练,因此我们将启用
eval()
模式:model.eval()
-
使用
opencv-python
读取位于image_file_path
的 RGB 图像。如果图像文件在本地不存在,我们会省略从本书仓库下载图像的代码:import cv2 img = cv2.imread(image_file_path)
这里,
img
是一个三维的numpy
整数数组。 -
实现单步图像预处理管道。它将
img
numpy
数组转换为torch.Tensor
,该 Tensor 将作为模型的输入:import torchvision.transforms as transforms transform = transforms.ToTensor()
-
运行检测模型:
nn_input = transform(img) detected_objects = model([nn_input])
这里,
detected_objects
是一个包含三个项目的字典:-
boxes
:一个边界框的列表,由它们的左上角和右下角像素坐标表示 -
labels
:每个检测到的物体的标签列表 -
scores
:每个检测到的物体的物体性得分列表
-
-
使用初始的
img
数组和detected_objects
作为draw_bboxes
函数的参数,该函数会在原始输入图像上叠加边界框和它们的标签(draw_bboxes
的实现可以在完整示例中找到):draw_bboxes(img, detected_objects)
-
使用
opencv-python
显示结果:cv2.imshow("Object detection", img) cv2.waitKey()
输出图像如下所示:
图 5.11 – 使用 Faster R-CNN 进行物体检测
我们现在已经熟悉了两种最流行的物体检测算法。在下一部分,我们将专注于下一个主要的计算机视觉任务,称为图像分割。
介绍图像分割
图像分割是将类标签(如人、自行车或动物)分配给图像中每个像素的过程。你可以将其视为像素级别的分类——而不是将整个图像分类为一个标签,我们会分别对每个像素进行分类。图像分割操作的输出被称为分割掩码。它是一个与原始输入图像具有相同维度的 Tensor,但每个像素不是用颜色表示,而是用它所属于的物体类别来表示。图像分割有两种类型:
-
语义分割:这种方法为每个像素分配一个类别,但不会区分物体实例。例如,下面图中的中间图像展示了一种语义分割掩码,其中每辆车的像素值是相同的。语义分割可以告诉我们某个像素属于某个物体,但不能区分不同的物体。
-
实例分割:这种方法为每个像素分配一个类别,并区分不同的物体实例。例如,下面图中的右侧展示了一种实例分割掩码,每辆车都被分割为独立的物体。
以下图示展示了语义分割和实例分割的例子:
图 5.12 – 左:输入图像;中:语义分割掩码;右:实例分割掩码。来源:http://sceneparsing.csail.mit.edu/
为了训练分割算法,我们需要一种特殊类型的真实数据,其中每张图像的标签是图像的分割版本。
分割图像最简单的方法是使用我们在物体检测方法部分中描述的常见滑动窗口技术——即,我们使用一个常规分类器,并以步幅 1 在任一方向上滑动它。当我们得到某个位置的预测时,我们将取位于输入区域中心的像素,并将其分配给预测的类别。可以预见,这种方法非常慢,因为图像中像素的数量非常庞大(即便是 1,024×1,024 的图像,也有超过 100 万个像素)。幸运的是,还有更快速和更准确的算法,我们将在接下来的部分中讨论。
使用 U-Net 的语义分割
我们将讨论的第一个分割方法被称为U-Net(U-Net:用于生物医学图像分割的卷积网络,arxiv.org/abs/1505.04597
)。这个名称来源于网络架构的可视化:
图 5.13 – U-Net 架构。来源:https://arxiv.org/abs/1505.04597
U-Net 是一种全卷积网络(FCN),之所以如此命名,是因为它仅包含卷积层,并且在输出端不使用任何全连接层。FCN 将整个图像作为输入,并在一次传递中输出其分割图。为了更好地理解这个架构,我们首先来澄清一下图示符号:
-
水平深蓝色箭头表示 3×3 的跨通道卷积,并应用 ReLU 激活函数。模型末端的单一浅蓝色箭头代表 1×1 的瓶颈卷积,用于减少通道数。
-
所有特征图都用蓝色框表示。特征图的数量显示在框的顶部,特征图的大小显示在框的左下角。
-
水平灰色箭头表示复制和裁剪操作(稍后会详细介绍)。
-
红色竖直箭头表示 2×2 最大池化操作。
-
竖直绿色箭头表示 2×2 上卷积(或转置卷积;参见第四章)。
我们可以将 U-Net 模型分为两个虚拟组件(实际上,这只是一个单一的网络):
-
编码器:网络的第一部分(U的左侧)类似于常规 CNN,但末尾没有全连接层。其作用是学习输入图像的高度抽象表示(这没有什么新意)。输入图像本身可以是任意大小,只要每次最大池化操作的输入特征图具有偶数(而非奇数)维度。否则,输出的分割掩模将被扭曲。默认情况下,输入大小为 572×572。接下来,它像常规 CNN 一样,交替进行卷积和最大池化层。编码器由四个相同的模块组成,每个模块包含两个连续的有效(未填充)卷积。
具有步幅 1 的 3×3 跨通道卷积,可选的批量归一化、ReLU 激活,以及 2×2 最大池化层。每个下采样步骤都会使特征图数量翻倍。最终的编码器卷积结束时会得到 1,024 个 28×28 的特征图。
-
解码器:网络的第二部分(U的右侧)与编码器对称。解码器接收最内层的 28×28 编码器特征图,并同时进行上采样,将其转换为 388×388 的分割图。它包含四个相同的上采样模块:
-
上采样使用 2×2 转置交叉通道卷积,步幅为 2。
-
每个上采样步骤的输出与相应编码器步骤的裁剪高分辨率特征图(灰色横向箭头)进行拼接。裁剪是必要的,因为每次未填充的编码器和解码器卷积都会丢失边缘像素。
-
每个转置卷积后跟随两个常规卷积,以平滑扩展后的表示。
-
上采样步骤会将特征图数量减半。最终输出使用 1×1 瓶颈卷积将 64 个分量的特征图张量映射到所需的类别数量(浅蓝色箭头)。论文的作者展示了医学图像中细胞的二分类分割。
-
网络的输出是对每个像素的分割掩模进行 softmax 处理——也就是说,输出中包含与像素数相等的独立 softmax 操作。某一像素的 softmax 输出决定了该像素的类别。U-Net 像常规分类网络一样进行训练。然而,损失函数是所有像素的 softmax 输出的交叉熵损失的组合。
我们可以看到,由于网络采用了无填充卷积,输出的分割图比输入图像小(388 对比 572)。然而,输出图并不是输入图像的缩放版本。相反,它与输入图具有一对一的比例,但仅覆盖输入图块的中心部分。这在下图中得到了说明:
图 5.14 – 用于分割大图像的重叠平铺策略。来源:arxiv.org/abs/1505.04597
无填充卷积是必要的,这样网络在分割图的边缘不会产生噪声伪影。这使得使用所谓的重叠平铺策略对任意大小的图像进行分割成为可能。输入图像被分割成重叠的输入图块,如前图左侧所示。右侧图像中小的亮区的分割图需要左侧图像中的大亮区(一个图块)作为输入。
下一个输入图块与前一个图块重叠,以使它们的分割图覆盖图像的相邻区域。为了预测图像边缘区域的像素,缺失的上下文通过镜像输入图像来推断。
我们不会实现 U-Net 的代码示例,但你可以查看 github.com/mateuszbuda/brain-segmentation-pytorch
来了解 U-Net 在大脑 MRI 图像分割中的应用。
使用 Mask R-CNN 进行实例分割
Mask R-CNN (arxiv.org/abs/1703.06870
) 是 Faster R-CNN 在实例分割方面的扩展。Faster R-CNN 为每个候选目标提供两个输出:边界框参数和类别标签。除了这些,Mask R-CNN 增加了第三个输出——一个 FCN,为每个 RoI 生成二进制分割掩码。下图展示了 Mask R-CNN 的结构:
图 5.15 – Mask R-CNN 结构
分割路径和检测路径都使用 RPN 的 RoI 预测,但除此之外,它们是独立的,且是并行的。分割路径生成 I m×m 分割掩码,每个 RoI 对应一个。由于检测路径负责处理目标的分类,因此分割掩码是二进制的,并且与目标类别无关。分割后的像素会自动被分配到检测路径所预测的类别中。这与其他算法(如 U-Net)不同,后者将分割与分类结合,在每个像素上应用个别的 softmax。在训练或推理时,仅考虑与分类路径中预测目标相关的掩码,其余的会被丢弃。
Mask R-CNN 用更精确的 RoI align 层替换了 RoI 最大池化操作。RPN 输出锚框的中心及其高度和宽度,作为四个浮点数。然后,RoI 池化层将其转换为整数特征图单元格坐标(量化)。此外,将 RoI 划分为 H×W 网格(与 RoI 池化区域大小相同)也涉及量化。从使用 Faster R-CNN 进行目标检测章节中的 RoI 示例可以看出,这些网格的大小不同(3×3、3×2、2×3、2×2)。这两个量化级别可能会导致 RoI 与提取的特征之间的不对齐。下图展示了 RoI align 如何解决这个问题:
图 5.16 – RoI align 示例。来源:https://arxiv.org/abs/1703.06870
虚线表示特征图的单元格。中间实线区域是覆盖在特征图上的 2×2 RoI。注意,它与单元格并不完全匹配,而是根据 RPN 预测的位置来定位的,没有进行量化。同样,RoI 的单元格(黑点)也不与特定的特征图单元格对齐。RoI align 操作通过双线性插值计算 RoI 单元格的值,涉及其相邻单元格。这样,RoI align 比 RoI pooling 更精确。
在训练中,如果一个 RoI 与地面真值框的 IoU 大于或等于 0.5,则为其分配正标签,否则为负标签。掩膜目标是 RoI 与其关联的地面真值掩膜的交集。只有正 RoI 会参与分割路径的训练。
使用 PyTorch 的 Mask R-CNN
在本节中,我们将使用一个预训练的 PyTorch Mask R-CNN 模型,搭载 ResNet50 主干网络进行实例分割。与 Faster R-CNN 类似,PyTorch 原生支持 Mask R-CNN。程序结构和要求与使用 PyTorch 的 Faster R-CNN章节中的相同。我们将只包含相关的代码部分,完整版本可以在本书的 GitHub 仓库中找到。让我们开始:
-
加载预训练模型并使用最新的权重,你可以通过选择
DEFAULT
选项来确保这一点:from torchvision.models.detection import \ maskrcnn_resnet50_fpn_v2, \ MaskRCNN_ResNet50_FPN_V2_Weights model = maskrcnn_resnet50_fpn_v2( weights=MaskRCNN_ResNet50_FPN_V2_Weights.DEFAULT)
-
我们将使用模型进行推理而不是训练,因此我们将启用
eval()
模式:model.eval()
-
使用
opencv-python
读取位于image_file_path
的 RGB 图像。如果本地没有图像,我们将省略从本书的仓库下载图像的代码:import cv2 img = cv2.imread(image_file_path)
这里,
img
是一个三维的numpy
整型数组。 -
实现单步图像预处理管道。它将
img
numpy
数组转换为torch.Tensor
,作为模型的输入:import torchvision.transforms as transforms transform = transforms.ToTensor()
-
运行检测模型:
nn_input = transform(image) segmented_objects = model([nn_input])
这里,
segmented_objects
是一个包含四个项的字典:boxes
、labels
、scores
和masks
。前三项与 Faster R-CNN 中的相同。masks
是一个形状为[number_of_detected_objects, 1, image_height, image_width]
的张量。对于每个检测到的物体,我们有一个覆盖整个图像的二进制分割掩码。每个这样的掩码在所有像素中都是零,除了物体被检测到的像素,其值为 1。 -
使用初始的
img
数组和segmented_objects
作为draw_segmentation_masks
函数的参数。它将检测到的物体的边界框、分割掩码和标签叠加到原始输入图像上(draw_segmentation_masks
的实现可以在完整示例中找到):draw_segmentation_masks(image, segmented_objects)
-
使用
opencv
显示结果:cv2.imshow("Object detection", img) cv2.waitKey()
输出图像如下所示:
图 5.17 – 使用 Mask R-CNN 进行实例分割
我们现在已经讨论了目标检测和语义分割。在下一节中,我们将讨论如何使用 CNN 生成新的图像,而不仅仅是处理现有的图像。
使用扩散模型生成图像
到目前为止,我们使用神经网络作为判别模型。这仅仅意味着,在给定输入数据的情况下,判别模型将映射它到某个标签(换句话说,就是分类)。一个典型的例子是将 MNIST 图像分类到十个数字类别之一,其中神经网络将输入数据特征(像素强度)映射到数字标签。我们也可以用另一种方式说:判别模型给出的是y(类)给定x(输入)的概率。在 MNIST 的例子中,就是在给定图像的像素强度时,识别数字的概率。在下一节中,我们将介绍神经网络作为生成模型的应用。
介绍生成模型
生成模型学习数据的分布。从某种程度上来说,它是我们刚刚描述的判别模型的对立面。它预测给定类y时输入样本的概率 – 。
例如,一个生成模型可以根据文本描述生成图像。通常,y 是张量,而不是标量。这个张量存在于所谓的潜在空间(或潜在特征空间)中,我们将其称为原始数据的潜在表示(或潜在空间表示),而原始数据本身存在于其自己的特征空间中。我们可以将潜在表示视为原始特征空间的压缩(或简化)版本。数字到类别的例子是这种范式的极端示例——毕竟,我们是在将整个图像压缩成一个数字。为了使潜在表示有效,它必须捕捉原始数据最重要的隐藏特征,并去除噪声。
由于其相对简单性,我们可以合理地期望我们对潜在空间的结构和属性有所了解。这与特征空间不同,后者复杂到超出我们的理解。因此,如果我们知道从潜在空间到特征空间的反向映射,我们就可以基于不同的潜在表示生成不同的特征空间表示(即图像)。更重要的是,我们可以通过有意识地修改初始潜在表示来影响输出图像的属性。
为了说明这一点,假设我们成功地创建了一个反向映射,将具有n=3元素的潜在向量与完整的车辆图像关联起来。每个向量元素表示一个车辆属性,例如长度、高度和宽度(如下面的图示所示):
图 5.18 – 特征空间-潜在空间与潜在空间-特征空间映射示例
假设平均车辆长度为四米。我们可以将这个属性表示为一个正态(高斯)分布(en.wikipedia.org/wiki/Normal_distribution
),均值为 4,从而使潜在空间变得连续(同样适用于其他属性)。然后,我们可以选择从每个属性的分布范围内采样新的值。它们将形成一个新的潜在向量(在这种情况下是一个潜在变量),我们可以将其作为种子来生成新的图像。例如,我们可以生成更长和更低的车辆。
(如前所示)。
注意
本书的第二版增加了关于基于神经网络的生成模型的整章内容,其中我们讨论了两个特别的架构:变分自编码器(VAE,Auto-Encoding Variational Bayes,arxiv.org/abs/1312.6114
)和生成对抗网络(GAN,arxiv.org/abs/1406.2661
)。当时,这些是用于图像生成的最先进的生成模型。从那时起,它们被一种新型算法——扩散模型所超越。为了与时俱进,本版中我们将省略 VAE 和 GAN,重点介绍扩散模型。
去噪扩散概率模型
扩散模型是一类特殊的生成模型,首次在 2015 年提出(深度无监督学习与非平衡热力学,arxiv.org/abs/1503.03585
)。在本节中,我们将重点介绍去噪扩散概率模型(DDPM,arxiv.org/abs/2006.11239
),它们构成了许多令人印象深刻的生成工具的基础,比如稳定扩散(github.com/CompVis/stable-diffusion
)。
DDPM 遵循与我们已讨论过的生成模型类似的模式:它从一个潜在变量开始,并使用它生成完整的图像。DDPM 的训练算法分为两个部分:
-
正向扩散:从初始图像开始,然后通过一系列小步骤逐渐向其中添加随机的高斯噪声(
en.wikipedia.org/wiki/Gaussian_noise
),直到最终(潜在)表示变成纯噪声。 -
反向扩散:这是正向过程的反向过程。它从纯噪声开始,并逐渐尝试恢复原始图像。
下图展示了正向(顶部)和反向(底部)扩散过程:
图 5.19 – 正向(底部)和反向(顶部)扩散过程。来源:https://arxiv.org/abs/2006.11239
让我们详细讨论一下:
-
:来自原始特征空间的初始图像,表示为张量。
-
T:正向和反向过程中的步骤数。最初,作者使用了T=1000。最近,提出了T=4000(改进的去噪扩散概率模型)。每个正向或反向步骤都会添加或去除少量噪声。
-
:前向扩散的最终结果,表示纯噪声。我们可以把
看作
的一个特殊潜在表示。这两个张量具有相同的维度,与我们在引入生成 模型部分讨论的例子不同。
-
(注意小写的t):在一个中间步骤中的噪声增强张量,t。它的维度与
和
相同。
-
:这是前向扩散过程在一个中间步骤t的概率密度函数(PDF)。PDF 听起来很复杂,但其实并不是。它的意思就是我们给已经有噪声的张量添加少量的高斯噪声,
,生成一个新的、更有噪声的张量,
(
是依赖于
)。前向扩散过程不涉及机器学习或神经网络,也没有可学习的参数。我们只是加了噪声,仅此而已。然而,它表示的是从原始特征空间到潜在表示空间的映射。
请注意,我们需要知道
来生成
,
,以此类推——也就是说,我们需要所有的张量
来生成
。幸运的是,作者提出了一种优化方法,使我们能够仅使用初始张量
来推导出任何
的值。
(1)
这里, 是一个系数,它会根据预定的时间表发生变化,但通常随着 t 的增加而增大。ϵ 是与
同样大小的高斯随机噪声张量。平方根确保新的
仍然遵循高斯分布。我们可以看到,
是
和ϵ的混合,
决定了两者之间的平衡。如果
,那么
将占据更多权重。当
时,噪声ϵ将占据主导地位。由于这种优化,我们并没有进行真正的多步前向扩散过程。相反,我们在一步操作中生成所需的噪声表示,位于步骤 t 的
。
:这是反向扩散过程在中间步骤 t-1 时的 PDF。这是
的对立函数。它是从潜在空间映射到原始特征空间的过程——也就是说,我们从纯噪声张量
开始,逐渐去除噪声,直到达到原始图像,整个过程需要 T 步。与在前向阶段中仅将噪声添加到图像相比,反向扩散要复杂得多。这也是将去噪过程分成多个步骤,并在初期引入少量噪声的主要原因。我们的最佳机会是训练一个神经网络(NN),希望它能学习到潜在空间与原始特征空间之间实际映射的合理近似。因此,
是一个神经网络,其中 θ 索引表示其权重。作者们提出了一种 U-Net 类型的网络。它以噪声张量
为输入,并输出它对原始图像中加入的噪声(即仅噪声,而非图像本身)的近似值,
。输入和输出张量具有相同的维度。DDPM 比原始的 U-Net 稍晚发布,因此它们的神经网络架构在这期间引入了一些改进。这些改进包括残差块、组归一化(一种批量归一化的替代方法)、以及 注意力机制 (Attention Is All You Need,
arxiv.org/abs/1706.03762
),arxiv.org/abs/1803.08494
) 和 注意力机制 (Attention Is All You Need,arxiv.org/abs/1706.03762
)。
接下来,我们聚焦于 DDPM 训练,以下图中的左侧所示:
图 5.20 – DDPM 训练(左);DDPM 采样(右)。来源:https://arxiv.org/abs/2006.11239
一个完整的训练过程包括以下步骤(第 1 行):
-
从训练集中的随机样本(图像)开始,
(第 2 行)。
-
在区间[1:T]内采样随机噪声步长,t(第 3 行)。
-
从高斯分布中采样随机噪声张量,ϵ(第 4 行)。在神经网络本身中,步长 t 被通过 正弦位置嵌入方式嵌入到ϵ的值中。如果你不理解位置嵌入的概念,不必担心。我们将在第七章中详细讨论它,因为它最早是在该上下文中引入的。现在我们需要知道的是,步长 t 被隐式编码在ϵ的元素中,方式使得模型能够利用这些信息。步长调整的噪声在前图中以
表示。
-
基于初始图像,
,生成一个损坏的图像张量,该图像以初始图像和根据采样的噪声步长,t,以及随机噪声,
为条件。为此,我们将使用前面在本节中介绍的公式(1)。感谢它,这一步构成了整个正向扩散阶段(第 5 行)。
-
执行一次梯度下降步骤和权重更新。训练过程中使用均方误差(MSE)。它衡量采样噪声ϵ(第 4 行)与模型预测的噪声之间的差异,
(第 5 行)。损失方程看起来 deceptively 简单。论文的作者做了长链的变换和假设,才得出这个简单的结果。这是论文的主要贡献之一。
一旦模型经过训练,我们就可以使用它基于随机初始张量,,来采样新的图像。我们可以通过以下过程实现这一点(前面的图示,右侧):
-
从高斯分布中采样初始随机潜变量张量,
(第 1 行)。
-
重复接下来的步骤T次(第 2 行):
-
从高斯分布中采样随机噪声张量,z(第 3 行)。我们为所有反向步骤执行此操作,除了最后一步。
-
使用训练好的 U-Net 模型预测噪声,
,在步骤t时。将此噪声从当前样本中减去,
,得到新的、较少噪声的,
(第 4 行)。调度系数,
,
该公式也参与了此过程,正如在前向阶段一样。该公式还保留了原始分布的均值和方差。
-
-
最终的去噪步骤生成了图像。
这部分是我们对 DDPM 的介绍,暂时到此为止。然而,我们将在第九章中再次回顾它们,但会在稳定扩散的背景下进行讨论。
总结
在本章中,我们讨论了一些高级计算机视觉任务。我们从 TL 开始,这是一种通过预训练模型帮助启动实验的技术。我们还介绍了对象检测和语义分割模型,这些模型受益于 TL。最后,我们重点介绍了生成模型,特别是 DDPM。
在下一章中,我们将介绍语言建模与递归网络。
第三部分:
自然语言处理与变换器
本部分将以自然语言处理的介绍开始,为我们关于递归网络和变换器的讨论提供背景。变换器将是本节的主要焦点,因为它们代表了近年来深度学习领域的重大进展之一。它们是大型语言模型(LLM)的基础,例如 ChatGPT。我们将讨论它们的架构以及它们的核心元素——注意力机制。接着,我们将讨论 LLM 的特性。最后,我们将重点介绍一些高级 LLM 应用,如文本和图像生成,并学习如何构建以 LLM 为核心的应用。
本部分包括以下章节:
-
第六章,自然语言处理与递归神经网络
-
第七章,注意力机制与变换器
-
第八章,深入探索大型语言模型
-
第九章,大型语言模型的高级应用
第六章:自然语言处理和循环神经网络
本章将介绍两个不同但互补的主题——自然语言处理(NLP)和循环神经网络(RNNs)。NLP 教会计算机处理和分析自然语言文本,以执行诸如机器翻译、情感分析和文本生成等任务。与计算机视觉中的图像不同,自然文本代表了一种不同类型的数据,其中元素的顺序(或序列)非常重要。幸运的是,RNNs 非常适合处理顺序数据,如文本或时间序列。通过在这些序列上定义递归关系(因此得名),它们帮助我们处理可变长度的序列。这使得 NLP 和 RNNs 成为天然的盟友。事实上,RNNs 可以应用于任何问题,因为已经证明它们是图灵完备的——从理论上讲,它们可以模拟任何常规计算机无法计算的程序。
然而,这并不全是好消息,我们需要从一个免责声明开始。尽管 RNNs 具有很好的理论特性,但我们现在知道它们在实际应用中有一定的局限性。这些局限性大多已经被一种更新的神经网络(NN)架构——transformer克服,我们将在第七章中讨论它。从理论上讲,transformer 相比 RNNs 有更多的限制。但有时候,实践证明它表现得更好。尽管如此,我相信本章对你仍然是有益的。一方面,RNNs 具有优雅的架构,仍然代表着神经网络中的重要一类;另一方面,本章和接下来的三章所呈现的知识进展,将与这些主题在实际研究中的进展紧密相符。因此,你将在接下来的几章中也能应用这里学到的概念。本章还将帮助你充分理解新模型的优势。
本章将涵盖以下主题:
-
自然语言处理
-
介绍 RNNs
技术要求
我们将在本章中使用 Python、PyTorch 和 TorchText 包(github.com/pytorch/text
)来实现示例。如果你没有配置这些工具的环境,不必担心——该示例可以在 Google Colab 上的 Jupyter Notebook 中运行。你可以在本书的 GitHub 仓库中找到代码示例:github.com/PacktPublishing/Python-Deep-Learning-Third-Edition/tree/main/Chapter06
。
自然语言处理
NLP 是机器学习的一个子领域,使计算机能够解释、操作和理解人类语言。这个定义听起来有点枯燥,因此,为了提供一些清晰度,让我们从一个非详尽的任务列表开始,看看都有哪些任务属于 NLP 的范畴:
-
文本分类:这会为整个输入文本分配一个标签。例如,情感分析可以判断一篇产品评论是积极的还是消极的。
-
标记分类:这为每个输入文本的标记分配一个标签。标记是文本的构建块(或单位)。单词可以是标记。一个流行的标记分类任务是命名实体识别,它为每个标记分配一个预定义类别列表,如地点、公司或人物。词性(POS)标注为每个单词分配一个特定的词性,如名词、动词或形容词。
-
文本生成:这是利用输入文本生成具有任意长度的新文本。文本生成任务包括机器翻译、问答和文本摘要(在保留原文精髓的同时创建简短版本)。
解决自然语言处理(NLP)问题并非易事。为了理解其原因,我们先回顾一下计算机视觉(第四章),其中输入的图像以像素强度的二维张量表示,具有以下特点:
-
图像由像素构成,并且没有其他显式定义的结构
-
像素基于彼此的接近度,形成了隐式的更大物体的层次结构
-
只有一种类型的像素,其仅由标量强度来定义
由于其同质化的结构,我们可以将(几乎)原始的图像输入到卷积神经网络(CNN)中,让它以相对较少的数据预处理做出处理。
现在,让我们回到文本数据,它具有以下特点:
-
有不同类型的字符,具有不同的语义意义,如字母、数字和标点符号。此外,我们还可能遇到以前未见过的符号。
-
自然文本有着显式的层次结构,包括字符、单词、句子和段落。我们还有引号、标题和层次结构的标题。
-
文本的某些部分可能与序列中较远的部分有关,而不是它们的直接上下文。例如,一篇虚构故事可能会先介绍一个人名,但随后只用他或她来提及。这些指代可能被长篇文本序列分隔开,但我们仍然需要能够找到这种关系。
自然文本的复杂性要求在实际神经网络模型发挥作用之前,进行几步预处理。第一步是归一化,包括去除多余的空白字符和将所有字母转换为小写。接下来的步骤并不像前面那样简单,因此我们将专门用接下来的两节来讨论这些步骤。
分词
一种直观的处理自然语言处理任务的方法是将语料库拆分为单词,这些单词将代表我们模型的基本输入单元。然而,使用单词作为输入并不是固定不变的,我们还可以使用其他元素,比如单个字符、短语,甚至整个句子。这些单元的通用术语是标记。标记指代文本语料库的方式,就像像素指代图像一样。将语料库拆分成标记的过程被称为标记化(真是意外!)。实体
(例如,执行这种标记化的算法)称为标记器。
注意
我们将在本节中讨论的标记器是通用的,意味着它们可以与不同的自然语言处理机器学习算法配合使用。因此,本节中的预处理算法通常用于变换器模型,我们将在第七章中介绍这些模型。
接下来,让我们讨论几种标记器的类型:
-
基于词:每个单词代表一个独特的标记。这是最直观的标记化方式,但也有严重的缺点。例如,单词don’t和do not将被表示为不同的标记,但它们的含义是相同的。另一个例子是单词car和cars,或ready和readily,它们会被表示为不同的标记,而一个单一的标记会更合适。由于自然语言如此多样,像这样的特殊情况非常多。问题不仅仅在于语义相似的单词会有不同的标记,还在于由此产生的大量唯一标记。这会导致模型计算效率低下。它还会产生许多出现次数较少的标记,这对模型的学习来说是一个挑战。最后,我们可能会遇到在新文本语料库中无法识别的单词。
-
基于字符:文本中的每个字符(字母、数字、标点符号等)都是一个独特的标记。通过这种方式,我们可以减少标记数量,因为字符的总数是有限的并且是有限的。由于我们事先知道所有的字符,因此不会遇到未知的符号。
然而,与基于词的模型相比,这种标记化方法不太直观,因为由字符组成的上下文比基于词的上下文意义较小。虽然唯一标记的数量相对较少,但语料库中的标记总数将非常庞大(等于字符总数)。
-
子词标记化:这是一个两步过程,首先将语料库分割成单词。分割文本最明显的方式是通过空格。此外,我们还可以通过空格和标点符号来分割文本。在自然语言处理术语中,这一步骤被称为预标记化。
(前缀意味着接下来会进行标记化)。然后,它保留常用词,并将稀有词拆解为更频繁的有意义子词。例如,我们可以将单词tokenization分解为核心词token和后缀ization,每个部分都有自己的标记。然后,当我们遇到carbonization这个词时,我们可以将其分解为carbon和ization。这样,我们会得到两个ization的实例,而不是一个tokenization和一个carbonization。子词标记化还使得可以将未知词分解为已知标记。
特殊服务标记。
为了使标记化的概念起作用,它引入了一些服务性标记。以下是一些服务性标记:
-
UNK:替换语料库中的未知标记(可以理解为稀有词汇,如字母数字标识符)。
-
EOS:句子(或序列)结束标记。
-
BOS:句子(或序列)开始标记。
-
SEP:用来分隔两个语义上不同的文本序列,例如问题和答案。
-
PAD:这是一个填充标记,它会附加到现有序列中,以便它可以达到某个预定义长度并适应固定长度的小批次。
例如,我们可以将句子I bought a product called FD543C标记化为BOS I bought a product called UNK EOS PAD PAD,以适应长度为 10 的固定输入。
子词标记化是最流行的标记化方式,因为它结合了基于字符(较小的词汇量)和基于词语(有意义的上下文)标记化的最佳特性。在接下来的几个部分中,我们将讨论一些最流行的子词标记器。
字节对编码和 WordPiece。
字节对编码(BPE,使用子词单元进行稀有词的神经机器翻译,arxiv.org/abs/1508.07909
)是一种流行的子词标记化算法。与其他此类标记器一样,它从预标记化开始,将语料库拆分为单词。
以这个数据集为起点,BPE 的工作方式如下:
-
从初始的基础(或种子)词汇开始,该词汇由文本语料库中所有单词的单个字符组成。因此,每个单词都是一系列单字符标记。
-
重复以下步骤,直到标记词汇的大小达到某个最大阈值:
-
找出最常一起出现的一对标记(最初这些是单个字符),并将它们合并成一个新的复合标记。
-
使用新的复合标记扩展现有的标记词汇。
-
使用新的标记结构更新标记化的文本语料库。
-
为了理解 BPE,让我们假设我们的语料库包含以下(虚构的)单词:{dab: 5, adab: 4, aab: 7, bub: 9, bun: 2}
。每个单词后面的数字表示该单词在文本中出现的次数。以下是相同的语料库,但已经按符号(即字符)拆分:{(d, a, b): 5, (a, d, a, b): 4, (a, a, b): 7, (b, u, b): 9, (b, u, c): 2}
。基于此,我们可以构建我们的初始符号词汇表,每个符号的出现次数为:{b: 36, a: 27, u: 11, d: 9, c: 2}
。以下列表展示了前四次合并操作:
-
最常见的符号对是
(a, b)
,其出现次数为freq((a, b)) = 5 + 4 + 7 = 16
次。因此,我们将它们合并,语料库变为{(d,
): 5, (a, d,
): 4, (a,
): 7, (b, u, b): 9, (b, u, c): 2}
。新的符号词汇表是{b: 20,
: 16, a: 11, u: 11, d: 9,
c: 2}
。 -
新的最常见的符号对是
(b, u)
,其freq((b, u)) = 9 + 2 = 11
次出现。接着,我们将它们合并为一个新的符号:{(d, ab): 5, (a, d, ab): 4, (a, ab): 7, (``, b): 9, (``, c): 2}
。更新后的符号词汇表是{ab: 16, a: 11,
: 11, b: 9, d: 9,
c: 2}
。 -
下一个符号对是
(d, ab)
,其出现次数为freq((d, ab)) = 5 + 4 = 9
次。合并后,符号化的语料库变为{(``): 5, (a,
): 4, (a, ab): 7, (bu, b): 9, (bu, c): 2}
。新的符号词汇表是{a: 11, bu: 11, b: 9,
: 9, ab: 7,
c: 2}
。 -
新的符号对是
(bu, b)
,其出现次数为 9 次。将它们合并后,语料库变为{(dab): 5, (a, dab): 4, (a, ab): 7, (``): 9, (bu, c): 2}
,而符号词汇表变为
{a: 11,
: 9,
: 9, ab: 7, bu: 2,
c: 2}
。
BPE 会存储所有符号合并规则及其顺序,而不仅仅是最终的符号词汇表。在模型推理过程中,它会按照相同的顺序将规则应用于新的未知文本,以对其进行符号化。
词尾符号
原始 BPE 实现会在每个单词的末尾添加一个特殊的词尾符号<w/>
,例如,单词aab
变为aab<w/>
。其他实现可以将该特殊符号放在单词的开头,而不是末尾。这使得算法能够区分,例如,单词ca<w/>
中的符号ab
,与a``<w/>
中的相同符号。因此,算法可以从符号化后的语料库恢复出原始语料库(去符号化),否则是无法做到的。本节中,为了简洁起见,我们省略了词尾符号。
让我们回顾一下,我们的基础词汇表包括文本语料库中的所有字符。如果这些是 Unicode 字符(这是通常的情况),我们最终可能会得到一个最多包含 150,000 个词汇的词汇表。而且这还只是我们开始词汇合并过程之前的情况。解决这个问题的一个技巧是借助 字节级 BPE。每个 Unicode 字符可以使用多个(最多 4 个)字节进行编码。字节级 BPE 最初将语料库拆分为字节序列,而不是完整的 Unicode 字符。如果一个字符使用 n 个字节编码,分词器将把它当作 n 个单字节词汇进行处理。通过这种方式,基础词汇表的大小将始终为 256(字节中可以存储的最大唯一值)。此外,字节级 BPE 保证我们不会遇到未知的词汇。
WordPiece (arxiv.org/abs/1609.08144
) 是另一种子词分词算法。它与 BPE 相似,但有一个主要区别。像 BPE 一样,它从单个字符的基础词汇表开始,然后将它们合并成新的复合词汇。然而,它根据一个得分来定义合并顺序,得分通过以下公式计算(与使用频繁共现的 BPE 不同):
通过这种方式,算法优先合并那些在语料库中出现频率较低的词对。让我们将这种方法与 BPE 进行比较,BPE 仅根据新词汇的潜在增益来合并词汇。相比之下,WordPiece 在增益(公式中的分子)和现有词汇的潜在损失(分母)之间进行平衡。这是有道理的,因为新词汇将取代旧的词对,而不是与它们并存。
内部词汇
WordPiece 为单词中的所有标记添加一个特殊的 ## 前缀,除了第一个。例如,它会将单词 aab 标记为 [a, ##a, ##b]
。标记合并会去掉标记之间的 ##。因此,当我们合并 ##a 和 ##b 时,aab 会变成 [``a, ##ab]
。
与 BPE 不同,WordPiece 只存储最终的标记词汇。当它对新词进行标记时,它会在词汇中找到最长的匹配子词,并在此处分割单词。例如,假设我们想用标记词汇 [a, ##b, ##c, ##d, ab, ##cd, ##bcd]
来分割单词 abcd。根据新规则,WordPiece 会首先选择最长的子词 bcd,然后将 abcd 标记为 [``a, ##bcd]
。
BPE 和 WordPiece 都是贪心算法——它们总是根据频率标准,确定性地合并标记。然而,使用不同的标记对相同的文本序列进行编码是可能的。这可能作为潜在 NLP 算法的正则化方法。接下来,我们将介绍一种利用这一点的标记化技术。
Unigram
与 BPE 和 WordPiece 不同,Unigram(子词正则化:通过多个子词候选改进神经网络翻译模型,arxiv.org/abs/1804.10959
)算法从一个大词汇表开始,并逐步尝试将其缩减。初始词汇表是所有独特字符和语料库中最常见子串的并集。找到最常见子串的一种方法是使用 BPE。该算法假设每个标记,,是独立发生的(因此得名 Unigram)。基于这一假设,一个标记,
,
,的概率就是它出现的次数除以语料库其他部分的总大小。然后,长度为 M 的标记序列,
,的概率如下:
这里,V 是完整的标记词汇表。
假设我们有相同的令牌序列,X,并且有多个令牌分割候选项,,
对于该序列。最可能的分割候选项,x**,对于X*如下:
让我们通过一个例子来澄清这一点。我们假设我们的语料库包含一些(假想的)单词,{dab: 5, aab: 7, bun: 4}
,其中数字表示该单词在文本中的出现次数。我们的初始令牌词汇是所有唯一字符和所有可能子字符串的并集(数字表示频率):{a: 19, b: 16, ab: 12, aa: 7, da: 5, d: 5, bu: 4, un: 4}
。所有令牌频率的总和为 19 + 16 + 12 + 7 + 5 + 5 + 4 + 4 = 72。然后,每个令牌的独立概率为 – 例如,
,
,等等。
我们扩展的词汇表使我们能够以多种方式对每个序列(为了简化起见,我们将重点放在单词上)进行分词。例如,我们可以将dab表示为{d, a, b}
、{da, b}
或{d, ab}
。在这里,每个候选项的概率为 P({d, a, b}) = P(d) * P(a) * P(b) = 0.07 * 0.264 * 0.222 = 0.0041;;
。
概率最高的候选项是*x** = {da, b}
。
基于此,以下是单元字(token)分词法的逐步实现过程:
-
从初始的大型基础词汇表V开始。
-
重复以下步骤,直到|V|的大小达到某个最小阈值:
-
使用维特比算法(
en.wikipedia.org/wiki/Viterbi_algorithm
),找到语料库中所有单词的l最佳分词候选项x**。使用此算法是必要的,因为这是一项计算密集型任务。选择l个候选项,而不是一个,使得可以在相同文本上采样不同的词元序列。你可以将这看作是对输入数据的一种数据增强技术,它为 NLP 算法提供了额外的正则化。一旦我们以这种方式得到了一个分词后的语料库,就可以利用期望最大化算法(en.wikipedia.org/wiki/Expectation%E2%80%93maximization_algorithm
)估计当前词汇表V*中所有词元的概率。
-
对于每个标记,
,计算一个特殊的损失函数,
,它确定如果我们从标记词汇中移除
,语料库的概率如何减少。
-
按照它们的
排序,并只保留前n%的标记(例如,n = 80)。始终保留个别字符,以避免未知标记。
-
这就结束了我们对分词的介绍。这些技术中的一些是在 Transformer 架构出现时发展起来的,我们将在接下来的章节中充分利用它们。但现在,让我们集中讨论 NLP 管道中的另一项基础技术。
引入词嵌入
现在我们已经学会了如何对文本语料库进行分词,我们可以继续 NLP 数据处理管道中的下一步。为了简便起见,我们假设我们已将语料库分割成单词,而不是子词或字符(在本节中,单词和标记是可以互换的)。
将序列中的词作为输入传递给 NLP 算法的一种方法是使用独热编码。我们的输入向量的大小将与词汇中标记的数量相同,每个标记将具有唯一的独热编码表示。然而,这种方法有一些缺点,如下所示:
-
稀疏输入:独热编码表示大多数值为零,只有一个非零值。如果我们的 NLP 算法是神经网络(而且确实如此),这种类型的输入每个词只会激活其权重的一小部分。因此,我们需要一个大规模的训练集,以包含每个词汇中足够数量的训练样本。
-
计算强度:词汇的庞大规模将导致输入张量很大,这需要更大的神经网络和更多的计算资源。
-
不切实际:每次我们向词汇表中添加一个新单词时,词汇表的大小会增加。然而,独热编码的输入大小也会增加。因此,我们必须改变神经网络的结构以适应新的大小,并且需要进行额外的训练。
-
缺乏上下文:像dog和wolf这样的单词在语义上是相似的,但独热编码表示无法传达这种相似性。
在本节中,我们将通过低维分布式表示法来解决这些问题,这种表示被称为词嵌入(神经概率语言模型,www.jmlr.org/papers/volume3/bengio03a/bengio03a.pdf
)。分布式表示是通过学习一个嵌入函数来创建的,该函数将独热编码的单词转化为低维的词嵌入空间,具体如下:
图 6.1 – 词汇 -> 独热编码 -> 词嵌入向量
从词汇表中,大小为V的单词被转化为大小为V的独热编码向量。然后,嵌入函数将这个V维空间转化为一个固定大小的分布式表示(向量),D(这里,D=4)。这个向量作为输入传递给 NLP 算法。我们可以看到,固定且较小的向量大小解决了我们刚才提到的稀疏性、计算强度和不切实际的问题。接下来,我们将看到它是如何解决上下文问题的。
嵌入函数学习关于单词的语义信息。它将词汇表中的每个单词映射到一个连续值向量表示——即词嵌入。每个单词在这个嵌入空间中对应一个点,不同的维度对应这些单词的语法或语义属性。嵌入空间的概念类似于潜在空间表示,我们在第五章中首次讨论了这一点,涉及到扩散模型。
目标是确保在嵌入空间中彼此接近的词语具有相似的含义。这里所说的接近是指它们的嵌入向量的点积(相似度)值较高。通过这种方式,某些词语在语义上相似的信息可以被机器学习算法利用。例如,它可能会学到fox和cat在语义上是相关的,并且the quick brown fox和the quick brown cat都是有效的短语。然后,一个词语序列可以被一组嵌入向量所替代,这些向量捕捉了这些词语的特征。我们可以将这个序列作为各种自然语言处理(NLP)任务的基础。例如,试图对文章情感进行分类的分类器,可能会基于之前学到的词嵌入进行训练,而不是使用独热编码向量。通过这种方式,词语的语义信息可以轻松地为情感分类器所用。
独热表示与嵌入向量之间的映射
假设我们已经计算出了每个词元的嵌入向量。一种实现一热编码表示与实际嵌入向量之间映射的方法是借助一个V×D形状的矩阵,。我们可以把矩阵的行看作查找表,其中每一行代表一个词的嵌入向量。这个过程之所以可行,是因为输入的词是经过一热编码的,这个向量中除了对应词的索引位置是 1 外,其它位置全为 0。因此,输入的词,
,将仅激活其对应的唯一行(向量)权重,
,位于
中。因此,对于每一个输入样本(词),只有该词的嵌入向量会参与计算。我们还可以把
看作一个全连接(FC)神经网络层的权重矩阵。通过这种方式,我们可以将嵌入(明白了吗?)作为神经网络的第一层 —— 即,神经网络将一热编码的词元作为输入,嵌入层将其转换为一个向量。然后,神经网络的其余部分使用嵌入向量而不是一热编码表示。这是所有深度学习库中常见的标准实现。
词嵌入的概念最早是在 20 多年前提出的,但至今仍是自然语言处理领域的核心范式之一。大型语言模型(LLMs),例如 ChatGPT,使用的是改进版的词嵌入,我们将在第七章中讨论。
现在我们已经熟悉了嵌入向量,我们将继续进行获取和计算嵌入向量的算法。
Word2Vec
很多研究都致力于创建更好的词嵌入模型,特别是通过省略对单词序列的概率函数学习来实现。其中一种最流行的方法是Word2Vec (papers.nips.cc/paper/5021-distributed-representations-of-words-and-phrases-and-their-compositionality.pdf
, https://arxiv.org/abs/1301.3781, 和 https://arxiv.org/abs/1310.4546)。它基于目标词的上下文(周围单词)创建嵌入向量。更具体地说,上下文是目标词前后的n个单词。下图展示了上下文窗口在文本中滑动,围绕不同的目标词:
图 6.2 – 一个 Word2Vec 滑动上下文窗口,n=2。相同类型的上下文窗口适用于 CBOW 和 skip-gram
Word2Vec 有两种版本:连续词袋模型 (CBOW) 和 skip-gram。我们将从 CBOW 开始,然后继续讨论 skip-gram。
CBOW
CBOW 根据上下文(周围单词)预测最可能的词。例如,给定序列 the quick _____ fox jumps,模型将预测 brown。它对上下文窗口内的所有单词赋予相等的权重,并且不考虑它们的顺序(因此名字中有“bag”)。我们可以借助以下简单的神经网络进行训练,该网络包含输入层、隐藏层和输出层:
图 6.3 – 一个 CBOW 模型神经网络
下面是模型的工作方式:
-
输入是一个独热编码的单词表示(其长度等于词汇表大小,V)。
-
嵌入向量由输入到隐藏矩阵表示,
。
-
所有上下文单词的嵌入向量被平均以产生隐藏网络层的输出(没有激活函数)。
-
隐藏层激活值作为输入传递给Softmax输出层,大小为V(与隐藏到输出的权重矩阵,
),用于预测最可能出现在输入词汇上下文(邻近)中的词汇。具有最高激活值的索引表示最相关的单词,采用独热编码表示。
我们将使用梯度下降和反向传播训练神经网络。训练集包含的是(上下文和标签)一对一的独热编码单词对,这些单词在文本中彼此接近。例如,如果文本的一部分是 [the, quick, brown, fox, jumps]
且 n=2,训练元组将包括 ([quick, brown], the)
,([the, brown, fox], quick)
,([the, quick, fox jumps], brown)
等等。由于我们只关心词嵌入,,我们将在训练完成后丢弃输出神经网络的权重,
。
CBOW 会告诉我们在给定上下文中最可能出现的单词。这对于稀有词可能是一个问题。例如,给定上下文 今天的天气真是 _____, 模型会预测单词 beautiful 而不是 fabulous(嘿,这只是个例子)。CBOW 的训练速度是 skip-gram 的几倍,而且对于常见单词的准确度稍好。
Skip-gram
Skip-gram 模型可以预测给定输入单词的上下文(与 CBOW 相反)。例如,单词 brown 会预测单词 The quick fox jumps。与 CBOW 不同,输入是单一的独热编码单词向量。但如何在输出中表示上下文单词呢?Skip-gram 不试图同时预测整个上下文(所有周围单词),而是将上下文转化为多个训练对,例如 (fox, the)
,(fox, quick)
,(fox, brown)
和 (fox, jumps)
。再次强调,我们可以用一个简单的单层神经网络训练该模型:
图 6.4 – 一个 Skip-gram 模型神经网络
与 CBOW 一样,输出是一个 softmax,表示独热编码的最可能上下文单词。输入到隐藏层的权重,,表示词嵌入查找表,隐藏到输出的权重,
,仅在训练过程中相关。隐藏层没有激活函数(即,它使用线性激活)。
我们将使用反向传播训练模型(这里没有惊讶的地方)。给定一系列单词,,skip-gram 模型的目标是最大化平均对数概率,其中n是窗口大小:
该模型定义了概率,,如以下 softmax 公式所示:
在这个例子中, 和
是输入和输出单词,而v和v' 是输入和输出权重矩阵中的相应词向量,
和
,分别表示(我们保留了论文中的原始符号)。由于神经网络没有隐藏激活函数,其对于一对输入/输出单词的输出值仅仅是输入词向量
和输出词向量
(因此需要进行转置操作)。
Word2Vec 论文的作者指出,词表示无法表示那些不是由单个词组成的习语。例如,New York Times 是一家报纸,而不仅仅是 New、York 和 Times 各自含义的自然组合。为了解决这个问题,模型可以扩展到包括完整的短语。然而,这会显著增加词汇表的大小。而且,正如我们从前面的公式中看到的,softmax 的分母需要计算所有词汇的输出向量。此外, 矩阵的每个权重都在每一步训练时被更新,这也减慢了训练速度。
为了解决这个问题,我们可以用所谓的 (fox, brown)
替代 softmax 操作,并添加 k 个额外的负样本对(例如,(fox, puzzle)
),其中 k 通常在 [5,20] 的范围内。我们不再预测最符合输入词的单词(softmax),而是直接预测当前的词对是否为真实的。实际上,我们将多项分类问题(从多个类别中选择一个)转化为二元逻辑回归(或二分类)问题。通过学习正负词对的区别,分类器最终会以与多项分类相同的方式学习词向量。在 Word2Vec 中,负样本词是从一个特殊分布中抽取的,该分布比频繁的词更常抽取不常见的词。
与稀有词相比,一些最常出现的词携带的信息量较少。此类词的例子包括定冠词和不定冠词 a、an 和 the。与 the 和 city 相比,模型从观察 London 和 city 的搭配中获益更多,因为几乎所有的词都与 the 经常同时出现。反之亦然——在大量例子上训练后,频繁词的向量表示不会发生显著变化。为了应对稀有词和频繁词之间的不平衡,论文的作者提出了一种子采样方法,其中训练集中的每个词,,会以某个概率被丢弃,这个概率通过启发式公式计算得出。
是单词
的频率,t是一个阈值(通常约为
):
它会积极地对频率大于t的单词进行子采样,同时保持频率的排名。
我们可以说,一般而言,跳字模型(skip-gram)在稀有词上的表现比 CBOW 更好,但训练时间更长。
现在我们已经了解了嵌入向量,让我们学习如何可视化它们。
可视化嵌入向量
一个成功的词嵌入函数将语义相似的单词映射到嵌入空间中具有高点积相似度的向量。为了说明这一点,我们将实现以下步骤:
-
在
text8
数据集上训练 Word2Vec 跳字模型,该数据集包含来自维基百科的前 1 亿字节的纯文本(mattmahoney.net/dc/textdata.html
)。每个嵌入向量是 100 维的,这是该类型模型的默认值。 -
选择一个种子词列表。在此案例中,词语包括mother、car、tree、science、building、elephant和green。
-
计算每个种子词的 Word2Vec 嵌入向量与词汇表中所有其他单词嵌入向量之间的点积相似度。然后,为每个种子词选择一组前k(在我们的例子中,k=5)个最相似的单词(基于它们的点积相似度)。
-
在二维图中可视化种子嵌入与其相似词汇聚类嵌入之间的相似性。由于嵌入是 100 维的,我们将使用 t-SNE(
en.wikipedia.org/wiki/T-distributed_stochastic_neighbor_embedding
)降维算法。它将每个高维嵌入向量映射到二维或三维点,方法是将相似的对象建模为邻近点,而将不相似的对象建模为距离较远的点,且这种建模方式具有较高的概率。我们可以在下面的散点图中看到结果:
图 6.5 – t-SNE 可视化种子词及其最相似词汇的聚类
该图证明了所获得的词向量包含了与单词相关的信息。
Word2Vec(和类似的模型)创建静态(或上下文无关)嵌入。每个单词都有一个单一的嵌入向量,基于该单词在文本语料库中的所有出现(即所有上下文)。这带来了一些局限性。例如,bank在不同的上下文中有不同的含义,如river bank(河岸)、savings bank(储蓄银行)和bank holiday(银行假日)。尽管如此,它还是通过单一的嵌入进行表示。此外,静态嵌入没有考虑上下文中的单词顺序。例如,表达式I like apples, but I don’t like oranges(我喜欢苹果,但我不喜欢橙子)和I like oranges, but I don’t like apples(我喜欢橙子,但我不喜欢苹果)具有相反的含义,但 Word2Vec 将它们视为相同的句子。我们可以通过所谓的动态(上下文相关)嵌入来解决这些问题,后者将在第七章中讨论。
到目前为止,我们一直专注于单个单词(或标记)。接下来,我们将扩展我们的研究范围,探索文本序列。
语言模型
基于词的语言模型(LM)定义了一个词汇序列的概率分布。对于本节内容,我们假设这些词元是单词。给定一个长度为m(例如,一个句子)的单词序列,语言模型会为该序列分配一个概率,,表示这个完整的单词序列可能存在。这些概率的一种应用是生成模型,用于创建新文本——基于词的语言模型可以计算出下一个单词的可能性,前提是已知前面的单词序列。一旦我们得到了这个新单词,就可以将它添加到现有序列中,接着预测下一个新单词,依此类推。通过这种方式,我们可以生成任意长度的新文本序列。例如,给定序列the quick brown,语言模型可能会预测fox作为下一个最可能的单词。然后,序列变成the quick brown fox,我们再次让语言模型基于更新后的序列预测新的最可能单词。输出依赖于先前值以及其随机性(即带有一定随机性)的输出(新值)的模型,被称为自回归模型。
接下来,我们将重点关注词序列的属性,而不是模型本身。
注意
即使是最先进的 LLM,例如 ChatGPT,也是自回归模型——它们每次只预测下一个单词。
理解 N-gram 模型
推断长序列的概率,例如,通常是不可行的。为了理解原因,我们可以注意到,利用联合概率链式法则可以计算出
。
给定前面的单词,后面的单词的概率尤其难以从数据中估计。这就是为什么这种联合概率通常通过独立性假设来近似,假设第 i 个单词仅依赖于前面 n-1 个单词。我们只会对 n 个连续单词的组合进行联合概率建模,这些组合称为 n-grams。例如,在短语 the quick brown fox 中,我们有以下 n-grams:
-
1-gram (unigram):the、quick、brown 和 fox(这就是 unigram 分词法的来源)
-
2-gram (bigram):the quick、quick brown 和 brown fox
-
3-gram (trigram):the quick brown 和 quick brown fox
-
4-gram:the quick brown fox
注意
n-gram 术语可以指其他长度为 n 的序列类型,例如 n 个字符。
联合分布的推断通过 n-gram 模型来逼近,该模型将联合分布分割成多个独立部分。如果我们有大量的文本语料库,可以找到所有 n-gram,直到某个 n(通常为 2 到 4),并统计每个 n-gram 在该语料库中的出现次数。通过这些计数,我们可以估算每个 n-gram 最后一个单词的概率,前提是给定前 n-1 个单词:
-
Unigram:
-
Bigram:
-
n-gram:
假设独立性,即第 i 个单词只依赖于前 n-1 个单词,现在可以用来逼近联合分布。
例如,我们可以通过以下公式近似单元语法的联合分布:
对于三元组,我们可以通过以下公式近似联合分布:
我们可以看到,基于词汇表的大小,n-gram 的数量随着n的增加呈指数增长。例如,如果一个小型词汇表包含 100 个词,那么可能的 5-gram 数量将是 种不同的 5-gram。相比之下,莎士比亚的全部作品包含大约 30,000 个不同的单词,这说明使用大n的n-gram 是不可行的。不仅存在存储所有概率的问题,而且我们还需要一个非常大的文本语料库,才能为更大的n值创建合理的n-gram 概率估计。
高维诅咒
当可能的输入变量(单词)数量增加时,这些输入值的不同组合数量会呈指数增长。这个问题被称为维度灾难。当学习算法需要每个相关值组合至少一个例子时,就会出现这种情况,这正是n-gram 建模中所面临的问题。我们的n越大,就能越好地逼近原始分布,但我们需要更多的数据才能对n-gram 概率进行良好的估计。
但不用担心,因为n-gram 语言模型给了我们一些重要线索,帮助我们继续前进。它的理论公式是可靠的,但维度灾难使其不可行。此外,n-gram 模型强调了单词上下文的重要性,就像 Word2Vec 一样。在接下来的几节中,我们将学习如何借助神经网络模拟n-gram 模型的概率分布。
介绍 RNN
RNN 是一种可以处理具有可变长度的顺序数据的神经网络。此类数据的例子包括文本序列或某股票在不同时间点的价格。通过使用顺序一词,我们意味着序列元素彼此相关,且它们的顺序很重要。例如,如果我们把一本书中的所有单词随机打乱,文本将失去意义,尽管我们仍然能够知道每个单独的单词。
RNN 得名于它对序列应用相同函数的递归方式。我们可以将 RNN 定义为递归关系:
在这里,f 是一个可微分的函数, 是称为内部 RNN 状态的值向量(在步骤 t 处),而
是步骤 t 处的网络输入。与常规的神经网络不同,常规神经网络的状态只依赖于当前的输入(和 RNN 权重),而在这里,
是当前输入以及先前状态
的函数。你可以把
看作是 RNN 对所有先前输入的总结。递归关系定义了状态如何在序列中一步一步地演变,通过对先前状态的反馈循环,如下图所示:
图 6.6 – 展开的 RNN
左侧展示了 RNN 递归关系的可视化示意图,右侧展示了 RNN 状态在序列 t-1、t、t+1 上的递归展开。
RNN 有三组参数(或权重),这些参数在所有步骤之间共享:
-
U:将输入,
,转换为状态,
-
W:将前一个状态,
,转化为当前状态,
-
V:将新计算出的内部状态,
,映射到输出,
U、V 和 W 对各自的输入应用线性变换。最基本的这种变换就是我们熟知并喜爱的全连接(FC)操作(因此,U、V 和 W 是权重矩阵)。我们现在可以定义内部状态和 RNN 输出如下:
这里,f 是非线性激活函数(如 tanh、sigmoid 或 ReLU)。
例如,在一个基于单词的语言模型(LM)中,输入 x 将是一个词嵌入向量的序列 ()。
状态 s 将是一个状态向量的序列 ()。最后,输出 y 将是下一个单词序列的概率向量 (
)。
请注意,在一个递归神经网络(RNN)中,每个状态依赖于通过递归关系的所有先前计算。这个重要的含义是,RNN 能够随着时间记忆,因为状态 包含基于之前步骤的信息。从理论上讲,RNN 可以记住信息很长一段时间,但实际上它们只能回溯几步。我们将在 消失和爆炸 梯度 部分详细讨论这个问题。
我们描述的 RNN 在某种程度上等同于一个单层常规神经网络(NN),但带有额外的递归关系。和常规神经网络一样,我们可以堆叠多个 RNN 来形成 堆叠 RNN。在时间 t 时,RNN 单元在第 l 层的单元状态 将接收来自第 l-1 层的 RNN 单元的输出
以及该层相同层级 l 的先前单元状态
。
在下图中,我们可以看到展开的堆叠 RNN:
图 6.7 – 堆叠 RNN
因为 RNN 不仅限于处理固定大小的输入,它们扩展了我们可以通过神经网络(NNs)计算的可能性。根据输入和输出大小之间的关系,我们可以识别几种类型的任务:
-
一对一:非顺序处理,例如前馈神经网络(NNs)和卷积神经网络(CNNs)。前馈神经网络和将 RNN 应用于单个时间步之间没有太大区别。一对一处理的一个例子是图像分类。
-
一对多:基于单一输入生成一个序列——例如,从图像生成标题(展示与讲解:神经图像标题生成器,
arxiv.org/abs/1411.4555
)。 -
多对一:根据一个序列输出一个结果——例如,文本的情感分类。
-
多对多间接:一个序列被编码成一个状态向量,之后该状态向量被解码成一个新的序列——例如,语言翻译(使用 RNN 编码器-解码器学习短语表示用于统计机器翻译,
arxiv.org/abs/1406.1078
和 序列到序列学习与神经网络,papers.nips.cc/paper/5346-sequence-to-sequence-learning-with-neural-networks.pdf
)。 -
多对多直接:为每个输入步骤输出一个结果——例如,语音识别中的帧音素标注。
注意
多对多模型通常被称为 序列到序列 (seq2seq) 模型。
以下是前述输入输出组合的图示表示:
图 6.8 – RNN 输入输出组合,灵感来源于 karpathy.github.io/2015/05/21/rnn-effectiveness/
既然我们已经介绍了 RNN,现在让我们通过实现一个简单的 RNN 示例来加深对其的理解。
RNN 实现与训练
在上一节中,我们简要讨论了 RNN 是什么以及它们可以解决哪些问题。接下来,让我们深入了解 RNN 的细节,并通过一个非常简单的玩具示例进行训练:计算序列中的 1 的数量。
我们将教一个基本的 RNN 如何计算输入中 1 的数量,并在序列结束时输出结果。这是一个多对一关系的例子,正如我们在上一节中定义的那样。
我们将使用 Python(不使用深度学习库)和 numpy 实现这个例子。以下是输入和输出的示例:
In: (0, 0, 0, 0, 1, 0, 1, 0, 1, 0)
Out: 3
我们将使用的 RNN 如下图所示:
图 6.9 – 用于计算输入中 1 的基本 RNN
注意
由于 ,
,U,W 和 y 都是标量值(x 仍然是一个向量),所以在 RNN 的实现和训练部分及其子部分中,我们不会使用矩阵表示法(粗体大写字母)。我们将使用斜体表示法。在代码部分,我们将它们表示为变量。然而,值得注意的是,这些公式的通用版本使用的是矩阵和向量参数。
RNN 只会有两个参数:一个输入权重 U 和一个递归权重 W。输出权重 V 设置为 1,这样我们只需将最后的状态作为输出 y。
首先,让我们添加一些代码,以便我们的例子可以执行。我们将导入 numpy 并定义我们的训练集——输入 x 和标签 y。x 是二维的,因为第一维代表了小批量中的样本。y 是一个单一的数值(它仍然有一个批量维度)。为了简单起见,我们将使用一个只有单一样本的小批量:
import numpy as np
# The first dimension represents the mini-batch
x = np.array([[0, 0, 0, 0, 1, 0, 1, 0, 1, 0]])
y = np.array([3])
由该 RNN 定义的递归关系是 。请注意,这是一个线性模型,因为我们没有在这个公式中应用非线性函数。我们可以通过以下方式实现递归关系:
def step(s_t, x_t, U, W):
return x_t * U + s_t * W
状态 s_t
和权重 W
和 U
是单一的标量值。x_t
表示输入序列中的单个元素(在我们的例子中,是 0 或 1)。
注意
解决这个任务的一个方法是直接获取输入序列中元素的和。如果我们设置 U=1
,那么每当输入被接收时,我们将得到它的完整值。如果我们设置 W=1
,那么我们所累积的值将不会衰减。因此,对于这个例子,我们将得到期望的输出:3。然而,让我们用这个简单的例子来解释 RNN 的训练和实现。接下来的部分将会很有趣,我们将看到这些内容。
我们可以将 RNN 视为一种特殊类型的常规神经网络,通过时间展开它,进行一定数量的时间步(如前面的图所示)。这个常规神经网络的隐藏层数量等于输入序列元素的大小。换句话说,一个隐藏层代表时间中的一步。唯一的区别是每一层有多个输入:前一个状态,,和当前输入,
。参数U和W在所有隐藏层之间共享。
前向传播沿序列展开 RNN,并为每个步骤构建状态堆栈。在下面的代码块中,我们可以看到前向传播的实现,它返回每个递归步骤和批次中每个样本的激活值s:
def forward(x, U, W):
# Number of samples in the mini-batch
number_of_samples = len(x)
# Length of each sample
sequence_length = len(x[0])
# Initialize the state activation for each sample along the sequence
s = np.zeros((number_of_samples, sequence_length + 1))
# Update the states over the sequence
for t in range(0, sequence_length):
s[:, t + 1] = step(s[:, t], x[:, t], U, W) # step function
return s
现在我们有了 RNN 的前向传播,我们来看一下如何训练展开的 RNN。
时间反向传播
时间反向传播(BPTT)是我们用来训练 RNN 的典型算法(时间反向传播:它的作用与如何实现,axon.cs.byu.edu/~martinez/classes/678/Papers/Werbos_BPTT.pdf
)。顾名思义,它是我们在第二章中讨论的反向传播算法的一个改进版。
假设我们将使用均方误差(MSE)损失函数。现在我们也有了前向传播步骤的实现,我们可以定义梯度如何向后传播。由于展开的 RNN 等同于一个常规的前馈神经网络,我们可以使用在第二章中介绍的反向传播链式法则。
因为权重W和U在各层之间是共享的,所以我们将在每个递归步骤中累积误差导数,最后用累积的值来更新权重。
首先,我们需要获取输出的梯度,,关于损失函数 J,∂J/∂s。一旦我们获得了它,我们将通过在前向步骤中构建的活动堆栈向后传播。这个反向传播过程从堆栈中弹出活动,在每个时间步积累它们的误差导数。通过 RNN 传播这个梯度的递归关系可以写成如下(链式法则):
权重 U 和 W 的梯度将如下方式积累:
掌握了这些知识后,让我们实现反向传播:
-
将
U
和W
的梯度分别累积到gU
和gW
中:def backward(x, s, y, W): sequence_length = len(x[0]) # The network output is just the last activation of sequence s_t = s[:, -1] # Compute the gradient of the output w.r.t. MSE cost function at final state gS = 2 * (s_t - y) # Set the gradient accumulations to 0 gU, gW = 0, 0 # Accumulate gradients backwards for k in range(sequence_length, 0, -1): # Compute the parameter gradients and accumulate the results. gU += np.sum(gS * x[:, k - 1]) gW += np.sum(gS * s[:, k - 1]) # Compute the gradient at the output of the previous layer gS = gS * W return gU, gW
-
使用梯度下降法优化我们的 RNN。通过反向传播函数计算梯度(使用 MSE),并用这些梯度来更新权重值:
def train(x, y, epochs, learning_rate=0.0005): # Set initial parameters weights = (-2, 0) # (U, W) # Accumulate the losses and their respective weights losses, gradients_u, gradients_w = list(), list(), list() # Perform iterative gradient descent for i in range(epochs): # Perform forward and backward pass to get the gradients s = forward(x, weights[0], weights[1]) # Compute the loss loss = (y[0] - s[-1, -1]) ** 2 # Store the loss and weights values for later display losses.append(loss) gradients = backward(x, s, y, weights[1]) gradients_u.append(gradients[0]) gradients_w.append(gradients[1]) # Update each parameter `p` by p = p - (gradient * learning_rate). # `gp` is the gradient of parameter `p` weights = tuple((p - gp * learning_rate) for p, gp in zip(weights, gradients)) return np.array(losses), np.array(gradients_u), np.array(gradients_w)
-
运行 150 个训练周期:
losses, gradients_u, gradients_w = train(x, y, epochs=150)
-
最后,显示每个权重在训练过程中的损失函数和梯度。我们将通过
plot_training
函数来实现,虽然这个函数在此处未实现,但可以在 GitHub 上的完整示例中找到。plot_training
会生成如下图表:
图 6.10 – RNN 损失 – 实线 – 损失值;虚线 – 训练过程中权重的梯度
现在我们已经学习了时间反向传播,让我们来讨论熟悉的梯度消失和梯度爆炸问题是如何影响它的。
梯度消失和梯度爆炸
前面的示例有一个问题。为了说明这个问题,让我们用更长的序列运行训练过程:
x = np.array([[0, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 0]])
y = np.array([12])
losses, gradients_u, gradients_w = train(x, y, epochs=150)
plot_training(losses, gradients_u, gradients_w)
输出结果如下:
RuntimeWarning: overflow encountered in multiply
return x * U + s * W
RuntimeWarning: invalid value encountered in multiply
gU += np.sum(gS * x[:, k - 1])
RuntimeWarning: invalid value encountered in multiply
gW += np.sum(gS * s[:, k - 1])
出现这些警告的原因是最终的参数 U
和 W
会通过 plot_training
函数生成以下结果:
图 6.11 – 在梯度爆炸情景下的参数和损失函数
在初期的训练阶段,梯度会缓慢增加,类似于它们在较短序列中增加的方式。然而,当达到第 23 个 epoch 时(虽然确切的 epoch 并不重要),梯度变得非常大,以至于超出了浮点变量的表示范围,变成了 NaN(如图中的跳跃所示)。这个问题被称为梯度爆炸。我们可以在常规的前馈神经网络中遇到梯度爆炸问题,但在 RNN 中尤为显著。为了理解原因,回顾一下我们在时间反向传播部分定义的两个连续序列步骤的递归梯度传播链规则:
根据序列的长度,展开的 RNN 相比常规的神经网络可以更深。同时,RNN 的权重W在所有步骤中是共享的。因此,我们可以将这个公式推广,用来计算序列中两个非连续步骤之间的梯度。由于W是共享的,方程形成了一个几何级数:
在我们简单的线性 RNN 中,如果|W|>1(梯度爆炸),梯度会呈指数增长,其中W是一个标量权重——例如,50 个时间步长下,当W=1.5时,结果是 。如果|W|<1(梯度消失),梯度会呈指数衰减,例如,10 个时间步长下,当W=0.6时,结果是
。如果权重参数W是矩阵而不是标量,那么这种梯度爆炸或梯度消失现象与W的最大特征值ρ(也称为谱半径)有关。当ρ<1时,梯度消失;当ρ>1时,梯度爆炸。
我们在第3 章中首先提到的梯度消失问题,在 RNN 中还有另一个更微妙的影响:梯度随着步数的增加呈指数衰减,直到在较早的状态下变得极其小。实际上,它们被来自较晚时间步长的更大梯度所掩盖,导致 RNN 无法保留这些早期状态的历史。这个问题更难以察觉,因为训练仍然会继续进行,且神经网络仍会生成有效的输出(与梯度爆炸不同)。只是它无法学习长期依赖。
通过这些内容,我们已经熟悉了 RNN 的一些问题。这些知识对我们接下来的讨论非常有帮助,因为在下一节中,我们将讨论如何借助一种特殊的 RNN 单元来解决这些问题。
长短时记忆
Hochreiter 和 Schmidhuber 广泛研究了梯度消失和梯度爆炸的问题,并提出了一种解决方案,称为长短时记忆网络(LSTM – www.bioinf.jku.at/publications/older/2604.pdf
和 Learning to Forget: Continual Prediction with LSTM, https://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.55.5709&rep=rep1&type=pdf)。LSTM 由于特别设计的记忆单元,可以处理长期依赖问题。它们表现得如此出色,以至于目前大多数 RNN 在解决各种问题时的成功都归功于 LSTM 的使用。在本节中,我们将探索这个记忆单元是如何工作的,以及它如何解决梯度消失的问题。
以下是一个 LSTM 单元的示意图:
Figure 6.12 – LSTM 细胞(顶部);展开的 LSTM 细胞(底部)。灵感来源于 colah.github.io/posts/2015-08-Understanding-LSTMs/
LSTM 的关键思想是细胞状态,(除了隐藏的 RNN 状态,
),在没有外部干扰的情况下,信息只能显式写入或移除以保持状态恒定。细胞状态只能通过特定的门来修改,这些门是信息传递的一种方式。典型的 LSTM 由三个门组成:遗忘门,输入门和输出门。细胞状态、输入和输出都是向量,以便 LSTM 可以在每个时间步长保持不同信息块的组合。
LSTM 符号表示
,
,以及
是 LSTM 在时刻t的输入、细胞记忆状态和输出(或隐藏状态)向量。
是候选细胞状态向量(稍后会详细介绍)。输入
和前一时刻的细胞输出
通过一组全连接(FC)权重W和U分别与每个门和候选细胞向量相连接。
,
,以及
是 LSTM 细胞的遗忘门、输入门和输出门(这些门也使用向量表示)。
这些门由全连接(FC)层、sigmoid 激活函数和逐元素相乘(表示为 )组成。由于 sigmoid 函数的输出仅限于 0 到 1 之间,乘法操作只能减少通过门的值。我们按顺序讨论它们:
- 遗忘门,
:它决定我们是否要擦除现有单元状态的部分内容。它根据前一单元输出的加权向量和当前输入来做出决定,前一单元的输出是
,当前输入是
:
从前述公式可以看出,遗忘门对先前状态向量的每个元素应用逐元素 sigmoid 激活,<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">mml:msubmml:mrow<mml:mi mathvariant="bold">c</mml:mi></mml:mrow>mml:mrowmml:mit</mml:mi>mml:mo-</mml:mo>mml:mn1</mml:mn></mml:mrow></mml:msub></mml:math>:(注意圆点符号)。由于操作是逐元素的,因此该向量的值被压缩到[0,1]范围内。输出为 0 意味着特定的
单元块被完全清除,而输出为 1 则允许该单元块中的信息通过。通过这种方式,LSTM 能够清除其单元状态向量中的无关信息。
- 输入门,
:它通过多步骤过程决定将哪些新信息添加到记忆单元中。第一步是决定是否添加任何信息。与遗忘门类似,它的决策是基于
和
:它通过 sigmoid 函数为候选状态向量的每个单元输出 0 或 1。0 的输出意味着没有信息被添加到该单元的记忆中。因此,LSTM 可以在其单元状态向量中存储特定的片段信息:
在输入门序列的下一步中,我们计算新的候选细胞状态,。它基于先前的输出,
,以及当前的输入,
,并通过一个 tanh 函数进行转换:
然后,我们将与输入门的 Sigmoid 输出通过逐元素乘法结合起来:
。
总结来说,遗忘门和输入门分别决定了要从先前的和候选单元状态中遗忘和包含哪些信息。最终版本的新单元状态,,只是这两个组成部分的逐元素和:
- 输出门,
:它决定了单元格的总输出是什么。它将
和
作为输入。它为每个单元格记忆块输出一个 (0, 1) 范围内的值(通过 sigmoid 函数)。和之前一样,0 表示该块不输出任何信息,1 表示该块可以作为单元格的输出传递。因此,LSTM 可以从其单元格状态向量中输出特定的信息块:
最后,LSTM 单元的输出通过 tanh 函数进行传递:
因为这些公式都是可推导的,我们可以将 LSTM 单元串联起来,就像我们将简单的 RNN 状态串联在一起,并通过时间反向传播来训练网络一样。
那么 LSTM 是如何防止梯度消失的呢?我们从前向传播阶段开始。注意,如果遗忘门为 1,且输入门为
0: 。只有遗忘门可以完全清除单元的记忆。因此,记忆可以在长时间内保持不变。此外,注意输入是一个 tanh 激活函数,它已被加入到当前单元的记忆中。这意味着单元的记忆不会爆炸,并且相当稳定。
让我们通过一个例子来演示 LSTM 单元如何展开。为了简化起见,我们假设它具有一维(单标量值)输入、状态和输出向量。由于值是标量,我们将在此示例的其余部分中不使用向量符号:
图 6.13 – 随时间展开 LSTM
过程如下:
-
首先,我们有一个值为 3 的候选状态。输入门设置为
,而忘记门设置为
。这意味着先前的状态
被抹去,并被新的状态
替换。
-
对于接下来的两个时间步骤,忘记门设置为 1,而输入门设置为 0。这样,在这些步骤中,所有信息都被保留,没有新信息被添加,因为输入门被设置为 0:
。
-
最后,输出门设置为
,3 被输出并保持不变。我们已成功展示了如何在多个步骤中存储内部状态。
接下来,让我们聚焦于反向阶段。细胞状态,,可以通过遗忘门的帮助,减轻消失/爆炸梯度的问题,
。像常规的 RNN 一样,我们可以利用链式法则计算偏导数,
,对于两个连续的步骤。根据公式
不展开细节,其偏导数如下:
我们还可以将其推广到非连续的步骤:
如果遗忘门的值接近 1,则梯度信息几乎不变地通过网络状态反向传播。这是因为 使用 sigmoid 激活函数,信息流仍然受到 sigmoid 激活特有的消失梯度的影响。但是,与普通 RNN 中的梯度不同,
在每个时间步的值是不同的。因此,这不是几何级数,消失梯度效应较不明显。
接下来,我们将介绍一种新的轻量级 RNN 单元,它仍然保留 LSTM 的特性。
门控循环单元
门控循环单元(GRU)是一种循环模块,首次在 2014 年提出(使用 RNN 编码器-解码器进行统计机器翻译的学习短语表示,arxiv.org/abs/1406.1078
和 门控递归神经网络在序列建模中的实证评估,https://arxiv.org/abs/1412.3555),作为对 LSTM 的改进。GRU 单元通常具有与 LSTM 相似或更好的性能,但它的参数和操作更少:
图 6.14 – 一个 GRU 单元示意图
类似于经典的 RNN,GRU 单元有一个单一的隐藏状态,。你可以将其看作是 LSTM 的隐藏状态和细胞状态的结合。GRU 单元有两个门:
- 更新门,
:结合输入和遗忘 LSTM 门。根据网络输入
和先前的隐藏状态
,决定丢弃哪些信息并确定新信息的包含方式。通过结合这两个门,我们可以确保细胞只会在有新信息需要包含时才丢弃信息:
- 重置门,
:使用先前的隐藏状态,
,和网络输入,
,来决定保留多少先前的状态:
接下来,我们得到候选状态,:
最后,GRU 输出,,在时刻 t 是前一个输出,
,以及候选输出,
:
由于更新门允许我们同时忘记和存储数据,因此它直接应用于之前的输出,,并应用于候选输出,
。
我们将在本章的最后部分回到之前的免责声明——RNN 的实际限制。我们可以通过 LSTM 或 GRU 单元来解决其中的一个限制——消失梯度和梯度爆炸问题。但还有两个问题:
-
RNN 的内部状态在每个序列元素之后都会更新——每个新元素都需要先处理所有前面的元素。因此,RNN 的序列处理无法并行化,也无法利用 GPU 的并行计算能力。
-
所有前序序列元素的信息都被总结在一个单一的隐藏状态单元中。RNN 没有直接访问历史序列元素的能力,而是必须依赖单元状态来处理。实际上,这意味着即使是 LSTM 或 GRU,RNN 也只能有效处理最大长度约为 100 个元素的序列。
正如我们将在下一章看到的,transformer 架构成功地解决了这两种限制。但现在,让我们先看看如何在实践中使用 LSTM。
实现文本分类
在这一部分,我们将使用 LSTM 实现一个情感分析示例,数据集是 Large Movie Review Dataset (IMDb,ai.stanford.edu/~amaas/data/sentiment/
),该数据集包含 25,000 条训练评论和 25,000 条测试评论,每条评论都有一个二进制标签,表示其是正面还是负面。这个问题是一个 多对一 的关系类型,正如我们在 循环神经网络 (**RNNs) 部分定义的那样。
情感分析模型显示在下图中:
图 6.15 – 使用词嵌入和 LSTM 的情感分析
让我们描述一下模型组件(这些适用于任何文本分类算法):
-
序列中的每个单词都被它的嵌入向量替代。这些嵌入可以通过 word2vec 生成。
-
词嵌入作为输入传递给 LSTM 单元。
-
单元格输出,
,作为输入传递给具有两个输出单元和 softmax 的 FC 层。softmax 输出表示评论是正面(1)还是负面(0)的概率。
-
网络可以通过 Word2Vec 生成。
-
序列的最后一个元素的输出被作为整个序列的结果。
为了实现这个例子,我们将使用 PyTorch 和 TorchText 包。它包含数据处理工具和流行的自然语言数据集。我们只会包括代码中的有趣部分,但完整示例可以在本书的 GitHub 仓库中找到。有了这些,我们开始吧:
-
定义设备(默认情况下,这是 GPU,并有 CPU 后备):
import torch device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
-
启动训练和测试数据集管道。首先,定义
basic_english
分词器,它通过空格分割文本(即词汇分词):from torchtext.data.utils import get_tokenizer tokenizer = get_tokenizer('basic_english')
-
接下来,使用
tokenizer
来构建 tokenvocabulary
:from torchtext.datasets import IMDB from torchtext.vocab import build_vocab_from_iterator def yield_tokens(data_iter): for _, text in data_iter: yield tokenizer(text) vocabulary = build_vocab_from_iterator( yield_tokens(IMDB(split='train')), specials=["<unk>"]) vocabulary.set_default_index(vocabulary["<unk>"])
在这里,
IMDB(split='train')
提供了一个迭代器,用于访问训练集中的所有电影评论(每条评论都表示为一个字符串)。yield_tokens(IMDB(split='train'))
生成器遍历所有样本并将它们分割成单词。结果作为输入传递给build_vocab_from_iterator
,该函数遍历已 token 化的样本并构建 tokenvocabulary
。请注意,词汇表仅包括训练样本。因此,任何出现在测试集(但不在训练集中的)中的 token,都将被替换为默认的未知<unk>
token。 -
接下来,定义
collate_batch
函数,该函数接收一个包含不同长度的 token 化样本的batch
,并将它们拼接成一个长的 token 序列:def collate_batch(batch): labels, samples, offsets = [], [], [0] for (_label, _sample) in batch: labels.append(int(_label) - 1) processed_text = torch.tensor( vocabulary(tokenizer(_sample)), dtype=torch.int64) samples.append(processed_text) offsets.append(processed_text.size(0)) labels = torch.tensor( labels, dtype=torch.int64) offsets = torch.tensor( offsets[:-1]).cumsum(dim=0) samples = torch.cat(samples) return labels, samples, offsets
在这里,
samples
列表聚合了batch
所有分词后的_sample
实例。最终,它们会被拼接成一个单一的列表。offsets
列表包含每个拼接样本的起始偏移量。这些信息使得可以将长的samples
序列逆向拆分回单独的项目。该函数的目的是创建一个压缩的batch
表示。这是必需的,因为每个样本的长度不同。另一种做法是将所有样本填充到与最长样本相同的长度,以便它们能够适配批量张量。幸运的是,PyTorch 提供了offsets
优化来避免这一点。一旦我们将压缩批次传入 RNN,它会自动将其逆转回单独的样本。 -
然后,我们定义了 LSTM 模型:
class LSTMModel(torch.nn.Module): def __init__(self, vocab_size, embedding_size, hidden_size, num_classes): super().__init__() # Embedding field self.embedding = torch.nn.EmbeddingBag( num_embeddings=vocab_size, embedding_dim=embedding_size) # LSTM cell self.rnn = torch.nn.LSTM( input_size=embedding_size, hidden_size=hidden_size) # Fully connected output self.fc = torch.nn.Linear( hidden_size, num_classes) def forward(self, text_sequence, offsets): # Extract embedding vectors embeddings = self.embedding( text_sequence, offsets) h_t, c_t = self.rnn(embeddings) return self.fc(h_t)
该模型实现了我们在本节开始时介绍的方案。顾名思义,
embedding
属性(一个EmbeddingBag
实例)将 token(在我们这里是单词)索引映射到其嵌入向量。我们可以看到构造函数接受词汇表大小(num_embeddings
)和嵌入向量的维度(embedding_dim
)。理论上,我们可以用预先计算好的 Word2Vec 嵌入向量初始化EmbeddingBag
。但在我们的例子中,我们将使用随机初始化,并让模型在训练过程中学习这些向量。embedding
还处理了压缩批量表示(因此在forward
方法中有offsets
参数)。嵌入的输出作为输入传入rnn
LSTM 单元,进而将输出传递给fc
层。 -
定义
train_model(model, cost_function, optimizer, data_loader)
和test_model(model, cost_function, data_loader)
函数。这些函数几乎与我们在第三章中首次定义的相同,因此我们不会在这里再次列出它们。不过,它们已经适配了压缩批量表示和额外的offsets
参数。 -
继续实验。实例化 LSTM 模型、交叉熵损失函数和 Adam 优化器:
model = LSTMModel( vocab_size=len(vocabulary), embedding_size=64, hidden_size=64, num_classes=2) cost_fn = torch.nn.CrossEntropyLoss() optim = torch.optim.Adam(model.parameters())
-
定义
train_dataloader
、test_dataloader
及其各自的数据集(使用 64 的小批量大小):from torchtext.data.functional import to_map_style_dataset train_iter, test_iter = IMDB() train_dataset = to_map_style_dataset(train_iter) test_dataset = to_map_style_dataset(test_iter) from torch.utils.data import DataLoader train_dataloader = DataLoader( train_dataset, batch_size=64, shuffle=True, collate_fn=collate_batch) test_dataloader = DataLoader( test_dataset, batch_size=64, shuffle=True, collate_fn=collate_batch)
-
训练 5 个周期:
for epoch in range(5): print(f'Epoch: {epoch + 1}') train_model(model, cost_fn, optim, train_dataloader) test_model(model, cost_fn, test_dataloader)
模型在测试集上的准确率达到了 87%。
这结束了我们关于 LSTM 文本分类的简单实用示例。巧合的是,这也标志着本章的结束。
总结
本章介绍了两个互补的主题——NLP 和 RNN。我们讨论了分词技术以及最流行的分词算法——BPE、WordPiece 和 Unigram。接着,我们介绍了词嵌入向量的概念以及生成它们的 Word2Vec 算法。我们还讨论了 n -gram 语言模型,这为我们平滑地过渡到 RNN 的话题。在那里,我们实现了一个基本的 RNN 示例,并介绍了两种最先进的 RNN 架构——LSTM 和 GRU。最后,我们实现了一个情感分析模型。
在下一章,我们将通过引入注意力机制和变压器来超级增强我们的 NLP 潜力。
第七章:注意力机制与 Transformer
在第六章中,我们概述了一个典型的自然语言处理(NLP)流程,并介绍了递归神经网络(RNNs)作为 NLP 任务的候选架构。但我们也概述了它们的缺点——它们本质上是顺序的(即不可并行化),并且由于其内部序列表示的局限性,无法处理更长的序列。在本章中,我们将介绍注意力机制,它使神经网络(NN)可以直接访问整个输入序列。我们将简要讨论 RNN 中的注意力机制,因为它最初是作为 RNN 的扩展引入的。然而,本章的主角将是Transformer——一种完全依赖于注意力的最新神经网络架构。Transformer 在过去 10 年中成为最重要的神经网络创新之一。它们是所有近期大型语言模型(LLMs)的核心,例如 ChatGPT(chat.openai.com/
),甚至是图像生成模型,如 Stable Diffusion(stability.ai/stable-diffusion
)。这是我们专注于 NLP 的章节中的第二章,也是三章中专门讨论 Transformer 的第一章。
本章将涵盖以下主题:
-
介绍序列到序列(seq2seq)模型
-
理解注意力机制
-
使用注意力机制构建 Transformer
技术要求
我们将使用 Python、PyTorch 和 Hugging Face Transformers 库(github.com/huggingface/transformers
)来实现本章的示例。如果你还没有配置这些工具的环境,不用担心——该示例作为 Jupyter 笔记本在 Google Colab 上提供。你可以在本书的 GitHub 仓库中找到代码示例:github.com/PacktPublishing/Python-Deep-Learning-Third-Edition/tree/main/Chapter07
。
介绍 seq2seq 模型
在 第六章 中,我们概述了几种类型的递归模型,取决于输入/输出的组合。其中一种是间接的多对多任务,或 seq2seq,其中一个输入序列被转换成另一个不同的输出序列,输出序列的长度不一定与输入序列相同。seq2seq 任务的一种类型是机器翻译。输入序列是一个语言中的句子的单词,而输出序列是同一句子翻译成另一种语言的单词。例如,我们可以将英语序列 tourist attraction 翻译成德语 Touristenattraktion。输出不仅长度不同,而且输入和输出序列的元素之间没有直接对应关系。一个输出元素对应于两个输入元素的组合。
另一种间接的多对多任务是会话聊天机器人,例如 ChatGPT,其中初始输入序列是用户的第一个查询。之后,整个对话(包括用户的查询和机器人的回复)都作为新生成的机器人的回复的输入序列。
在这一部分,我们将重点介绍编码器-解码器的 seq2seq 模型(使用神经网络的序列到序列学习,arxiv.org/abs/1409.3215
; 使用 RNN 编码器-解码器进行统计机器翻译的短语表示学习,arxiv.org/abs/1406.1078
),该模型首次于 2014 年提出。它们使用 RNN 的方式特别适用于解决间接的多对多任务,例如这些任务。以下是 seq2seq 模型的示意图,其中输入序列 [A, B, C, <EOS>]
被解码为输出序列 [W, X, Y, Z, <EOS>]
:
图 7.1 – 一个 seq2seq 模型(灵感来源于 arxiv.org/abs/1409.3215
)
该模型由两部分组成:
-
<EOS>
—序列结束—标记已到达。假设输入是使用词级别标记化的文本序列。那么,我们将在每一步使用词嵌入向量作为编码器输入,<EOS>
标记表示句子的结束。编码器的输出会被丢弃,在 seq2seq 模型中没有作用,因为我们只关心隐藏的编码器状态。 -
<GO>
输入信号。编码器也是一个 RNN(LSTM 或 GRU)。编码器和解码器之间的联系是编码器最新的内部状态向量,(也称为
<EOS>
,成为最可能的符号,解码完成)。
自回归模型的示例
假设我们想将英语句子How are you today?翻译成西班牙语。我们将其标记为[how, are, you, today, ?, <EOS>]
。一个自回归模型将从初始序列[<GO>]
开始。然后,它会生成翻译的第一个词,并将其附加到现有的输入序列中:[<GO>, ¿]
。新的序列将作为解码器的输入,以便生成下一个元素并再次扩展序列:[<GO>, ¿, cómo]
。我们将重复相同的步骤,直到解码器预测出<EOS>
标记:[<GO>, ¿, cómo, estás, hoy, ?, <EOS>]
。
该模型的训练是有监督的,因为它需要知道输入序列及其对应的目标输出序列(例如,多个语言中的相同文本)。我们将输入序列送入编码器,生成思维向量,,并利用它启动解码器的输出序列生成。训练解码器使用一种叫做
[W, X, Y]
的过程,但当前解码器生成的输出序列是[W, X, Z]
。通过教师强制法,在步骤t+1时,解码器的输入将是Y而不是Z。换句话说,解码器学习在给定目标值[...,t]
的情况下生成目标值[t+1,...]
。我们可以这样理解:解码器的输入是目标序列,而其输出(目标值)是同一序列,但向右移动了一个位置。
总结来说,seq2seq 模型通过将输入序列编码为固定长度的状态向量v,然后使用这个向量作为基础来生成输出序列,从而解决了输入/输出序列长度变化的问题。我们可以通过以下方式形式化这一过程:它尝试最大化以下概率:
这等价于以下表达式:
让我们更详细地看一下这个公式的各个元素:
-
:条件概率,其中
是长度为T的输入序列,
是长度为T’的输出序列。
-
v:输入序列的固定长度编码(思维向量)。
-
: 给定先前的词 y 以及思想向量 v,输出词
的概率。
原始的 seq2seq 论文介绍了一些技巧,用于增强模型的训练和性能。例如,编码器和解码器是两个独立的 LSTM。在机器翻译的情况下,这使得可以使用相同的编码器为不同语言训练不同的解码器。
另一个改进是输入序列以反向方式输入到解码器。例如,[A,B,C]
-> [W,X,Y,Z]
将变成 [C,B,A]
-> [W,X,Y,Z]
。没有明确的解释说明为什么这样有效,但作者分享了他们的直觉:由于这是一个逐步模型,如果序列按正常顺序排列,源句子中的每个源词将远离其在输出句子中的对应词。如果我们反转输入序列,输入/输出词之间的平均距离不会改变,但第一个输入词会非常接近第一个输出词。这有助于模型在输入和输出序列之间建立更好的通信。然而,这一改进也展示了 RNN(即使是 LSTM 或 GRU)隐藏状态的不足——较新的序列元素会抑制较老元素的可用信息。在下一节中,我们将介绍一种优雅的方式来彻底解决这个问题。
理解注意力机制
在这一节中,我们将按引入的顺序讨论注意力机制的几个迭代版本。
Bahdanau 注意力
第一次注意力迭代(Neural Machine Translation by Jointly Learning to Align and Translate, arxiv.org/abs/1409.0473
),被称为巴赫达瑙注意力,扩展了 seq2seq 模型,使解码器能够与所有编码器隐藏状态进行交互,而不仅仅是最后一个状态。它是对现有 seq2seq 模型的补充,而不是一个独立的实体。下图展示了巴赫达瑙注意力的工作原理:
图 7.2 – 注意力机制
别担心——它看起来比实际更复杂。我们将从上到下解析这个图:注意力机制通过在编码器和解码器之间插入一个额外的上下文向量,,来实现。隐藏的解码器状态
在时间 t 上,现在不仅是隐藏状态和解码器输出的函数,还包含上下文向量
:
每个解码步骤都有一个独特的上下文向量,而一个解码步骤的上下文向量只是所有编码器隐藏状态的加权和。通过这种方式,编码器可以在每个输出步骤 t 中访问所有输入序列状态,这消除了像常规 seq2seq 模型那样必须将源序列的所有信息编码为一个固定长度的思维向量的必要性:
让我们更详细地讨论这个公式:
-
:解码器输出步骤t(总共有T’个输出步骤)的上下文向量
-
:编码器步骤i(总共有T个输入步骤)的隐藏状态向量
-
:与当前解码器步骤t中
相关的标量权重
请注意, 对于编码器和解码器步骤都是唯一的——也就是说,输入序列的状态将根据当前的输出步骤具有不同的权重。例如,如果输入和输出序列的长度为 10,那么权重将由一个 10×10 的矩阵表示,共有 100 个权重。这意味着注意力机制将根据输出序列的当前状态,将解码器的注意力集中在输入序列的不同部分。如果
很大,那么解码器将非常关注在步骤 t 时的
。
那么,我们如何计算权重 呢?首先,我们需要提到,对于解码器的每个步骤 t,所有
权重的总和为 1。我们可以通过在注意力机制之上执行 softmax 操作来实现这一点:
这里, 是一个对齐分数,表示输入序列中位置 i 附近的元素与位置 t 的输出匹配(或对齐)的程度。这个分数(由权重
) 基于前一个解码器状态
(我们使用
,因为我们还没有计算
),以及编码器状态
:
这里,a(而非 alpha)是一个可微分函数,通过反向传播与系统的其他部分一起训练。不同的函数满足这些要求,但论文的作者选择了所谓的加性注意力,它通过向量加法将和
结合起来。它有两种变体:
在第一个公式中,W 是一个权重矩阵,应用于连接向量 和
,而 v 是一个权重向量。第二个公式类似,但这次我们有单独的 全连接 (FC) 层(权重矩阵
和
),然后我们对
和
求和。在这两种情况下,对齐模型可以表示为一个简单的 前馈网络 (FFN),带有一个隐藏层。
现在我们知道 和
的公式,让我们用前者替换后者:
作为总结,下面是一步一步总结的注意力算法:
-
将输入序列输入编码器,并计算一组隐藏状态,H = {h 1, h 2…h T}。
-
计算对齐分数,
,该对齐分数使用来自前一步解码器状态的值
。如果t=1,我们将使用最后一个编码器状态,
,作为初始隐藏状态。
-
计算权重
。
-
计算上下文向量
。
-
计算隐藏状态,
,基于连接的向量
和
以及先前的解码器输出
。此时,我们可以计算最终输出
。如果我们需要对下一个单词进行分类,将使用 softmax 输出,
,其中
是一个权重矩阵。
-
重复 步骤 2 到 步骤 5,直到序列结束。
接下来,我们将讨论 Bahdanau 注意力的一个稍微改进的版本。
Luong 注意力
Luong 注意力 (Effective Approaches to Attention-based Neural Machine Translation, arxiv.org/abs/1508.04025
) 相比 Bahdanau 注意力做出了若干改进。最显著的变化是对齐分数依赖于解码器的隐藏状态 ,而不像 Bahdanau 注意力中的
。为了更好地理解这一点,我们来比较这两种算法:
图 7.3 – 左:Bahdanau 注意力;右:Luong 注意力
我们将逐步执行 Luong 注意力的过程:
-
将输入序列传入编码器,并计算编码器的隐藏状态集
。
-
: 计算解码器的隐藏状态,基于上一个解码器的隐藏状态!<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">mml:msubmml:mrow<mml:mi mathvariant="bold">s</mml:mi></mml:mrow>mml:mrowmml:mit</mml:mi>mml:mo-</mml:mo>mml:mn1</mml:mn></mml:mrow></mml:msub></mml:math> 和上一个解码器的输出!<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">mml:msubmml:mrowmml:miy</mml:mi></mml:mrow>mml:mrowmml:mit</mml:mi>mml:mo-</mml:mo>mml:mn1</mml:mn></mml:mrow></mml:msub></mml:math>(但不是上下文向量)。
-
: 计算对齐分数,使用当前步骤的解码器状态!<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">mml:msubmml:mrow<mml:mi mathvariant="bold">s</mml:mi></mml:mrow>mml:mrowmml:mit</mml:mi></mml:mrow></mml:msub></mml:math>。除了加性注意力之外,Luong 注意力论文还提出了两种乘性注意力:
-
: 点积没有任何参数。在这种情况下,向量s和h(表示为列矩阵和行矩阵)需要具有相同的大小。
-
: 这里,
是注意力层的可训练权重矩阵。
将向量相乘作为对齐评分的度量有一个直观的解释——正如我们在第二章中提到的,点积作为向量之间相似性的度量。因此,如果向量相似(即对齐),那么乘积的结果将是一个较大的值,注意力将集中在当前的t,i关系上。
-
-
: 计算权重。
-
: 计算上下文向量。
-
: 根据连接的向量
和
计算中间向量。此时,我们可以计算最终输出
。在分类的情况下,我们将使用 softmax,
,其中
是一个权重矩阵。
-
重复步骤 2到步骤 6直到序列结束。
接下来,我们将使用巴赫达努(Bahdanau)和 Luong 注意力作为通用注意力机制的垫脚石。
通用注意力
尽管我们在使用 RNN 的 seq2seq 背景下讨论了注意力机制,但它本身就是一种通用的深度学习(DL)技术。为了理解它,我们从以下图示开始:
图 7.4 – 通用注意力
它从一个查询q开始,执行对一组键值对k和的查询。每个键
有一个对应的值
。查询、键和值都是向量。因此,我们可以将键值存储表示为两个矩阵K和V。如果有多个查询
,我们也可以将它们表示为一个矩阵Q。因此,这些通常被简写为Q、K和V。
通用注意力与巴赫达努/Luong 注意力的区别
与一般的注意力机制不同,Bahdanau 和 Luong 注意力的键 K 和值 V 是相同的——也就是说,这些注意力模型更像是 Q/V,而不是 Q/K/V。分开键和值为一般的注意力机制提供了更多的灵活性——键专注于匹配输入查询,而值则携带实际的信息。我们可以把 Bahdanau 向量 (或 Luong 注意力中的
)视为查询,![一般的注意力使用的是乘法机制,而非加法机制(像 Luong 注意力一样)。以下是它的工作原理:1. 起始点是其中一个输入查询向量,
。
-
: 使用查询向量
和每个关键向量
计算对齐分数。正如我们在巴达瑙注意力部分提到的那样,点积充当相似度度量,并且在这种情况下使用它是有意义的。
-
:借助 softmax 计算每个值向量相对于查询的最终权重。
-
最终的注意力向量是所有值向量的加权和(即元素级别的求和),
:
为了更好地理解注意力机制,我们将使用以下图表中显示的数值示例:
图 7.5 – 使用四维查询在包含四个向量的键值存储中执行的注意力示例
让我们一步一步地跟踪它:
-
执行一个四维查询向量,
,对一个包含四个四维向量的键值存储进行查询。
-
计算对齐得分。例如,第一个得分是
。其余的得分显示在图 7**.5中。我们故意选择了查询
,它与第二个键向量
相对相似。这样,
具有最大的对齐得分,
,
它应该对最终结果产生最大的影响。
-
计算权重,
,借助 softmax 函数——例如,α q 1,k 2 = exp(2.4)/(exp(0.36) + exp(2.4) + exp(0.36) + exp(0.36)) = 0.756。关键向量,
,由于其较大的对齐分数,具有最大的权重。softmax 函数夸大了输入之间的差异,因此,最终的权重!<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">mml:msubmml:mrow<mml:mi mathvariant="bold">k</mml:mi></mml:mrow>mml:mrowmml:mn2</mml:mn></mml:mrow></mml:msub></mml:math>甚至比输入对齐分数的比例还要高。
-
计算最终结果,
,这是值向量的加权元素级求和,
。例如,我们可以计算结果的第一个元素为
。我们可以看到结果的值最接近值向量,
,这再次反映了键向量,
,和输入查询,
之间的大量对齐。
我希望这个例子能帮助你理解注意力机制,因为这是过去 10 年深度学习领域的一个重要创新。接下来,我们将讨论一个更先进的注意力版本。
Transformer 注意力
在本节中,我们将讨论注意力机制,正如它在 Transformer 神经网络架构中出现的那样(Attention Is All You Need,arxiv.org/abs/1706.03762
)。别担心——你现在不需要了解 Transformer,因为Transformer 注意力(TA)是整个模型的一个独立且自给自足的构建模块。它在下图中展示:
图 7.6 – 缩放点积(乘法)TA(灵感来源于 arxiv.org/abs/1706.03762
)
TA 使用点积(乘法)相似度,并遵循我们在通用注意力部分介绍的通用注意力过程(正如我们之前提到的,它并不限于 RNN 模型)。我们可以用以下公式来定义它:
在实际应用中,我们会同时计算一组查询的 TA 函数,这些查询被打包在一个矩阵Q中(键 K、值 V 和结果也是矩阵)。让我们更详细地讨论公式中的各个步骤:
- 将查询Q与数据库(键K)进行矩阵乘法匹配,以生成对齐分数,
。矩阵乘法等价于在每一对查询和键向量之间应用点积相似度。假设我们希望将m个不同的查询与一个n个值的数据库进行匹配,且查询-键向量的长度为
。然后,我们有查询矩阵,
,每行包含一个
-维的查询向量,共m行。类似地,我们有键矩阵,
,每行包含一个
-维的键向量,共n行(其转置为
)。然后,输出矩阵将为
,其中每一行包含一个查询与数据库中所有键的对齐分数:
![
换句话说,我们可以通过单个矩阵乘法操作,在单个矩阵乘法中匹配多个查询与多个数据库键。例如,在翻译的上下文中,我们可以计算目标句子中所有单词与源句子中所有单词的对齐分数。
-
使用
缩放对齐分数,其中
与矩阵 K 中键向量的大小相同,也等于 Q 中查询向量的大小(类似地,
是值向量 V 的大小)。文中作者怀疑,在
的大值情况下,点积的幅度增大,推动 softmax 函数进入极小梯度区域。这反过来导致梯度消失问题,因此需要对结果进行缩放。
-
对矩阵的行应用 softmax 操作来计算注意力分数(稍后我们会讨论掩码操作):
- 通过将注意力得分与值V相乘,计算最终的注意力向量:
完整的 TA 使用一组注意力块,称为多头注意力(MHA),如以下图所示:
图 7.7 – MHA(灵感来自 arxiv.org/abs/1706.03762
)
不同于单一的注意力函数,其中包含-维度的键,我们将键、查询和数值线性投影h次,以生成h个不同的
-维,
-维和
-维的这些数值投影。然后,我们对新创建的向量应用独立的并行注意力块(或头部),每个头部生成一个
-维度的输出。接着,我们将这些头部输出连接起来,并进行线性投影,生成最终的注意力结果。
注意
线性投影是指应用全连接(FC)层。也就是说,最初我们借助单独的 FC 操作将Q/K/V矩阵分支处理。最终,我们使用一个 FC 层来合并并压缩连接后的头部输出。在这种情况下,我们遵循原论文中使用的术语。
MHA 允许每个头部关注序列的不同元素。同时,模型将各个头部的输出合并为一个统一的表示。更精确地说,我们可以通过以下公式来定义这一过程:
这里是 。
让我们更详细地了解一下,从头部开始:
-
每个头接收初始Q、K和V矩阵的线性投影版本。这些投影是通过可学习的权重矩阵计算的!<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">mml:msubsupmml:mrow<mml:mi mathvariant="bold">W</mml:mi></mml:mrow>mml:mrowmml:mii</mml:mi></mml:mrow>mml:mrowmml:miQ</mml:mi></mml:mrow></mml:msubsup></mml:math>、
和
,分别计算出来(再次强调,投影是全连接层)。注意,我们为每个组件(Q、K、V)以及每个头,i,拥有一组单独的权重。为了满足从
到
和
的转换,这些矩阵的维度是
,
和
。
-
一旦Q、K和V被转换,我们就可以使用本节开头描述的常规注意力块来计算每个头的注意力。
-
最终的注意力结果是线性投影(带有权重矩阵
,可学习的权重)作用于拼接的头输出
。
到目前为止,我们假设注意力机制适用于不同的输入和输出序列。例如,在翻译中,每个翻译句子的单词会 关注 源句子的单词。然而,注意力还有另一个有效的应用场景。变换器也依赖于自注意力(或内部注意力),其中查询 Q 来自于与键 K 和值 V 相同的数据集。换句话说,在自注意力中,源和目标是相同的序列(在我们的例子中,就是同一句话)。自注意力的好处并不是立刻显而易见的,因为没有直接的任务来应用它。从直观的角度来看,它使我们能够看到同一序列中单词之间的关系。为了理解为什么这很重要,让我们回想一下 word2vec 模型(第六章),在这个模型中,我们利用一个词的上下文(即它周围的词)来学习该词的嵌入向量。word2vec 的一个局限性是其嵌入是静态的(或与上下文无关的)——我们为该词在整个训练语料库中的所有上下文使用相同的嵌入向量。例如,无论我们将 new 使用在 new shoes 还是 New York 中,它的嵌入向量都是相同的。自注意力使我们能够通过创建该词的动态嵌入(或上下文相关)来解决这个问题。我们暂时不深入细节(我们将在构建带注意力机制的变换器一节中讲解),但是动态嵌入的工作原理如下:我们将当前词输入注意力模块,同时也输入其当前的即时上下文(即周围词)。该词是查询 q,而上下文是 K/V 键值存储。通过这种方式,自注意力机制使模型能够产生一个动态的嵌入向量,这个向量是针对该词当前上下文的独特表示。这个向量作为各种下游任务的输入。它的目的类似于静态的 word2vec 嵌入,但它更具表现力,并使得能够以更高的准确度解决更复杂的任务。
我们可以通过以下图示来说明自注意力是如何工作的,图中展示了词 economy 的多头自注意力(不同的颜色代表不同的注意力头):
图 7.8 – 词“economy”的多头自注意力(由 https://github.com/jessevig/bertviz 生成)
我们可以看到,economy 与词 market 的关联最强,这很有道理,因为这两个词组成了一个具有独特含义的短语。然而,我们也可以看到,不同的注意力头会关注输入序列中的不同、更远的部分。
作为本节的总结,让我们概括一下注意力机制相较于 RNN 处理序列的优势:
-
直接访问序列元素:RNN 将输入元素的信息编码为一个单一的隐藏层(思维向量)。理论上,它代表了目前为止所有序列元素的浓缩版本。实际上,它的表示能力有限——它只能在最大长度约为 100 个标记的序列中保留有意义的信息,在此之后最新的标记开始覆盖旧的标记信息。
相比之下,注意力机制提供了对所有输入序列元素的直接访问。一方面,这对最大序列长度施加了严格的限制;另一方面,它使得在本书编写时,基于 Transformer 的 LLM 能够处理超过 32,000 个标记的序列。
-
输入序列的并行处理:RNN 按照元素到达的顺序逐一处理输入序列元素。因此,我们无法对 RNN 进行并行化。与此相比,注意力机制完全由矩阵乘法操作构成,显著并行。这使得能够在大规模训练数据集上训练具有数十亿可训练参数的 LLM。
但是这些优势伴随着一个劣势——当 RNN 保留序列元素的顺序时,注意力机制由于其直接访问的特性,则没有保留顺序。然而,我们将在Transformer 编码器部分介绍一种解决该限制的变通方法。
这就结束了我们对 TA 的理论介绍。接下来,让我们实现它。
实现 TA
在本节中,我们将实现 MHA,遵循 Transformer 注意力 部分的定义。本节中的代码是更大 Transformer 实现的一部分,我们将在本章中讨论这些内容。我们不会提供完整的源代码,但你可以在本书的 GitHub 仓库中找到它。
注意
这个示例基于 github.com/harvardnlp/annotated-transformer
。还需要注意的是,PyTorch 有原生的 Transformer 模块(文档可在 pytorch.org/docs/stable/generated/torch.nn.Transformer.html
查阅)。尽管如此,在本节中,我们将从头实现 TA,以便更好地理解它。
我们将从常规缩放点积注意力的实现开始。提醒一下,它实现的公式是 ,其中
query
,key
和 value
:
def attention(query, key, value, mask=None, dropout=None):
d_k = query.size(-1)
# 1) and 2) Compute the alignment scores with scaling
scores = (query @ key.transpose(-2, -1)) \
/ math.sqrt(d_k)
if mask is not None:
scores = scores.masked_fill(mask == 0, -1e9)
# 3) Compute the attention scores (softmax)
p_attn = scores.softmax(dim=-1)
if dropout is not None:
p_attn = dropout(p_attn)
# 4) Apply the attention scores over the values
return p_attn @ value, p_attn
attention
函数包括 dropout
,因为它是完整的 transformer 实现的一部分。我们暂时将 mask
参数及其用途留到后面再讨论。还需要注意一个新细节——@
操作符的使用(query @ key.transpose(-2, -1)
和 p_attn @ value
),自 Python 3.5 起,它已被保留用于矩阵乘法。
接下来,让我们继续实现 MHA。提醒一下,实现遵循以下公式:。这里,head i = Attention(Q W i Q,
K W i K,V W i V。
我们将其实现为torch.nn.Module
的子类,名为MultiHeadedAttention
。我们将从构造函数开始:
class MultiHeadedAttention(torch.nn.Module):
def __init__(self, h, d_model, dropout=0.1):
"""
:param h: number of heads
:param d_model: query/key/value vector length
"""
super(MultiHeadedAttention, self).__init__()
assert d_model % h == 0
# We assume d_v always equals d_k
self.d_k = d_model // h
self.h = h
# Create 4 fully connected layers
# 3 for the query/key/value projections
# 1 to concatenate the outputs of all heads
self.fc_layers = clones(
torch.nn.Linear(d_model, d_model), 4)
self.attn = None
self.dropout = torch.nn.Dropout(p=dropout)
请注意,我们使用clones
函数(在 GitHub 上实现)来创建四个相同的全连接self.fc_layers
实例。我们将使用其中三个作为self.attn
属性。
接下来,让我们实现MultiHeadedAttention.forward
方法。请记住,声明应该缩进,因为它是MultiHeadedAttention
类的一个属性:
def forward(self, query, key, value, mask=None):
if mask is not None:
# Same mask applied to all h heads.
mask = mask.unsqueeze(1)
batch_samples = query.size(0)
# 1) Do all the linear projections in batch from d_model => h x d_k
projections = [
l(x).view(batch_samples, -1, self.h, self.d_k)
.transpose(1, 2)
for l, x in zip(self.fc_layers, (query, key, value))
]
query, key, value = projections
# 2) Apply attention on all the projected vectors in batch.
x, self.attn = attention(
query, key, value,
mask=mask,
dropout=self.dropout)
# 3) "Concat" using a view and apply a final linear.
x = x.transpose(1, 2).contiguous() \
.view(batch_samples, -1, self.h * self.d_k)
return self.fc_layers-1
我们遍历query
/key
/value
张量及其参考投影self.fc_layers
,并使用以下代码片段生成query
/key
/value
的投影:
l(x).view(batch_samples, -1, self.h, self.d_k).transpose(1, 2)
然后,我们使用我们最初定义的注意力函数,对投影进行常规注意力操作。接下来,我们将多个头的输出进行拼接,最后将它们传递到最后的全连接层(self.fc_layers[-1]
)并返回结果。
现在我们已经讨论了 TA,让我们继续深入讲解变换器模型本身。
构建具有注意力机制的变换器
我们在本章的大部分内容中都在强调注意力机制的优势。现在是时候揭示完整的变换器架构了,它与 RNN 不同,完全依赖于注意力机制(Attention Is All You Need,arxiv.org/abs/1706.03762
)。下图展示了两种最流行的变换器类型,后归一化和前归一化(或后归一化和前归一化):
图 7.9 – 左:原始(后归一化,后 ln)变换器;右:前归一化(前 ln)变换器(灵感来源于 https://arxiv.org/abs/1706.03762)
它看起来很复杂,但别担心——其实比想象的更简单。在本节中,我们将以 seq2seq 任务为背景讨论变换器,该任务我们在引入 seq2seq 模型一节中已经定义。也就是说,它将接受一组标记序列作为输入,并输出另一组不同的标记序列。与 seq2seq 模型一样,它有两个组成部分——编码器和解码器。我们将从编码器开始(即前面图中两部分的左侧)。
变换器编码器
编码器以一组独热编码的标记序列作为输入。最常用的标记化算法有字节对编码(BPE)、WordPiece 和 Unigram(第六章)。这些标记会被转换成-维度的嵌入向量。这一转换方式如我们在第六章中所描述。我们有一个查找表(矩阵)——独热编码标记的索引指示矩阵的行,该行代表嵌入向量。嵌入向量进一步与
相乘。它们被随机初始化,并与整个模型一起训练(这与使用 word2vec 等算法初始化不同)。
下一步是将位置位置信息添加到现有的嵌入向量中。这是必要的,因为注意力机制无法保留序列元素的顺序。此步骤通过一种方式修改嵌入向量,隐式地将位置信息编码到其中。
原始的 Transformer 实现使用静态位置编码,通过与词嵌入大小相同的特殊位置编码向量来表示。我们将这些向量使用逐元素加法的方式加到序列中的所有嵌入向量上,具体取决于它们的位置。静态编码对于序列的每个位置都是唯一的,但对于序列元素而言是常量。由于这个原因,我们可以仅预计算位置编码一次,然后在后续使用。
编码位置信息的另一种方式是使用相对位置表示
(相对位置表示的自注意力, arxiv.org/abs/1803.02155
)。在这里,位置信息在注意力块的键值矩阵 K/V 中动态编码。输入序列的每个元素相对于其余元素的位置都是不同的。因此,相对位置编码对于每个标记动态计算。此编码作为注意力公式的附加部分应用于 K 和 V 矩阵。
编码器的其余部分由 N=6 个相同的块组成,这些块有两种类型:post-ln 和 pre-ln。这两种类型的块共享以下子层:
-
一个多头自注意力机制,就像我们在Transformer 注意力部分描述的那样。由于自注意力机制作用于整个输入序列,编码器在设计上是双向的。也就是说,当前标记的上下文包括序列中当前标记之前和之后的标记。这与常规的 RNN 相对立,后者只能访问当前标记之前的标记。在一个编码器块中,每个位置都可以关注前一个编码器块中的所有位置。
我们将每个标记的嵌入作为查询 q 输入到多头自注意力中(我们可以将整个输入序列作为一个输入矩阵 Q 一次性传递)。同时,其上下文的嵌入作为键值存储 K/V。多头自注意力操作的输出向量作为模型其余部分的输入。
MHA 和激活函数
MHA 为每个输入标记产生 h 个注意力向量,分别来自每个 h 个注意力头。然后,这些向量通过一个全连接(FC)层进行线性投影并进行结合。整个注意力块没有显式的激活函数。但让我们回想一下,注意力块的结束有一个非线性函数——softmax。键值向量的点积是一个额外的部分。
非线性。从严格意义上讲,注意力块不需要额外的激活函数。
- 一个简单的全连接 FFN,由以下公式定义:
网络应用于每个序列元素 x,并且是独立进行的。它使用相同的参数集 (,
,
,和
),但不同的编码器块使用不同的参数。原始 transformer 使用 整流线性单元(ReLU)激活函数。然而,近年来的模型使用其变种之一,如 sigmoid 线性单元(SiLU)。FFN 的作用是以更适合下一个块输入的方式处理 MHA 输出。
pre-ln 和 post-ln 块之间的区别在于归一化层的位置。每个 post-ln 子层(包括 MHA 和 FFN)都有一个残差连接,并以该连接和自身输出的和进行归一化和丢弃。post-ln transformer 中的归一化层分别位于注意力机制和 FFN 之后。因此,每个 post-ln 子层的输出如下:
相比之下,pre-ln 块(图 7.9的右侧部分)在两个编码器归一化层中,分别位于注意力机制和 FFN 之前。因此,每个 pre-ln 子层的输出是这样的:
两种变体的差异在训练过程中表现得尤为明显。无需深入细节,恰如其分地命名的论文理解训练变压器的难度(arxiv.org/abs/2004.08249
)表明,后-ln 变压器对残差连接的强烈依赖放大了由于参数变化(例如自适应学习率)引起的波动,从而使训练不稳定。因为这个原因,后-ln 训练从一个较低学习率的预热阶段开始,然后逐渐增加学习率。这与通常的学习率调度相反,通常从较大的学习率开始,并随着训练的进行逐渐减小。而 pre-ln 块则没有这个问题,也不需要预热阶段。然而,它们可能会遭遇表示崩溃的问题,即深层块(靠近神经网络末端的块)中的隐藏表示会变得相似,从而对模型容量贡献较小。在实践中,两种类型的块都会被使用。
到目前为止,编码器部分进展顺利。接下来,让我们在构建编码器的过程中,继续完善我们的注意力实现。
实现编码器
在这一部分,我们将实现后-ln 编码器,它由几个不同的子模块组成。让我们从主要类Encoder
开始:
class Encoder(torch.nn.Module):
def __init__(self, block: EncoderBlock, N: int):
super(Encoder, self).__init__()
self.blocks = clones(block, N)
self.norm = torch.nn.LayerNorm(block.size)
def forward(self, x, mask):
"""Iterate over all blocks and normalize"""
for layer in self.blocks:
x = layer(x, mask)
return self.norm(x)
它堆叠了N
个EncoderBlock
实例(self.blocks
),后面跟着一个LayerNorm
归一化层(self.norm
)。每个实例都作为下一个实例的输入,如forward
方法的定义所示。除了常规的输入x
外,forward
方法还接受一个mask
参数作为输入。但它只与解码器部分相关,因此我们在这里不会关注它。
接下来,让我们看看EncoderBlock
类的实现:
class EncoderBlock(torch.nn.Module):
def __init__(self,
size: int,
self_attn: MultiHeadedAttention,
ffn: PositionwiseFFN,
dropout=0.1):
super(EncoderBlock, self).__init__()
self.self_attn = self_attn
self.ffn = ffn
# Create 2 sub-layer connections
# 1 for the self-attention
# 1 for the FFN
self.sublayers = clones(SublayerConnection(size, dropout), 2)
self.size = size
def forward(self, x, mask):
x = self.sublayers0)
return self.sublayers1
每个编码器块由多头自注意力(self.self_attn
)和 FFN(self.ffn
)子层(self.sublayers
)组成。每个子层都被其残差连接(SublayerConnection
类)包裹,并通过熟悉的 clone
函数实例化:
class SublayerConnection(torch.nn.Module):
def __init__(self, size, dropout):
super(SublayerConnection, self).__init__()
self.norm = torch.nn.LayerNorm(size)
self.dropout = torch.nn.Dropout(dropout)
def forward(self, x, sublayer):
return x + self.dropout(sublayer(self.norm(x)))
SublayerConnection.forward
方法接受数据张量 x
和 sublayer
作为输入,sublayer
是 MultiHeadedAttention
或 PositionwiseFFN
的实例(它与来自 Transformer encoder 部分的子层定义相匹配)。
我们尚未定义的唯一组件是 PositionwiseFFN
,它实现了公式 。我们将使用 SiLU 激活函数。现在,让我们补充这一缺失的部分:
class PositionwiseFFN(torch.nn.Module):
def __init__(self, d_model: int, d_ff: int, dropout=0.1):
super(PositionwiseFFN, self).__init__()
self.w_1 = torch.nn.Linear(d_model, d_ff)
self.w_2 = torch.nn.Linear(d_ff, d_model)
self.dropout = torch.nn.Dropout(dropout)
def forward(self, x):
return self.w_2(
self.dropout(
torch.nn.functional.silu(
self.w_1(x)
)))
这标志着我们对编码器部分的实现完成。接下来,让我们将注意力集中在解码器上。
Transformer 解码器
解码器基于编码器输出和自身先前生成的标记序列的组合生成输出序列(我们可以在图 7.9的构建带注意力的变换器部分的两侧看到解码器)。在 seq2seq 任务的背景下,完整的编码器-解码器变换器是一个自回归模型。首先,我们将初始序列——例如,待翻译的句子或待回答的问题——输入给编码器。如果序列足够短,可以适应查询矩阵的最大大小,这可以在一次传递中完成。一旦编码器处理完所有序列元素,解码器将使用编码器的输出开始逐个标记地生成输出序列。它将每个生成的标记附加到初始输入序列中。我们将新的扩展序列再次输入给编码器。编码器的新输出将启动解码器的下一步标记生成,如此循环。实际上,目标标记序列与输入标记序列相同,且位移了一位(类似于 seq2seq 解码器)。
解码器使用与编码器相同的嵌入向量和位置编码。它继续通过堆叠N=6个相同的解码器块。每个块由三个子层组成,每个子层都使用残差连接、丢弃法和归一化。与编码器一样,这些块有后-ln 和前-ln 两种形式。子层如下:
- 一种带掩码的多头自注意力机制。编码器的自注意力是双向的——它可以关注序列中的所有元素,无论这些元素是在当前元素之前还是之后。然而,解码器只有部分生成的目标序列。因此,解码器是单向的——自注意力只能关注前面的序列元素。在推理过程中,我们别无选择,只能以顺序的方式运行转换器,从而让它一个接一个地生成输出序列中的每个标记。然而,在训练过程中,我们可以同时输入整个目标序列,因为它是提前已知的。为了避免非法的前向注意力,我们可以通过在注意力的软最大输入中将所有非法值设为−∞来屏蔽非法连接。我们可以在图 7.6中的Transformer 注意力部分看到掩码组件,掩码操作的结果如下:
![<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" display="block"><mml:mi mathvariant="normal">m</mml:mi><mml:mi mathvariant="normal">a</mml:mi><mml:mi mathvariant="normal">s</mml:mi><mml:mi mathvariant="normal">k</mml:mi><mml:mfenced separators="|">mml:mrow<mml:mi mathvariant="bold">Q</mml:mi>mml:msupmml:mrow<mml:mi mathvariant="bold">K</mml:mi></mml:mrow>mml:mrow<mml:mi mathvariant="normal">⊤</mml:mi></mml:mrow></mml:msup></mml:mrow></mml:mfenced>mml:mo=</mml:mo><mml:mi mathvariant="normal">m</mml:mi><mml:mi mathvariant="normal">a</mml:mi><mml:mi mathvariant="normal">s</mml:mi><mml:mi mathvariant="normal">k</mml:mi><mml:mfenced separators="|">mml:mrow<mml:mfenced open="[" close="]" separators="|">mml:mrowmml:mtablemml:mtrmml:mtdmml:msubmml:mrowmml:mie</mml:mi></mml:mrow>mml:mrowmml:mn11</mml:mn></mml:mrow></mml:msub></mml:mtd>mml:mtdmml:mo⋯</mml:mo></mml:mtd>mml:mtdmml:msubmml:mrowmml:mie</mml:mi></mml:mrow>mml:mrowmml:mn1</mml:mn><mml:mi mathvariant="bold-italic">n</mml:mi></mml:mrow></mml:msub></mml:mtd></mml:mtr>mml:mtrmml:mtdmml:mo⋮</mml:mo></mml:mtd>mml:mtdmml:mo⋱</mml:mo></mml:mtd>mml:mtdmml:mo⋮</mml:mo></mml:mtd></mml:mtr>mml:mtrmml:mtdmml:msubmml:mrowmml:mie</mml:mi></mml:mrow>mml:mrow<mml:mi mathvariant="bold-italic">m</mml:mi>mml:mn1</mml:mn></mml:mrow></mml:msub></mml:mtd>mml:mtdmml:mo⋯</mml:mo></mml:mtd>mml:mtdmml:msubmml:mrowmml:mie</mml:mi></mml:mrow>mml:mrow<mml:mi mathvariant="bold-italic">m</mml:mi><mml:mi mathvariant="bold-italic">n</mml:mi></mml:mrow></mml:msub></mml:mtd></mml:mtr></mml:mtable></mml:mrow></mml:mfenced></mml:mrow></mml:mfenced>mml:mo=</mml:mo><mml:mfenced open="[" close="]" separators="|">mml:mrowmml:mtablemml:mtrmml:mtdmml:mtablemml:mtrmml:mtdmml:msubmml:mrowmml:mie</mml:mi></mml:mrow>mml:mrowmml:mn11</mml:mn></mml:mrow></mml:msub></mml:mtd>mml:mtdmml:mo-</mml:mo>mml:mi∞</mml:mi></mml:mtd>mml:mtdmml:mo-</mml:mo>mml:mi∞</mml:mi></mml:mtd></mml:mtr>mml:mtrmml:mtdmml:msubmml:mrowmml:mie</mml:mi></mml:mrow>mml:mrowmml:mn21</mml:mn></mml:mrow></mml:msub></mml:mtd>mml:mtdmml:msubmml:mrowmml:mie</mml:mi></mml:mrow>mml:mrowmml:mn22</mml:mn></mml:mrow></mml:msub></mml:mtd>mml:mtdmml:mo-</mml:mo>mml:mi∞</mml:mi></mml:mtd></mml:mtr>mml:mtrmml:mtdmml:msubmml:mrowmml:mie</mml:mi></mml:mrow>mml:mrowmml:mn31</mml:mn></mml:mrow></mml:msub></mml:mtd>mml:mtdmml:msubmml:mrowmml:mie</mml:mi></mml:mrow>mml:mrowmml:mn32</mml:mn></mml:mrow></mml:msub></mml:mtd>mml:mtdmml:msubmml:mrowmml:mie</mml:mi></mml:mrow>mml:mrowmml:mn33</mml:mn></mml:mrow></mml:msub></mml:mtd></mml:mtr></mml:mtable></mml:mtd>mml:mtdmml:mo⋯</mml:mo></mml:mtd>mml:mtdmml:mtablemml:mtrmml:mtdmml:mo-</mml:mo>mml:mi∞</mml:mi></mml:mtd></mml:mtr>mml:mtrmml:mtdmml:mo-</mml:mo>mml:mi∞</mml:mi></mml:mtd></mml:mtr>mml:mtrmml:mtdmml:mo-</mml:mo>mml:mi∞</mml:mi></mml:mtd></mml:mtr></mml:mtable></mml:mtd></mml:mtr>mml:mtrmml:mtdmml:mo⋮</mml:mo></mml:mtd>mml:mtdmml:mo⋱</mml:mo></mml:mtd>mml:mtdmml:mo⋮</mml:mo></mml:mtd></mml:mtr>mml:mtrmml:mtdmml:mtablemml:mtrmml:mtdmml:msubmml:mrowmml:mie</mml:mi></mml:mrow>mml:mrow<mml:mi mathvariant="bold-italic">m</mml:mi>mml:mn1</mml:mn></mml:mrow></mml:msub></mml:mtd>mml:mtdmml:msubmml:mrowmml:mie</mml:mi></mml:mrow>mml:mrowmml:mim</mml:mi>mml:mn2</mml:mn></mml:mrow></mml:msub></mml:mtd>mml:mtdmml:msubmml:mrowmml:mie</mml:mi></mml:mrow>mml:mrowmml:mim</mml:mi>mml:mn3</mml:mn></mml:mrow></mml:msub></mml:mtd></mml:mtr></mml:mtable></mml:mtd>mml:mtdmml:mo⋯</mml:mo></mml:mtd>mml:mtdmml:msubmml:mrowmml:mie</mml:mi></mml:mrow>mml:mrow<mml:mi mathvariant="bold-italic">m</mml:mi><mml:mi mathvariant="bold-italic">n
-
常规注意力机制(而不是自注意力机制),查询来自前一个解码器层,键和值来自编码器输出。这使得解码器中的每个位置都能关注原始输入序列中的所有位置。这模拟了我们在引入 seq2seq 模型部分中讨论的典型编码器-解码器注意力机制。
-
FFN,与编码器中的类似。
解码器以一个全连接层结束,接着是 softmax 操作,它生成句子中最可能的下一个单词。
我们可以使用在引入 seq2seq 模型部分中定义的教师强迫过程来训练完整的编码器-解码器模型。
接下来,我们来实现解码器。
实现解码器
在这一部分,我们将以与编码器类似的模式实现解码器。我们将从主模块Decoder
的实现开始:
class Decoder(torch.nn.Module):
def __init__(self, block: DecoderBlock, N: int, vocab_size: int):
super(Decoder, self).__init__()
self.blocks = clones(block, N)
self.norm = torch.nn.LayerNorm(block.size)
self.projection = torch.nn.Linear(block.size, vocab_size)
def forward(self, x, encoder_states, source_mask, target_mask):
for layer in self.blocks:
x = layer(x, encoder_states, source_mask, target_mask)
x = self.norm(x)
return torch.nn.functional.log_softmax(self.projection(x), dim=-1)
它由N
个DecoderBlock
实例(self.blocks
)组成。正如我们在forward
方法中看到的,每个DecoderBlock
实例的输出作为下一个实例的输入。这些后面跟着self.norm
归一化(一个LayerNorm
实例)。解码器以一个全连接层(self.projection
)结束,接着是 softmax 操作,以生成最可能的下一个单词。需要注意的是,Decoder.forward
方法有一个额外的参数encoder_states
,该参数会传递给DecoderBlock
实例。encoder_states
表示编码器的输出,是编码器和解码器之间的联系。此外,source_mask
参数提供了解码器自注意力的掩码。
接下来,我们来实现DecoderBlock
类:
class DecoderBlock(torch.nn.Module):
def __init__(self,
size: int,
self_attn: MultiHeadedAttention,
encoder_attn: MultiHeadedAttention,
ffn: PositionwiseFFN,
dropout=0.1):
super(DecoderBlock, self).__init__()
self.size = size
self.self_attn = self_attn
self.encoder_attn = encoder_attn
self.ffn = ffn
self.sublayers = clones(SublayerConnection(size,
dropout), 3)
def forward(self, x, encoder_states, source_mask, target_mask):
x = self.sublayers0)
x = self.sublayers1)
return self.sublayers2
该实现遵循EncoderBlock
的模式,但针对解码器进行了调整:除了自注意力(self_attn
),我们还增加了编码器注意力(encoder_attn
)。因此,我们实例化了三个sublayers
实例(即熟悉的SublayerConnection
类的实例):分别用于自注意力、编码器注意力和 FFN。
我们可以看到在DecoderBlock.forward
方法中结合了多种注意力机制。encoder_attn
以前一个解码器块的输出(x
)作为查询,并结合编码器输出(encoder_states
)的键值对。通过这种方式,常规注意力机制在编码器和解码器之间建立了联系。另一方面,self_attn
使用x
作为查询、键和值。
这就完成了解码器的实现。接下来,我们将在下一部分中构建完整的 transformer 模型。
将所有内容结合起来
现在我们已经实现了编码器和解码器。接下来让我们将它们组合在一起,构建完整的EncoderDecoder
类:
class EncoderDecoder(torch.nn.Module):
def __init__(self,
encoder: Encoder,
decoder: Decoder,
source_embeddings: torch.nn.Sequential,
target_embeddings: torch.nn.Sequential):
super(EncoderDecoder, self).__init__()
self.encoder = encoder
self.decoder = decoder
self.source_embeddings = source_embeddings
self.target_embeddings = target_embeddings
def forward(self, source, target, source_mask, target_mask):
encoder_output = self.encoder(
x=self.source_embeddings(source),
mask=source_mask)
return self.decoder(
x=self.target_embeddings(target),
encoder_states=encoder_output,
source_mask=source_mask,
target_mask=target_mask)
它将 encoder
、decoder
、source_embeddings
和 target_embeddings
结合在一起。forward
方法接受源序列并将其输入到 encoder
中。然后,decoder
从前一步的输出中获取输入(x=self.target_embeddings(target)
),以及编码器的状态(encoder_states=encoder_output
)和源目标掩码。使用这些输入,它生成预测的下一个词(或标记),这是 forward
方法的返回值。
接下来,我们将实现 build_model
函数,该函数实例化我们实现的所有类,生成一个单一的 Transformer 实例:
def build_model(source_vocabulary: int,
target_vocabulary: int,
N=6, d_model=512, d_ff=2048, h=8, dropout=0.1):
c = copy.deepcopy
attn = MultiHeadedAttention(h, d_model)
ff = PositionwiseFFN(d_model, d_ff, dropout)
position = PositionalEncoding(d_model, dropout)
model = EncoderDecoder(
encoder=Encoder(
EncoderBlock(d_model, c(attn), c(ff), dropout), N),
decoder=Decoder(
DecoderBlock(d_model, c(attn), c(attn),
c(ff), dropout), N, target_vocabulary),
source_embeddings=torch.nn.Sequential(
Embeddings(d_model, source_vocabulary), c(position)),
target_embeddings=torch.nn.Sequential(
Embeddings(d_model, target_vocabulary), c(position)))
# Initialize parameters with random weights
for p in model.parameters():
if p.dim() > 1:
torch.nn.init.xavier_uniform_(p)
return model
除了熟悉的 MultiHeadedAttention
和 PositionwiseFFN
,我们还创建了一个 position
变量(PositionalEncoding
类的实例)。这个类实现了我们在 Transformer 编码器 部分描述的静态位置编码(我们不会在这里包含完整的实现)。
现在,让我们关注 EncoderDecoder
的实例化:我们已经熟悉了 encoder
和 decoder
,所以这部分没有什么意外。但嵌入层则更为有趣。以下代码实例化了源嵌入(但这对目标嵌入同样有效):
source_embeddings=torch.nn.Sequential(Embeddings(d_model, source_vocabulary), c(position))
我们可以看到,它们是两个组件的顺序列表:
-
Embeddings
类的一个实例,它只是torch.nn.Embedding
的组合,进一步乘以(我们这里不包括类定义)
-
位置编码
c(position)
,它将静态位置数据添加到嵌入向量中
一旦我们以这种方式预处理了输入数据,它就可以作为编码器-解码器核心部分的输入。
在下一部分中,我们将讨论 Transformer 架构的主要变体。
仅解码器和仅编码器模型
到目前为止,我们已经讨论了 Transformer 架构的完整编码器-解码器变体。但在实际应用中,我们主要会使用它的两种变体:
-
仅编码器:这些模型只使用完整 Transformer 的编码器部分。仅编码器模型是双向的,遵循编码器自注意力的特性。
-
仅解码器:这些模型只使用 Transformer 的解码器部分。仅解码器模型是单向的,遵循解码器的掩蔽自注意力特性。
我知道这些枯燥的定义听起来有些模糊,但别担心——在接下来的两部分中,我们将讨论每种类型的一个例子来澄清。
来自 Transformer 的双向编码器表示
来自变压器的双向编码器表示 (BERT; 详见 arxiv.org/abs/1810.04805
),顾名思义,是一种仅编码器(因此是双向的)模型,用于学习表示。这些表示作为解决各种下游任务的基础(纯 BERT 模型不解决任何特定问题)。以下图表显示了通用的前层归一化和后层归一化编码器模型,带有 softmax 输出(这也适用于 BERT):
图 7.10 – 左:后层归一化编码器模型;右:前层归一化编码器模型
BERT 模型规格
BERT 有两种变体 和
。
包含 12 个编码器块,每个块有 12 个注意力头,768 维注意力向量(
参数),总共 110M 参数。
包含 24 个编码器块,每个块有 16 个注意力头,1,024 维注意力向量,总共 340M 参数。这些模型使用 WordPiece 分词,并有一个 30,000 个词汇量。
让我们从 BERT 表示其输入数据的方式开始,这是其架构的重要部分。我们可以在以下图表中看到输入数据表示:
图 7.11 – BERT 输入嵌入作为标记嵌入、分段嵌入和位置嵌入的总和(来源:https://arxiv.org/abs/1810.04805)
由于 BERT 是仅编码器模型,它有两种特殊的输入数据表示模式,使其能够处理各种下游任务:
-
单一序列(例如,在分类任务中,如情感分析,或SA)
-
一对序列(例如,机器翻译或问答(QA)问题)
每个序列的第一个标记总是一个特殊的分类标记[CLS]
。与此标记对应的编码器输出作为分类任务的聚合序列表示。例如,如果我们想对序列进行 SA 分析,[CLS]
输入标记对应的输出将表示模型的情感(正面/负面)输出(当输入数据是单一序列时,此示例相关)。这是必要的,因为[CLS]
标记充当查询,而输入序列的其他所有元素充当键/值存储。通过这种方式,序列中的所有标记都参与加权注意力向量,它作为输入进入模型的其余部分。选择除了[CLS]
以外的其他标记将把该标记排除在注意力公式之外,这会导致对该标记的不公平偏见,并导致不完整的序列。
如果输入数据是一个序列对,我们将它们合并成一个单一的序列,用特殊的[SEP]
标记分隔。除此之外,我们为每个标记添加了额外学习到的分段嵌入,表示它属于序列 A 还是序列 B。因此,输入嵌入是标记嵌入、分段嵌入和位置嵌入的总和。在这里,标记嵌入和位置嵌入的作用与常规的 Transformer 相同。
现在我们已经熟悉了输入数据的表示形式,让我们继续进行训练。
BERT 训练
BERT 训练是一个两步过程(这对其他基于 Transformer 的模型同样适用):
-
预训练:在不同的预训练任务上使用无标签数据训练模型。
-
微调:一种迁移学习(TL)的形式,我们通过预训练的参数初始化模型,并在特定下游任务的有标签数据集上进行微调。
我们可以在下面的图中看到左侧是预训练,右侧是微调:
图 7.12 – 左:预训练;右:微调(来源:https://arxiv.org/abs/1810.04805)
这里,Tok N表示一热编码输入标记,E表示标记嵌入,T表示模型输出向量。最顶部的标签表示我们可以在每种训练模式下使用模型进行的不同任务。
本文的作者通过两个无监督的训练任务对模型进行了预训练:掩码语言模型(MLM)和下一句预测(NSP)。
我们从 MLM(掩码语言模型)开始,其中模型输入一个序列,目标是预测该序列中缺失的单词。MLM 本质上类似于[MASK]
标记(80%的情况)、一个随机单词(10%的情况),或者保持单词不变(10%的情况)。这是必要的,因为下游任务的词汇表中没有[MASK]
标记。另一方面,预训练模型可能会期望它,这可能导致不可预测的行为。
接下来,让我们继续讨论 NSP(下一句预测)。作者认为,许多重要的下游任务,如问答或自然语言推理(NLI),基于理解两个句子之间的关系,而这种关系并未被语言建模直接捕捉。
NLI
NLI(自然语言推理)决定一个句子(表示假设)在给定另一个句子(称为前提)的情况下,是否为真(蕴含)、假(矛盾)或未确定(中立)。例如,给定前提我在跑步,我们有以下假设:我在睡觉是假的;我在听音乐是未确定的;我在训练是真的。
BERT 的作者提出了一种简单优雅的无监督解决方案来预训练模型,理解句子之间的关系(如图 7.12左侧所示)。我们将模型训练为二分类任务,每个输入样本以[CLS]
标记开始,由两个序列(为了简便,我们称之为句子A和B)组成,这两个序列通过[SEP]
标记分隔。我们将从训练语料库中提取句子A和B。在 50%的训练样本中,B是紧跟A的实际下一个句子(标记为is_next
)。在另外 50%的样本中,B是语料库中的随机句子(标记为not_next
)。如我们所提到,模型将在与输入对应的[CLS]
上输出is_next
/not_next
标签。
接下来,让我们专注于微调任务,它是在预训练任务之后进行的(如图 7.12右侧所示)。这两个步骤非常相似,但不同的是,我们不再创建一个掩码序列,而是将任务特定的未修改输入和输出直接输入 BERT 模型,并以端到端的方式微调所有参数。因此,我们在微调阶段使用的模型与我们将在实际生产环境中使用的模型相同。
让我们继续探讨一些可以通过 BERT 解决的下游任务。
BERT 下游任务
以下图表展示了如何通过 BERT 解决几种不同类型的任务:
图 7.13 – BERT 在不同任务中的应用(来源:arxiv.org/abs/1810.04805
)
让我们来讨论一下这两个任务:
-
左上方的场景展示了如何使用 BERT 进行句子对分类任务,如 NLI。简而言之,我们将两个连接的句子输入模型,并只查看
[CLS]
令牌的输出分类,进而得出模型结果。例如,在 NLI 任务中,目标是预测第二个句子与第一个句子的关系,是蕴含、矛盾还是中立。 -
右上方的场景展示了如何使用 BERT 进行单句分类任务,如 SA。这与句子对分类非常相似。在这两种情况下,我们都将编码器扩展为一个全连接层(FC 层)和一个二进制 Softmax,并有N个可能的类别(N是每个任务的类别数)。
-
左下方的场景展示了如何在 QA 数据集上使用 BERT。假设序列A是一个问题,序列B是来自Wikipedia的段落,包含答案,目标是预测答案在该段落中的文本跨度(起始和结束)。模型输出序列B中每个令牌作为答案起始或结束的概率。
-
右下方的场景展示了如何使用 BERT 进行命名实体识别(NER),其中每个输入令牌都被分类为某种类型的实体。
这就是我们对 BERT 模型部分的介绍。接下来,让我们聚焦于仅解码器模型。
生成预训练变换器
在本节中,我们将讨论一个仅解码器模型,称为生成预训练变换器(GPT;参见通过生成预训练提升语言理解,cdn.openai.com/research-covers/language-unsupervised/language_understanding_paper.pdf
)。这是 OpenAI 发布的一系列 GPT 模型中的第一个,后续推出了如今著名的 GPT-3 和 GPT-4。
GPT 模型的规模
GPT 有 12 个解码器层,每个层有 12 个注意力头,768 维度的注意力向量。FFN 的维度为 3,072。该模型共有 1.17 亿个参数(权重)。GPT 使用 BPE 分词,并且具有 40,000 个令牌词汇表。
我们可以在下面的图中看到 GPT 仅解码器架构:
图 7.14 – 左:后-ln 解码器模型;右:前-ln 解码器模型;预训练和微调训练步骤的不同输出
注
我们在原始 GPT 论文的背景下讨论仅解码器架构,但它适用于广泛的仅解码器模型类。
它源自我们在Transformer 解码器部分讨论的解码器。该模型将令牌嵌入作为输入,并添加静态位置编码。接着是一个N个解码器块的堆叠。每个块有两个子层:
-
掩蔽的多头自注意力:我们重点强调“掩蔽”部分。它决定了仅解码器模型的主要特性——单向性和自回归性。这与双向的编码器模型是对立的。
-
FFN:这个子层的作用与编码器-解码器模型中的作用相同。
子层包含相关的残差链接、归一化和丢弃层。解码器有预-ln 和后-ln 两种形式。
模型以一个全连接层(FC)结束,后面跟着一个 softmax 操作,可以根据具体任务进行调整。
该解码器与完整编码器-解码器转换器中的解码器的主要区别在于缺少一个注意力子层,该子层在完整模型中连接编码器和解码器部分。由于当前架构没有编码器部分,因此该子层已不再使用。这使得解码器与编码器非常相似,除了掩蔽的自注意力。由此,编码器模型和解码器模型之间的主要区别在于它们分别是双向和单向的。
与 BERT 类似,GPT 的训练也是一个两步过程,包括无监督的预训练和有监督的微调。我们先从预训练开始,它类似于我们在介绍 seq2seq 模型部分描述的 seq2seq 训练算法(解码器部分)。提醒一下,我们训练原始的 seq2seq 模型,将输入的标记序列转换为另一个不同的输出标记序列。这类任务的例子包括机器翻译和问答。原始的 seq2seq 训练是有监督的,因为匹配输入和输出序列相当于标签化。一旦完整的输入序列被送入 seq2seq 编码器,解码器就开始一次生成一个标记的输出序列。实际上,seq2seq 解码器学会了预测序列中的下一个单词(与 BERT 预测完整序列中任何被遮蔽的单词不同)。在这里,我们有一个类似的算法,但输出序列与输入序列相同。从语言建模的角度来看,预训练学习的是近似下一个标记的条件概率,,给定输入标记序列
和模型参数θ:
。
让我们通过一个例子来说明预训练。我们假设输入序列为[[START],
``,
``, ...,
``]
,我们将训练对标记为{input: label}
。我们的训练对为{[[START]]:
``}
,{[[START],
``]:
``}
,以及{[[START],
``,...,
``]:
``}
。我们可以从下图中看到相同的场景:
图 7.15 – GPT 预训练以预测相同输入/输出序列的下一个词
接下来,让我们讨论监督式微调步骤,这与 BERT 的微调类似。以下图示说明了在 GPT 中,序列分类和 NLI 任务是如何工作的:
图 7.16 – GPT 微调;上:文本分类;下:自然语言推理(NLI)
在这两种情况下,我们都有特殊的[START]
和[EXTRACT]
标记。[EXTRACT]
标记在这里起着与 BERT 中的[CLS]
相同的作用——我们将该标记的输出作为分类结果。但是在这里,它位于序列的末尾,而不是开头。再次说明,这样做的原因是解码器是单向的,并且只能在序列末尾完全访问输入序列。NLI 任务将前提和蕴含通过一个特殊的[
DELIM]`标记连接起来。
这就是我们对 GPT 的介绍——解码器-only 模型的典型示例。至此,我们已经介绍了三种主要的变换器架构——编码器-解码器、仅编码器和仅解码器。这也是本章的一个良好总结。
总结
本章的重点是注意力机制和变换器。我们从 seq2seq 模型开始,并讨论了 Bahdanau 和 Luong 注意力机制。在此基础上,我们逐步引入了 TA 机制,然后讨论了完整的编码器-解码器变换器架构。最后,我们专注于仅编码器和仅解码器的变换器变种。
在下一章,我们将专注于 LLM,并探讨 Hugging Face 的变换器库。
第八章:深入探索大型语言模型
近年来,学术界、工业界甚至大众对 Transformer 的兴趣急剧上升。当前最前沿的基于 Transformer 的架构被称为大型语言模型(LLM)。其最吸引人的特点是文本生成能力,而最著名的例子便是 ChatGPT(chat.openai.com/
)。但在其核心,依旧是我们在 第七章中介绍的朴素 Transformer。幸运的是,我们已经建立了坚实的 Transformer 基础。这一架构的一个显著特点是,自从引入以来,它几乎没有发生过太大变化。相反,LLM 的能力随着模型规模的增大而增长(这一点从名称上就可以看出来),这也为“量变导致质变”这一说法提供了有力的佐证。
LLM 的成功进一步推动了该领域的研究(或者是反过来?)。一方面,大型工业实验室(如 Google、Meta、Microsoft 或 OpenAI)投入大量资金,以推动更大规模 LLM 的边界。另一方面,灵活的开源社区也以有限的资源找到创造性的方式,取得了大量进展。
在本章中,我们将从理论和实践两个角度探索当前的 LLM 领域。我们将调查许多最新的 LLM,它们的特性以及训练过程。此外,我们还将看到如何借助 Hugging Face Transformers 库将它们应用于我们的目标。
在本章中,我们将涵盖以下主要内容:
-
介绍 LLM
-
LLM 架构
-
训练 LLM
-
LLM 的突现能力
-
介绍 Hugging Face Transformers
技术要求
我们将使用 Python、PyTorch 和 Hugging Face Transformers 库(github.com/huggingface/transformers
)实现本章中的示例。如果你没有安装这些工具的环境,也不必担心——示例可以在 Google Colab 的 Jupyter notebook 中找到。代码示例也在本书的 GitHub 仓库中: github.com/PacktPublishing/Python-Deep-Learning-Third-Edition/tree/main/Chapter08
。
介绍 LLM
在本节中,我们将采用更系统化的方法,深入探讨基于 Transformer 的架构。正如我们在介绍中提到的,自 2017 年引入以来,Transformer 模块的变化几乎可以说微乎其微。相反,主要的进展体现在模型规模和训练数据集的增大。例如,原始的 GPT 模型(GPT-1)有 1.17 亿个参数,而 GPT-3(语言模型是少样本学习者,arxiv.org/abs/2005.14165
)则有 1750 亿个参数,增加了 1000 倍。我们可以根据模型的规模区分两类非正式的 Transformer 模型:
-
预训练语言模型(PLMs):具有较少参数的变换器,例如双向编码器表示来自变换器(BERT)和生成式预训练变换器(GPT)属于这一类别。从 BERT 开始,这些变换器引入了两步预训练/微调(FT)范式。注意力机制和无监督预训练(掩蔽语言建模(MLM)或下一个词预测(NWP)的结合创造了有效的通用语义特征,我们可以利用这些特征进行多个下游任务。因此,PLMs 比其他自然语言处理(NLP)算法(如递归神经网络(RNNs))表现得更好。加上它们高度并行化的架构,这激发了很多基于变换器的后续研究,产生了改进的模型,并最终带来了下一个类别。
-
LLMs:这些是具有数十亿参数的变换器模型。LLMs 与 PLMs 在以下几个方面有质的区别:
-
突现能力:它们能够解决一系列复杂的任务,我们将在LLMs 的突现能力部分进行讨论
-
提示接口:LLMs 可以通过自然语言与人类互动,而无需特定的 API
-
研究与工程的融合:LLM 的规模要求研究人员具备强大的大规模数据处理和并行训练的工程能力
-
目前,LLMs 几乎完全是解码器-only 模型,因为当前 LLMs 的主要应用集中在文本生成(例如,像 ChatGPT 这样的聊天机器人)。这种情况是以牺牲编码器-only 和编码器-解码器架构为代价的。为了更好地理解为什么会这样,我们来看一下聊天机器人的工作原理。它以用户生成的消息(称为提示)开始。提示是解码器模型的初始输入序列,该模型每次生成一个标记作为响应。响应会被添加回输入序列。一个特殊的标记将提示和响应分开。一旦 LLM 生成了响应,用户可以再发出一个新的提示。在这种情况下,我们将新提示与现有序列连接,并要求 LLM 基于扩展的序列创建新的响应。LLM 没有机制去记住现有的聊天会话,除了将其作为输入序列的一部分。这一过程可以无限继续。然而,一旦达到上下文窗口的最大长度,它将开始截断序列的最初部分(我们可以把它看作一个滑动窗口)。
注意
本章部分内容来源于论文《大规模语言模型概览》(arxiv.org/abs/2303.18223
)。我们将简单地称之为概览。
LLM 架构
在第七章中,我们介绍了多头注意力(MHA)机制以及三种主要的 Transformer 变体——编码器-解码器、仅编码器和仅解码器(我们使用 BERT 和 GPT 作为典型的编码器和解码器模型)。在本节中,我们将讨论 LLM 架构的各个方面。让我们首先集中注意力(是的——这又是那个老笑话)在注意力机制上。
LLM 注意力变体
到目前为止,我们讨论的注意力机制被称为全局注意力。下图展示了双向全局自注意力机制的连接矩阵(上下文窗口大小为 n=8):
图 8.1 – 带有上下文窗口大小为 n=8 的全局自注意力
每一行和每一列代表完整的输入令牌序列,。虚线彩色对角单元格表示当前输入令牌(查询),
。每列中未中断的彩色单元格表示所有令牌(键),这些令牌是
可以关注的对象。例如,
关注所有前面的令牌,[t 1…t 4],
以及所有后续的标记,。术语全局意味着
会关注所有的标记。因此,所有的单元格都会被着色。正如我们将在稀疏注意力部分看到的,存在一些注意力的变体,并非所有标记都会参与其中。我们将用透明单元格来表示这些标记。该图展示了双向自注意力机制,因为查询可以同时关注前面的(下方)和后面的(上方)元素。在单向情况下,查询只会关注当前输入标记下方的元素。例如,
只会关注
。
正如我们将看到的,注意力机制的一个主要挑战是其时间和空间复杂度。
注意力复杂度
尽管注意力机制(特别是全局注意力)有其优点,但也存在一些缺点。其中之一是随着上下文窗口的增大,空间和时间复杂度会呈二次方增长。这是因为该机制是通过矩阵和矩阵乘法实现的。
矩阵乘法的时间复杂度
两个n×n矩阵相乘的时间复杂度是,因为经典实现使用了三重嵌套循环。在实践中,该算法经过优化,复杂度较低。本节目的是使用经典实现的复杂度。
例如,大小为n=4的上下文窗口会产生n×n=4x4 Q 和 V 矩阵,每个矩阵有 16 个单元格。但是,大小为n=8的上下文窗口会产生n×n=8x8 Q 和 V 矩阵,每个矩阵有 64 个单元格。因此,两倍大的上下文窗口需要四倍的内存。由于矩阵乘法的时间复杂度是,将上下文窗口从n=4增加到n=8会将操作数从
增加到
。
接下来,让我们专注于变压器块,其中包括一个前馈网络(FFN),
多头自注意力机制和四个线性投影(全连接(FC)层)——三个用于Q/K/V预注意力分离,一个用于合并注意力头的输出。我们将讨论每个组件在模块计算负载中的相对权重。我们用表示嵌入大小,用
表示关键维度,用
表示值维度(
),用上下文窗口大小n,头的数量h,以及 FFN 中隐藏层的大小ffn(通常约定为ffn=4d*)表示。不同组件的时间复杂度如下所示:
-
:三个输入线性投影用于所有头
-
: h 自注意力头
-
: 第四个自注意力头后的输出线性投影
-
: FFN 模块
该块的完整综合复杂度为 。我们可以看到它依赖于上下文窗口长度 n 与嵌入大小 d 之间的比例。如果 d>>n,则线性投影的计算时间将超过注意力头的时间,反之亦然。在实际应用中,d>>n 是最常见的情况。但无论如何,注意力机制至少具有二次空间和时间复杂度。我们来看看一些应对这一挑战的解决方案。
多查询和分组查询注意力
MHA 将输入数据通过每个头的三个线性投影分支到多个头。下图展示了该配置的两种优化:
图 8.2 – 左侧:MHA;中间:多查询注意力(MQA);右侧:分组查询注意力(GQA)(灵感来源于 arxiv.org/abs/2305.13245
)
我们来讨论它们(除了我们在 第七章 中介绍的 MHA)。
-
MQA(快速转换器解码:只需一个写头,
arxiv.org/abs/1911.02150
):不同的头共享键和值投影,而不是 MHA 中的独立投影。由于输入序列相同,所有头共享相同的键值存储,仅在查询上有所不同。这个优化减少了内存和计算需求,且几乎没有性能损失。 -
GQA(GQA:从多头检查点训练通用多查询转换器模型,
arxiv.org/abs/2305.13245
):MHA 和 MQA 的混合体,为一组查询头共享单一的键和值头。作者显示,GQA 的速度几乎与 MQA 一致,且质量接近 MHA。
在下一节中,我们将讨论注意力优化,它考虑了 GPU 内存管理的具体细节。
FlashAttention
在本节中,我们将介绍 FlashAttention(FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness,arxiv.org/abs/2205.14135;
FlashAttention-2: Faster Attention with Better Parallelism and Work Partitioning,arxiv.org/abs/2307.08691
)。这不是一种新的注意力机制,而是全球注意力的一种实现,考虑了 GPU 硬件的具体特点。GPU 拥有大量的计算核心,可以执行相对简单但高度可并行化的操作(如矩阵乘法)。它有两级内存:小但快速的缓存(L1 和 L2)和大但相对较慢的 高带宽内存 (HBM)。为了执行一个操作,它会将必要的数据从 HBM 转移到缓存。计算核心使用缓存进行计算。操作完成后,结果会存储回 HBM。在这个管道中,主要的瓶颈是数据传输,而不是实际的计算(数据传输越少越好)。
接下来,让我们关注注意力模块,它包含五个操作:1)矩阵乘法 (),2)掩码,3)softmax,4)dropout,和 5)矩阵乘法(V)。标准实现顺序地执行这些操作,从第一个矩阵乘法开始。完成后,它会继续进行掩码操作,依此类推。每个操作涉及 HBM 和缓存之间的双向数据传输。这些传输是多余的,因为操作 i 的结果从缓存传输到 HBM 后,又需要从 HBM 返回到缓存进行操作 i+1。FlashAttention 提出了一个特殊的 融合内核 来解决这种低效问题。它将 Q/K/V 矩阵拆分成可以适配缓存的小块。一旦这些块被传输到缓存中,融合内核将执行所有五个操作,无需中间的数据传输。只有最终结果被发送回 HBM。将矩阵拆分成小块是可行的,因为矩阵乘法是显式并行的。但 FlashAttention 的另一个创新是能够拆分 softmax 操作,这并不像矩阵乘法那样简单(我们不会详细介绍它是如何实现的)。当所有矩阵块通过这个管道时,操作就完成了。
矩阵乘法拆分
假设我们要对矩阵A和B进行乘法运算。由于矩阵乘法的运作方式,我们可以按列将B拆分为两个矩阵,和
。然后,我们在每个设备上执行两个矩阵乘法运算:
和
。最后,我们将两个操作的输出合并到一个矩阵中,相当于原始乘法产生的矩阵,AB。
在下一节中,我们将讨论通过新的注意力机制解决性能问题。
稀疏注意力
稀疏注意力是一类方法,其中输出向量仅关注所有关键向量的一个子集,而不是整个上下文窗口。例如,如果我们可以从八个上下文向量中选择四个感兴趣的向量进行关注,那么我们可以将所需的计算量减少一半。
下图展示了三种双向稀疏注意力机制:
图 8.3 – 左:局部注意力;中:膨胀局部注意力;右:随机注意力;上下文窗口大小 n=12
这些机制与图 8.2中的符号相同,唯一的区别是——透明单元格代表令牌(关键字),查询不会关注这些单元格。
在左侧,我们有双向局部注意力(或滑动窗口注意力),这是在图像 Transformer中首次提出的,arxiv.org/abs/1802.05751
。查询仅关注当前标记周围最近的w个键的有限上下文窗口(左边½w,右边½w)。自注意力模块仍然将整个n大小的序列作为输入,但每个标记只关注有限的w大小的局部上下文。这样,内存占用与全局注意力相同,但时间复杂度减少为,而不是
。
为了理解局部注意力为何有效,我们回顾一下卷积神经网络(CNN)。回想一下,CNN 的早期层具有较小的感受野,并捕获较小、更简单的特征。相反,CNN 的更深层具有较大的感受野,能够捕获更大和更复杂的特征。我们可以将相同的原则应用于 Transformer。研究表明,初始的 Transformer 模块学习简单的标记特征和局部语法,而更深层则学习标记语义中更复杂的上下文相关特征。因此,我们可以将局部注意力应用于较浅的 Transformer 模块,而将全局注意力保留给更深的模块,而不会牺牲性能。
扩展注意力(图 8.3,中间)是局部注意力的一种修改,工作原理类似于我们在第四章中介绍的扩展卷积。与局部注意力不同,这里上下文窗口不是连续的。相反,每个上下文标记之间有一个间隔(可以是多个单元格)。这使得在相同的计算数量下,可以关注更广泛的上下文。
接下来,我们有双向的 随机注意力 (图 8.3,右),其中当前查询(标记)会关注来自完整上下文窗口的 r 个键(标记)的子集。时间复杂度减少为 而不是
。注意力模式可以视为一个有向图。在随机注意力的情况下,这个图也是随机的。也就是说,信息可以在任何一对节点之间迅速流动,而不考虑数据的实际结构,这可能会带有偏见。
也可以结合全局和局部注意力。其中一个例子是 Longformer (Longformer:长文档变换器,arxiv.org/abs/2004.05150
),如下图所示:
图 8.4 – 结合了局部和全局注意力;左:Longformer 块;右:大鸟块
它在一个未修改的变换器模型中引入了一个可以替代的自注意力块。该块表示全局和局部(或扩展)注意力的结合。它对大多数输入标记应用局部注意力,但少数标记可以使用全局注意力。图 8.4 的左侧显示了结合的自注意力块和一个应用局部和全局注意力的输入标记示例。更具体地说,作者在一个单向 BERT 风格的模型中使用 Longformer 块来解决 MLM 和 [CLS]
在 MLM 任务中的问题。如图所示,全局注意力在两个方向上都有效。特殊标记可以关注所有其他标记,但其他标记除了其局部注意力上下文之外,还可以关注特殊标记。在自回归语言建模(单向模型)的情况下,他们仅应用扩展的局部注意力,因为没有具有特殊意义的标记。完整的 Longformer 模型在较深的层使用扩展注意力和更大的上下文窗口,而较早的层仅使用局部注意力。
大鸟 (图 8.4,右;大鸟:用于长序列的变换器,arxiv.org/abs/2007.14062
) 类似于 Longformer,但添加了随机注意力。
接下来,我们讨论由 OpenAI 开发的 稀疏变换器 注意力机制(生成长序列的稀疏变换器,arxiv.org/abs/1904.10509
)。稀疏变换器引入了单向步长和固定注意力机制,如下图所示:
图 8.5 – 左:步长稀疏注意力,l=4;右:固定稀疏注意力;输入图像大小 4×4;序列长度 n=12(灵感来自 arxiv.org/abs/1904.10509
)
为了理解它们是如何工作的,我们来讨论一下论文的背景。论文提出了一种统一的仅解码器模型,用于生成新的图像、文本或音频。根据使用场景,输入和输出数据可以是二维图像张量(为简化起见,我们省略颜色维度)。然而,变换器接受的是一维序列作为输入。我们可以通过将图像的行连接成一个一维张量来解决这个问题。完成后,我们可以将图像视为常规序列,并将其输入到模型中。图 8.5 显示了一个二维图像(顶部)及其等效的一维连接序列(底部)的步长(左)和固定注意力(右)连接矩阵。需要注意的是,底部扩展的序列与顶部图像的尺寸不匹配——它的长度应为 n=16,对应 4×4 图像,而不是现在的 n=12。由于这是一个生成式解码器模型,它使用单向注意力,尽管图像中的方向性并不像文本中那样明确。
接下来,我们讨论这两种注意力机制。我们从步长注意力开始,其中当前词元关注输入图像的前一行和列。这是两个分别在不同注意力头之间分开的机制:
- 行头:等同于单向局部注意力,它关注前一个
词元,其中
是二维输入图像每行的长度。我们可以用 i 表示当前输入词元的索引,用 j 表示它关注的词元。我们可以用以下方式总结行机制:
- 列头:等同于具有步幅(间隔)为
的单向膨胀注意力(与行头相同)。假设输入图像是正方形的,列头会跳过相当于一行的间隔 (
),并关注一个位置,表示在一维序列虚拟列中的前一个单元格。我们可以用以下方式总结列跨步注意力:
该方案在二维输入数据上表现最佳,例如图像,因为行/列划分反映了底层数据结构。该方案的时间复杂度为。
接下来,我们介绍固定注意力,它关注一个固定的列及其最新列元素之后的元素。对于非周期性数据(如文本),它表现更好。再次强调,这是两种独立机制在不同头之间的组合:
- 列头:关注于一个固定的列,该列不一定与当前输入标记的列相匹配,
。多个输入标记可以关注相同的列,这使得它能够关注整个序列的长度。我们可以用以下方式总结列机制:
这里,c 是一个参数(8、16 或 32)。例如,如果 l=64 且 c=16,那么所有大于 64 的位置可以关注 48-64 的位置,所有大于 128 的位置可以关注 112-128 的位置,以此类推。
- 行头:第一个头与跨步注意力中的行头类似。但不同的是,它不是关注整个行的长度,而是只关注当前列头的位置。行头提供了局部上下文。我们可以将其总结如下:
这里,floor 将除法结果向下取整到最接近的整数。
接下来,让我们将注意力集中在一种解码器架构的特殊案例及大语言模型架构的各个方面上(我简直停不下来)。
前缀解码器
在这一节中,我们将介绍前缀(或非因果)解码器(统一语言模型预训练用于自然语言理解与生成,arxiv.org/abs/1905.03197
)。这是一种仅包含解码器的模型,提出了一种新的注意力模式,如下图所示:
图 8.6 – 前缀解码器自注意力模式(灵感来自 https://arxiv.org/abs/1905.03197)
我们将输入序列分成两个部分—— 到
(源或前缀),以及
到
(目标)。源段落的词汇之间可以互相访问。而目标段落的词汇只能单向访问整个(源和目标)输入序列中前面的词汇。例如,
是源段落的一部分,可以访问
,
,和
。相反,
是目标的一部分,只能处理从
到
(但不能处理
)。
前缀解码器是编码器-解码器和解码器模型的混合体。源段充当编码器,目标段充当解码器,但其底层架构基于解码器。
我们可以使用前缀解码器来表示 [SOS]
和序列结束([EOS]
)标记。例如,我们来看一下文本摘要任务。我们将要总结的文本序列(S1
)及其摘要(S2
)表示为一个单一序列:[[SOS],S1,[EOS],S2,[EOS]]
。源序列 [[SOS],S1,[EOS]]
属于双向注意力模式,而目标序列 [S2,[EOS]]
则属于单向注意力模式。我们通过 MLM(Masked Language Model)预训练模型,其中我们从完整序列中随机遮蔽一些标记。我们通过随机遮蔽目标序列中的一些标记并学习恢复被遮蔽的词语来微调模型。需要注意的是,[EOS]
标记也可以参与遮蔽。通过这种方式,模型学习何时生成 [EOS]
标记,并终止目标序列的生成。
接下来,让我们更详细地了解 LLM 架构的各个方面。
Transformer 的基本构件
以下表格提供了主要 Transformer 网络配置及其变种的详细总结:
图 8.7 – 不同的 Transformer 配置(来源:https://arxiv.org/abs/2303.18223)
我们已经熟悉了其中的许多内容——我们在第七章介绍了三种不同的归一化位置。我们还在第三章介绍了三种归一化方法中的两种。默认情况下,大多数 Transformer 使用 层归一化 (LN)。然而,一些模型使用 RMSNorm,因为它在训练速度和性能上优于 LN。最后但同样重要的是,DeepNorm (DeepNet: 扩展 Transformer 至 1000 层, arxiv.org/abs/2203.00555
) 对我们来说是新的。正如论文标题所示,这种归一化帮助构建了一个 1000 层的 Transformer。作者认为,在层归一化 (pre-ln) 架构中,底层的梯度往往大于顶部层的梯度,这导致与 后层归一化 (post-ln) 模型相比性能下降。另一方面,后层归一化模型由于梯度爆炸而不稳定。为了克服这一问题,他们提出了一种简单而有效的残差连接归一化方法:
这里,α 是应用在残差连接输出处的常数。其值取决于变压器类型(编码器或解码器)和模型深度(块数)。DeepNorm 的理论基础在于通过这个常数限制模型更新。
接下来,让我们讨论激活函数。更具体地说,我们将讨论前馈网络(FFN)子层的第一层激活函数(ActivationFunc
),因为这是变压器块中唯一显式的激活函数。作为提醒,我们可以定义原始的 FFN 如下:
我们在 第三章 中讨论了大多数激活函数,除了 SwiGLU 和 GeGLU(GLU 变体改进变压器,arxiv.org/abs/2002.05202
)。它们是 门控线性单元(GLU)的变体,GLU 更像是层和激活函数的融合,而不是纯粹的激活函数。我们可以定义 GLU 如下:
这里,ActivationFunc 是一个特定的激活函数(SwiGLU 对应 Swish,GeGLU 对应 GeLU),⊗ 表示两个向量的逐元素乘积,W 和 V 是权重矩阵,表示线性投影(即,全连接层)。GLU 引入了一个额外的线性投影 V,与原始网络路径 W 并行。由于逐元素乘积,带有激活的路径 W 作为来自 V 路径信号的门控。这类似于 长短时记忆(LSTM)门。我们现在可以定义带 GLU 激活的前馈网络(FFN):
让我们注意到,作者已经从修改后的 FFN 中排除了偏置。这也是提及不同 LLMs 具有不同偏置配置的一个好地方,下面列出:
-
在线性投影和注意力块本身都使用偏置。
-
在线性投影中使用偏置,但在注意力块中不使用。
-
不要在线性投影或注意力块中使用偏置。
根据一些实验,缺乏偏置可以稳定训练。
接下来,让我们专注于迄今为止未提及的各种类型的位置嵌入。不幸的是(或者幸运的是),详细讨论它们超出了本书的范围。但要记住的重要一点是,我们有绝对(静态)或相对(动态)位置编码。在第一种情况下,我们修改输入令牌嵌入向量。在第二种情况下,我们修改与当前输入令牌位置相关的K/V注意力矩阵。
该调查总结了现有文献中有关详细变压器配置的建议。为了更强的泛化能力,建议使用预先的 RMSNorm 归一化,以及 SwiGLU 或 GeGLU 激活函数。此外,在嵌入层之后立即使用 LN 可能会导致性能下降。至于位置嵌入,Rotary Positional Embedding(RoPE)或Attention with Linear Biases(AliBi)在处理长序列时比其他方法表现更好。
现在我们对 LLMs 的架构属性已经很熟悉,让我们讨论具体的模型实例。
模型
以下表格总结了一些流行的最近 LLMs:
图 8.8 – 最近大型语言模型的模型卡,包含公开配置详情(修改自 https://arxiv.org/abs/2303.18223p)
这里,PE 表示位置嵌入,#L 表示变换器层数,#H 表示每层的注意力头数, 表示隐层状态的大小,MCL 表示训练期间的最大上下文长度。
我们将从 GPT 系列模型开始(由 OpenAI 开发),如以下图所示:
图 8.9 – GPT 系列模型的演变(灵感来源于 https://arxiv.org/abs/2303.18223)
我们已经熟悉 GPT-1,因此让我们继续了解 gpt-3.5-turbo
,其上下文长度为 4,096 个标记,gpt-3.5-turbo-16k
的上下文长度为 16,384 个标记。目前的 Copilot 版本基于 GPT-3.5。最新的模型 GPT-4 接受多模态输入(图像和文本),但仅输出文本。它也是封闭的,但可能具有超过 1T 的参数。根据 OpenAI 首席执行官 Sam Altman 的说法,训练 GPT-4 的成本已超过 1 亿美元(https://www.wired.com/story/openai-ceo-sam-altman-the-age-of-giant-ai-models-is-already-over/)。GPT-4 也通过 OpenAI 的 API 提供,有两个子变体——gpt-4
,上下文长度为 8,192 个标记,以及 gpt-4-32k
,上下文长度为 32,768 个标记。
接下来,让我们讨论 Meta 发布的LlaMa系列预训练(且未微调)模型。第一个版本(LLaMA: Open and Efficient Foundation Language Models, arxiv.org/abs/2302.13971
)有四个变体,参数量从 6B 到 65B 不等。由于 Meta 还发布了其权重(尽管不允许商业使用),这是开源社区中最受欢迎的 LLM 之一。这样,Meta 完成了预训练模型的重担,开源社区则将其作为基础模型使用,因为它可以通过相对较少的计算资源进行微调。最近,Meta 发布了Llama 2——Llama 的更新版(Llama 2: Open Foundation and Fine-Tuned Chat Models, ai.meta.com/research/publications/llama-2-open-foundation-and-fine-tuned-chat-models
)。它有三个变体,分别为 7B、13B 和 70B 参数。Llama 2 使用比 Llama 1 多 40%的预训练数据,并且每个变体还有一个使用 RLHF 微调的版本。该模型的许可证允许商业使用(有些限制)。
这就结束了我们对 LLM 架构的调查。接下来,让我们讨论它们的训练。
训练 LLM
由于大多数 LLM 是仅解码器模型,最常见的 LLM 预训练任务是 NWP。模型参数的庞大数量(可达数百亿个)需要相对较大的训练数据集来防止过拟合,并实现模型的全部能力。这一要求带来了两个重大挑战:确保训练数据的质量和处理大量数据的能力。在接下来的部分中,我们将讨论 LLM 训练流水线的各个方面,从训练数据集开始。
训练数据集
我们可以将训练数据分为两大类:
-
通用:例如网页、书籍或对话文本。LLM 几乎总是基于通用数据进行训练,因为这些数据广泛可用且多样化,能够提升 LLM 的语言建模和泛化能力。
-
专业:代码、科学文章、教科书或多语言数据,旨在为 LLM 提供特定任务的能力。
以下表格列出了最受欢迎的语言模型数据集:
图 8.10 – 语言建模数据集(修改自 https://arxiv.org/abs/2303.18223)
让我们来讨论一下:
-
书籍:我们将专注于两个数据集:
-
BookCorpus(Aligning Books and Movies: Towards Story-like Visual Explanations by Watching Movies and Reading Books,
arxiv.org/abs/1506.06724
):包含了 11,000 本虚构书籍,约有 10 亿个词(2015 年发布)。 -
古腾堡计划 (
www.gutenberg.org/
):包括 70,000 本小说类书籍。
-
-
Common Crawl (
commoncrawl.org/
):PB 级别的网络抓取数据库。数据按照获取日期进行分割,从 2008 年开始。最新的档案包含 31 亿网页(390 TiB 的未压缩内容),这些数据来源于 4,400 万个主机或 3,500 万个注册域名。虽然包含大量低质量数据,但也有多个子集包含更高质量的数据:-
庞大且清理过的 Common Crawl 版本(C4):由 Google 开发的 800 GiB 数据集。原始数据集不可下载,但 Google 已发布工具,以便从 Common Crawl 数据库中重建该数据集。2019 年,艾伦人工智能研究所(AI2,https://allenai.org/)发布了该数据集的重建版本,可通过
huggingface.co/datasets/allenai/c4
获取。其最受欢迎的子版本是 en 版本,它移除了所有包含“坏词”列表中单词的文档(“坏词”列表可通过github.com/LDNOOBW/List-of-Dirty-Naughty-Obscene-and-Otherwise-Bad-Words
查看)。 -
CC-News:来自全球各大新闻网站的文章。
-
RealNews:从 Google News 索引的 5,000 个新闻域名中提取的新闻文章。
-
CC-Stories-R:一个用于常识推理和语言建模的数据集。它由与常识推理任务中的问题具有最大重叠的 n-gram 的 Common Crawl 文档组成。新的训练语料库代表了排名前 1.0% 的高质量文档。
-
-
Reddit 链接:解决 Common Crawl 低信噪比的一种方法是依赖人工策划的内容。Reddit 就是一个典型平台,用户可以发布文本内容或链接,其他用户可以对这些提交进行点赞(点赞称为 karma)。我们将提到两个基于 Reddit 的数据集:
-
WebText(与 GPT-2 模型一起发布):包含 4,500 万个 Reddit 提交链接,其中 karma 为三次或以上。这些链接背后的文档构成了 LLM 训练数据。WebText 并未公开发布,但有一个开源版本,名为 OpenWebText (
github.com/jcpeterson/openwebtext
)。 -
Pushshift (
arxiv.org/abs/2001.08435
):包含所有在 Reddit 上提交的链接和评论。
-
Reddit API 定价争议
LLMs 的兴起使得 Reddit 的数据比以往更有价值。基于这一点,公司决定对其原本免费的 API 引入费用。这项措施主要针对那些计划利用这些数据训练 LLM 的 AI 公司。然而,这一提案导致许多该网站的志愿版主(Reddit 依赖他们)宣布通过暂时关闭他们所管理的原本开放的社区来进行罢工。截至写作时,双方的争议仍在持续。
-
The Pile (An 800GB Dataset of Diverse Text for Language Modeling,
arxiv.org/abs/2101.00027
): 由 22 个多样且高质量的数据集组成,来源包括 PubMed、arXiv、GitHub、Stack Exchange、Hacker News、YouTube 等。The Pile 还引入了原始 OpenWebText 和 BookCorpus 数据集的扩展版本 OpenWebText2 和 BookCorpus2。 -
ROOTS (The BigScience ROOTS Corpus: A 1.6TB Composite Multilingual Dataset,
arxiv.org/abs/2303.03915
): 一个规模庞大的精心策划的数据集,涵盖 46 种自然语言和 13 种编程语言。 -
Wikimedia (
dumps.wikimedia.org/
): 因其高质量的内容,这是一个优秀的训练数据来源。 -
Stack Exchange (
archive.org/details/stackexchange
): 一个拥有评分系统的 QA 主题网站网络。最具代表性的站点是Stack Overflow。它每三个月发布一次匿名化数据转储,包含所有用户贡献的内容。 -
arXiv (https://www.kaggle.com/datasets/Cornell-University/arxiv): 主要的科学数据来源,包含超过 22 亿篇科学文章。
-
GitHub: GH Archive 项目 (
www.gharchive.org/
) 记录、归档并提供公共 GitHub 时间线的访问。
实际上,LLM 的预训练步骤使用的是多个数据集的混合。以下截图展示了几个代表性 LLM 的预训练数据来源分布:
图 8.11 – 现有 LLM 预训练数据中各种数据源的比例(来源:https://arxiv.org/abs/2303.18223)
数据集混合并非一项简单的过程,需要多个处理步骤。我们来讨论一下这些步骤:
-
移除低质量或无关的数据:例如,网页中包含大量 HTML 标签、JavaScript 或层叠样式表(CSS)。然而,我们只对
人类可读文本(除非我们明确希望训练模型理解 HTML)。在这种情况下,我们必须去除 HTML 和 JavaScript,只保留文本。
-
移除个人可识别信息(PII):数据通常从网页中提取,而网页中可能包含个人信息。此步骤旨在从训练集中删除此类数据。
-
分词:我们在第六章中深入讨论了分词,本文不再赘述。
最后,让我们介绍一个实际的变压器缩放法则(神经语言模型的缩放法则, https://arxiv.org/abs/2001.08361)。由于其规模,训练 LLM 可能非常昂贵。因此,避免过度训练或训练不足至关重要。根据经验实验,缩放法则提出了训练计算量(以浮动点操作每秒,或FLOPS表示)、C,模型大小(参数数量)、N,以及训练数据集大小(令牌数量)之间的最佳比例:
既然我们已经知道了构建训练集的步骤,接下来让我们专注于实际的预训练。
预训练的特性
与其他神经网络(NNs)类似,LLM 的预训练通过梯度下降和反向传播进行。但由于其规模庞大,训练具有一些特定的特性,我们将在本节中讨论这些特性。
Adam 优化器
大多数 LLM 使用 Adam(Adam:一种随机优化方法, arxiv.org/abs/1412.6980
)或其某些变体。尽管我们在许多示例中使用了它,但至今我们尚未详细讨论它。现在是时候弥补这个遗漏了。
权重更新公式的回顾
在第二章,我们学习到使用反向传播来计算损失函数J(θ)关于每个参数![<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">mml:msubmml:mrowmml:miθ</mml:mi></mml:mrow>mml:mrowmml:mij</mml:mi></mml:mrow></mml:msub></mml:math>的梯度(一阶导数):![<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">mml:mo∂</mml:mo>mml:miJ</mml:mi><mml:mfenced separators="|">mml:mrowmml:miθ</mml:mi></mml:mrow></mml:mfenced>mml:mtext/</mml:mtext>mml:mo∂</mml:mo>mml:msubmml:mrowmml:miθ</mml:mi></mml:mrow>mml:mrowmml:mij</mml:mi></mml:mrow></mml:msub></mml:math>(img/795.png). 一旦我们得到梯度,我们可以使用公式![<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">mml:msubmml:mrowmml:miθ</mml:mi></mml:mrow>mml:mrowmml:mij</mml:mi></mml:mrow></mml:msub>mml:mo←</mml:mo>mml:msubmml:mrowmml:miθ</mml:mi></mml:mrow>mml:mrowmml:mij</mml:mi></mml:mrow></mml:msub>mml:mo-</mml:mo>mml:miη</mml:mi>mml:mo∂</mml:mo>mml:miJ</mml:mi><mml:mfenced separators="|">mml:mrowmml:miθ</mml:mi></mml:mrow></mml:mfenced>mml:mtext/</mml:mtext>mml:mo∂</mml:mo>mml:msubmml:mrowmml:miθ</mml:mi></mml:mrow>mml:mrowmml:mij</mml:mi></mml:mrow></mml:msub></mml:math>(img/796.png),其中η是学习率。我们可以在该公式中加入动量(或速度)。为此,我们假设我们处于训练过程的第t步。然后,我们可以根据第t-1步的更新动量来计算当前更新的动量:![<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">mml:msubmml:mrowmml:miv</mml:mi></mml:mrow>mml:mrowmml:mit</mml:mi></mml:mrow></mml:msub>mml:mo←</mml:mo>mml:miμ</mml:mi>mml:msubmml:mrowmml:miv</mml:mi></mml:mrow>mml:mrowmml:mit</mml:mi>mml:mo-</mml:mo>mml:mn1</mml:mn></mml:mrow></mml:msub>mml:mo-</mml:mo>mml:miη</mml:mi>mml:mo∂</mml:mo>mml:miJ</mml:mi><mml:mfenced separators="|">mml:mrowmml:miθ</mml:mi></mml:mrow></mml:mfenced>mml:mtext/</mml:mtext>mml:mo∂</mml:mo>mml:msubmml:mrowmml:miθ</mml:mi></mml:mrow>mml:mrowmml:mij</mml:mi></mml:mrow></mml:msub></mml:math>(img/797.png),其中µ是[0:1]范围内的动量率。此外,我们还可以添加 L2 正则化(或权重衰减;见第三章):![<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">mml:msubmml:mrowmml:miv</mml:mi></mml:mrow>mml:mrowmml:mit</mml:mi></mml:mrow></mml:msub>mml:mo←</mml:mo>mml:miμ</mml:mi>mml:msubmml:mrowmml:miv</mml:mi></mml:mrow>mml:mrowmml:mit</mml:mi>mml:mo-</mml:mo>mml:mn1</mml:mn></mml:mrow></mml:msub>mml:mo-</mml:mo>mml:miη</mml:mi><mml:mfenced separators="|">mml:mrow<mml:mfenced separators="|">mml:mrowmml:mo∂</mml:mo>mml:miJ</mml:mi><mml:mfenced separators="|">mml:mrowmml:miθ</mml:mi></mml:mrow></mml:mfenced>mml:mtext/</mml:mtext>mml:mo∂</mml:mo>mml:msubmml:mrowmml:miθ</mml:mi></mml:mrow>mml:mrowmml:mij</mml:mi></mml:mrow></mml:msub></mml:mrow></mml:mfenced>mml:mo+</mml:mo>mml:miλ</mml:mi>mml:msubmml:mrowmml:miθ</mml:mi></mml:mrow>mml:mrowmml:mij</mml:mi></mml:mrow></mml:msub></mml:mrow></mml:mfenced></mml:math>(img/799.png),其中λ是权重衰减系数。最后,我们可以执行权重更新:![<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">mml:msubmml:mrowmml:miθ</mml:mi></mml:mrow>mml:mrowmml:mij</mml:mi></mml:mrow></mml:msub>mml:mo←</mml:mo>mml:msubmml:mrowmml:miθ</mml:mi></mml:mrow>mml:mrowmml:mij</mml:mi></mml:mrow></mml:msub>mml:mo+</mml:mo>mml:msubmml:mrowmml:miv</mml:mi></mml:mrow>mml:mrowmml:mit</mml:mi></mml:mrow></mml:msub></mml:math>(img/800.png).Adam 根据先前的权重更新(动量)为每个权重计算个体和自适应学习率。让我们看看是如何工作的:1. 计算梯度的一阶矩(或均值)和二阶矩(或方差):
在这里, 和
是具有默认值 0.9 和 0.95 的超参数。 这两个公式与动量公式非常相似。
(
) 和
(
) 作为一种移动平均的模拟。但不同于跨多个前值进行平均,我们只取最新的前一个值,
(
),并为其分配一个权重系数,
(
)。
和
的初始值为 0,因此在训练的初期阶段,它们会对 0 产生偏差。为了理解为什么这可能是个问题,假设在 t=1 时,
且
。然后,
远小于实际梯度 5。我们可以通过使用偏差校正版本的
和
来补偿这个偏差:
- 使用以下公式进行权重更新:
在这里,ε 是一个小值,用于防止除以 0。
AdamW(解耦权重衰减正则化, arxiv.org/abs/1711.05101
)通过解耦的权重衰减改进了 Adam 算法:
回顾一下,L2 正则化参与损失函数,并通过导数过程被转移(作为权重衰减)到权重更新公式中。在这种情况下,正则化会经过损失函数的所有变换,并且会受到这些变换的影响。正如名字所示,解耦权重衰减绕过了所有这些变换,直接参与到前面的公式中。
Adam 和 AdamW 的一个问题是增加的内存消耗——优化器为每个模型参数存储至少两个额外的值 ( 和
)。
并行处理
LLMs 的规模需要特别的步骤来进行高效训练。首先,我们将讨论如何在多个设备上训练 LLMs。更具体地,我们将讨论三种不同类型的并行性组合(也称为 3D 并行性):
-
数据并行性:当模型足够小,可以适配到单个设备上时,它有效:
-
在所有设备上创建整个模型及其优化器状态(包括随机种子)的相同副本。
-
将每批训练集拆分成唯一的子集(分片),并分配到所有设备上。
-
每个设备根据其唯一的输入批次子集计算其梯度。
-
将所有设备的梯度汇总为一个单一的梯度更新。
-
将聚合的更新分发到各个设备,并在每个设备上执行权重更新。通过这种方式,我们在每个训练步骤开始和结束时,都会使用相同的模型。
-
-
模型(或流水线):在操作(层)级别将模型分布到多个设备上。例如,如果我们的模型有 9 层,我们可以将第 1 到第 6 层发送到一台设备,将第 7 到第 9 层发送到另一台设备。通过这种方式,我们可以训练无法在单个设备内存中容纳的模型。不仅如此,我们即使在单个设备上也可以应用这种方法。在这种情况下,我们将加载第一组操作(1-6)并计算它们的输出。然后,我们会卸载这些操作并加载接下来的子集(7-9)。第一组的输出将作为第二组的输入。反向传播以相同的方式进行,但方向相反。模型并行的一个问题是,如果使用多个设备,第二个设备会在第一个设备产生输出之前处于空闲状态。
-
张量(或水平):在张量级别将模型分布到不同的设备上,从而解决了模型并行中的空闲问题。为了理解这一点,我们回顾一下矩阵乘法是当代神经网络中最计算密集的操作。但正如我们在FlashAttention部分讨论的,它也是极其容易并行化的。因此,我们可以将它分配到不同的设备上。
零冗余优化器
零冗余优化器(ZeRO)(ZeRO: 面向训练万亿参数模型的内存优化, https://arxiv.org/abs/1910.02054) 是数据并行和模型并行的混合体。下图展示了 ZeRO 的三个阶段:
图 8.12 – ZeRO(灵感来源于 https://arxiv.org/abs/1910.02054)
第一行表示数据并行系统的情况。每个 GPU 接收输入小批量数据的一个独特分片。它还保存模型参数的副本(GPUi 块的第一个彩色矩形)、梯度(第二个矩形)和优化器状态(第三个矩形)。优化器状态占用的内存最大(例如,Adam 为每个模型参数存储多个值),因此它们在训练过程中占用大部分内存。接下来的三行表示 ZeRO 的三个阶段:
-
优化器状态分区 (
):每个 GPU 保存整个模型参数及其梯度的副本,但优化器状态在 GPU 之间进行分区,每个 GPU 只保存其中一部分。
-
添加梯度分区 (
):每个 GPU 持有整个模型参数的副本,但梯度和优化器状态是分区的。
-
添加模型参数 (
):每个 GPU 保存所有组件的一部分。
为了理解算法的工作原理,我们假设使用,一个包含N层和N个 GPU 的模型。每一层都分布在一个 GPU 上——第一层在
,第二层在
。
以此类推。让我们从前向传播阶段开始。首先, 接收
。由于它持有模型的第一层,它可以独立地将输入喂入并计算其激活值。同时,
将第一层的参数广播到所有其他 GPU。现在,每个 GPU 除了持有自己的部分模型参数外,还持有第一层的参数。通过这种方式,
可以处理自己的输入,
,通过第一层,就像
做的那样。一旦 GPU 计算完第一层的激活值,它会从内存中删除其参数(除了
保留的参数)。我们重复相同的步骤,这次处理第二层。
广播其参数,以便所有 GPU 可以继续前向传播阶段。之后,除了
之外,所有其他 GPU 都会删除第二层的参数。这个过程会一直持续,直到所有 GPU 输出模型结果。然后,所有 GPU 的损失函数会被汇总。接下来,反向传播阶段开始,它的工作方式与前向传播相同,但这次 GPU 会同时广播梯度和优化器状态。
混合精度训练
混合精度训练 (arxiv.org/abs/1710.03740
)的核心思想是,并非所有的值都必须以 32 位(双精度或全精度)浮动点精度(FP32 或 Float32 数据格式)来存储。研究表明,将部分值存储为 16 位(单精度或半精度)浮动点精度(FP16 或 Float16)不会降低模型的性能。权重、激活、梯度和优化器状态都以 FP16 格式存储。此外,模型保留一个 FP32 的主副本作为权重。前向和反向传播使用的是 FP16 权重,但当进行权重更新操作时,使用的是 FP32 主副本,这样结果是最优的。一个可能的解释是,权重更新公式使用了乘以学习率的梯度,而结果可能会变得太小,无法在 FP16 中表示。另一个解释是,权重值和权重更新的比例非常大,这可能导致权重更新变为零。
Bfloat16 和 TensorFloat32
Google Brain 部门为 机器学习(ML)应用开发了 brain floating-point 格式(因此得名 bfloat)。标准的 FP16 格式有一个符号位、五个位的指数部分和十个位的尾数部分。与之不同,bfloat16 具有八个位的指数部分和七个位的尾数部分。其指数部分与 FP32 相同。就 ML 任务的性能而言,bfloat16 与 FP32 相差无几。我们还可以找到 TensorFloat-32(TF32)格式——这是 NVIDIA 为 ML 目的开发的 19 位格式,具有 8 位的指数部分和 10 位的尾数部分。
预训练特殊性和总结
在这一节中,我们将讨论一些 LLM 预训练的特殊性。首先从小批量大小开始。理想情况下,我们会在整个训练数据集上计算梯度,然后进行一次权重更新。然而,庞大的数据集和模型使得这种计算方式在实际操作中不可行。另一个极端是对每个训练样本进行一次权重更新,但这样训练就容易受到离群样本的影响,这可能会将损失函数引导到次优的局部最小值。小批量训练是一种折衷方法,使得在有限的计算资源内可以进行训练,并避免离群样本的影响。但从理论上讲,小批量大小越大越好。LLM 训练是分布式在多个设备上进行的,这使得使用大批量大小成为可能(甚至是理想的)。根据模型的不同,批量大小可以在 32K 到 8.25M 个 token 之间变化。此外,批量大小还可以是动态的,并随着训练的进行逐渐增大。实验证明,这种技术可以稳定训练过程。
接下来,我们关注学习率,η。虽然 Adam 实现了自适应学习率,但大多数 LLM 从 预热阶段 开始,以稳定训练。更具体来说,在训练的前 0.1% 到 0.5% 步骤中,学习率从大约 增加到
。
然后,学习率将按余弦(或线性)衰减策略逐渐降低到最大值的 10% 左右。
LLM 训练还使用了 梯度裁剪——一种防止梯度爆炸问题的技术。一种实现方法是通过值裁剪:
如果 lgl ≥ max_threshold 或 lgl ≤ min_threshold,则 g ← relevant_threshold
这里,g 是一个包含所有梯度值的向量(lgl 是向量的范数或其绝对值)。首先,我们选择 min_threshold 和 max_threshold 值。然后,如果梯度值超出这些边界,我们将在权重更新公式中将其裁剪至阈值。
另一种选择是通过范数裁剪:
如果 lgl ≥ threshold,则 g ← threshold * g/lgl
这里,g/lgl 是一个单位向量。它的方向与原向量相同,但长度为 1。单位向量中每个元素的值都在 [0:1] 范围内。通过将其乘以 threshold,每个元素都落在 [0: threshold] 范围内。这样,范数裁剪将梯度限制在预定义的阈值范围内。
下表总结了一些流行 LLM 的训练特性:
图 8.13 – LLM 训练特性(修改自 https://arxiv.org/abs/2303.18223)
这就是我们对 LLM 预训练的介绍。接下来,我们将关注 FT 阶段。
带有 RLHF 的 FT
到目前为止,我们已经关注了 LLM 的预训练阶段。LLM 的预训练目标是基于(主要是)网页训练数据集预测下一个标记。然而,预训练模型可能表现出不良行为。它们经常编造事实,生成偏见或有毒的文本,或者根本不遵循用户指令。然而,它们的目的是以有帮助、诚实和无害的方式与人类互动。在本节中,我们将讨论 RLHF 技术,这使得微调 LLM 以更好地与人类价值观对齐成为可能(也称为对齐调优)。更具体地,我们将关注 OpenAI 在《用人类反馈训练语言模型以遵循指令》(arxiv.org/abs/2203.02155
)中描述的技术。他们在 GPT-3 模型上应用 RLHF,进而推出 GPT-3.5 系列模型。这是使得 ChatGPT 如此擅长与用户互动的秘密之一。
FT 从预训练结束的地方开始——即使用预训练的 LLM。以下图表展示了 RLHF 过程的三步:
图 8.14 – 左侧:监督式 FT;中间:奖励模型训练;右侧:LLM RLHF(灵感来源于 https://arxiv.org/abs/2203.02155)
第一步是[prompt: response]
样本,其中prompt
和response
分别是源和目标的标记序列。该数据集用于微调 LLM,使用与预训练相同的目标——即在给定提示的情况下预测响应的下一个标记。微调后的 LLM 作为下一步的基础。
关于预训练和三步 FT 的必要性
SFT 步骤隐含地回答了一个未被提问的问题——为什么我们需要预训练和三步 FT 来训练我们的模型?原因在于,生成一个人工标注的训练数据集是不可扩展的,并且是一个主要的瓶颈。例如,预训练数据集可以包含超过一万亿个标记;使用人工标注员生成如此规模的提示及其相应的响应是不现实的。因此,我们需要预训练为 LLM 提供一个坚实的基础,而我们可以使用一个较小的数据集来进行微调。
第二步是[(A, B), (A, C), (B, C)]
。该数据集训练 RM,基于微调后的 LLM。其输出的下一个标记分类器被替换为一个随机初始化的回归层,该层输出给定响应的预测标量分数。RM 计算每对响应的分数,它们之间的差异参与损失函数的计算。这是迁移学习(TL)的一个例子,旨在在原始 LLM 的基础上训练新的回归层。
第三步是使用 RL 通过 RM 和近端策略优化(PPO)来训练 LLM(图 8.14—右侧)。
RL 的回顾
为了理解第三步,我们来回顾一下在第一章中的强化学习介绍。我们有一个环境系统和一个智能体。智能体可以采取多种行动之一,这些行动会改变环境的状态。环境会对智能体的行动作出反应,并提供修改后的状态和奖励(或惩罚)信号,帮助智能体决定下一步的行动。智能体的决策算法称为策略。智能体的目标是最大化在整个训练过程中所获得的总奖励。
在这个场景中,智能体的策略由经过微调的 LLM 表示。令牌词汇表表示智能体可以采取的行动——也就是说,智能体的行动是选择序列中的下一个令牌。环境向 LLM 提供一个随机提示,智能体(LLM)生成一个响应。然后,环境的一部分 RM 对生成的响应进行评分。RM 分数是发送给智能体的奖励,并用于更新其参数。
在下一节中,我们将讨论 LLM 与其他模型的不同之处。
LLM 的涌现能力
在本节中,我们将讨论 LLM 的涌现能力现象,该现象首次在arxiv.org/abs/2206.07682
中总结。该论文将涌现能力定义如下:
如果某个能力在较小的模型中不存在,但在较大的模型中存在,则该能力为涌现能力。
这些能力代表了大语言模型与小语言模型之间的质的差异,这种差异无法通过外推预测。
我们将从被称为少量示例提示(或上下文学习)的能力开始,这种能力由 GPT-3 普及。在这里,初始用户提示是 LLM 必须通过响应遵循的指令,且无需任何额外的训练。提示本身可能用自然语言描述一个或多个训练示例(因此,称为少量示例)。这是 LLM 在生成响应之前可以用来进行训练的唯一上下文。以下图表展示了一个少量示例提示的例子:
图 8.15 – 一个少量示例提示的例子(灵感来自 arxiv.org/abs/2206.07682
)
接下来,让我们讨论大型语言模型(LLM)在思维链(CoT)提示策略的帮助下解决复杂多步骤推理任务的能力(思维链提示引发大型语言模型的推理能力,https://arxiv.org/abs/2201.11903)。这种提示为 LLM 提供了一系列中间步骤,可以引导模型达到最终任务答案。以下图表展示了常规提示和思维链提示的比较:
图 8.16 – 左:常规的一次性提示;右:链式思维一次性提示(灵感来自 arxiv.org/abs/2201.11903
)
有人推测,这种能力是通过将源代码包括在训练数据中获得的。
还需要注意的是,我们在 FT with RLHF 部分讨论的对齐微调也是一种紧急能力,因为它仅仅提升了大模型的性能。
下图展示了随着模型规模的增加,模型在各种任务上的性能如何显著提升:
图 8.17 – 紧急能力仅出现在大规模模型中 (来源:arxiv.org/abs/2206.07682
)
x 轴显示每个模型的训练计算时间(以 FLOPS 计),y 轴显示模型的准确性。图表显示了模型在三个不同基准测试上的准确性:
-
一个算术基准,测试 2 位数乘法,以及 3 位数的加法和减法
-
涵盖多个主题的 57 项大学级测试,包括数学、历史、法律等
-
在数学应用题上,链式思维与常规提示的比较,例如 图 8.16 所描述的那种
这就是我们对大语言模型(LLM)的理论介绍。接下来,让我们看看如何在实践中使用它们。
介绍 Hugging Face Transformers
到目前为止,我们已经深入讨论了 LLM 的架构和训练特性。但可悲的事实是,这些模型如此庞大,以至于你或我都不太可能从零开始构建一个。相反,我们很可能会使用一个预训练模型。在本节中,我们将看到如何使用 Hugging Face Transformers 库(github.com/huggingface/transformers
)。顾名思义,它的重点是 transformer 架构。它支持三种不同的后端——PyTorch、TensorFlow 和 JAX(像往常一样,我们将重点讨论 PyTorch)。它是开源的,可以用于商业用途。背后的公司 Hugging Face 还开发了 Hugging Face Hub——这是一个与库配套的云平台服务。它支持托管和/或运行 Git 仓库(如 GitHub)、transformer 模型、数据集和 Web 应用程序(用于 概念验证 (POC) 的机器学习应用演示)。好了,让我们继续我们的第一个示例。
我们将从一个基本的使用案例开始——加载一个预训练的 Llama 2 chat 7B 模型,并使用它来生成对用户提示的回答:
-
首先,我们加入
import
语句:import torch from transformers import AutoTokenizer, pipeline
-
然后,我们在一个变量中定义模型的名称:
model = "meta-llama/Llama-2-7b-chat-hf"
每个 transformer 模型都有一个唯一的标识符,这对于 Hugging Face 模型中心(Hub)有效。该 Hub 托管所有模型,库可以在后台自动下载模型权重。在这种情况下,我们使用的是最小的 Llama 2 7B RLHF 优化模型,以节省计算资源。
-
接下来,让我们加载模型分词器:
tokenizer = AutoTokenizer.from_pretrained(model)
各种 LLM 模型使用不同的分词器。
AutoTokenizer
实例可以根据模型标识符选择正确的分词器。 -
让我们通过
print(tokenizer)
打印分词器来查看其属性:LlamaTokenizerFast(name_or_path='meta-llama/Llama-2-7b-chat-hf', vocab_size=32000, model_max_length=1000000000000000019884624838656, is_fast=True, padding_side='left', truncation_side='right', special_tokens={'bos_token': AddedToken("<s>", rstrip=False, lstrip=False, single_word=False, normalized=False), 'eos_token': AddedToken("</s>", rstrip=False, lstrip=False, single_word=False, normalized=False), 'unk_token': AddedToken("<unk>", rstrip=False, lstrip=False, single_word=False, normalized=False)}, clean_up_tokenization_spaces=False)
分词器基于字节级字节对编码(BPE)。该输出提供了有关标记词汇大小、特殊标记和其他属性的有用信息。
-
接下来,我们创建一个
pipeline
实例:text_gen_pipeline = pipeline( task='text-generation', model=model, tokenizer=tokenizer, torch_dtype=torch.bfloat16, device_map='auto', )
流水线抽象使得使用模型进行推理变得简单。
task
参数决定了解决任务的类型。该库支持多种任务,还涵盖了图像和音频。pipeline
会根据任务返回不同的对象。它还负责下载和初始化模型。此外,我们将数据类型设置为torch.bfloat16
以减少内存占用。device_map='auto'
参数允许 Accelerate 库(github.com/huggingface/accelerate
)自动在任何分布式配置中运行模型。 -
我们可以使用以下命令查看模型定义:
print(text_gen_pipeline.model)
。例如,最大 70B Llama 2 模型Llama-2-70b-hf
的命令输出如下:LlamaForCausalLM( (model): LlamaModel( (embed_tokens): Embedding(32000, 8192) (layers): ModuleList( (0-79): 80 x LlamaDecoderLayer( (self_attn): LlamaAttention( (q_proj): Linear(in=8192, out=8192) (k_proj): Linear(in=8192, out=1024) (v_proj): Linear(in=8192, out=1024) (o_proj): Linear(in=8192, out=8192) (rotary_emb): LlamaRotaryEmbedding() ) (mlp): LlamaMLP( (gate_proj): Linear(in=8192, out=28672) (up_proj): Linear(in=8192, out=28672) (down_proj): Linear(in=28672, out=8192) (act_fn): SiLUActivation() ) (input_layernorm): LlamaRMSNorm() (post_attention_layernorm): LlamaRMSNorm() ) ) (norm): LlamaRMSNorm() ) (lm_head): Linear(in=8192, out=32000) )
为了适应页面行长,我修改了原始输出:
in
代表in_features
,out
代表out_features
,所有线性层都有一个额外的bias=False
参数。标记词汇大小为 32,000,嵌入大小 () 为 8,192。模型有 80 个相同的解码器块(
LlamaDecoderLayer
)。每个块包含一个自注意力子层(
*_proj
为投影)、一个具有单隐藏层的 FFN(LlamaMLP
)、旋转嵌入(LlamaRotaryEmbedding
)、LlamaRMSNorm
)以及 SiLU 激活(SiLUActivation
)。需要注意的是,该激活函数与论文中定义的 SwiGLU 激活函数有所不同。 -
然后,我们运行推理:
sequences = text_gen_pipeline( text_inputs='What is the answer to the ultimate question of life, the universe, and everything?', max_new_tokens=200, num_beams=2, top_k=10, top_p=0.9, do_sample=True, num_return_sequences=2, )
这里,
text_inputs
是用户的提示,作为初始输入序列。num_return_sequences=2
参数表示模型将生成两个独立的响应(稍后会详细介绍)。这是第一个响应:Answer: The answer to the ultimate question of life, the universe, and everything is 42. Explanation: max_new_tokens=200 parameter.
让我们分析一下text_gen_pipeline
调用中的其余参数,因为它们都与生成新标记的策略相关。LLM 的输出以 softmax 操作结束,该操作对词汇表中的所有标记输出一个概率分布。选择下一个标记的最简单方法是贪心策略,它总是选择概率最高的那个。然而,这通常不是最优的,因为它可能会把高概率的单词隐藏在低概率的单词后面。为了说明这一点,某个标记在当前生成序列的状态下可能会被分配一个低概率,然后会选择另一个标记替代它。这意味着包含当前低概率标记的潜在序列将不会存在。因此,即使它在后续有高概率的标记,我们也永远无法知道,因为低概率标记阻止了它的进一步探索。解决这个问题的一种方法是使用do_sample=True
。在这种情况下,算法会考虑整个当前序列的概率,而不仅仅是最新标记的概率。因此,新标记将是最大化整个序列概率的那个,而不是局部概率的最大值。num_beams=2
参数表示算法始终保留两个具有最高概率的序列(beams)。因为我们可以有多个输出序列,num_return_sequences=2
参数表示返回的序列数量。例如,如果num_beams=5
并且num_return_sequences=3
,算法将返回五个可用序列中三个最高概率的序列(num_return_sequences > num_beams
是无效的参数)。early_stopping=True
参数表示当所有 beams 的假设都到达序列结束标记([EOS]
)时,生成过程结束。top_k=10
参数表示算法只会从概率最高的前 10 个标记中进行采样,而不考虑它们的序列概率。top_p=0.9
类似于top_k
,但它不是只从最可能的k个标记中进行采样,而是从一组最小的标记中选择,这些标记的总概率超过p。
这就结束了我们对 Transformers 库的介绍以及整个章节的内容。
总结
LLM(大型语言模型)是非常庞大的变压器模型,具有各种修改以适应其庞大的规模。在这一章中,我们讨论了这些修改,以及 LLM 和普通变压器之间的质的差异。首先,我们重点讲解了它们的架构,包括更高效的注意力机制,如稀疏注意力和前缀解码器。我们还讨论了 LLM 架构的细节。接下来,我们回顾了最新的 LLM 架构,特别关注了 GPT 和 LlaMa 系列模型。然后,我们讨论了 LLM 的训练过程,包括训练数据集、Adam 优化算法以及各种性能提升。我们还讨论了 RLHF 技术和 LLM 的突现能力。最后,我们介绍了 Hugging Face Transformers 库。
在下一章中,我们将讨论计算机视觉(CV)的变压器模型、多模态变压器,并继续介绍 Transformers 库。
第九章:大型语言模型的高级应用
在前两章中,我们介绍了变换器架构,并学习了其最新的大规模版本,被称为大型语言模型(LLMs)。我们在自然语言处理(NLP)任务中讨论了它们。NLP 是变换器最初的应用领域,并且仍然是大型语言模型发展的前沿领域。然而,架构的成功使研究界开始探索变换器在其他领域的应用,如计算机视觉。
在本章中,我们将重点讨论以下内容。我们将讨论将变换器用作卷积网络(CNN,第四章)的替代品,用于图像分类和目标检测等任务。我们还将学习如何将它们用作图像生成模型,而不是像之前那样只用于文本。我们还将实现一个模型微调的示例——这是我们在第八章中未能完成的内容。最后,我们将实现一个基于大型语言模型驱动的新型应用。
在本章中,我们将覆盖以下主要主题:
-
使用视觉变换器进行图像分类
-
检测变换器
-
使用稳定扩散生成图像
-
微调变换器
-
利用 LangChain 发挥大型语言模型的力量
技术要求
我们将在本章中使用 Python、PyTorch、Hugging Face 的 Transformers 库(github.com/huggingface/transformers
)以及 LangChain 框架(www.langchain.com/
,github.com/langchain-ai/langchain
)来实现示例。如果你没有配置这些工具的环境,也不用担心——该示例可以在 Google Colab 上的 Jupyter Notebook 中找到。代码示例可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Python-Deep-Learning-Third-Edition/tree/main/Chapter09
。
使用视觉变换器进行图像分类
视觉变换器(ViT,一张图胜过 16x16 个词:用于大规模图像识别的变换器,arxiv.org/abs/2010.11929
)通过引入一种巧妙的图像处理技术,证明了注意力机制的适应性。使用变换器处理图像输入的一种方法是通过四个变量对每个像素进行编码——像素强度、行、列和通道位置。每个像素编码是一个简单的神经网络(NN)的输入,该网络输出一个维度的嵌入向量。我们可以将三维图像表示为这些嵌入向量的一维序列。它作为模型的输入,方式与令牌嵌入序列相同。每个像素将在注意力块中关注到所有其他像素。
这种方法在输入序列长度(上下文窗口)方面存在一些缺点。与一维文本序列不同,图像具有二维结构(颜色通道不会增加像素数量)。因此,随着图像尺寸的增大,输入序列的长度呈二次方增长。即使是一个小的 64×64 图像,也会导致输入序列的长度为 64*64=4,096。这样一来,一方面使得模型的计算量增加,另一方面,由于每个像素都要关注整个长序列,模型很难学习图像的结构。卷积神经网络(CNN)通过使用滤波器来解决这个问题,滤波器将单位的输入大小限制在其周围的区域内(感受野)。为了理解 ViT 是如何解决这个问题的,让我们从下面的图开始:
图 9.1 – 视觉变换器。灵感来源于 arxiv.org/abs/2010.11929
设输入图像的分辨率为(H, W),通道数为C。然后,我们可以将输入图像表示为一个张量,。ViT 将图像分割成一系列二维方形图像块,
(图 9.1)。这里,(P, P)是每个图像块的分辨率(P=16),而
是图像块的数量(即输入序列的长度)。这些图像块的序列作为输入提供给模型,方式与 token 序列相同。
接下来,输入图像块将作为输入传递给线性投影,输出一个
-维的图像块嵌入向量用于每个图像块。这些图像块嵌入形成输入序列
。我们可以用以下公式总结图像块到嵌入的过程:
这里, 是线性投影,和
是静态位置编码(与原始变换器相同)。
一旦我们获得嵌入序列,ViT 会使用标准的仅编码器预归一化变换器进行处理,类似于 BERT( 第七章)。它有三种变体,如下所示:
图 9.2 – ViT 变体。基于 https://arxiv.org/abs/2010.11929
编码器架构使用无掩码的自注意力,这允许一个标记关注整个序列,而不仅仅是前面的标记。这是有道理的,因为图像像素之间的关系不像文本序列中的元素顺序那样,前后元素并不携带相同的意义。两种模型的相似之处不仅仅止于此。与 BERT 类似,输入序列以特殊的 [CLS]
() 标记开始(用于分类任务)。模型对
标记的输出是整个图像的输出。通过这种方式,
标记关注整个输入序列(即整个图像)。或者,如果我们选择任何其他补丁的模型输出,我们将引入所选补丁与序列中其他部分之间的不平衡。
模仿 BERT,ViT 也有预训练和微调阶段。预训练使用大规模通用图像数据集(例如 ImageNet),而微调则是在较小的任务特定数据集上训练模型。
该模型以 分类头 结束,预训练阶段包含一个隐藏层,而微调阶段则没有隐藏层。
ViT 的一个问题是,当使用非常大的数据集进行预训练时,它的表现最好,例如使用 300M 标签图像的 JFT-300M 数据集(Revisiting Unreasonable Effectiveness of Data in Deep Learning Era,arxiv.org/abs/1707.02968
)。这使得训练变得比可比的 CNN 更加计算密集。许多 ViT 的变体尝试解决这一挑战,并对原始模型提出其他改进。你可以在 A Survey on Visual Transformer(arxiv.org/abs/2012.12556
)中找到更多信息,该文献定期更新领域的最新进展。
这样,我们来看看如何在实际中使用 ViT。
使用 ViT 和 Hugging Face Transformers
在本节中,我们将借助 Hugging Face Transformers 和其pipeline
抽象实现一个 ViT 图像分类的基本示例,这一点我们在第8 章中有介绍。让我们开始:
-
导入
pipeline
抽象:from transformers import pipeline
-
创建一个图像分类管道实例。该管道使用 ViT-Base 模型:
img_classification_pipeline = pipeline( task="image-classification", model="google/vit-base-patch16-224")
-
使用来自维基百科的自行车图像运行实例:
img_classification_pipeline("https://upload.wikimedia.org/wikipedia/commons/thumb/4/41/Left_side_of_Flying_Pigeon.jpg/640px-Left_side_of_Flying_Pigeon.jpg")
这段代码输出以下前五个类别的概率分布(这里只显示第一个类别):
[{'score': 0.4616938531398773, 'label': 'tricycle, trike, velocipede'}]
这个示例足够简单,但让我们深入分析 ViT 模型本身。我们可以使用
print(img_classification_pipeline.model)
命令来实现,输出如下:ViTForImageClassification( (vit): ViTModel( (embeddings): ViTEmbeddings( (patch_embeddings): ViTPatchEmbeddings( (projection): Conv2d(3, 768, kernel_size=(16, 16), stride=(16, 16)) ) (dropout): Dropout(p=0.0) ) (encoder): ViTEncoder( (layer): ModuleList( (0-11): 12 x ViTLayer( (attention): ViTAttention( (attention): ViTSelfAttention( (query): Linear(in_f=768, out_f=768) (key): Linear(in_f=768, out_f=768) (value): Linear(in_f=768, out_f=768) (dropout): Dropout(p=0.0) ) (output): ViTSelfOutput( (dense): Linear(in_f=768, out_f=768) (dropout): Dropout(p=0.0) ) ) (intermediate): ViTIntermediate( (dense): Linear(in_f=768, out_f=3072) (intermediate_act_fn):GELUActivation() ) (output): ViTOutput( (dense): Linear(in_f=3072, out_f=768) (dropout): Dropout(p=0.0) ) (layernorm_before): LayerNorm((768,)) (layernorm_after): LayerNorm((768,)) ) ) ) (layernorm): LayerNorm((768,)) ) (classifier): Linear(in_f=768, out_f=1000) )
该模型处理 224×224 的输入图像。这里,
in_f
和out_f
是in_features
和out_features
的缩写。与其他模型不同,ViT 在所有Linear
层中使用偏置(bias=True
输入参数未显示)。让我们按出现顺序讨论模型的组成部分:-
ViTEmbeddings
:补丁嵌入块。它包含一个大小为 16×16 的 2D 卷积滤波器,步幅为 16,三个输入通道(每个颜色一个),以及 768 个输出通道 ()。在每个位置应用卷积滤波器,生成每个输入图像位置的一个 768 维的补丁嵌入。由于补丁形成了一个二维网格(与输入图像相同),所以输出会被展平为一维序列。该块还添加了位置编码信息,这些信息在其字符串表示中没有体现。所有 dropout 实例的丢弃概率为 0,因为该模型在推理模式下运行,而非训练模式。
-
ViTEncoder
:主编码器模型包含 12 个ViTLayer
预归一化(LayerNorm
)编码块实例。每个实例包含以下内容:ViTAttention
注意力块:ViTSelfAttention
多头注意力及其输出线性投影,ViTSelfOutput
。所有的ViTIntermediate
加上GELUActivation
和ViTOutput
。
-
分类头(
classifier
):在推理模式下,分类头只有一个Linear
层,输出 1,000 个结果(因为该模型在 ImageNet 数据集上进行了微调)。
-
接下来,让我们看看使用 Transformer 进行目标检测是如何工作的。
理解 DEtection TRansformer
DEtection TRansformer (DETR, 端到端目标检测与 Transformer, arxiv.org/abs/2005.12872
)引入了一种基于 Transformer 的创新目标检测算法。
快速回顾 YOLO 目标检测算法
我们在第五章中首次介绍了 YOLO。它有三个主要组件。第一个是骨干网络——即一个卷积神经网络(CNN)模型,用于从输入图像中提取特征。接下来是颈部——模型的中间部分,连接骨干网络和头部。最后,头部使用多步骤算法输出检测到的物体。更具体地,它将图像划分为一个网格,每个网格包含若干个具有不同形状的预定义锚框。模型预测这些锚框中是否包含物体以及物体边界框的坐标。许多框会重叠并预测相同的物体。模型通过交并比(IoU)和非极大值抑制(NMS)帮助筛选重叠的物体。
和 YOLO 一样,DetR 也从 CNN 骨干网络开始。然而,它用一个完整的后归一化变换器编码器-解码器替代了颈部和头部。这消除了需要手工设计组件(例如非极大值抑制过程或锚框)的需求。相反,模型输出一组边界框和类标签,用于表示检测到的物体。为了理解它是如何工作的,我们从下图开始,它展示了 DetR 的组件:
图 9.3 – DetR 架构。灵感来源于 https://arxiv.org/abs/2005.12872
首先,主干 CNN 从输入图像中提取特征,和 YOLO 中的操作相同。其输出是最后一个卷积层的特征图。原始输入图像是一个形状为的张量,其中
是颜色通道的数量,![然而,三维(排除批次维度)主干输出与编码器预期的输入张量不兼容,后者应该是一个一维的输入序列,大小为
大小的嵌入张量 (
)。为了解决这个问题,模型应用了 1×1 瓶颈卷积,将通道数从 C 降采样到
,然后进行展平操作。变换后的张量变为
,我们可以将其作为变换器的输入序列使用。
接下来,让我们关注实际的变换器,其详细内容显示在下图中:
图 9.4 – DetR 变换器的详细结构。灵感来源于 arxiv.org/abs/2005.12872
编码器将输入序列映射到一系列连续的表示,就像原始编码器一样(第七章)。不同之处在于,模型在每个Q/K张量的所有注意力层中添加了固定的绝对位置编码,而原始变换器仅在初始输入张量中添加了静态位置编码。
解码器是更有趣的地方。首先,我们注意到,固定位置编码也参与了解码器的编码器-解码器注意力块。由于它们参与了编码器的所有自注意力块,我们将它们传递到编码器-解码器注意力层,以使得各部分公平竞争。
接下来,编码器将输入一个N个物体查询的序列,这些查询由张量表示,。
我们可以将它们视为槽位,模型利用这些槽位来检测物体。每个输入物体查询的模型输出代表一个被检测物体的属性(边界框和类别)。拥有N个物体查询意味着模型最多可以检测N个物体。正因为如此,论文的作者提出使用N,其值显著大于图像中通常的物体数量。与原始的变换器不同,解码器的注意力在这里没有被掩盖,因此它可以并行检测所有物体,而不是按顺序进行检测。
在训练过程开始时,物体查询张量是随机初始化的。训练过程中会更新模型权重和查询张量——也就是说,模型在学习权重的同时也在学习物体查询。它们作为检测物体的学习型位置编码,并起到了与初始固定输入位置编码相同的作用。因此,我们将物体查询添加到编码器-解码器注意力层以及解码器块的自注意力层中,方式与将输入位置编码添加到编码器时相同。这种架构存在一个bug——第一个解码器块的第一个自注意力层将重复两次接收相同的物体查询作为输入,这使得该查询变得无用。实验证明,这不会降低模型的性能。为了简化实现,模型没有设计一个没有自注意力的独立第一个解码器块,而是直接使用了标准解码器块。
编码配置
模型可以处理固定编码和学习编码的多种配置:
-
只将两种类型的编码添加到输入数据;
-
将固定编码添加到输入数据,并将学习到的编码添加到输入和所有解码器注意力层;
-
将固定编码添加到数据和所有编码器注意力层,只将学习到的编码添加到解码器输入;
-
将两种类型的编码添加到输入数据以及编码器和解码器的每个注意力层中。
该模型在第四种配置下表现最佳,但为了简化起见,可以在第一种配置中实现。
物体查询使得不再强制施加诸如网格单元和锚框这样的几何限制(如在 YOLO 中)。相反,我们只指定检测的最大物体数量,并让模型发挥其魔力。学习到的查询通常会专注于图像的不同区域。然而,这归因于训练和训练数据集的特性,而不是手动设计的特征。
该模型以两个头部的组合结束:一个具有 ReLU 激活的三层感知机和一个独立的全连接(FC)层。该感知机被称为 FFN,它与变换器块中的 FFN 不同。它预测检测到的物体边界框的高度、宽度和相对于输入图像的标准化中心坐标。FC 层采用 softmax 激活,预测物体的类别。像 YOLO 一样,它包含一个额外的特殊背景类,表示在该位置没有检测到任何物体。拥有这个类别尤为必要,因为某些位置不可避免地会为空,N 远大于图像中的物体数量。
预测一组无限制的边界框给训练带来了挑战,因为将预测的边界框与真实框匹配并非易事。第一步是为每张图片的真实框添加虚拟条目,使得真实框的数量等于预测框的数量,即 N。接下来,训练使用预测框与真实框之间的 二分匹配。最后,算法监督每个预测框接近与其匹配的真实框。你可以查阅论文以获取有关训练的更多细节。
用于图像分割的 DetR
DetR 的作者扩展了该模型以用于图像分割。DetR 在检测和分割之间的关系类似于 Faster R-CNN 和 Mask R-CNN 之间的关系(见 第五章)。用于分割的 DetR 添加了一个第三个头部,通过上采样卷积实现。它并行生成每个检测物体的二值分割掩码。最终结果通过像素级的 argmax 合并所有掩码。
使用 Hugging Face Transformers 运行 DetR
在本节中,我们将借助 Hugging Face Transformers 和它的 pipeline
抽象,实施一个基本的 DetR 物体检测示例,我们在 第八章 中介绍了该抽象。这个示例遵循 ViT 模式,因此我们将包括完整的代码,而不做任何注释。代码如下:
from transformers import pipeline
obj_detection_pipeline = pipeline(
task="object-detection",
model="facebook/detr-resnet-50")
obj_detection_pipeline("https://upload.wikimedia.org/wikipedia/commons/thumb/4/41/Left_side_of_Flying_Pigeon.jpg/640px-Left_side_of_Flying_Pigeon.jpg")
最后一次调用返回以下形式的检测到的物体列表:
{'score': 0.997983455657959,
'label': 'bicycle',
'box': {'xmin': 16, 'ymin': 14, 'xmax': 623, 'ymax': 406}}
接下来,我们可以使用print(obj_detection_pipeline.model)
命令查看模型定义。这里,in_f
和out_f
分别是in_features
和out_features
的缩写。DetR 在所有Linear
层中使用偏置(bias=True
输入参数未显示)。我们将省略主干网络的定义。
让我们按出现顺序讨论模型元素,从 1×1 瓶颈卷积开始(我们有 ):
(input_projection): Conv2d(2048, 256,
kernel_size=(1, 1),
stride=(1, 1))
接下来,我们有对象查询嵌入(N=100)。正如我们所提到的,对象查询会在训练过程中与权重更新一起学习:
(query_position_embeddings): Embedding(100, 256)
以下是带有六个后置-ln 编码器块、ReLU 激活函数和具有一个 2,048 维隐藏层的 FFN 的编码器。注意,位置编码未显示(解码器也同样适用):
(encoder): DetrEncoder(
(layers): ModuleList(
(0-5): 6 x DetrEncoderLayer(
(self_attn): DetrAttention(
(k_proj): Linear(in_f=256, out_f=256)
(v_proj): Linear(in_f=256, out_f=256)
(q_proj): Linear(in_f=256, out_f=256)
(out_proj): Linear(in_f=256, out_f=256)
)
(self_attn_layer_norm): LayerNorm((256,))
(activation_fn): ReLU()
(fc1): Linear(in_f=256, out_f=2048)
(fc2): Linear(in_f=2048, out_f=256)
(final_layer_norm): LayerNorm((256,))
)
)
)
然后,我们有了解码器,带有六个后置-ln 解码器块,属性与编码器相同:
(decoder): DetrDecoder(
(layers): ModuleList(
(0-5): 6 x DetrDecoderLayer(
(self_attn): DetrAttention(
(k_proj): Linear(in_f=256, out_f=256)
(v_proj): Linear(in_f=256, out_f=256)
(q_proj): Linear(in_f=256, out_f=256)
(out_proj): Linear(in_f=256, out_f=256)
)
(activation_fn): ReLU()
(self_attn_layer_norm): LayerNorm((256,))
(encoder_attn): DetrAttention(
(k_proj): Linear(in_f=256, out_f=256)
(v_proj): Linear(in_f=256, out_f=256)
(q_proj): Linear(in_f=256, out_f=256)
(out_proj): Linear(in_f=256, out_f=256)
)
(encoder_attn_layer_norm): LayerNorm((256,))
(fc1): Linear(in_f=256, out_f=2048)
(fc2): Linear(in_f=2048, out_f=256)
(final_layer_norm): LayerNorm((256,))
)
)
(layernorm): LayerNorm((256,))
)
最后,我们得到了输出 FFN 和线性层。FFN 输出四个值(边界框坐标),而线性层可以检测 91 个类别和背景:
(class_labels_classifier): Linear(in_f=256, out_f=92)
(bbox_predictor): DetrMLPPredictionHead(
(layers): ModuleList(
(0-1): 2 x Linear(in_f=256, out_f=256)
(2): Linear(in_f=256, out_f=4)
)
)
接下来,让我们看看如何使用变换器生成新图像。
使用稳定扩散生成图像
在本节中,我们将介绍稳定扩散(SD,高分辨率图像合成与潜在扩散模型,arxiv.org/abs/2112.10752
,github.com/Stability-AI/stablediffusion
)。这是一种生成模型,可以基于文本提示或其他类型的数据合成图像(在本节中,我们将重点讨论文本到图像的场景)。为了理解它的工作原理,让我们从以下图开始:
图 9.5 – 稳定扩散模型与训练。灵感来源于 arxiv.org/abs/2112.10752
SD 结合了自编码器(AE,图 9.5 中的像素空间部分),去噪扩散概率模型(DDPM或简写为DM,图 9.5 中的潜在分布空间部分,以及第五章),和变换器(图 9.5 中的条件部分)。在我们深入讨论这些组件之前,先概述它们在 SD 的训练和推理管道中的作用。训练涉及所有这些组件——AE 编码器、前向扩散、反向扩散(U-Net,第五章)、AE 解码器和条件。推理(从文本生成图像)仅涉及反向扩散、AE 解码器和条件。不要担心如果你没有完全理解刚才所读内容,我们将在接下来的部分详细讨论。我们将从 AE 开始,接着讨论条件变换器,并在讨论扩散过程时将它们结合起来。
自编码器
尽管我们在第一章中简要提到了自编码器(AE),但在这里我们将更详细地介绍这一架构,从以下图示开始:
图 9.6 – 一个 AE
自编码器(AE)是一个前馈神经网络,它试图重建其输入。换句话说,AE 的目标值(标签)y等于输入数据x。我们可以正式地说,它试图学习一个恒等函数,(一个重复其输入的函数)。在最基本的形式下,自编码器由隐藏层组成。
(或瓶颈)和输出层(W和W’是这些层的权重矩阵)。像 U-Net 一样,我们可以将自编码器看作是两个组件的虚拟组合:
-
编码器:它将输入数据映射到网络的内部潜在表示。为了简化起见,在这个例子中,编码器是一个单一的全连接瓶颈层。内部状态就是其激活张量z。编码器可以有多个隐藏层,包括卷积层(如 SD 中的情况)。在这种情况下,z是最后一层的激活。
-
解码器:它试图从网络的内部状态z重建输入。解码器也可以具有复杂的结构,通常与编码器相似。虽然 U-Net 试图将输入图像转换为其他领域的目标图像(例如,分割图),但自编码器仅仅试图重建其输入。
我们可以通过最小化一个损失函数来训练自编码器,这个损失函数被称为重构误差。它衡量原始输入与其重构之间的距离。
潜在张量z是整个自编码器的核心。关键在于瓶颈层的单元数少于输入/输出层的单元数。因为模型试图从较小的特征空间重构输入,我们迫使它只学习数据中最重要的特征。可以将这种紧凑的数据表示看作一种压缩形式(但不是无损的)。我们可以仅使用模型的编码器部分来生成下游任务所需的潜在张量。或者,我们可以仅使用解码器从生成的潜在张量合成新的图像。
在训练过程中,编码器将输入样本映射到潜在空间,在这里每个潜在属性都有一个离散的值。一个输入样本只能有一个潜在表示。因此,解码器只能用一种可能的方式来重构输入。换句话说,我们只能生成一个输入样本的单一重构。然而,我们希望生成基于文本提示的新图像,而不是重新创建原始图像。解决此任务的一种可能方法是变分自编码器(VAE)。VAE 可以用概率术语来描述潜在表示。我们将不再使用离散值,而是为每个潜在属性提供一个概率分布,从而使潜在空间变为连续的。我们可以修改潜在张量以影响生成图像的概率分布(即属性)。在 SD 中,DM 组件与条件文本提示相结合,充当这个修改器。
完成这个简短的绕道后,我们来讨论卷积编码器在 SD 中的作用(像素空间部分,见图 9.5)。在训练过程中,AE 编码器创建了一个压缩的初始潜在表示张量,,来自输入图像,
。更具体地说,编码器将图像按因子进行下采样,f = H/h = W/w,其中
(m是通过经验实验选择的整数)。然后,整个扩散过程(前向和反向)使用压缩后的z,而不是原始图像x。只有当反向扩散结束时,AE 解码器才会将新生成的表示z上采样成最终生成的图像,
。通过这种方式,更小的z允许使用更小、更高效的计算 U-Net,这对训练和推理都有好处。论文的作者将这种 AE 与扩散模型的结合称为潜在扩散模型。
AE 训练与 U-Net 训练是分开的。因此,我们可以先训练 AE,然后用它在不同的 U-Net 配置下进行多个下游任务。
条件变换器
条件变换器, (图 9.5),生成所需图像的文本描述的潜在表示。SD 将此表示提供给 U-Net,以便它可以影响其输出。为了使这一点生效,文本的潜在表示必须与 U-Net 的图像潜在表示处于相同的语义(不仅仅是维度)空间中。为此,SD 的最新版本 2.1 使用 OpenCLIP 开源模型作为条件变换器(可重复的对比语言-图像学习的规模定律,https://arxiv.org/abs/2212.07143)。CLIP 代表 对比语言-图像预训练。这一技术由 OpenAI 提出(从自然语言监督中学习可迁移的视觉模型,https://arxiv.org/abs/2103.00020)。让我们从下面的图开始更详细地讨论:
图 9.7 – CLIP。灵感来源于 https://arxiv.org/abs/2103.00020
它有两个主要组成部分:
-
[EOS]
标记。该标记处的模型输出作为整个序列的嵌入向量。在 SD 的背景下,我们只关注文本编码器,CLIP 系统的所有其他组件仅在其训练时才是必要的。 -
图像编码器:这可以是 ViT 或 CNN(最常见的是 ResNet)。它以图像作为输入,输出其嵌入向量,i。与文本编码器类似,这也是模型最高层的激活值,而不是任务特定的头部。
为了使 CLIP 起作用,两个编码器的嵌入向量必须具有相同的大小,。如果有必要(例如,在 CNN 图像编码器的情况下),编码器的输出张量会被展平为一维向量。如果两个编码器的维度仍然不同,我们可以添加线性投影(FC 层)来使它们相等。
接下来,让我们专注于实际的预训练算法。训练集包含N个文本-图像对,其中每对的文本描述对应图像的内容。我们将所有文本表示输入文本编码器,将图像输入图像编码器,分别产生 和
的嵌入。然后,我们计算每两个嵌入向量之间的余弦相似度(共N×N个相似度测量)。在这些测量中,我们有N个正确匹配的文本-图像对(图 9**.5的表对角线)和N×N-N个不正确的对(表对角线之外的所有对)。训练更新两个编码器的权重,以使正确对的相似度分数最大化,不正确对的分数最小化。如果训练成功,我们将获得对于正确描述图像内容的文本提示的相似嵌入,并且在所有其他情况下具有不相似的嵌入。在 SD 训练期间,我们优化文本编码器以及 U-Net(但不包括完整的 CLIP 系统)。
现在我们知道如何生成语义上正确的文本嵌入,我们可以继续进行实际的扩散模型。
扩散模型
DM 是一种具有正向和反向阶段的生成模型。前向扩散从由 AE 编码器产生的潜在向量z开始(接受图像x作为输入)。然后,通过一系列T步骤逐渐向z添加随机高斯噪声,直到最终的(潜在的)表示,。
是纯噪声。前向扩散使用加速算法,在一个步骤中生成,而不是T步骤(第五章)。
反向扩散与此相反,从纯噪声开始。它逐渐通过在一系列T去噪步骤中去除少量噪声来恢复原始的潜在张量z。实际上,我们更关心反向扩散,它是基于潜在表示生成图像(前向扩散只参与训练)。它通常使用 U-Net 类型的 CNN 来实现(图 9.5,)。
它以噪声张量作为输入,并输出对添加到原始潜在张量z中的噪声的近似值(即仅噪声,而不是张量本身)。然后,我们从当前的 U-Net 输入中减去预测的噪声,
,并将结果作为新的输入传递给 U-Net,
。
在训练过程中,代价函数衡量预测噪声与实际噪声之间的差异,并在每次去噪步骤后相应地更新 U-Net 的权重。这个过程持续进行,直到(希望)仅剩下原始张量z。然后,AE 解码器使用它生成最终图像。
DM 的纯粹形式无法影响生成图像的属性(这被称为条件化),因为我们从随机噪声开始,导致生成的是随机图像。SD 允许我们做到这一点——一种方法可以将 U-Net 条件化,以根据特定的文本提示或其他数据类型生成图像。为了实现这一点,我们需要将条件化变换器的输出嵌入与去噪 U-Net 结合。假设我们有一个文本提示[EOS]
标记,那么我们通过交叉注意力层将其输出映射到 U-Net 的中间层。在这一层中,键和值张量表示条件化变换器的输出,而查询张量表示 U-Net 的中间层(图 9.5):
在这里,i 是第 i 个中间 U-Net 层, 是该层的展平激活值,且
是展平激活张量的大小。
,
,以及
是可学习的投影矩阵,其中 d 是所选择的实际交叉注意嵌入的大小。对于每个带有交叉注意力的 i 中间 U-Net 层,我们有一组独特的三维矩阵。最简单的形式是,在中间 U-Net 层的输出之后添加一个或多个交叉注意力模块。这些模块可以具有残差连接,保留未修改的中间层输出并通过注意力向量进行增强。请注意,中间卷积层的输出有四个维度:
[batch, channel, height, width]
。然而,标准的注意力模块使用二维输入:[batch, dim]
。一种解决方案是,在将其输入到注意力模块之前,先展平卷积输出。或者,我们可以保留通道维度,只展平高度和宽度:[batch, channel, height*width]
。在这种情况下,我们可以将每个卷积通道的输出分配给一个注意力头。
注意
图 9.5有一个开关组件,它允许我们将文本提示表示与 U-Net 输入连接,而不是在中间层使用交叉注意力。这个用例适用于文本到图像以外的任务,这是本节的重点。
接下来,让我们看看如何实际使用 SD。
使用 Hugging Face Transformers 进行稳定扩散
在本节中,我们将使用 SD 生成一个基于文本提示的图像。除了 Transformers 库外,我们还需要Diffusers(https://github.com/huggingface/diffusers)—一个用于生成图像和音频的预训练扩散模型的库。请注意,Diffusers 的 SD 实现要求有 GPU。您可以在启用 GPU 的 Google Colab 笔记本中运行此示例。让我们开始:
-
进行必要的导入:
import torch from diffusers import StableDiffusionPipeline
-
使用 SD 版本 2.1 实例化 SD 管道(
sd_pipe
)。我们不使用前面例子中使用的主变换器pipeline
抽象。相反,我们使用来自diffusers
库的StableDiffusionPipeline
。如果有可用的cuda
设备(NVIDIA GPU),我们还将把模型移动到该设备:sd_pipe = StableDiffusionPipeline.from_pretrained( "stabilityai/stable-diffusion-2-1", torch_dtype=torch.float16) sd_pipe.to('cuda')
-
让我们运行
sd_pipe
进行 100 次去噪步骤,并使用以下文本提示:prompt = \ "High quality photo of a racing car on a track" image = sd_pipe( prompt, num_inference_steps=100).images[0]
生成的
image
如下:
图 9.8 – SD 生成的图像
不幸的是,AE、U-Net 和条件变换器的描述很长,包含在此处不太实际。不过,它们可以在 Jupyter Notebook 中查看。尽管如此,我们仍然可以通过print(sd_pipe)
命令看到整个 SD 管道的简要总结:
StableDiffusionPipeline {
"safety_checker": [null, null],
"tokenizer": ["transformers", "CLIPTokenizer"],
"text_encoder": ["transformers", "CLIPTextModel"],
"unet": ["diffusers", "UNet2DConditionModel"],
"vae": ["diffusers", "AutoencoderKL"],
"scheduler": ["diffusers", "DDIMScheduler"]
}
这里,transformers
和diffusers
指的是给定组件的源包。
第一个组件是一个可选的safety_checker
(未初始化),它可以识别不适合工作环境(NSFW)的图像。
接下来,我们有一个基于 BPE 的CLIPTokenizer
tokenizer
,它的词汇表大小约为 50,000 个词汇。它将文本提示进行标记化并传递给CLIPTextModel
的text_encoder
。Hugging Face 的CLIPTextModel
复制了 OpenAI CLIP 变换器解码器(模型卡可以在 https://huggingface.co/openai/clip-vit-large-patch14 查阅)。
然后,我们有UNet2DConditionModel
。U-Net 的卷积部分使用残差块(见第四章)。它有四个下采样块,下采样因子为 2(通过步长为 2 的卷积实现)。前三个块包括text_encoder
交叉注意力层。然后,我们有一个单一的中间块,它保持输入大小,并包含一个残差层和一个交叉注意力子层。模型以四个跳跃连接的上采样块结束,结构上与下采样序列对称。最后三个块也包括交叉注意力层。模型使用sigmoid 线性单元(SiLU,见第三章)激活函数。
接下来,我们有卷积自编码器AutoencoderKL
,包含四个下采样残差块,一个残差中间块(与 U-Net 中的相同),四个上采样残差块(与下采样序列对称),以及 SiLU 激活函数。
最后,让我们聚焦于DDIMScheduler
的scheduler
,它是diffusers
库的一部分。这是多个可用调度器之一。在训练过程中,调度器会向样本添加噪声,以训练 DM。它定义了在推理期间如何根据 U-Net 的输出更新潜在张量。
Stable Diffusion XL
最近,Stability AI 发布了 Stable Diffusion XL(SDXL:改进高分辨率图像合成的潜在扩散模型,arxiv.org/abs/2307.01952
)。SDXL 使用了三倍大的 U-Net。更大的尺寸源于更多的注意力块和更大的注意力上下文(新版本使用了两个不同文本编码器的连接输出)。它还利用了一个可选的精炼模型(refiner)——第二个 U-Net 与第一个处于相同的潜在空间,专注于高质量、高分辨率的数据。它将第一个 U-Net 的输出潜在表示z作为输入,并使用相同的条件文本提示。
至此,我们已完成对 SD 的介绍,以及计算机视觉中变压器的更大主题。接下来,让我们看看如何微调基于变压器的模型。
探索微调变压器
在本节中,我们将使用 PyTorch 微调一个预训练的变压器。更具体地,我们将微调一个Trainer
类(huggingface.co/docs/transformers/main_classes/trainer
),它实现了基本的训练循环、模型评估、在多个 GPU/TPU 上的分布式训练、混合精度和其他训练特性。这与我们目前在 PyTorch 示例中所做的从头开始实现训练相对立。我们还需要Datasets(github.com/huggingface/datasets
)和Evaluate(github.com/huggingfahttps://github.com/huggingface/evaluate
)包。让我们开始:
-
加载数据集,该数据集被划分为
train
、validation
和test
部分:from datasets import load_dataset dataset = load_dataset('rotten_tomatoes')
-
加载 DistilBERT WordPiece 子词
tokenizer
:from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained('distilbert-base-uncased')
-
使用
tokenizer
对数据集进行分词。此外,它将对每个样本进行填充或截断,直到符合模型所接受的最大长度。batched=True
映射通过将数据合并成批次(而不是单个样本)来加速处理。Tokenizers
库在批量处理时运行更快,因为它并行化了批次中所有示例的分词过程:tok_dataset = dataset.map( lambda x: tokenizer( text=x['text'], padding='max_length', truncation=True), batched=True)
-
加载变压器
model
:from transformers import AutoModelForSequenceClassification model = AutoModelForSequenceClassification.from_pretrained( 'distilbert-base-uncased')
AutoModelForSequenceClassification
类加载 DistilBERT 配置,用于二分类任务——该模型头部有一个隐藏层和一个带有两个单元的输出层。此配置适用于我们的任务,因为我们需要将电影评论分类为两类。 -
初始化
Trainer
实例的TrainingArguments
。我们将指定output_dir
,作为模型预测和检查点的存储位置。我们还将每个周期运行一次评估:from transformers import TrainingArguments training_args = TrainingArguments( output_dir='test_trainer', evaluation_strategy='epoch')
-
初始化
accuracy
评估指标:import evaluate accuracy = evaluate.load('accuracy')
-
初始化
trainer
,包括训练和评估所需的所有组件:from transformers import Trainer import numpy as np trainer = Trainer( model=model, train_dataset=tok_dataset['train'], eval_dataset=tok_dataset['test'], args=training_args, compute_metrics= lambda x: accuracy.compute( predictions=x[0], references=x[1]), preprocess_logits_for_metrics= lambda x, _: np.argmax(x.cpu(), axis=-1) )
它接受模型、训练和评估数据集,以及
training_args
实例。compute_metrics
函数将在每个周期后计算验证准确率。preprocess_logits_for_metrics
会将经过独热编码的模型输出(x[0]
)转换为索引标签,以便与compute_metrics
函数中的真实标签(x[1]
)格式匹配。 -
最后,我们可以开始训练:
trainer.train()
该模型将在三个训练周期内达到大约 85%的准确率。
接下来,让我们看看如何使用 LangChain 框架释放 LLM 的强大能力。
利用 LangChain 释放 LLM 的强大能力
LLM 是强大的工具,但它们也有一些局限性。其中之一是上下文窗口的长度。例如,Llama 2 的最大输入序列为 4,096 个令牌,按单词计数则更少。作为参考,本书大部分章节大约为 10,000 个单词。许多任务的长度会超出这个限制。另一个 LLM 的局限性是,它的所有知识都储存在训练时的模型权重中。它没有直接与外部数据源(如数据库或服务 API)交互的方式。因此,知识可能会过时或不足。LangChain框架可以帮助我们缓解这些问题。它通过以下模块实现:
-
模型输入/输出:该框架区分经典的 LLM 和聊天模型。在第一种情况下,我们可以通过一个简单的提示来激活模型,模型会生成一个响应。第二种情况则更具互动性——它假设人类和模型之间会有来回的交流。内部来说,两者都是 LLM,区别在于使用了不同的 API。无论模型类型如何,令牌序列都是输入数据的唯一方式。I/O 模块为不同的用例提供了辅助的提示模板。例如,聊天模板会维持一个明确的所有消息列表,而不是将它们合并为一个单一的序列。我们还提供了一个少量示例模板,它为输入查询提供了一个接口,可以在查询中包含一个或多个指令性输入/输出示例。
该模块还可以解析模型输出(将令牌序列转换为单词)。例如,如果输出是一个 JSON 字符串,JSON 解析器可以将其转换为实际的 JSON 对象。
-
检索:这是用来获取外部数据并将其输入到模型序列中的功能。其最基本的功能是解析文件格式,例如 CSV 和 JSON。如果文档过大而无法适应上下文窗口大小,它还可以将文档拆分成更小的块。
向量数据库
LLM 和其他神经网络的主要输出(在任何任务特定头部之前)是嵌入向量,我们将这些向量用于下游任务,如分类或文本生成。该数据格式的通用性促使了向量特定数据库(或存储)的创建。如其名称所示,这些存储仅与向量一起使用,并支持快速的向量操作,例如对整个数据库执行不同的相似度度量。我们可以查询输入的嵌入向量与数据库中所有其他向量进行比较,找到最相似的向量。这个概念类似于 Q/K/V 注意力机制,但它是一种外部数据库形式,可以处理比内存注意力更多的数据集。
该检索模块与多个向量数据库集成。这样,我们就可以使用 LLM 生成并存储文档嵌入(文档作为输入序列)。随后,我们可以查询 LLM 为给定查询生成新的嵌入,并将此查询与数据库进行比较,找到最相似的匹配项。在这种情况下,LLM 的作用仅限于生成向量嵌入。
-
链:链是将多个 LangChain 组件组合起来,创建单一应用程序的机制。例如,我们可以创建一个链,它接收用户输入,使用特殊的提示模板格式化输入,将其传递给 LLM,并将 LLM 输出解析为 JSON。我们还可以分支链或组合多个链。
-
记忆:记忆保持输入的标记序列,贯穿整个步骤链或模型与外部世界的交互过程中,它可以动态地修改和扩展该序列。它还可以利用新兴的 LLM 能力来创建当前历史序列的简短摘要。这个简短版本会替代原始输入序列中的内容,用于未来的输入。这种压缩使我们能够更高效地利用上下文窗口,并存储更多信息。
-
代理:代理是可以采取与环境交互的行动的实体。在当前上下文中,LLM 作为代理的推理引擎,决定代理应该采取哪些行动以及按什么顺序执行。为了帮助完成这一任务,代理/LLM 可以使用特殊的功能,称为 工具。这些工具可以是通用实用程序(例如 API 调用)、其他链,甚至是代理。
-
回调:我们可以使用回调函数来插入 LLM 应用程序的不同点,这对于日志记录、监控或流式处理非常有用。
接下来,让我们通过一个示例加深对 LangChain 的理解。
实际应用 LangChain
在本节中,我们将使用 LangChain、LangChain Experimental(github.com/langchain-ai/langchain/tree/master/libs/experimental
)以及 OpenAI 的gpt-3.5-turbo
模型来回答这个问题:海洋最深处和地球最高峰的海拔总和是多少?仅使用公制单位。为了让事情更有趣,我们不会让 LLM 一次生成一个词。相反,我们将要求它将解决方案拆分为步骤,并使用数据查找和计算来找到正确答案。
注意
此示例部分基于python.langchain.com/docs/modules/agents/agent_types/plan_and_execute
。它需要访问 OpenAI API(platform.openai.com/
)和 SerpAPI(serpapi.com/
)。
让我们开始:
-
初始化 LangChain 的 API 包装器以使用
gpt-3.5-turbo
模型。temperature
参数(在[0,1]范围内)决定模型如何选择下一个 token。例如,如果temperature=0
,它将始终输出概率最高的 token。temperature
越接近 1,模型选择低概率 token 的可能性就越大:from langchain.chat_models import ChatOpenAI model = ChatOpenAI(temperature=0)
-
定义将帮助我们解决此任务的工具。首先是搜索工具,它使用 SerpAPI 执行 Google 搜索。这允许 LLM 查询 Google,以获取我们问题中关于海洋最深处和最高山峰的海拔:
# Tools from langchain.agents.tools import Tool # Search tool from langchain import SerpAPIWrapper search = Tool( name='Search', func=SerpAPIWrapper().run, description='Google search tool')
-
接下来是计算器工具,它将允许 LLM 计算海拔的总和。该工具使用一个特殊的少样本学习 LangChain
PromptTemplate
来查询 LLM 计算数学方程式:from langchain import LLMMathChain llm_math_chain = LLMMathChain.from_llm( llm=model, verbose=True) calculator = Tool( name='Calculator', func=llm_math_chain.run, description='Calculator tool')
-
初始化一个特殊的
PlanAndExecute
agent
。它接受 LLM、我们刚才定义的工具,以及planner
和executor
代理作为参数:from langchain_experimental.plan_and_execute import PlanAndExecute, load_agent_executor, load_chat_planner agent = PlanAndExecute( planner=load_chat_planner( llm=model), executor=load_agent_executor( llm=model, tools=[search, calculator], verbose=True), verbose=True)
planner
使用一个特殊的 LangChain 文本提示模板来查询 LLM 模型,将任务的解决方案拆分为子任务(步骤)。模型生成一个适合列表格式的字符串,planner
解析并返回作为executor
(本身是一个agent
)执行的步骤列表。 -
最后,我们可以运行
agent
:agent.run('What is the sum of the elevations of the deepest section of the ocean and the highest peak on Earth? Use metric units only.')
最终答案是
海洋最深处的深度(公制单位)为 19,783 米
。尽管文本描述有些不准确,但计算结果似乎是正确的。
让我们分析planner
和executor
在达到结果的过程中所采取的部分步骤。首先,planner
接受我们的初始查询,并要求 LLM 将其拆分为以下步骤列表:
1\. 'Find the depth of the deepest section of the ocean in metric units.'
2\. 'Find the elevation of the highest peak on Earth in metric units.'
3\. 'Add the depth of the deepest section of the ocean to the elevation of the highest peak on Earth.'
4\. 'Round the sum to an appropriate number of decimal places.'
5\. "Given the above steps taken, respond to the user's original question. \n"
接下来,agent
遍历每个步骤,并指派executor
执行它。executor
有一个内部 LLM 规划器,它也可以将当前步骤拆分为子任务。除了步骤描述外,executor
还使用一个特殊的文本提示,指示其 LLMmodel
识别它可以用于每个步骤的tools
(工具有名称)。例如,executor
返回以下结果作为第一步增强版本的输出:
'Action: {
"action": "Search",
"action_input": "depth of the deepest section of the ocean in metric units"
}'
Search
代理的action
表示在当前步骤之后执行的一个新的中间步骤。它将使用搜索工具通过action_input
查询 Google。从这个角度来看,链条是动态的,因为一个步骤的输出可以导致额外的步骤被添加到链条中。我们将每个步骤的结果加入到未来步骤的输入序列中,LLM 通过不同的提示模板最终决定下一步的行动。
这就是我们对 LangChain 的介绍——展示了 LLM 的可能性。
总结
在本章中,我们讨论了多个话题。我们从计算机视觉领域的 LLM 开始:ViT 用于图像分类,DetR 用于目标检测,SD 用于文本到图像的生成。接着,我们学习了如何使用 Transformers 库对 LLM 进行微调。最后,我们使用 LangChain 实现了一个新型的 LLM 驱动应用。
在下一章,我们将脱离传统话题,深入探讨 MLOps 的实际应用。
第四部分:
开发
和深度神经网络的部署
在这一单章部分,我们将讨论一些有助于我们开发和部署神经网络模型的技术和工具。
本部分包含以下章节:
- 第十章,机器学习运维(MLOps)
第十章:机器学习
运营(MLOps)
到目前为止,在本书中,我们专注于神经网络(NNs)的理论、各种 NN 架构以及我们可以解决的任务。这一章节有些不同,因为我们将专注于 NN 开发的一些实际方面。我们将深入探讨这个主题,因为 ML 模型(特别是 NNs)的开发和生产部署存在一些独特的挑战。我们可以将这个过程分为三个步骤:
-
训练数据集创建:数据收集、清理、存储、转换和特征工程。
-
模型开发:尝试不同的模型和训练算法,并评估它们。
-
部署:在生产环境中部署训练好的模型,并监控其在计算和准确性方面的性能。
这个多步骤的复杂流程预设了解决 ML 任务时的一些挑战:
-
多样化的软件工具包:每个步骤都有多个竞争工具。
-
模型开发很难:每个训练实例都有大量的变量。这些可能包括 NN 架构的修改、训练超参数的变化(如学习率或动量)或不同的训练数据分布。此外,NNs 还有随机源,例如权重初始化或数据增强。因此,如果我们无法重现早期的结果,很难找出原因。即使代码中有错误,它可能不会导致易于检测的运行时异常。相反,它可能只是稍微降低模型的准确性。为了不丢失所有实验的记录,我们需要一个强大的跟踪和监控系统。
-
复杂的部署和监控:NNs 需要 GPU 和批处理组织的数据以达到最佳性能。这些要求可能与实时处理数据或逐样本处理的真实世界要求发生冲突。此外,用户数据的性质可能随时间变化,这可能导致模型漂移。
在本章中,我们将涵盖以下主要主题:
-
理解模型开发
-
探索模型部署
技术要求
我们将使用 Python、PyTorch、TensorFlow(TF)和Hugging Face(HF) Transformers 等工具实现本章中的示例。如果您还没有配置好这些工具的环境,不要担心——示例代码可以在 Google Colab 的 Jupyter Notebook 中找到。您可以在本书的 GitHub 存储库中找到这些代码示例:github.com/PacktPublishing/Python-Deep-Learning-Third-Edition/tree/main/Chapter10
。
理解模型开发
在本节中,我们将讨论各种工具,这些工具将帮助我们管理 ML 解决方案生命周期的模型开发阶段。让我们从最重要的问题开始——我们应该选择哪个 NN 框架?
选择一个 NN 框架
到目前为止,在本书中我们主要使用了 PyTorch 和 TensorFlow。我们可以将它们称为基础框架,因为它们是整个神经网络软件堆栈中最重要的组件。它们作为机器学习神经网络生态系统中其他组件的基础,比如 Keras 或 HF Transformers,这些组件可以使用它们作为后端(Keras 3.0 将支持多后端)。除了 TensorFlow,Google 还发布了 JAX(github.com/google/jax
),这是一个支持 GPU 加速的 NumPy 操作和 Autograd 的基础库。其他流行的库,如 NumPy、pandas 和 scikit-learn(scikit-learn.org
)超出了本书的范围,因为它们与神经网络没有直接关系。由于基础库的重要性,它们是我们工具包中的首选和最重要的选择。但如果我们从零开始启动一个项目,应该选择哪一个呢?
PyTorch 与 TensorFlow 与 JAX
让我们来查看这些库在社区中的采用程度。我们的第一站是Papers with Code (paperswithcode.com/
),它索引了机器学习论文、代码、数据集和结果。该网站还维护了按框架分组的论文实现趋势(paperswithcode.com/trends
)。截至 2023 年 9 月,57%的新论文使用 PyTorch。TF 和 JAX 分别以 3%和 2%的比例位居第二和第三。这个趋势并不新鲜——PyTorch 于 2016 年发布,但它在 2019 年已经超过了 TF。这个特定的数据点表明,PyTorch 主导着前沿研究,而这些研究正是最新的论文。因此,如果你希望始终使用该领域最新和最优秀的技术,选择 PyTorch 是个不错的主意。接下来,我们来看一下托管在 HF 平台上的机器学习模型(huggingface.co/models
),我们也可以按项目框架进行筛选。在大约 335,000 个托管模型中,约 131,000 个使用 PyTorch,约 10,000 个使用 TF,约 9,000 个使用 JAX。再次,这一结果强烈支持 PyTorch。然而,这并不是完整的图景,因为这些结果仅适用于公开和开源项目。它们不一定能反映公司在生产环境中的使用情况。更具代表性的可能是 PyPI Stats(pypistats.org/
),它提供了Python 软件包索引(PyPi,pypi.org/
)上 Python 包的下载汇总信息。这里的情况稍微复杂一些——PyTorch 在过去一个月(2023 年 8 月-9 月)有 11,348,753 次下载,而 TF 为 16,253,288 次,JAX 为 3,041,747 次。然而,我们应该对 PyPi Stats 持谨慎态度,因为许多自动化流程(例如持续集成)可能会使 PyPi 的下载次数膨胀,而这些并不代表真实世界的使用情况。此外,PyTorch 的下载页面建议通过 Conda(conda.io/
)安装该库。月度统计数据显示,PyTorch 有 759,291 次下载,而 TF 为 154,504 次,JAX 为 6,260 次。因此,PyTorch 在这里也占据领先地位。总的来说,我的结论是,PyTorch 比 TF 更受欢迎,但这两者都在生产环境中使用。
我的建议是,如果你现在开始一个项目,可以选择 PyTorch,具体如何采纳这个建议可以根据你的实际情况来决定。正因如此,本书在讲解时相对于 TF 更多强调了 PyTorch。这个规则的一个例外是,如果你的项目运行在移动设备或边缘设备(en.wikipedia.org/wiki/Edge_device
)上,并且计算能力有限。TF 通过 TF Lite 库(www.tensorflow.org/lite
)对这类设备提供了更好的支持。
但最终,你可以使用自己偏好的软件栈进行工作,然后将模型转换为其他库以进行部署。我们将在下一节中看到这如何实现。
开放神经网络交换格式
开放神经网络交换格式(ONNX,onnx.ai/
)提供了一个用于基于神经网络(NN)和传统机器学习(ML)模型的开源格式(我们将在这里专注于神经网络)。它定义了一个可扩展的计算图模型、内置操作符和标准数据类型。换句话说,ONNX 提供了一个通用的神经网络表示格式,允许我们将一个库(例如 PyTorch)实现的模型转换为另一个库(如 TF)的模型,前提是源库和目标库都支持 ONNX。通过这种方式,你可以使用一个库来训练模型,然后在部署到生产环境时将其转换为另一个库。这也很有意义,因为 ONNX 关注推理模式,而不是训练(使用 ONNX 表示训练过程是实验模式)。
ONNX 通过计算onnx
(!pip install onnx
)Python 包来表示一个神经网络。我们开始吧:
-
定义图表示的输入(
X
、A
、B
)和输出(Y
)变量:import numpy as np from onnx import TensorProto, numpy_helper from onnx.helper import make_tensor_value_info X = make_tensor_value_info( name='X', elem_type=TensorProto.FLOAT, shape=[None, None]) Y = make_tensor_value_info( 'Y', TensorProto.FLOAT, [None]) A = numpy_helper.from_array( np.array([0.5, -0.6], dtype=np.float32), name='A') B = numpy_helper.from_array( np.array([0.4], dtype=np.float32), name='B')
在这里,
make_tensor_value_info
声明了命名的图输入输出变量(X
和Y
),并为其定义了类型(elem_type
)和shape
。shape=[None]
意味着任意形状,而shape=[None, None]
意味着没有具体维度大小的二维张量。另一方面,A
和B
是函数参数(权重),我们用 NumPy 数组中的预定义值对它们进行初始化。 -
定义图的操作:
from onnx.helper import make_node mat_mul = make_node( op_type='MatMul', inputs=['X', 'A'], outputs=['XA']) addition = make_node('Add', ['XA', 'B'], ['Y'])
mat_mul
表示输入矩阵X
和A
之间的矩阵乘法(MatMul
),并将结果存储到输出变量XA
中。addition
将mat_mul
的输出XA
与偏置B
相加。
ONNX 操作符
这个示例介绍了MatMul
和Add
的 ONNX 操作符。支持的操作符完整列表(请参见onnx.ai/onnx/operators/
)包括许多其他神经网络构建块,如激活函数、卷积、池化以及张量操作符(例如concat
、pad
、reshape
和flatten
)。此外,它还支持所谓的if
操作符,根据布尔值执行一个子图或另一个子图。ONNX 本身并不实现这些操作符。相反,支持它的库(如 PyTorch)有自己的实现。反过来,如果你的库模型使用了 ONNX 不支持的操作符,ONNX 转换将会失败。
-
现在我们已经具备了定义计算
graph
的条件:from onnx.helper import make_graph graph = make_graph( nodes=[mat_mul, addition], name='Linear regression', inputs=[X], outputs=[Y], initializer=[A, B])
我们可以在以下图中看到我们的计算图:
图 10.1 – 线性回归 ONNX 计算图
-
使用
graph
来创建一个onnx_model
实例。该模型允许你向图中添加额外的元数据,如文档字符串、版本、作者和许可证等:from onnx.helper import make_model onnx_model = make_model(graph) onnx_model.doc_string = 'Test model' onnx_model.model_version = 1
-
检查模型的一致性。这可以验证模型组件之间输入类型或形状是否匹配:
from onnx.checker import check_model check_model(onnx_model) print(onnx_model)
-
最后,我们可以使用
ReferenceEvaluator
实例计算两个随机输入样本的模型输出:from onnx.reference import ReferenceEvaluator sess = ReferenceEvaluator(onnx_model) print(sess.run( output_names=None, feed_inputs={'X': np.random.randn(2, 2).astype(np.float32)}))
计算的结果是一个 NumPy 数组:
[array([-0.7511951, 1.0294889], dtype=float32)]
-
ONNX 允许我们使用 协议缓冲区 (Protocol Buffers,protobuf,
protobuf.dev/
) 来序列化和反序列化模型结构及其权重。以下是操作方法:with open('model.onnx', 'wb') as f: f.write(onnx_model.SerializeToString()) from onnx import load with open('model.onnx', 'rb') as f: onnx_model = load(f)
现在我们已经介绍了 ONNX,接下来看看如何通过将 PyTorch 和 TF 模型导出到 ONNX 来实际应用它。
除了 torch
和 tensorflow
,我们还需要 torchvision
、onnx
和 tf2onnx
(github.com/onnx/tensorflow-onnx
,!pip install tf2onnx
)包。我们先从 PyTorch 开始:
-
加载一个预训练模型(
MobileNetV3
,参考 第五章):import torch from torchvision.models import mobilenet_v3_small, MobileNet_V3_Small_Weights torch_model = mobilenet_v3_small( weights=MobileNet_V3_Small_Weights.DEFAULT)
-
然后,导出模型:
torch.onnx.export( model=torch_model, args=torch.randn(1, 3, 224, 224), f="torch_model.onnx", export_params=True)
大多数参数不言而喻。
args=torch.randn(1, 3, 224, 224)
指定了一个虚拟张量。这是必要的,因为序列化器可能会调用模型一次,以推断图结构和张量的大小。这个虚拟张量将作为调用的输入。然而,这也暴露了转换过程中的一个限制:如果模型包含动态计算图,转换器仅会转换当前调用路径。export_params
告诉导出器在导出模型结构时也包括模型的权重。 -
使用 ONNX 加载导出的模型并检查其一致性(剧透:它可以正常工作):
import onnx torch_model_onnx = onnx.load('torch_model.onnx') onnx.checker.check_model(torch_model_onnx)
接下来,我们也可以使用 TF 执行相同的操作。与 PyTorch 不同,TF 没有开箱即用的 ONNX 序列化支持。相反,我们将使用 tf2onnx
包:
-
加载一个预训练的
MobileNetV3
模型:import tensorflow as tf tf_model = tf.keras.applications.MobileNetV3Small( weights='imagenet', input_shape=(224, 224, 3), )
-
使用
tf2onnx
序列化模型。它遵循与 PyTorch 相同的原理,包括虚拟输入张量(input_signature
),这是调用模型时必需的:import tf2onnx tf_model_onnx, _ = tf2onnx.convert.from_keras( model=tf_model, input_signature=[tf.TensorSpec([1, 224, 224, 3])]) onnx.save(tf_model_onnx, 'tf_model.onnx')
我们再次可以使用 ONNX 加载模型,以验证其一致性。
接下来,我们可以使用 torch_model.onnx
或 tf_model.onnx
。这是一种用于神经网络和其他机器学习模型的图形查看工具。它可以作为 用户界面 (UI) 的 Web 版,或者作为独立应用程序存在。它支持 ONNX、TensorFlow Lite 和 PyTorch(实验性支持),以及其他一些库。例如,以下图显示了通过 Netron 可视化的初始MobileNetV3层(完整模型的可视化太大,无法在本章中显示):
图 10.2 – MobileNetV3 ONNX 模型文件的 Netron 可视化
在这里,输入形状为 3×224×224,W 是卷积滤波器的形状,B 是偏置。我们在 第四章 中介绍了其余的卷积属性。
不幸的是,PyTorch 和 TF 都没有集成加载 ONNX 模型的功能。但是,已经有开源包允许我们实现这一点。其中有两个分别为 PyTorch 提供的 onnx2torch
(github.com/ENOT-AutoDL/onnx2torch
) 和为 TF 提供的 onnx2tf
(github.com/PINTO0309/onnx2tf
)。
接下来,我们将重点介绍一款能够简化训练过程的工具。
介绍 TensorBoard
TensorBoard(TB,www.tensorflow.org/tensorboard/
,github.com/tensorflow/tensorboard
)是一个 TF 补充的基于网页的工具,提供了机器学习实验的可视化和工具支持。它的一些功能如下:
-
指标(如损失和精度)跟踪和可视化
-
模型图可视化(类似 Netron)
-
显示权重、偏差或其他张量随时间变化的时间序列直方图
-
低维嵌入投影
TensorBoard 可以与 TF/Keras 和 PyTorch 一起使用,但它与 TF 的集成更好(毕竟是由 TF 团队开发的)。在这两种情况下,TensorBoard 在训练过程中并不会直接与模型通信。相反,训练过程会将其状态和当前进度存储在一个特殊的日志文件中。TensorBoard 跟踪该文件的变化,并自动更新其图形界面,展示最新信息。通过这种方式,它可以随着训练的进展实时可视化训练过程。此外,该文件还会存储整个训练历史,即使训练完成后,仍然可以展示这些数据。为了更好地理解其工作原理,我们将把 TensorBoard 添加到我们在 第五章 中介绍的迁移学习计算机视觉示例中。简要回顾一下,我们将从 ImageNet 预训练的 MobileNetV3 模型开始。接着,我们将使用两种迁移学习技术,特征工程和微调,来训练这些模型以对 CIFAR-10 数据集进行分类。TensorBoard 将可视化训练过程。
让我们从 Keras 示例开始。我们只会包括相关部分的代码,而不是完整的示例,因为我们在 第五章 中已经讨论过了。更具体地说,我们将专注于 train_model(model, epochs=5)
函数,该函数将预训练的 model
和训练的 epochs
数量作为参数。以下是该函数的主体(请注意,实际的实现有缩进):
初始化 TensorBoard
本示例假设 TensorBoard 已经初始化并正在运行(尽管即使未安装,代码仍然可以正常工作)。我们不会包括 TensorBoard 的初始化代码,因为它取决于环境设置。但它在本示例的 Jupyter Notebook 中是有包含的。
按照以下步骤操作:
-
首先,我们将使用 Adam 优化器、二元交叉熵损失函数以及精度跟踪来配置预训练 Keras 模型的训练:
model.compile( optimizer=tf.keras.optimizers.Adam( learning_rate=0.0001), loss='categorical_crossentropy', metrics=['accuracy'])
-
接下来,我们将添加特殊的
tensorboard_callback
,它实现了 TB 连接:tensorboard_callback = tf.keras.callbacks.TensorBoard( log_dir='logs/tb/' + datetime.datetime.now().strftime('%Y%m%d-%H%M%S'), update_freq='epoch', histogram_freq=1, write_graph=True, write_images=True, write_steps_per_second=True, profile_batch=0, embeddings_freq=0)
回调参数如下:
-
log_dir
:这指示tensorboard_callback
将日志文件写入一个唯一的时间戳文件夹,即'logs/tb/' + datetime.datetime.now().strftime('%Y%m%d-%H%M%S')
,位于主文件夹'logs/tb/'
下。TB 将同时选择'logs/tb/'
下所有训练文件夹,并在其 UI 中显示它们作为唯一的训练实例。 -
update_freq=1
:每个周期更新日志文件。 -
histogram_freq=1
:每个周期计算一次权重直方图。 -
write_graph=True
:生成 NN 架构的图形可视化。 -
write_images=True
:将模型权重可视化为图像。 -
write_steps_per_second=True
:记录每秒训练步骤。 -
profile_batch=1
:对第一批次进行分析以采样其计算特性。 -
Embeddings_freq=0
:嵌入层将被可视化的频率(以周期为单位)(我们没有嵌入层,因此默认情况下禁用)。
-
-
最后,我们将使用
model.fit
方法运行训练:steps_per_epoch=metadata.splits['train'].num_examples // BATCH_SIZE validation_steps=metadata.splits['test'].num_examples // BATCH_SIZE model.fit( train_batches, epochs=epochs, validation_data=test_batches, callbacks=[tensorboard_callback], steps_per_epoch=steps_per_epoch, validation_steps=validation_steps)
我们将
tensorboard_callback
添加到model
的回调列表中。训练过程会通知每个回调不同的训练事件:训练开始、训练结束、测试开始、测试结束、周期开始、周期结束、批次开始和批次结束。反过来,tensorboard_callback
根据其配置和当前事件更新日志文件。TB UI 显示了日志文件中的所有信息。虽然它过于复杂无法在此处包含,但我们仍然可以显示一个关于准确度的片段:
图 10.3 – TB UI 中的准确度
在这里,TB 显示了四个不同实验的准确度 - 特征工程的训练/测试和微调的训练/测试。
接下来,让我们看看 PyTorch 如何与 TB 集成。它提供了一个特殊的torch.utils.tensorboard.SummaryWriter
类,它将条目直接写入事件日志文件,以供 TB 消费。它遵循与 Keras 相同的原则。SummaryWriter
的高级 API 允许我们在log_dir
中创建一个事件文件,并异步向其添加内容。与 Keras 不同的主要区别在于,我们负责添加内容,而不是由自动事件侦听器执行。让我们看看实际操作中是如何工作的。与 Keras 一样,我们将使用计算机视觉迁移学习示例来自第五章。我们只关注相关部分,但您可以在本书的 GitHub 存储库的 Jupyter 笔记本中查看完整示例。
首先,我们将初始化两个SummaryWriter
实例,用于特征提取器的微调模式。无论我们在哪里执行它,只要在开始使用它们之前执行即可。与 Keras 一样,每个训练实例都有一个唯一的时间戳文件夹,位于'logs/tb/'
下(我们仅显示一个初始化,因为它们都是相同的):
import datetime
from torch.utils.tensorboard import SummaryWriter
writer = SummaryWriter(
log_dir='logs/tb/' + datetime.datetime.now().strftime('%Y%m%d-%H%M%S'))
为了清晰起见,我们将包括初始化 MobileNetV3 预训练模型的代码:
from torchvision.models import (
MobileNet_V3_Small_Weights, mobilenet_v3_small)
model = mobilenet_v3_small(
weights=MobileNet_V3_Small_Weights.IMAGENET1K_V1)
接下来,我们将进入训练(或测试)循环,其中 train_loader
,torch.utils.data.DataLoader
的一个实例,生成 inputs
和 labels
的小批量数据:
for i, (inputs, labels) in enumerate(data_loader):
# Training loop goes here
在循环中,我们可以将模型图添加到日志文件中。它以模型和输入张量作为参数生成可视化(因此需要在训练循环中调用 add_graph
):
writer.add_graph(model, inputs)
最后,在训练循环的末尾,我们将添加当前 epoch
的损失和准确率作为标量值:
writer.add_scalar(
tag='train/accuracy',
scalar_value=total_acc,
global_step=epoch)
writer.add_scalar(
tag='train/loss',
scalar_value=total_loss,
global_step=epoch)
每个标量值都有一个唯一的 tag
(除了代码中的两个标签外,我们还有 tag='validation/loss'
)。请注意,global_step
(等于 epoch)将 scalar_value
存储为同一 tag
下的一个序列。除了图形和标量外,SummaryWriter
还可以添加图像、张量、直方图和嵌入等内容。
本文结束了我们对 TB 的介绍。接下来,我们将学习如何为边缘设备开发神经网络模型。
使用 TF Lite 开发边缘设备的神经网络模型
TF Lite 是一个源自 TF 的工具集,使我们能够在移动设备、嵌入式设备和边缘设备上运行模型。其多功能性是 TF 在工业应用中受欢迎的原因之一(与 PyTorch 主导的研究应用领域相对)。TF Lite 的核心范式是模型在设备上运行,而不是传统的客户端-服务器架构,其中模型部署在远程、更强大的硬件上。这种组织方式有以下影响(包括正面和负面):
-
低延迟执行:缺少服务器的往返连接显著减少了模型推理时间,使我们能够运行实时应用程序。
-
隐私:用户数据从不离开设备。
-
互联网连接:不需要互联网连接。
-
.tflite
文件扩展名。除了文件体积小,它还允许我们直接访问数据,而无需首先解析/解包它。
TF Lite 模型支持 TF Core 操作的子集,并允许我们定义自定义操作:
-
低功耗:这些设备通常使用电池供电。
-
训练与推理的差异:神经网络训练比推理需要更多的计算资源。因此,模型训练通常在比实际设备更强大的硬件上进行,而这些设备用于推理。
此外,TF Lite 具有以下关键特性:
-
支持多平台和多语言,包括 Android(Java)、iOS(Objective-C 和 Swift)设备、Web(JavaScript)以及其他环境的 Python。谷歌提供了一个名为 MediaPipe Solutions 的 TF Lite 封装 API (
developers.google.com/mediapipe
,github.com/google/mediapipe/
),它取代了之前的 TF Lite API。 -
性能优化。
-
它具有端到端的解决方案管道。TF Lite 主要面向实际应用,而非研究。因此,它包含了用于常见机器学习任务的不同管道,如图像分类、物体检测、文本分类和问答等。计算机视觉管道使用了修改版的 EfficientNet 或 MobileNet(第四章),自然语言处理管道则使用基于 BERT 的(第七章)模型。
那么,TF Lite 模型开发是如何工作的呢?首先,我们将通过以下方式选择一个模型:
-
一个已存在的预训练
.tflite
模型(tfhub.dev/s?deployment-format=lite
)。 -
使用
.tflite
模型和自定义训练数据集。Model Maker 仅适用于 Python。 -
将一个完整的 TF 模型转换为
.tflite
格式。
TFLite 模型元数据
.tflite
模型可能包含三个组件的可选元数据:
-- 可读部分:为模型提供额外的信息。
-- 输入信息:描述输入数据格式以及必要的预处理步骤。
-- 输出信息:描述输出数据格式以及必要的后处理步骤。
最后两部分可以被代码生成器(例如,Android 代码生成器)利用,以在目标平台上创建现成的模型包装器。
接下来,我们来看如何使用 Model Maker 训练一个 .tflite
模型,然后用它来分类图像。我们只会展示相关的代码部分,但完整的示例可以在本书的 GitHub 仓库中的 Jupyter Notebook 中找到。让我们开始吧:
-
首先,我们将创建训练和验证数据集:
from mediapipe_model_maker import image_classifier dataset = image_classifier.Dataset.from_folder(dataset_path) train_data, validation_data = dataset.split(0.9)
在这里,
dataset_path
是 Flowers 数据集的路径(www.tensorflow.org/datasets/catalog/tf_flowers
),该数据集包含了 3,670 张低分辨率的 RGB 花卉图片,分为五个类别(每个类别一个子文件夹)。data.split(0.9)
将数据集(image_classifier.Dataset
实例)拆分为train_data
(90%的图片)和validation_data
(10%的图片)两部分。 -
接下来,我们将定义训练超参数——训练三轮,使用 mini-batch 大小为 16,并将训练好的模型导出到
export_dir
文件夹(也可以使用其他参数):hparams = image_classifier.HParams( export_dir='tflite_model', epochs=3, batch_size=16)
-
然后,我们将定义模型参数(我们将使用
EfficientNet
):options = image_classifier.ImageClassifierOptions( supported_model=image_classifier.SupportedModels.EFFICIENTNET_LITE4, hparams=hparams)
-
最后,我们将创建一个新模型并开始训练:
model = image_classifier.ImageClassifier.create( train_data=train_data, validation_data=validation_data, options=options, )
这个模型在三轮训练中达到了大约 92% 的准确率。训练过程会创建一个与 TB 兼容的日志文件,因此我们可以通过 TB 跟踪进度(在 Jupyter Notebook 中可用)。
-
接下来,我们将导出模型为
.tflite
格式,进入示例的下一个阶段:model.export_model('model.tflite')
-
现在我们有了一个训练好的模型,可以用它来分类图像。我们将使用
MediaPipe
Python API(与 Model Maker 不同):import mediapipe as mp from mediapipe.tasks import python from mediapipe.tasks.python import vision generic_options = python.BaseOptions( model_asset_path='/content/tflite_model/model.tflite') cls_options = vision.ImageClassifierOptions( base_options=generic_options) classifier = vision.ImageClassifier.create_from_options(cls_options)
这里,
classifier
是预训练模型,generic_options
包含.tflite
模型的文件路径,而cls_options
包含特定于分类的选项(我们使用默认值)。 -
我们将加载五张随机的花卉图像(每个花卉类别一张,如
labels
中所列),并将它们存储在一个名为image_paths
的列表中(这里不显示)。我们将对每张图像进行分类,并将其预测标签与真实标签进行比较:for image_path, label in zip(image_paths, labels): image = mp.Image.create_from_file(image_path) result = classifier.classify(image) top_1 = result.classifications[0].categories[0] print(f'Label: {label}; Prediction: {top_1.category_name}')
可以预见,模型能够正确分类所有图像。
接下来,我们将学习如何使用混合精度计算来优化训练过程。
使用 PyTorch 进行混合精度训练
我们在第八章中讨论了 LLM 的混合精度训练。在这一节中,我们将展示如何在实践中使用 PyTorch 来实现它。我们将再次使用第五章中的转移学习 PyTorch 示例作为实现的基础。所有的代码修改都集中在train_model
函数中。这里我们只包括train_model
,但完整的示例可以在本书的 GitHub 仓库中的 Jupyter Notebook 中找到。以下是该函数定义的简化版本:
def train_model(model, loss_fn, optimizer, data_loader):
scaler = torch.cuda.amp.GradScaler()
for i, (inputs, labels) in enumerate(data_loader):
optimizer.zero_grad()
with torch.autocast(
device_type=device,
dtype=torch.float16):
# send the input/labels to the GPU
inputs = inputs.to(device)
labels = labels.to(device)
# forward
outputs = model(inputs)
loss = loss_fn(outputs, labels)
# backward with scaler
scaler.scale(loss).backward()
scaler.step(optimizer)
scaler.update()
我们使用两种独立且不相关的机制来进行混合精度训练:
-
torch.autocast
:它充当上下文管理器(或装饰器),允许代码的某个区域在混合精度下运行。device_type
指定autocast
应用的设备,dtype
指定 CUDA 操作使用的数据类型。PyTorch 文档建议仅将前向传播和损失计算封装在torch.autocast
中,反向传播操作会自动使用与前向传播相同的数据类型。 -
torch.cuda.amp.GradScaler
:当前向传播使用float16
精度操作时,反向传播也使用相同的精度进行梯度计算。然而,由于较低的精度,一些梯度值可能会变为零。为了防止这种情况,梯度缩放将神经网络的损失乘以一个缩放因子,并用缩放后的值执行反向传播。反向传播时,梯度流也会按相同的因子进行缩放。通过这种方式,整个反向传播过程使用较大的数值,以防止梯度被清零。在权重更新之前,该机制会反缩放梯度值,以确保权重更新时使用的是实际的梯度值。
这就是我们对模型开发工具的介绍。接下来,我们将讨论一些模型部署机制。
探索模型部署
在这一节中,我们将讨论两个基本的模型部署示例。这些示例将帮助你创建简单但功能完整的概念验证应用程序,用于你的实验。我们开始吧。
使用 Flask 部署神经网络模型
在第一个示例中,我们将结合使用 Google Colab 和prompt
参数,生成图像,并将其作为结果返回。
根据其主页,Flask 是一个轻量级的localhost
,但我们无法访问它。为了解决这个问题,我们需要flask-ngrok
(ngrok.com/docs/using-ngrok-with/flask/
),它将使服务器暴露给外界(你需要一个免费的ngrok
注册和认证令牌来运行这个示例)。
为了满足所有依赖项,我们需要安装transformers
、diffusers
、accelerate
和flask-ngrok
包。让我们开始吧:
-
首先,我们将以与第九章中相同的方式初始化 SD HF 管道(
sd_pipe
):import torch from diffusers import StableDiffusionPipeline sd_pipe = StableDiffusionPipeline.from_pretrained( "stabilityai/stable-diffusion-2-1", torch_dtype=torch.float16) sd_pipe.to('cuda')
-
接下来,我们将初始化我们的 Flask
app
:from flask import Flask from flask_ngrok import run_with_ngrok app = Flask(__name__) run_with_ngrok(app)
这里,
run_with_ngrok
表示应用将使用ngrok
运行,但实际的app
尚未启动(这将在本示例的最后进行)。由于我们无法访问 Colab 的localhost
,ngrok
将使我们能够通过测试客户端访问它。 -
然后,我们将实现我们的
text-to-image
端点,它将处理作为 Web 请求传入的提示,并基于它们生成图像:import io from flask import Flask, request, send_file, abort @app.route('/text-to-image', methods=['POST', 'GET']) def predict(): if request.method in ('POST', 'GET'): prompt = request.get_json().get('prompt') if prompt and prompt.strip(): image = sd_pipe( prompt, num_inference_steps=100).images[0] image_io = io.BytesIO() image.save(image_io, format='PNG') image_io.seek(0) return send_file( image_io, as_attachment=False, mimetype='image/png' ) else: abort(500, description='Invalid prompt')
该端点的名称是
/text-to-image
,它将处理POST
和GET
请求(处理流程是相同的)。该函数将解析文本的prompt
参数,并将其传递给sd_pipe
以生成image
参数(与第九章示例中的方式相同)。最后,send_file
函数将把image
的结果返回给客户端。 -
我们现在可以通过
app.run()
命令启动 Flask 应用。它将初始化 Flask 开发服务器,使我们的端点准备好处理请求。此外,ngrok
将通过RANDOM-SEQUENCE.ngrok.io/
类型的 URL 将应用暴露给外界。 -
我们可以使用这个 URL 发起对
text-to-image
端点的测试请求(这在 Colab 笔记本外进行):import requests response = requests.post( url='http://RANDOM-SEQUENCE.ngrok.io/text-to-image', json={'prompt': 'High quality photo of a racing car on a track'})
-
我们可以使用以下代码来显示图像:
from PIL import Image import io image = Image.open(io.BytesIO(response.content)) image.show()
这就结束了我们的 REST API 示例。接下来,我们将在 Web 环境中部署一个带有 UI 的模型。
使用 Gradio 构建机器学习 Web 应用
Gradio(www.gradio.app/
)是一个开源的 Python 库,允许我们为 ML 模型构建互动式 Web 演示。HF Spaces(huggingface.co/spaces
)支持托管 Gradio 应用。因此,我们可以在 HF 基础设施上构建一个 Gradio 应用,它不仅包括托管,还可以访问所有可用的 HF 模型(huggingface.co/models
)。
我们可以在 huggingface.co/new-space
创建一个 HF 空间。这个空间有一个名字(它也将成为其 URL)、一个许可证和一个 SDK。在写作时,HF Spaces 支持基于 Streamlit 的(streamlit.io/
)、基于 Gradio 的和静态实例。然而,你也可以部署自定义的 Docker 容器以获得更多灵活性。
每个新的 HF 空间都有一个关联的 Git 仓库。例如,本文示例的空间位于 huggingface.co/spaces/ivan-vasilev/gradio-demo
,这也是其相应 Git 仓库的 URL。基于 Gradio 的空间期望在其根目录中有一个名为 app.py
的 Python 模块(在我们的例子中,整个示例将驻留在 app.py
中)和一个 requirements.txt
文件。每次你推送更改到仓库时,应用程序将自动接收这些更改并重新启动。
注意
若要复制此示例,你需要一个 HF 账户。HF Spaces 提供不同的硬件等级。基本版是免费的,但此特定示例需要启用 GPU 的等级,这会按小时收费。因此,如果你想运行此示例,可以将其复制到自己的账户中并启用 GPU 等级。
Gradio 从一个名为 gradio.Interface
的中央高级类开始。其构造函数接受三个主要参数:
-
fn
:主函数,将处理输入并返回输出。 -
inputs
:一个或多个 Gradio 输入组件。这些可以是文本输入、文件上传或组合框等。你可以将组件指定为类实例或通过其字符串标签。输入的数量应与fn
参数的数量匹配。 -
outputs
:一个或多个 Gradio 组件,表示fn
执行结果。输出的数量应与fn
返回的值的数量匹配。
Gradio 将根据 input
和 output
参数自动实例化并排列 UI 组件。
接下来,我们将实现我们的示例。我们将使用与 使用 Flask 部署神经网络模型 部分相同的文本到图像的 SD 场景。为了避免重复,我们假设 sd_pipe
流水线已被初始化。现在开始:
-
首先,我们将实现
generate_image
函数,该函数使用prompt
在inf_steps
步骤内合成一张图像:def generate_image( prompt: str, inf_steps: int = 100): return sd_pipe( prompt=prompt, num_inference_steps=inf_steps).images[0]
-
接下来,我们将初始化
gradio.Interface
类:import gradio as gr interface = gr.Interface( fn=generate_image, inputs=[ gr.components.Textbox(label='Prompt'), gr.components.Slider( minimum=0, maximum=100, label='Inference Steps')], outputs=gr.components.Image(), title='Stable Diffusion', )
如我们所讨论的,
inputs
和outputs
gr.Interface
参数与generate_image
函数的输入/输出签名相匹配。 -
最后,我们可以使用
interface.launch()
命令运行应用程序。以下是该应用程序响应式 UI 的样子:
图 10.4 – SD Gradio 应用程序的响应式 UI,托管在 HF Spaces 上。上方:输入组件;下方:生成的图像
这部分内容总结了我们对 Gradio 和模型部署的介绍。
总结
在这一章中,我们概述了机器学习开发生命周期的三个主要组成部分——训练数据集的创建、模型开发和模型部署。我们主要关注了后两者,从开发开始。首先,我们讨论了基础神经网络框架的流行。接着,我们聚焦于几个模型开发主题——ONNX 通用模型表示格式、TB 监控平台、TF Lite 移动开发库,以及混合精度的 PyTorch 训练。然后,我们讨论了两种基本的模型部署场景——作为 Flask 应用的 REST 服务和使用 Gradio 的交互式 Web 应用。
这章以及本书到此结束。希望你享受这段旅程!