TensorFlow-深度学习入门指南-全-

TensorFlow 深度学习入门指南(全)

原文:Beginning Deep Learning with TensorFlow

协议:CC BY-NC-SA 4.0

一、人工智能导论

我们想要的是一台能从经验中学习的机器。

—艾伦·图灵

1.1 人工智能在行动

信息技术是人类历史上的第三次工业革命。计算机、互联网和智能家居技术的普及极大地方便了人们的日常生活。通过编程,人类可以将事先设计好的交互逻辑交给机器反复快速执行,从而将人类从简单繁琐的重复性劳动中解放出来。然而,对于需要高智能水平的任务,如人脸识别、聊天机器人和自动驾驶,很难设计出清晰的逻辑规则。因此,传统的编程方法对这类任务无能为力,而人工智能作为解决这类问题的关键技术,是非常有前途的。

随着深度学习算法的兴起,AI 在一些任务上已经实现甚至超过了类似人类的智能。比如 AlphaGo 程序已经击败了人类围棋最强选手之一的柯洁,OpenAI Five 在 Dota 2 的比赛上击败了冠军战队 OG。在此期间,人脸识别、智能语音、机器翻译等实用技术进入了人们的日常生活。现在我们的生活其实已经被 AI 包围了。虽然目前所能达到的智能水平距离人工通用智能(AGI)还有一段距离,但我们仍然坚信 AI 的时代已经到来。

接下来,我们将介绍 AI、机器学习和深度学习的概念,以及它们之间的联系和区别。

1.1.1 人工智能解释

AI 是一种允许机器像人类一样获得智能和推理机制的技术。这个概念最早出现在 1956 年的达特茅斯会议上。这是一项非常具有挑战性的任务。人类目前还不能对人脑的工作机制有全面科学的认识。制造能达到人脑水平的智能机器,无疑难度更大。也就是说,在某些方面类似甚至超过人类智力的机器已经被证明是可行的。

如何实现 AI 是一个非常宽泛的问题。人工智能的发展主要经历了三个阶段,每个阶段都代表了人类试图从不同角度实现人工智能的探索足迹。在早期,人们试图通过总结和概括一些逻辑规则,并以计算机程序的形式实现它们来开发智能系统。但是这种显式规则往往过于简单,难以用来表达复杂抽象的概念和规则。这个阶段被称为推理期。

20 世纪 70 年代,科学家试图通过知识库和推理来实现 AI。他们建立了一个大型复杂的专家系统来模拟人类专家的智力水平。这些明确指定的规则的最大困难之一是,许多复杂、抽象的概念无法在具体的代码中实现。比如人类对图片的识别和对语言的理解过程,根本无法用既定的规则来模拟。为了解决这样的问题,一个允许机器从数据中自动学习规则的研究学科诞生了,称为机器学习。20 世纪 80 年代,机器学习成为人工智能领域的一个热门话题。这是第二阶段。

在机器学习中,有一个方向是通过神经网络学习复杂、抽象的逻辑。神经网络方向的研究经历了两次大起大落。自 2012 年以来,深度神经网络技术的应用在计算机视觉、自然语言处理(NLP)和机器人等领域取得了重大突破。有些任务甚至已经超过了人类的智力水平。这是人工智能的第三次复兴。深度神经网络最终有了一个新名字——深度学习。总的来说,神经网络和深度学习的本质区别并不大。深度学习是指基于深度神经网络的模型或算法。人工智能、机器学习、神经网络、深度学习之间的关系如图 1-1 所示。

img/515226_1_En_1_Fig1_HTML.png

图 1-1

人工智能、机器学习、神经网络和深度学习的关系

1.1.2 机器学习

机器学习可以分为有监督学习、无监督学习、强化学习,如图 1-2 。

img/515226_1_En_1_Fig2_HTML.png

图 1-2

机器学习的类别

监督学习。监督学习数据集包含样本 x 和样本标签 y 。算法需要学习映射关系fθ:xy,其中 f θ 代表模型函数, θ 是模型的参数。在训练过程中,通过最小化模型预测与真实值 y 之间的误差来优化模型参数 θ ,使模型能够有更准确的预测。常见的监督学习模型包括线性回归、逻辑回归、支持向量机(SVMs)和随机森林。

无监督学习。收集带标签的数据通常更昂贵。对于只有样本的数据集,算法需要发现数据本身的模态。这种算法被称为无监督学习。无监督学习中的一类算法是将自身作为监督信号,即fθ:xx,这就是所谓的自监督学习。在训练过程中,通过最小化模型预测值fθ(x)与其自身 x 之间的误差来优化参数。常见的无监督学习算法包括自编码器和生成对抗网络(GANs)。

强化学习。这是一种通过与环境交互来学习解决问题的策略的算法。与有监督和无监督学习不同,强化学习问题没有明确的“正确的”动作监督信号。该算法需要与环境进行交互,以从环境反馈中获得滞后的奖励信号。因此,不可能计算模型强化学习预测和“正确值”之间的误差来直接优化网络。常见的强化学习算法是深度 Q 网络(DQNs)和近似策略优化(PPO)。

1.1.3 神经网络和深度学习

神经网络算法是一类基于神经网络从数据中学习的算法。它们仍然属于机器学习的范畴。由于计算能力和数据量的限制,早期的神经网络很浅,通常有一到四层左右。因此,网络表达能力有限。随着计算能力的提升和大数据时代的到来,高度并行化的图形处理单元(GPU)和海量数据使得大规模神经网络的训练成为可能。

2006 年,Geoffrey Hinton 首次提出了深度学习的概念。2012 年,八层深度神经网络 AlexNet 发布,在图像识别比赛中取得了巨大的性能提升。此后,相继开发出几十层、几百层、甚至上千层的神经网络模型,显示出很强的学习能力。使用深度神经网络实现的算法通常被称为深度学习模型。本质上,神经网络和深度学习可以被认为是一样的。

我们简单对比一下深度学习和其他算法。如图 1-3 所示,基于规则的系统通常会编写显式逻辑,这种逻辑一般是为特定任务设计的,不适合其他任务。传统机器学习算法人为设计具有一定通用性的特征检测方法,如 SIFT、HOG 特征等。这些特性适用于某一类任务,具有一定的通用性。但是性能很大程度上取决于如何设计这些特性。神经网络的出现使得计算机可以通过神经网络自动设计那些功能,而无需人工干预。浅层神经网络通常具有有限的特征提取能力,而深层神经网络能够提取高级抽象特征,并且具有更好的性能。

img/515226_1_En_1_Fig3_HTML.png

图 1-3

深度学习与其他算法的比较

1.2 神经网络的历史

我们把神经网络的发展分为浅层神经网络阶段和深度学习阶段,以 2006 年为分界点。2006 年之前,深度学习以神经网络的名义发展,经历了两起两落。2006 年,Geoffrey Hinton 首次将深度神经网络命名为深度学习,开始了它的第三次复兴。

1.2.1 浅层神经网络

1943 年,心理学家沃伦·麦卡洛克和逻辑学家沃尔特·皮茨根据生物神经元的结构提出了最早的神经元数学模型,以他们的姓氏首字母命名为 MP 神经元模型。型号f(x)=h(g(x)),其中g(x)=∑IxIxI如果 g ( x ) ≥ 0,输出为 1;如果 g ( x ) <为 0,输出为 0。MP 神经元模型没有学习能力,只能完成固定的逻辑判断。**

img/515226_1_En_1_Fig4_HTML.png

图 1-4

MP 神经元模型

1958 年,美国心理学家弗兰克·罗森布拉特(Frank Rosenblatt)提出了第一个可以自动学习权重的神经元模型,称为感知器(perceptron)。如图 1-5 所示,利用输出值 o 与真值 y 之间的误差来调整神经元的权重{ w 1w 2 ,…,wn}。Frank Rosenblatt 随后实现了基于“Mark 1 感知器”硬件的感知器模型。如图 1-6 和 1-7 所示,输入为 400 像素的图像传感器,输出有 8 个节点。它可以成功地识别一些英文字母。一般认为,1943–1969 年是人工智能发展的第一个繁荣期。

img/515226_1_En_1_Fig7_HTML.png

图 1-7

马克 1 感知器网络架构 2

img/515226_1_En_1_Fig6_HTML.jpg

图 1-6

弗兰克·罗森布拉特和马克 1 号感知器 1 号

img/515226_1_En_1_Fig5_HTML.png

图 1-5

感知器模型

1969 年,美国科学家马文·明斯基等人在《感知器一书中指出了感知器等线性模型的主要缺陷。他们发现,感知器不能处理简单的线性不可分问题,如异或。这直接导致了对神经网络感知机相关研究的低谷期。一般认为 1969–1982 年是人工智能的第一个冬天。

虽然处于 AI 的低谷期,但还是有很多意义重大的研究陆续发表。其中最重要的是反向传播(BP)算法,它仍然是现代深度学习算法的核心基础。事实上,BP 算法的数学思想早在 20 世纪 60 年代就已经衍生出来,但当时还没有应用到神经网络中。1974 年,美国科学家 Paul Werbos 在其博士论文中首次提出 BP 算法可以应用于神经网络。遗憾的是,这个结果没有得到足够的重视。1986 年,David Rumelhart 等人在 Nature 发表了一篇使用 BP 算法进行特征学习的论文。此后,BP 算法开始得到广泛关注。

1982 年,随着约翰·霍普菲尔德(John Hopfield)的循环连接霍普菲尔德网络(cyclical connected Hopfield network)的提出,从 1982 年到 1995 年开始了人工智能复兴的第二次浪潮。在此期间,卷积神经网络、循环神经网络和反向传播算法相继被开发出来。1986 年,David Rumelhart、Geoffrey Hinton 等人将 BP 算法应用于多层感知器。1989 年,Yann LeCun 等人将 BP 算法应用于手写数字图像识别,取得了巨大的成功,被称为 LeNet。LeNet 系统在邮政编码识别、银行支票识别和许多其他系统中成功商业化。1997 年,Jürgen Schmidhuber 提出了最广泛使用的循环神经网络变体之一,长短期记忆(LSTM)。同年,还提出了双向循环神经网络。

遗憾的是,随着以支持向量机(SVM)为代表的传统机器学习算法的兴起,神经网络的研究逐渐进入低谷,被称为人工智能的第二个冬天。支持向量机具有严谨的理论基础,需要的训练样本数量少,还具有良好的泛化能力。相比之下,神经网络缺乏理论基础,难以解释。深网难练,表现正常。图 1-8 显示了 1943 年到 2006 年间 AI 发展的重要时期。

img/515226_1_En_1_Fig8_HTML.png

图 1-8

浅层神经网络开发时间表

1.2.2 深度学习

2006 年,Geoffrey Hinton 等人发现多层神经网络可以通过逐层预训练得到更好的训练,并在 MNIST 手写数字图片数据集上取得了比 SVM 更好的错误率,开启了第三次人工智能复兴。在那篇论文中,Geoffrey Hinton 首次提出了深度学习的概念。2011 年,Xavier Glorot 提出了一种校正线性单位(ReLU)激活函数,这是目前应用最广泛的激活函数之一。2012 年,Alex Krizhevsky 提出了一个八层深度神经网络 AlexNet,它使用了 ReLU 激活函数和 Dropout 技术来防止过拟合。同时摒弃了逐层预训练的方式,直接在两个 NVIDIA GTX580 GPUs 上训练网络。AlexNet 在 ILSVRC-2012 图片识别比赛中获得第一名,显示前 5 名的错误率比第二名惊人地降低了 10.9%。

自 AlexNet 模型开发以来,各种模型相继问世,包括 VGG 系列、GoogleNet 系列、ResNet 系列和 DenseNet 系列。ResNet 系列型号将网络的层数增加到数百甚至数千层,同时保持相同甚至更好的性能。其算法简单通用,性能显著,是深度学习最具代表性的模型。

除了在监督学习方面取得惊人的成果,在无监督学习和强化学习方面也取得了巨大的成就。2014 年,Ian Goodfellow 提出了生成对抗网络(GANs),它通过对抗训练来学习样本的真实分布,以生成近似度更高的样本。此后,人们提出了大量的 GAN 模型。最新的图像生成模型可以生成达到肉眼难以辨别的保真度的图像。2016 年,DeepMind 将深度神经网络应用于强化学习领域,提出了 DQN 算法,在雅达利游戏平台的 49 款游戏中,达到了与人类相当甚至更高的水平。在围棋领域,来自 DeepMind 的 AlphaGo 和 AlphaGo Zero 智能程序先后战胜了人类顶级围棋选手李世石、柯洁等。在多智能体协作 Dota 2 游戏平台中,OpenAI 开发的 OpenAI 五大智能程序在受限游戏环境下击败 TI8 冠军战队 OG,展现了大量专业的高水平智能操作。图 1-9 列出了 2006 年到 2019 年 AI 发展的主要时间点。

img/515226_1_En_1_Fig9_HTML.png

图 1-9

深度学习发展的时间表

1.3 深度学习的特点

与传统的机器学习算法和浅层神经网络相比,现代深度学习算法通常具有以下特点。

数据量

早期的机器学习算法训练起来相对简单快速,所需数据集的规模也相对较小,比如英国统计学家罗纳德·费雪在 1936 年收集的鸢尾花数据集,只包含三类花,每类有 50 个样本。随着计算机技术的发展,设计的算法越来越复杂,对数据量的需求也越来越大。Yann LeCun 在 1998 年收集的 MNIST 手写数字图片数据集包含从 0 到 9 的总共 10 类数字,每类多达 7000 张图片。随着神经网络尤其是深度学习网络的兴起,网络层数普遍较大,模型参数数量可达百万、千万,甚至十亿。为了防止过拟合,训练数据集的大小通常很大。现代社交媒体的普及也使得收集海量数据成为可能。例如,2010 年发布的 ImageNet 数据集总共包括 14,197,122 张图片,整个数据集的压缩文件大小为 154GB。图 1-10 和 1-11 列出了一段时间内的样本数量和数据集大小。

虽然深度学习对大数据集的需求很高,但是收集数据,尤其是收集有标签的数据,往往是非常昂贵的。一个数据集的形成通常需要人工采集、爬取原始数据并清除无效样本,然后用人类的智能对数据样本进行标注,因此不可避免地会引入主观偏差和随机误差。因此,数据量要求小的算法是非常热门的话题。

img/515226_1_En_1_Fig11_HTML.png

图 1-11

数据集大小随时间变化

img/515226_1_En_1_Fig10_HTML.png

图 1-10

数据集样本大小随时间变化

1.3.2 计算能力

计算能力的提升是第三次人工智能复兴的重要因素。事实上,现代深度学习的基础理论早在 20 世纪 80 年代就已经提出,但直到 2012 年基于两个 GTX580 GPUs 上的训练的 AlexNet 发布,深度学习的真正潜力才得以实现。传统的机器学习算法不像深度学习那样对数据量和计算能力有苛刻的要求。通常情况下,在 CPU 上进行串行训练可以获得满意的效果。但是深度学习非常依赖并行加速计算设备。目前大多数神经网络使用并行加速芯片,如英伟达 GPU 和谷歌 TPU 来训练模型参数。比如 AlphaGo Zero 程序,需要从零开始在 64 个 GPU 上训练 40 天,才能超越所有 AlphaGo 历史版本。自动网络结构搜索算法使用 800 个 GPU 来优化更好的网络结构。

目前普通消费者可以使用的深度学习加速硬件设备主要来自 NVIDIA GPU 显卡。图 1-12 图解了 2008-2017 年 NVIDIA GPU 和 x86 CPU 的每秒十亿次浮点运算(GFLOPS)的变化。可以看到,x86 CPU 的曲线变化相对较慢,NVIDIA GPU 的浮点计算能力呈指数级增长,这主要是游戏和深度学习计算的业务不断增加推动的。

img/515226_1_En_1_Fig12_HTML.jpg

图 1-12

英伟达 GPU FLOPS 变化(数据来源:英伟达)

1.3.3 网络规模

早期的感知器模型和多层神经网络只有一层或两层到四层,网络参数也在几万左右。随着深度学习的发展和计算能力的提升,相继提出了 AlexNet (8 层)、VGG16 (16 层)、GoogleNet (22 层)、ResNet50 (50 层)、DenseNet121 (121 层)等模型,同时输入图片的大小也从 28×28 到 224×224 逐渐增大到 299×299 甚至更大。这些变化使得网络的参数总数达到千万级,如图 1-13 所示。

网络规模的增加相应地增强了神经网络的能力,使得网络可以学习更复杂的数据形态,并且模型性能可以相应地提高。另一方面,网络规模的增大也意味着我们需要更多的训练数据和计算能力来避免过拟合。

img/515226_1_En_1_Fig13_HTML.png

图 1-13

网络层的变化

一般情报

过去,为了提高算法在某项任务上的性能,往往需要利用先验知识,人工设计相应的特征来帮助算法更好地收敛到最优解。这种类型的特征提取方法通常与特定任务密切相关。一旦场景发生变化,这些人为设计的特征和先验设置就无法适应新的场景,人们往往需要重新设计算法。

设计一种能够像人脑一样自动学习、自我调整的通用智能机构,一直是人类共同的愿景。深度学习是最接近一般智能的算法之一。在计算机视觉领域,以前需要为特定任务设计特征并添加先验假设的方法已经被深度学习算法所抛弃。目前,几乎所有的图像识别、目标检测和语义分割算法都是基于端到端的深度学习模型,表现出良好的性能和较强的适应性。在 Atari 游戏平台上,DeepMind 设计的 DQN 算法在相同的算法、模型结构、超参数设置下,可以在 49 场游戏中达到人类同等水平,表现出一定程度的通用智能。图 1-14 是 DQN 算法的网络结构。它不是为某个游戏设计的,但可以控制 Atari 游戏平台上的 49 个游戏。

img/515226_1_En_1_Fig14_HTML.jpg

图 1-14

DQN 网络结构[1]

1.4 深度学习应用

深度学习算法已经广泛应用于我们的日常生活中,比如手机中的语音助手、汽车中的智能辅助驾驶、刷脸支付等。我们将从计算机视觉、自然语言处理和强化学习开始,介绍深度学习的一些主流应用。

计算机视觉

图像分类是一个常见的分类问题。神经网络的输入是图片,输出值是当前样本属于每个类别的概率。通常,选择概率最高的类别作为样本的预测类别。图像识别是深度学习最早的成功应用之一。经典的神经网络模型包括 VGG 系列、Inception 系列和 ResNet 系列。

物体检测是指通过算法自动检测出图片中常见物体的大概位置。通常用一个包围盒来表示,对包围盒中对象的类别信息进行分类,如图 1-15 所示。常见的对象检测算法有 RCNN、快速 RCNN、更快 RCNN、掩模 RCNN、SSD 和 YOLO 系列。

语义分割是对图片中的内容进行自动分割和识别的算法。我们可以把语义分割理解为对每个像素的分类,分析每个像素的类别信息,如图 1-16 。常见的语义分割模型有 FCN、U-net、SegNet、DeepLab 系列等。

img/515226_1_En_1_Fig16_HTML.jpg

图 1-16

语义分割示例

img/515226_1_En_1_Fig15_HTML.png

图 1-15

对象检测示例

视频了解。随着深度学习在 2D 图片相关任务上取得更好的效果,具有时间维度信息(第三维是帧序列)的 3D 视频理解任务受到越来越多的关注。常见的视频理解任务包括视频分类、行为检测和视频主题提取。常见的机型有 C3D、TSN、DOVF、TS_LSTM。

图像生成从学习到的分布中学习真实图片和样本的分布,获得高度逼真的生成图片。目前常见的图像生成模型有系列和 GAN 系列。其中,GAN 系列算法近年来取得了长足的进步。最新的 GAN 模型产生的画面效果已经到了肉眼难以辨别真伪的程度,如图 1-17 。

除了前面的应用,深度学习在其他领域也取得了显著的成果,例如艺术风格转移(图 1-18 )、超分辨率、图片去噪/模糊、灰度图片着色等等。

img/515226_1_En_1_Fig18_HTML.jpg

图 1-18

艺术风格转移图像

img/515226_1_En_1_Fig17_HTML.jpg

图 1-17

模型生成的图像

1.4.2 自然语言处理

机器翻译。在过去,机器翻译算法通常基于统计机器翻译模型,这也是谷歌翻译系统在 2016 年之前使用的技术。2016 年 11 月,谷歌推出了基于 Seq2Seq 模型的谷歌神经机器翻译(GNMT)系统。首次实现了从源语言到目标语言的直接翻译技术,在多项任务上提高了 50–90%。常用的机器翻译模型有 Seq2Seq、BERT、GPT 和 GPT-2。其中,OpenAI 提出的 GPT-2 模型约有 15 亿个参数。起初,OpenAI 出于技术安全原因拒绝开源 GPT-2 模型。

聊天机器人也是自然语言处理的一个主流任务。机器自动学习与人类对话,对简单的人类需求提供满意的自动响应,提高客户服务效率和服务质量。聊天机器人通常用于咨询系统、娱乐系统和智能家居。

强化学习

虚拟游戏。与真实环境相比,虚拟游戏平台既可以训练和测试强化学习算法,又可以避免无关因素的干扰,同时还可以最小化实验成本。目前,常用的虚拟游戏平台包括 OpenAI Gym、OpenAI Universe、OpenAI Roboschool、DeepMind OpenSpiel 和 MuJoCo,常用的强化学习算法包括 DQN、A3C、A2C 和 PPO。在围棋领域,DeepMind AlphaGo 程序已经超越了人类围棋专家。在 Dota 2 和星际争霸游戏中,OpenAI 和 DeepMind 开发的智能程序也曾在限制规则下击败过职业队伍。

机器人。在现实环境中,对机器人的控制也取得了一些进展。例如,加州大学伯克利分校实验室在机器人领域的模仿学习、元学习、少射学习等领域取得了很多进展。波士顿动力公司在机器人应用方面取得了可喜的成绩。它制造的机器人在复杂地形行走和多智能体协作等任务上表现出色(图 1-19 )。

自动驾驶被认为是短期内强化学习的一个应用方向。许多公司在自动驾驶方面投入了大量资源,如百度、优步和谷歌。来自百度的 Apollo 已经开始在北京、雄安、武汉等地试运营。图 1-20 显示的是百度的自动驾驶汽车 Apollo。

img/515226_1_En_1_Fig20_HTML.jpg

图 1-20

百度的自动驾驶汽车阿波罗 4

img/515226_1_En_1_Fig19_HTML.jpg

图 1-19

来自波士顿动力公司的机器人 3

1.5 深度学习框架

工欲善其事,必先利其器。了解了深度学习的基础知识之后,我们来挑选一下用来实现深度学习算法的工具。

主要框架

  • Theano 是最早的深度学习框架之一。它是由 Yoshua Bengio 和 Ian Goodfellow 开发的。它是一个基于 Python 的计算库,用于定位底层操作。Theano 同时支持 GPU 和 CPU 操作。由于 Theano 开发效率低,模型编译时间长,开发者改用 TensorFlow,目前 Theano 已经停止维护。

  • Scikit-learn 是一个完整的机器学习算法计算库。它内置了对常见的传统机器学习算法的支持,并且拥有丰富的文档和示例。然而,scikit-learn 并不是专门为神经网络设计的。不支持 GPU 加速,神经网络相关层的实现也有欠缺。

  • 咖啡由贾于 2013 年创立。它主要用于使用卷积神经网络的应用,不适用于其他类型的神经网络。Caffe 的主要开发语言是 C ++,同时也为 Python 等其他语言提供接口。它还支持 GPU 和 CPU。由于发展时间较早,在业内知名度较高,2017 年脸书推出了 Caffe 的升级版——Caffe 2。Caffe2 现在已经集成到 PyTorch 库中。

  • Torch 是一个非常好的科学计算库,基于不太流行的编程语言 Lua 开发。Torch 灵活性很高,很容易实现自定义网络层,这也是 PyTorch 继承的优秀基因。但由于 Lua 语言用户较少,Torch 一直无法获得主流应用。

  • MXNet 由陈天琦和李牧开发,是亚马逊官方深度学习框架。它采用命令式编程和符号式编程的混合方法,灵活性高,运行速度快,文档和实例丰富。

  • PyTorch 是脸书推出的深度学习框架,基于原始的 Torch 框架,使用 Python 作为主要开发语言。PyTorch 借鉴了 Chainer 的设计风格,采用命令式编程,使得网络的搭建和调试非常方便。虽然 PyTorch 在 2017 年才发布,但由于其精致小巧的界面设计,PyTorch 在学术界获得了广泛好评。1.0 版本后,原有的 PyTorch 和 Caffe2 合并,弥补 PyTorch 在工业部署上的不足。总体来说,PyTorch 是一个优秀的深度学习框架。

  • Keras 是基于 Theano 和 TensorFlow 等框架提供的底层操作实现的高层框架。它为快速训练和测试提供了大量的高级接口。对于常见的应用程序,用 Keras 开发是非常高效的。但因为没有底层实现,需要抽象底层框架,所以运行效率不高,灵活性一般。

  • TensorFlow 是 Google 在 2015 年发布的深度学习框架。最初的版本只支持符号编程。由于其较早的发布和谷歌在深度学习领域的影响力,TensorFlow 迅速成为最受欢迎的深度学习框架。但由于界面设计变化频繁、功能设计冗余、符号编程开发调试困难等原因,TensorFlow 1.x 一度被业界诟病。2019 年,Google 推出了 TensorFlow 2 正式版,运行于动态图优先模式,可以避免 TensorFlow 1.x 版本的诸多缺陷。TensorFlow 2 得到了业界的广泛认可。

目前,TensorFlow 和 PyTorch 是业界应用最广泛的两个深度学习框架。TensorFlow 在行业内拥有完整的解决方案和用户基础。得益于其精简灵活的接口设计,PyTorch 可以快速构建和调试网络,在学术界好评如潮。TensorFlow 2 发布后,让用户更容易学习 TensorFlow,将模型无缝部署到生产中。这本书使用 TensorFlow 2 作为主要框架来实现深度学习算法。

下面是 TensorFlow 和 Keras 的联系和区别。Keras 可以理解为一套高级别的 API 设计规范。Keras 本身有规范的官方实现。TensorFlow 中也实现了相同的规范,称为 tf.keras 模块,tf.keras 将作为唯一的高层接口,避免接口冗余。除非特别说明,本书中的 Keras 均指 tf.keras。

1.5.2 TensorFlow 2 和 1.x

TensorFlow 2 在用户体验上和 TensorFlow 1.x 是完全不同的框架。TensorFlow 2 与 TensorFlow 1.x 代码不兼容。同时在编程风格和功能界面设计上也大相径庭。TensorFlow 1.x 代码需要依靠人工迁移,自动化迁移方式不太靠谱。Google 即将停止更新 TensorFlow 1.x,不建议现在学习 TensorFlow 1.x。

TensorFlow 2 支持动态图形优先级模式。在计算过程中,您可以获得计算图形和数值结果。您可以调试代码并实时打印数据。网络像积木一样搭建,一层一层堆叠,符合软件开发思维。

以简单加法 2.0 + 4.0 为例,在 TensorFlow 1.x 中,我们需要先创建一个计算图,如下:

import tensorflow as tf
# 1\. Create computation graph with tf 1.x
# Create 2 input variables with fixed name and type
a_ph = tf.placeholder(tf.float32, name='variable_a')
b_ph = tf.placeholder(tf.float32, name='variable_b')
# Create output operation and name
c_op = tf.add(a_ph, b_ph, name='variable_c')

创建计算图的过程类似于通过符号建立公式 c = a + b 的过程。它只记录公式的计算步骤,并不实际计算数值结果。数值结果只能通过运行输出 c 并赋值 a = 2.0 和 b = 4.0 来获得,如下所示:

# 2.Run computational graph with tf 1.x
# Create running environment
sess = tf.InteractiveSession()
# Initialization
init = tf.global_variables_initializer()
sess.run(init) # Run the initialization
# Run the computation graph and return value to c_numpy
c_numpy = sess.run(c_op, feed_dict={a_ph: 2., b_ph: 4.})
# print out the output
print('a+b=',c_numpy)

可见在 TensorFlow 1 中进行简单的加法运算都是如此繁琐,更不用说创建复杂的神经网络算法了。这种创建计算图并在以后运行它的编程方法被称为符号编程。

接下来,我们使用 TensorFlow 2 完成相同的操作,如下所示:

import tensorflow as tf
# Use TensorFlow 2 to run
# 1.Create and initialize variable
a = tf.constant(2.)
b = tf.constant(4.)
# 2.Run and get result directly
print('a+b=',a+b)

可以看到,计算过程非常简单,没有额外的计算步骤。

同时获得计算图形和数值结果的方法称为命令式编程,也称为动态图形模式。TensorFlow 2 和 PyTorch 都是使用动态图优先模式开发的,很容易调试。一般来说,动态图模式对于开发来说效率很高,但是对于运行来说可能没有静态图模式效率高。TensorFlow 2 还支持通过 tf.function 将动态图模式转换为静态图模式,实现开发和运营效率的双赢。在本书的剩余部分,我们使用 TensorFlow 来表示一般的 TensorFlow 2。

演示

深度学习的核心是算法的设计思想,深度学习框架只是我们实现算法的工具。在下文中,我们将演示 TensorFlow 深度学习框架的三个核心功能,以帮助我们理解框架在算法设计中的作用。

  1. 加速计算

神经网络本质上是由大量的矩阵乘法和加法等基本数学运算组成的。TensorFlow 的一个重要功能就是利用 GPU 方便地实现并行计算加速功能。为了演示 GPU 的加速效果,我们可以比较 CPU 和 GPU 上多个矩阵乘法的平均运行时间,如下所示。

我们分别创建形状为[1,n]和[n,1]的两个矩阵 A 和 B。可以使用参数 n 调整矩阵的大小,代码如下:

    # Create two matrices running on CPU
    with tf.device('/cpu:0'):
        cpu_a = tf.random.normal([1, n])
        cpu_b = tf.random.normal([n, 1])
        print(cpu_a.device, cpu_b.device)
    # Create two matrices running on GPU
    with tf.device('/gpu:0'):
        gpu_a = tf.random.normal([1, n])
        gpu_b = tf.random.normal([n, 1])
        print(gpu_a.device, gpu_b.device)

让我们实现 CPU 和 GPU 操作的函数,并通过 timeit.timeit()函数测量这两个函数的计算时间。需要注意的是,第一次计算一般需要额外的环境初始化工作,所以这个时间不能算。我们通过预热阶段去除此时间,然后测量计算时间,如下所示:

    def cpu_run(): # CPU function
        with tf.device('/cpu:0'):
            c = tf.matmul(cpu_a, cpu_b)
        return c

    def gpu_run():# GPU function
        with tf.device('/gpu:0'):
            c = tf.matmul(gpu_a, gpu_b)
        return c
    # First calculation needs warm-up
    cpu_time = timeit.timeit(cpu_run, number=10)
    gpu_time = timeit.timeit(gpu_run, number=10)
    print('warmup:', cpu_time, gpu_time)
    # Calculate and print mean running time
    cpu_time = timeit.timeit(cpu_run, number=10)
    gpu_time = timeit.timeit(gpu_run, number=10)
    print('run time:', cpu_time, gpu_time)

我们绘制了不同矩阵大小的 CPU 和 GPU 环境下的计算时间,如图 1-21 所示。可以看出,当矩阵规模较小时,CPU 和 GPU 时间相差无几,体现不出 GPU 并行计算的优势。当矩阵规模较大时,CPU 计算时间显著增加,GPU 充分利用并行计算,而计算时间几乎没有任何变化。

img/515226_1_En_1_Fig21_HTML.png

图 1-21

CPU/GPU 矩阵乘法时间

  1. 自动梯度计算

在使用 TensorFlow 构建正演计算过程时,TensorFlow 除了能够获得数值结果外,还会自动构建计算图。TensorFlow 提供自动微分功能,无需手动求导即可计算网络参数输出的导数。考虑以下函数的表达式:

$$ y=a{w}²+ bw+c $$

输出 y 对变量 w 的导数关系为

$$ \frac{dy}{dw}=2 aw+b $$

考虑在( abcw ) = (1,2,3,4)处的导数。我们可以得到$$ \frac{dy}{dw}=2\bullet 1\bullet 4+2=10 $$

使用 TensorFlow,我们可以直接计算给定函数表达式的导数,而无需手动推导导数的表达式。TensorFlow 可以自动导出。代码实现如下:

import tensorflow as tf
# Create 4 tensors
a = tf.constant(1.)
b = tf.constant(2.)
c = tf.constant(3.)
w = tf.constant(4.)

with tf.GradientTape() as tape:# Track derivative
    tape.watch([w]) # Add w to derivative watch list
    # Design the function
    y = a * w**2 + b * w + c
# Auto derivative calculation
[dy_dw] = tape.gradient(y, [w])
print(dy_dw) # print the derivative

程序的结果是

tf.Tensor(10.0, shape=(), dtype=float32)

可以看出 TensorFlow 自动微分的结果与手工计算的结果是一致的。

  1. 通用神经网络接口

除了矩阵乘法、加法等底层数学功能,TensorFlow 还具有常用神经网络运算功能、常用网络层、网络训练、模型保存、加载、部署等一系列深度学习系统的便捷功能。使用 TensorFlow,可以轻松使用这些函数完成常见的生产流程,高效稳定。

1.6 开发环境安装

在了解了深度学习框架带来的便利后,我们现在准备在本地桌面安装最新版本的 TensorFlow。TensorFlow 支持多种常用操作系统,如 Windows 10、Ubuntu 18.04、Mac OS 等。它支持在 NVIDIA GPU 上运行的 GPU 版本和仅使用 CPU 进行计算的 CPU 版本。我们以最常见的操作系统 Windows 10、NVIDIA GPU、Python 为例,介绍如何安装 TensorFlow 框架等开发软件。

一般来说,开发环境安装分为四个主要步骤:Python 解释器 Anaconda、CUDA 加速库、TensorFlow 框架和常用编辑器。

1.6.1 Anaconda 安装

Python 解释器是让用 Python 写的代码被 CPU 执行的桥梁,是 Python 语言的核心软件。用户可以从 www.python.org/ 下载合适版本(此处使用 Python 3.7)的解释器。安装完成后,可以调用 python.exe 程序来执行用 Python(.py 文件)。

这里我们选择安装 Anaconda 软件,该软件集成了 Python 解释器、包管理、虚拟环境等一系列辅助功能。我们可以从 www.anaconda.com/distribution/#download-section 下载 Anaconda,选择最新版本的 Python 下载安装。如图 1-22 所示,勾选“将 Anaconda 添加到我的 PATH 环境变量”选项,这样就可以通过命令行调用 Anaconda 程序了。如图 1-23 所示,安装人员询问是否一起安装 VS 代码软件。选择跳过。整个安装过程大约 5 分钟,具体时间视电脑性能而定。

img/515226_1_En_1_Fig23_HTML.jpg

图 1-23

蟒蛇装置 2

img/515226_1_En_1_Fig22_HTML.jpg

图 1-22

蟒蛇装置 1

安装完成后,我们如何验证 Anaconda 是否安装成功?按键盘上的 Windows+R 组合键,可以调出正在运行的程序对话框,输入“cmd”,按 enter 键打开 Windows 自带的命令行程序“cmd.exe”。或者点击开始菜单,输入“cmd”找到“cmd.exe”程序,打开。输入“conda list”命令查看 Python 环境中已安装的库。如果是新安装的 Python 环境,列出的库都是 Anaconda 自带的库,如图 1-24 所示。如果“conda list”能正常弹出一系列库列表信息,则 Anaconda 软件安装成功。否则,安装失败,您需要重新安装。

img/515226_1_En_1_Fig24_HTML.jpg

图 1-24

Anaconda 安装测试

CUDA 安装

目前的深度学习框架大多基于英伟达的 GPU 显卡进行加速计算,所以你需要安装英伟达提供的 GPU 加速库 CUDA。在安装 CUDA 之前,请确保您的计算机具有支持 CUDA 程序的 NVIDIA 图形设备。如果你的电脑没有 NVIDIA 显卡——比如有些电脑显卡厂商是 AMD 或者 Intel——CUDA 程序就不行,你可以跳过这一步直接安装 TensorFlow CPU 版本。

CUDA 的安装分为三步:CUDA 软件安装、cuDNN 深度神经网络加速库安装、环境变量配置。安装过程有点繁琐。我们将以 Windows 10 系统为例,一步一步地介绍它们。

CUDA 软件安装打开 CUDA 程序官方下载网站: https://developer.nvidia.com/cuda-10.0-download-archive 。这里我们用的是 CUDA 10.0 版本:选择 Windows 平台,x86_64 架构,10 系统,exe(本地)安装包,然后选择“下载”,下载 CUDA 安装软件。下载完成后,打开软件。如图 1-25 所示,选择“自定义”选项,点击“下一步”按钮,进入如图 1-26 所示的安装程序选择列表。在这里,您可以选择需要安装的组件,取消选择不需要安装的组件。在“CUDA”类别下,取消选择“Visual Studio 集成”项。在“驱动程序组件”类别下,在“显示驱动程序”行比较“当前版本”和“新版本”的版本号。如果“当前版本”大于“新版本”,您需要取消选中“显示驱动程序”如果“当前版本”小于或等于“新版本”,则勾选“显示驱动程序”,如图 1-27 所示。安装完成后,您可以单击“下一步”并按照说明进行安装。

img/515226_1_En_1_Fig26_HTML.jpg

图 1-26

CUDA 安装 2

img/515226_1_En_1_Fig25_HTML.jpg

图 1-25

CUDA 安装 1

安装完成后,我们来测试一下 CUDA 软件是否安装成功。打开“cmd”终端,输入“nvcc -V”打印当前 CUDA 版本信息,如图 1-28 所示。如果无法识别该命令,则安装失败。我们可以从 CUDA 安装路径“C:\ Program Files \ NVIDIA GPU Computing Toolkit \ CUDA \ v 10.0 \ bin”中找到“nvcc.exe”程序,如图 1-29 所示。

img/515226_1_En_1_Fig29_HTML.png

图 1-29

CUDA 安装测试 2

img/515226_1_En_1_Fig28_HTML.jpg

图 1-28

CUDA 安装测试 1

img/515226_1_En_1_Fig27_HTML.jpg

图 1-27

CUDA 安装 3

cuDNN 神经网络加速库安装。CUDA 不是专门针对神经网络的 GPU 加速库;它是为各种需要并行计算的应用而设计的。如果你想为神经网络应用加速,你需要安装一个额外的 cuDNN 库。需要注意的是,cuDNN 库不是一个可执行程序。您只需要下载并解压缩 cuDNN 文件,并配置 Path 环境变量。

打开 https://developer.nvidia.com/cudnn 网站,选择“下载 cuDNN”由于 NVIDIA 的规定,用户需要登录或创建一个新用户才能继续下载。登录后进入 cuDNN 下载界面,勾选“我同意 cuDNN 软件许可协议的条款”,会弹出 cuDNN 版本下载选项。选择与 CUDA 10.0 匹配的 cuDNN 版本,点击“cuDNN Library for Windows 10”链接下载 cuDNN 文件,如图 1-30 。需要注意的是,cuDNN 本身是有版本号的,同样需要匹配 CUDA 版本号。

img/515226_1_En_1_Fig30_HTML.jpg

图 1-30

cuDNN 版本选择界面

下载完 cuDNN 文件后,将其解压缩,并将文件夹“cuda”重命名为“cudnn765”。然后将“cudnn765”文件夹复制到 CUDA 安装路径“C:\ Program Files \ NVIDIA GPU Computing Toolkit \ CUDA \ v 10.0”(图 1-31 )。这里可能会弹出一个需要管理员权限的对话框。选择继续粘贴。

img/515226_1_En_1_Fig31_HTML.jpg

图 1-31

cuDNN 安装路径

环境变量配置。我们已经完成了 cuDNN 的安装,但是为了让系统知道 cuDNN 文件的位置,我们需要如下配置 Path 环境变量。打开文件浏览器,右键“我的电脑”,选择“属性”,选择“高级系统设置”,选择“环境变量”,如图 1-32 所示。在“系统变量”栏中选择“Path”环境变量,选择“编辑”,如图 1-33 所示。选择“新建”,输入 cuDNN 安装路径“C:\ Program Files \ NVIDIA GPU Computing Toolkit \ CUDA \ v 10.0 \ cud nn 765 \ bin”,使用“上移”按钮将此项移动到顶部。

img/515226_1_En_1_Fig33_HTML.png

图 1-33

环境变量配置 2

img/515226_1_En_1_Fig32_HTML.png

图 1-32

环境变量配置 1

CUDA 安装完成后,环境变量应该包括“C:\ Program Files \ NVIDIA GPU Computing Toolkit \ CUDA \ v 10.0 \ bin”、“C:\ Program Files \ NVIDIA GPU Computing Toolkit \ CUDA \ v 10.0 \ libnvvp”、“C:\ Program Files \ NVIDIA GPU Computing Toolkit \ CUDA \ v 10.0 \ cud nn 765 \ bin”。根据实际路径,前面的路径可能略有不同,如图 1-34 所示。确认后,单击“确定”关闭所有对话框。

img/515226_1_En_1_Fig34_HTML.png

图 1-34

与 CUDA 相关的环境变量

1 . 6 . 3 tensorflow 安装

TensorFlow 和其他 Python 库一样,可以使用 Python 包管理工具“pip install”命令进行安装。在安装 TensorFlow 时,你需要根据你的电脑是否有 NVIDIA GPU 显卡来确定是安装更强大的 GPU 版本还是一般性能的 CPU 版本。

# Install numpy
pip install numpy

使用前面的命令,您应该能够自动下载并安装 numpy 库。现在让我们安装 TensorFlow 的最新 GPU 版本。该命令如下所示:

# Install TensorFlow GPU version
pip install -U tensorflow

前面的命令应该会自动下载并安装 TensorFlow GPU 版本,该版本目前是 TensorFlow 2.x 的正式版本。“-U”参数指定如果安装了此软件包,则执行升级命令。

现在来测试一下 TensorFlow 的 GPU 版本是否安装成功。在“cmd”命令行输入“ipython”进入 ipython 交互终端,然后输入“import tensorflow as tf”命令。如果没有出现错误,继续输入“tf.test.is_gpu_available()”测试 gpu 是否可用。该命令将打印一系列信息。以“I”(信息)开头的信息包含了可用的 GPU 图形设备的信息,最后会返回“真”或“假”,表示 GPU 设备是否可用,如图 1-35 所示。如果为真,则 TensorFlow GPU 版本安装成功;如果为 False,安装将失败。您可能需要再次检查 CUDA、cuDNN 和环境变量配置的步骤,或者复制错误并向搜索引擎寻求帮助。

img/515226_1_En_1_Fig35_HTML.png

图 1-35

tensorflow gpu 安装测试

如果没有 GPU,可以安装 CPU 版本。CPU 版本无法使用 GPU 加速计算,计算速度相对较慢。但是,因为本书中作为学习目的介绍的模型一般计算量不大,所以也可以使用 CPU 版本。将来对深度学习有了更好的理解后,也可以添加 NVIDIA GPU 设备。如果 TensorFlow GPU 版本安装失败,我们也可以直接使用 CPU 版本。安装 CPU 版本的命令是

# Install TensorFlow CPU version
pip install -U tensorflow-cpu

安装完成后,在 ipython 终端输入“import tensorflow as tf”命令,验证 CPU 版本安装成功。TensorFlow 安装完成后,可以通过“tf。__ 版本 __"。图 1-36 给出了一个例子。注意,即使是代码也适用于所有 TensorFlow 2.x 版本。

img/515226_1_En_1_Fig36_HTML.jpg

图 1-36

tensorflow 版本测试

前面手动安装 CUDA 和 cuDNN、配置 Path 环境变量以及安装 TensorFlow 的过程是标准的安装方法。虽然步骤繁琐,但对理解每个库的功能作用有很大帮助。事实上,对于新手来说,您可以通过如下两个命令来完成前面的步骤:

# Create virtual environment tf2 with tensorflow-gpu setup required
# to automatically install CUDA,cuDNN,and TensorFlow GPU
conda create -n tf2 tensorflow-gpu
# Activate tf2 environment
conda activate tf2

这种快速安装方法称为最小安装方法。这也是使用 Anaconda 发行版的便利之处。通过极简版安装的 TensorFlow 在使用前需要激活相应的虚拟环境,需要与标准版区分开来。标准版本安装在 Anaconda 的默认环境基础中,通常不需要手动激活基础环境。

默认情况下也可以安装常见的 Python 库。该命令如下所示:

# Install common python libraries
pip install -U ipython numpy matplotlib pillow pandas

TensorFlow 在运行的时候,会默认消耗所有的 GPU 资源,这在计算上是非常不友好的,尤其是当计算机有多个用户或者程序同时使用 GPU 资源的时候。占用所有的 GPU 资源会让其他程序无法运行。所以一般建议将 TensorFlow 的 GPU 内存使用设置为增长模式,即根据实际模型大小申请 GPU 内存资源。代码实现如下:

# Set GPU resource usage method
# Get GPU device list
gpus = tf.config.experimental.list_physical_devices('GPU')
if gpus:
  try:
    # Set GPU usage to growth mode
    for gpu in gpus:
      tf.config.experimental.set_memory_growth(gpu, True)
  except RuntimeError as e:
    # print error
    print(e)

通用编辑器安装

用 Python 写程序有很多种方法。你可以使用 IPython 或者 Jupyter Notebook 来交互式地编写代码。还可以使用 Sublime Text、PyCharm 和 VS 代码来开发大中型项目。本书推荐使用 PyCharm 编写和调试代码,使用 VS 代码进行交互式项目开发。他们两个都是免费的。用户可以自行下载安装。

接下来,让我们开始深度学习之旅吧!

1.7 摘要

1.8 参考

  1. 动词 (verb 的缩写)Mnih、K. Kavukcuoglu、D. Silver、A. A .鲁苏、J. Veness、M. G. Bellemare、A. Graves、M. Riedmiller、A. K. Fidjeland、G. Ostrovski、S. Petersen、C. Beattie、A. Sadik、I. Antonoglou、H. King、D. Kumaran、D. Wierstra、S. Legg 和 D. Hassabis,“通过深度强化学习实现人类水平的控制”,《自然》,511

二、回归

有些人担心人工智能会让我们感到自卑,但话说回来,任何一个头脑正常的人每次看到一朵花都会有一种自卑感。

—艾伦·凯

2.1 神经元模型

一个成年人的大脑包含大约 1000 亿个神经元。每个神经元通过树突获得输入信号,通过轴突传递输出信号。神经元相互连接形成了一个巨大的神经网络,从而形成了人类的大脑,感知和意识的基础。图 2-1 是典型的生物神经元结构。1943 年,心理学家沃伦·麦卡洛克和数学逻辑学家沃尔特·皮茨提出了人工神经网络的数学模型来模拟生物神经元的机制[1]。这项研究由美国神经学家 Frank Rosenblatt 进一步发展为感知器模型[2],这也是现代深度学习的基石。

img/515226_1_En_2_Fig1_HTML.png

图 2-1

典型的生物神经元结构 1

从生物神经元的结构出发,重温科学先驱的探索,逐步揭开自动学习机的神秘面纱。

首先,我们可以将神经元模型抽象成如图 2-2 (a)所示的数学结构。神经元输入向量x=x1x 2x 3 ,…,xnT通过函数 f 映射到 y 考虑一个简化的情况,比如线性变换:f(x)=wTx+b。扩展的形式是**

![$$ f\left(\boldsymbol{x}\right)={w}_1{x}_1+{w}_2{x}_2+{w}_3{x}_3+\dots +{w}_n{x}_n+b $$前面的计算逻辑可以直观的如图 2-2 (b)所示。img/515226_1_En_2_Fig2_HTML.png

图 2-2

数学神经元模型

参数 θ = { w 1w 、 2 、 w 、 3 、…、 w 、??n、??b}决定了神经元的状态,通过固定这些参数可以确定该神经元的处理逻辑。当输入节点数 n = 1(单输入)时,神经元模型可进一步简化为

$$ \boldsymbol{y}=\boldsymbol{wx}+\boldsymbol{b} $$

然后我们可以将 y 的变化绘制成 x 的函数,如图 2-3 所示。随着输入信号 x 增加,输出 y 也线性增加。这里参数 w 可以理解为直线的斜率,b 为直线的偏置。

img/515226_1_En_2_Fig3_HTML.png

图 2-3

单输入线性神经元模型

对于某个神经元来说, xy 之间的映射关系fw, b 未知但固定。两点可以确定一条直线。为了估计 wb 的值,我们只需要从图t】中的直线上采样任意两个数据点(x(1), y (1)x (2)y (2) )

$$ {y}{(1)}=w{x}{(1)}+b $$

$$ {y}{(2)}=w{x}{(2)}+b $$

如果( x (1)y(1))≦(x(2)y (2) ),我们就可以求解前面的方程组得到 wb 的值。我们来考虑一个具体的例子:x(1)= 1, y (1) = 1.567, x (2) = 2, y (2) = 3.043。代入前面公式中的数字,得到

$$ 1.567=w\bullet 1+b $$

$$ 3.043=w\bullet 2+b $$

这是我们初高中学过的二元线性方程组。利用消元法可以很容易地计算出解析解,即 w = 1.477, b = 0.089。

你可以看到,我们只需要两个不同的数据点就可以完美地求解一个单输入线性神经元模型的参数。对于输入为 N 的线性神经元模型,我们只需要采样 N + 1 个不同的数据点。似乎线性神经元模型可以被完美地解析。那么前面的方法有什么问题呢?考虑到任何采样点都可能存在观测误差,我们假设观测误差变量 ϵ 服从正态分布$$ \mathcal{N}\left(\mu, {\sigma}²\right) $$,均值为 μ ,方差为 σ 2 。然后示例如下:

$$ y= wx+b+\epsilon, \epsilon \sim \mathcal{N}\left(\mu, {\sigma}²\right) $$

一旦引入观测误差,即使是简单的线性模型,如果只采样两个数据点,也可能带来较大的估计偏差。如图 2-4 所示,数据点都存在观测误差。如果估计基于两个蓝色矩形数据点,则估计的蓝色虚线将与真正的橙色直线有较大偏差。为了减少观测误差引入的估计偏差,我们可以对多个数据点$$ \mathbbm{D}=\left{\left({x}{(1)},{y}{(1)}\right),\left({x}{(2)},{y}{(2)}\right),\dots, \left({x}{(n)},{y}{(n)}\right)\right} $$进行采样,然后寻找一条“最佳”的直线,使其最小化所有采样点与该直线之间的误差之和。

img/515226_1_En_2_Fig4_HTML.png

图 2-4

有观测误差的模型

由于观测误差的存在,可能不存在完美通过所有采样点$$ \mathbbm{D} $$的直线。因此,我们希望找到一条接近所有采样点的“好”直线。如何衡量「好」与「坏」?一个自然的想法是用所有采样点的预测值wx??(I)+b与真实值y(I)之间的均方误差(MSE)作为总误差,即

$$ \mathcal{L}=\frac{1}{n}{\sum}_{i=1}n{\left(w{x}{(i)}+b-{y}^{(i)}\right)}² $$

然后搜索一组参数wb使总误差最小$$ \mathcal{L}. $$总误差最小对应的直线就是我们要找的最优直线,也就是

$$ {w}^{\ast },{b}^{\ast }=\underset{w,b}{\arg\ \min}\frac{1}{n}{\sum}_{i=1}n{\left(w{x}{(i)}+b-{y}^{(i)}\right)}² $$

这里 n 表示采样点数。

2.2 优化方法

现在我们来总结一下前面的解法:我们需要找到最优参数wb,使输入输出满足一个线性关系y(I)=wx(I)+b但是,由于观测误差 ϵ 的存在,需要对一个由足够数量的数据样本组成的数据集$$ \mathbbm{D}=\left{\left({x}{(1)},{y}{(1)}\right),\left({x}{(2)},{y}{(2)}\right),\dots, \left({x}{(n)},{y}{(n)}\right)\right} $$进行采样,以找到一组最优的参数wb,使均方误差$$ \mathcal{L}=\frac{1}{n}{\sum}_{i=1}n{\left(w{x}{(i)}+b-{y}^{(i)}\right)}² $$最小。

对于单输入神经元模型,通过消去法只需要两个样本就可以得到方程的精确解。这种由严格公式导出的精确解称为解析解。然而,在多个数据点( n ≫ 2)的情况下,很可能没有解析解。我们只能用数值优化的方法来获得一个近似的数值解。为什么叫优化?这是因为计算机的计算速度非常快。我们可以利用强大的计算能力进行多次“搜索”和“尝试”,从而逐步减少错误$$ \mathcal{L} $$。最简单的优化方法就是蛮力搜索或者随机实验。比如为了找到最合适的w∫和b,我们可以从实数空间中随机抽取任意一个 wb ,计算出对应模型的误差值$$ \mathcal{L} $$。从所有实验$$ \left{\mathcal{L}\right} $$中挑出误差最小的$$ {\mathcal{L}}^{\ast } $$,其对应的wb就是我们要找的最优参数。

这种强力算法简单明了,但对于大规模、高维优化问题效率极低。梯度下降是神经网络训练中最常用的优化算法。凭借强大的图形处理单元(GPU)芯片的并行加速能力,非常适合优化具有海量数据的神经网络模型。自然,它也适用于优化我们简单的线性神经元模型。由于梯度下降算法是深度学习的核心算法,我们将首先应用梯度下降算法来解决简单的神经元模型,然后在第七章中详细介绍其在神经网络中的应用。

有了导数的概念,如果要求解一个函数的最大值和最小值,可以简单地将导函数设为 0,找到对应的自变量数值,也就是驻点,然后检查驻点类型。以函数f(x)=x2sin(x)为例,我们可以在区间x∈【10,10】内绘制函数及其导数,其中蓝色实线为 f ( x ),黄色虚线为$$ \frac{\mathrm{d}f(x)}{\mathrm{d}x} $$,如图所示可以看出,导数(虚线)为 0 的点就是驻点, f ( x )的最大值和最小值都出现在驻点。

img/515226_1_En_2_Fig5_HTML.jpg

图 2-5

函数f(x)=x2 ( x )及其衍生

函数的梯度被定义为函数对每个独立变量的偏导数的向量。考虑一个三维函数 z = f ( xy ),函数对自变量 x 的偏导数为$$ \frac{\partial z}{\partial x} $$,函数对自变量 y 的偏导数记为$$ \frac{\partial z}{\partial y} $$,梯度∇ f 为向量$$ \left(\frac{\partial z}{\partial x},\frac{\partial z}{\partial y}\right) $$。我们来看一个具体的函数 f ( xy)=(cos2x+cos2y)2。如图 2-6 所示,平面中红色箭头的长度代表梯度向量的模,箭头的方向代表梯度向量的方向。可以看出,箭头的方向始终指向函数值增加的方向。函数曲面越陡,箭头的长度越长,梯度的模数越大。

img/515226_1_En_2_Fig6_HTML.jpg

图 2-6

一个函数及其梯度2

通过前面的例子,我们可以直观的感受到,函数的梯度方向总是指向函数值增加的方向。那么梯度的反方向应该指向函数值减小的方向。

$$ {\boldsymbol{x}}^{\prime }=\boldsymbol{x}-\boldsymbol{\eta} \bullet \mathbf{\nabla}\boldsymbol{f} $$

(2.1)

为了利用这个特性,我们只需要按照前面的等式迭代更新x。然后我们可以得到越来越小的函数值。 η 用于缩放梯度向量,称为学习率,一般设置为较小的值,如 0.01 或 0.001。特别地,对于一维函数,前面的向量形式可以写成标量形式:

$$ {x}^{\prime }=x-\eta \bullet \frac{\mathrm{d}y}{\mathrm{d}x} $$

通过前面的公式多次迭代更新x,则x??’处的函数值y??’总是比 x 处的函数值小的可能性更大。

用公式( 2.1 )优化参数的方法称为梯度下降算法。它计算函数 f 的梯度∇ f 并迭代更新参数 θ 以获得当函数 f 达到其最小值时参数 θ 的最优数值解。需要注意的是,深度学习中的模型输入一般表示为 x ,需要优化的参数一般表示为 θwb

现在,我们将在本次会议开始时应用梯度下降算法来计算最佳参数wb。这里,均方误差函数被最小化:

$$ \mathcal{L}=\frac{1}{n}{\sum}_{i=1}n{\left(w{x}{(i)}+b-{y}^{(i)}\right)}² $$

需要优化的模型参数是 wb ,因此我们使用以下等式迭代更新它们:

$$ {w}^{\prime }=w-\eta \frac{\partial \mathcal{L}}{\partial w} $$

$$ {b}^{\prime }=b-\eta \frac{\partial \mathcal{L}}{\partial b} $$

2.3 运行中的线性模型

让我们使用梯度下降算法实际训练一个单输入线性神经元模型。首先,我们需要对多个数据点进行采样。对于具有已知模型的玩具示例,我们直接从指定的真实模型中取样:

$$ y=1.477x+0.089 $$

  1. 采样数据

为了模拟观测误差,我们在模型中增加了一个独立的误差变量 ϵ ,其中 ϵ 服从高斯分布,平均值为 0,标准差为 0.01(即方差为 0.01 2 ):

$$ y=1.477x+0.089+\epsilon, \epsilon \sim \mathcal{N}\left(0,{0.01}²\right) $$

通过随机采样 n = 100 次,我们使用以下代码获得一个训练数据集$$ {\mathbbm{D}}^{\mathrm{train}} $$:

data = [] # A list to save data samples
for i in range(100): # repeat 100 times
    # Randomly sample x from a uniform distribution
    x = np.random.uniform(-10., 10.)
    # Randomly sample from Gaussian distribution
    eps = np.random.normal(0., 0.01)
    # Calculate model output with random errors
    y = 1.477 * x + 0.089 + eps
    data.append([x, y]) # save to data list
data = np.array(data) # convert to 2D Numpy array

在前面的代码中,我们在一个循环中执行 100 个样本,每次我们从均匀分布u(10,10)中随机采样一个数据点 x ,然后从高斯分布$$ \mathcal{N}\left(0,{0.1}²\right) $$中随机采样噪声 ϵ 。最后,我们使用真实模型和随机噪声 ϵ 生成数据,并将其保存为 Numpy 数组。

  1. 计算均方误差

现在,让我们通过平均每个数据点的预测值和真实值之间的平方差来计算训练集的均方误差。我们可以使用以下函数来实现这一点:

  1. 计算坡度
def mse(b, w, points):
    # Calculate MSE based on current w and b
    totalError = 0
    # Loop through all points
    for i in range(0, len(points)):
        x = points[i, 0] # Get ith input
        y = points[i, 1] # Get ith output
        # Calculate the total squared error
        totalError += (y - (w * x + b)) ** 2
    # Calculate the mean of the total squared error

    return totalError / float(len(points))

根据梯度下降算法,我们需要计算每个数据点$$ \left(\frac{\partial \mathcal{L}}{\partial w},\frac{\partial \mathcal{L}}{\partial b}\right) $$的梯度。首先,考虑扩展均方误差函数$$ \frac{\partial \mathcal{L}}{\partial w} $$:

$$ \frac{\partial \mathcal{L}}{\partial w}=\frac{\partial \frac{1}{n}{\sum}_{i=1}n{\left(w{x}{(i)}+b-{y}^{(i)}\right)}²}{\partial w}=\frac{1}{n}{\sum}_{i=1}^n\frac{\partial {\left(w{x}{(i)}+b-{y}{(i)}\right)}²}{\partial w} $$

因为

$$ \frac{\partial {g}²}{\partial w}=2\bullet g\bullet \frac{\partial g}{\partial w} $$

我们有

$$ \frac{\partial \mathcal{L}}{\partial w}=\frac{1}{n}{\sum}_{i=1}n2\left(w{x}{(i)}+b-{y}^{(i)}\right)\bullet \frac{\partial \left(w{x}{(i)}+b-{y}{(i)}\right)}{\partial w} $$

$$ =\frac{1}{n}{\sum}_{i=1}n2\left(w{x}{(i)}+b-{y}^{(i)}\right)\bullet {x}^{(i)} $$

$$ =\frac{\mathbf{2}}{\boldsymbol{n}}{\sum}_{\boldsymbol{i}=\mathbf{1}}{\boldsymbol{n}}\left(\boldsymbol{w}{\boldsymbol{x}}{\left(\boldsymbol{i}\right)}+\boldsymbol{b}-{\boldsymbol{y}}^{\left(\boldsymbol{i}\right)}\right)\bullet {\boldsymbol{x}}^{\left(\boldsymbol{i}\right)} $$

(2.2)

如果很难理解前面的推导,可以复习数学中与梯度相关的课程。详细内容也会在本书第七章介绍。我们可以暂时记住$$ \frac{\partial \mathcal{L}}{\partial w} $$的最终表情。同样的,我们可以推导出偏导数的表达式$$ \frac{\partial \mathcal{L}}{\partial b} $$:

$$ \frac{\partial \mathcal{L}}{\partial b}=\frac{\partial \frac{1}{n}{\sum}_{i=1}n{\left(w{x}{(i)}+b-{y}^{(i)}\right)}²}{\partial b}=\frac{1}{n}{\sum}_{i=1}^n\frac{\partial {\left(w{x}{(i)}+b-{y}{(i)}\right)}²}{\partial b} $$

$$ =\frac{1}{n}{\sum}_{i=1}n2\left(w{x}{(i)}+b-{y}^{(i)}\right)\bullet \frac{\partial \left(w{x}{(i)}+b-{y}{(i)}\right)}{\partial b} $$

$$ =\frac{1}{n}{\sum}_{i=1}n2\left(w{x}{(i)}+b-{y}^{(i)}\right)\bullet 1 $$

$$ =\frac{\mathbf{2}}{\boldsymbol{n}}{\sum}_{\boldsymbol{i}=\mathbf{1}}{\boldsymbol{n}}\left(\boldsymbol{w}{\boldsymbol{x}}{\left(\boldsymbol{i}\right)}+\boldsymbol{b}-{\boldsymbol{y}}^{\left(\boldsymbol{i}\right)}\right) $$

(2.3)

根据表达式( 2.2 )和( 2.3 ),我们只需要计算出(wx(I)+by(I))x(I)的平均值实现如下:

  1. 渐变更新
def step_gradient(b_current, w_current, points, lr):
    # Calculate gradient and update w and b.
    b_gradient = 0
    w_gradient = 0
    M = float(len(points)) # total number of samples
    for i in range(0, len(points)):
        x = points[i, 0]
        y = points[i, 1]
        # dL/db:grad_b = 2(wx+b-y) from equation (2.3)
        b_gradient += (2/M) * ((w_current * x + b_current) - y)
        # dL/dw:grad_w = 2(wx+b-y)*x from equation (2.2)
        w_gradient += (2/M) * x * ((w_current * x + b_current) - y)
    # Update w',b' according to gradient descent algorithm
    # lr is learning rate
    new_b = b_current - (lr * b_gradient)
    new_w = w_current - (lr * w_gradient)
    return [new_b, new_w]

在计算出误差函数在 wb 的梯度后,我们可以根据方程( 2.1 )更新 wb 的值。训练数据集的所有样本一次被称为一个时期。我们可以使用之前定义的函数迭代多个时期。实现如下:

def gradient_descent(points, starting_b, starting_w, lr, num_iterations):
    # Update w, b multiple times
    b = starting_b # initial value for b
    w = starting_w # initial value for w
    # Iterate num_iterations time
for step in range(num_iterations):
        # Update w, b once
        b, w = step_gradient(b, w, np.array(points), lr)
        # Calculate current loss
   loss = mse(b, w, points)
        if step%50 == 0: # print loss and w, b
            print(f"iteration:{step}, loss:{loss}, w:{w}, b:{b}")
     return [b, w] # return the final value of w and b

主要培训功能定义如下:

def main():
    # Load training dataset
    data = []
    for i in range(100):
         x = np.random.uniform(3., 12.)
         # mean=0, std=0.1
         eps = np.random.normal(0., 0.1)
         y = 1.477 * x + 0.089 + eps
         data.append([x, y])
    data = np.array(data)
    lr = 0.01      # learning rate
    initial_b = 0 # initialize b
    initial_w = 0 # initialize w
    num_iterations = 1000
    # Train 1000 times and return optimal w*,b* and corresponding loss
    [b, w]= gradient_descent(data, initial_b, initial_w, lr, num_iterations)
    loss = mse(b, w, data) # Calculate MSE
    print(f'Final loss:{loss}, w:{w}, b:{b}')

经过 1000 次迭代更新,最终的 wb 就是我们要找的“最优”解。结果如下:

iteration:0, loss:11.437586448749, w:0.88955725981925, b:0.02661765516748428
iteration:50, loss:0.111323083882350, w:1.48132089048970, b:0.58389075913875
iteration:100, loss:0.02436449474995, w:1.479296279074, b:0.78524532356388
...
iteration:950, loss:0.01097700897880, w:1.478131231919, b:0.901113267769968

Final loss:0.010977008978805611, w:1.4781312318924746, b:0.901113270434582

可以看出,在第 100 次迭代时, wb 的值已经接近真实模型值。经过 1000 次更新后得到的 wb 非常接近真实模型。训练过程的均方误差如图 2-7 所示。

img/515226_1_En_2_Fig7_HTML.jpg

图 2-7

训练过程中的 MSE 变化

前面的示例显示了梯度下降算法在求解模型参数方面的强大功能。需要注意的是,对于复杂的非线性模型,梯度下降算法求解的参数可能是局部最小解而不是全局最小解,这是由函数非凸性决定的。然而,我们在实践中发现,通过梯度下降算法获得的数值解的性能通常可以被很好地优化,并且相应的解可以直接用于近似最优解。

2.4 总结

简单回顾一下我们的探索:我们先假设输入为 n 的神经元模型是线性模型,然后通过 n + 1 个样本可以计算出 wb 的精确解。引入观测误差后,可以对多组数据点进行采样,通过梯度下降算法进行优化,得到 wb 的数值解。

如果从另一个角度来看这个问题,其实可以理解为一组连续值(向量)预测问题。给定一个数据集$$ \mathbbm{D} $$,我们需要从数据集学习一个模型,以便预测一个未知样本的输出值。在假设模型的类型之后,学习过程变成了搜索模型参数的问题。比如我们假设神经元是线性模型,那么训练过程就是搜索线性模型参数 *** w *** 和 *** b *** 的过程。训练之后,我们可以使用模型输出值作为任何新输入的真实值的近似值。从这个角度来说,是一个连续值预测问题。

在现实生活中,连续值预测问题非常常见,比如股票价格趋势的预测、天气预报中的温湿度预测、年龄的预测、交通流量的预测等等。如果它的预测在一个连续的实数范围内,或者属于某个连续的实数范围,我们称之为回归问题。特别是如果用线性模型来近似真实模型,那么我们称之为线性回归,这是回归问题的一种具体实现。

除了连续值预测问题,还有离散值预测问题吗?比如硬币正反面的预测,只能有正反面两种预测。给定一张图片,这张图片中的物体类型只能是一些离散的类别比如猫或者狗。像这样的问题被称为分类问题,这将在下一章介绍。

2.5 参考文献

  1. W.s .麦卡洛克和 w .皮茨,“神经活动内在思想的逻辑演算”,《数学生物物理学通报》, 5,第 115-133 页,1943 年 12 月 1 日。

  2. F.罗森布拉特,感知机,一个感知和识别自动机项目,康奈尔航空实验室,1957 年。

三、分类

花在人工智能上的一年时间,足以让一个人相信上帝。

—艾伦·珀利斯

前面已经介绍了用于连续变量预测的线性回归模型。现在让我们深入分类问题。分类问题的一个典型应用是教计算机如何自动识别图像中的对象。我们来考虑一个图像分类中最简单的任务:0–9 数字图片识别,相对简单,也有非常广泛的应用,比如邮政编码、快递单号、手机号识别。我们将以 0–9 数字图片识别为例,探讨如何利用机器学习解决分类问题。

3.1 手写数字图片数据集

机器学习需要从数据中学习,所以首先需要收集大量的真实数据。以手写数字图片识别为例,如图 3-1 所示,我们需要收集大量真人书写的 0–9 数字图片。为了便于存储和计算,采集到的图片一般会缩放到固定的大小,比如 224 × 224 或者 96 × 96 像素。这些图片将作为输入数据 x 。同时,我们需要给每张图片贴上标签,这些标签将作为图片的真实价值。此标签指示图像属于哪个特定类别。对于手写数字图片识别,标签是数字 0-9,代表 0-9 的图片。

img/515226_1_En_3_Fig1_HTML.jpg

图 3-1

手写数字图片

如果我们希望模型在新样本上表现良好,即实现良好的模型泛化能力,那么我们需要尽可能地增加数据集的大小和多样性,使训练数据集尽可能接近真实的人口分布,并且模型也可以在看不见的样本上表现良好。

为了便于算法评估,Lecun 等人[1]发布了一个名为 MNIST 的手写数字图片数据集,其中包含了数字 0–9 的真实手写图片。每个数字总共有 7000 张图片,收集自不同的写作风格。总图数 7 万。其中 6 万张图片用于训练,剩下的 1 万张图片作为测试集。

因为手写数字图片中的信息相对简单,所以每张图片在只保留灰度信息的情况下,缩放为同样大小的 28 × 28 像素,如图 3-2 所示。这些图片由真人书写,包含丰富的字体大小、书写风格、线条粗细等信息,保证这些图片的分布尽可能接近真实手写数字图片的人口分布,从而保证模型泛化能力。

img/515226_1_En_3_Fig2_HTML.jpg

图 3-2

MNIST 数据集示例

现在让我们来看一幅画的表现。图片包含 h 行和 w 列,像素值为 h×w。通常,像素值是从 0 到 255 范围内的整数,以表示颜色强度信息。例如,0 表示最低强度,255 表示最高强度。如果是彩色图片,每个像素包含三个通道 R、G 和 B 的强度信息,这三个通道分别代表红色、绿色和蓝色的颜色强度。因此,与灰度图像不同,彩色图片的每个像素由具有三个元素的一维向量表示,这三个元素表示 R、G 和 B 颜色的强度。这样一来,彩色图像被保存为维数为[h,w,3]的张量,而灰度图片只需要形状为[h,w]的二维矩阵或形状为[h,w,1]的三维张量来表示其信息。图 3-3 显示了 8 号图片的矩阵内容。可以看出,图片中的黑色像素用 0 表示,灰度信息用 0–255 表示。图片中较白的像素对应于矩阵中较大的值。

img/515226_1_En_3_Fig3_HTML.jpg

图 3-3

一幅画是如何表现的 1

像 TensorFlow 和 PyTorch 这样的深度学习框架可以通过几行代码轻松下载、管理和加载 MNIST 数据集。这里,我们使用 TensorFlow 自动下载 MNIST 数据集,并将其转换为 Numpy 数组格式:

import  os
import  tensorflow as tf
from    tensorflow import keras
from    tensorflow.keras import layers, optimizers, datasets
# load MNIST dataset
(x, y), (x_val, y_val) = datasets.mnist.load_data()
# convert to float type and rescale to [-1, 1]
x = 2*tf.convert_to_tensor(x, dtype=tf.float32)/255.-1
# convert to integer tensor
y = tf.convert_to_tensor(y, dtype=tf.int32)
# one-hot encoding
y = tf.one_hot(y, depth=10)
print(x.shape, y.shape)
# create training dataset
train_dataset = tf.data.Dataset.from_tensor_slices((x, y))
# train in batch
train_dataset = train_dataset.batch(512)

load_data()函数返回两个元组对象:第一个是训练集,第二个是测试集。第一个元组的第一个元素是训练图片数据 X ,第二个元素是对应的类别号 Y 。与图 3-3 类似,训练集 X 中的每幅图像由 28×28 像素组成,训练集 X 中有 60000 幅图像,因此 X 的最终维数为(60000,28,28)。 Y 的大小为(60000),代表 0-9 的 60000 个数字。类似地,测试集包含 10,000 个测试图片和相应的数字编号,其维数分别为(10000,28,28)和(10,000)。

从 TensorFlow 加载的 MNIST 数据集包含值从 0 到 255 的图像。在机器学习中,一般希望数据的范围分布在 0 左右的小范围内。因此,我们将像素范围重新调整为区间[1,1],这将有利于模型优化过程。

每张图的计算过程都是通用的。所以我们可以一次计算多张图片,充分利用 CPU 或者 GPU 的并行计算能力。我们用一个形状矩阵[ hw 来表示一张图片。对于多张图,我们可以在前面多加一个维度,用一个形状张量[ bhw 来表示。这里 b 代表批量。彩色图片可以用一个形状为[ bhwc ]的张量来表示,其中 c 表示通道数,对于彩色图片为 3。TensorFlow 的 Dataset 对象可用于使用 batch()函数方便地将数据集转换为批处理。

3.2 建立模型

回想一下我们在上一章讨论的生物神经元结构。我们将输入向量$$ x={\left[{x}_1,{x}_2,\dots, {x}_{d_{in}}\right]}^T $$简化为单个输入标量 x,模型可以表示为 y = * xw * + b 。如果是多输入单输出的模型结构,我们需要使用向量形式:

$$ y={w}^Tx+b=\left[{w}_1,{w}_2,{w}_3,\dots, {w}_{d_{in}}\right]\bullet \left[{x}_1\ {x}_2\ {x}_3\vdots {x}_{d_{in}}\ \right]+b $$

更一般地,通过组合多个多输入单输出神经元模型,我们可以构建一个多输入多输出模型:

$$ y= Wx+b $$

其中$$ x\in {R}^{d_{in}} $$$$ b\in {R}^{d_{out}} $$$$ y\in {R}^{d_{out}} $$$$ \mathrm{and}\ W\in {R}^{d_{out}\times {d}_{in}} $$

对于多输出和批量训练,我们以批量形式编写模型:

$$ Y=X@W+b $$

(3.1)

其中 中的$$ X\in {R}^{b\times {d}_{in}} $$$$ b\in {R}^{d_{out}} $$$$ Y\in {R}^{b\times {d}_{out}} $$$$ W\in {R}^{d_{in}\times {d}_{out}} $$d 表示输入维度,dout表示输出维度。 X 有形状bd b 为样本数d为每个样本的长度。 W 有形状 d d ,包含 d ∵*d出* 参数。偏置向量 b 有形状 d 。@符号表示矩阵乘法。由于运算结果 X @ W 是一个形状为[ bdout]的矩阵,所以不能直接加到向量 b 上。所以批量形式的+号需要支持广播,即通过复制 b 将向量 b 展开成形状为 bdout的矩阵。

考虑两个样本,其中 d in = 3,dout= 2。方程式 3.1 展开如下:

$$ \left[{o}_1{(1)} {o}_2{(1)}\ {o}_1{(2)} {o}_2{(2)}\ \right]=\left[{x}_1{(1)} {x}_2{(1)}\ {x}_3{(1)} {x}_1{(2)}\ {x}_2{(2)} {x}_3{(2)}\ \right]\left[{w}_{11}\ {w}_{12}\ {w}_{21}\ {w}_{22}\ {w}_{31}\ {w}_{32}\ \right]+\left[{b}_1\ {b}_2\ \right] $$

其中上标如(1)和(2)表示样本索引,下标如 1 和 2 表示某个样本向量的元素。相应的模型结构如图 3-4 所示。

img/515226_1_En_3_Fig4_HTML.png

图 3-4

具有三个输入和两个输出的神经网络

可以看出,矩阵形式更加简洁明了,同时可以充分发挥矩阵计算的并行加速能力。那么如何将图像识别任务的输入输出转化为张量形式呢?

使用形状为[ hwb 的矩阵存储灰度图像,使用形状为[ bhw 的张量存储图片。但是我们的模型只能接受向量,所以我们需要将[ hw ]矩阵展平成一个长度为 hw 的向量,如图 [3-5 所示,其中输入特征的长度din=hw

img/515226_1_En_3_Fig5_HTML.png

图 3-5

展平矩阵

对于输出标签 y ,之前已经介绍了数字编码。它可以用一个数字来表示标签信息。输出只需要一个数字来表示网络的预测类别值,比如 1 号代表猫,3 号代表鱼。然而,数字编码的一个最大问题是,数字之间存在自然的顺序关系。比如 1、2、3 对应的标签是猫、狗、鱼,它们之间没有顺序关系,而是 1 < 2 < 3。因此,如果使用数字编码,将迫使模型学习这种不必要的约束。换句话说,数字编码会将标称标度(即,没有特定顺序)改变为序数标度(即,具有特定顺序),这不适合这种情况。

那么如何解决这个问题呢?输出实际上可以设置为一组长度为 d out 的向量,其中 d out 与类别的数量相同。例如,如果输出属于第一类,则相应的索引被设置为 1,其他位置被设置为 0。这种编码方法称为一键编码。以图 3-6 中的“猫、狗、鱼、鸟”识别系统为例,所有样本只属于“猫、狗、鱼、鸟”四类中的一类我们使用索引位置来分别表示猫、狗、鱼和鸟的类别。对于猫的所有图片,它们的一热编码是[1,0,0,0];对于所有的狗图片,它们的一热编码是[0,1,0,0];诸如此类。一键编码广泛应用于分类问题。

img/515226_1_En_3_Fig6_HTML.png

图 3-6

独热编码示例

手写数字图片的总类别数为十,即 d out = 10。对于一个样本,假设它属于一个类别 i ,即编号 i 。使用 one-hot 编码,我们可以使用长度为 10 的向量 y 来表示它,其中这个向量中的第 I 个元素是 1,其余的是 0。比如图片 0 的一热编码是[1,0,0,…,0],图片 2 的一热编码是[0,0,1,…,0],图片 9 的一热编码是[0,0,0,…,1]。独热编码非常稀疏。与数字编码相比,它需要更多的存储空间,所以一般采用数字编码进行存储。在计算过程中,数字编码被转换为一位热码编码,这可以通过 tf.one_hot()函数实现,如下所示:

y = tf.constant([0,1,2,3]) # digits 0-3
y = tf.one_hot(y, depth=10) # one-hot encoding with length 10
print(y)
Out[1]:
tf.Tensor(
[[1\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0.]  # one-hot encoding of number 0
 [0\. 1\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0.]  # one-hot encoding of number 1
 [0\. 0\. 1\. 0\. 0\. 0\. 0\. 0\. 0\. 0.]  # one-hot encoding of number 2
 [0\. 0\. 0\. 1\. 0\. 0\. 0\. 0\. 0\. 0.]], shape=(4, 10), dtype=float32)

现在让我们回到手写数字图像识别的任务。输入是一个扁平化的图片向量 xR 784 ,输出是一个长度为 10oR10对应某个数的一键编码的向量,形成一个多输入多输出的线性模型o=WT我们希望模型输出更接近真实标签。

3.3 误差计算

对于分类问题,我们的目标是最大化某个性能指标,比如准确率。但是当精度被用作损失函数时,它实际上是不可微的。因此,梯度下降算法不能用于优化模型参数。一般的方法是建立一个平滑的和可导的代理目标函数,例如优化模型的输出和独热编码的真实标签之间的距离。通过优化代理目标函数获得的模型通常在测试数据集上也表现良好。与回归问题相比,分类问题的优化和评价目标函数是不一致的。训练一个模型的目标是通过优化损失函数 L 找到最优数值解Wb:

对于一个分类问题的误差计算,更常见的是使用交叉熵损失函数,而不是回归问题中引入的均方误差损失函数。我们将在以后的章节中介绍交叉熵损失函数。为了简单起见,这里我们仍然使用均方误差损失函数来解决手写数字图片识别问题。 n 个样本的均方误差损失函数可以表示为

$$ L\left(o,y\right)=\frac{1}{n}{\sum}_{i=1}n{\sum}_{j=1}{10}{\left({o}_j{(i)}-{y}_j{(i)}\right)}² $$

现在我们只需要用梯度下降算法优化损失函数得到最优解 Wb 然后用得到的模型预测未知的手写数字图片 xD 测试

3.4 我们真的解决了问题吗?

根据前面的解决方案,手写数字图片识别的问题真的完美解决了吗?至少有两个主要问题:

  • 一个线性模型是机器学习中最简单的模型之一。它只有几个参数,只能表达线性关系。复杂大脑的感知和决策远比一个线性模型复杂。因此,线性模型显然是不够的。

  • 复杂性是模型近似复杂分布的能力。前述解决方案仅使用由少量神经元组成的单层神经网络模型。相比人脑中的 1000 亿个神经元互联结构,其泛化能力明显较弱。

图 3-7 显示了模型复杂性和数据分布的示例。绘制了带有观测误差的采样点分布图。实际分布可以是二次抛物线模型。如图 3-7 (a)如果用线性模型拟合数据,很难学习到好的模型;如果使用合适的多项式函数模型进行学习,比如二次多项式,就可以学习到如图 3-7 (b)所示的合适模型。但当模型过于复杂时,比如一个十次多项式,很可能会过拟合,伤害模型的泛化能力,如图 3-7 (c)。

img/515226_1_En_3_Fig7_HTML.png

图 3-7

模型复杂性

我们目前使用的多神经元模型仍然是线性模型,泛化能力较弱。接下来,我们将尝试解决这两个问题。

3.5 非线性模型

由于线性模型不可行,我们可以在线性模型中嵌入非线性函数,将其转换为非线性模型。我们称这个非线性函数为激活函数,用 σ 表示:

$$ o=\sigma \left( Wx+b\right) $$

这里 σ 代表一个特定的非线性激活函数,比如 Sigmoid 函数(图 3-8 (a))和 ReLU 函数(图 3-8 (b))。

img/515226_1_En_3_Fig8_HTML.png

图 3-8

常见激活功能

ReLU 函数只保留函数 y = x 的正部分,并将负部分设置为零。它具有单边抑制特性。虽然简单,但 ReLU 函数具有极好的非线性特性、容易的梯度计算和稳定的训练过程。它是深度学习模型中使用最广泛的激活函数之一。这里,我们通过嵌入 ReLU 函数将模型转换为非线性模型:

$$ o= ReLU\left( Wx+b\right) $$

3.6 模型复杂性

为了增加模型的复杂性,我们可以重复堆叠多个转换,例如

$$ {h}_1= ReLU\left({W}_1x+{b}_1\right) $$

$$ {h}_2= ReLU\left({W}_2{h}_1+{b}_2\right) $$

$$ o={W}_3{h}_2+{b}_3 $$

在前面的等式中,我们将第一层神经元的输出值 h 1 作为第二层神经元的输入,然后将第二层神经元的输出 h 2 作为第三层神经元的输入,最后一层神经元的输出为模型输出。

如图 3-9 所示,函数嵌入以一个接一个的连接网络出现。我们称输入节点 x 所在的层为输入层。每个非线性模块 h i 的输出及其参数 W ib i 称为一个网络层。特别是网络中间的那一层叫隐藏层,最后一层叫输出层。这种由大量神经元连接而成的网络结构称为神经网络。每层的节点数和层数决定了神经网络的复杂程度。

img/515226_1_En_3_Fig9_HTML.png

图 3-9

三层神经网络体系结构

现在我们的网络模型已经升级为三层神经网络,具有下降的复杂度和良好的非线性泛化能力。接下来,我们来讨论如何优化网络参数。

3.7 优化方法

我们已经在第二章介绍了回归问题的详细优化过程。实际上,类似的优化方法也可以用来解决分类问题。对于只有一层的网络模型,我们可以直接导出$$ \frac{\partial L}{\partial w} $$$$ \frac{\partial L}{\partial b} $$的偏导数表达式,然后计算每一步的梯度,并使用梯度下降算法更新参数 wb 。然而,随着复杂非线性函数的嵌入,网络层数和数据特征长度也增加,模型变得非常复杂,并且难以手动导出梯度表达式。此外,一旦网络结构发生变化,模型函数和相应的梯度表达式也会发生变化。因此,依靠人工计算梯度显然是不可行的。

这就是为什么我们发明了深度学习框架。在自动微分技术的帮助下,深度学习框架可以在计算每一层的输出和相应的损失函数时建立神经网络的计算图,然后自动计算任意参数 θ 的梯度$$ \frac{\partial L}{\partial \theta } $$。用户只需要设置好网络结构,梯度就会自动计算更新,使用起来非常方便高效。

3.8 动手手写数字图像识别

在本节中,我们将体验神经网络的乐趣,而不会介绍太多 TensorFlow 的细节。本节的主要目的不是讲授每一个细节,而是让读者对神经网络算法有一个全面直观的体验。让我们开始体验神奇的图像识别算法吧!

构建网络

对于第一层,输入是 xR 784 ,输出h1R256是一个长度为 256 的向量。我们不需要明确写出h1=ReLU(W1x+b1)的计算逻辑。它可以在 TensorFlow 中用一行代码实现:

# Create one layer with 256 output dimension and ReLU activation function
layers.Dense(256, activation='relu')

使用 TensorFlow 的序列函数,我们可以很容易地建立一个多层网络。对于三层网络,可以按如下方式实现:

# Build a 3-layer network. The output of 1st layer is the input of 2nd layer.
model = keras.Sequential([
    layers.Dense(256, activation='relu'),
    layers.Dense(128, activation='relu'),
    layers.Dense(10)])

三层中的输出节点数分别为 256、128 和 10。调用 model (x)可以直接返回最后一层的输出。

模型培训

构建好三层神经网络后,给定输入 x ,我们可以调用 model( x )得到模型输出 o 并计算电流损耗 L :

    with tf.GradientTape() as tape: # Record the gradient calculation
        # Flatten x, [b, 28, 28] => [b, 784]
        x = tf.reshape(x, (-1, 28*28))
        # Step1\. get output [b, 784] => [b, 10]
        out = model(x)
        # [b] => [b, 10]
        y_onehot = tf.one_hot(y, depth=10)
        # Calculate squared error, [b, 10]
        loss = tf.square(out-y_onehot)
        # Calculate the mean squared error, [b]
        loss = tf.reduce_sum(loss) / x.shape[0]

然后我们使用 TensorFlow tape.gradient(loss,model.trainable _ variables)的自动微分函数来计算所有的梯度$$ \frac{\partial L}{\partial \theta },\theta \in \left{{W}_1,{b}_1,{W}_2,{b}_2,{W}_3,{b}_3\right} $$。:

        # Step3\. Calculate gradients w1, w2, w3, b1, b2, b3
        grads = tape.gradient(loss, model.trainable_variables)

使用梯度列表变量保存梯度结果。然后我们使用优化器对象根据梯度更新规则自动更新模型参数 θ

$$ {\theta}^{\prime }=\theta -\eta \bullet \frac{\partial L}{\partial \theta } $$

代码如下:

        # Auto gradient calculation
        grads = tape.gradient(loss, model.trainable_variables)
        # w' = w - lr * grad, update parameters
        optimizer.apply_gradients(zip(grads, model.trainable_variables))

经过多次迭代后,学习到的模型 f θ 可以用来预测未知图片的分类概率。这里暂时不讨论模型测试部分。

MNIST 数据集的训练误差曲线如图 3-10 所示。由于三层神经网络具有较强的泛化能力,手写数字图像识别任务相对简单,训练误差下降较快。在图 3-10 中,x 轴代表迭代所有训练样本的次数,称为历元。迭代所有训练样本一次被称为一个时期。我们可以在几个时期后测试模型的准确性和其他指标,以监控模型的训练效果。

img/515226_1_En_3_Fig10_HTML.jpg

图 3-10

MNIST 数据集的训练误差

3.9 摘要

本章通过将一层线性回归模型类比于分类问题,提出了一个三层非线性神经网络模型来解决手写数字图像识别问题。学完这一章,大家应该对(浅显的)神经网络算法有了很好的理解。除了数字图像识别,分类模型还有各种各样的应用。例如,分类模型用于区分垃圾邮件和非垃圾邮件,对非结构化文本进行情感分析,以及处理图像以进行分割。我们将在以后的章节中遇到更多的分类问题和应用。

接下来学习 TensorFlow 的一些基础知识,为后续学习和实现深度学习算法打下坚实的基础。

3.10 参考

  1. Y.Lecun、L. Bottou、Y. Bengio 和 P. Haffner,“基于梯度的学习应用于文档识别”,《IEEE 学报,1998 年。

四、基本 TensorFlow

我设想在未来,我们可能相当于机器人宠物狗,到那时我也会支持机器人。

—克劳德·香农

TensorFlow 是深度学习算法的科学计算库。所有操作都是基于张量对象执行的。复杂的神经网络算法本质上是张量的乘、加等基本运算的组合。因此,熟悉 TensorFlow 中的基本张量运算非常重要。只有掌握了这些操作,才能随意实现各种复杂新颖的网络模型,理解各种模型和算法的本质。

4.1 数据类型

TensorFlow 中的基本数据类型包括数值、字符串和布尔。

数字

数值张量是 TensorFlow 的主要数据格式。根据维度,可以分为

  • 标量:单个实数,如 1.2 和 3.4,维数为 0,形状为[]。

  • 向量:实数的有序集合,用方括号包裹,如【1.2】【1.2,3.4】,维数为 1,形状根据长度不同为【n】。

** 矩阵: n 行、 m 列的实数有序集合,如[[1,2],[3,4]],维数为 2,形状为[ nm

*   张量:维数大于 2 的数组。张量的每个维度也称为轴。通常,每个维度代表特定的物理意义。例如,形状为[2,32,32,3]的张量有四个维度。如果表示图像数据,每个维度或轴表示图像的数量、图像高度、图像宽度和颜色通道的数量,即 2 表示两个图片,图像高度和宽度都是 32,3 表示总共三个颜色通道,即 RGB。张量的维数和每个维数所代表的具体物理意义需要用户定义。* 

*在 TensorFlow 中,标量、向量和矩阵也不加区分地统称为张量。你需要根据张量的维数或者形状来做出自己的判断。同样的惯例也适用于这本书。

首先,让我们在 TensorFlow 中创建一个标量。实现如下:

In [1]:
a = 1.2 # Create a scalar in Python
aa = tf.constant(1.2)  # Create a scalar in TensorFlow
type(a), type(aa), tf.is_tensor(aa)
Out[1]:
     (float, tensorflow.python.framework.ops.EagerTensor, True)

如果要使用 TensorFlow 提供的函数,就必须按照 TensorFlow 指定的方式创建张量,而不是标准的 Python 语言。我们可以通过 print (x)或者 x 把张量 x 的相关信息打印出来,代码如下:

In [2]: x = tf.constant([1,2.,3.3])
x # print out x
Out[2]:
<tf.Tensor: id=165, shape=(3,), dtype=float32, numpy=array([1\. , 2\. , 3.3], dtype=float32)>

在输出中,id 是 TensorFlow 中内部对象的索引,shape 表示张量的形状,dtype 表示张量的数值精度。numpy()方法可以返回 Numpy.array 类型的数据,方便将数据导出到系统中的其他模块。

In [3]:  x.numpy()      # Convert TensorFlow (TF) tensor to numpy array
Out[3]:
array([1\. , 2\. , 3.3], dtype=float32)

与标量不同,向量的定义必须通过一个列表容器传递给 tf.constant()函数。例如,下面是如何创建一个向量:

In [4]:
a = tf.constant([1.2])  # Create a vector with one element
a, a.shape
Out[4]:
(<tf.Tensor: id=8, shape=(1,), dtype=float32, numpy=array([1.2], dtype=float32)>,
 TensorShape([1]))

创建一个包含三个元素的向量:

In [5]:
a = tf.constant([1,2, 3.])
a, a.shape
Out[5]:
 (<tf.Tensor: id=11, shape=(3,), dtype=float32, numpy=array([1., 2., 3.], dtype=float32)>,
 TensorShape([3]))

类似地,矩阵的实现如下:

In [6]:
a = tf.constant([[1,2],[3,4]])  # Create a 2x2 matrix
a, a.shape
Out[6]:
(<tf.Tensor: id=13, shape=(2, 2), dtype=int32, numpy=
 array([[1, 2],
        [3, 4]])>, TensorShape([2, 2]))

三维张量可以定义为

In [7]:
a = tf.constant([[[1,2],[3,4]],[[5,6],[7,8]]])
Out[7]:
<tf.Tensor: id=15, shape=(2, 2, 2), dtype=int32, numpy=
array([[[1, 2],
        [3, 4]],
       [[5, 6],
        [7, 8]]])>

字符串

除了数值类型,TensorFlow 还支持字符串类型。例如,在处理图像数据时,我们可以先记录图像的路径串,然后通过预处理函数根据路径读取图像张量。字符串张量可以通过传入字符串对象来创建,例如:

In [8]:
a = tf.constant('Hello, Deep Learning.')
a
Out[8]:
<tf.Tensor: id=17, shape=(), dtype=string, numpy=b'Hello, Deep Learning.'>

tf.strings 模块为字符串提供了常见的实用函数,如 lower()、join()、length()和 split()。例如,我们可以将所有字符串转换成小写:

In [9]:
tf.strings.lower(a)  # Convert string a to lowercase
Out[9]:
<tf.Tensor: id=19, shape=(), dtype=string, numpy=b'hello, deep learning.'>

深度学习算法主要基于数值张量运算,字符串数据使用频率较低,这里不做过多赘述。

布尔型

为了方便比较操作,TensorFlow 还支持布尔张量。我们可以轻松地将 Python 标准布尔数据转换为 TensorFlow 内部布尔数据,如下所示:

In [10]: a = tf.constant(True)
a
Out[10]:
<tf.Tensor: id=22, shape=(), dtype=bool, numpy=True>

同样,我们可以创建一个布尔向量,如下所示:

In [1]:
a = tf.constant([True, False])
Out[1]:
<tf.Tensor: id=25, shape=(2,), dtype=bool, numpy=array([ True, False])>

请注意,Tensorflow 和标准 Python 布尔类型并不总是等价的,也不能通用,例如:

In [1]:
a = tf.constant(True) # Create TF Boolean data
a is True # Whether a is a Python Boolean
Out[1]:
False # TF Boolean is not a Python Boolean
In [2]:
a == True  # Are they numerically the same?
Out[2]:
<tf.Tensor: id=8, shape=(), dtype=bool, numpy=True> # Yes, numerically, they are equal.

4.2 数值精度

对于数值张量,可以用对应于不同精度的不同字节长度来保存。例如,浮点数 3.14 可以以 16 位、32 位或 64 位精度保存。位越长,精度越高,当然,数字占用的存储空间也越大。TensorFlow 中常用的精度类型有 tf.int16、tf.int32、tf.int64、tf.float16、tf.float32 和 tf.float64,其中 tf.float64 称为 tf.double。

当创建一个张量时,我们可以指定它的精度,例如:

In [12]:
tf.constant(123456789, dtype=tf.int16)
tf.constant(123456789, dtype=tf.int32)
Out[12]:
<tf.Tensor: id=33, shape=(), dtype=int16, numpy=-13035>
<tf.Tensor: id=35, shape=(), dtype=int32, numpy=123456789>

注意,当精度太低时,数据 123456789 溢出,并返回错误的结果。通常,tf.int32 和 tf.int64 精度更常用于整数。对于浮点数,高精度张量可以更准确地表示数据。比如 tf.float32 用于 π 时,实际保存的数据是 3.1415927:

In [1]:
import numpy as np
tf.constant(np.pi, dtype=tf.float32)  # Save pi with 32 byte
Out[1]:
<tf.Tensor: id=29, shape=(), dtype=float32, numpy=3.1415927>

如果我们使用 tf.float64,我们可以得到更高的精度:

In [2]:
tf.constant(np.pi, dtype=tf.float64)  # Save pi with 64 byte
Out[2]:
<tf.Tensor: id=31, shape=(), dtype=float64, numpy=3.141592653589793>

对于大多数深度学习算法,tf.int32 和 tf.float32 一般能够满足精度要求。一些精度要求比较高的算法,比如强化学习,可以用 tf.int64 和 tf.float64。

张量精度可以通过 dtype 属性来访问。对于一些只能处理指定精度类型的运算,需要事先检查输入张量的精度类型,不符合要求的张量要用 tf.cast 函数转换成合适的类型,例如:

In [3]:
a = tf.constant(3.14, dtype=tf.float16)
print('before:',a.dtype)  # Get a's precision
if a.dtype != tf.float32:  # If a is not tf.float32, convert it to tf.float32.
    a = tf.cast(a,tf.float32)  # Convert a to tf.float32
print('after :',a.dtype)  # Get a's current precision
Out[3]:
before: <dtype: 'float16'>
after : <dtype: 'float32'>

在执行类型转换时,需要确保转换操作的合法性。例如,将高精度张量转换为低精度张量时,可能会出现隐藏的数据溢出风险:

In [4]:
a = tf.constant(123456789, dtype=tf.int32)
tf.cast(a, tf.int16)  # Convert a to lower precision and we have overflow
Out[4]:
<tf.Tensor: id=38, shape=(), dtype=int16, numpy=-13035>

布尔类型和整数类型之间的转换也是合法且常见的:

In [5]:
a = tf.constant([True, False])
tf.cast(a, tf.int32)  # Convert boolean to integers
Out[5]:
<tf.Tensor: id=48, shape=(2,), dtype=int32, numpy=array([1, 0])>

一般来说,在类型转换期间,0 表示 False,1 表示 True。在 TensorFlow 中,非零数字被视为真,例如:

In [6]:
a = tf.constant([-1, 0, 1, 2])
tf.cast(a, tf.bool)  # Convert integers to booleans
Out[6]:
<tf.Tensor: id=51, shape=(4,), dtype=bool, numpy=array([ True, False,  True,  True])>

4.3 待优化的张量

为了区分需要计算梯度信息的张量和不需要计算梯度信息的张量,TensorFlow 增加了一个特殊的数据类型来支持梯度信息的记录:tf.Variable. tf。变量在普通张量的基础上增加了名称、可训练性等属性,支持计算图的构造。由于梯度运算消耗大量计算资源并自动更新相关参数,tf。对于不需要梯度信息的张量,如神经网络的输入 X ,不需要封装变量。而是需要计算梯度的张量,比如神经网络层的 Wb ,需要用 tf 进行包裹。变量,以便 TensorFlow 跟踪相关的梯度信息。

特遣部队。Variable()函数可用于将普通张量转换成具有梯度信息的张量,例如:

In [20]:
a = tf.constant([-1, 0, 1, 2])  # Create TF tensor
aa = tf.Variable(a)  # Convert to tf.Variable type
aa.name, aa.trainable # Get tf.Variable properties
Out[20]:
 ('Variable:0', True)

名称和可训练属性是特定于 tf 的。可变类型。name 属性用于命名计算图形中的变量。这个命名系统由 TensorFlow 内部维护,一般不需要用户做任何事情。可训练属性指示是否需要为张量记录梯度信息。创建变量对象时,默认启用可训练标志。可以将可训练属性设置为 False,以避免记录渐变信息。

除了创造 tf。可变张量通过普通张量,也可以直接创建,例如:

In [21]:
a = tf.Variable([[1,2],[3,4]])  # Directly create Variable type tensor
a
Out[21]:
<tf.Variable 'Variable:0' shape=(2, 2) dtype=int32, numpy=
array([[1, 2],
       [3, 4]])>

特遣部队。可变张量可以认为是普通张量的一种特殊类型。实际上,为了支持自动微分功能,也可以通过 GradientTape.watch()方法将普通张量临时添加到跟踪梯度信息的列表中。

4.4 创建张量

在 TensorFlow 中,您可以通过多种方式创建张量,例如从 Python 列表、从 Numpy 数组或从已知的分布中创建。

4.4.1 从数组和列表创建张量

Numpy 数组和 Python 列表是 Python 中非常重要的数据容器。许多数据在转换为张量之前被加载到数组或列表中。TensorFlow 的输出数据通常也导出到数组或列表中,这使得它们很容易用于其他模块。

tf.convert_to_tensor 函数可用于从 Python 列表或 Numpy 数组创建新的张量,例如:

In [22]:
# Create a tensor from a Python list
tf.convert_to_tensor([1,2.])
Out[22]:
<tf.Tensor: id=86, shape=(2,), dtype=float32, numpy=array([1., 2.], dtype=float32)>
In [23]:
# Create a tensor from a Numpy array
tf.convert_to_tensor(np.array([[1,2.],[3,4]]))
Out[23]:
<tf.Tensor: id=88, shape=(2, 2), dtype=float64, numpy=
array([[1., 2.],
       [3., 4.]])>

请注意,默认情况下,Numpy 浮点数组以 64 位精度存储数据。转换为张量类型时,精度为 tf.float64,需要时可以转换为 tf.float32。事实上,tf.constant()和 tf.convert_to_tensor()都可以自动将 Numpy 数组或 Python 列表转换为张量类型。

4.4.2 创建全 0 或全 1 张量

创建全 0 或全 1 的张量是一种非常常见的张量初始化方法。考虑线性变换 y = Wx + b 。权重矩阵 W 可以用全 1 的矩阵初始化, b 可以用全 0 的向量初始化。于是线性变换变为 y = x 。我们可以使用 tf.zeros()或 tf.ones()创建任意形状的全零或全一张量:

In [24]: tf.zeros([]),tf.ones([])
Out[24]:
 (<tf.Tensor: id=90, shape=(), dtype=float32, numpy=0.0>,
 <tf.Tensor: id=91, shape=(), dtype=float32, numpy=1.0>)

创建一个全 0 和全 1 的向量:

In [25]: tf.zeros([1]),tf.ones([1])
Out[25]:
(<tf.Tensor: id=96, shape=(1,), dtype=float32, numpy=array([0.], dtype=float32)>,
 <tf.Tensor: id=99, shape=(1,), dtype=float32, numpy=array([1.], dtype=float32)>)

创建一个全零矩阵:

In [26]: tf.zeros([2,2])
Out[26]:
<tf.Tensor: id=104, shape=(2, 2), dtype=float32, numpy=
array([[0., 0.],
       [0., 0.]], dtype=float32)>

创建一个全 1 矩阵:

In [27]: tf.ones([3,2])
Out[27]:
<tf.Tensor: id=108, shape=(3, 2), dtype=float32, numpy=
array([[1., 1.],
       [1., 1.],
       [1., 1.]], dtype=float32)>

使用 tf.zeros_like 和 tf.ones_like,您可以轻松地创建一个全为 0 或 1 的张量,它与另一个张量的形状一致。例如,下面是如何创建一个与张量 a 形状相同的全零张量:

In [28]: a = tf.ones([2,3])  # Create a 2x3 tensor with all 1s
tf.zeros_like(a)  # Create a all zero tensor with the same shape of a
Out[28]:
<tf.Tensor: id=113, shape=(2, 3), dtype=float32, numpy=
array([[0., 0., 0.],
       [0., 0., 0.]], dtype=float32)>

创建一个与张量 a 形状相同的全 1 张量:

In [29]: a = tf.zeros([3,2])  # Create a 3x2 tensor with all 0s tf.ones_like(a)  # Create a all 1 tensor with the same shape of a
Out[29]:
<tf.Tensor: id=120, shape=(3, 2), dtype=float32, numpy=
array([[1., 1.],
       [1., 1.],
       [1., 1.]], dtype=float32)>

4.4.3 创建定制的数值张量

除了用全 0 或全 1 初始化张量之外,有时还需要用特定的值初始化张量,比如–1。使用 tf.fill(shape,value),我们可以创建一个具有特定数值的张量,其中维度由 shape 参数指定。例如,下面是如何用 element–1 创建一个标量:

In [30]:tf.fill([], -1)  #
Out[30]:
<tf.Tensor: id=124, shape=(), dtype=int32, numpy=-1>

创建一个包含所有元素的向量–1:

In [31]:tf.fill([1], -1)
Out[31]:
<tf.Tensor: id=128, shape=(1,), dtype=int32, numpy=array([-1])>

创建一个包含所有元素的矩阵 99:

In [32]:tf.fill([2,2], 99)  # Create a 2x2 matrix with all 99s
Out[32]:
<tf.Tensor: id=136, shape=(2, 2), dtype=int32, numpy=
array([[99, 99],
       [99, 99]])>

4.4.4 根据已知分布创建张量

有时,创建从普通分布(如正态(或高斯)和均匀分布)采样的张量非常有用。例如,在卷积神经网络中,卷积核 W 通常从正态分布初始化,以便于训练过程。在敌对网络中,隐藏变量 z 通常从均匀分布中取样。

使用 tf.random.normal(shape,mean=0.0,stddev=1.0),我们可以创建一个张量,其维数由形状参数和从正态分布 N ( meanstddev 2 )中采样的值定义。例如,以下是如何从均值为 0、标准差为 1 的正态分布创建张量:

In [33]: tf.random.normal([2,2])  # Create a 2x2 tensor from a normal distribution
Out[33]:
<tf.Tensor: id=143, shape=(2, 2), dtype=float32, numpy=
array([[-0.4307344 ,  0.44147003],
       [-0.6563149 , -0.30100572]], dtype=float32)>

根据均值为 1、标准差为 2 的正态分布创建张量:

In [34]: tf.random.normal([2,2], mean=1,stddev=2)
Out[34]:
<tf.Tensor: id=150, shape=(2, 2), dtype=float32, numpy=
array([[-2.2687864, -0.7248812],
       [ 1.2752185,  2.8625617]], dtype=float32)>

利用 tf.random.uniform(shape,minval=0,maxval=None,dtype=tf.float32),我们可以创建一个从区间[ minvalmaxval]采样的均匀分布张量。例如,下面是如何创建一个从区间[0,1]均匀采样的矩阵,其形状为[2,2]:

In [35]: tf.random.uniform([2,2])
Out[35]:
<tf.Tensor: id=158, shape=(2, 2), dtype=float32, numpy=
array([[0.65483284, 0.63064325],
       [0.008816  , 0.81437767]], dtype=float32)>

创建一个从区间[0,10]均匀采样的矩阵,形状为[2,2]:

In [36]: tf.random.uniform([2,2],maxval=10)
Out[36]:
<tf.Tensor: id=166, shape=(2, 2), dtype=float32, numpy=
array([[4.541913  , 0.26521802],
       [2.578913  , 5.126876  ]], dtype=float32)>

如果我们需要对整数进行统一采样,我们必须指定 maxval 参数,并将数据类型设置为 tf.int*:

In [37]:
# Create a integer tensor from a uniform distribution with interval [0,100)
tf.random.uniform([2,2],maxval=100,dtype=tf.int32)
Out[37]:
<tf.Tensor: id=171, shape=(2, 2), dtype=int32, numpy=
array([[61, 21],
       [95, 75]])>

请注意,来自所有随机函数的这些输出可能是不同的。但是,这并不影响这些功能的使用。

创建一个序列

当循环或索引一个张量时,经常需要创建一个连续的整数序列,这可以通过 tf.range()函数来实现。函数 tf.range(limit,delta=1)可以创建步长为 delta 且在区间[0, limit 内的整数序列。例如,以下是如何创建步长为 1 的 0–10 的整数序列:

In [38]: tf.range(10)  # 0~10, 10 is not included
Out[38]:
<tf.Tensor: id=180, shape=(10,), dtype=int32, numpy=array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])>

创建一个 0 到 10 之间的整数序列,步长为 2:

In [39]: tf.range(10,delta=2) # 10 is not included
Out[39]:
<tf.Tensor: id=185, shape=(5,), dtype=int32, numpy=array([0, 2, 4, 6, 8])>

利用 tf.range(start,limit,delta=1),我们可以在区间[ startlimit]内创建一个整数序列,步长为 delta:

In [40]: tf.range(1,10,delta=2)  # 1~10, 10 is not included
Out[40]:
<tf.Tensor: id=190, shape=(5,), dtype=int32, numpy=array([1, 3, 5, 7, 9])>

4.5 张量的典型应用

在介绍了张量的性质和创建方法之后,下面将介绍张量在各个维度的典型应用,让读者直观地想到它们的主要物理意义和用途,为后续张量的维度变换等一系列抽象运算的学习奠定基础。

这一节不可避免的会提到以后章节要学的网络模型或者算法。你现在不需要完全理解他们,但可以有一个初步的印象。

标量

在 TensorFlow 中,标量是最容易理解的。它是一个维数为 0 的简单数字,形状为[]。标量的典型用途是表示错误值和各种度量,如准确度、精确度和召回率。

考虑模型的训练曲线。如图 4-1 所示,x 轴为训练步数,y 轴为每查询图像误差变化损失(图 4-1 (a))和准确度变化(图 4-1 (b)),其中损失值和准确度为张量计算生成的标量。

img/515226_1_En_4_Fig1_HTML.png

图 4-1

损耗和精度曲线

以均方误差函数为例。tf.keras.losses.mse(或 tf.keras.losses.MSE,同一个函数)返回每个样本的误差值,最后取误差的平均值作为当前批次的误差后,自动变成标量:

In [41]:
out = tf.random.uniform([4,10]) # Create a model output example
y = tf.constant([2,3,2,0]) # Create a real observation
y = tf.one_hot(y, depth=10) # one-hot encoding
loss = tf.keras.losses.mse(y, out) # Calculate MSE for each sample
loss = tf.reduce_mean(loss) # Calculate the mean of MSE
print(loss)

Out[41]:
tf.Tensor(0.19950335, shape=(), dtype=float32)

4.5.2 矢量

向量在神经网络中非常常见。比如在全连接网络和卷积神经网络中,偏置张量 b 用向量来表示。如图 4-2 所示,在每个全连接层的输出节点上加一个偏置值,所有输出节点的偏置用向量形式表示b=b1,b2T:

![img/515226_1_En_4_Fig2_HTML.png

图 4-2

偏置向量的应用

考虑两个输出节点的网络层,我们创建长度为 2 的偏置向量,并在每个输出节点上加回:

In [42]:
# Suppose z is the output of an activation function
z = tf.random.normal([4,2])
b = tf.zeros([2]) # Create a bias vector
z = z + b
Out[42]:
<tf.Tensor: id=245, shape=(4, 2), dtype=float32, numpy=
array([[ 0.6941646 ,  0.4764454 ],
       [-0.34862405, -0.26460952],
       [ 1.5081744 , -0.6493869 ],
       [-0.26224667, -0.78742725]], dtype=float32)>

注意,形状为[4,2]的张量 z 和形状为[2]的向量 b 可以直接相加。这是为什么呢?我们将在稍后的“广播”部分揭示它。

对于通过高级接口类 Dense()创建的网络层,张量 Wb 由类内部自动创建和管理。偏置变量 b 可以通过全连接层的偏置成员访问。例如,如果创建了具有四个输入节点和三个输出节点的线性网络层,则它的偏置向量 b 应该具有长度 3,如下所示:

In [43]:
fc = layers.Dense(3) # Create a dense layer with output length of 3
# Create W and b through build function with input nodes of 4
fc.build(input_shape=(2,4))
fc.bias # Print bias vector
Out[43]:
<tf.Variable 'bias:0' shape=(3,) dtype=float32, numpy=array([0., 0., 0.], dtype=float32)>

可以看出,类的 bias 成员是一个长度为 3 的向量,初始化为全 0。这也是 bias b 的默认初始化方案。此外,偏置向量的类型是可变的,因为对于 Wb 都需要梯度信息。

矩阵

矩阵也是一种非常常见的张量。例如,一个全连通层的批量输入张量 X 的形状为 中的 bd ,其中 b 表示输入样本的个数,即批量大小, 中的 d 表示输入特征的长度。例如,特征长度 4 和包含总共两个样本的输入可以表示为矩阵:

x = tf.random.normal([2,4])  # A tensor with 2 samples and 4 features

设全连通层的输出节点数为三,则它的权张量形状W【4,3】。我们可以使用张量 XW 和向量 b 直接实现一个网络层。代码如下:

In [44]:
w = tf.ones([4,3])
b = tf.zeros([3])
o = x@w+b # @ means matrix multiplication
Out[44]:
<tf.Tensor: id=291, shape=(2, 3), dtype=float32, numpy=
array([[ 2.3506963,  2.3506963,  2.3506963],
       [-1.1724043, -1.1724043, -1.1724043]], dtype=float32)>

在前面的代码中, XW 都是矩阵。前面的代码实现了线性变换网络层,激活函数为空。一般来说,网络层σ(X@W+b)称为全连通层,可以直接用 TensorFlow 中的 Dense()类实现。特别地,当激活函数 σ 为空时,全连通层也称为线性层。我们可以通过 Dense()类创建一个具有四个输入节点和三个输出节点的网络层,并通过全连接层的内核成员查看其权重矩阵 W :

In [45]:
fc = layers.Dense(3) # Create fully-connected layer with 3 output nodes
fc.build(input_shape=(2,4)) # Define the input nodes to be 4
fc.kernel # Check kernel matrix W
Out[45]:
<tf.Variable 'kernel:0' shape=(4, 3) dtype=float32, numpy=
array([[ 0.06468129, -0.5146048 , -0.12036425],
       [ 0.71618867, -0.01442951, -0.5891943 ],
       [-0.03011459,  0.578704  ,  0.7245046 ],
       [ 0.73894167, -0.21171576,  0.4820758 ]], dtype=float32)>

4.5.4 三维张量

三维张量的典型应用是表示序列信号。其格式为

$$ X=\left[b, sequence\ length, feature\ length\right] $$

其中序列信号的个数为 b ,序列长度表示时间维度上采样点或步长的个数,特征长度表示每个点的特征长度。

考虑自然语言处理(NLP)中句子的表示,比如评价一个句子是否是正面情感的情感分类网络,如图 4-3 所示。为了便于神经网络对字符串的处理,一般通过嵌入层将单词编码成固定长度的向量。例如,“a”被编码为长度为 3 的向量。那么两个长度相等的句子(每个句子有五个单词)可以表示为一个形状为[2,5,3]的三维张量,其中 2 代表句子的数量,5 代表单词的数量,3 代表编码后的单词向量的长度。我们演示如何通过 IMDB 数据集来表示句子,如下所示:

In [46]:  # Load IMDB dataset
from tensorflow import keras
(x_train,y_train),(x_test,y_test)=keras.datasets.imdb.load_data(num_words=10000)
# Convert each sentence to length of 80 words
x_train = keras.preprocessing.sequence.pad_sequences(x_train,maxlen=80)
x_train.shape
Out [46]: (25000, 80)

我们可以看到 x_train 的形状是[25000,80],其中 25000 代表句子的数量,80 代表每个句子总共 80 个单词,每个单词用一种数字编码的方式表示。接下来,我们使用层。嵌入函数将每个数字编码字转换为长度为 100 的向量:

In [47]: # Create Embedding layer with 100 output length
embedding=layers.Embedding(10000, 100)
# Convert numeric encoded words to word vectors
out = embedding(x_train)
out.shape
Out[47]: TensorShape([25000, 80, 100])

通过嵌入层,句子张量的形状变成了[25000,80,100],其中 100 表示每个单词都被编码为长度为 100 的向量。

img/515226_1_En_4_Fig3_HTML.png

图 4-3

情感分类网络

对于具有一个特征的序列信号,比如一个产品在 60 天内的价格,只需要一个标量来表示产品价格,那么两个产品的价格变化就可以用一个形状为[2,60]的张量来表示。为了便于格式统一,价格变化也可以表示为形状的张量[2,60,1],其中 1 表示特征长度为 1。

4.5.5 四维张量

大多数时候我们只使用维数小于 5 的张量。对于更高维张量,例如元学习中的五维张量表示,可以应用类似的原理。四维张量广泛应用于卷积神经网络中。它们用于保存要素地图。格式一般定义为

$$ \left[b,h,w,c\right] $$

其中 b 表示输入样本的数量; hw 分别代表特征图的高度和宽度;而 c 是通道数。有些深度学习框架也使用[ bchw ]的格式,比如 PyTorch。影像数据是一种特征地图。具有 RGB 三个通道的彩色图像包含像素的 h 行和 w 列。每个点需要三个值来表示 RGB 通道的颜色强度,因此可以使用形状为[ hw ,3]的张量来表示一幅图片。如图 4-4 所示,上图代表原始图像,包含了三个下通道的亮度信息。

img/515226_1_En_4_Fig4_HTML.jpg

图 4-4

RGB 图像的特征图

在神经网络中,一般会并行计算多个输入以提高计算效率,所以 b 图片的张量可以表示为[ bhw ,3]:

In [48]:
# Create 4 32x32 color images
x = tf.random.normal([4,32,32,3])
# Create convolutional layer
layer = layers.Conv2D(16,kernel_size=3)
out = layer(x)
out.shape
Out[48]: TensorShape([4, 30, 30, 16])

卷积核张量也是一个四维张量,可以通过核成员变量来访问:

In [49]: layer.kernel.shape
Out[49]: TensorShape([3, 3, 3, 16])

4.6 索引和切片

张量数据的一部分可以通过索引和切片操作提取出来,这是非常常用的。

索引

在 TensorFlow 中,支持标准的 Python 索引方式,比如[ i ][ j ]以及逗号和“:”。考虑四张 32 × 32 大小的彩色图片(为方便起见,大部分张量由随机正态分布产生,下同)。相应的张量具有如下形状[4,32,32,3]:

x = tf.random.normal([4,32,32,3])

接下来,我们使用索引方法从张量中读取部分数据。

  • 读取第一图像数据:

  • 阅读第一幅图的第二行:

x = tf.random.normal ([4,32,32,3]) # Create a 4D tensor
In [51]: x[0]  # Index 0 indicates the 1st element in Python
Out[51]:<tf.Tensor: id=379, shape=(32, 32, 3), dtype=float32, numpy=
array([[[ 1.3005302 ,  1.5301839 , -0.32005513],
        [-1.3020388 ,  1.7837263 , -1.0747638 ], ...
        [-1.1092019 , -1.045254  , -0.4980363 ],
        [-0.9099222 ,  0.3947732 , -0.10433522]]], dtype=float32)>

  • 阅读第一幅图片的第二行第三列:
In [52]: x[0][1]
Out[52]:
<tf.Tensor: id=388, shape=(32, 3), dtype=float32, numpy=
array([[ 4.2904025e-01,  1.0574218e+00,  3.1540772e-01],
       [ 1.5800388e+00, -8.1637271e-02,  6.3147342e-01], ...,
       [ 2.8893018e-01,  5.8003378e-01, -1.1444757e+00],
       [ 9.6100050e-01, -1.0985689e+00,  1.0827581e+00]], dtype=float32)>

  • 选择第三张图片的第二行、第一列和第二(B)通道:
In [53]: x[0][1][2]
Out[53]:
<tf.Tensor: id=401, shape=(3,), dtype=float32, numpy=array([-0.55954427,  0.14497331,  0.46424514], dtype=float32)>

In [54]: x[2][1][0][1]
Out[54]:
<tf.Tensor: id=418, shape=(), dtype=float32, numpy=-0.84922135>

当维数较大时,使用[ i ][ j 的方式...【 k 不方便。相反,我们可以使用[ ij ,..., k 进行分度。它们是等价的。

  • 阅读第二幅图片的第十行第三列:
In [55]: x[1,9,2]
Out[55]:
<tf.Tensor: id=436, shape=(3,), dtype=float32, numpy=array([ 1.7487534 , -0.41491988, -0.2944692 ], dtype=float32)>

切片

使用格式start:end:step可以很容易地提取一段数据,其中 start 是起始位置的索引,end 是结束位置的索引(不包括),step 是采样步长。

以形状为[4,32,32,3]的图像张量为例,我们将说明如何使用切片来获得不同位置的数据。例如,如下阅读第二幅和第三幅图片:

In [56]: x[1:3]
Out[56]:
<tf.Tensor: id=441, shape=(2, 32, 32, 3), dtype=float32, numpy=
array([[[[ 0.6920027 ,  0.18658352,  0.0568333 ],
         [ 0.31422952,  0.75933754,  0.26853144],
         [ 2.7898    , -0.4284912 , -0.26247284],...

开始 : 结束 : 步骤刀法有很多缩写。可以根据需要有选择地省略开始、结束和步进参数。当像::,都被省略时,表示读取是从开始到结束,步长为 1。比如 x [0,:]表示读取第一张图片的所有行,其中::表示行维度的所有行,相当于 x [0]:

In [57]: x[0,::]      # Read 1st picture
Out[57]:
<tf.Tensor: id=446, shape=(32, 32, 3), dtype=float32, numpy=
array([[[ 1.3005302 ,  1.5301839 , -0.32005513],
        [-1.3020388 ,  1.7837263 , -1.0747638 ],
        [-1.1230233 , -0.35004002,  0.01514002],
        ...

为简洁起见,::可以缩写为单个冒号:,例如:

In [58]: x[:,0:28:2,0:28:2,:]
Out[58]:
<tf.Tensor: id=451, shape=(4, 14, 14, 3), dtype=float32, numpy=
array([[[[ 1.3005302 ,  1.5301839 , -0.32005513],
         [-1.1230233 , -0.35004002,  0.01514002],
         [ 1.3474811 ,  0.639334  , -1.0826371 ],
         ...

前面的代码表示读取所有图片,隔行采样,读取所有通道数据,相当于缩放图片原来高度和宽度的 50%。

我们来总结一下不同的切片方式,从第一个元素开始读取时可以省略“start”,即取最后一个元素时可以省略“start = 0”,取最后一个元素时可以省略“end”,步长为 1 时可以省略“step”。详情汇总在表 4-1 中。

表 4-1

切片方法概述

|

方法

|

意义

|
| --- | --- |
| 开始:结束:步骤 | 从“开始”读到“结束”(不包括),步长为“步长” |
| 出发 | 从“开始”读到“结束”(不含),步长为 1。 |
| 开始: | 以步长 1 从“开始”读到对象的结尾。 |
| 开始::步骤 | 以“步长”从“起点”读取到对象的终点 |
| :结束:步骤 | 从第 0 项读到“end”(不含),步长为“step” |
| :结束 | 从第 0 项读到“end”(不含),步长为 1。 |
| *步骤 | 从第 0 项读取到最后一项,步长为“step” |
| :: | 阅读所有项目。 |
| : | 阅读所有项目。 |

特别地,步长可以是负的。例如,start:end:—1 表示从“start”开始,逆序读取,以“end”结束(不含),索引“end”小于“start”考虑一个从 0 到 9 的简单序列向量,以相反的顺序取第一个元素,不包括第一个元素:

In [59]: x = tf.range(9)  # Create the vector
x[8:0:-1]  # Reverse slicing
Out[59]:
<tf.Tensor: id=466, shape=(8,), dtype=int32, numpy=array([8, 7, 6, 5, 4, 3, 2, 1])>

按如下相反顺序提取所有元素:

In [60]: x[::-1]
Out[60]:
<tf.Tensor: id=471, shape=(9,), dtype=int32, numpy=array([8, 7, 6, 5, 4, 3, 2, 1, 0])>

每两个项目反向采样的实现方式如下:

In [61]: x[::-2]
Out[61]:
<tf.Tensor: id=476, shape=(5,), dtype=int32, numpy=array([8, 6, 4, 2, 0])>

读取每个图像的所有通道,其中行和列以相反的顺序每两个元素采样一次。实现如下:

In [62]: x = tf.random.normal([4,32,32,3])
x[0,::-2,::-2]
Out[62]:
<tf.Tensor: id=487, shape=(16, 16, 3), dtype=float32, numpy=
array([[[ 0.63320625,  0.0655185 ,  0.19056146],
        [-1.0078577 , -0.61400175,  0.61183935],
        [ 0.9230892 , -0.6860094 , -0.01580668],
        ...

当张量维数较大时,不需要采样的维数一般用单冒号“:”表示所有元素都被选中。这样一来,可能会出现很多“:”。考虑形状为[4,32,32,3]的图像张量。当需要读取绿色通道上的数据时,前面的所有维度都被提取为

In [63]: x[:,:,:,1]  # Read data on Green channel
Out[63]:
<tf.Tensor: id=492, shape=(4, 32, 32), dtype=float32, numpy=
array([[[ 0.575703  ,  0.11028383, -0.9950867 , ...,  0.38083118, -0.11705163, -0.13746642],
        ...

为了避免出现像 x [:,:,:,1]冒号太多的情况,我们可以使用符号“⋯来取多维度的所有数据,其中维度的个数需要根据规则自动推断:当符号⋯以切片方式出现时,“⋯”左边的维度会自动向最左边对齐。符号“⋯”右侧的尺寸将自动与最右侧对齐。系统将自动推断由符号“⋯".”表示的维数详情汇总在表 4-2 中。

表 4-2

"..."切片方法摘要

|

方法

|

意义

|
| --- | --- |
| a, ,b | 为维度 a 选择 0 到 a,为维度 b 选择 b 到结束,为其他维度选择所有元素。 |
| 一、 | 为维度 a 选择 0 到 a,为其他维度选择所有元素。 |
| ,b | 选择 b 以结束维 b 和其他维的所有元素。 |
| | 读取所有元素。 |

我们列举更多的例子如下:

  • 读取第一和第二图片的绿色和蓝色通道数据:

  • 阅读最后两张图片:

In [64]: x[0:2,...,1:]
Out[64]:
<tf.Tensor: id=497, shape=(2, 32, 32, 2), dtype=float32, numpy=
array([[[[ 0.575703  ,  0.8872789 ],
         [ 0.11028383, -0.27128693],
         [-0.9950867 , -1.7737272 ],
         ...

  • 读取红色和绿色通道数据:
In [65]: x[2:,...]  # equivalent to x[2:]
Out[65]:
<tf.Tensor: id=502, shape=(2, 32, 32, 3), dtype=float32, numpy=
array([[[[-8.10753584e-01,  1.10984087e+00,  2.71821529e-01],
         [-6.10031188e-01, -6.47952318e-01, -4.07003373e-01],
         [ 4.62206364e-01, -1.03655539e-01, -1.18086267e+00],
         ...

In [66]: x[...,:2]
Out[66]:
<tf.Tensor: id=507, shape=(4, 32, 32, 2), dtype=float32, numpy=
array([[[[-1.26881   ,  0.575703  ],
         [ 0.98697686,  0.11028383],
         [-0.66420585, -0.9950867 ],
         ...

切片总结

张量索引和切片方法多种多样,尤其是切片操作,初学者很容易混淆。本质上,切片操作只有这个基本形式开始 : 结束 : 步骤。通过这种基本形式,有目的地省略一些默认参数,并派生出多个缩写方法。所以写起来更容易更快。由于深度学习一般处理的维数在四维以内,所以你会发现张量切片运算在深度学习中并没有那么复杂。

4.7 维度转换

在神经网络中,维度变换是最核心的张量运算。通过维度变换,数据可以任意切换,满足不同情况的计算需求。考虑线性图层的批处理形式:

$$ Y=X@W+b $$

假设两个样本,每个样本的特征长度为 4,包含在 X 中,形状为【2,4】。线性层的输出节点数为三,即 W 的形状为【4,3】,定义 b 的形状为【3】。那么 X @ W 的结果具有[2,3]的形状。注意,我们还需要添加形状为[3]的 b 。如何将两个不同形状的张量直接相加?

回想一下,我们要做的是给每层的每个输出节点增加一个偏置。这种偏差由每个节点的所有样本共享。换句话说,每个样本应该在每个节点增加相同的偏置,如图 4-5 所示。

img/515226_1_En_4_Fig5_HTML.png

图 4-5

线性层的偏差

因此,对于两个样本的输入 X ,我们需要复制偏倚

$$ b=\left[{b}_1\ {b}_2\ {b}_3\ \right] $$

将样本数转化为如下矩阵形式

$$ {B}^{\prime }=\left[{b}_1\ {b}_2\ {b}_3\ {b}_1\ {b}_2\ {b}_3\ \right] $$

然后加上X=X@W

$$ {X}^{\prime }=\left[{x}_{11}^{\prime }\ {x}_{12}^{\prime }\ {x}_{13}^{\prime }\ {x}_{21}^{\prime }\ {x}_{22}^{\prime }\ {x}_{23}^{\prime }\ \right] $$

因为此时它们的形状相同,这就满足了矩阵加法的要求:

$$ Y={X}^{\prime }+{B}^{\prime }=\left[{x}_{11}^{\prime }\ {x}_{12}^{\prime }\ {x}_{13}^{\prime }\ {x}_{21}^{\prime }\ {x}_{22}^{\prime }\ {x}_{23}^{\prime }\ \right]+\left[{b}_1\ {b}_2\ {b}_3\ {b}_1\ {b}_2\ {b}_3\ \right] $$

这样既满足了矩阵加法需要形状一致的要求,又实现了每个输入样本的输出节点共享偏置向量的逻辑。为了实现这一点,我们向偏置向量 b 插入一个新的维度 batch,然后复制 batch 维度中的数据,以获得形状为[2,3]的转换版本B。这一系列的操作称为维度变换。

每种算法对张量格式都有不同的逻辑要求。当现有的张量格式不满足算法要求时,需要通过量纲变换将张量调整到正确的格式。基本维度转换包括诸如改变视图(reshape())、插入新维度(expand_dims())、删除维度(squeeze())和交换维度(transpose())之类的功能。

重塑

在介绍整形操作之前,我们先来了解一下张量存储和视图的概念。张量的观点就是我们理解张量的方式。例如,形状[2,4,4,3]的张量在逻辑上被理解为两个图片,每个图片具有四行和四列,并且每个像素具有三个通道的 RGB 数据。张量的存储体现在张量在内存中是作为一个连续的区域存储的。对于同一个存储,我们可以有不同的看法。对于[2,4,4,3]张量,我们可以把它看作两个样本,每个样本的特征是一个长度为 48 的向量。同一个张量可以产生不同的视图。这就是存储和视图的关系。视图生成非常灵活,但需要合理。

我们可以通过 tf.range()生成一个向量,通过 tf.reshape()函数生成不同的视图,例如:

In [67]: x=tf.range(96)
x=tf.reshape(x,[2,4,4,3])  # Change view to [2,4,4,3] without change storage
Out[67]:  # Data is not changed, only view is changed.
<tf.Tensor: id=11, shape=(2, 4, 4, 3), dtype=int32, numpy=
array([[[[ 0,  1,  2],
         [ 3,  4,  5],
         [ 6,  7,  8],
         [ 9, 10, 11]],...

存储数据时,内存不支持这种维度层次概念,数据只能以分块和顺序的方式写入内存。所以这种层次关系需要人工管理,即需要人工跟踪每个张量的存储顺序。为了便于表达,我们将张量形状列表左侧的维度称为大维度,将形状列表右侧的维度称为小维度。例如,在形状为[2,4,4,3]的张量中,图像 2 的数量被称为大维度,通道 3 的数量被称为小维度。在小维度优先写入的优先级设置下,前一个张量 x 的内存布局为

| one | Two | three | four | five | six | seven | eight | nine | ... | ... | ... | Ninety-three | Ninety-four | Ninety-five |

改变张量的观点只会改变理解张量的方式。它不会改变存储顺序。因为写入大量数据会消耗更多的计算资源,所以这样做是为了提高计算效率。由于数据在存储时只有扁平化的结构,与逻辑结构是分离的,新的逻辑结构(视图)不需要改变数据存储方式,可以节省大量的计算资源。改变视图操作在提供便利的同时,也带来了很多逻辑上的危险。改变视图操作的默认前提是存储不变;否则,更改视图操作是非法的。我们首先介绍合法的视图转换操作,然后介绍一些非法的视图转换。

比如张量 A 按照 bhwc 的初始视图写入内存。如果我们改变理解方式,它可以有以下格式:

  • 张量 bhwchw 像素和 c 通道表示 b 图片。

  • 张量[ bhwch 线条表示 b 图片,每条线的特征长度为 wc

  • 张量[ bhwc 代表 b 图片,每张图片的特征长度为hwc

前面视图的存储不需要改变,所以都是正确的。

从语法上来说,视图转换只需要确保新视图的元素总数和存储区域的大小相等,即新视图的元素数等于

![$$ b\bullet h\bullet w\bullet c $$

正是因为视图设计的语法约束很少,完全由用户定义,所以在改变视图时容易出现逻辑风险。

现在让我们考虑非法的视图转换。例如,如果新视图被定义为[ bwhcbchw ,或者[ bchw如果存储顺序没有同步更新,恢复的数据会与新视图不一致,造成数据混乱。这就需要用户了解数据,才能确定操作是否合法。我们将在“交换维度”一节中展示如何改变张量的存储。**

正确使用视图转换操作的一种技术是跟踪存储维度的顺序。比如初始视图中保存的“图片数-行-列-通道”的张量,存储也是按照“图片数-行-列-通道”的顺序写的。如果用“图片数-像素-通道”的方法恢复视图,与“图片数-行-列-通道”不冲突,所以可以得到正确的数据。但如果用“图片数-通道数-像素”的方法恢复数据,由于内存布局是按照“图片数-行-列-通道数”的顺序,视图维的顺序与存储维的顺序不一致,导致数据杂乱。

改变视图是神经网络中非常常见的操作。您可以通过串联多个整形操作来实现复杂的逻辑。但是,当通过 reshape 更改视图时,您必须始终记住张量的存储顺序。新视图的维度顺序必须与存储顺序相同。否则,您需要通过交换维度操作来同步存储顺序。例如,对于具有形状[4,32,32,3]的图像数据,可以通过整形操作将形状调整为[4,1024,3]。视图的维度顺序为b像素c,张量的存储顺序为[ bhwc ]。形状为[4,1024,3]的张量可以恢复为以下形式:

  • 当[ bhwc ] = [4,32,32,3]时,新视图的维度顺序和存储顺序一致,可以恢复数据,不会出现紊乱。

  • 当[ bwhc ] = [4,32,32,3]时,新视图的维度顺序与存储顺序冲突。

  • 当[hwcb ] = [3072,4]时,新视图的维度顺序与存储顺序冲突。

在 TensorFlow 中,我们可以通过张量的 ndim 和 shape 属性获得张量的维数和形状:

In [68]: x.ndim,x.shape # Get the tensor's dimension and shape
Out[68]:(4, TensorShape([2, 4, 4, 3]))

使用 TF . shape(x,new_shape),我们可以合法地任意改变张量的视图,例如:

In [69]: tf.reshape(x,[2,-1])
Out[69]:<tf.Tensor: id=520, shape=(2, 48), dtype=int32, numpy=
array([[ 0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12, 13, 14, 15,
         16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31,...
         80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95]])>

参数–1 表示当前轴上的长度需要根据张量的总元素不变的规则自动导出。例如,前面的–1 可以推导为

$$ \frac{2\bullet 4\bullet 4\bullet 3}{2}=48 $$

将数据视图再次更改为[2,4,12],如下所示:

In [70]: tf.reshape(x,[2,4,12])
Out[70]:<tf.Tensor: id=523, shape=(2, 4, 12), dtype=int32, numpy=
array([[[ 0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11],...
        [36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47]],
       [[48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59], ...
        [84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95]]])>

将数据视图再次更改为[2,16,3],如下所示:

In [71]: tf.reshape(x,[2,-1,3])
Out[71]:<tf.Tensor: id=526, shape=(2, 16, 3), dtype=int32, numpy=
array([[[ 0,  1,  2], ...
        [45, 46, 47]],
       [[48, 49, 50],...
        [93, 94, 95]]])>

通过前面一系列连续的视图变换操作,我们需要知道张量的存储顺序没有改变,数据仍然按照 0,1,2,⋯,95 的初始顺序存储在内存中。

4.7.2 添加和删除尺寸

增加一个维度。添加一个长度为 1 的维度相当于在原始数据中添加一个新维度的概念。维度长度为 1,所以不需要改变数据;这只是观点的改变。

考虑一个具体的例子。大灰度图像的数据保存为 28 × 28 形状的张量。最后,一个新的维度被添加到张量中,它被定义为通道的数量。那么张量的形状变成[28,28,1]如下:

In [72]:  # Generate a 28x28 matrix
x = tf.random.uniform([28,28],maxval=10,dtype=tf.int32)
Out[72]:
<tf.Tensor: id=11, shape=(28, 28), dtype=int32, numpy=
array([[6, 2, 0, 0, 6, 7, 3, 3, 6, 2, 6, 2, 9, 3, 0, 3, 2, 8, 1, 3, 6, 2, 3, 9, 3, 6, 1, 7],...

使用 tf.expand_dims (x,axis),我们可以在指定的轴之前插入一个新的维度:

In [73]:  x = tf.expand_dims(x,axis=2)
Out[73]:
<tf.Tensor: id=13, shape=(28, 28, 1), dtype=int32, numpy=
array([[[6],
        [2],
        [0],
        [0],
        [6],
        [7],
        [3],...

可以看到,插入新维度后,数据的存储顺序并没有改变。插入新维度后,只有数据视图会发生变化。

同样,我们可以在前面插入一个新的维度,表示长度为 1 的图像数维度。这时张量的形状变成[1,28,28,1]:

In [74]: x = tf.expand_dims(x,axis=0)  # Insert a dimension at the beginning
Out[74]:
<tf.Tensor: id=15, shape=(1, 28, 28, 1), dtype=int32, numpy=
array([[[[6],
         [2],
         [0],
         [0],
         [6],
         [7],
         [3],...

注意,当 tf.expand_dims 的轴为正时,意味着在当前维度之前插入了一个新维度;当它为负值时,表示在当前尺寸之后插入了一个新尺寸。以张量 bhwc 为例,不同轴参数的实际插入位置如图 4-6 所示。

img/515226_1_En_4_Fig6_HTML.png

图 4-6

不同轴参数的插入位置

删除一个尺寸。删除维度是添加维度的反向操作。与添加维度一样,删除维度只能删除长度为 1 的维度,并且不会改变张量的存储顺序。继续考虑形状[1,28,28,1]的例子。如果我们想删除图片维数,我们可以使用 tf.squeeze (x,axis)函数。axis 参数是要删除的维度的索引号:

In [75]: x = tf.squeeze(x, axis=0)  # Delete the image number dimension
Out[75]:
<tf.Tensor: id=586, shape=(28, 28, 1), dtype=int32, numpy=
array([[[8],
        [2],
        [2],
        [0],...

继续删除频道号维度。由于删除了图像数维,此时 x 的形状为[28,28,1]。删除通道号维度时,我们应该指定 axis = 2,如下所示:

In [76]: x = tf.squeeze(x, axis=2)  # Delete channel dimension
Out[76]:
<tf.Tensor: id=588, shape=(28, 28), dtype=int32, numpy=
array([[8, 2, 2, 0, 7, 0, 1, 4, 9, 1, 7, 4, 8, 2, 7, 4, 8, 2, 9, 8, 8, 0, 9, 9, 7, 5, 9, 7],
       [3, 4, 9, 9, 0, 6, 5, 7, 1, 9, 9, 1, 2, 7, 2, 7, 5, 3, 3, 7, 2, 4, 5, 2, 7, 3, 8, 0],...

如果我们不指定尺寸参数 axis,即 tf.squeeze(x),它将默认删除所有长度为 1 的尺寸,例如:

In [77]:
x = tf.random.uniform([1,28,28,1],maxval=10,dtype=tf.int32)
tf.squeeze(x)     # Delete all dimensions with length 1
Out[77]:
<tf.Tensor: id=594, shape=(28, 28), dtype=int32, numpy=
array([[9, 1, 4, 6, 4, 9, 0, 0, 1, 4, 0, 8, 5, 2, 5, 0, 0, 8, 9, 4, 5, 0, 1, 1, 4, 3, 9, 9],...

建议逐个指定要删除的维度参数,以防止 TensorFlow 意外删除某些长度为 1 的维度,导致计算结果无效。

交换尺寸

改变视图或添加或删除维度不会影响张量的存储。有时,仅仅改变对张量的理解而不改变量纲的顺序是不够的。也就是需要直接调整存储顺序。通过交换维度,张量的存储顺序和视图都发生了变化。

交换维度操作非常常见。例如,一个图像张量在 TensorFlow 中默认的存储格式是[ bhwc 格式,但是有些库的图像格式是[ bchw 格式。我们以[ bhwc 到[ bchw 的转换为例,介绍如何使用 tf.transpose(x,perm)函数完成维度交换操作,其中参数 perm 代表新维度的顺序。考虑形状为[2,32,32,3]的图像张量,“图片数,行数,列数,通道数”的维数指标分别为 0,1,2,3。如果新维度的顺序是“图片数、通道数、行数、列数”,那么对应的索引号就变成了[0,3,1,2],所以需要将参数 perm 设置为[0,3,1,2]。实现如下:

In [78]: x = tf.random.normal([2,32,32,3])
tf.transpose(x,perm=[0,3,1,2])  # Swap dimension
Out[78]:
<tf.Tensor: id=603, shape=(2, 3, 32, 32), dtype=float32, numpy=
array([[[[-1.93072677e+00, -4.80163872e-01, -8.85614634e-01, ...,
           1.49124235e-01,  1.16427064e+00, -1.47740364e+00],
         [-1.94761145e+00,  7.26879001e-01, -4.41877693e-01, ...

如果我们要将[ bhwc 改为[ bwhc ],即交换高度和宽度尺寸,则新的尺寸索引变为[0,2,1,3],如下所示:

In [79]:
x = tf.random.normal([2,32,32,3])
tf.transpose(x,perm=[0,2,1,3]) # Swap dimension
Out[79]:
<tf.Tensor: id=612, shape=(2, 32, 32, 3), dtype=float32, numpy=
array([[[[ 2.1266546 , -0.64206547,  0.01311932],
         [ 0.918484  ,  0.9528751 ,  1.1346699 ],
         ...,

需要注意的是,通过 tf.transpose 完成维度交换后,张量的存储顺序发生了变化,视图也相应发生了变化。所有后续操作都必须基于新的订单和视图。与改变视图操作相比,维度交换操作的计算开销更大。

复制数据

插入新维度后,我们可能希望复制新维度上的数据,以满足后续计算的要求。考虑例子Y=X@W+b。在插入一个样本数为 b 的新维度后,我们需要复制新维度中的批量数据,并将 b 的形状改为与 X @ W 一致,以完成张量加法运算。

我们可以使用 tf.tile(x,倍数)函数来完成指定维度的数据复制操作。参数 multiples 分别指定每个维度的复制编号。例如,1 表示不会复制数据,2 表示新长度是原始长度的两倍。

以输入[2,4]和三输出节点线性变换层为例,偏差 b 定义为

$$ b=\left[{b}_1\ {b}_2\ {b}_3\ \right] $$

通过 tf.expand_dims(b,axis = 0)插入一个新的维度,变成一个矩阵:

$$ B=\left[{b}_1\ {b}_2\ {b}_3\ \right] $$

现在 B 的形状变成了【1,3】。我们需要根据输入样本的数量在轴= 0 的维度上复制数据。这里的批量是 2,也就是做了一份拷贝就变成了

$$ B=\left[{b}_1\ {b}_2\ {b}_3\ {b}_1\ {b}_2\ {b}_3\ \right] $$

通过 tf.tile(b,倍数= [2,1]),可以在 axis = 0 维复制一次,在 axis = 1 维不复制。首先,插入一个新维度如下:

In [80]:
b = tf.constant([1,2])  # Create tensor b
b = tf.expand_dims(b, axis=0)  # Insert new dimension
b
Out[80]:
<tf.Tensor: id=645, shape=(1, 2), dtype=int32, numpy=array([[1, 2]])>

复制批处理维度中数据的一个副本,以实现以下目的:

In [81]: b = tf.tile(b, multiples=[2,1])
Out[81]:
<tf.Tensor: id=648, shape=(2, 2), dtype=int32, numpy=
array([[1, 2],
       [1, 2]])>

现在 B 的形状变成了【2,3】, B 可以直接加到 X @ W 上。考虑另一个 2×2 矩阵的例子。实现如下:

In [82]: x = tf.range(4)
x=tf.reshape(x,[2,2])  # Create 2x2 matrix
Out[82]:
<tf.Tensor: id=655, shape=(2, 2), dtype=int32, numpy=
array([[0, 1],
       [2, 3]])>

首先,复制列维度中数据的一个副本,如下所示:

In [83]: x = tf.tile(x,multiples=[1,2])
Out[83]:
<tf.Tensor: id=658, shape=(2, 4), dtype=int32, numpy=
array([[0, 1, 0, 1],
       [2, 3, 2, 3]])>

然后复制行维度中数据的一个副本:

In [84]: x = tf.tile(x,multiples=[2,1])
Out[84]:
<tf.Tensor: id=672, shape=(4, 4), dtype=int32, numpy=
array([[0, 1, 0, 1],
       [2, 3, 2, 3],
       [0, 1, 0, 1],
       [2, 3, 2, 3]])>

在二维复制操作之后,我们可以看到数据的形状已经翻倍。这个例子帮助我们更直观地理解数据复制的过程。

需要注意的是,tf.tile 会创建一个新的张量来保存复制的张量。由于复制操作涉及大量的数据读写操作,计算成本相对较高。神经网络中不同形状之间的张量运算很常见,那么有没有轻量级的复制运算呢?这就是接下来要介绍的广播操作。

4.8 广播

广播是一种轻量级的张量复制方法,它在逻辑上扩展了张量数据的形状,但只在需要时执行实际的存储复制操作。对于大多数场景,广播机制可以通过避免实际的数据复制来完成逻辑运算,从而与 tf.tile 函数相比减少了大量的计算开销。

对于长度为 1 的所有维度,广播的效果与 tf.tile 相同,不同的是 tf.tile 通过执行 copy IO 操作创建了一个新的张量。广播不会立即复制数据;相反,它会在逻辑上改变张量的形状,使视图成为复制的形状。广播将使用深度学习框架的优化方法来避免数据的实际复制,并完成逻辑运算。对于用户来说,广播和 tf.tile 复制的最终效果是一样的,但是广播机制节省了大量的计算资源。建议在计算过程中尽量使用广播,提高效率。

继续考虑前面的例子Y=X@W+bX @ W 的形状为【2,3】, b 的形状为【3】。我们可以通过组合 tf.expand_dims 和 tf.tile 来手动完成复制数据的操作,即把 b 变换成 shape【2,3】然后加到 X @ W 。但其实用 shape [3]直接把 X @ W 加到 b 上也是正确的,比如:

x = tf.random.normal([2,4])
w = tf.random.normal([4,3])
b = tf.random.normal([3])
y = x@w+b # Add tensors with different shapes directly

前面的加法运算不会引发逻辑错误。这是因为它自动调用广播函数 tf.broadcast_to(x,new_shape),将 b 的形状展开为【2,3】。前面的操作等效于

y = x@w + tf.broadcast_to(b,[2,3])

换句话说,当运算符+遇到两个形状不一致的张量时,它会自动考虑将这两个张量展开成一致的形状,然后调用 tf.add 来完成张量加法运算。通过自动调用 tf.broadcast_to(b,[2,3]),既达到了增加维度的目的又避免了实际复制数据的昂贵计算成本。

广播机制的核心思想是普遍性。也就是说,相同的数据通常适用于其他位置。在验证普适性之前,我们需要先将张量形状向右对齐,然后进行普适性检查:对于长度为 1 的维度,默认情况下这个数据一般适用于当前维度中的其他位置;对于不存在的维度,增加一个新维度后,默认的当前数据也普遍适用于新维度,从而可以展开成任意维数的张量形状。

考虑到张量 A 具有形状[ w ,1],需要扩展到形状[ bhwc ]。如图 4-7 所示,第一行为展开形状,第二行为现有形状。

img/515226_1_En_4_Fig7_HTML.png

图 4-7

广播示例 1

首先,将两个形状向右对齐。对于通道维度 c ,张量的当前长度为 1。默认情况下,该数据也适用于当前维度中的其他位置。数据被逻辑复制,长度变成 c;对于不存在的维度 bh ,自动插入一个新的维度,新维度的长度为 1,同时当前数据一般适用于新维度中的其他位置,即对于其他图片和其他行,与当前行的数据完全一致。这将自动扩展相应的尺寸到 bh ,如图 4-8 所示。

img/515226_1_En_4_Fig8_HTML.png

图 4-8

广播示例 2

tf.broadcast_to(x,new_shape)函数可用于显式执行自动扩展功能,将现有形状扩展为 new_shape。实现如下:

In [87]:
A = tf.random.normal([32,1])  # Create a matrix
tf.broadcast_to(A, [2,32,32,3])  # Expand to 4 dimensions
Out[87]:
<tf.Tensor: id=13, shape=(2, 32, 32, 3), dtype=float32, numpy=
array([[[[-1.7571245 , -1.7571245 , -1.7571245 ],
         [ 1.580159  ,  1.580159  ,  1.580159  ],
         [-1.5324328 , -1.5324328 , -1.5324328 ],...

可以看出,在普遍性原则的指导下,广播机制变得直观易懂。

让我们考虑一个不满足普遍性原则的例子,如图 4-9 所示。

img/515226_1_En_4_Fig9_HTML.png

图 4-9

传播坏榜样

c 维中,张量已经有两个特征,新形状对应维的长度为 c ( c ≠ 2,如 c = 3)。那么当前维度中的这两个特征就不能普遍适用于其他位置,所以不符合普遍性原则。如果我们应用广播,它将触发错误,例如

In [88]:
A = tf.random.normal([32,2])
tf.broadcast_to(A, [2,32,32,4])
Out[88]:
InvalidArgumentError: Incompatible shapes: [32,2] vs. [2,32,32,4] [Op:BroadcastTo]

在进行张量运算时,有些运算会在处理不同形状的张量时自动调用广播机制,比如+、-、*、和/,将对应的张量广播成一个共同的形状,然后做相应的计算。图 4-10 展示了三种不同形状的张量加法的一些例子。

img/515226_1_En_4_Fig10_HTML.jpg

图 4-10

自动广播示例

我们来测试一下基础运营商的自动播报机制,比如:

a = tf.random.normal([2,32,32,1])
b = tf.random.normal([32,32])
a+b,a-b,a*b,a/b # Test automatic broadcasting for operations +, -, *, and /

这些操作可以在实际计算之前广播成一个公共形状。使用广播机制可以使代码更加简洁高效。

4.9 数学运算

在前几章中,我们已经使用了一些基本的数学运算,如加、减、乘、除。本节将系统介绍 TensorFlow 中常见的数学运算。

4.9.1 加减乘除

加减乘除是最基本的数学运算。它们分别由 TensorFlow 中的 tf.add、tf.subtract、tf.multiply 和 tf.divide 函数实现。TensorFlow 具有重载运算符+、-、和/。一般建议直接使用那些运算符。底数除法和余数除法是另外两种常见的运算,分别由//和%运算符实现。让我们演示一下除法运算,例如:

In [89]:
a = tf.range(5)
b = tf.constant(2)
a//b # Floor dividing
Out[89]:
<tf.Tensor: id=115, shape=(5,), dtype=int32, numpy=array([0, 0, 1, 1, 2])>
In [90]: a%b # Remainder dividing
Out[90]:
<tf.Tensor: id=117, shape=(5,), dtype=int32, numpy=array([0, 1, 0, 1, 0])>

电源操作

通过 tf.pow(x,a)函数,或者运算符** as x**a,可以方便地完成幂运算:

In [91]:
x = tf.range(4)
tf.pow(x,3)
Out[91]:
<tf.Tensor: id=124, shape=(4,), dtype=int32, numpy=array([ 0,  1,  8, 27])>
In [92]: x**2
Out[92]:
<tf.Tensor: id=127, shape=(4,), dtype=int32, numpy=array([0, 1, 4, 9])>

将指数设置为$$ \frac{1}{a} $$的形式来实现根运算$$ \sqrt[a]{x} $$,例如:

In [93]: x=tf.constant([1.,4.,9.])
x**(0.5)  # square root
Out[93]:
<tf.Tensor: id=139, shape=(3,), dtype=float32, numpy=array([1., 2., 3.], dtype=float32)>

特别是对于常见的平方和平方根运算,可以使用 tf.square(x)和 tf.sqrt(x)。平方运算的实现如下:

In [94]:x = tf.range(5)
x = tf.cast(x, dtype=tf.float32)  # convert to float type
x = tf.square(x)
Out[94]:
<tf.Tensor: id=159, shape=(5,), dtype=float32, numpy=array([ 0.,  1.,  4.,  9., 16.], dtype=float32)>

平方根运算实现如下:

In [95]:tf.sqrt(x)
Out[95]:
<tf.Tensor: id=161, shape=(5,), dtype=float32, numpy=array([0., 1., 2., 3., 4.], dtype=float32)>

指数和对数运算

使用 tf.pow(a,x)或**运算符也可以轻松实现指数运算,例如:

In [96]: x = tf.constant([1.,2.,3.])
2**x
Out[96]:
<tf.Tensor: id=179, shape=(3,), dtype=float32, numpy=array([2., 4., 8.], dtype=float32)>

具体来说,对于自然指数 e x ,这可以用 tf.exp(x)来实现,例如:

In [97]: tf.exp(1.)
Out[97]:
<tf.Tensor: id=182, shape=(), dtype=float32, numpy=2.7182817>

在 TensorFlow 中,自然对数 x 可以用 tf.math.log(x)实现,例如:

In [98]: x=tf.exp(3.)
tf.math.log(x)
Out[98]:
<tf.Tensor: id=186, shape=(), dtype=float32, numpy=3.0>

如果要计算其他底数的对数,可以使用对数换底公式:

$$ x=\frac{x\ }{a\ } $$

例如,$$ \frac{x\ }{10\ } $$的计算可以通过下式实现

In [99]: x = tf.constant([1.,2.])
x = 10**x
tf.math.log(x)/tf.math.log(10.)
Out[99]:
<tf.Tensor: id=6, shape=(2,), dtype=float32, numpy=array([1., 2.], dtype=float32)>

矩阵乘法

神经网络包含大量矩阵乘法运算。我们之前介绍过,矩阵乘法可以通过@运算符和 tf.matmul(a,b)函数轻松实现。需要注意的是,TensorFlow 中的矩阵乘法可以使用批处理方式,即张量 AB 可以有大于 2 的维数。当维度大于 2 时,TensorFlow 选择 AB 的最后两个维度进行矩阵乘法,前面的维度全部视为批量维度。

根据矩阵乘法的定义, A 能乘一个矩阵 B 的条件是 A 的倒数第二个维度(列)的长度和 B 的倒数第二个维度(行)的长度必须相等。例如,形状为[4,3,28,32]的张量 a 可以乘以形状为[4,3,32,2]的张量 b。代码如下:

In [100]:
a = tf.random.normal([4,3,28,32])
b = tf.random.normal([4,3,32,2])
a@b
Out[100]:
<tf.Tensor: id=236, shape=(4, 3, 28, 2), dtype=float32, numpy=
array([[[[-1.66706240e+00, -8.32602978e+00],
         [ 9.83304405e+00,  8.15909767e+00],
         [ 6.31014729e+00,  9.26124632e-01],...

矩阵乘法也支持自动广播机制,例如:

In [101]:
a = tf.random.normal([4,28,32])
b = tf.random.normal([32,16])
tf.matmul(a,b)  # First broadcast b to shape [4, 32, 16] and then multiply a
Out[101]:
<tf.Tensor: id=264, shape=(4, 28, 16), dtype=float32, numpy=
array([[[-1.11323869e+00, -9.48194981e+00,  6.48123884e+00, ...,
          6.53280640e+00, -3.10894990e+00,  1.53050375e+00],
        [ 4.35898495e+00, -1.03704405e+01,  8.90656471e+00, ...,

前面的操作会自动将变量 b 展开为一个常见的形状[4,32,16],然后以批处理形式将变量 a 相乘,以获得形状为[4,28,16]的结果。

4.10 动手向前传播

到目前为止,我们已经介绍了张量创建、索引切片、维度转换和常见的数学运算。最后,我们将使用我们所学的知识来完成三层神经网络的实现:

out=ReLU{ReLU{ReLUX@W1+b1]@W2+b2} @W

我们使用的数据集是 MNIST 手写数字图片数据集。输入节点的数量是 784。第一、第二和第三层的输出节点数分别是 256、128 和 10。首先,让我们为每个非线性层创建张量参数 Wb 如下:

# Every layer's tensor needs to be optimized. Set initial bias to be 0s.
# w and b for first layer
w1 = tf.Variable(tf.random.truncated_normal([784, 256], stddev=0.1))
b1 = tf.Variable(tf.zeros([256]))
# w and b for second layer
w2 = tf.Variable(tf.random.truncated_normal([256, 128], stddev=0.1))
b2 = tf.Variable(tf.zeros([128]))
# w and b for third layer
w3 = tf.Variable(tf.random.truncated_normal([128, 10], stddev=0.1))
b3 = tf.Variable(tf.zeros([10]))

在正演计算中,首先将形状为[ b ,28,28]的输入张量的视图调整为形状为[ b ,784]的矩阵,使其适合网络的输入格式:

        # Change view[b, 28, 28] => [b, 28*28]
        x = tf.reshape(x, [-1, 28*28])

接下来,完成第一层的计算。我们在这里执行自动扩展操作:

            # First layer calculation, [b, 784]@[784, 256] + [256] => [b, 256] + [256] => [b, 256] + [b, 256]
            h1 = x@w1 + tf.broadcast_to(b1, [x.shape[0], 256])
            h1 = tf.nn.relu(h1) # apply activation function

对第二和第三非线性功能层使用相同的方法。输出层可以使用 ReLU 激活函数:

            # Second layer calculation, [b, 256] => [b, 128]
            h2 = h1@w2 + b2
            h2 = tf.nn.relu(h2)
            # Output layer calculation, [b, 128] => [b, 10]
            out = h2@w3 + b3

将实标记张量转换为一位热编码,并从 out 计算均方误差,如下所示:

            # Calculate mean square error, mse = mean(sum(y-out)²)
            # [b, 10]
            loss = tf.square(y_onehot - out)
            # Error metrics, mean: scalar
            loss = tf.reduce_mean(loss)

前面的正向计算过程需要在“with tf”的上下文中进行包装。GradientTape() as tape”,以便可以在自动微分操作的正向计算期间保存计算图形信息。

使用 tape.gradient()函数获取网络参数的梯度信息。结果存储在梯度列表变量中,如下所示:

        # Calculate gradients for [w1, b1, w2, b2, w3, b3]
        grads = tape.gradient(loss, [w1, b1, w2, b2, w3, b3])

然后我们需要通过

$$ {\theta}^{\prime }=\theta -\eta \bullet \frac{\partial L}{\partial \theta } $$

来更新参数

    # Update parameters using assign_sub (subtract the update and assign back to the original parameter)
        w1.assign_sub(lr * grads[0])
        b1.assign_sub(lr * grads[1])
        w2.assign_sub(lr * grads[2])
        b2.assign_sub(lr * grads[3])
        w3.assign_sub(lr * grads[4])
        b3.assign_sub(lr * grads[5])

其中,assign_sub()从给定的参数值中减去自身,实现就地更新操作。网络训练误差的变化如图 4-11 所示。

img/515226_1_En_4_Fig11_HTML.jpg

图 4-11

正向计算的训练误差*

五、高级 TensorFlow

人工智能将是谷歌的终极版本。理解网络上一切的终极搜索引擎。它会准确理解你想要什么,并给你正确的东西。

—拉里·佩奇

在介绍了基本的张量运算后,让我们进一步探讨高级运算,如张量合并和分割、范数统计、张量填充和裁剪。我们也将再次使用 MNIST 数据集来增强我们对 TensorFlow 中张量运算的理解。

5.1 合并和拆分

合并

归并就是将多个张量合并成某一维的一个张量。以某学校的成绩册数据为例,张量 A 用于保存 1-4 班的成绩册。每个班有 35 名学生,共有 8 门课程。张量 A 的形状为【4,35,8】。类似地,张量 B 保存其他六个类的成绩册,形状为【6,35,8】。将这两个成绩册合并,就可以得到学校所有班级的成绩册,记为张量 C ,对应的形状应该是【10,35,8】,其中 10 代表十个班级,35 代表 35 个学生,8 代表八个科目。

张量可以使用连接和堆栈操作来合并。串联操作不会生成新的维度。它仅沿现有维度合并。但是堆栈操作会创建新的维度。是否使用连接或堆叠操作来合并张量取决于是否需要为特定场景创建新的维度。我们将在下一节课中讨论这两个问题。

串联。在 TensorFlow 中,可以使用 tf.concat(tensors,axis)函数连接张量,其中第一个参数包含需要合并的张量列表,第二个参数指定要合并的维度索引。回到前面的例子,我们合并班级维度中的年级册。这里,类维的索引号是 0,即 axis = 0。合并 AB 的代码如下:

In [1]:
a = tf.random.normal([4,35,8]) # Create gradebook A
b = tf.random.normal([6,35,8]) # Create gradebook B
tf.concat([a,b],axis=0) # Merge gradebooks
Out[1]:
<tf.Tensor: id=13, shape=(10, 35, 8), dtype=float32, numpy=
array([[[ 1.95299834e-01,  6.87859178e-01, -5.80048323e-01, ...,
          1.29430830e+00,  2.56610274e-01, -1.27798581e+00],
        [ 4.29753691e-01,  9.11329567e-01, -4.47975427e-01, ...,

除了类维度,我们还可以合并其他维度的张量。考虑张量 A 用 shape [10,35,4]保存所有班级所有学生的前四科成绩,张量 B 用 shape [10,35,4]保存其余 4 科成绩。我们可以通过合并 AB 得到总的年级簿张量,如下所示:

In [2]:
a = tf.random.normal([10,35,4])
b = tf.random.normal([10,35,4])
tf.concat([a,b],axis=2) # Merge along the last dimension
Out[2]:
<tf.Tensor: id=28, shape=(10, 35, 8), dtype=float32, numpy=
array([[[-5.13509691e-01, -1.79707789e+00,  6.50747120e-01, ...,
          2.58447856e-01,  8.47878829e-02,  4.13468748e-01],
        [-1.17108583e+00,  1.93961406e+00,  1.27830813e-02, ...,

从语法上讲,concatenate 操作可以在任何维度上执行。唯一的限制是非合并维度的长度必须相同。例如,shape [4,32,8]和 shape [6,35,8]的张量不能在班级维度中直接合并,因为学生人数维度的长度不一样——一个是 32,一个是 35,例如:

In [3]:
a = tf.random.normal([4,32,8])
b = tf.random.normal([6,35,8])
tf.concat([a,b],axis=0) # Illegal merge. Second dimension is different.
Out[3]:
InvalidArgumentError: ConcatOp : Dimensions of inputs should match: shape[0] = [4,32,8] vs. shape[1] = [6,35,8] [Op:ConcatV2] name: concat

堆栈。concatenate 操作直接合并现有维度上的数据,并且不创建新维度。如果我们想在合并数据时创建一个新的维度,我们需要使用 tf.stack 操作。考虑张量 A 以【35,8】的形状保存一个班级的成绩册,张量 B 以【35,8】的形状保存另一个班级的成绩册。当合并这两个类的数据时,我们需要创建一个新的维度,定义为类维度。新尺寸可以放置在任何位置。一般把班级维度放在学生维度之前,也就是合并张量的新形状应该是[2,35,8]。

tf.stack(tensors,axis)函数可用于合并多个张量。第一个参数表示要合并的张量列表,第二个参数指定插入新维度的位置。axis 的用法与 tf.expand_dims 函数的用法相同。当 ≥ 0 时,在轴前插入一个新尺寸。当 <为 0 时,我们在轴后插入一个新的尺寸。图 5-1 显示了形状为 bchw 的张量对应不同轴参数设置的新维度位置。

img/515226_1_En_5_Fig1_HTML.png

图 5-1

具有不同轴值的堆栈操作的新尺寸插入位置

使用堆栈操作合并两个班级的成绩册,并在 axis = 0 位置插入班级维度。代码如下:

In [4]:
a = tf.random.normal([35,8])
b = tf.random.normal([35,8])
tf.stack([a,b],axis=0) # Stack a and b and insert new dimension at axis=0
Out[4]:
<tf.Tensor: id=55, shape=(2, 35, 8), dtype=float32, numpy=
array([[[ 3.68728966e-01, -8.54765773e-01, -4.77824420e-01,
         -3.83714020e-01, -1.73216307e+00,  2.03872994e-02,
          2.63810277e+00, -1.12998331e+00],...

我们也可以选择在其他地方插入新的维度。例如,在末尾插入类维度:

In [5]:
a = tf.random.normal([35,8])
b = tf.random.normal([35,8])
tf.stack([a,b],axis=-1) # Insert new dimension at the end
Out[5]:
<tf.Tensor: id=69, shape=(35, 8, 2), dtype=float32, numpy=
array([[[ 0.3456724 , -1.7037214 ],
        [ 0.41140947, -1.1554345 ],
        [ 1.8998919 ,  0.56994915],...

现在类维度在 axis = 2 上,我们需要根据最新维度顺序代表的视图来理解数据。如果我们选择使用 tf.concat 来合并前面的脚本,那么它将是

In [6]:
a = tf.random.normal([35,8])
b = tf.random.normal([35,8])
tf.concat([a,b],axis=0) # No class dimension
Out[6]:
<tf.Tensor: id=108, shape=(70, 8), dtype=float32, numpy=
array([[-0.5516891 , -1.5031327 , -0.35369992,  0.31304857,  0.13965549,
         0.6696881 , -0.50115544,  0.15550546],
       [ 0.8622069 ,  1.0188094 ,  0.18977325,  0.6353301 ,  0.05809061,...

可以看出 tf.concat 也可以平滑地合并数据,但是我们需要按照前 35 个学生来自第一节课,后 35 个学生来自第二节课的方式来理解张量数据,这不是很直观。对于这个例子,通过 tf.stack 方法创建一个新的维度显然更合理。

tf.stack 函数也需要满足一定的条件才能使用。它需要所有的张量合并成相同的形状。让我们来看看当堆叠两个不同形状的张量时会发生什么:

In [7]:
a = tf.random.normal([35,4])
b = tf.random.normal([35,8])
tf.stack([a,b],axis=-1) # Illegal use of stack function. Different shapes.
Out[7]:
InvalidArgumentError: Shapes of all inputs must match: values[0].shape = [35,4] != values[1].shape = [35,8] [Op:Pack] name: stack

前面的操作试图合并两个形状分别为[35,4]和[35,8]的张量。因为两个张量的形状不一样,合并操作无法完成。

拆分

合并操作的逆过程是拆分,即将一个张量拆分成多个张量。让我们继续学习成绩册的例子。我们得到全校形状为[10,35,8]的年级册张量。现在我们需要将数据在班级维度上切割成十个张量,每个张量保存对应班级的年级簿数据。tf.split(x,num_or_size_splits,axis)可以用来完成张量分裂运算。函数中参数的含义如下:

  • x:要分割的张量。

  • 数量 _ 或 _ 大小 _ 分割:切割方案。当 num_or_size_splits 为单值时,如 10,则表示张量 x 被切成等长的十份。当 num_or_size_splits 是一个列表时,列表中的每个元素代表每个部分的长度。比如 num_or_size_splits=[2,4,2,2]表示张量被切割成四个部分,每个部分的长度为 2,4,2,2。

  • 轴:指定分割的尺寸索引。

现在,我们将总成绩册张量分成十份,如下所示:

In [8]:
x = tf.random.normal([10,35,8])
# Cut into 10 pieces with equal length
result = tf.split(x, num_or_size_splits=10, axis=0)
len(result)  # Return a list with 10 tensors of equal length
Out[8]: 10

我们可以查看一个张量切割后的形状,应该是形状为[1,35,8]的一个类的所有年级书数据:

In [9]: result[0] # Check the first class gradebook
Out[9]: <tf.Tensor: id=136, shape=(1, 35, 8), dtype=float32, numpy=
array([[[-1.7786729 ,  0.2970506 ,  0.02983334,  1.3970423 ,
          1.315918  , -0.79110134, -0.8501629 , -1.5549672 ],
        [ 0.5398711 ,  0.21478991, -0.08685189,  0.7730989 ,...

可以看出,第一类张量的形状为[1,35,8],其中仍然具有类维数。让我们进行不等长切割。例如,将数据分成四部分,每个部分的长度为[4,2,2,2]:

In [10]: x = tf.random.normal([10,35,8])
# Split tensor into 4 parts
result = tf.split(x, num_or_size_splits=[4,2,2,2] ,axis=0)
len(result)
Out[10]: 4

检查第一个分裂张量的形状。根据我们的拆分方案,它应该包含四个班的成绩册。形状应该是[4,35,8]:

In [10]: result[0]
Out[10]: <tf.Tensor: id=155, shape=(4, 35, 8), dtype=float32, numpy=
array([[[-6.95693314e-01,  3.01393479e-01,  1.33964568e-01, ...,

特别是,如果我们想将某个维度除以长度 1,我们可以使用 tf.unstack(x,axis)函数。这个方法是 tf.split 的一个特例,拆分长度固定为 1。我们只需要指定拆分维度的索引号。例如,在班级维度中拆分总成绩簿张量:

In [11]: x = tf.random.normal([10,35,8])
result = tf.unstack(x,axis=0)
len(result) # Return a list with 10 tensors
Out[11]: 10

查看分割张量的形状:

In [12]: result[0] # The first class tensor
Out[12]: <tf.Tensor: id=166, shape=(35, 8), dtype=float32, numpy=
array([[-0.2034383 ,  1.1851563 ,  0.25327438, -0.10160723,  2.094969  ,
        -0.8571669 , -0.48985648,  0.55798006],...

可以看到,通过 tf.unstack 分裂后,分裂张量形状变成了[35,8],即类维消失,这与 tf.split 不同。

5.2 常见统计数据

在神经网络计算期间,需要计算各种统计属性,例如最大值、最小值、平均值和范数。由于张量通常包含大量的数据,通过获取这些张量的统计信息,更容易推断出张量值的分布。

5.2.1 规范

范数是向量“长度”的度量。可以推广到张量。在神经网络中,它通常用于表示张量权重和梯度大小。常用的规范有:

  • L1 norm, defined as the sum of the absolute values of all the elements of the vector:

    $$ {\left\Vert x\right\Vert}_1={\sum}_i\left|{x}_i\right| $$

  • L2 norm, defined as the root sum of the squares of all the elements of the vector:

    $$ {\left\Vert x\right\Vert}_2=\sqrt{\sum_i{\left|{x}_i\right|}²} $$

  • ∞ norm, defined as the maximum of the absolute values of all elements of a vector:

    $$ {\left\Vert x\right\Vert}_{\infty }={\mathit{\max}}_i\left(\left|{x}_i\right|\right) $$

对于矩阵和张量,在将矩阵和张量展平成向量后,也可以使用前面的公式。在 TensorFlow 中,tf.norm(x,ord)函数可用于求解 L1、L2 和∞范数,其中 L1、L2 和∞范数的参数 ord 分别指定为 1、1 和 np.inf:

In [13]: x = tf.ones([2,2])
tf.norm(x,ord=1) # L1 norm
Out[13]: <tf.Tensor: id=183, shape=(), dtype=float32, numpy=4.0>
In [14]: tf.norm(x,ord=2) # L2 norm
Out[14]: <tf.Tensor: id=189, shape=(), dtype=float32, numpy=2.0>
In [15]: import numpy as np
tf.norm(x,ord=np.inf) # ∞ norm
Out[15]: <tf.Tensor: id=194, shape=(), dtype=float32, numpy=1.0>

5.2.2 最大值、最小值、平均值和总和

tf.reduce_max、tf.reduce_min、tf.reduce_mean 和 tf.reduce_sum 函数可用于获取某维或所有维中张量的最大值、最小值、平均值和总和。

考虑形状为[4,10]的张量,其中第一维表示样本的数量,第二维表示当前样本属于十个类别中的每一个的概率。每个样本概率的最大值可以通过 tf.reduce_max 函数获得:

In [16]: x = tf.random.normal([4,10])
tf.reduce_max(x,axis=1) # get maximum value at 2nd dimension
Out[16]:<tf.Tensor: id=203, shape=(4,), dtype=float32, numpy=array([1.2410722 , 0.88495886, 1.4170984 , 0.9550192 ], dtype=float32)>

前面的代码返回一个长度为 4 的向量,它表示每个样本的最大概率值。类似地,我们可以找到每个样本的概率最小值,如下所示:

In [17]: tf.reduce_min(x,axis=1) # get the minimum value at 2nd dimension
Out[17]:<tf.Tensor: id=206, shape=(4,), dtype=float32, numpy=array([-0.27862206, -2.4480672 , -1.9983795 , -1.5287997 ], dtype=float32)>

求每个样本的平均概率:

In [18]: tf.reduce_mean(x,axis=1)
Out[18]:<tf.Tensor: id=209, shape=(4,), dtype=float32, numpy=array([ 0.39526337, -0.17684573, -0.148988  , -0.43544054], dtype=float32)>

当未指定轴参数时,tf.reduce_*函数将查找所有数据的最大值、最小值、平均值和总和:

In [19]:x = tf.random.normal([4,10])
tf.reduce_max(x),tf.reduce_min(x),tf.reduce_mean(x)
Out [19]: (<tf.Tensor: id=218, shape=(), dtype=float32, numpy=1.8653786>,
 <tf.Tensor: id=220, shape=(), dtype=float32, numpy=-1.9751656>,
 <tf.Tensor: id=222, shape=(), dtype=float32, numpy=0.014772797>)

在求解误差函数时,可以通过 MSE 函数得到每个样本的误差,需要计算样本的平均误差。这里我们可以使用 tf.reduce_mean 函数如下:

In [20]:
out = tf.random.normal([4,10]) # Simulate output
y = tf.constant([1,2,2,0]) # Real labels
y = tf.one_hot(y,depth=10) # One-hot encoding
loss = keras.losses.mse(y,out) # Calculate loss of each sample
loss = tf.reduce_mean(loss) # Calculate mean loss
loss
Out[20]:
<tf.Tensor: id=241, shape=(), dtype=float32, numpy=1.1921183>

与 tf.reduce_mean 函数类似,sum 函数 tf.reduce_sum(x,axis)可以计算张量在相应轴上的所有特征的和:

In [21]:out = tf.random.normal([4,10])
tf.reduce_sum(out,axis=-1) # Calculate sum along the last dimension
Out[21]:<tf.Tensor: id=303, shape=(4,), dtype=float32, numpy=array([-0.588144 ,  2.2382064,  2.1582587,  4.962141 ], dtype=float32)>

另外,为了获得张量的最大值或最小值,我们有时也想获得相应的位置指数。例如,对于分类任务,我们需要知道最大概率的位置索引,这通常被用作预测类别。考虑具有十个类别的分类问题,我们得到形状为[2,10]的输出张量,其中 2 表示两个样本,10 表示属于十个类别的概率。由于元素的位置索引代表了当前样本属于该类别的概率,所以我们经常使用最大概率对应的索引作为预测类别。

In [22]:out = tf.random.normal([2,10])
out = tf.nn.softmax(out, axis=1) # Use softmax to convert to probability
out
Out[22]:<tf.Tensor: id=257, shape=(2, 10), dtype=float32, numpy=
array([[0.18773547, 0.1510464 , 0.09431915, 0.13652141, 0.06579739,
        0.02033597, 0.06067333, 0.0666793 , 0.14594753, 0.07094406],
       [0.5092072 , 0.03887136, 0.0390687 , 0.01911005, 0.03850609,
        0.03442522, 0.08060656, 0.10171875, 0.08244187, 0.05604421]],
       dtype=float32)>

以第一个样本为例,可以看出概率最高(0.1877)的指数为 0。因为每个指标上的概率代表样本属于该类别的概率,所以第一个样本属于 0 类的概率最大。因此,第一个样本最有可能属于类别 0。这是一个典型的应用,其中需要求解最大值的指数。

我们可以用 tf.argmax(x,axis)和 tf.argmin(x,axis)来求 x 在轴参数上的最大值和最小值的索引。例如:

In [23]:pred = tf.argmax(out, axis=1)
pred
Out[23]:<tf.Tensor: id=262, shape=(2,), dtype=int64, numpy=array([0, 0], dtype=int64)>

可以看出,两个样本的最大概率出现在索引 0 上,因此最有可能的是,它们都属于类别 0。我们可以使用类别 0 作为两个样本的预测类别。

5.3 张量比较

为了得到准确率等分类度量,一般需要将预测结果与真实标签进行比较。考虑 100 个样本的预测结果,可以通过 tf.argmax 得到预测的类别。

In [24]:out = tf.random.normal([100,10])
out = tf.nn.softmax(out, axis=1) # Convert to probability
pred = tf.argmax(out, axis=1) # Find corresponding category
Out[24]:<tf.Tensor: id=272, shape=(100,), dtype=int64, numpy=
array([0, 6, 4, 3, 6, 8, 6, 3, 7, 9, 5, 7, 3, 7, 1, 5, 6, 1, 2, 9, 0, 6,
       5, 4, 9, 5, 6, 4, 6, 0, 8, 4, 7, 3, 4, 7, 4, 1, 2, 4, 9, 4,...

pred 变量保存 100 个样本的预测类别。我们将它们与真实标签进行比较,以获得一个布尔张量,表示每个样本是否预测了正确的样本。tf.equal(a,b)(或 tf.math.equal(a,b),二者等价)函数可以比较两个张量是否相等,例如:

In [25]: # Simiulate the true labels
y = tf.random.uniform([100],dtype=tf.int64,maxval=10)
Out[25]:<tf.Tensor: id=281, shape=(100,), dtype=int64, numpy=
array([0, 9, 8, 4, 9, 7, 2, 7, 6, 7, 3, 4, 2, 6, 5, 0, 9, 4, 5, 8, 4, 2,
       5, 5, 5, 3, 8, 5, 2, 0, 3, 6, 0, 7, 1, 1, 7, 0, 6, 1, 2, 1, 3, ...
In [26]:out = tf.equal(pred,y) # Compare true and prediction
Out[26]:<tf.Tensor: id=288, shape=(100,), dtype=bool, numpy=
array([False, False, False, False, True, False, False, False, False,
       False, False, False, False, False, True, False, False, True,...

tf.equal 函数将比较结果作为布尔张量返回。我们只需要计算真实元素的数量,就可以得到正确的预测数量。为了实现这一点,我们先将布尔类型转换为整数张量,即 True 对应 1,False 对应 0,然后将 1 的个数求和,得到比较结果中 True 元素的个数:

In [27]:out = tf.cast(out, dtype=tf.float32) # convert to int type
correct = tf.reduce_sum(out) # get the number of True elements
Out[27]:<tf.Tensor: id=293, shape=(), dtype=float32, numpy=12.0>

可以看出,我们随机生成的预测数据中,正确预测的数量是 12 个,所以其准确率为:

$$ accuracy=\frac{12}{100}=12% $$

这是随机预测模型的正常水平。

除 tf.equal 函数外,其他常用的比较函数如表 5-1 所示。

表 5-1

常见比较函数

|

功能

|

比较逻辑

|
| --- | --- |
| tf.math.greater | > |
| tf.math.less | < |
| tf.math.greater_equal | a**b |
| tf.math.less_equal | a≤【b】 |
| tf.math.not_equal | |
| tf.math.is_nan | = |

5.4 填写和复制

填充

图像的高度和宽度以及序列信号的长度可以不同。为了便于网络的并行计算,需要将不同长度的数据扩展到相同的长度。我们之前介绍过,可以通过复制来增加数据的长度。但是,重复复制数据会破坏原有的数据结构,不适合某些情况。常见的做法是在数据的开头或结尾填入足够数量的特定值。这些特定值(例如,0)通常表示无效的含义。这种操作称为填充。

考虑一个两句话的张量,每个单词用一个数字代码表示,比如 1 代表 I,2 代表 like,等等。第一句是“我喜欢今天的天气。”我们假设句号编码为[1,2,3,4,5,6]。第二句是“我也是”,编码为[7,8,1,6]。为了将这两个句子存储在一个张量中,我们需要保持这两个句子的长度一致,即需要将第二个句子的长度扩展为 6。常见的填充方案是在第二句话的末尾填充若干个零,即[7,8,1,6,0,0]。现在这两个句子可以堆叠起来,组合成一个形状为[2,6]的张量。

填充操作可以通过 tf.pad(x,paddings)函数实现。参数 paddings 是多个嵌套方案的列表,格式为[ 左填充右填充。例如, paddings = [[0,0],[2,1],[1,2]]表示第一维度不填充,第二维度左边(开头)填充两个单位,第二维度右边(结尾)填充一个单位,第三维度左边填充一个单位,第三维度右边填充两个单位。考虑到前面两个句子的例子,第二个句子的第一维右边需要填充两个单位,paddings 方案为[[0,2]]:

In [28]:a = tf.constant([1,2,3,4,5,6]) # 1st sentence
b = tf.constant([7,8,1,6]) # 2nd sentence
b = tf.pad(b, [[0,2]]) # Pad two 0's in the end of 2nd sentence
b
Out[28]:<tf.Tensor: id=3, shape=(6,), dtype=int32, numpy=array([7, 8, 1, 6, 0, 0])>

填充后,两个张量的形状是一致的,我们可以把它们叠加在一起。代码如下:

In [29]:tf.stack([a,b],axis=0) # Stack a and b
Out[29]:<tf.Tensor: id=5, shape=(2, 6), dtype=int32, numpy=
array([[1, 2, 3, 4, 5, 6],
       [7, 8, 1, 6, 0, 0]])>

在自然语言处理中,需要加载不同长度的句子。有的句子比较短,比如只有十个字,有的句子比较长,比如 100 多个字。为了能够保存在同一个张量中,一般选择一个能够覆盖大部分句子长度的阈值,比如 80 个单词。对于少于 80 个单词的句子,我们在句尾用 0 填充。对于超过 80 个单词的句子,我们通过删除结尾的一些单词将句子截短为 80 个单词。我们将以 IMDB 数据集为例,演示如何将长度不等的句子转换成长度相等的结构。代码如下:

In [30]:total_words = 10000 # Set word number
max_review_len = 80 # Maximum length for each sentence
embedding_len = 100 # Word vector length
# Load IMDB dataset
(x_train, y_train), (x_test, y_test) = keras.datasets.imdb.load_data(num_words=total_words)
# Pad or truncate sentences to the same length with end padding and truncation
x_train = keras.preprocessing.sequence.pad_sequences(x_train, maxlen=max_review_len,truncating='post',padding='post')
x_test = keras.preprocessing.sequence.pad_sequences(x_test, maxlen=max_review_len,truncating='post',padding='post')
print(x_train.shape, x_test.shape)
Out[30]: (25000, 80) (25000, 80)

在前面的代码中,我们将语句 max_review_len 的最大长度设置为 80 个单词。通过 keras . preprocessing . sequence . pad _ sequences 函数,我们可以快速完成填充和截断实现。以其中一个句子为例,变换后的向量是这样的:

[   1  778  128   74   12  630  163   15    4 1766 7982 1051    2   32
   85  156   45   40  148  139  121  664  665   10   10 1361  173    4
  749    2   16 3804    8    4  226   65   12   43  127   24    2   10
   10    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0]

我们可以看到,句子的最后一部分用 0 填充,因此句子的长度正好是 80。其实在句子长度不够的情况下,我们也可以选择填充句子的开头部分。经过处理后,所有句子长度都变成 80,这样训练集可以统一存储在 shape [25000,80]的张量中,测试集可以存储在 shape [25000,80]的张量中。

下面介绍一个同时填写多个维度的例子。考虑填充图像的高度和宽度尺寸。如果我们有尺寸为 28 × 28 的图片,神经网络的输入层形状为 32 × 32,我们需要填充图像以获得 32 × 32 的形状。我们可以选择在图像矩阵的上、下、左、右各填充 2 个单元,如图 5-2 所示。

img/515226_1_En_5_Fig2_HTML.png

图 5-2

图像填充示例

前述填充方案可以如下实现:

In [31]:
x = tf.random.normal([4,28,28,1])
# Pad two units at each edge of the image
tf.pad(x,[[0,0],[2,2],[2,2],[0,0]])
Out[31]:
<tf.Tensor: id=16, shape=(4, 32, 32, 1), dtype=float32, numpy=
array([[[[ 0\.        ],
         [ 0\.        ],
         [ 0\.        ],...

经过填充操作后,图片大小变为 32 × 32,满足了神经网络的输入要求。

副本

在维度转换一节中,我们介绍了复制长度为 1 的维度的 tf.tile 函数。实际上,tf.tile 函数可用于在任何维度上重复复制多个数据副本。例如,对于形状为[4,32,32,3]的图像数据,如果复制方案为 multiples=[2,3,3,1],则表示通道维度不复制,高度和宽度维度复制三份,图像编号维度复制两份。实现如下:

In [32]:x = tf.random.normal([4,32,32,3])
tf.tile(x,[2,3,3,1])
Out[32]:<tf.Tensor: id=25, shape=(8, 96, 96, 3), dtype=float32, numpy=
array([[[[ 1.20957184e+00,  2.82766962e+00,  1.65782201e+00],
         [ 3.85402292e-01,  2.00732923e+00, -2.79068202e-01],
         [-2.52583921e-01,  7.82584965e-01,  7.56870627e-01],...

5.5 数据限制

考虑如何实现非线性激活函数 ReLU。事实上,它可以通过简单的数据限制操作来实现,其中元素的范围被限制为 x ∈ [0,+∞)。

在 TensorFlow 中,可以通过 tf.maximum (x,a)设置数据的下限,也就是可以通过 tf.minimum (x,a)设置数据的上限。

In [33]:x = tf.range(9)
tf.maximum(x,2) # Set lower limit of x to 2
Out[33]:<tf.Tensor: id=48, shape=(9,), dtype=int32, numpy=array([2, 2, 2, 3, 4, 5, 6, 7, 8])>
In [34]:tf.minimum(x,7) # Set x upper limit to 7
Out[34]:<tf.Tensor: id=41, shape=(9,), dtype=int32, numpy=array([0, 1, 2, 3, 4, 5, 6, 7, 7])>

基于 tf.maximum 函数,我们可以如下实现 ReLU:

def relu(x): # ReLU function
    return tf.maximum(x,0.) # Set lower limit of x to be 0

通过组合 tf.maximum(x,a)和 tf.minimum(x,b),可以同时限定数据的上下边界,即 x ∈ [ ab

In [35]:x = tf.range(9)
tf.minimum(tf.maximum(x,2),7) # Set x range to be [2, 7]
Out[35]:<tf.Tensor: id=57, shape=(9,), dtype=int32, numpy=array([2, 2, 2, 3, 4, 5, 6, 7, 7])>

更方便的是,我们可以使用 tf.clip_by_value 函数来实现上下限幅:

In [36]:x = tf.range(9)
tf.clip_by_value(x,2,7) # Set x range to be [2, 7]
Out[36]:<tf.Tensor: id=66, shape=(9,), dtype=int32, numpy=array([2, 2, 2, 3, 4, 5, 6, 7, 7])>

5.6 高级操作

前面的大多数函数都很常见,很容易理解。接下来,我们将介绍一些常用但稍微复杂一些的函数。

收集 tf

tf.gather 函数可以根据索引号收集数据。考虑年级书的例子。假设有四个班,每个班 35 个学生,共八个科目,年级书的张量形状为[4,35,8]。

x = tf.random.uniform([4,35,8],maxval=100,dtype=tf.int32)

现在需要收集一、二班的年级书。我们可以给出想要收集的类的索引号(例如[0,1]),并指定类的维数(例如 axis = 0)。然后通过 tf.gather 函数收集数据。

In [38]:tf.gather(x,[0,1],axis=0) # Collect data for 1st and 2nd classes
Out[38]:<tf.Tensor: id=83, shape=(2, 35, 8), dtype=int32, numpy=
array([[[43, 10, 93, 85, 75, 87, 28, 19],
        [52, 17, 44, 88, 82, 54, 16, 65],
        [98, 26,  1, 47, 59,  3, 59, 70],...

事实上,通过切片可以更方便地实现前面的要求。但是对于不规则的索引方式,比如需要抽查 1、4、9、12、13、27 名学生的年级数据,切片方式就不适合了。tf.gather 函数就是针对这种情况设计的,使用起来更方便。实现如下:

In [39]: # Collect the grade of students 1,4,9,12,13 and 27
tf.gather(x,[0,3,8,11,12,26],axis=1)
Out[39]:<tf.Tensor: id=87, shape=(4, 6, 8), dtype=int32, numpy=
array([[[43, 10, 93, 85, 75, 87, 28, 19],
        [74, 11, 25, 64, 84, 89, 79, 85],...

如果需要汇总所有学生的第三、第五科成绩,可以指定科目维度 axis = 2,实现如下:

# Collect the grades of the 3rd and 5th subjects of all students
In [40]:tf.gather(x,[2,4],axis=2)
Out[40]:<tf.Tensor: id=91, shape=(4, 35, 2), dtype=int32, numpy=
array([[[93, 75],
        [44, 82],
        [ 1, 59],...

可以看出 tf.gather 非常适合索引号没有规律的情况。索引号可以不按顺序排列,收集的数据也将按相应的顺序排列。例如:

In [41]:a=tf.range(8)
a=tf.reshape(a,[4,2])
Out[41]:<tf.Tensor: id=115, shape=(4, 2), dtype=int32, numpy=
array([[0, 1],
       [2, 3],
       [4, 5],
       [6, 7]])>
In [42]:tf.gather(a,[3,1,0,2],axis=0) # Collect element 4,2,1,3
Out[42]:<tf.Tensor: id=119, shape=(4, 2), dtype=int32, numpy=
array([[6, 7],
       [2, 3],
       [0, 1],
       [4, 5]])>

我们会把问题变得复杂一点。如果我们想检查[2,3]班[3,4,6,27]学生的学科成绩,可以通过组合多个 tf.gather 操作来实现。首先提取类[2,3]的数据:

In [43]:
students=tf.gather(x,[1,2],axis=0) # Collect data for class 2 and 3
Out[43]:<tf.Tensor: id=227, shape=(2, 35, 8), dtype=int32, numpy=
array([[[ 0, 62, 99,  7, 66, 56, 95, 98],...

然后,我们提取所选学生的相应数据:

In [44]:
tf.gather(students,[2,3,5,26],axis=1) # Collect data for students 3,4,6,27
Out[44]:<tf.Tensor: id=231, shape=(2, 4, 8), dtype=int32, numpy=
array([[[69, 67, 93,  2, 31,  5, 66, 65], ...

现在我们得到了形状为[2,4,8]的选定张量。

这次要抽查二班第二同学的所有科目,三班第三同学的所有科目,四班第四同学的所有科目。那么它是如何工作的呢?可以用笨拙的方式逐个手动提取数据。先提取第一个采样点的数据:x【1,1】。

In [45]: x[1,1]
Out[45]:<tf.Tensor: id=236, shape=(8,), dtype=int32, numpy=array([45, 34, 99, 17,  3,  1, 43, 86])>

然后提取第二个采样点x【2,2】的数据和第三个采样点x【3,3】的数据,最后将采样结果合并在一起。

In [46]: tf.stack([x[1,1],x[2,2],x[3,3]],axis=0)
Out[46]:<tf.Tensor: id=250, shape=(3, 8), dtype=int32, numpy=
array([[45, 34, 99, 17,  3,  1, 43, 86],
       [11, 25, 84, 95, 97, 95, 69, 69],
       [ 0, 89, 52, 29, 76,  7,  2, 98]])>

使用前面的方法,我们可以正确地获得 shape [3,8]的结果,其中 3 代表采样点数,4 代表每个采样点的数据。最大的问题是采样是手工串行进行的,计算效率极低。有没有更好的方法来实现这一点?

5.6.2 tf.gather_nd

使用 tf.gather_nd 函数,我们可以通过指定每个采样点的多维坐标来采样多个点。回到前面的挑战,我们要抽查二班第二个同学的所有科目,三班第三个同学的所有科目,四班第四个同学的所有科目。那么三个采样点的索引坐标就可以记录为[1,1],[2,2], [3,3],我们就可以把这个采样方案组合成一个列表[[1,1],[2,2],[3,3]]。

In [47]:
tf.gather_nd(x,[[1,1],[2,2],[3,3]])
Out[47]:<tf.Tensor: id=256, shape=(3, 8), dtype=int32, numpy=
array([[45, 34, 99, 17,  3,  1, 43, 86],
       [11, 25, 84, 95, 97, 95, 69, 69],
       [ 0, 89, 52, 29, 76,  7,  2, 98]])>

结果与串行采样方法一致,且实现更加简洁高效。

一般来说,当使用 tf.gather_nd 对多个样本进行采样时,例如,如果我们想要对类 i ,学生 j ,主题 k 进行采样,我们可以使用表达式[...,[ ijk ,...].内部列表包含每个采样点的相应索引坐标,例如:

In [48]:
tf.gather_nd(x,[[1,1,2],[2,2,3],[3,3,4]])
Out[48]:<tf.Tensor: id=259, shape=(3,), dtype=int32, numpy=array([99, 95, 76])>

在前面的代码中,我们提取了 1 班学生 2 的科目 1、2 班学生 3 的科目 2 和 3 班学生 3 的科目 4 的成绩。总共有三个年级的数据,结果总结成一个张量,形状为[3]。

5.6.3 tf.boolean_mask

除了通过给定的索引号进行采样之外,还可以通过给定的掩码进行采样。继续以形状为[4,35,8]的年级书张量为例;这次我们使用掩码方法进行数据提取。

考虑类维度中的采样,设置对应的掩码为:

$$ mask=\left[ True, False, False, True\right] $$

即采样第一类和第四类。使用函数 tf.boolean_mask(x,mask,axis),可以根据掩码方案在相应的轴上执行采样,具体实现如下:

In [49]:
tf.boolean_mask(x,mask=[True, False,False,True],axis=0)
Out[49]:<tf.Tensor: id=288, shape=(2, 35, 8), dtype=int32, numpy=
array([[[43, 10, 93, 85, 75, 87, 28, 19],...

请注意,遮罩的长度必须与相应尺寸的长度相同。如果我们在类维中采样,我们必须指定长度为 4 的掩码,以指定四个类是否在采样。

如果对八个对象进行掩码采样,我们需要将掩码采样方案设置为

$$ mask=\left[ True, False, False, True, True, False, False, True\right] $$

也就是说,对第一、第四、第五和第八个受试者进行采样:

In [50]:
tf.boolean_mask(x,mask=[True,False,False,True,True,False,False,True],axis=2)
Out[50]:<tf.Tensor: id=318, shape=(4, 35, 4), dtype=int32, numpy=
array([[[43, 85, 75, 19],...

不难发现,这里 tf.boolean_mask 的用法其实和 tf.gather 很像,只不过一个是用 mask 方法采样,另一个是直接给索引号。

现在让我们考虑一个类似于 tf.gather_nd 的多维掩码采样方法。为了便于演示,我们将班级数量减少到两个,学生数量减少到三个。即一个班只有三个学生,张量形状为[2,3,8]。如果我们想对第一个班的学生 1 到 2 和第二个班的学生 2 到 3 进行采样,我们可以使用 tf.gather_nd:

In [51]:x = tf.random.uniform([2,3,8],maxval=100,dtype=tf.int32)
tf.gather_nd(x,[[0,0],[0,1],[1,1],[1,2]])
Out[51]:<tf.Tensor: id=325, shape=(4, 8), dtype=int32, numpy=
array([[52, 81, 78, 21, 50,  6, 68, 19],
       [53, 70, 62, 12,  7, 68, 36, 84],
       [62, 30, 52, 60, 10, 93, 33,  6],
       [97, 92, 59, 87, 86, 49, 47, 11]])>

总共有四个学生的结果被取样,形状为[4,8]。

如果我们使用面具,我们如何表达它?表 5-2 表示相应位置的采样:

表 5-2

使用掩模法采样

|   |

学生 0

|

学生 1

|

学生 2

|
| --- | --- | --- | --- |
| 0 类 | 真实的 | 真实的 | 错误的 |
| 1 类 | 错误的 | 真实的 | 真实的 |

因此,通过该表,可以很好地表达采用掩膜法的采样方案。代码实现如下:

In [52]:
tf.boolean_mask(x,[[True,True,False],[False,True,True]])
Out[52]:<tf.Tensor: id=354, shape=(4, 8), dtype=int32, numpy=
array([[52, 81, 78, 21, 50,  6, 68, 19],
       [53, 70, 62, 12,  7, 68, 36, 84],
       [62, 30, 52, 60, 10, 93, 33,  6],
       [97, 92, 59, 87, 86, 49, 47, 11]])>

结果和 tf.gather_nd 方法完全一样。可以看出,tf.boolean_mask 方法可用于一维和多维采样。

前面三个操作比较常用,尤其是 tf.gather 和 tf.gather_nd。下面添加了三个额外的高级操作。

在哪里

通过 tf.where(cond,a,b)函数,我们可以根据 cond 条件的真假情况从参数 a 或 b 中读取数据。条件确定规则如下:

img/515226_1_En_5_Figa_HTML.png

其中 i 是张量的元素索引。返回的张量大小与 a 和 b 一致,当condI对应位置为真时,数据从 a i 复制到 o i 。否则,将数据从 b i 复制到 o i 。考虑从所有 1 和 0 的两个张量 AB 中提取数据,其中中condIA 的对应位置提取元素 1,否则从 B 的对应位置提取元素 0。代码如下:

In [53]:
a = tf.ones([3,3])  # Tensor A
b = tf.zeros([3,3]) # Tensor B
# Create condition matrix
cond = tf.constant([[True,False,False],[False,True,False],[True,True,False]])
tf.where(cond,a,b)
Out[53]:<tf.Tensor: id=384, shape=(3, 3), dtype=float32, numpy=
array([[1., 0., 0.],
       [0., 1., 0.],
       [1., 1., 0.]], dtype=float32)>

可以看出,返回张量中 1 的位置都来自张量 A ,返回张量中 0 的位置来自张量 B

当参数 a=b=None 时,即不指定 a 和 b 参数;tf.where 返回 cond 张量中所有真元素的索引坐标。考虑下面的 cond 张量:

In [54]: cond
Out[54]:<tf.Tensor: id=383, shape=(3, 3), dtype=bool, numpy=
array([[ True, False, False],
       [False,  True, False],
       [ True,  True, False]])>

True 总共出现四次,每个 True 元素所在位置的索引分别为[0,0]、[1,1]、[2,0]和[2,1]。这些元素的索引坐标可以通过 tf.where(cond)的形式直接获得,如下所示:

In [55]:tf.where(cond)
Out[55]:<tf.Tensor: id=387, shape=(4, 2), dtype=int64, numpy=
array([[0, 0],
       [1, 1],
       [2, 0],
       [2, 1]], dtype=int64)>

那么这个有什么用呢?考虑一个场景,我们需要提取一个张量中所有的正数据和索引。首先构造张量 a,通过比较运算得到所有正数的位置掩码:

In [56]:x = tf.random.normal([3,3]) # Create tensor a
Out[56]:<tf.Tensor: id=403, shape=(3, 3), dtype=float32, numpy=
array([[-2.2946844 ,  0.6708417 , -0.5222212 ],
       [-0.6919401 , -1.9418817 ,  0.3559235 ],
       [-0.8005251 ,  1.0603906 , -0.68819374]], dtype=float32)>

通过比较运算,我们得到所有正数的掩码:

In [57]:mask=x>0 # equivalent to tf.math.greater()
mask
Out[57]:<tf.Tensor: id=405, shape=(3, 3), dtype=bool, numpy=
array([[False,  True, False],
       [False, False,  True],
       [False,  True, False]])>

通过 tf 提取掩膜张量中真元素的索引坐标,其中:

In [58]:indices=tf.where(mask) # Extract all element greater than 0
Out[58]:<tf.Tensor: id=407, shape=(3, 2), dtype=int64, numpy=
array([[0, 1],
       [1, 2],
       [2, 1]], dtype=int64)>

拿到索引后,我们可以通过 tf.gather_nd 恢复所有的正元素:

In [59]:tf.gather_nd(x,indices) # Extract all positive elements
Out[59]:<tf.Tensor: id=410, shape=(3,), dtype=float32, numpy=array([0.6708417, 0.3559235, 1.0603906], dtype=float32)>

其实在我们得到了 mask 之后,也可以直接通过 tf.boolean_mask 得到所有的正元素:

In [60]:tf.boolean_mask(x,mask) # Extract all positive elements
Out[60]:<tf.Tensor: id=439, shape=(3,), dtype=float32, numpy=array([0.6708417, 0.3559235, 1.0603906], dtype=float32)>

通过前面的一系列比较,我们可以直观地感受到这个函数有很大的实际应用价值,也可以深入了解它们的本质,以便能够以更灵活、简单、高效的方式实现我们的目的。

分散 _nd

TF . scatter _ nd(indexes,updates,shape)函数可以高效地刷新部分张量数据,但该函数只能对所有 0 张量进行刷新操作,因此可能需要结合其他操作来实现非零张量的数据刷新功能。

图 5-3 给出了一维全零张量的刷新计算原理。白板的形状由 shape 参数表示,要刷新的数据的索引号由 indexes 表示,updates 参数包含新数据。TF . scatter _ nd(indexes,updates,shape)函数根据 indexes 给出的索引位置将新数据写入全零张量,并返回更新后的结果张量。

img/515226_1_En_5_Fig3_HTML.png

图 5-3

用于刷新数据的 scatter_nd 函数

我们实现了图 5-3 中张量的刷新示例,如下所示:

In [61]: # Create indices for refreshing data
indices = tf.constant([[4], [3], [1], [7]])
# Create data for filling the indices
updates = tf.constant([4.4, 3.3, 1.1, 7.7])
# Refresh data for all 0 vector of length 8
tf.scatter_nd(indices, updates, [8])
Out[61]:<tf.Tensor: id=467, shape=(8,), dtype=float32, numpy=array([0\. , 1.1, 0\. , 3.3, 4.4, 0\. , 0\. , 7.7], dtype=float32)>

可以看出,在长度为 8 的全零张量上,相应位置的数据用来自更新的值填充。

考虑一个三维张量的例子。如图 5-4 所示,全零张量的形状是一个共有四个通道的特征图,每个通道的大小为 4 × 4。新的数据更新有一个形状[2,4,4],需要写入索引[1,3]。

img/515226_1_En_5_Fig4_HTML.png

图 5-4

3D 张量数据刷新

我们将新的特征映射写入现有张量,如下所示:

In [62]:
indices = tf.constant([[1],[3]])
updates = tf.constant([
    [[5,5,5,5],[6,6,6,6],[7,7,7,7],[8,8,8,8]],
    [[1,1,1,1],[2,2,2,2],[3,3,3,3],[4,4,4,4]]
])
tf.scatter_nd(indices,updates,[4,4,4])
Out[62]:<tf.Tensor: id=477, shape=(4, 4, 4), dtype=int32, numpy=
array([[[0, 0, 0, 0],
        [0, 0, 0, 0],
        [0, 0, 0, 0],
        [0, 0, 0, 0]],
       [[5, 5, 5, 5], # New data 1
        [6, 6, 6, 6],
        [7, 7, 7, 7],
        [8, 8, 8, 8]],
       [[0, 0, 0, 0],
        [0, 0, 0, 0],
        [0, 0, 0, 0],
        [0, 0, 0, 0]],
       [[1, 1, 1, 1], # New data 2
        [2, 2, 2, 2],
        [3, 3, 3, 3],
        [4, 4, 4, 4]]])>

可以看出,数据被刷新到第二和第四通道特征图上。

5.6.6 tf.网栅

tf.meshgrid 函数可以方便地生成二维网格的采样点坐标,方便可视化等应用。考虑两个自变量 x 和 y 的 Sinc 函数为:

$$ z=\frac{sinsin\ \left({x}²+{y}²\right)\ }{x²+{y}²} $$

如果我们需要绘制区间x∈[8,8],y∈[8,8]的 Sinc 函数的 3D 曲面,如图 5-5 所示,我们首先需要生成 x 轴和 y 轴的网格点坐标集,这样就可以通过 Sinc 函数 z 的表达式计算出函数在每个位置的输出值,我们可以通过下式生成 10000 个坐标采样点:

points = []
for x in range(-8,8,100): # Loop to generate 100 sampling point for x-axis
for y in range(-8,8,100): # Loop to generate 100 sampling point for y-axis
        z = sinc(x,y)
        points.append([x,y,z])

显然,这种串行采样方法效率极低。有没有简单高效的生成网格坐标的方法?答案是 tf.meshgrid 函数。

img/515226_1_En_5_Fig5_HTML.jpg

图 5-5

正弦函数

通过分别在 x 轴和 y 轴上采样 100 个数据点,可以使用 tf.meshgrid(x,y)来生成这 10,000 个数据点的张量数据,并将它们保存在形状为[100,100,2]的张量中。为了计算方便,tf.meshgrid 在轴=二维切割后会返回两个张量,其中张量 A 包含所有点的 x 坐标,张量 B 包含所有点的 y 坐标。

In [63]:
x = tf.linspace(-8.,8,100) # x-axis
y = tf.linspace(-8.,8,100) # y-axis
x,y = tf.meshgrid(x,y)
x.shape,y.shape
Out[63]: (TensorShape([100, 100]), TensorShape([100, 100]))

使用生成的网格点坐标张量,Sinc 函数在 TensorFlow 中实现如下:

z = tf.sqrt(x**2+y**2)
z = tf.sin(z)/z  # sinc function

matplotlib 库可以用来绘制函数的 3D 曲面,如图 5-5 所示。

import matplotlib
from matplotlib import pyplot as plt
from mpl_toolkits.mplot3d import Axes3D

fig = plt.figure()
ax = Axes3D(fig)
# Plot Sinc function
ax.contour3D(x.numpy(), y.numpy(), z.numpy(), 50)
plt.show()

5.7 加载经典数据集

到目前为止,我们已经学习了常见的张量运算,并准备实现大部分深度网络。最后,我们将用一个以张量形式实现的分类网络模型来完成这一章。在此之前,我们先正式介绍一下,对于常用的经典数据集,如何使用 TensorFlow 提供的工具方便地加载数据集。对于加载自定义数据集,我们将在后续章节中介绍。

在 TensorFlow 中,keras.datasets 模块提供了常用经典数据集的自动下载、管理、加载和转换功能,以及相应的数据集对象,这有助于多线程、预处理、混排和批量训练。

一些常用的经典数据集:

  • 波士顿住房:波士顿住房价格趋势数据集,用于训练和测试回归模型。

  • CIFAR10/100:用于图片分类任务的真实图片数据集。

  • MNIST/时尚 _MNIST:一个手写的数字图片数据集,用于图片分类任务。

  • IMDB:情感分类任务数据集,用于文本分类任务。

这些数据集在机器学习或深度学习中使用非常频繁。对于新提出的算法,一般倾向于在经典数据集上测试,然后尝试迁移到更大更复杂的数据集。

我们可以使用 datasets.xxx.load_data()函数自动加载经典数据集,其中 xxx 代表具体的数据集名称,如“CIFAR10”和“MNIST”。TensorFlow 将数据缓存在。keras/datasets 文件夹默认在用户目录下,如图 5-6 所示。用户不需要关心数据集是如何保存的。如果当前数据集不在缓存中,将自动从网络下载、解压缩和加载该数据集。如果它已经在缓存中,加载将自动完成。例如,要自动加载 MNIST 数据集:

In [66]:
import  tensorflow as tf
from    tensorflow import keras
from    tensorflow.keras import datasets # Load dataset loading module
# Load MNIST dataset
(x, y), (x_test, y_test) = datasets.mnist.load_data()
print('x:', x.shape, 'y:', y.shape, 'x test:', x_test.shape, 'y test:', y_test)
Out [66]:
x: (60000, 28, 28) y: (60000,) x test: (10000, 28, 28) y test: [7 2 1 ... 4 5 6]

函数将以相应的格式返回数据。对于图像数据集 MNIST 和 CIFAR10,将返回两个元组。第一个元组保存训练数据 x 和 y 对象;第二个元组是测试数据 x_test 和 y_test 对象。所有数据都存储在 Numpy 数组容器中。

img/515226_1_En_5_Fig6_HTML.jpg

图 5-6

TensorFlow 经典数据集保存目录

将数据加载到内存后,需要将其转换为 Dataset 对象,以便利用 TensorFlow 提供的各种便利功能。Dataset.from_tensor_slices 可用于将训练数据图像 x 和标签 y 转换成数据集对象:

# Convert to Dataset objects
train_db = tf.data.Dataset.from_tensor_slices((x, y))

将数据转换成 Dataset 对象后,我们一般需要为数据集添加一系列标准的处理步骤,比如随机洗牌、预处理、批量加载等。

洗牌

使用 Dataset.shuffle(buffer_size)函数,我们可以随机地对 Dataset 对象进行混排,以防止在每次训练过程中按照固定的顺序生成数据,这样模型就不会“记住”标签信息。代码实现如下:

train_db = train_db.shuffle(10000)

这里,buffer_size 参数指定缓冲池的大小,它通常被设置为一个较大的常数。调用数据集提供的这些实用函数将返回一个新的数据集对象。

$$ db= db. step1\left(\right). step2\left(\right). step3.\left(\right) $$

这种方法按顺序完成所有的数据处理步骤,实现起来非常方便。

批量培训

为了利用 GPU 的并行计算能力,网络计算过程中一般会同时计算多个样本。我们把这种训练方法叫做批量训练,一个批量的样本数叫做批量。为了从数据集一次性生成批量样本,需要将数据集设置为批量训练模式。实现如下:

train_db = train_db.batch(128) # batch size is 128

这里 128 是批量参数,即一次并行计算 128 个样本。批量 sis 一般根据用户的 GPU 内存资源来设置。当 GPU 内存不足时,可以适当减小批量。

预处理

从 keras.datasets 加载的数据集格式在大多数情况下无法满足模型输入要求,因此需要根据用户的逻辑实现预处理步骤。Dataset 对象通过提供 map(func)实用函数可以非常方便地调用用户自定义的预处理逻辑,而预处理逻辑是在 func 函数中实现的。例如,下面的代码调用名为 preprocess 的函数来完成每个样本的预处理:

# Preprocessing is implemented in the preprocess function
train_db = train_db.map(preprocess)

考虑到 MNIST 手写数字图片数据集,图像 x 从 keras.datasets 加载后。batch()操作有 shape [ b ,28,28],其中像素用 0 到 255 的整数表示,标签形状为[ b ,数字编码。实际的神经网络输入通常需要将图像数据归一化到 0 附近的区间[0,1]或[1,1]。同时,根据网络设置,需要将 shape [28,28]的输入视图调整为合适的格式。对于标签信息,我们可以在预处理或误差计算期间选择一键编码。

这里,我们将 MNIST 图像数据映射到区间[0,1],并将视图调整为[b,28∫28]。对于标签数据,我们选择在预处理函数中执行一键编码。预处理功能实现如下:

def preprocess(x, y): # Customized preprocessing function
    x = tf.cast(x, dtype=tf.float32) / 255.
    x = tf.reshape(x, [-1, 28*28])     # flatten
    y = tf.cast(y, dtype=tf.int32)    # convert to int
    y = tf.one_hot(y, depth=10)    # one-hot encoding
    return x,y

5.7.4 新纪元培训

对于数据集对象,我们可以通过以下方式进行迭代:

   for step, (x,y) in enumerate(train_db): # Iterate with step
or
    for x,y in train_db: # Iterate without step

每次返回的 x 和 y 对象是批量样本和标签。当对 train_db 的所有样本完成一次迭代时,for 循环终止。完成一批数据训练称为一个步骤,通过多个步骤完成整个训练集的一次迭代称为一个历元。在训练中,通常需要在数据集上迭代多个历元,以获得更好的训练结果。例如,20 个历元的固定训练实现如下:

    for epoch in range(20): # Epoch number
        for step, (x,y) in enumerate(train_db): # Iteration step number
            # training...

此外,我们还可以设置一个数据集对象,以便数据集在退出之前将遍历多次,例如:

train_db = train_db.repeat(20) # Dataset iteration 20 times

上述代码使 train_db 中的 for x,y 在退出前迭代 20 个历元。无论采用这几种方法中的哪一种,都能达到同样的效果。由于上一章已经完成了正向计算的实际计算,这里就跳过了。

5.8 动手操作 MNIST 数据集

我们已经介绍并实现了前向传播和数据集。现在让我们完成剩下的分类任务逻辑。在训练过程中,通过几个步骤后打印出来,可以有效地监控错误数据。代码如下:

        # Print training error every 100 steps
        if step % 100 == 0:
            print(step, 'loss:', float(loss))

由于 loss 是张量类型的 TensorFlow,因此可以通过 float()函数将其转换为标准的 Python 浮点数。在几个步骤或几个历元训练之后,可以执行测试(验证)以获得模型的当前性能,例如:

        if step % 500 == 0: # Do a test every 500 steps
            # evaluate/test

现在让我们用张量运算函数来完成精度的实际计算。首先考虑一个批量样本 x。网络的预测值可以通过如下正向计算获得:

            for x, y in test_db: # Iterate through test dataset
                h1 = x @ w1 + b1 # 1st layer
                h1 = tf.nn.relu(h1) # Activation function
                h2 = h1 @ w2 + b2 # 2nd layer
                h2 = tf.nn.relu(h2) # Activation function
                out = h2 @ w3 + b3 # Output layer

预测值的形状是[ b ,10]。它表示样本属于每个类别的概率。我们根据 tf.argmax 函数选择出现最大概率的索引号,这是样本最可能的类别号:

                # Select the max probability category
                pred = tf.argmax(out, axis=1)

由于 y 已经在预处理中被一键编码,我们可以类似地得到 y 的类别号:

                y = tf.argmax(y, axis=1)

使用 tf.equal,我们可以比较两个结果是否相等:

                correct = tf.equal(pred, y)

对结果中所有 True(转换为 1)元素的数量求和,这是正确的预测数量:

                total_correct += tf.reduce_sum(tf.cast(correct, dtype=tf.int32)).numpy()

将正确的预测数除以测试总数,得到准确度,并将其打印出来,如下所示:

             # Calcualte accuracy
            print(step, 'Evaluate Acc:', total_correct/total)

在用 20 个历元训练一个简单的三层神经网络后,我们在测试集上取得了 87.25%的准确率。如果我们使用复杂的神经网络模型并微调网络超参数,我们可以获得更好的精度。训练误差曲线如图 5-7 所示,测试精度曲线如图 5-8 所示。

img/515226_1_En_5_Fig8_HTML.jpg

图 5-8

MNIST 测试精度

img/515226_1_En_5_Fig7_HTML.jpg

图 5-7

MNIST 培训损失

六、神经网络

很难想象哪个大行业不会被人工智能改变。人工智能将在这些行业中发挥主要作用,这一趋势非常明显。

—吴恩达

机器学习的最终目的是找到一组好的参数,让模型从训练集中学习映射关系fθ:xyxyDtrain,利用训练好的关系预测新的样本。神经网络属于机器学习研究的一个分支。具体指用多个神经元对映射函数 f θ 进行参数化的模型。

6.1 感知机

1943 年,美国神经科学家沃伦·斯特吉斯·麦卡洛克和数学逻辑学家沃尔特·皮茨受到生物神经元结构的启发,提出了人工神经元的数学模型,美国神经物理学家弗兰克·罗森布拉特进一步发展并提出了这一模型,被称为感知器模型。1957 年,Frank Rosenblatt 在 IBM-704 计算机上实现了感知器模型。该模型可以完成一些简单的视觉分类任务,如区分三角形、圆形和矩形[1]。

感知器模型的结构如图 6-1 所示。它接受一个长度为 nx = [ x 1x 2 ,…,xn]的一维向量,每个输入节点通过一个权重连接 w i

$$ z={w}_1;{x}_1+{w}_2;{x}_2+\cdots +{w}_n;{x}_n+b $$

其中, b 称为感知器的偏差,一维向量w=【w1w 2 ,…, w n 称为感知器的权重,而 z 称为感知器的净激活值。

img/515226_1_En_6_Fig1_HTML.png

图 6-1

感知模型

前面的公式可以写成向量形式:

$$ z={w}^Tx+b $$

感知器是线性模型,不能处理线性不可分性。激活值通过在线性模型之后添加激活函数来获得:

$$ a=\sigma (z)=\sigma \left({w}^Tx+b\right) $$

激活函数可以是阶跃函数。如图 6-2 所示,阶跃函数的输出只有 0/1。当z0 时,则输出 0,代表类别 0;当 z ≥ 0 时,1 为输出,代表类别 1,即:

$$ a=\Big{1\ {w}^Tx+b\ge 0\ 0\ {w}^Tx+b<0 $$

也可以是如图 6-3 所示的符号函数,表达式为:

$$ a=\Big{1\ {w}^Tx+b\ge 0-1\ {w}^Tx+b<0 $$

img/515226_1_En_6_Fig3_HTML.jpg

图 6-3

符号函数

img/515226_1_En_6_Fig2_HTML.jpg

图 6-2

阶跃函数

加入激活函数后,感知器模型可以用来完成二元分类任务。阶跃函数和符号函数在 z = 0 处不连续,因此梯度下降算法不能用于优化参数。

为了使感知器模型能够自动从数据中学习,Frank Rosenblatt 提出了一种感知器学习算法,如算法 1 所示。

| 算法 1:感知器训练算法 | | `Initialize`***w =***0***,b =*** **0**重复从训练集中随机选择一个样本(***x******I***,***y******I***)计算输出 ***a =符号***(***w******T******x******I******+b***)`If`***a≥y******【I】*****w*****;【w+y】******【I】*****【b】*****;【b+ y】******【I】***直到达到所需的步数`Output:parameters`***w***`and`*b* |

这里 η 是学习率。

虽然感知器模型已经被提出,具有很好的发展潜力,但是马文·李·闵斯基和西蒙·派珀特在 1969 年的《感知器》一书中证明了以感知器为代表的线性模型无法解决线性不可分性问题(XOR ),这直接导致了当时神经网络研究出现谷底。虽然感知器模型不能解决线性不可分问题,但书中也提到可以通过嵌套多层神经网络来解决。

6.2 全连接层

感知器模型的不可驱动性严重限制了它的潜力,使它只能解决极其简单的任务。事实上,现代深度学习模型的参数规模有几百万甚至上亿,但核心结构与感知器模型并无太大区别。在感知器模型的基础上,他们用其他光滑连续可导的激活函数代替不连续的阶跃激活函数,并堆叠多个网络层以增强网络的表达能力。

在本节中,我们替换感知器模型的激活函数,并并行堆叠多个神经元,以实现多输入多输出的网络层结构。如图 6-4 所示,两个神经元并行堆叠,即两个激活函数被替换的感知器,形成三个输入节点和两个输出节点的网络层。第一个输出节点是:

$$ {o}_1=\sigma \left({w}_{11}\bullet {x}_1+{w}_{21}\bullet {x}_2+{w}_{31}\bullet {x}_3+{b}_1\right) $$

第二个节点的输出是:

$$ {o}_2=\sigma \left({w}_{12}\bullet {x}_1+{w}_{22}\bullet {x}_2+{w}_{32}\bullet {x}_3+{b}_2\right) $$

把它们放在一起,输出向量就是 o = [ o 1o 2 。整个网络层可以用矩阵关系来表示:

$$ \left[{o}_1\ {o}_2\ \right]=\left[{x}_1\ {x}_2\ {x}_3\ \right]@\left[{w}_{11}\ {w}_{12}\ {w}_{21}\ {w}_{22}\ {w}_{31}\ {w}_{32}\ \right]+\left[{b}_1\ {b}_2\ \right] $$

(6-1)

那就是:

$$ O=X@W+b $$

输入矩阵 X 的形状定义为 中的 bd ,而 中的样本数为 b 和输入节点数为 d 。权重矩阵 W 的形状定义为[dindout],而输出节点数为dout,偏移向量 b 的形状为[dout]。

考虑两个样本$$ {x}{(1)}=\left[{x}_1{(1)},{x}_2{(1)},{x}_3{(1)}\right] $$$$ {x}{(2)}=\left[{x}_1{(2)},{x}_2{(2)},{x}_3{(2)}\right] $$,前面的等式也可以写成:

$$ \left[{o}_1{(1)} {o}_2{(1)}\ {o}_1{(2)} {o}_2{(2)}\ \right]=\left[{x}_1{(1)} {x}_2{(1)}\ {x}_3{(1)} {x}_1{(2)}\ {x}_2{(2)} {x}_3{(2)}\ \right]@\left[{w}_{11}\ {w}_{12}\ {w}_{21}\ {w}_{22}\ {w}_{31}\ {w}_{32}\ \right]+\left[{b}_1\ {b}_2\ \right] $$

其中输出矩阵 O 包含了 b 样本的输出,形状为[ bd out ]。由于每个输出节点都连接到所有输入节点,所以这个网络层称为全连接层,或密集层,其中 W 为权重矩阵, b 为偏置向量。

img/515226_1_En_6_Fig4_HTML.png

图 6-4

全连接层

张量模式实施

在 TensorFlow 中,要实现全连通层,只需要定义权重张量 W 和偏置张量 b 并使用 TensorFlow 提供的批量矩阵乘法函数 tf.matmul()完成网络层的计算即可。例如,对于一个输入矩阵 X 有两个样本,每个样本的输入特征长度din= 784,输出节点数dout= 256,则权重矩阵 W 的形状为【784,256】。偏置向量的形状 b 是【256】。相加后,输出层的形状为[2,256],即每个特征长度为 256 的两个样本的特征。代码实现如下:

In [1]:
x = tf.random.normal([2,784])
w1 = tf.Variable(tf.random.truncated_normal([784, 256], stddev=0.1))
b1 = tf.Variable(tf.zeros([256]))
o1 = tf.matmul(x,w1) + b1  # linear transformation
o1 = tf.nn.relu(o1)  # activation function
Out[1]:
   <tf.Tensor: id=31, shape=(2, 256), dtype=float32, numpy=
   array([[ 1.51279330e+00,  2.36286330e+00,  8.16453278e-01,
           1.80338228e+00,  4.58602428e+00,  2.54454136e+00,...

事实上,我们已经多次使用上述代码来实现网络层。

层实现

全连接层本质上是矩阵乘法和加法运算。但作为最常用的网络层之一,TensorFlow 有一个更方便的实现方法:layers。密集(单位,激活)。穿过这层。密集类,只需要指定输出节点数(单位)和激活函数类型(activation)。需要注意的是,在第一次操作时,输入节点的数量将根据输入形状来确定,权重张量和偏置张量将根据输入和输出节点的数量自动创建和初始化。由于延迟评估,权重张量和偏差张量不会立即创建。需要构建函数或直接计算来完成网络参数的创建。激活参数指定当前层的激活函数,可以是常用激活函数,也可以是自定义激活函数,也可以指定为 none,即没有激活函数。

In [2]:
x = tf.random.normal([4,28*28])
from tensorflow.keras import  layers
# Create fully-connected layer with output nodes and activation function
fc = layers.Dense(512, activation=tf.nn.relu)
h1 = fc(x)  # calculate and return a new tensor
Out[2]:
<tf.Tensor: id=72, shape=(4, 512), dtype=float32, numpy=
array([[0.63339347, 0.21663809, 0\.        , ..., 1.7361937 , 0.39962345, 2.4346168 ],...

我们可以用前面代码中的一行代码创建一个全连接层 fc,输出节点数为 512,输入节点数在计算过程中自动获得。代码也自动创建内部权重张量和偏差张量。我们可以通过类内的类成员核和偏差来获得权重和偏差张量对象:

In [3]: fc.kernel # Get the weight tensor
Out[3]:
<tf.Variable 'dense_1/kernel:0' shape=(784, 512) dtype=float32, numpy=
array([[-0.04067389,  0.05240148,  0.03931375, ..., -0.01595572, -0.01075954, -0.06222073],
In [4]: fc.bias # Get the bias tensor
Out[4]:
<tf.Variable 'dense_1/bias:0' shape=(512,) dtype=float32, numpy=
array([0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,...])

可以看出,权重的形状和偏张量是符合我们的理解的。在优化参数时,我们需要获得网络中所有待优化张量参数的列表,这可以通过类 trainable _ variables 来完成。

In [5]: fc.trainable_variables
Out[5]:  # Return all parameters to be optimized
 [<tf.Variable 'dense_1/kernel:0' shape=(784, 512) dtype=float32,...,
 <tf.Variable 'dense_1/bias:0' shape=(512,) dtype=float32, numpy=...]

事实上,网络层不仅保存要优化的可训练变量列表,还保存不参与梯度优化的张量。例如,批处理规范化层可以通过 non _ trainable _ variables 成员返回所有不需要优化的参数列表。如果你想得到所有参数的列表,你可以通过类的 variables 成员得到所有内部张量,例如:

In [6]: fc.variables # Get all parameters
Out[6]:
[<tf.Variable 'dense_1/kernel:0' shape=(784, 512) dtype=float32,...,
 <tf.Variable 'dense_1/bias:0' shape=(512,) dtype=float32, numpy=...]

对于全连通层,所有内部张量都参与梯度优化,因此变量返回的列表与 trainable _ variables 相同。

在使用网络层类对象进行正向计算时,只需要调用该类的 call 方法,即以 fc(x)模式编写,它会自动调用 call 方法。该设置由 TensorFlow 框架自动完成。对于一个全连接的层类,在 call 方法中实现的操作逻辑非常简单。

6.3 神经网络

通过堆叠图 6-4 中完全连接的层,并确保前一层的输出节点数与当前层的输入节点数相匹配,可以创建任意层数的网络,这就是所谓的神经网络。如图 6-5 所示,通过堆叠四个全连接层,可以得到一个四层的神经网络。由于每一层都是全连通层,所以称为全连通网络。其中,第一至第三个全连接层称为隐层,最后一个全连接层的输出称为网络的输出层。隐藏层的输出节点数分别为[256,128,64],输出层的节点数为 10。

在设计全连通网络时,可以根据经验法则自由设置网络的配置等超参数,只需要遵循一些约束条件。例如,第一个隐藏层中输入节点的数量需要与数据的实际特征长度相匹配。每层中输入层的数量与前一层中输出节点的数量相匹配。输出层的激活函数和节点数需要根据所需输出的具体设置来设置。一般来说,神经网络模型的设计具有更大的自由度。如图 6-5 所示,每层输出节点数不一定非得是【256,128,64,10】可以自由搭配,比如【256,256,64,10】或者【512,64,32,10】。至于哪组超参数是最优的,需要大量的野外经验和实验。

img/515226_1_En_6_Fig5_HTML.png

图 6-5

四层神经网络

张量模式实施

对于图 6-5 这样的多层神经网络,需要分别定义每层的权矩阵和偏置向量。每层的参数只能用于对应的层,不能混用。图 6-5 中的网络模型实现如下:

# Hidden layer 1
w1 = tf.Variable(tf.random.truncated_normal([784, 256], stddev=0.1))
b1 = tf.Variable(tf.zeros([256]))
# Hidden layer 2
w2 = tf.Variable(tf.random.truncated_normal([256, 128], stddev=0.1))
b2 = tf.Variable(tf.zeros([128]))
# Hidden layer 3
w3 = tf.Variable(tf.random.truncated_normal([128, 64], stddev=0.1))
b3 = tf.Variable(tf.zeros([64]))
# Hidden layer 4
w4 = tf.Variable(tf.random.truncated_normal([64, 10], stddev=0.1))
b4 = tf.Variable(tf.zeros([10]))

计算时,只需要将上一层的输出作为当前层的输入,重复进行直到最后一层,将输出层的输出作为网络的输出。

        with tf.GradientTape() as tape:
            # x: [b, 28*28]
            #  Hidden layer 1 forward calculation, [b, 28*28] => [b, 256]
            h1 = x@w1 + tf.broadcast_to(b1, [x.shape[0], 256])
            h1 = tf.nn.relu(h1)
            # Hidden layer 2 forward calculation, [b, 256] => [b, 128]
            h2 = h1@w2 + b2
            h2 = tf.nn.relu(h2)
            # Hidden layer 3 forward calculation, [b, 128] => [b, 64]
            h3 = h2@w3 + b3
            h3 = tf.nn.relu(h3)
            # Output layer forward calculation, [b, 64] => [b, 10]
            h4 = h3@w4 + b4

最后一层是否需要加入激活功能,通常取决于具体的任务。

使用 TensorFlow 自动求导功能计算梯度时,正演计算过程需要放在 tf 中。GradientTape()环境,这样就可以使用 GradientTape 对象的 gradient()方法自动求解参数的渐变,参数由 optimizers 对象更新。

层模式实现

对于常规的网络层,通过层的方法实现更加简洁高效。首先,创建新的网络层类,并指定每层的激活功能类型:

#  Import layers modules
from tensorflow.keras import layers,Sequential

fc1 = layers.Dense(256, activation=tf.nn.relu) #  Hidden layer 1
fc2 = layers.Dense(128, activation=tf.nn.relu) #  Hidden layer 2
fc3 = layers.Dense(64, activation=tf.nn.relu) #  Hidden layer 3
fc4 = layers.Dense(10, activation=None) #  Output layer
x = tf.random.normal([4,28*28])
h1 = fc1(x)  #  Get output of hidden layer 1
h2 = fc2(h1) #  Get output of hidden layer 2
h3 = fc3(h2) #  Get output of hidden layer 3
h4 = fc4(h3) #  Get the network output

对于这样一个数据依次转发的网络,也可以通过顺序容器封装成一个网络类对象,调用一次类的转发计算函数,完成所有层的转发计算。使用起来更方便,实现如下:

from tensorflow.keras import layers,Sequential

#  Encapsulate a neural network through Sequential container
model = Sequential([
    layers.Dense(256, activation=tf.nn.relu) , # Hidden layer 1
    layers.Dense(128, activation=tf.nn.relu) , # Hidden layer 2
    layers.Dense(64, activation=tf.nn.relu) , # Hidden layer 3
    layers.Dense(10, activation=None) , # Output layer
])

在正演计算中,只需要调用一次大型网络对象就可以完成所有图层的顺序计算:

out = model(x)

优化

我们把神经网络从输入到输出的计算过程称为前向传播。神经网络的前向传播过程也是数据张量从第一层流向输出层的过程。即从输入数据开始,通过每个隐层传递张量,直到得到输出并计算误差,这也是 TensorFlow 框架名称的由来。

正向传播的最后一步是完成误差计算:

$$ L=g\left({f}_{\theta }(x),y\right) $$

在前面的公式中,fθT5(。)表示参数为 θ 的神经网络模型。 g (。)称为误差函数,用于描述当前网络的预测值fθ(x)与真实标签 y 的差距,如常用的均方误差函数。 L 称为网络的误差或损耗,一般为标量。我们希望通过学习训练集Dtrain上的一组参数来最小化训练误差 L :

前面的最小化问题通常使用反向传播算法来求解,并使用梯度下降算法来迭代更新参数:

$$ {\theta}^{\prime }=\theta -\eta \bullet {\nabla}_{\theta }L $$

其中 η 为学习率。

从另一个角度来理解神经网络,它完成特征维数变换的功能,如四层 MNIST 手写数字图像识别全连通网络,依次完成 784 → 256 → 128 → 64 → 10 的特征降维过程。原始特征通常具有更高的维度,并且包含许多低级特征和无用信息。通过逐层的特征变换,较高的维度被降低到较低的维度,其中通常产生与任务高度相关的高级抽象特征信息,并且通过这些特征的简单逻辑确定,例如图片的分类,可以完成特定的任务。

网络参数的数量是衡量网络规模的重要指标。那么如何计算全连通层的参数量呢?考虑一个网络层,权重矩阵 W ,偏置向量 b ,输入特征长度 d in ,输出特征长度doutW 的参数个数为dindout。加上 bias 参数,参数总数为dindout+dout。对于多层全连接神经网络,例如 784 → 256 → 128 → 64 → 10,总参数量的表达式为:

$$ 256\bullet 784+256+128\bullet 256+128+64\bullet 128+64+10\bullet 64+10=242762 $$

全连接层是最基本的神经网络类型。这对后续神经网络模型的研究非常重要,如卷积神经网络和循环神经网络。通过学习其他网络类型,我们会发现它们或多或少都源于全连接层网络的思想。由于 Geoffrey Hinton、Yoshua Bengio、Yann LeCun 坚持在神经网络前沿研究,为人工智能的发展做出了突出贡献,获得了 2018 年图灵奖(图 6-6 ,从右起分别为 Yann LeCun、Geoffrey Hinton、Yoshua Bengio)。

img/515226_1_En_6_Fig6_HTML.jpg

图 6-6

2018 图灵奖获奖名单 1

6.4 激活功能

下面,我们介绍神经网络中常见的激活函数。与阶跃函数和符号函数不同,这些函数是光滑的和可导的,并且适用于梯度下降算法。

乙状结肠

Sigmoid 函数也称为逻辑函数,定义如下:

$$ Sigmoid(x)\triangleq \frac{1}{1+{e}^{-x}} $$

它的一个优秀特性是能够将输入 xR 压缩到一个区间 x ∈ (0,1)。这个区间的值在机器学习中常用来表示以下含义:

  • 概率分布区间(0,1)的输出匹配概率的分布范围。输出可以通过 Sigmoid 函数转换成概率

  • 信号强度通常 0~1 可以理解为某个信号的强度,比如像素的颜色强度:1 代表当前通道颜色最强,0 代表当前通道没有颜色。也可以用来表示当前闸门状态,即 1 表示打开,0 表示关闭。

Sigmoid 函数是连续可导的,如图 6-7 所示。梯度下降算法可直接用于优化网络参数。

img/515226_1_En_6_Fig7_HTML.jpg

图 6-7

Sigmoid 函数

在 TensorFlow 中,Sigmoid 函数可以通过 tf.nn.sigmoid 函数实现,如下所示:

In [7]:x = tf.linspace(-6.,6.,10)
x # Create input vector -6~6
Out[7]:
<tf.Tensor: id=5, shape=(10,), dtype=float32, numpy=
array([-6\.       , -4.6666665, -3.3333333, -2\.       , -0.6666665,
        0.666667 ,  2\.       ,  3.333334 ,  4.666667 ,  6\.       ]...
In [8]:tf.nn.sigmoid(x) # Pass x to Sigmoid function
Out[8]:
<tf.Tensor: id=7, shape=(10,), dtype=float32, numpy=
array([0.00247264, 0.00931597, 0.03444517, 0.11920291, 0.33924365, 0.6607564 , 0.8807971 , 0.96555483, 0.99068403, 0.9975274 ],
      dtype=float32)>

如您所见,向量中元素值的范围[6,6]映射到区间(0,1)。

6.4.2 ReLU

在 ReLU(整流线性单元)之前,提出了激活函数;Sigmoid 函数通常是神经网络激活函数的首选。但是,当 Sigmoid 函数的输入值过大或过小时,梯度值接近于 0,这就是所谓的梯度弥散现象。出现这种现象时,网络参数会长时间不更新,导致训练不收敛的现象。梯度分散现象更可能发生在较深的网络模型中。2012 年提出的八层 AlexNet 模型使用了一个名为 ReLU 的激活函数,使得网络层数达到 8 层。此后,ReLU 函数的应用越来越广泛。ReLU 函数定义为:

$$ ReLU(x)\triangleq \mathit{\max}\left(0,x\right) $$

功能曲线如图 6-8 所示。可以看出,ReLU 抑制所有小于 0 到 0 的值;对于正数,它直接输出。这种单方面的抑制特征来自于生物学。2001 年,神经科学家 Dayan 和 Abott 模拟了一个更精确的大脑神经元激活模型,如图 6-9 所示。具有单边压制、激发边界相对宽松等特点。ReLU 函数的设计与其非常相似[2]。

img/515226_1_En_6_Fig9_HTML.jpg

图 6-9

人脑的激活功能[2]

img/515226_1_En_6_Fig8_HTML.png

图 6-8

ReLU 函数

在 TensorFlow 中,ReLU 函数可以通过 tf.nn.relu 函数实现如下:

In [9]:tf.nn.relu(x)
Out[9]:
<tf.Tensor: id=11, shape=(10,), dtype=float32, numpy=
array([0\.      , 0\.      , 0\.      , 0\.      , 0\.      , 0.666667,       2\.      , 3.333334, 4.666667, 6\.      ], dtype=float32)>

可以看到,ReLU 激活函数后,负数全部被抑制为 0,正数被保留。

除了使用函数接口 tf.nn.relu 实现 relu 函数外,ReLU 函数还可以像密集层一样作为网络层添加到网络中。对应的类是层。ReLU()。一般来说,激活函数类不是主要的网络计算层,不计入网络层数。

ReLU 函数的设计源自神经科学。函数值和导数值的计算非常简单。同时具有优秀的梯度特性。在大量深度学习应用中已经被验证非常有效。

6.4.3 泄漏事故

x <为 0 时,ReLU 函数的导数始终为 0,这也可能造成梯度分散。为了克服这个问题,提出了 LeakyReLU 功能(图 6-10 )。

$$ LeakyReLU\triangleq \Big{x\kern1em x\ge 0\kern0.5em px\ x<0 $$

其中 p 是用户设定的小值,比如 0.02。当 p = 0 时,LeakyReLU 函数退化为 ReLU 函数。当 p ≠ 0 时,可以在 x < 0 处得到一个小的导数值,从而避免了梯度分散的现象。

img/515226_1_En_6_Fig10_HTML.png

图 6-10

LeakyReLU 函数

在 TensorFlow 中,LeakyReLU 函数可以通过 tf.nn.leaky_relu 实现如下:

In [10]:tf.nn.leaky_relu(x, alpha=0.1)
Out[10]:
<tf.Tensor: id=13, shape=(10,), dtype=float32, numpy=
array([-0.6       , -0.46666667, -0.33333334, -0.2       , -0.06666666,
        0.666667  ,  2\.        ,  3.333334  ,  4.666667  ,  6\.        ],
      dtype=float32)>

alpha 参数代表 p 。tf.nn.leaky_relu 对应的类是 layers.LeakyReLU,可以通过 LeakyReLU(alpha)创建一个 LeakyReLU 网络层,设置参数 p 。和密集层一样,LeakyReLU 层可以放在网络上合适的位置。

6.4.4 谭

双曲正切函数可以将输入 xR 压缩到一个区间(1,1),定义如下:

$$ tanhtanh\ (x)=\frac{\left({e}x-{e}{-x}\right)}{\left({e}x+{e}{-x}\right)} $$

$$ =2\bullet sigmoid(2x)-1 $$

可以看出,通过 Sigmoid 函数缩放平移后可以实现 Tanh 激活功能,如图 6-11 所示。

img/515226_1_En_6_Fig11_HTML.png

图 6-11

Tanh 函数

在 Tensorflow 中,可以使用 tf.nn.tanh 实现 Tanh 函数,如下所示:

In [11]:tf.nn.tanh(x)
Out[11]:
<tf.Tensor: id=15, shape=(10,), dtype=float32, numpy=
array([-0.9999877 , -0.99982315, -0.997458  , -0.9640276 , -0.58278286, 0.5827831 ,  0.9640276 ,  0.997458  ,  0.99982315,  0.9999877 ],
      dtype=float32)>

可以看到,向量元素值的范围映射到(1,1)。

6.5 输出层的设计

我们来具体讨论一下网络最后一层的设计。除了所有的隐藏层,它完成了维度变换和特征提取的功能,它也作为一个输出层。需要根据具体的任务来决定是否使用激活功能以及使用什么类型的激活功能。

我们将根据输出值的范围对讨论进行分类。常见的输出类型包括:

  • oIRd输出属于整个实数空间,或实数空间的某一部分,如函数值趋势预测、年龄预测问题。

  • o

  • oI∈【0,1】,∈IoI= 1 输出值落在区间[0,1]内,所有输出值之和为 1。常见的问题包括多分类问题,如 MNIST 手写数字图片识别,该图片属于十类的概率之和应为 1。

  • oI∈[1,1]输出值在-1 和 1 之间。

6.5.1 常见实数空间

这类问题比较常见。比如正弦函数曲线、年龄预测、股票走势预测都属于连续实数空间的整体或部分,输出层可能没有激活函数。误差的计算直接基于最后一层的输出 o 和真值 y 。例如,均方误差函数用于测量输出值 o 和真实值 y 之间的距离:

$$ L=g\left(o,y\right) $$

其中 g 代表特定的误差计算函数,如 MSE。

[0,1]区间

输出值属于区间[0,1]也很常见,比如图像生成,二值分类问题。在机器学习中,图像像素值一般归一化为区间[0,1]。如果直接使用输出图层的值,像素值范围将分布在整个实数空间中。为了将像素值映射到有效实数空间[0,1],需要在输出层之后添加合适的激活函数。在这里,Sigmoid 函数是一个很好的选择。

同样,对于二进制分类问题,比如硬币的正面和反面的预测,输出层只能是一个节点就是一个事件 A 发生的概率 P ( x )给网络输入 x,如果我们用网络的输出标量 o 来表示正面事件发生的概率,那么负面事件发生的概率就是 1o。网络结构如图 6-12 所示。

$$ P(x)=o $$

$$ P(x)=1-o $$

img/515226_1_En_6_Fig12_HTML.png

图 6-12

具有单个输出节点的二元分类网络

在这种情况下,只需在输出图层的值后添加 Sigmoid 函数,即可将输出转换为概率值。对于二进制分类问题,除了用单个输出节点来表示事件 A P ( x )的发生概率外,还可以分别预测 P ( x )和 P ( x ),并满足约束条件:

$$ P(x)+P(x)=1 $$

其中$$ \underset{_}{A} $$表示事件 a 的相反事件,如图 6-13 所示,二元分类网络的输出层为两个节点。第一个节点的输出值代表事件 A * P * ( x )发生的概率,第二个节点的输出值代表相反事件 P ( x )发生的概率。该函数只能将单个值压缩到区间(0,1),并且不考虑两个节点值之间的关系。我们希望它们除了满足oI∈【0,1】之外,还能满足概率之和为 1:

$$ {\sum}_i{o}_i=1 $$

这种情况就是下一节要介绍的问题设置。

img/515226_1_En_6_Fig13_HTML.png

图 6-13

具有两个输出的二元分类网络

6.5.3 和为 1 的[0,1]区间

对于输出值oI∈【0,1】,且所有输出值之和为 1 的情况,是多分类最常见的问题。如图 6-15 所示,输出层的每个输出节点代表一个类别。图中的网络结构用于处理三种分类任务。三个节点的输出值分布代表当前样本属于类别 A、B、C 的概率: P ( x )、P(B|x)、P(C|x)。因为多分类问题中的样本只能属于其中一个类别,所以所有类别的概率之和应该是 1。

如何实现这个约束逻辑?这可以通过向输出层添加 Softmax 函数来实现。Softmax 函数定义为:

$$ Softmax\left({z}_i\right)\triangleq \frac{e{z_i}}{\sum_{j=1}{d_{out}}{e}^{z_j}} $$

Softmax 函数不仅可以将输出值映射到区间[0,1],还可以满足所有输出值之和为 1 的特性。如图 6-14 中的例子所示,输出层的输出为[2.0,1.0,0.1]。通过 Softmax 函数后,输出变为[0.7,0.2,0.1]。每个值代表当前样本属于每个类别的概率,概率值之和为 1。输出层的输出可以通过 Softmax 函数转换为类别概率,该函数在分类问题中非常常用。

img/515226_1_En_6_Fig15_HTML.png

图 6-15

多分类网络结构

img/515226_1_En_6_Fig14_HTML.png

图 6-14

Softmax 函数示例

在 TensorFlow 中,Softmax 函数可以通过 tf.nn.softmax 实现如下:

In [12]: z = tf.constant([2.,1.,0.1])
tf.nn.softmax(z)
Out[12]:
<tf.Tensor: id=19, shape=(3,), dtype=float32, numpy=array([0.6590012, 0.242433 , 0.0985659], dtype=float32)>

与密集图层类似,Softmax 函数也可以用作网络层类。通过图层添加 Softmax 图层很方便。Softmax (axis = -1)类,其中 axis 参数指定要计算的维度。

在 Softmax 函数的数值计算过程中,由于输入值较大,容易出现数值溢出现象。当计算交叉熵时,可能发生类似的问题。对于数值计算的稳定性,TensorFlow 提供了一个统一的接口,同时实现 Softmax 和交叉熵损失函数,并处理数值不稳定的异常。通常建议使用这些接口函数。函数接口为 TF . keras . loss . categorial _ cross entropy(y_true,y_pred,from_logits = False),其中 y _ true 表示独热编码真标签,y_pred 表示网络的预测值。当 from_logits 设置为 True 时,y_pred 表示没有经过 Softmax 函数的变量 z。当 from_logits 设置为 False 时,y_pred 表示为 Softmax 函数的输出。对于数值计算稳定性,一般将 from_logits 设置为 True,这样 TF . keras . loss . category _ cross entropy 会在内部进行 Softmax 函数计算,不需要在模型中显式显式调用 Softmax 函数。例如:

In [13]:
z = tf.random.normal([2,10]) # Create output of the output layer
y_onehot = tf.constant([1,3]) # Create real label
y_onehot = tf.one_hot(y_onehot, depth=10) # one-hot encoding
# The Softmax function is not explicitly used in output layer, so
# from_logits=True. categorical_cross-entropy function will use Softmax
# function first in this case.
loss = keras.losses.categorical_crossentropy(y_onehot,z,from_logits=True)
loss = tf.reduce_mean(loss) # calculate the loss
loss
Out[13]:
<tf.Tensor: id=210, shape=(), dtype=float32, numpy= 2.4201946>

除了功能界面,你还可以使用损耗。CategoricalCrossentropy(from _ logits)类方法来同时计算 Softmax 和交叉熵损失函数。例如:

In [14]:
criteon = keras.losses.CategoricalCrossentropy(from_logits=True)
loss = criteon(y_onehot,z)
loss
Out[14]:
<tf.Tensor: id=258, shape=(), dtype=float32, numpy= 2.4201946>

区间(-1,1)

如果希望输出值的范围以区间(1,1)分布,只需使用双曲正切函数即可:

In [15]:
x = tf.linspace(-6.,6.,10)
tf.tanh(x)
Out[15]:
<tf.Tensor: id=264, shape=(10,), dtype=float32, numpy=
array([-0.9999877 , -0.99982315, -0.997458  , -0.9640276 , -0.58278286, 0.5827831 ,  0.9640276 ,  0.997458  ,  0.99982315,  0.9999877 ],
      dtype=float32)>

输出层的设计具有一定的灵活性,可以根据实际应用场景进行设计,并充分利用现有激活功能的特点。

6.6 误差计算

建立模型结构后,下一步是选择合适的误差函数来计算误差。常见的误差函数有均方误差、交叉熵、KL 散度和铰链损耗。其中,均方误差函数和交叉熵函数在深度学习中较为常见。均方差函数主要用于回归问题,交叉熵函数主要用于分类问题。

均方差函数

均方误差(MSE)函数将输出向量和真实向量映射到笛卡尔坐标系中的两个点,通过计算这两个点之间的欧几里德距离(准确地说,欧几里德距离的平方)来测量这两个向量之间的差异:

$$ MSE\left(y,o\right)\triangleq \frac{1}{d_{out}}{\sum}_{i=1}^{d_{out}}{\left({y}_i-{o}_i\right)}² $$

MSE 的值总是大于或等于 0。当 MSE 函数达到最小值 0 时,输出等于真实标签,神经网络的参数达到最优状态。

MSE 函数广泛用于回归问题。实际上,MSE 函数也可以用于分类问题。在 TensorFlow 中,MSE 计算可以以函数或层的方式实现。例如,使用如下函数实现 MSE 计算:

In [16]:
o = tf.random.normal([2,10]) # Network output
y_onehot = tf.constant([1,3]) # Real label
y_onehot = tf.one_hot(y_onehot, depth=10)
loss = keras.losses.MSE(y_onehot, o) # Calculate MSE
loss
Out[16]:
<tf.Tensor: id=27, shape=(2,), dtype=float32, numpy=array([0.779179 , 1.6585705], dtype=float32)>

特别是,MSE 函数返回每个样本的均方误差。您需要在样本维度上再次求平均值,以获得平均样本的均方误差。实现如下:

In [17]:
loss = tf.reduce_mean(loss)
loss
Out[17]:
<tf.Tensor: id=30, shape=(), dtype=float32, numpy=1.2188747>

它也可以在层模式下实现。对应的类是 keras . loss . meansquadererror()。和其他类一样,可以调用 call 函数来完成正向计算。代码如下:

In [18]:
criteon = keras.losses.MeanSquaredError()
loss = criteon(y_onehot,o)
loss
Out[18]:
<tf.Tensor: id=54, shape=(), dtype=float32, numpy=1.2188747>

6.6.2 交叉熵误差函数

在引入交叉熵损失函数之前,我们先介绍一下信息学中熵的概念。1948 年,Claude Shannon 将热力学中熵的概念引入信息论,用来度量信息的不确定性。熵在信息科学中也叫信息熵或香农熵。熵越大,不确定性越大,信息量越大。分布的熵 P ( i )定义为:

$$ H(P)\triangleq -{\sum}_iP(i)P(i) $$

事实上,也可以使用其他基函数。例如,对于四类分类问题,如果样本的真实标签是类别 4,那么标签的一键编码就是[0,0,0,1]。即这张图片的分类是唯一确定的,属于不确定度为 0 的类别 4,其熵可以简单计算为:

$$ -0\bullet 0-0\bullet 0-0\bullet 0-1\bullet 1=0 $$

也就是说,对于某个分布,熵为 0,不确定性最低。

如果预测的概率分布是[0.1,0.1,0.1,0.7],那么它的熵可以计算为:

$$ -0.1\bullet 0.1-0.1\bullet 0.1-0.1\bullet 0.1-0.7\bullet 0.7\approx 1.356 $$

考虑一个随机分类器,它对每个类别的预测概率是相等的:[0.25,0.25,0.25,0.25]。同理,可以计算出它的熵约为 2,这种情况下的不确定性略大于前一种情况。

因为,熵总是大于等于 0。当熵达到最小值 0 时,不确定度为 0。分类问题的一热码分布就是熵为 0 的典型例子。在 TensorFlow 中,我们可以用 tf.math.log 来计算熵。

在引入熵的概念后,我们将基于熵推导出交叉熵的定义:

$$ H\left(p\Big\Vert q\right)\triangleq -{\sum}_ip(i)q(i) $$

通过变换,交叉熵可以分解为熵和 KL 散度之和(Kullback-Leibler 散度):

$$ H\left(p\Big\Vert q\right)=H(p)+{D}_{KL}\left(p\Big\Vert q\right) $$

其中 KL 散度为:

$$ {D}_{KL}\left(p\Big\Vert q\right)={\sum}_ip(i)\mathit{\log}\left(\frac{p(i)}{q(i)}\right) $$

KL 散度是 Solomon Kullback 和 Richard A. Leibler 在 1951 年使用的一个指标,用来衡量两个分布之间的距离。当 p = q 时,DKL(pq)的最小值为 0。 pq 相差越大,DKL(pq)越大。应当注意,交叉熵和 KL 散度都不是对称的,即:

$$ H\left(p\Big\Vert q\right)\ne H\left(q\Big\Vert p\right) $$

$$ {D}_{KL}\left(p\Big\Vert q\right)\ne {D}_{KL}\left(q\Big\Vert p\right) $$

交叉熵是两个分布之间“距离”的一个很好的度量。特别是当分类问题中 y 的分布采用一热编码时, H ( p ) = 0。然后,

$$ H\left(p\Big\Vert q\right)=H(p)+{D}_{KL}\left(p\Big\Vert q\right)={D}_{KL}\left(p\Big\Vert q\right) $$

也就是说,交叉熵退化为真实标签分布和输出概率分布之间的 KL 散度。

根据 KL 散度的定义,我们推导出分类问题中交叉熵的计算表达式:

$$ H\left(p\Big\Vert q\right)={D}_{KL}\left(p\Big\Vert q\right)={\sum}_j{y}_j\mathit{\log}\left(\frac{y_j}{o_j}\right) $$

$$ =1\bullet \mathit{\log}\frac{1}{o_i}+{\sum}_{j\ne i}0\bullet \mathit{\log}\left(\frac{0}{o_j}\right) $$

$$ =-\mathit{\log}{o}_i $$

其中 I 是独热编码中 1 的索引号,也是实范畴。可以看出,交叉熵只与实范畴上的概率oI有关,对应的概率 o i 越大,H(pq越小。当相应类别上的概率为 1 时,交叉熵达到最小值 0。此时网络输出与真实标签完全一致,神经网络获得最优状态。

因此,最小化交叉熵损失函数的过程也是最大化正确类别预测概率的过程。从这个角度来看,理解交叉熵损失函数是非常直观和容易的。

6.7 神经网络的类型

全连接层是神经网络最基本的类型,它为神经网络的后续研究做出了巨大的贡献。全连通层的正演计算过程比较简单,梯度求导也比较简单,但是有一个最大的缺陷。在处理特征长度较大的数据时,全连通层的参数量往往较大,使得全连通网络的参数数量庞大,难以训练。近年来,社交媒体的发展产生了大量的图片、视频、文本等数字资源,极大地推动了神经网络在计算机视觉和自然语言处理领域的研究,并相继提出了一系列神经网络类型。

6.7.1 卷积神经网络

如何识别、分析和理解图片、视频等数据是计算机视觉的一个核心问题。全连接层在处理高维图片和视频数据时,往往存在网络参数庞大、训练非常困难等问题。Yann Lecun 于 1986 年提出了卷积神经网络(CNN)。随着深度学习的繁荣,卷积神经网络在计算机视觉中的性能已经大大超越了其他算法,呈现出统治计算机视觉领域的趋势。用于图像分类的流行模型包括 AlexNet、VGG、GoogLeNet、ResNet 和 DenseNet。对于客观识别,有 RCNN、快速 RCNN、更快 RCNN、掩蔽 RCNN、YOLO 和 SSD。我们将在第十章中详细介绍卷积神经网络的原理。

6.7.2 循环神经网络

除了具有空间结构的图片、视频等数据,序列信号也是一种非常常见的数据类型。最有代表性的序列信号之一是文本。如何处理和理解文本数据是自然语言处理的一个核心问题。由于缺乏记忆机制和处理不定长信号的能力,卷积神经网络不擅长处理序列信号。在 Yoshua Bengio,Jürgen Schmidhuber 等人的不断研究下,循环神经网络(RNN)被证明在处理序列信号方面非常出色。1997 年,Jürgen Schmidhuber 提出了 LSTM 网络。作为 RNN 的变体,它更好地克服了 RNN 缺乏长期记忆和不擅长处理长序列的问题。LSTM 在自然语言处理中得到了广泛应用。基于 LSTM 模型,Google 提出了机器翻译的 Seq2Seq 模型,并成功应用于 Google 神经机器翻译系统(GNMT)中。其他 RNN 变体包括 GRU 和双向 RNN。我们将在第十一章中详细介绍循环神经网络的原理。

6.7.3 注意机制网络

RNN 不是自然语言处理的最终解决方案。近年来,注意力机制的提出,克服了 RNN 的不足,如训练不稳定、难以并行化等。在自然语言处理、图像生成等领域逐渐崭露头角。注意机制最初是在图像分类任务上提出的,但逐渐开始在自然语言处理中变得更加有效。2017 年,谷歌提出了第一个使用纯注意力机制的网络模型 Transformer,随后基于 Transformer 模型,又相继提出了 GPT、伯特、GPT-2 等一系列用于机器翻译的注意力网络模型。在其他领域,基于注意机制尤其是自我注意机制的网络也取得了不错的效果,比如 BigGAN 模型。

6.7.4 图形卷积神经网络

图片和文本等数据具有规则的空间或时间结构,称为欧几里德数据。卷积神经网络和循环神经网络非常擅长处理这种类型的数据。对于像一系列不规则的空间拓扑、社交网络、通讯网络、蛋白质分子结构这样的数据,那些网络似乎无能为力。2016 年,Thomas Kipf 等人提出了基于一阶近似谱卷积算法的图卷积网络(GCN)模型。GCN 算法实现简单,可以从空间一阶邻居信息聚合的角度直观地理解,因此在半监督任务上取得了良好的效果。随后,一系列网络模型被提出,如 GAT、EdgeConv 和 DeepGCN。

6.8 汽车油耗预测实践

在本节中,我们将使用全连接网络模型来完成汽车 MPG(每加仑英里数)的预测。

数据集

我们使用 auto MPG 数据集,其中包括各种车辆性能指标的真实数据和其他因素,如气缸数量、重量和马力。数据集的前五项如表 6-1 所示。此外,数字字段 origin 表示类别,其他字段都是数字类型。对于原产地,1 表示美国,2 表示欧洲,3 表示日本。

表 6-1

自动 MPG 数据集的前五项

|

每加仑行驶英里数

|

圆筒

|

排水量

|

马力

|

重量

|

加速

|

年型

|

起源

|
| --- | --- | --- | --- | --- | --- | --- | --- |
| Eighteen | eight | Three hundred and seven | One hundred and thirty | Three thousand five hundred and four | Twelve | Seventy | one |
| Fifteen | eight | Three hundred and fifty | One hundred and sixty-five | Three thousand six hundred and ninety-three | Eleven point five | Seventy | one |
| Eighteen | eight | Three hundred and eighteen | One hundred and fifty | Three thousand four hundred and thirty-six | Eleven | Seventy | one |
| Sixteen | eight | Three hundred and four | One hundred and fifty | Three thousand four hundred and thirty-three | Twelve | Seventy | one |
| Seventeen | eight | Three hundred and two | One hundred and forty | Three thousand four hundred and forty-nine | Ten point five | Seventy | one |

自动 MPG 数据集总共包括 398 条记录。我们将数据集从 UCI 服务器下载并读取到 DataFrame 对象中。代码如下:

# Download the dataset online
dataset_path = keras.utils.get_file("auto-mpg.data", "http://archive.ics.uci.edu/ml/machine-learning-databases/auto-mpg/auto-mpg.data")
# Use Pandas library to read the dataset
column_names = ['MPG','Cylinders','Displacement','Horsepower','Weight', 'Acceleration', 'Model Year', 'Origin']
raw_dataset = pd.read_csv(dataset_path, names=column_names,
                      na_values = "?", comment='\t',
                      sep=" ", skipinitialspace=True)
dataset = raw_dataset.copy()
# Show some data
dataset.head()

原始表中的数据可能包含丢失的值。这些记录项目需要清除:

dataset.isna().sum() # Calculate the number of missing values
dataset = dataset.dropna() # Drop missing value records
dataset.isna().sum() # Calculate the number of missing values again

清除后,数据集记录项减少到 392 项。

由于 origin 字段是分类数据,我们首先将其删除,然后将其转换为三个新字段,USA、Europe 和 Japan,这三个字段表示它们是否来自该原点:

origin = dataset.pop('Origin')
dataset['USA'] = (origin == 1)*1.0
dataset['Europe'] = (origin == 2)*1.0
dataset['Japan'] = (origin == 3)*1.0
dataset.tail()

将数据分为训练(80%)和测试(20%)数据集:

train_dataset = dataset.sample(frac=0.8,random_state=0)
test_dataset = dataset.drop(train_dataset.index)

将 MPG 移出并使用其真正的标签:

train_labels = train_dataset.pop('MPG')
test_labels = test_dataset.pop('MPG')

计算训练集每个字段值的均值和标准差,完成数据的标准化,通过 norm()函数;代码如下:

train_stats = train_dataset.describe()
train_stats.pop("MPG")
train_stats = train_stats.transpose()
# Normalize the data
def norm(x): # minus mean and divide by std
  return (x - train_stats['mean']) / train_stats['std']
normed_train_data = norm(train_dataset)
normed_test_data = norm(test_dataset)

打印训练和测试数据集的形状:

print(normed_train_data.shape,train_labels.shape)
print(normed_test_data.shape, test_labels.shape)
(314, 9) (314,) # 314 records in training dataset with 9 features.
(78, 9) (78,) # 78 records in training dataset with 9 features.

创建 TensorFlow 数据集:

train_db = tf.data.Dataset.from_tensor_slices((normed_train_data.values, train_labels.values))
train_db = train_db.shuffle(100).batch(32) # Shuffle and batch

我们可以通过简单观察数据集中各场之间的分布来观察各场对 MPG 的影响,如图 6-16 所示。大致可以观察到,汽车排量、重量、MPG 之间的关系比较简单。随着排量或重量的增加,汽车的 MPG 降低,能耗增加;缸数越少,MPG 就能越好,符合我们的生活经验。

img/515226_1_En_6_Fig16_HTML.jpg

图 6-16

特征之间的关系

6.8.2 创建网络

考虑到自动 MPG 数据集的规模较小,我们仅创建一个三层全连接网络来完成 MPG 预测任务。有九个输入要素,因此第一层的输入节点数为 9。第一层和第二层的输出节点数设计为 64 和 64。由于预测值只有一种,所以输出层的输出节点设计为 1。因为 MPG 属于实数空间,所以可以不增加输出层的激活功能。

我们将网络实现为自定义的网络类。我们只需要在初始化函数中创建每个子网络层,在正向计算函数中实现自定义网络类的计算逻辑。自定义网络类继承自 keras。模型类,这也是自定义网络类的标准编写方法,为了方便使用 keras 提供的 trainable _ variables、save_weights 等各种方便的函数。模特班。网络模型类的实现如下:

class Network(keras.Model):
    # regression network
    def __init__(self):
        super(Network, self).__init__()
        # create 3 fully-connected layers
        self.fc1 = layers.Dense(64, activation='relu')
        self.fc2 = layers.Dense(64, activation='relu')
        self.fc3 = layers.Dense(1)

    def call(self, inputs, training=None, mask=None):
        # pass through the 3 layers sequentially
        x = self.fc1(inputs)
        x = self.fc2(x)
        x = self.fc3(x)

        return x

培训和测试

创建主网络模型类后,让我们实例化网络对象并创建优化器,如下所示:

model = Network() # Instantiate the network
# Build the model with 4 batch and 9 features
model.build(input_shape=(4, 9))
model.summary() # Print the network
# Create the optimizer with learning rate 0.001
optimizer = tf.keras.optimizers.RMSprop(0.001)

接下来,实现网络培训部分。通过 Epoch 和 Step 组成的双层循环训练网络,共训练 200 个 Epoch。

for epoch in range(200): # 200 Epoch
    for step, (x,y) in enumerate(train_db): # Loop through training set once
        # Set gradient tape
        with tf.GradientTape() as tape:
            out = model(x) # Get network output
            loss = tf.reduce_mean(losses.MSE(y, out)) # Calculate MSE
            mae_loss = tf.reduce_mean(losses.MAE(y, out)) # Calculate MAE

        if step % 10 == 0: # Print training loss every 10 steps
            print(epoch, step, float(loss))
        # Calculate and update gradients
        grads = tape.gradient(loss, model.trainable_variables)
        optimizer.apply_gradients(zip(grads, model.trainable_variables))

对于回归问题,除了均方误差(MSE)之外,平均绝对误差(MAE)也可以用来衡量模型的性能。

$$ mae\triangleq \frac{1}{d_{out}}{\sum}_i\left|{y}_i-{o}_i\right| $$

我们可以在训练和测试数据集的每个历元结束时记录 MAE,并绘制如图 6-17 所示的变化曲线。

img/515226_1_En_6_Fig17_HTML.jpg

图 6-17

MAE 曲线

可以看出,当训练达到大约第 25 个历元时,MAE 的下降变得较慢,其中训练集的 MAE 继续缓慢下降,但测试集的 MAE 几乎保持不变,所以我们可以在第 25 个历元左右结束训练,利用此时的网络参数来预测新的输入。

6.9 参考文献

  1. 尼克,2017。人工智能简史。

  2. X.Glorot,A. Bordes 和 Y. Bengio,“深度稀疏整流器神经网络”,第十四届人工智能和统计国际会议论文集,美国佛罗里达州劳德代尔堡,2011 年。

  3. J.Mizera-Pietraszko 和 P. Pichappan,《实时智能系统讲义》,施普林格国际出版公司,2017 年。

七、反向传播算法

回顾的时间越长,就能向前看的越远。

—丘吉尔

在第六章中,我们已经系统地介绍了基本的神经网络算法:从输入输出的表示出发;介绍感知器模型、多输入多输出全连接层;然后扩展到多层神经网络。我们还介绍了不同场景下输出层的设计和常用的损失函数及其实现。

本章我们将从理论层面学习神经网络中的核心算法之一:误差反向传播(BP)。实际上,反向传播算法早在 60 年代初就已经提出,但一直没有引起业界的重视。1970 年,Seppo Linnainmaa 在其硕士论文中提出了自动链推导方法,并实现了反向传播算法。1974 年,Paul Werbos 在其博士论文中首次提出了将反向传播算法应用于神经网络的可能性,但遗憾的是,Paul Werbos 并未发表后续的相关研究。事实上,Paul Werbos 认为这种研究思路对于解决感知机问题是有意义的,但由于人工智能的寒冬,社区普遍失去了解决那些问题的信念。直到大约 10 年后的 1986 年,Geoffrey Hinton 等人将反向传播算法应用于神经网络[1],使得反向传播算法在神经网络界轰轰烈烈。

通过深度学习框架的自动推导和自动参数更新功能,算法设计人员无需深入了解反向传播算法就可以构建复杂的模型和网络,并可以通过调用优化工具轻松训练网络模型。然而,反向传播算法和梯度下降算法是神经网络的核心,深入理解其原理非常重要。我们先回顾导数、梯度等数学概念,然后推导常用的激活函数和损失函数的梯度形式,开始逐步推导感知器和多层神经网络的梯度传播方法。如果你想刷新你的记忆或者学习更多的线性代数和微积分知识,[2]和[3]有更多的细节。

7.1 导数和梯度

高中的时候我们接触到了导数的概念,定义为当自变量 x 产生轻微扰动时∈x趋近于零时函数输出值的增量∈y与自变量 x 的增量∈x之比的极限:

$$ a=\frac{\Delta  y}{\Delta  x}=\frac{f\left(x+\Delta  x\right)-f(x)}{\Delta  x} $$

函数 f ( x )的导数可以写成f(x)或者$$ \frac{dy}{dx} $$。从几何的角度来说,一元函数的导数就是这里函数的切线的斜率,也就是函数值沿着 x 方向的变化率。考虑一个物理学上的例子,比如自由落体运动的位移函数的表达式$$ y=\frac{1}{2}g{t}² $$。对时间的导数是$$ \frac{dy}{dt}=\frac{d\frac{1}{2}g{t}²}{dt}= gt $$。考虑到速度 v 定义为位移的变化率,那么位移对时间的导数就是速度,即 v = gt

事实上,导数是一个非常宽泛的概念。因为我们之前遇到的函数大多是一元函数,所以自变量只有两个方向: x +x-。当函数的自变量个数大于 1 时,函数导数的概念推广到函数值在任意方向的变化率。导数本身是标量,没有方向,但是导数表征了函数值在某个方向上的变化率。在这些任意方向中,沿着坐标轴的几个方向比较特殊,也叫偏导数。对于一元函数,导数写成$$ \frac{dy}{dx} $$。对于多元函数的偏导数,记为$$ \frac{\partial y}{\partial {x}_1},\frac{\partial y}{\partial {x}_2},\cdots $$。偏导数是导数的特例,没有方向。

考虑一个本质上是多元函数的神经网络模型,比如 shape [784,256]的一个权重矩阵 W ,其中包含 784 × 256 的连接权重,我们需要求 784 × 256 的偏导数。需要注意的是,在数学表达习惯中,要讨论的自变量一般记为 x ,但在神经网络中,一般用来表示输入,如图片、文本、语音数据等。网络的自变量是网络参数集 θ = { w 1b 1w 2b 2 、⋯}.当使用梯度下降算法优化网络时,需要请求网络的所有偏导数。所以我们也关注误差函数 L 沿自变量 θ i 方向输出的导数,即$$ \frac{\partial L}{\partial {w}_1},\frac{\partial L}{\partial {b}_1},\cdots $$。用向量形式写出函数的所有偏导数:

$$ {\nabla}_{\theta }L=\left(\frac{\partial L}{\partial {\theta}_1},\frac{\partial L}{\partial {\theta}_2},\frac{\partial L}{\partial {\theta}_3},\cdots, \frac{\partial L}{\partial {\theta}_n}\right) $$

梯度下降算法可以以矢量的形式更新:

$$ {\theta}^{\prime }=\theta -\eta \bullet {\nabla}_{\theta }L $$

η是学习率。梯度下降算法一般是求函数 L 的最小值,有时也希望求函数的最大值,这就需要按如下方式更新梯度:

$$ {\theta}^{\prime }=\theta +\eta \bullet {\nabla}_{\theta }L $$

这种更新方法称为梯度上升算法。梯度下降算法和梯度上升算法在原理上是相同的。一个是向渐变的反方向更新,一个是向渐变的方向更新。都需要解偏导数。这里向量$$ \left(\frac{\partial L}{\partial {\theta}_1},\frac{\partial L}{\partial {\theta}_2},\frac{\partial L}{\partial {\theta}_3},\cdots, \frac{\partial L}{\partial {\theta}_n}\right) $$称为函数的梯度,由所有偏导数组成,代表方向。梯度的方向表示函数值上升最快的方向,梯度的反向表示函数值下降最快的方向。

梯度下降算法不能保证全局最优解,这主要是由于目标函数的非凸性造成的。考虑图 7-1 中的非凸函数。深蓝色区域是最小区域。不同的优化轨迹可能获得不同的最优数值解。这些数值解不一定是全局最优解。

img/515226_1_En_7_Fig1_HTML.png

图 7-1

非凸函数示例

神经网络模型表达式通常非常复杂,模型参数可达数千万或上亿级。几乎所有的神经网络优化问题都依赖于深度学习框架来自动计算网络参数的梯度,然后使用梯度下降来迭代优化网络参数,直到性能满足要求。深度学习框架中实现的主要算法是反向传播和梯度下降算法。所以了解这两种算法的原理有助于理解深度学习框架的作用。

在介绍多层神经网络的反向传播算法之前,我们首先介绍导数的公共属性、公共激活函数的梯度导数和损失函数,然后推导多层神经网络的梯度传播规律。

7.2 衍生产品的共同属性

本节介绍常用函数的求导规则和示例说明,为神经网络相关函数的求导做铺垫。

7.2.1 常见衍生工具

  • 常数函数 c 的导数为 0。比如 y = 2 的导数就是$$ \frac{dy}{dx}=0 $$

  • 线性函数 y = ax + c 的导数为 a 。比如 y = 2 x + 1 的导数就是$$ \frac{dy}{dx}=2 $$

  • 函数xT3a的导数为axa—1。比如y=x2的导数就是$$ \frac{dy}{dx}=2x $$

  • 指数函数的导数ax是axln ln a。比如y=ex的导数就是$ \frac{dy}{dx}={e}^x lnln\ e={e}^x $

  • 对数函数 x 的导数为$$ \frac{1}{xln\ a} $$。比如 y = lnln x 的导数就是$$ \frac{dy}{dx}=\frac{1}{xln\ e}=\frac{1}{x} $$

7.2.2 衍生产品的共同属性

  • (f+g)=f+g

  • (fg)=【F5】【g】+【f】

  • $$ {\left(\frac{f}{g}\right)}^{\prime }=\frac{f^{\prime }g-f{g}^{\prime }}{g²} $$g0

  • Consider function of function f (g(x)), let u = g(x), the derivative is:

    $$ \frac{df\left(g(x)\right)}{dx}=\frac{df(u)}{du}\frac{dg(x)}{dx}={f}^{\prime }(u)\bullet {g}^{\prime }(x) $$

7.2.3 实践衍生产品发现

考虑目标函数l=xw2+b2,其导数为:

$$ \frac{\partial L}{\partial w}=\frac{\partial x\bullet {w}²}{\partial w}=x\bullet 2w $$

$$ \frac{\partial L}{\partial b}=\frac{\partial {b}²}{\partial b}=2b $$

考虑目标函数l=xew+eb,其导数为:

$$ \frac{\partial L}{\partial w}=\frac{\partial x\bullet {e}^w}{\partial w}=x\bullet {e}^w $$

$$ \frac{\partial L}{\partial b}=\frac{\partial {e}^b}{\partial b}={e}^b $$

考虑到目标函数L=y-(xw+b)2=[(xw+b)-y-2,设g=xw+b**

考虑目标函数L=AlN(xw+b),设g=xw+b*,导数为:

![$$ \frac{\partial L}{\partial w}=a\bullet \frac{\partial \mathit{\ln}(g)}{\partial g}\bullet \frac{\partial g}{\partial w}=a\bullet \frac{1}{g}\bullet \frac{\partial g}{\partial w}=\frac{a}{xw+b}\bullet x $$$$ \frac{\partial L}{\partial b}=a\bullet \frac{\partial \mathit{\ln}(g)}{\partial g}\bullet \frac{\partial g}{b}=a\bullet \frac{1}{g}\bullet \frac{\partial g}{\partial b}=\frac{a}{xw+b} $$

7.3 激活函数的导数

这里我们介绍神经网络中常用的激活函数的推导。

7 . 3 . 1 Sigmoid 函数的导数

乙状结肠函数的表达式为:

$$ \sigma (x)=\frac{1}{1+{e}^{-x}} $$

让我们推导出 Sigmoid 函数的导数表达式:

$$ \frac{d}{dx}\sigma (x)=\frac{d}{dx}\left(\frac{1}{1+{e}^{-x}}\right) $$

$$ =\frac{e{-x}}{{\left(1+{e}{-x}\right)}²} $$

$$ =\frac{\left(1+{e}{-x}\right)-1}{{\left(1+{e}{-x}\right)}²} $$

$$ =\frac{1+{e}{-x}}{{\left(1+{e}{-x}\right)}²}-{\left(\frac{1}{1+{e}^{-x}}\right)}² $$

$$ =\sigma (x)-\sigma {(x)}² $$

$$ =\sigma \left(1-\sigma \right) $$

可以看出,Sigmoid 函数的导数表达式最终可以表示为激活函数输出值的简单运算。利用这个性质,我们可以在神经网络的梯度计算中,通过缓存各层 Sigmoid 函数的输出值来计算它的导数。Sigmoid 函数的导函数如图 7-2 所示。

img/515226_1_En_7_Fig2_HTML.jpg

图 7-2

Sigmoid 函数及其导数

为了帮助理解反向传播算法的实现细节,本章选择不使用 TensorFlow 的自动求导功能。本章使用 Numpy 实现了一个由反向传播算法优化的多层神经网络。这里,Sigmoid 函数的导数由 Numpy 实现:

import numpy as np # import numpy library
def sigmoid(x): # implement sigmoid function
    return 1 / (1 + np.exp(-x))

def derivative(x):  # calculate derivative of sigmoid
    # Using the derived expression of the derivatives
    return sigmoid(x)*(1-sigmoid(x))

ReLU 函数的导数

回忆一下 ReLU 函数的表达式:

$$ ReLU(x)=\mathit{\max}\left(0,x\right) $$

其导数的推导很简单:

$$ \frac{d}{dx} ReLU=\Big{1\ x\ge 0\kern0.5em 0\ x<0 $$

可以看出 ReLU 函数的导数计算比较简单。当 x 大于或等于零时,导数值总是 1。在反向传播过程中,既不会放大梯度,造成梯度爆炸,也不会缩小梯度,造成梯度消失现象。ReLU 函数的导数曲线如图 7-3 所示。

img/515226_1_En_7_Fig3_HTML.png

图 7-3

ReLU 函数及其导数

在 ReLU 函数被广泛使用之前,神经网络中的激活函数大多是 Sigmoid。然而,Sigmoid 函数倾向于梯度分散。当网络的层数增加时,由于梯度值变得很小,网络的参数不能有效地更新。导致无法训练更深层次的神经网络,导致神经网络的研究停留在浅层次。ReLU 函数的引入,很好地缓解了梯度分散现象,神经网络的层数可以达到更深的层。比如 AlexNet 中使用 ReLU 激活功能,层数达到八层。一些超过 100 层的卷积神经网络也大多使用 ReLU 激活函数。

通过 Numpy,我们可以很容易地实现 ReLU 函数的求导,代码如下:

def derivative(x):  # Derivative of ReLU
    d = np.array(x, copy=True)
    d[x < 0] = 0
    d[x >= 0] = 1
    return d

7 . 3 . 3 leaky relu 函数的导数

回忆 LeakyReLU 函数的表达式:

$$ LeakyReLU=\Big{x\ x\ge 0\kern0.5em px\ x<0 $$

其导数可以推导为:

$$ \frac{d}{dx} LeakyReLU=\Big{1\ x\ge 0\kern0.5em p\ x<0 $$

它与 ReLU 函数不同是因为当 x 小于零时,LeakyReLU 函数的导数值不是 0,而是一个常数 p,一般设置为较小的值,如 0.01 或 0.02。LeakyReLU 函数的导数曲线如图 7-4 所示。

img/515226_1_En_7_Fig4_HTML.png

图 7-4

LeakyReLU 函数及其导数

LeakyReLU 函数有效地克服了 ReLU 函数的缺陷,也得到广泛的应用。我们可以通过 Numpy 实现 LeakyReLU 函数的导数如下:

def derivative(x, p): # p is the slope of negative part of LeakyReLU
    dx = np.ones_like(x)  # initialize a vector with 1
    dx[x < 0] = p  # set negative part to p
    return dx

7.3.4 双曲正切函数的导数

回忆一下 Tanh 函数的表达式:

$$ \mathit{\tanh}\ (x)=\frac{\left({e}x-{e}{-x}\right)}{\left({e}x+{e}{-x}\right)} $$

$$ =2\bullet sigmoid(2x)-1 $$

其衍生表达式为:

$$ \frac{d}{dx} tanhtanh\ (x)=\frac{\left({e}x+{e}{-x}\right)\left({e}x+{e}{-x}\right)-\left({e}x-{e}{-x}\right)\left({e}x-{e}{-x}\right)}{{\left({e}x+{e}{-x}\right)}²} $$

$$ =1-\frac{{\left({e}x-{e}{-x}\right)}²}{{\left({e}x+{e}{-x}\right)}²}=1-(x) $$

双曲正切函数及其导数曲线如图 7-5 所示。

img/515226_1_En_7_Fig5_HTML.png

图 7-5

双曲正切函数及其导数

在 Numpy 中,双曲正切函数的导数通过 Sigmoid 函数实现,如下所示:

def sigmoid(x):  # sigmoid function
    return 1 / (1 + np.exp(-x))

def tanh(x):  # tanh function
    return 2*sigmoid(2*x) - 1

def derivative(x):  # derivative of tanh
    return 1-tanh(x)**2

7.4 损失函数的梯度

前面已经介绍了常见的损失函数。这里我们主要推导均方误差损失函数和交叉熵损失函数的梯度表达式。

7.4.1 均方误差函数的梯度

均方误差损失函数表达式为:

$$ L=\frac{1}{2}{\sum}_{k=1}^K{\left({y}_k-{o}_k\right)}² $$

上式中的$$ \frac{1}{2} $$项是为了简化计算,也可以用$$ \frac{1}{K} $$来取平均值来代替。这些缩放操作都不会改变渐变方向。那么它的偏导数$$ \frac{\partial L}{\partial {o}_i} $$就可以展开为:

$$ \frac{\partial L}{\partial {o}_i}=\frac{1}{2}{\sum}_{k=1}^K\frac{\partial }{\partial {o}_i}{\left({y}_k-{o}_k\right)}² $$

由复合函数的导数定律分解:

$$ \frac{\partial L}{\partial {o}_i}=\frac{1}{2}{\sum}_{k=1}^K2\bullet \left({y}_k-{o}_k\right)\bullet \frac{\partial \left({y}_k-{o}_k\right)}{\partial {o}_i} $$

即:

$$ \frac{\partial L}{\partial {o}_i}={\sum}_{k=1}^K\left({y}_k-{o}_k\right)\bullet -1\bullet \frac{\partial {o}_k}{\partial {o}_i} $$

$$ ={\sum}_{k=1}^K\left({o}_k-{y}_k\right)\bullet \frac{\partial {o}_k}{\partial {o}_i} $$

考虑到其他情况下 k = i$$ \frac{\partial {o}_k}{\partial {o}_i} $$为 0 时$$ \frac{\partial {o}_k}{\partial {o}_i} $$为 1,即偏导数$$ \frac{\partial L}{\partial {o}_i} $$只与第 i 个节点有关,所以可以去掉上式中的求和符号。均方误差函数的导数可以表示为:

$$ \frac{\partial L}{\partial {o}_i}=\left({o}_i-{y}_i\right) $$

7.4.2 交叉熵函数的梯度

在计算交叉熵损失函数时,Softmax 函数和交叉熵函数一般以统一的方式实现。我们首先导出 Softmax 函数的梯度,然后导出交叉熵函数的梯度。

Softmax 的渐变回忆 soft max 的表情:

$$ {p}_i=\frac{e{z_i}}{\sum_{k=1}K{e}^{z_k}} $$

其作用是将输出节点的值转换成概率,并保证概率之和为 1,如图 7-6 所示。

img/515226_1_En_7_Fig6_HTML.png

图 7-6

Softmax 图解

回忆:

$$ f(x)=\frac{g(x)}{h(x)} $$

函数的导数是:

$$ {f}^{\prime }(x)=\frac{g^{\prime }(x)h(x)-{h}^{\prime }(x)g(x)}{h{(x)}²} $$

对于 Softmax 功能,$$ g(x)={e}^{z_i} $$$$ h(x)={\sum}_{k=1}K{e}{z_k} $$。我们将在两种情况下推导其梯度: i = jIj

  • i = j. The derivative of Softmax $$ \frac{\partial {p}_i}{\partial {z}_j} $$ is:

    $$ \frac{\partial {p}_i}{\partial {z}_j}=\frac{\partial \frac{e{z_i}}{\sum_{k=1}K{e}^{z_k}}}{\partial {z}_j}=\frac{e{z_i}{\sum}_{k=1}K{e}{z_k}-{e}{z_j}{e}{z_i}}{{\left({\sum}_{k=1}K{e}^{z_k}\right)}²} $$

$$ =\frac{e{z_i}\left({\sum}_{k=1}K{e}{z_k}-{e}{z_j}\right)}{{\left({\sum}_{k=1}K{e}{z_k}\right)}²} $$

$$ =\frac{e{z_i}}{\sum_{k=1}K{e}^{z_k}}\times \frac{\left({\sum}_{k=1}K{e}{z_k}-{e}{z_j}\right)}{\sum_{k=1}K{e}^{z_k}} $$

前面的表达式是 p i 和 1pjpI=pj的乘积。所以当 i = j 时,Softmax $$ \frac{\partial {p}_i}{\partial {z}_j} $$的导数为:

$$ \frac{\partial {p}_i}{\partial {z}_j}={p}_i\left(1-{p}_j\right),i=j $$

  • i ≠ j. Extend the Softmax function:

    $$ \frac{\partial {p}_i}{\partial {z}_j}=\frac{\partial \frac{e{z_i}}{\sum_{k=1}K{e}^{z_k}}}{\partial {z}_j}=\frac{0-{e}{z_j}{e}{z_i}}{{\left({\sum}_{k=1}K{e}{z_k}\right)}²} $$

$$ =\frac{-{e}{z_j}}{\sum_{k=1}K{e}^{z_k}}\times \frac{e{z_i}}{\sum_{k=1}K{e}^{z_k}} $$

也就是:

$$ \frac{\partial {p}_i}{\partial {z}_j}=-{p}_j\cdotp {p}_i $$

可以看出,虽然 Softmax 函数的梯度求导过程略显复杂,但最终的表达式还是非常简洁的。偏导数表达式如下:

$$ \frac{\partial {p}_i}{\partial {z}_j}=\Big{{p}_i\left(1-{p}_j\right)\ when\ i=j\kern0.5em -{p}_i\cdotp {p}_j\kern2em when\ i\ne j $$

交叉熵函数的梯度考虑交叉熵损失函数的表达式:

$$ L=-{\sum}_k{y}_k; loglog\ \left({p}_k\right) $$

这里我们直接推导出最终损耗值 L 对网络输出的 logits 变量zI的偏导数,展开为:

$$ \frac{\partial L}{\partial {z}_i}=-{\sum}_k{y}_k\frac{\partial loglog\ \left({p}_k\right)\ }{\partial {z}_i} $$

将复合函数 log log h 分解为:

$$ =-{\sum}_k{y}_k\frac{\partial loglog\ \left({p}_k\right)\ }{\partial {p}_k}\bullet \frac{\partial {p}_k}{\partial {z}_i} $$

也就是:

$$ =-{\sum}_k{y}_k\frac{1}{p_k}\bullet \frac{\partial {p}_k}{\partial {z}_i} $$

其中$$ \frac{\partial {p}_k}{\partial {z}_i} $$是我们已经推导出的 Softmax 函数的偏导数。

将求和符号拆分为两种情况: k = ikI,代入$$ \frac{\partial {p}_k}{\partial {z}_i} $$的表达式,我们可以得到:

$$ \frac{\partial L}{\partial {z}_i}=-{y}_i\left(1-{p}_i\right)-{\sum}_{k\ne i}{y}_k\frac{1}{p_k}\left(-{p}_k\cdotp {p}_i\right) $$

$$ =-{y}_i\left(1-{p}_i\right)+{\sum}_{k\ne i}{y}_k\cdotp {p}_i $$

$$ =-{y}_i+{y}_i{p}_i+{\sum}_{k\ne i}{y}_k\cdotp {p}_i $$

也就是:

$$ \frac{\partial L}{\partial {z}_i}={p}_i\left({y}_i+{\sum}_{k\ne i}{y}_k\right)-{y}_i $$

具体来说,分类问题中标签的一键编码方式有如下关系:

$$ {\sum}_k{y}_k=1 $$

$$ {y}_i+{\sum}_{k\ne i}{y}_k=1 $$

因此,交叉熵的偏导数可以进一步简化为:

$$ \frac{\partial L}{\partial {z}_i}={p}_i-{y}_i $$

7.5 全连接层的坡度

在介绍了梯度的基本知识之后,我们正式进入了神经网络的反向传播算法的推导。神经网络的结构是多样的,不可能一一分析梯度表达式。我们将使用具有全连接层网络的神经网络,使用 Sigmoid 函数作为激活函数,使用 softmax + MSE 损失函数作为误差函数来导出梯度传播定律。

7.5.1 单一神经元的梯度

对于一个使用 Sigmoid 激活函数的神经元模型,其数学模型可以写成:

$$ {o}^{(1)}=\sigma \left({w{(1)}}Tx+{b}^{(1)}\right) $$

变量的上标代表层数。例如, o (1) 代表第一层的输出, x 为网络的输入。我们以权重参数 w j 1 的偏导数求导$$ \frac{\partial L}{\partial {w}_{j1}} $$为例。为了便于演示,我们绘制了如图 7-7 所示的神经元模型。图中未示出 Bias b ,输入节点数为 j,从第 j 个节点的输入到输出 o (1) 的权重连接记为$$ {w}_{j1}^{(1)} $$,其中上标表示权重参数所属的层数,下标表示当前连接的起始节点数和结束节点数。例如,下标 j 1 表示上一层的第 j 个节点到当前层的第一个节点。激活函数 σ 之前的变量称为$$ {z}_1^{(1)} $$,激活函数 σ 之后的变量称为$$ {o}_1^{(1)} $$。因为只有一个输出节点,所以$$ {o}_1{(1)}={o}{(1)}=o $$。误差值 L 由输出和实际标签之间的误差函数计算。

img/515226_1_En_7_Fig7_HTML.png

图 7-7

神经元模型

如果使用均方误差函数,考虑到单个神经元只有一个输出$$ {o}_1^{(1)} $$,那么损耗可以表示为:

$$ L=\frac{1}{2}{\left({o}_1^{(1)}-t\right)}²=\frac{1}{2}{\left({o}_1-t\right)}² $$

其中, t 为真实标签值。加入$$ \frac{1}{2} $$不影响梯度的方向,计算更简单。我们以Jth(J∈【1, J )节点的权重变量 w * j * 1 为例,考虑损失函数 L :

$$ \frac{\partial L}{\partial {w}_{j1}}=\left({o}_1-t\right)\frac{\partial {o}_1}{\partial {w}_{j1}} $$

的偏导数$$ \frac{\partial L}{\partial {w}_{j1}} $$

考虑到o1=σ(z1)以及 Sigmoid 函数的导数为σ=σ(1-σ),我们有:

$$ \frac{\partial L}{\partial {w}_{j1}}=\left({o}_1-t\right)\frac{\partial \sigma \left({z}_1\right)}{\partial {w}_{j1}} $$

$$ =\left({o}_1-t\right)\sigma \left({z}_1\right)\left(1-\sigma \left({z}_1\right)\right)\frac{\partial {z}_1^{(1)}}{\partial {w}_{j1}} $$

把σ ( z 1 )写成o1:

$$ \frac{\partial L}{\partial {w}_{j1}}=\left({o}_1-t\right){o}_1\left(1-{o}_1\right)\frac{\partial {z}_1^{(1)}}{\partial {w}_{j1}} $$

考虑$$ \frac{\partial {z}_1^{(1)}}{\partial {w}_{j1}}={x}_j $$,我们有:

$$ \frac{\partial L}{\partial {w}_{j1}}=\left({o}_1-t\right){o}_1\left(1-{o}_1\right){x}_j $$

从上式可以看出,误差对权重的偏导数wj1只与输出值 o 1 ,真值 t ,以及连接到当前权重的输入 x j 有关。

7.5.2 全连接层的坡度

我们将单神经元模型推广为全连接层的单层网络,如图 7-8 所示。输入层通过全连接层获得输出向量 o (1) ,并计算与真实标签向量 t 的均方误差。输入节点数为 J,输出节点数为 K

img/515226_1_En_7_Fig8_HTML.png

图 7-8

全连接层

多输出全连接网络层模型与单神经元模型的区别在于它有更多的输出节点$$ {o}_1{(1)},{o}_2{(1)},{o}_3^{(1)},\cdots, {o}_K^{(1)} $$,每个输出节点对应一个真实的标签t1、t2、…、 t * K wJK*是第 j 个输入节点和第 k 个输出节点的连接权重。均方差可以表示为:

$$ L=\frac{1}{2}{\sum}_{i=1}K{\left({o}_i{(1)}-{t}_i\right)}² $$

由于$$ \frac{\partial L}{\partial {w}_{jk}} $$只与节点$$ {o}_k^{(1)} $$关联,所以可以去掉前面公式中的求和符号,即 i = * k * :

$$ \frac{\partial L}{\partial {w}_{jk}}=\left({o}_k-{t}_k\right)\frac{\partial {o}_k}{\partial {w}_{jk}} $$

代入ok=σ(zk):

$$ \frac{\partial L}{\partial {w}_{jk}}=\left({o}_k-{t}_k\right)\frac{\partial \sigma \left({z}_k\right)}{\partial {w}_{jk}} $$

考虑 Sigmoid 函数的导数σ=σ(1σ):

$$ \frac{\partial L}{\partial {w}_{jk}}=\left({o}_k-{t}_k\right)\sigma \left({z}_k\right)\left(1-\sigma \left({z}_k\right)\right)\frac{\partial {z}_k^{(1)}}{\partial {w}_{jk}} $$

σ(z【k】)写成【k】

*考虑$$ \frac{\partial {z}_k^{(1)}}{\partial {w}_{jk}}={x}_j $$ :

$$ \frac{\partial L}{\partial {w}_{jk}}=\left({o}_k-{t}_k\right){o}_k\left(1-{o}_k\right){x}_j $$

可以看出wJK的偏导数只与当前连接的输出节点$ {o}_k^{(1)}  {t}_k^{(1)} $,对应输入节点 x * j *

【k】=(

*变量 δ k 表征了连线末端节点误差梯度传播的某种特性。使用表示法 δ k 后,偏导数$$ \frac{\partial L}{\partial {w}_{jk}} $$只与当前连接的起始节点 x j 和结束节点 δ k 相关。后面我们会看到 δ k * 在循环推导梯度中的作用。

现在已经导出了单层神经网络(即输出层)的梯度传播法,接下来我们尝试导出倒数第二层的梯度传播法。在完成倒数第二层的传播推导后,类似地,可以循环推导所有隐层的梯度传播模式,得到所有层参数的梯度计算表达式。

在介绍反向传播算法之前,我们先学习导数传播的一个核心规则——链式规则。

7.6 链式法则

前面我们介绍了输出图层的渐变计算方法。我们现在引入链规则,这是一个核心公式,可以逐层推导梯度,而不需要显式推导神经网络的数学表达式。

事实上,在推导梯度的过程中,或多或少地使用了链式法则。考虑到复合函数 y = f ( u ), u = g ( x ),我们可以从$$ \frac{dy}{du} $$$$ \frac{du}{dx} $$ :

$$ \frac{dy}{dx}=\frac{dy}{du}\bullet \frac{du}{dx}={f}^{\prime}\left(g(x)\right)\bullet {g}^{\prime }(x) $$

推导出$$ \frac{dy}{dx} $$

考虑两个变量的复合函数 z = f ( xy ),其中x=g(t),y=h(t),那么导数$$ \frac{dz}{dt} $$可以由$$ \frac{\partial z}{\partial x} $$$$ \frac{\partial z}{\partial y} $$ :

比如$$ z={\left(2t+1\right)}²+{e}^{t²} $$,设 x = 2 * t * + 1, y = * t * 2 ,那么z=x2+ey。利用前面的公式,我们有:

$$ \frac{dz}{dt}=\frac{\partial z}{\partial x}\frac{dx}{dt}+\frac{\partial z}{\partial y}\frac{dy}{dt}=2x\bullet 2+{e}^y\bullet 2t $$

x = 2 t + 1、y=t2:

$$ \frac{dz}{dt}=2\left(2t+1\right)\bullet 2+{e}^{t²}\bullet 2t $$

也就是:

$$ \frac{dz}{dt}=4\left(2t+1\right)+2t{e}^{t²} $$

神经网络的损失函数 L 来自每个输出节点$$ {o}_k^{(K)} $$,如图 7-9 所示,其中输出节点$$ {o}_k^{(K)} $$与隐含层的输出节点$$ {o}_j^{(J)} $$关联,因此链式法则非常适合神经网络的梯度求导。让我们考虑如何将链式法则应用于损失函数。

img/515226_1_En_7_Fig9_HTML.png

图 7-9

梯度传播插图

在前向传播中,数据通过$$ {w}_{ij}^{(J)} $$到达倒数第二层的节点$$ {o}_j^{(J)} $$,然后传播到输出层的节点$$ {o}_k^{(K)} $$。当每层只有一个节点时,可以利用链式法则将$$ \frac{\partial L}{\partial {w}_{ij}^{(J)}} $$逐层分解为:

$$ \frac{\partial L}{\partial {w}_{ij}^{(J)}}=\frac{\partial L}{\partial {o}_j^{(J)}}\frac{\partial {o}_j^{(J)}}{\partial {w}_{ij}^{(J)}}=\frac{\partial L}{\partial {o}_k^{(K)}}\frac{\partial {o}_k^{(K)}}{\partial {o}_j^{(J)}}\frac{\partial {o}_j^{(J)}}{\partial {w}_{ij}^{(J)}} $$

在哪里中$$ \frac{\partial L}{\partial {o}_k^{(K)}} $$可以直接从误差函数中导出,而$$ \frac{\partial {o}_k^{(K)}}{\partial {o}_j^{(J)}} $$可以从全连接层公式中导出。导数$$ \frac{\partial {o}_j^{(J)}}{\partial {w}_{ij}^{(J)}} $$就是输入$$ {x}_i^{(I)} $$。可以看出,通过链式法则,对于$$ L=f\left({w}_{ij}^{(J)}\right) $$的导数,我们不需要具体的数学表达式;而是可以直接分解偏导数,逐层迭代求导。

这里我们简单用 TensorFlow 自动求导功能来体验一下链式法则的魅力。

import tensorflow as tf
# Create vectors
x = tf.constant(1.)
w1 = tf.constant(2.)
b1 = tf.constant(1.)
w2 = tf.constant(2.)
b2 = tf.constant(1.)
# Create gradient recorder
with tf.GradientTape(persistent=True) as tape:
    # Manually record gradient info for non-tf.Variable variables
    tape.watch([w1, b1, w2, b2])
    # Create two layer neural network
    y1 = x * w1 + b1
    y2 = y1 * w2 + b2

# Solve partial derivatives
dy2_dy1 = tape.gradient(y2, [y1])[0]
dy1_dw1 = tape.gradient(y1, [w1])[0]
dy2_dw1 = tape.gradient(y2, [w1])[0]

# Valdiate chain rule
print(dy2_dy1 * dy1_dw1)
print(dy2_dw1)

在前面的代码中,我们通过 Tensorflow 中的自动梯度计算和链式法则计算了$$ \frac{\partial {y}_2}{\partial {y}_1} $$ , $$ \frac{\partial {y}_1}{\partial {w}_1} $$ ,$$ \frac{\partial {y}_2}{\partial {w}_1} $$,我们知道$$ \frac{\partial {y}_2}{\partial {y}_1}\bullet \frac{\partial {y}_1}{\partial {w}_1} $$$$ \frac{\partial {y}_2}{\partial {w}_1} $$应该等于.,它们的结果如下:

tf.Tensor(2.0, shape=(), dtype=float32)
tf.Tensor(2.0, shape=(), dtype=float32)

7.7 反向传播算法

现在我们来推导一下隐藏层的渐变传播规律。简单回顾一下输出层的偏导数公式:

$$ \frac{\partial L}{\partial {w}_{jk}}=\left({o}_k-{t}_k\right){o}_k\left(1-{o}_k\right){x}_j={\delta}_k{x}_j $$

考虑倒数第二层$$ \frac{\partial L}{\partial {w}_{ij}} $$的偏导数,如图 7-10 所示。输出层节点数为 K,输出为$$ {o}{(K)}=\left[{o}_1{(K)},{o}_2^{(K)},\cdots, {o}_K^{(K)}\right] $$。倒数第二层有 J 个节点,输出是$$ {o}{(J)}=\left[{o}_1{(J)},{o}_2^{(J)},\cdots, {o}_J^{(J)}\right] $$。倒数第二层有 I 个节点,输出为$$ {o}{(I)}=\left[{o}_1{(I)},{o}_2^{(I)},\cdots, {o}_I^{(I)}\right] $$

img/515226_1_En_7_Fig10_HTML.png

图 7-10

反向传播算法

为了表达简洁,一些变量的上标有时被省略。首先,扩展均方误差函数:

$$ \frac{\partial L}{\partial {w}_{ij}}=\frac{\partial }{\partial {w}_{ij}}\frac{1}{2}{\sum}_k{\left({o}_k-{t}_k\right)}² $$

因为 L 通过各个输出节点 o kwij关联,这里不能去掉求和符号,可以用链式法则分解均方误差函数:

$$ \frac{\partial L}{\partial {w}_{ij}}={\sum}_k\left({o}_k-{t}_k\right)\frac{\partial }{\partial {w}_{ij}}{o}_k $$

代入ok=σ(zk):

$$ \frac{\partial L}{\partial {w}_{ij}}={\sum}_k\left({o}_k-{t}_k\right)\frac{\partial }{\partial {w}_{ij}}\sigma \left({z}_k\right) $$

Sigmoid 函数的导数是σ=σ(1—σ,所以:

$$ \frac{\partial L}{\partial {w}_{ij}}={\sum}_k\left({o}_k-{t}_k\right)\sigma \left({z}_k\right)\left(1-\sigma \left({z}_k\right)\right)\frac{\partial {z}_k}{\partial {w}_{ij}} $$

σ(zk写成 o k ,再考虑链式法则,我们有:

$$ \frac{\partial L}{\partial {w}_{ij}}={\sum}_k\left({o}_k-{t}_k\right){o}_k\left(1-{o}_k\right)\frac{\partial {z}_k}{\partial {o}_j}\cdotp \frac{\partial {o}_j}{\partial {w}_{ij}} $$

其中$$ \frac{\partial {z}_k}{\partial {o}_j}={w}_{jk} $$,所以:

$$ \frac{\partial L}{\partial {w}_{ij}}={\sum}_k\left({o}_k-{t}_k\right){o}_k\left(1-{o}_k\right){w}_{jk}\frac{\partial {o}_j}{\partial {w}_{ij}} $$

因为$$ \frac{\partial {o}_j}{\partial {w}_{ij}} $$k 没有关联,所以我们有:

$$ \frac{\partial L}{\partial {w}_{ij}}=\frac{\partial {o}_j}{\partial {w}_{ij}}{\sum}_k\left({o}_k-{t}_k\right){o}_k\left(1-{o}_k\right){w}_{jk} $$

因为oj=σ(zj)和σ=(1σ,我们有:

$$ \frac{\partial L}{\partial {w}_{ij}}={o}_j\left(1-{o}_j\right)\frac{\partial {z}_j}{\partial {w}_{ij}}{\sum}_k\left({o}_k-{t}_k\right){o}_k\left(1-{o}_k\right){w}_{jk} $$

其中$$ \frac{\partial {z}_j}{\partial {w}_{ij}} $$o i * ,所以:

img/515226_1_En_7_Figa_HTML.png

其中$$ {\delta}_k^{(K)}=\left({o}_k-{t}_k\right){o}_k\left(1-{o}_k\right) $$,所以:

$$ \frac{\partial L}{\partial {w}_{ij}}={o}_j\left(1-{o}_j\right){o}_i{\sum}_k{\delta}_k^{(K)}{w}_{jk} $$

类似于$$ \frac{\partial L}{\partial {w}_{jk}}={\delta}_k^{(K)}{x}_j $$的格式,将$$ {\delta}_j^J $$定义为:

$$ {\delta}_j^J\triangleq {o}_j\left(1-{o}_j\right){\sum}_k{\delta}_k^{(K)}{w}_{jk} $$

此时,$$ \frac{\partial L}{\partial {w}_{ij}} $$可以写成当前连接的起始节点的输出值 o * i * 和结束节点的梯度变量信息$$ {\delta}_j^{(J)} $$

$$ \frac{\partial L}{\partial {w}_{ij}}={\delta}_j{(J)}{o}_i{(I)} $$

的简单相乘

可以看出,通过定义变量 δ ,各层的梯度表达式变得更加清晰简洁,其中 δ 可以简单理解为当前权重 w ij 对误差函数的贡献值。

我们来总结一下各层偏导数的传播规律。

输出图层:

$$ \frac{\partial L}{\partial {w}_{jk}}={\delta}_k^{(K)}{o}_j $$

$$ {\delta}_k^{(K)}={o}_k\left(1-{o}_k\right)\left({o}_k-{t}_k\right) $$

倒数第二层:

$$ \frac{\partial L}{\partial {w}_{ij}}={\delta}_j^{(J)}{o}_i $$

$$ {\delta}_j{(J)}={o}_j\left(1-{o}_j\right){\sum}_k{\delta}_k{(K)}{w}_{jk} $$

倒数第二层:

$$ \frac{\partial L}{\partial {w}_{ni}}={\delta}_i^{(I)}{o}_n $$

$$ {\delta}_i{(I)}={o}_i\left(1-{o}_i\right){\sum}_j{\delta}_j{(J)}{w}_{ij} $$

其中 o n 为倒数第二层的输入。

根据这一规律,只需迭代计算每层各节点的$$ {\delta}_k^{(K)} $$$$ {\delta}_j^{(J)} $$$$ {\delta}_i^{(I)} $$值,即可得到当前层的偏导数,从而得到每层权重矩阵 W 的梯度,再通过梯度下降算法迭代优化网络参数。

至此,反向传播算法介绍完毕。

接下来我们将进行两个动手案例:第一个案例是使用 TensorFlow 提供的自动求导来优化 Himmelblau 函数的极值。第二种情况是实现基于 Numpy 的反向传播算法,完成针对二分类问题的多层神经网络训练。

7.8 Himmelblau 的实际优化

Himmelblau 函数是测试优化算法的常用示例函数之一。包含两个自变量 xy ,数学表达式为:

$$ f\left(x,y\right)={\left({x}²+y-11\right)}²+{\left(x+{y}²-7\right)}² $$

首先,我们通过以下代码实现 Himmelblau 函数的表达式:

def himmelblau(x):
    # Himmelblau function implementation. Input x is a list with 2 elements.
    return (x[0] ** 2 + x[1] - 11) ** 2 + (x[0] + x[1] ** 2 - 7) ** 2

然后我们完成 Himmelblau 函数的可视化。使用 np.meshgrid 函数(TensorFlow 中也有 meshgrid 函数)生成二维平面网格点坐标如下:

x = np.arange(-6, 6, 0.1) # x-axis
y = np.arange(-6, 6, 0.1) # y-axis
print('x,y range:', x.shape, y.shape)
X, Y = np.meshgrid(x, y)
print('X,Y maps:', X.shape, Y.shape)
Z = himmelblau([X, Y])

使用 Matplotlib 库可视化 Himmelblau 函数,如图 7-11 所示:

img/515226_1_En_7_Fig11_HTML.jpg

图 7-11

天蓝色功能

# Plot the Himmelblau function
fig = plt.figure('himmelblau')
ax = fig.gca(projection='3d')
ax.plot_surface(X, Y, Z)
ax.view_init(60, -30)
ax.set_xlabel('x')
ax.set_ylabel('y')
plt.show()

图 7-12 是 Himmelblau 功能的等高线图。大致可以看出它有四个局部极小点,局部极小值都是 0,所以这四个局部极小值也是全局极小值。我们可以通过分析方法计算局部最小值的精确坐标;他们是:

$$ \left(3,2\right),\left(-2.805,3.131\right),\left(-3.779,-3.283\right),\left(3.584,-1.848\right) $$

知道了极值的解析解,我们现在使用梯度下降算法来优化 Himmelblau 函数的最小数值解。

img/515226_1_En_7_Fig12_HTML.jpg

图 7-12

Himmelblau 函数等高线图

我们可以使用 TensorFlow 自动求导来查找函数和的偏导数,并迭代更新和值,如下所示:

# The influence of the initialization value of the parameter on the optimization cannot be ignored, you can try different initialization values # Test the minimum value of function optimization
# [1., 0.], [-4, 0.], [4, 0.]
x = tf.constant([4., 0.]) # Initialization

for step in range(200):# Loop 200 times
    with tf.GradientTape() as tape: #record gradient
        tape.watch([x]) # Add to the gradient recording list
        y = himmelblau(x) # forward propagation
    # backward propagration
    grads = tape.gradient(y, [x])[0]
    # update paramaters with learning rate of 0.01
    x -= 0.01*grads
    # print info
    if step % 20 == 19:
        print ('step {}: x = {}, f(x) = {}'
               .format(step, x.numpy(), y.numpy()))

经过 200 次迭代更新,程序可以找到一个最小解,此时函数值接近于零。数值解是

step 199: x = [ 3.584428  -1.8481264], f(x) = 1.1368684856363775e-12

这几乎与其中一个解析解(3.584,1.848)相同。

实际上,通过改变网络参数的初始化状态,程序可以获得多种最小数值解。参数的初始化状态可能会影响梯度下降算法的搜索轨迹,甚至可能会搜索出完全不同的数值解,如表 7-1 所示。这个例子解释了不同初始状态对梯度下降算法的影响。

表 7-1

初始值对优化结果的影响

|

x 的初始值

|

数值解

|

解析解

|
| --- | --- | --- |
| ( 40 ) | (3.58,-1.84) | (3.58,-1.84) |
| (1,0) | (3,1.99) | (3,2) |
| (-4,0) | (-3.77,-3.28) | (-3.77,-3.28) |
| (-2,2) | (-2.80,3.13) | (-2.80,3.13) |

7.9 实际操作反向传播算法

本节我们将利用前面介绍的多层全连通网络的梯度推导结果,直接用 Python 计算各层的梯度,根据梯度下降算法手动更新。由于 TensorFlow 有自动求导功能,所以我们选择没有自动求导功能的 Numpy 来实现网络,使用 Numpy 手动计算梯度,手动更新网络参数。

需要注意的是,本章推导的梯度传播公式是针对只有 Sigmoid 函数的多个全连通层的,损失函数是网络类型的均方误差函数。对于其他类型的网络,如具有 ReLU 激活函数和交叉熵损失函数的网络,需要重新推导梯度传播表达式,但方法类似。正是因为手动推导梯度的方法比较有限,所以在实际中很少使用。

我们将实现一个四层全连接网络来完成二进制分类任务。网络输入节点数为 2,隐层节点数设计为 20、50、25。输出层的两个节点分别代表属于类别 1 和类别 2 的概率,如图 7-13 所示。这里,Softmax 函数不用于约束网络输出概率值的总和。相反,均方误差函数被直接用于计算预测和独热码编码的真实标签之间的误差。所有激活函数都是 Sigmoid。这个设计是直接用我们的梯度传播公式。

img/515226_1_En_7_Fig13_HTML.png

图 7-13

网络结构

数据集

通过 scikit-learn 库提供的便利工具,生成了 2000 个线性不可分的 2 类数据集。数据的特征长度是 2。采样数据分布如图 7-14 所示。红点属于一类,蓝点属于另一类。每个类别的分布是新月形的,并且是线性不可分的,这意味着不能使用线性网络来获得好的结果。为了测试网络的性能,我们按照 7:3 的比例划分训练集和测试集。两千。0 s3 = 600 个样本点用于测试,不参与训练。剩下的 1400 分用于网络培训。

img/515226_1_En_7_Fig14_HTML.png

图 7-14

数据集分布

数据集的集合使用 scikit-learn 提供的 make_moons 函数直接生成,采样点数和测试比设置如下:

N_SAMPLES = 2000 # number of sampling points
TEST_SIZE = 0.3 # testing ratio
# Use make_moons function to generate data set
X, y = make_moons(n_samples = N_SAMPLES, noise=0.2, random_state=100)
# Split traning and testing data set

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=TEST_SIZE, random_state=42)
print(X.shape, y.shape)

数据集的分布可以通过下面的可视化代码绘制出来,如图 7-14 所示。

# Make a plot
def make_plot(X, y, plot_name, file_name=None, XX=None, YY=None, preds=None, dark=False):
    if (dark):
        plt.style.use('dark_background')
    else:
        sns.set_style("whitegrid")
    plt.figure(figsize=(16,12))
    axes = plt.gca()
    axes.set(xlabel="$x_1$", ylabel="$x_2$")
    plt.title(plot_name, fontsize=30)
    plt.subplots_adjust(left=0.20)
    plt.subplots_adjust(right=0.80)
    if(XX is not None and YY is not None and preds is not None):
        plt.contourf(XX, YY, preds.reshape(XX.shape), 25, alpha = 1, cmap=cm.Spectral)
        plt.contour(XX, YY, preds.reshape(XX.shape), levels=[.5], cmap="Greys", vmin=0, vmax=.6)
    # Use color to distinguish labels
    plt.scatter(X[:, 0], X[:, 1], c=y.ravel(), s=40, cmap=plt.cm.Spectral, edgecolors='none')

    plt.savefig('dataset.svg')
    plt.close()
# Make distribution plot
make_plot(X, y, "Classification Dataset Visualization ")
plt.show()

网络层

一个新的层类用于实现网络层。输入节点的数量、输出节点的数量和激活函数的类型等参数被传递到网络层。权重和偏差张量偏差是基于初始化期间输入和输出节点的数量自动生成的,如下所示:

class Layer:
    # Fully connected layer
    def __init__(self, n_input, n_neurons, activation=None, weights=None, bias=None):
        """
        :param int n_input: input nodes
        :param int n_neurons: output nodes
        :param str activation: activation function
        :param weights: weight vectors
        :param bias: bias vectors
        """
        # Initialize weights through Normal distribution
        self.weights = weights if weights is not None else np.random.randn(n_input, n_neurons) * np.sqrt(1 / n_neurons)
        self.bias = bias if bias is not None else np.random.rand(n_neurons) * 0.1
        self.activation = activation # activation function, e.g. ’sigmoid’
        self.last_activation = None # output of activation function o
        self.error = None
        self.delta = None

网络层的前向传播函数实现如下,其中 last_activation 变量用于保存当前层的输出值:

    def activate(self, x):
        # Forward propagation function
        r = np.dot(x, self.weights) + self.bias  # X@W+b
        # Get output through activation function
        self.last_activation = self._apply_activation(r)
        return self.last_activation

自我。前面代码中的 _apply_activation 函数实现了不同类型激活函数的正向计算过程,虽然这里我们只使用 Sigmoid 激活函数。

    def _apply_activation(self, r):
        # Calculate output of activation function
        if self.activation is None:
            return r # No activation function
        # ReLU
        elif self.activation == 'relu':
            return np.maximum(r, 0)
        # tanh
        elif self.activation == 'tanh':
            return np.tanh(r)
        # sigmoid
        elif self.activation == 'sigmoid':
            return 1 / (1 + np.exp(-r))

        return r

对于不同类型的激活函数,它们的导数计算如下:

    def apply_activation_derivative(self, r):
        # Calculate the derivative of activation functions
        # If no activation function, derivative is 1
        if self.activation is None:
            return np.ones_like(r)
        # ReLU
        elif self.activation == 'relu':
            grad = np.array(r, copy=True)
            grad[r > 0] = 1.
            grad[r <= 0] = 0.
            return grad
        # tanh
        elif self.activation == 'tanh':
            return 1 - r ** 2
        # Sigmoid
        elif self.activation == 'sigmoid':
            return r * (1 - r)

        return r

可以看出,Sigmoid 函数的导数实现为r(1—r,其中 rσ ( z )。

网络模型

在创建了单层网络类之后,我们实现了网络模型的 NeuralNetwork 类,在内部维护各层的网络层对象。您可以通过 add_layer 函数添加网络层,以达到创建不同结构的网络模型的目的,如下所示:

class NeuralNetwork:
    # Neural Network Class
    def __init__(self):
        self._layers = []  # list of network class

    def add_layer(self, layer):
        # Add layers
        self._layers.append(layer)

网络的前向传播只需要循环调整每个网络层对象的前向计算函数。代码如下:

    def feed_forward(self, X):
        # Forward calculation
        for layer in self._layers:
            # Loop through every layer
            X = layer.activate(X)
        return X

根据图 7-13 中的网络结构配置,我们使用 NeuralNetwork 类创建一个网络对象,添加一个四层全连通网络。代码如下:

nn = NeuralNetwork()
nn.add_layer(Layer(2, 25, 'sigmoid'))  # Hidden layer 1, 2=>25
nn.add_layer(Layer(25, 50, 'sigmoid')) # Hidden layer 2, 25=>50
nn.add_layer(Layer(50, 25, 'sigmoid')) # Hidden layer 3, 50=>25
nn.add_layer(Layer(25, 2, 'sigmoid'))  # Hidden layer, 25=>2

网络模型的反向传播稍微复杂一些。我们需要从最后一层开始,计算每一层的变量 δ ,然后将计算出来的变量 δ 按照下面推导出的梯度公式存储在 layer 类的 delta 变量中:

    def backpropagation(self, X, y, learning_rate):
        # Back propagation
        # Get result of forward calculation
        output = self.feed_forward(X)
        for i in reversed(range(len(self._layers))):  # reverse loop
            layer = self._layers[i]  # get current layer
            # If it’s output layer
            if layer == self._layers[-1]:  # output layer
                layer.error = y - output
                # calculate delta
                layer.delta = layer.error * layer.apply_activation_derivative(output)
            else:  # For hidden layer
                next_layer = self._layers[i + 1]
                layer.error = np.dot(next_layer.weights, next_layer.delta)
                # Calculate delta
                layer.delta = layer.error * layer.apply_activation_derivative(layer.last_activation)
                ... # See following code

在反算出每层的变量 δ 后,只需要根据公式$$ \frac{\partial L}{\partial {w}_{ij}}={o}_i{\delta}_j^{(J)} $$计算出每层参数的梯度,更新网络参数即可。因为代码中的δ实际计算为 δ ,所以更新时使用加号。代码如下:

    def backpropagation(self, X, y, learning_rate):
        ... # Continue above code
        # Update weights
        for i in range(len(self._layers)):
            layer = self._layers[i]
            # o_i is output of previous layer
            o_i = np.atleast_2d(X if i == 0 else self._layers[i - 1].last_activation)
            # Gradient descent
            layer.weights += layer.delta * o_i.T * learning_rate

因此,在反向传播函数中,反向计算每层的变量 δ ,根据梯度公式计算每层参数的梯度值,根据梯度下降算法完成参数更新。

7.9.4 网络培训

这里的二进制分类网络设计有两个输出节点,所以真正的标签需要一热编码。代码如下:

    def train(self, X_train, X_test, y_train, y_test, learning_rate, max_epochs):
        # Train network
        # one-hot encoding
        y_onehot = np.zeros((y_train.shape[0], 2))
        y_onehot[np.arange(y_train.shape[0]), y_train] = 1

计算一热编码实标签和网络输出的均方误差,调用反向传播函数更新网络参数,迭代训练集 1000 次,如下:

        mses = []
        for i in range(max_epochs):  # Train 1000 epoches
            for j in range(len(X_train)):  # Train one sample per time
                self.backpropagation(X_train[j], y_onehot[j], learning_rate)
            if i % 10 == 0:
                # Print MSE Loss
                mse = np.mean(np.square(y_onehot - self.feed_forward(X_train)))
                mses.append(mse)
                print('Epoch: #%s, MSE: %f' % (i, float(mse)))

                # Print accuracy
                print('Accuracy: %.2f%%' % (self.accuracy(self.predict(X_test), y_test.flatten()) * 100))

        return mses

网络性能

我们记录每个历元的训练损失值 L 并绘制成曲线,如图 7-15 所示。

img/515226_1_En_7_Fig15_HTML.jpg

图 7-15

训练误差图

在训练 1000 个时期后,在测试集中的 600 个样本上获得的准确率为:

Epoch: #990, MSE: 0.024335
Accuracy: 97.67%

可以看出,通过手动计算梯度公式和手动更新网络参数,对于简单的二元分类任务,我们也可以获得更低的错误率。通过微调网络超参数和其他技术,您还可以获得更好的网络性能。

在每个历元中,我们在测试集上完成一次精度测试,并将其绘制成曲线,如图 7-16 所示。可以看出,随着 Epoch 的进步,模型的精度得到了稳步提升,初始阶段更快,后续的提升也比较顺利。

img/515226_1_En_7_Fig16_HTML.jpg

图 7-16

测试准确度

通过这种基于 Numpy 手动计算梯度的全连通网络的二元分类,相信读者可以更深刻地体会到深度学习框架在算法实现中的作用。没有 TensorFlow 这样的框架,我们也可以实现复杂的神经网络,但是灵活性、稳定性、开发效率、计算效率都很差。基于这些深度学习框架的算法设计和训练将大大提高算法开发者的工作效率。同时,我们也可以认识到,框架只是一个工具。更重要的是,我们对算法本身的理解是算法开发者最重要的能力。

7.10 参考文献

  1. D.E. Rumelhart,G. E. Hinton 和 R. J. Williams,“通过反向传播错误学习表征”,《自然》, 323,6088,第 533-536 页,1986 年。

  2. 辛格库尔迪普。线性代数:循序渐进。第一版。英国牛津:牛津大学出版社,2013 年。

  3. 斯图尔特,詹姆斯。微积分:早期先验论。第八版。美国马萨诸塞州波士顿:Cengage Learning,2015。*****

八、Keras 高级接口

人工智能的问题不仅是计算机科学的问题,也是数学、认知科学和哲学的问题。

弗朗索瓦·乔列特

Keras 是一个主要用 Python 语言开发的开源神经网络计算库。它最初是由弗朗索瓦·乔莱写的。它被设计为高度模块化和可扩展的高级神经网络接口,使用户无需过多的专业知识就能快速完成模型的建立和训练。Keras 库分为前端和后端。后端一般调用已有的深度学习框架来实现底层操作,如 Theano、CNTK、TensorFlow 等。前端接口是由 Keras 抽象出来的一组统一的接口函数。用户可以通过 Keras 轻松切换不同的后端操作。由于 Keras 的高度抽象和易用性,根据 KDnuggets 的数据,截至 2019 年,Keras 的市场份额达到 26.6%,增长了 19.7%,在深度学习框架中仅次于 TensorFlow。

TensorFlow 和 Keras 之间是一种交错的关系,既竞争又合作。甚至 Keras 的创始人也在谷歌工作。早在 2015 年 11 月,Keras 后端支持中就加入了 TensorFlow。自 2017 年以来,Keras 的大部分组件都已集成到 TensorFlow 框架中。2019 年,Keras 被正式确定为 TensorFlow 2 唯一的高级接口 API,取代 TensorFlow 1 中包含的 tf.layers 等高级接口。换句话说,现在你只能使用 Keras 接口来完成 TensorFlow 图层模型的建立和训练。在 TensorFlow 2 中,Keras 是在 tf.keras 子模块中实现的。

Keras 和 tf.keras 有什么区别和联系?实际上,Keras 可以理解为一组用于构建和训练神经网络的高级 API 协议。Keras 本身已经实现了这个协议。安装标准的 Keras 库可以轻松调用 TensorFlow、CNTK 等后端完成加速计算。在 TensorFlow 中,也通过 tf.keras 实现了一套 Keras 协议,与 TensorFlow 深度融合,只基于 TensorFlow 后端操作,更完美的支持 TensorFlow。对于使用 TensorFlow 的开发者来说,tf.keras 可以理解为一个普通的子模块,与 tf.math、tf.data 等其他子模块没有区别,除非特别说明,否则以下章节中 keras 指的是 tf.keras 而不是标准的 Keras 库。

8.1 常用功能模块

Keras 提供了一系列与神经网络相关的高级类和函数,如经典数据集加载函数、网络层类、模型容器、损失函数类、优化器类和经典模型类。

对于经典数据集,一行代码就可以下载、管理和加载数据集。这些数据集包括波士顿房价预测数据集、CIFAR 图片数据集、MNIST/FashionMNIST 手写数字图片数据集和 IMDB 文本数据集。我们已经在前几章中介绍了其中的一些。

8.1.1 常见网络层类别

对于常见的神经网络层,我们可以使用张量模式的底层接口函数来实现,这些函数一般都包含在 tf.nn 模块中。对于常见的网络层,我们一般采用层的方法来完成模型的构建。tf.keras.layers 命名空间中提供了大量常见的网络层(下文中使用层来指代 tf.keras.layers),例如全连接层、激活功能层、池层、卷积层和循环神经网络层。对于这些网络层类,只需要在创建时指定网络层的相关参数,使用 call 方法完成正向计算即可。使用 call 方法时,Keras 会自动调用各层的正向传播逻辑,一般在类的 call 函数中实现。

以 Softmax 层为例,它可以使用 tf.nn.softmax 函数来完成正向传播中的 softmax 操作,也可以通过层来构建 Softmax 网络层。Softmax(轴)类,其中轴参数指定 Softmax 操作的尺寸。首先,导入相关的子模块,如下所示:

import tensorflow as tf
# Do not use "import keras" which will import the standard Keras, not the one in Tensorflow
from tensorflow import keras
from tensorflow.keras import layers # import common layer class

然后创建一个 Softmax 图层,并使用 call 方法完成正向计算:

In [1]:
x = tf.constant([2.,1.,0.1])  # create input tensor
layer = layers.Softmax(axis=-1)  # create Softmax layer
out = layer(x)  # forward propagation

通过 Softmax 网络层后,概率分布输出为:

Out[1]:
<tf.Tensor: id=2, shape=(3,), dtype=float32, numpy=array([0.6590012, 0.242433 , 0.0985659], dtype=float32)>

当然,我们也可以通过 tf.nn.softmax()函数直接完成计算,如下:

out = tf.nn.softmax(x)

网络容器

对于常见的网络,我们需要手动调用各层的类实例来完成正向传播操作。当网络层越深入,这部分代码就显得非常臃肿。通过 Keras 提供的网络容器 Sequential,可以将多个网络层封装成一个大型网络模型。只需要调用一次网络模型的实例,就可以完成数据从第一层到最后一层的顺序传播操作。

例如,具有独立激活功能层的两层全连接网络可以通过顺序容器封装为一个网络。

from tensorflow.keras import layers, Sequential
network = Sequential([
    layers.Dense(3, activation=None), # Fully-connected layer without activation function
    layers.ReLU(),# activation function layer
    layers.Dense(2, activation=None), # Fully-connected layer without activation function
    layers.ReLU() # activation function layer
])
x = tf.random.normal([4,3])
out = network(x)

顺序容器还可以通过 add()方法继续添加新的网络层,以动态创建网络:

In [2]:
layers_num = 2
network = Sequential([]) # Create an empty container
for _ in range(layers_num):
    network.add(layers.Dense(3)) # add fully-connected layer
    network.add(layers.ReLU())# add activation layer
network.build(input_shape=(4, 4))
network.summary()

前面的代码可以创建一个网络结构,其层数由 layers_num 参数指定。网络创建完成后,网络层类不会创建成员变量,如内部权重张量。使用 build 方法,可以指定输入大小,这将自动为所有层创建内部张量。通过 summary()函数,可以方便地打印出网络结构和参数。结果如下:

Out[2]:
Model: "sequential_2"
_______________________________________________________________
Layer (type)                 Output Shape              Param Number
===============================================================
dense_2 (Dense)              multiple                  15
_______________________________________________________________
re_lu_2 (ReLU)               multiple                  0
_______________________________________________________________
dense_3 (Dense)              multiple                  12
_______________________________________________________________
re_lu_3 (ReLU)               multiple                  0
===============================================================
Total params: 27
Trainable params: 27
Non-trainable params: 0
_______________________________________________________________

图层列包括由 TensorFlow 内部维护的每个图层的名称,与 Python 的对象名称不同。“输出形状”列指示每个图层的输出形状。请注意,“输出形状”列的值都是“多个”,因为我们此时仅构建或编译了网络,并未真正训练或执行网络。在我们使用真实输入调用网络后,每个层的真实输出形状将反映在输出形状列中。Param number 列是每层的参数数。Total params 统计参数的总数。可训练参数是要优化的参数总数。不可训练参数是不需要优化的参数总数。

当我们通过顺序容器封装多个网络层时,每一层的参数表都会自动合并到顺序容器中。序列对象的可训练变量和变量包含要优化的张量列表和所有层的张量,例如:

In [3]: # print name and shape of trainable variables
for p in network.trainable_variables:
   print(p.name, p.shape)
Out[3]:
dense_2/kernel:0 (4, 3)
dense_2/bias:0 (3,)
dense_3/kernel:0 (3, 3)
dense_3/bias:0 (3,)

顺序容器是最常用的类之一。这对快速建立多层神经网络非常有用。应该尽可能地使用它来简化网络模型的实现。

8.2 模型配置、培训和测试

在训练网络时,一般的流程是通过正向计算得到网络的输出值,然后通过损失函数计算网络误差,再通过自动微分工具计算并更新梯度,不定期测试网络性能。对于这种常用的训练逻辑,可以通过 Keras 提供的高层接口直接实现。

8.2.1 型号配置

在 keras,有两个特殊的阶层:Keras。Model 和 keras . layers . Layer . Layer . Layer 层类是网络层的父类,它定义了网络层的一些常用功能,比如添加权重、管理权重列表等。模型类是网络的父类。除了 layer 类的功能之外,还增加了保存模型、加载模型、训练测试模型等方便的功能。Sequential 也是 model 的子类,所以它拥有 model 类的所有功能。

下面介绍一下模型类及其子类的模型配置和训练功能。以序列容器封装的网络为例,我们首先为 MNIST 手写数字图像识别建立一个五层全连通网络。代码如下:

# Create a 5-layer fully connected network
network = Sequential([layers.Dense(256, activation='relu'),
                     layers.Dense(128, activation='relu'),
                     layers.Dense(64, activation='relu'),
                     layers.Dense(32, activation='relu'),
                     layers.Dense(10)])
network.build(input_shape=(4, 28*28))
network.summary()

网络创建后,正常的流程是在数据集中迭代多个历元,批量生成训练数据,做前向传播计算,然后通过损失函数计算误差值,通过反向传播自动计算梯度,更新网络参数。因为这部分逻辑非常通用,所以 Keras 中提供了 compile()和 fit()函数来简化逻辑。我们可以通过 compile 函数直接指定网络使用的优化器、损失函数、评估指标和其他设置。这一步称为配置。

# Import optimizer, loss function module
from tensorflow.keras import optimizers,losses
# Use Adam optimizer with learning rate of 0.01
# Use cross-entropy loss function with Softmax
network.compile(optimizer=optimizers.Adam(lr=0.01),
        loss=losses.CategoricalCrossentropy(from_logits=True),
        metrics=['accuracy'] # Set accuracy as evaluation metric
)

compile()函数中指定的优化器、损失函数和其他参数也是我们在自己的训练中需要设置的参数。Keras 在内部实现了这部分通用逻辑,以提高开发效率。

模型培训

模型配置完成后,可以通过 fit()函数发送用于训练和验证的数据集。这一步叫做模型训练。

# Training dataset is train_db, and validation dataset is val_db
# Train 5 epochs and validate every 2 epoch
# Training record and history is saved in history variable
history = network.fit(train_db, epochs=5, validation_data=val_db, validation_freq=2)

train_db 可以是 tf.data.Dataset 对象或 Numpy 数组。Epochs 参数指定训练迭代的时期数。validation_data 参数指定用于验证的数据集,验证频率由 validation_freq 控制。

前面的代码可以实现网络训练和验证的功能。fit 函数将返回训练过程数据记录的历史,其中 history.history 是字典对象,包括训练过程的损失、评估度量和其他记录,例如:

In [4]: history.history # print training record
Out[4]:
{'loss': [0.31980024444262184,  # training loss
  0.1123824894875288,
  0.07620834542314212,
  0.05487803366283576,
  0.041726120284820596],  # training accuracy
'accuracy': [0.904, 0.96638334, 0.97678334, 0.9830833, 0.9870667],
 'val_loss': [0.09901347314302303, 0.09504951824009701],  # validation loss
 'val_accuracy': [0.9688, 0.9703]}  # validation accuracy

fit()函数的操作代表了网络的训练过程,所以会消耗相当多的训练时间,训练完成后返回。训练时生成的历史数据可以通过返回值对象获得。可以看出,通过 Compile&Fit 方法实现的代码非常简洁高效,大大减少了开发时间。但是因为界面很高级,灵活性也降低了,要不要用还是由用户自己决定。

模型测试

该模型类不仅可以方便地完成网络的配置、训练和验证,还可以非常方便地进行预测和测试。我们将在过拟合一章中阐述验证和测试的区别。在这里,验证和测试可以理解为模型评估的一种方式。

Model.predict(x)方法可以完成模型预测,例如:

# Load one batch of test dataset
x,y = next(iter(db_test))
print('predict x:', x.shape) # print the batch shape
out = network.predict(x) # prediction
print(out)

其中 out 是网络的输出。通过前面的代码,训练好的模型可以用来预测新样本的标签信息。

如果只需要测试模型的性能,可以使用 Model.evaluate(db)来测试 db 数据集上的所有样本,并打印出性能指标,例如:

network.evaluate(db_test)

8.3 模型保存和加载

模型训练完成后,需要将模型保存到文件系统中,以便于后续的模型测试和部署。事实上,在训练时保存模型状态也是一个好习惯,这对于训练大规模网络尤为重要。一般大规模的网络需要几天甚至几周的训练。一旦训练过程中断或发生意外,之前的训练进度就会丢失。如果模型状态能够间歇性地保存到文件系统中,那么即使发生了宕机等意外,也可以从最新的网络状态文件中恢复,从而避免浪费大量的训练时间和计算资源。因此,模型的保存和加载非常重要。

在 Keras 中,有三种保存和加载模型的常用方法。

张量方法

网络的状态主要体现在网络的结构和网络层内的张量数据上。因此,在拥有网络结构源文件的情况下,将网络张量参数直接保存到文件系统是最轻量级的方式。以 MNIST 手写数字图片识别模型为例,可以通过调用 Model.save_weights(path)方法保存当前的网络参数。代码如下:

network.save_weights('weights.ckpt') # Save tensor data of the model

上述代码将网络模型保存到 weights.ckpt 文件中。需要时,我们先创建一个网络对象,然后调用网络对象的 load_weights(path)方法,将指定模型文件中保存的张量值加载到当前网络参数中,例如:

# Save tensor data of the model
network.save_weights('weights.ckpt')
print('saved weights.')
del network # delete network object
# Create similar network
network = Sequential([layers.Dense(256, activation='relu'),
                     layers.Dense(128, activation='relu'),
                     layers.Dense(64, activation='relu'),
                     layers.Dense(32, activation='relu'),
                     layers.Dense(10)])
network.compile(optimizer=optimizers.Adam(lr=0.01),
        loss=tf.losses.CategoricalCrossentropy(from_logits=True),
        metrics=['accuracy']
    )
# Load weights from file
network.load_weights('weights.ckpt')
print('loaded weights!')

这种保存和加载网络的方法是最轻量级的。该文件只保存张量参数的值,没有其他额外的结构参数。但它需要使用相同的网络结构才能正确还原网络状态,所以一般在有网络源文件的情况下使用。

网络方法

下面介绍一种不需要网络源文件,只需要模型参数文件就可以恢复网络模型的方法。模型结构和模型参数可以通过 Model.save(path)函数保存到路径文件中,网络结构和网络参数可以通过 keras.models.load_model(path)恢复,不需要网络源文件。

首先,将 MNIST 手写数字图片识别模型保存到一个文件中,并删除网络对象:

# Save model and parameters to a file
network.save('model.h5')
print('saved total model.')
del network # Delete the network

网络的结构和状态可以通过 model.h5 文件恢复,不需要事先创建网络对象。代码如下:

# Recover the model and parameters from a file
network = keras.models.load_model('model.h5')

如您所见,除了存储模型参数,model.h5 文件还应该保存网络结构信息。您可以直接从文件中恢复网络对象,而无需事先创建模型。

8.3.3 保存模型方法

TensorFlow 之所以被业界看好,不仅是因为出色的神经网络层 API 支持,还因为它拥有强大的生态系统,包括移动端和 web 端的支持。当模型需要部署到其他平台时,TensorFlow 提出的 SavedModel 方法是平台无关的。

通过 tf.saved_model.save(network,path),可以将模型保存到路径目录中,如下所示:

#  Save model and parameters to a file
tf.saved_model.save(network, 'model-savedmodel')
print('saving savedmodel.')
del network # Delete network object

以下网络文件出现在文件系统 model-savedmodel 目录中,如图 8-1 所示:

img/515226_1_En_8_Fig1_HTML.jpg

图 8-1

保存模型方法目录

用户不需要关心文件保存格式,只需要通过 tf.saved_model.load 函数还原模型对象即可。在恢复模型实例后,我们完成了测试准确率的计算,并实现了以下内容:

print('load savedmodel from file.')
# Recover network and parameter from files
network =  tf.saved_model.load('model-savedmodel')
# Accuracy metrics
acc_meter = metrics.CategoricalAccuracy()
for x,y in ds_val:   # Loop through test dataset
    pred = network(x) # Forward calculation
    acc_meter.update_state(y_true=y, y_pred=pred) # Update stats
# Print accuracy
print("Test Accuracy:%f" % acc_meter.result())

8.4 定制网络

尽管 Keras 提供了许多常见的网络层类,但用于深度学习的网络远不止这些。研究人员通常自己实现相对较新的网络层。因此,掌握自定义网络层和网络的实现非常重要。

对于需要创建自定义逻辑的网络层,可以通过自定义类来实现。创建自定义的网络层类时,需要从层中继承。层基类。创建自定义网络类时,需要从 keras 继承。模型基类,所以用这种方式创建的自定义类可以很容易地使用层/模型基类。该类提供的参数管理和其他功能也可以与其他标准网络层类交互使用。

8.4.1 自定义网络层

对于自定义网络层,我们至少需要实现初始化(init)方法和正向传播逻辑。我们以一个具体的自定义网络层为例,假设需要一个没有偏置向量的全连通层,即偏置为 0,固定激活函数为 ReLU。尽管这可以通过标准的密集层来创建,我们仍然解释如何通过实现这个“特殊的”网络层类来实现一个定制的网络层。

首先,创建一个类,并从基础层类继承。创建一个初始化方法,调用父类的初始化函数。因为是全连通层,所以需要设置两个参数:输入特征 inp_dim 的长度和输出特征 outp_dim 的长度,形状大小由 self.add_variable(name,shape)创建。名张量 W 被设置为优化。

class MyDense(layers.Layer):
    # Custom layer
    def __init__(self, inp_dim, outp_dim):
        super(MyDense, self).__init__()
        # Create weight tensor and set to be trainable
        self.kernel = self.add_variable('w', [inp_dim, outp_dim], trainable=True)

需要注意的是,self.add_variable 会返回一个对张量 W 的 Python 引用,变量名由 TensorFlow 内部维护,使用频率较低。我们实例化 MyDense 类并查看其参数列表,例如:

In [5]: net = MyDense(4,3) # Input dimension is 4 and output dimension is 3.
net.variables,net.trainable_variables  # Check the trainable parameters
Out[5]:
# All parameters
([<tf.Variable 'w:0' shape=(4, 3) dtype=float32, numpy=...
# Trainable parameters
 [<tf.Variable 'w:0' shape=(4, 3) dtype=float32, numpy=...

可以看到张量 W 自动包含在参数表中。

通过修改为 self . kernel = self . add _ variable(' W ',[inp_dim,outp_dim],trainable = False),我们可以设置张量 W 不可训练,然后观察张量的管理状态:

([<tf.Variable 'w:0' shape=(4, 3) dtype=float32, numpy=...], # All parameters
[])# Trainable parameters

如你所见,张量此时不由 trainable _ variables 管理。另外,创建为 tf 的类成员变量。类初始化中变量也自动包含在张量管理中,例如:

        self.kernel = tf.Variable(tf.random.normal([inp_dim, outp_dim]), trainable=False)

托管张量列表打印如下:

# All parameters
([<tf.Variable 'Variable:0' shape=(4, 3) dtype=float32, numpy=...],
[])# Trainable parameters

在自定义类初始化之后,我们将设计正向计算逻辑。对于这个例子,只需要完成矩阵运算 O = X @ W 就可以使用固定的 ReLU 激活函数。代码如下:

    def call(self, inputs, training=None):
        # Forward calculation
        # X@W
        out = inputs @ self.kernel
        # Run activation function
        out = tf.nn.relu(out)
        return out

如上所述,正向计算逻辑是在 call(inputs,training = None)函数中实现的,其中 inputs 参数表示输入并由用户传入。training 参数用于指定模型的状态:True 表示训练模式,False 表示测试模式,默认值为 None,即测试模式。因为全连接层的训练和测试模式在逻辑上是一致的,所以这里不需要额外的处理。对于测试和训练模式不一致的网络层,需要根据训练参数设计要执行的逻辑。

定制网络

在完成自定义全连接层类实现后,我们基于前面描述的“无偏全连接层”创建了 MNIST 手写数字图片模型。

自定义网络类可以像其他标准类一样,通过顺序容器轻松封装到网络模型中:

network = Sequential([MyDense(784, 256), # Use custom layer
                     MyDense(256, 128),
                     MyDense(128, 64),
                     MyDense(64, 32),
                     MyDense(32, 10)])
network.build(input_shape=(None, 28*28))
network.summary()

可以看出,通过堆叠我们自定义的网络层类,也可以实现五层全连通的层网络。全连通层的每一层都没有偏置张量,激活函数使用 ReLU 函数。

顺序容器适用于这样的网络模型,其中数据按顺序从第一层传播到第二层,然后从第二层传播到第三层,并且以这种方式传播。例如,对于复杂的网络结构,第三层的输入不仅是第二层的输出,也是第一层的输出。这时,使用定制的网络更加灵活。首先创建一个从模型基类继承的类,然后分别创建相应的网络层对象,如下所示:

class MyModel(keras.Model):
    # Custom network class
    def __init__(self):
        super(MyModel, self).__init__()
        # Create the network
        self.fc1 = MyDense(28*28, 256)
        self.fc2 = MyDense(256, 128)
        self.fc3 = MyDense(128, 64)
        self.fc4 = MyDense(64, 32)
        self.fc5 = MyDense(32, 10)

然后实现定制网络的转发操作逻辑,如下所示:

    def call(self, inputs, training=None):
        # Forward calculation
        x = self.fc1(inputs)
        x = self.fc2(x)
        x = self.fc3(x)
        x = self.fc4(x)
        x = self.fc5(x)
        return x

这个例子可以使用顺序容器方法直接实现。但是定制网络的正向计算逻辑可以自由定义,更加通用。我们将在卷积神经网络一章中看到定制网络的优越性。

8.5 模型动物园

对于常用的网络模型,如 ResNet 和 VGG,您不需要手动创建它们。它们可以通过 keras.applications 子模块用一行代码直接实现。同时,还可以通过设置权重参数来加载预先训练好的模型。

负载模型

以 ResNet50 网络模型为例,去除 ResNet50 最后一层后的网络一般作为新任务的特征提取子网,即利用 ImageNet 数据集上预先训练好的网络参数,根据任务的类别,初始化并追加一个与数据类别数相对应的全连通层,从而在预先训练好的网络基础上快速高效地学习新任务。

首先使用 Keras model zoo 加载 ImageNet 预先训练好的 ResNet50 网络。代码如下:

# Load ImageNet pre-trained network. Exclude the last layer.
resnet = keras.applications.ResNet50(weights='imagenet',include_top=False)
resnet.summary()
# test the output
x = tf.random.normal([4,224,224,3])
out = resnet(x) # get output
out.shape

上述代码自动从服务器下载 ImageNet 数据集的模型结构和预训练网络参数。通过将 include_top 参数设置为 False,我们选择移除 ResNet50 的最后一层。网络输出特征图的大小为[ b ,7,7,2048]。对于特定的任务,我们需要设置自定义数量的输出节点。以 100 个分类任务为例,基于 ResNet50 重建一个新的网络。创建一个新的池层(此处的池层可以理解为在高维度和宽维度中向下采样的函数),并将特征维度从[b,7,7,2048]减少到[b,2048],如下所示。

In [6]:
# New pooling layer
global_average_layer = layers.GlobalAveragePooling2D()
# Use last layer's output as this layer's input
x = tf.random.normal([4,7,7,2048])
# Use pooling layer to reduce dimension from [4,7,7,2048] to [4,1,1,2048],and squeeze to [4,2048]
out = global_average_layer(x)
print(out.shape)
Out[6]: (4, 2048)

最后,创建一个新的完全连接层,并将输出节点数设置为 100。代码如下:

In [7]:
# New fully connected layer
fc = layers.Dense(100)
# Use last layer's output as this layer's input
x = tf.random.normal([4,2048])
out = fc(x)
print(out.shape)
Out[7]: (4, 100)

在创建了预训练的 ResNet50 功能子网、新的池层和全连接层之后,我们重新使用顺序容器来封装新的网络:

# Build a new network using previous layers
mynet = Sequential([resnet, global_average_layer, fc])
mynet.summary()

可以看到新网络模型的结构信息是:

Layer (type)                 Output Shape              Param Number
===============================================================
resnet50 (Model)             (None, None, None, 2048)  23587712
_______________________________________________________________
global_average_pooling2d (Gl (None, 2048)              0
_______________________________________________________________
dense_4 (Dense)              (None, 100)               204900
===============================================================
Total params: 23,792,612
Trainable params: 23,739,492
Non-trainable params: 53,120

通过设置 resnet.trainable = False,可以选择冻结 resnet 部分的网络参数,只训练新创建的网络层,从而快速高效地完成网络模型训练。当然,你也可以更新网络的所有参数。

8.6 指标

在网络的训练过程中,往往需要准确率和召回率等指标。Keras 在 keras.metrics 模块中提供了一些常用的指标。

使用 Keras 度量有四个主要步骤:创建新的度量容器、写入数据、读取统计数据和清除度量容器。

8.6.1 创建指标容器

在 keras.metrics 模块中,它提供了许多常用的度量类,如均值、精度和余弦相似度。下面,我们以平均误差为例。

loss_meter = metrics.Mean()

写入数据

可以通过 update_state 函数写入新数据,度量会按照自己的逻辑记录和处理采样的数据。例如,损失值在每个步骤结束时收集一次:

     # Record the sampled data, and convert the tensor to an ordinary value through the float() function
        loss_meter.update_state(float(loss))

在每个批处理操作结束时放置前面的采样代码后,血糖仪将根据采样数据自动计算平均值。

读取统计数据

采样多次数据后,可以选择调用测量器的 result()函数来获取统计值。例如,区间统计平均损失如下:

      # Print the average loss during the statistical period
        print(step, 'loss:', loss_meter.result())

清理容器

由于度量容器将记录所有历史数据,因此在开始新一轮统计时,有必要清除历史状态。可以通过 reset_states()函数来实现。例如,每次读取平均误差后,清除统计信息以开始下一轮统计,如下所示:

    if step % 100 == 0:
        # Print the average loss
        print(step, 'loss:', loss_meter.result())
        loss_meter.reset_states() # reset the state

8.6.5 实际操作准确度指标

根据使用度量工具的方法,我们在训练过程中使用准确度度量来统计准确率。首先,创建一个新的准确度测量容器,如下所示:

acc_meter = metrics.Accuracy()

每次正向计算完成后,记录训练准确率。需要注意的是,精度类的 update_state 函数的参数是预测值和真值,而不是当前批次的准确率。我们将当前批次样本的标签和预测结果写入度量,如下所示:

            # [b, 784] => [b, 10, network output
            out = network(x)
            # [b, 10] => [b], feed into argmax()
            pred = tf.argmax(out, axis=1)
            pred = tf.cast(pred, dtype=tf.int32)
            # record the accuracy
            acc_meter.update_state(y, pred)

对测试集中所有批次的预测值进行计数后,打印统计数据的平均准确度,并清除指标容器。代码如下:

        print(step, 'Evaluate Acc:', acc_meter.result().numpy())
        acc_meter.reset_states() # reset metric

8.7 可视化

在网络培训过程中,通过 web 终端监控网络的培训进度,并可视化培训结果,对于提高开发效率非常重要。TensorFlow 提供了一个名为 TensorBoard 的特殊可视化工具,通过 TensorFlow 将监控数据写入文件系统,并使用 web 后端监控相应的文件目录,从而允许用户查看网络监控数据。

TensorBoard 的使用需要模型代码和浏览器的配合。使用 TensorBoard 之前,需要安装 TensorBoard 库。安装命令如下:

# Install TensorBoard
pip install tensorboard

接下来介绍如何使用 TensorBoard 工具在模型端和浏览器端监控网络训练进度。

模型侧

在模型方面,您需要创建一个汇总类,在需要时写入监控数据。首先通过 tf.summary.create_file_writer 创建一个监控对象类的实例,并指定监控数据写入的目录。代码如下:

# Create a monitoring class, the monitoring data will be written to the log_dir directory
summary_writer = tf.summary.create_file_writer(log_dir)

我们以监控误差和可视图像数据为例,介绍如何编写监控数据。正演计算完成后,对于误差等标量数据,我们通过 tf.summary.scalar 函数记录监控数据,并指定时间戳步长参数。这里的步长参数类似于每个数据对应的时标信息,也可以理解为数据曲线的坐标,不做赘述。每种类型的数据通过字符串的名称来区分,相似的数据需要用相同的名称写入数据库。例如:

        with summary_writer.as_default():
            # write the current loss to train-loss database
            tf.summary.scalar('train-loss', float(loss), step=step)

TensorBoard 通过字符串 ID 区分不同类型的监测数据,所以对于错误数据,我们命名为“train-loss”;不能写入其他类型的数据,以防止数据污染。

对于图片类型的数据,可以通过 tf.summary.image 函数写入监控图片数据。例如,在训练期间,可以通过 tf.summary.image 函数来可视化样本图像。由于 TensorFlow 中的张量通常包含多个样本,因此 tf.summary.image 函数接受多个图片的张量数据,并设置 max_outputs 参数来选择显示图片的最大数量。代码如下:

        with summary_writer.as_default():
            # log accuracy
            tf.summary.scalar('test-acc', float(total_correct/total), step=step)
            # log images
            tf.summary.image("val-onebyone-images:", val_images, max_outputs=9, step=step)

运行模型程序,相应的数据会实时写入指定的文件目录。

8.7.2 浏览器端

运行程序时,监控数据被写入指定的文件目录。如果您想要远程实时查看和可视化这些数据,您还需要使用浏览器和 web 后端。第一步是打开 web 后端。在终端中运行“tensorboard - logdir path”命令,指定 web 后端监控的文件目录路径,即可打开 web 后端监控流程,如图 8-2 所示:

img/515226_1_En_8_Fig2_HTML.jpg

图 8-2

打开 web 服务器

打开浏览器,输入网址 http://localhost: 6006(也可以通过 IP 地址远程访问,具体端口号可能会根据命令行提示有所变化)监控网络训练的进度。TensorBoard 可以同时显示多条监控记录。在监控页面的左侧,可以选择监控记录,如图 8-3 所示:

img/515226_1_En_8_Fig3_HTML.jpg

图 8-3

张量板快照

在监控页面的上端,您可以选择不同类型的数据监控页面,例如标量监控页面标量和图片可视化页面图像。对于这个例子,我们需要监控标量数据的训练误差和测试准确率,其曲线可以在 SCALARS 页面查看,如图 8-4 和图 8-5 所示。

img/515226_1_En_8_Fig5_HTML.jpg

图 8-5

训练准确度曲线

img/515226_1_En_8_Fig4_HTML.jpg

图 8-4

训练损失曲线

在图像页面,您可以查看每个步骤的图像,如图 8-6 所示。

img/515226_1_En_8_Fig6_HTML.jpg

图 8-6

每一步的图片

除了监控标量数据和图像数据,TensorBoard 还支持通过 tf.summary.histogram 查看张量数据的直方图分布、通过 tf.summary.text 打印文本信息等功能,例如:

        with summary_writer.as_default():
            tf.summary.scalar('train-loss', float(loss), step=step)
   tf.summary.histogram('y-hist',y, step=step)
            tf.summary.text('loss-text',str(float(loss)))

在直方图页面可以查看张量的直方图,如图 8-7 所示,在文本页面可以查看文本信息,如图 8-8 所示。

img/515226_1_En_8_Fig8_HTML.jpg

图 8-8

张量板文本可视化

img/515226_1_En_8_Fig7_HTML.jpg

图 8-7

张量板直方图

其实除了 TensorBoard,脸书开发的 Visdom 工具也可以方便的实现数据的可视化,实时支持多种可视化方式,使用起来更加方便。图 8-9 显示了 Visdom 数据的可视化。Visdom 可以直接接受 PyTorch 的张量类型数据,但不能直接接受 TensorFlow 的张量类型数据。需要将其转换为 Numpy 数组。对于追求丰富的可视化方法和实时监控的读者来说,Visdom 可能是更好的选择。

img/515226_1_En_8_Fig9_HTML.png

图 8-9

智慧快照〔??〕〔??〕〔??〕〔??〕

8.8 摘要

在这一章中,我们介绍了 Keras 高级 API 的使用,它可以节省我们在网络开发过程中的大量时间。我们可以很容易地用容器法来构造网络。使用 Keras 内置函数可以快速实现神经网络的训练和测试。在对网络进行训练和测试之后,我们还可以保存训练好的模型,并在将来使用 Keras 重新加载模型。除了常见的网络层,Keras 还提供了为不同用例构建定制网络层的功能。我们还讨论了如何使用 Keras 加载流行的网络模型,以及使用 TensorBoard 设置评估指标和可视化模型性能。我们通过本章学习的工具可以帮助我们显著提高网络开发效率。

九、过拟合

一切都要尽量简单,但不能更简单。

—爱因斯坦

机器学习的主要目的是从训练集中学习数据的真实模型,使其能够在看不见的测试集上表现良好。我们称之为概括能力。一般来说,训练集和测试集是从相同的数据分布中采样的。抽样样本彼此独立,但来自相同的分布。我们称这种假设为独立同分布(i.i.d .)假设。

模型的表现力前面已经提到了,也称为模型的容量。当模型的表达能力较弱时,如单一线性层,只能学习一个线性模型,不能很好地近似非线性模型。当模型的表达能力太强时,可能会减少训练集的噪声模态,但会导致测试集的性能较差(泛化能力较弱)。因此,对于不同的任务,设计一个容量合适的模型可以获得更好的泛化性能。

9.1 模型容量

通俗地说,模型的容量或表达能力是指模型对复杂函数的拟合能力。反映模型能力的一个指标是模型假设空间的大小,即模型所能表示的函数集的大小。假设空间越大、越完整,就越有可能从假设空间中搜索接近真实模型的函数。反之,如果假设空间非常有限,就很难找到一个近似真实模型的函数。

考虑从真实分布中抽样:

$$ {p}_{data}=\left{\left(x,y\right)|y=\mathit{\sin}(x),x\in \Big[-5,5\Big]\right} $$

从真实分布中抽取少量点组成训练集,训练集包含观测误差 ϵ ,如图 9-1 中的小点所示。如果我们只搜索所有一次多项式的模型空间,并将偏差设为 0,即 y = ax ,如图 9-1 中一次多项式的直线所示。那么就很难找到一条非常接近真实数据分布的直线。稍微增加假设空间,使假设空间都是三次多项式函数,即y=ax3+bx2+CX,很明显这个假设空间明显大于一次多项式的假设空间,我们可以找到一条曲线(如图 9-1 所示)反映了再次增加假设空间使可搜索函数为 5 次多项式,即y=ax5+bx4+CX+3+dx2+ex。在这个假设空间中,可以搜索到更好的函数,如图 9-1 中的 5 次多项式所示。再次增大假设空间后,如图 9-1 中 7、9、11、13、15、17 的多项式曲线所示,函数的假设空间越大,越有可能找到更接近真实分布的函数模型。

img/515226_1_En_9_Fig1_HTML.png

图 9-1

多项式能力

然而,过大的假设空间无疑会增加搜索难度和计算成本。事实上,在有限计算资源的约束下,更大的假设空间不一定能搜索到更好的模型。由于观测误差的存在,更大的假设空间可能包含更多的表达能力太强的函数,这些函数也可以学习训练样本的观测误差,从而伤害模型的泛化能力。选择合适的模型容量是一个难题。

9.2 过度配合和不足配合

由于真实数据的分布往往是未知且复杂的,因此无法推导出分布函数的类型及相关参数。因此,在选择学习模型的容量时,人们往往根据经验值选择稍大的模型容量。然而,当模型的容量过大时,它可能在训练集上表现得更好,但在测试集上表现得更差,如图 9-2 所示。当模型的容量过小时,可能在训练集和测试集的性能都很差,如图 9-2 中红色竖线左侧区域所示。

img/515226_1_En_9_Fig2_HTML.jpg

图 9-2

模型容量与误差的关系[1]

当模型的容量过大时,网络模型除了学习训练集数据的模态外,还会学习额外的观测误差,导致学习后的模型在训练集上表现较好,但在看不见的样本上表现较差,即模型的泛化能力较弱。我们称这种现象为过拟合。当模型的容量太小时,模型不能很好地学习训练集数据的模态,导致训练集和看不见的样本的性能都很差。我们称这种现象为欠拟合。

这里有一个简单的例子来解释模型的容量和数据分布之间的关系。图 9-3 描绘了某些数据的分布。可以粗略推测,数据可能属于某个 2 次多项式分布。如果使用简单的线性函数进行学习,会发现很难学习到更好的函数,导致训练集和测试集表现不佳的欠拟合现象,如图 9-3 (a) 。但如果使用更复杂的函数模型进行学习,有可能学习到的函数会过度“拟合”训练集样本,而导致在测试集上表现不佳,即过拟合,如图 9-3 (c) 。只有当学习到的模型与真实模型的容量大致匹配时,模型才能具有良好的泛化能力,如图 9-3 (b) 所示。

img/515226_1_En_9_Fig3_HTML.png

图 9-3

过拟合和欠拟合

考虑数据点的分布 p 数据(xy ),其中

$$ y=\mathit{\sin}\left(1.2\bullet \pi \bullet x\right) $$

采样时加入随机高斯噪声,得到 120 个点的数据集,如图 9-4 所示。图中曲线为真实模型函数,黑色圆点为训练样本,绿色矩阵点为测试样本。

img/515226_1_En_9_Fig4_HTML.png

图 9-4

数据集和实函数

在已知真实模型的情况下,设计一个具有适当容量的函数空间来获得一个好的学习模型是很自然的。如图 9-5 所示,我们假设模型为二次多项式模型,学习到的函数曲线近似真实模型。然而,在实际场景中,真实的模型往往是未知的,因此如果设计假设空间太小,将无法搜索到合适的学习模型。如果设计假设空间过大,会导致模型泛化能力差。

img/515226_1_En_9_Fig5_HTML.png

图 9-5

适当的模型能力

那么如何选择机型的容量呢?统计学习理论为我们提供了一些思路。VC 维(Vapnik-Chervonenkis 维)是一种广泛使用的度量函数容量的方法。虽然这些方法为机器学习提供了一定程度的理论保障,但是这些方法很少应用于深度学习。部分原因是神经网络过于复杂,无法确定网络结构背后数学模型的 VC 维数。

虽然统计学习理论很难给出一个神经网络所需的最小容量,但它可以用来指导一个基于奥卡姆剃刀的神经网络的设计和训练。奥卡姆剃刀原则是由奥卡姆的威廉提出的解决规则,他是 14 世纪的逻辑学家和方济各会的方济各会修士。他在书中声明“不要浪费更多的东西,做那些用更少的东西就能做好的事情。”换句话说,如果两层神经网络结构可以很好地表达真实模型,那么三层神经网络也可以很好地表达,但我们应该更喜欢使用更简单的两层神经网络,因为它的参数数量更小,更容易训练,更容易通过较少的训练样本获得良好的泛化误差。

装配不足

让我们考虑一下拟合不足的现象。如图 9-6 所示,黑点和绿色矩形是从抛物线函数的分布中独立采样的。因为我们已经知道真实的模型,如果用一个比真实模型容量低的线性函数来拟合数据,模型很难有好的表现。具体表现为学习出来的线性模型在训练集上的误差(如均方差)较大,在测试集上的误差也较大。

img/515226_1_En_9_Fig6_HTML.png

图 9-6

典型的欠拟合模型

当我们发现当前模型在训练集上一直保持着较高的误差,难以优化和降低误差,在测试集上也表现不佳时,就可以考虑是否存在欠拟合的现象。可以通过增加神经网络的层数或增加中间维度的大小来解决欠拟合的问题。然而,因为现代深度神经网络模型可以容易地到达更深的层,所以用于学习的模型的容量通常是足够的。在实际应用中,会出现更多的过拟合现象。

过度装配

考虑同一个问题,训练集的黑点和测试机的绿色矩形分别从一个分布相同的抛物线模型中独立采样。当我们将模型的假设空间设置为 25 次多项式时,它远大于真实模型的功能容量。发现学习后的模型很可能会过拟合训练样本,导致学习模型对训练样本的误差很小,甚至小于真实模型对训练集的误差。但对于测试样本,模型性能急剧下降,泛化能力很差,如图 9-7 。

img/515226_1_En_9_Fig7_HTML.png

图 9-7

典型的过拟合模型

现代深度神经网络很容易出现过拟合现象,主要是因为神经网络具有非常强的表达能力,而训练集中的样本数量不够,很容易出现神经网络容量过大的情况。那么如何有效地检测和减少过拟合呢?

接下来,我们将介绍一系列有助于检测和抑制过拟合的方法。

9.3 数据集划分

前面我们介绍过,数据集需要分为训练集和测试集。为了选择模型超参数和检测过拟合,通常需要将原始训练集分成新的训练集和验证集,即需要将数据集分成三个子集:训练集、验证集和测试集。

9.3.1 验证集和超参数

前面已经介绍了训练集和测试集之间的区别。训练集 D train 用于训练模型参数,测试集 D test 用于测试模型的泛化能力。测试集中的样本不能参与模型训练,妨碍了模型对数据特征的“记忆”,损害了模型的泛化能力。训练集和测试集都是从相同的数据分布中采样的。例如,MNIST 手写数字图片集共有 70,000 幅样本图片,其中 60,000 幅图片用作训练集,其余 10,000 幅图片用于测试集。用户可以定义训练集和测试集的分离比。比如 80%的数据用于训练,剩下的 20%用于测试。当数据集规模较小时,为了更准确地检验模型的泛化能力,可以适当增加测试集的比例。图 9-8 展示了 MNIST 手写数字图片集的划分:80%用于训练,剩下的 20%用于测试。

img/515226_1_En_9_Fig8_HTML.png

图 9-8

训练和测试数据集部门

但是,仅将数据集分为训练集和测试集是不够的。因为测试集的性能不能用作模型训练的反馈,所以我们需要能够在模型训练期间挑选出更合适的模型超参数,以确定模型是否过拟合。因此,我们需要将训练集分为训练集和验证集,如图 9-9 所示。划分的训练集具有与原始训练集相同的功能,并且用于训练模型的参数,而验证集用于选择模型的超参数。其职能包括:

img/515226_1_En_9_Fig9_HTML.png

图 9-9

培训、验证和测试数据集

  • 调整学习率、权重衰减系数、训练次数等。根据验证集的性能来设置。

  • 根据验证集的性能重新调整网络拓扑。

  • 根据验证集的性能,确定它是过拟合还是欠拟合。

类似于训练集-测试集的划分,训练集、验证集和测试集可以根据自定义的比例来划分,例如常见的 60%-20%-20%划分。图 9-9 为该分部的 MNIST 笔迹数据集示意图。

验证集和测试集的区别在于,算法设计者可以根据验证集的性能来调整模型的各种超参数的设置,以提高模型的泛化能力,但不能用测试集的性能来调整模型。否则,测试集和验证集的功能会重叠,因此测试集上的性能不会代表模型的泛化能力。

事实上,一些开发人员会错误地使用测试集来选择最佳模型,然后将其作为模型泛化性能报告。对于那些情况,测试集实际上是验证集,所以报告的“泛化性能”本质上是验证集上的性能,而不是真正的泛化性能。为了防止这种“作弊”,可以选择生成多个测试集,这样即使开发者使用其中一个测试集来选择模型,我们也可以使用其他测试集来评估模型,这也是 Kaggle 比赛中常用的方法。

提前停止

一般我们把训练集中的一次批量更新称为一步,把训练集中的所有样本迭代一次称为一个历元。在几个步骤或时期之后,可以使用验证集来计算模型的验证性能。如果验证步骤过于频繁,它可以准确地观察模型的训练状态,但也会引入额外的计算成本。通常建议在几个时期后执行验证操作。

以分类任务为例,训练性能指标包括训练误差、训练精度等。相应的,验证过程中也有验证误差和验证精度,测试过程中也有测试误差和测试精度。训练精度和验证精度可以大致推断出模型是过拟合还是欠拟合。如果模型的训练误差低,训练精度高,但验证误差高,验证准确率低,就可能出现过拟合。如果训练集和验证集的误差都很高,而精度很低,则可能出现欠拟合。

当观察到过拟合时,可以重新设计网络模型的容量,如减少网络的层数,减少网络的参数个数,增加正则化方法,增加对假设空间的约束,使模型的实际容量减少来解决过拟合现象。当观察到欠拟合现象时,可以尝试增加网络的容量,比如加深网络的层数,增加网络参数的个数,尝试更复杂的网络结构。

事实上,由于网络的实际容量可以随着训练的进行而改变,即使具有相同的网络设置,也可以观察到不同的过拟合和欠拟合情况。图 9-10 显示了分类问题的典型训练曲线。红色曲线是训练精度,蓝色曲线是测试精度。从图中可以看出,随着训练前期训练的进行,模型的训练精度和测试精度都呈现出不断提高的趋势,此时并没有出现过拟合现象。在训练后期,即使是同样的网络结构,由于模型实际容量的变化,我们观察到了过拟合的现象。即训练精度不断提高,但泛化能力变弱(测试精度下降)。

这意味着,对于神经网络,即使网络超参数数量保持不变(即,网络的最大容量是固定的),该模型仍可能看起来过拟合,因为神经网络的有效容量与网络参数的状态密切相关。神经网络的有效容量可以非常大,并且也可以通过稀疏参数和正则化来降低有效容量。在训练的早中期,没有出现过拟合的现象。随着训练次数的增加,过拟合现象越来越严重。在图 9-10 中,垂直虚线处于网络的最佳状态,没有明显的过拟合现象,网络的泛化能力最好。

img/515226_1_En_9_Fig10_HTML.png

图 9-10

培训流程图

那么如何选择合适的纪元提前停止训练(提前停止)以避免过拟合呢?我们可以通过观察验证度量的变化来预测最合适的历元的可能位置。具体来说,对于分类问题,我们可以记录模型的验证精度,并监控其变化。当发现验证精度对于连续的历元没有降低时,我们可以预测最合适的历元可能已经到达,因此我们可以停止训练。图 9-11 绘制了特定训练过程中训练和验证精度随训练时期的变化曲线。可以观察到,当 Epoch 在 30 左右时,模型达到最优状态,我们可以提前停止训练。

img/515226_1_En_9_Fig11_HTML.png

图 9-11

训练曲线示例

算法 1 是使用早期停止模型训练算法的伪代码。

| **算法 1:提前停止的网络训练** | | **初始化参数***θ***重复****为** ***步=*** **1** ***,*** … ***,N*** **做****随机选择批**{**、 ***和***}**~ d12】**** *****【θ】*******【l12】****

****结束

如果每隔第 n 个纪元做

计算验证集 {( xy)}~ Dval性能**

如果某些连续步骤的验证性能没有提高,则执行

保存网络并停止训练

结束******** |
| 直到训练达到最大历元使用保存的网络计算测试集 {( xy)}~ D测试 性能****输出:网络参数 θ 和测试精度 |

9.4 模型设计

验证集可以确定网络模型是过拟合还是欠拟合,这为调整网络模型的容量提供了基础。对于神经网络来说,网络的层数和参数是网络容量非常重要的参考指标。通过减少层数和减少每层网络参数的大小,可以有效地降低网络容量。相反,如果发现模型欠拟合,我们可以通过增加层数和每层中的参数数量来增加网络的容量。

为了演示网络层数对网络容量的影响,我们可视化了分类任务的决策边界。图 [9-12 ,图 9-13 ,图 9-14 ,图 9-15 分别展示了不同网络层下训练两类分类任务的决策边界图,其中红色矩形块和蓝色圆形块分别代表训练集上的两类样本。在保持其他超参数一致的情况下,只调整网络的层数。如图,可以看到随着网络层数的增加,学习到的模型决策边界越来越接近训练样本,表示过拟合。对于这个任务,两层神经网络可以获得良好的泛化能力。网络的更深层不会提高整体模型性能。反而会导致过拟合,泛化能力变差,计算成本也更高。

img/515226_1_En_9_Fig15_HTML.jpg

图 9-15

六层

img/515226_1_En_9_Fig14_HTML.jpg

图 9-14

四层

img/515226_1_En_9_Fig13_HTML.jpg

图 9-13

三层

img/515226_1_En_9_Fig12_HTML.jpg

图 9-12

两层

9.5 正规化

通过设计具有不同层和大小的网络模型,可以为优化算法提供初始函数假设空间,但是模型的实际容量可以随着网络参数的优化和更新而改变。以多项式函数模型为例:

$$ y={\beta}_0+{\beta}_1x+{\beta}_2{x}²+{\beta}_3{x}³+\cdots +{\beta}_n{x}^n+\varepsilon $$

前一个型号的容量可以简单的通过 n 来衡量。在训练过程中,如果网络参数 β k + 1 、⋯、 β n 都为 0,则网络的实际容量退化为 kth 多项式的函数容量。因此,通过限制网络参数的稀疏性,可以约束网络的实际容量。

这种约束通常通过向损失函数添加额外的参数稀疏惩罚来实现。添加约束前的优化目标是:

$$ \mathit{\min}\ L\left({f}_{\theta }(x),y\right),\left(x,y\right)\in {D}^{train} $$

在给模型的参数添加附加约束后,优化的目标变成:

$$ \mathit{\min}\ L\left({f}_{\theta }(x),y\right)+\lambda \bullet \varOmega \left(\theta \right),\left(x,y\right)\in {D}^{train} $$

其中ω(θ)表示网络参数 θ 上的稀疏约束函数。一般情况下,参数 θ 的稀疏性约束是通过约束参数的 L 范数来实现的,即:

$$ \varOmega \left(\theta \right)={\sum}_{\theta_i}{\left\Vert {\theta}_i\right\Vert}_l $$

其中‖θIT5l代表参数 θ il 范数。

新的优化目标除了最小化原损失函数 L ( xy ),还需要约束网络参数的稀疏性ω(θ)。优化算法将在降低 L ( xy )的同时,尽可能降低网络参数稀疏度ω(θ)。这里 λ 是平衡 L ( xy )和ω(θ)重要性的权重参数。更大的 λ 意味着网络的稀疏性更重要;更小的 λ 意味着网络的训练误差更重要。通过选择合适的 λ ,可以获得更好的训练性能,同时保证网络的稀疏性,从而获得良好的泛化能力。

常用的正则化方法有 L0、L1 和 L2 正则化。

L0 正则化

L0 正则化是指以 L0 范数为稀疏罚项的正则化计算方法ω(θ),即:

$$ \varOmega \left(\theta \right)={\sum}_{\theta_i}{\left\Vert {\theta}_i\right\Vert}_0 $$

L0 范数‖θI0定义为 θ i 中非零元素的个数。$$ {\sum}_{\theta_i}{\left\Vert {\theta}_i\right\Vert}_0 $$的约束可以强制网络中的连接权重大部分为 0,从而减少网络参数的实际数量和网络容量。然而,由于 L0 范数不可导,梯度下降算法不能用于优化。L0 范数在神经网络中不常用。

L1 正规化

将 L1 范数作为稀疏惩罚项ω(θ)的正则化计算方法称为 L1 正则化,即:

$$ \varOmega \left(\theta \right)={\sum}_{\theta_i}{\left\Vert {\theta}_i\right\Vert}_1 $$

L1 范数‖θI‖1定义为张量 θ i 中所有元素的绝对值之和。L1 正则化也称为 Lasso 正则化,它是连续可导的,广泛应用于神经网络中。

L1 正则化可以如下实现:

# Create weights w1,w2
w1 = tf.random.normal([4,3])
w2 = tf.random.normal([4,2])
# Calculate L1 regularization term
loss_reg = tf.reduce_sum(tf.math.abs(w1))\
    + tf.reduce_sum(tf.math.abs(w2))

L2 正规化

将 L2 范数作为稀疏惩罚项ω(θ)的正则化计算方法称为 L2 正则化,即:

$$ \varOmega \left(\theta \right)={\sum}_{\theta_i}{\left\Vert {\theta}_i\right\Vert}_2 $$

L2 范数‖θIT52定义为张量 θ i 中所有元素的平方和。L2 正则化也叫岭正则化,和 L1 正则化一样是连续可导的,在神经网络中有着广泛的应用。

L2 正则化项实现如下:

# Create weights w1,w2
w1 = tf.random.normal([4,3])
w2 = tf.random.normal([4,2])
# Calculate L2 regularization term
loss_reg = tf.reduce_sum(tf.square(w1))\
     + tf.reduce_sum(tf.square(w2))

规则化效应

继续以月牙形二类数据为例。在网络结构等其他超参数不变的情况下,在损失函数中加入 L2 正则项,使用不同的正则化超参数 λ 获得不同程度的正则化效果。

经过 500 个历元的训练,我们得到学习模型的分类决策边界,如图 9-16 ,图 9-17 ,图 9-18 ,图 9-19 所示。该分布表示当使用正则化系数 λ = 0.00001,0.001,0.1, 0.13 时的分类效果。可以看出,随着正则化系数的增加,参数稀疏的网络惩罚变得更大,从而迫使优化算法搜索使网络容量更小的模型。当 λ = 0.00001 时,正则化效果相对较弱,网络过拟合。然而,当在 λ = 0.1,网络已经优化到合适的容量,没有明显的过拟合或欠拟合。

在实际训练中,一般倾向于尝试较小的正则化系数来观察网络是否过拟合。然后尝试逐渐增加参数 λ 来增加网络参数的稀疏性,提高泛化能力。但是过大的 λ 可能会导致网络不收敛,需要根据实际任务进行调整。

img/515226_1_En_9_Fig19_HTML.png

图 9-19

正则化参数:0.13

img/515226_1_En_9_Fig18_HTML.png

图 9-18

正则化参数:0.1

img/515226_1_En_9_Fig17_HTML.png

图 9-17

正则化参数:0.001

img/515226_1_En_9_Fig16_HTML.png

图 9-16

正则化参数:0.00001

在不同的正则化系数下,统计了网络中各连接权的取值范围。考虑网络第二层的权重矩阵 W ,其形状为【256,256】,即把一个输入长度为 256 的向量转换成一个输出长度为 256 的向量。从全连接层的权重连接来看,权重 W 包含 256 条连接线。我们将它们对应到图 9-20 ,图 9-21 ,图 9-22 ,图 9-23 中的 XY 网格,其中 X 轴范围为【0,255】,Y 轴范围为【0,255】。XY 网格的所有整数点分别代表形状【256,256】的权重张量 W 的每个位置,每个网格点表示当前连接的权重。从图中可以看出不同程度的正则化约束对网络权值的影响。当 λ = 0.00001 时,正则化的效果相对较弱,网络中的权值相对较大,主要分布在区间[1.6088,1.1599]内。将值增加到 λ = 0.13 后,网络权重值被限制在一个较小的范围内(0.1104,0.0785)。如表 9-1 所示,也可以观察到正则化后权重的稀疏性。

img/515226_1_En_9_Fig23_HTML.png

图 9-23

正则化参数:0.13

img/515226_1_En_9_Fig22_HTML.png

图 9-22

正则化参数:0.1

img/515226_1_En_9_Fig21_HTML.png

图 9-21

正则化参数:0.001

img/515226_1_En_9_Fig20_HTML.png

图 9-20

正则化参数:0.00001

表 9-1

正则化后的权重变化

| |

min(W

|

max(WT7)**

|

意为 ( W )

|
| --- | --- | --- | --- |
| 0.00001 | -1.6088 | 1.1599 | 0.0026 |
| Zero point zero zero one | -0.1393 | 0.3168 | 0.0003 |
| Zero point one | -0.0969 | 0.0832 | Zero |
| Zero point one three | -0.1104 | 0.0785 | Zero |

9.6 辍学

2012 年,Hinton 等人在他们的论文“通过防止特征检测器的共同适应来改善神经网络”中使用了 dropout 方法来改善模型性能。Dropout 法通过随机断开神经网络,减少每次训练时模型实际参与计算的参数数量。但是,在测试期间,dropout 方法将恢复所有连接,以确保模型测试期间的最佳性能。

图 9-24 是某次正向计算时全连通层网络的连接状态示意图。图 9-24(a) 是一个标准的全连接神经网络。当前节点连接到前一层中的所有输入节点。在添加了丢包函数的网络层中,如图 9-24(b) 所示,每条连接是否断开都符合某种预设的概率分布,比如带有断开概率的伯努利分布图 9-24(b) 所示为具体的采样结果。虚线表示采样结果是断开的线,实线表示采样结果没有断开。

img/515226_1_En_9_Fig24_HTML.png

图 9-24

漏失图

在 TensorFlow 中,可以通过 tf.nn.dropout(x,rate)函数实现 dropout 函数,其中 rate 参数设置断开连接的概率 p 。例如:

# Add dropout operation with disconnection rate of 0.5
x = tf.nn.dropout(x, rate=0.5)

也可以使用 dropout 作为网络层,并在网络中间插入 Dropout 层。例如:

# Add Dropout layer with disconnection rate of 0.5
model.add(layers.Dropout(rate=0.5))

为了探究脱落层对网络训练的影响,我们保持网络层数等超参数不变,通过在 5 个全连通层中插入不同数量的脱落层,观察脱落对网络训练的影响。如图 9-25 、图 9-26 、图 9-27 、图 9-28 所示,分布绘制了不加脱落层、加一层、二层、四层脱落层的网络模型的决策边界效应。可以看出,当不添加漏失层时,网络模型与前面的观察结果相同。随着脱落层的增加,网络模型在训练时的实际容量减小,泛化能力变强。

img/515226_1_En_9_Fig28_HTML.jpg

图 9-28

具有四个脱落层

img/515226_1_En_9_Fig27_HTML.jpg

图 9-27

具有两个脱落层

img/515226_1_En_9_Fig26_HTML.jpg

图 9-26

有一个脱落层

img/515226_1_En_9_Fig25_HTML.jpg

图 9-25

无脱落层

9.7 数据增长

除了前面描述的可以有效检测和抑制过拟合的方法之外,增加数据集的大小是解决过拟合问题的最重要的方法。然而,收集样本数据和标签通常成本高昂。对于有限的数据集,可以通过数据扩充技术增加训练样本的数量,以获得一定程度的性能提升。数据扩充是指在保持样本标签不变的情况下,基于先验知识改变样本的特征,使新生成的样本也符合或近似符合数据的真实分布。

以图像数据为例,我们来介绍一下如何做数据增广。数据集中图片的大小经常不一致。为了便于神经网络的处理,需要将图片重新缩放到固定大小,如图 9-29 所示,这是重新缩放后固定大小的 224 × 224 图片。对于图片中的人,根据先验知识,我们知道旋转、缩放、平移、裁剪、改变视角、遮挡某个局部区域都不会改变图片的主类别标签,所以对于图片数据,有多种数据增强方法。

img/515226_1_En_9_Fig29_HTML.jpg

图 9-29

重新调整为 224 × 224 像素后的图片

TensorFlow 提供常见的图像处理功能,位于 tf.image 子模块中。通过 tf.image.resize 函数,我们可以对图片进行缩放。我们通常在预处理步骤中实现数据扩充。在从文件系统中读取图片之后,可以执行图像数据扩充操作。例如:

def preprocess(x,y):
    # Preprocess function
    # x: picture path, y:picture label
    x = tf.io.read_file(x)
    x = tf.image.decode_jpeg(x, channels=3) # RGBA
    # rescale pictures to 244x244
    x = tf.image.resize(x, [244, 244])

旋转

旋转图片是扩充图片数据的一种非常常见的方式。将原图片旋转一定角度,可以得到不同角度的新图片,这些图片的标签信息保持不变,如图 9-30 所示。

img/515226_1_En_9_Fig30_HTML.jpg

图 9-30

图像旋转

通过 tf.image.rot90(x,k = 1)可以将图片逆时针旋转 90 度 k 次,例如:

     # Picture rotates 180 degrees counterclockwise
    x = tf.image.rot90(x,2)

翻转

画面的翻转分为沿水平轴翻转和沿垂直轴翻转,分别如图 9-31 和图 9-32 所示。在 TensorFlow 中,可以使用 TF . image . random _ flip _ left _ right 和 tf.image.random_flip_up_down 在水平和垂直方向随机翻转图像,例如:

img/515226_1_En_9_Fig32_HTML.jpg

图 9-32

垂直翻转

img/515226_1_En_9_Fig31_HTML.jpg

图 9-31

水平翻转

    # Random horizontal flip
    x = tf.image.random_flip_left_right(x)
    # Random vertical flip
    x = tf.image.random_flip_up_down(x)

9.7.3 种植

通过去除原始图像的左、右或上下方向的部分边缘像素,可以保持图像的主体不变,同时可以获得新的图像样本。实际裁剪时,图片一般会缩放到比网络输入尺寸稍大的尺寸,然后再裁剪到合适的尺寸。比如网络的输入尺寸是 224 × 224,那么你可以使用 resize 函数将图片重新缩放到 244 × 244,然后随机裁剪到 224 × 224 的尺寸。代码实现如下:

    # Rescale picture to larger size
    x = tf.image.resize(x, [244, 244])
    # Then randomly crop the picture to the desired size
x = tf.image.random_crop(x, [224,224,3])

图 9-33 是缩放到 244 × 244 的图片,图 9-34 是随机裁剪到 244 × 244 的例子,图 9-35 也是随机裁剪的例子。

img/515226_1_En_9_Fig35_HTML.jpg

图 9-35

裁剪和重缩放后-2

img/515226_1_En_9_Fig34_HTML.jpg

图 9-34

裁剪和重缩放后-1

img/515226_1_En_9_Fig33_HTML.jpg

图 9-33

裁剪前

生成数据

通过在原始数据上训练生成模型并学习真实数据的分布,生成模型可用于获得新样本。这种方法也可以在一定程度上提高网络性能。比如条件生成对抗网络(conditional generation adversive network,简称 CGAN)可以生成标记样本数据,如图 9-36 所示。

img/515226_1_En_9_Fig36_HTML.jpg

图 9-36

CGAN 生成的数字

其他方法

除了先前描述的典型图片数据扩充方法之外,图片数据可以被任意变换以基于先验知识获得新的图片,而不改变图片标签信息。图 9-37 展示的是在原图片上叠加高斯噪声后的图片数据,图 9-38 展示的是通过改变图片的视角得到的新图片,图 9-39 展示的是对原图片的部分进行随机分块得到的新图片。

img/515226_1_En_9_Fig39_HTML.jpg

图 9-39

随机阻塞零件

img/515226_1_En_9_Fig38_HTML.jpg

图 9-38

改变视角

img/515226_1_En_9_Fig37_HTML.jpg

图 9-37

添加高斯噪声

9.8 手工过度装配

之前,我们使用了大量月牙形的两类数据集来演示网络模型在各种防止过拟合措施下的性能。在本节中,我们将基于月牙形的两个分类数据集的过拟合和欠拟合模型来完成练习。

9.8.1 构建数据集

我们使用的样本数据集的特征向量长度为 2,标签为 0 或 1,代表两个类别。在 scikit-learn 库中提供的 make_moons 工具的帮助下,我们可以生成任意数量数据的训练集。首先打开 cmd 命令终端并安装 scikit-learn 库。该命令如下所示:

# Install scikit-learn library
pip install -U scikit-learn

为了证明过拟合现象,我们仅采样了 1000 个样本,并添加了标准偏差为 0.25 的高斯噪声,如下所示:

# Import libraries
from sklearn.datasets import make_moons
# Randomly choose 1000 samples, and split them into training and testing sets
X, y = make_moons(n_samples = N_SAMPLES, noise=0.25, random_state=100)
X_train, X_test, y_train, y_test = train_test_split(X, y,
                                    test_size = TEST_SIZE, random_state=42)

make_plot 函数可以根据样本的坐标 X 和样本的标签 y 方便地绘制出数据的分布图:

def make_plot(X, y, plot_name, file_name, XX=None, YY=None, preds=None):
    plt.figure()
    # sns.set_style("whitegrid")
    axes = plt.gca()
    axes.set_xlim([x_min,x_max])
    axes.set_ylim([y_min,y_max])
    axes.set(xlabel="$x_1$", ylabel="$x_2$")
    # Plot prediction surface
    if(XX is not None and YY is not None and preds is not None):
        plt.contourf(XX, YY, preds.reshape(XX.shape), 25, alpha = 0.08, cmap=cm.Spectral)
        plt.contour(XX, YY, preds.reshape(XX.shape), levels=[.5], cmap="Greys", vmin=0, vmax=.6)
    # Plot samples
    markers = ['o' if i == 1 else 's' for i in y.ravel()]
    mscatter(X[:, 0], X[:, 1], c=y.ravel(), s=20,
             cmap=plt.cm.Spectral, edgecolors='none', m=markers)
    # Save the figure
    plt.savefig(OUTPUT_DIR+'/'+file_name)

画出抽样的 1000 个样本的分布,如图 9-40 ,红色方块点为一类,蓝色圆圈为另一类。

img/515226_1_En_9_Fig40_HTML.png

图 9-40

月亮形两类数据点

# Plot data points
make_plot(X, y, None, "dataset.svg")

9.8.2 网络层数的影响

为了探索不同网络深度下的过拟合程度,我们一共进行了五个训练实验。当n∈【0,4】时,构建具有 n + 2 层的全连通层网络,通过 Adam 优化器训练 500 个历元,得到网络在训练集上的分离曲线,如图 9.12、9.13、9.14、9.15 所示。

for n in range(5): # Create 5 different network with different layers
    model = Sequential()
    # Create 1st layer
    model.add(Dense(8, input_dim=2,activation='relu'))
    for _ in range(n): # Add nth layer
        model.add(Dense(32, activation='relu'))
    model.add(Dense(1, activation='sigmoid')) # Add last layer
    model.compile(loss='binary_crossentropy', optimizer='adam', metrics=['accuracy']) # Configure and train
    history = model.fit(X_train, y_train, epochs=N_EPOCHS, verbose=1)
    # Plot boundaries for different network
    preds = model.predict_classes(np.c_[XX.ravel(), YY.ravel()])
    title = "Network layer ({})".format(n)
    file = "NetworkCapacity%f.png"%(2+n*1)
    make_plot(X_train, y_train, title, file, XX, YY, preds)

9.8.3 辍学影响

为了探讨辍学层对网络训练的影响,我们一共进行了五个实验。每个实验使用七层全连接层网络进行训练,但在全连接层中间隔插入 0~4 个漏层,并通过 Adam 优化器训练 500 个历元。网络训练结果如图 9.25、9.26、9.27 和 9.28 所示。

for n in range(5): # Create 5 different networks with different number of Dropout layers
model = Sequential()
    # Create 1st layer
    model.add(Dense(8, input_dim=2,activation='relu'))
    counter = 0
    for _ in range(5): # Total number of layers is 5
        model.add(Dense(64, activation='relu'))
        if counter < n: # Add n Dropout layers
            counter += 1
            model.add(layers.Dropout(rate=0.5))
    model.add(Dense(1, activation='sigmoid')) # Output layer
    model.compile(loss='binary_crossentropy', optimizer='adam', metrics=['accuracy']) # Configure and train
    # Train
    history = model.fit(X_train, y_train, epochs=N_EPOCHS, verbose=1)
    # Plot decision boundaries for different number of Dropout layers
    preds = model.predict_classes(np.c_[XX.ravel(), YY.ravel()])
    title = "Dropout({})".format(n)
    file = "Dropout%f.png"%(n)

    make_plot(X_train, y_train, title, file, XX, YY, preds)

9.8.4 正规化的影响

为了探讨正则化系数对网络模型训练的影响,我们采用 L2 正则化方法构建了一个五层神经网络,其中第二、三、四层神经网络的权张量 W 加入了 L2 正则化约束项,如下所示:

def build_model_with_regularization(_lambda):
    # Create networks with regularization terms
    model = Sequential()
    model.add(Dense(8, input_dim=2,activation='relu')) # without regularization
    model.add(Dense(256, activation='relu', # With L2 regularization
                    kernel_regularizer=regularizers.l2(_lambda)))
    model.add(Dense(256, activation='relu', # With L2 regularization
                    kernel_regularizer=regularizers.l2(_lambda)))
    model.add(Dense(256, activation='relu', # With L2 regularization
                    kernel_regularizer=regularizers.l2(_lambda)))
    # Output
    model.add(Dense(1, activation='sigmoid'))
    model.compile(loss='binary_crossentropy', optimizer='adam', metrics=['accuracy']) # Configure and train
    return model

在保持网络结构不变的情况下,我们调整正则化系数 λ = 0.00001,0.001,0.1,0.12,0.13 来测试网络的训练效果,并在训练集上绘制学习模型的决策边界曲线,如图 9-16 ,图 9-17 ,图 9-18 ,图 9-19 所示

for _lambda in [1e-5,1e-3,1e-1,0.12,0.13]:
    # Create model with regularization term
model = build_model_with_regularization(_lambda)
    # Train model
    history = model.fit(X_train, y_train, epochs=N_EPOCHS, verbose=1)
    # Plot weight range
    layer_index = 2
    plot_title = "Regularization-[lambda = {}]".format(str(_lambda))
    file_name = " Regularization _" + str(_lambda)
    # Plot weight ranges

    plot_weights_matrix(model, layer_index, plot_title, file_name)
    # Plot decision boundaries
    preds = model.predict_classes(np.c_[XX.ravel(), YY.ravel()])
    title = " regularization ".format(_lambda)
    file = " regularization %f.svg"%_lambda
    make_plot(X_train, y_train, title, file, XX, YY, preds)

矩阵 3D 绘图功能的 plot_weights_matrix 代码如下:

def plot_weights_matrix(model, layer_index, plot_name, file_name):
    # Plot weight ranges
    # Get weights for certain layers
    weights = model.layers[LAYER_INDEX].get_weights()[0]
    # Get minimum, maximum and mean values
    min_val = round(weights.min(), 4)
    max_val = round(weights.max(), 4)
    mean_val = round(weights.mean(), 4)
    shape = weights.shape
    # Generate grids
    X = np.array(range(shape[1]))
    Y = np.array(range(shape[0]))
    X, Y = np.meshgrid(X, Y)
    print(file_name, min_val, max_val,mean_val)
    # Plot 3D figures
    fig = plt.figure()
    ax = fig.gca(projection='3d')
    ax.xaxis.set_pane_color((1.0, 1.0, 1.0, 0.0))
    ax.yaxis.set_pane_color((1.0, 1.0, 1.0, 0.0))
    ax.zaxis.set_pane_color((1.0, 1.0, 1.0, 0.0))
    # Plot weight ranges
    surf = ax.plot_surface(X, Y, weights, cmap=plt.get_cmap('rainbow'), linewidth=0)

    ax.set_xlabel('x', fontsize=16, rotation = 0)
    ax.set_ylabel('y', fontsize=16, rotation = 0)
    ax.set_zlabel('weight', fontsize=16, rotation = 90)
    # save figure
    plt.savefig("./" + OUTPUT_DIR + "/" + file_name + ".svg")

9.9 参考文献

  1. I. Goodfellow,Y. Bengio 和 a .库维尔,《深度学习》,麻省理工学院出版社,2016 年。

十、卷积神经网络

目前人工智能还没有达到 5 岁人类的水平,但是在感知方面进步很快。在机器语音和视觉识别领域,五到十年超越人类已经没有悬念。

—沈向阳

我们介绍了神经网络的基本理论,TensorFlow 的使用,以及基本的全连通网络模型,对神经网络有了更全面和深入的了解。但是对于深度学习,我们还是有点怀疑。深度学习的深度是指网络的更深层次,一般在五层以上,目前介绍的神经网络层大多在五层以内实现。那么深度学习和神经网络有什么区别和联系呢?

本质上,深度学习和神经网络指的是同一类型的算法。在 20 世纪 80 年代,基于生物神经元的多层感知器(MLP)数学模型的网络模型被称为神经网络。由于当时计算能力有限、数据量小等因素,神经网络一般只能训练到很少的层数。我们把这种类型的神经网络称为浅层神经网络(shallow neural network)。浅层神经网络不容易从数据中提取高层特征,一般表达能力也不好。虽然在数字图片识别等简单任务中取得了不错的效果,但很快被 90 年代提出的新的支持向量机超越。

加拿大多伦多大学教授杰弗里·辛顿(Geoffrey Hinton)长期坚持神经网络的研究。然而,由于当时支持向量机的流行,神经网络相关的研究遇到了许多障碍。2006 年,Geoffrey Hinton 在[1]中提出了一种逐层预训练算法,可以有效地初始化深度信念网络(DBN)网络,从而使训练大规模、深层次(数百万个参数)的网络成为可能。在论文中,Geoffrey Hinton 将神经网络称为深度神经网络,相关研究也称为深度学习(deep learning)。从这个角度来看,深度学习和神经网络在指定上本质上是一致的,深度学习更侧重于深度神经网络。深度学习的“深度”将在本章的相关网络结构中得到最淋漓尽致的体现。

在学习更深层次的网络模型之前,我们先来考虑这样一个问题:神经网络的理论研究在 80 年代已经基本到位,但为什么未能充分挖掘深度网络的巨大潜力?通过对这个问题的讨论,我们引出本章的核心内容:卷积神经网络。这也是一种可以轻松达到几百层的神经网络。

10.1 全连接 N 的问题

首先,我们来分析一下全连通网络的问题。考虑一个简单的四层全连接层网络。输入是调平后的 784 个节点的手写数字图片矢量。中间三个隐层节点数为 256,输出层节点数为十,如图 10-1 所示。

img/515226_1_En_10_Fig1_HTML.png

图 10-1

四层全连接网络结构简图

我们可以通过 TensorFlow 快速构建这个网络模型:添加 4 个密集层,并使用顺序容器将其封装为一个网络对象:

import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers,Sequential,losses,optimizers,datasets
# Create 4-layer fully connected network
model = keras.Sequential([
    layers.Dense(256, activation='relu'),
    layers.Dense(256, activation='relu'),
    layers.Dense(256, activation='relu'),
    layers.Dense(10),
])
# build model and print the model info
model.build(input_shape=(4, 784))
model.summary()

使用 summary()函数打印出模型中各层参数的统计结果,如表 10-1 所示。网络的参数是如何计算的?每条连接线的权标量被认为是一个参数,所以对于一个有 n 个输入节点和 m 个输出节点的全连接层,张量 W 中包含的参数总共有 nm 个, m 个参数包含在向量 b 中。因此,全连接层的参数总数为nm+m。以第一层为例,输入特征长度为 784,输出特征长度为 256,当前层的参数量为 784 ⋅ 256 + 256 = 200960。同样的方法可以计算第二层、第三层、第四层的参数量,分别是 65792、65792、 2570。总参数量约 34 万。在计算机中,如果将单个权重保存为 float 类型的变量,至少需要占用 4 个字节的内存(float 在 Python 中占用的内存更多),那么 34 万个参数至少需要 1.34MB 左右的内存。换句话说,仅存储网络参数就需要 1.34MB 的内存。实际上,网络训练过程还需要缓存计算图、梯度信息、输入和中间计算结果等。,其中与梯度相关的操作会占用大量资源。

表 10-1

网络参数统计

|

|

隐藏层 1

|

隐藏层 2

|

隐藏层 3

|

输出层

|
| --- | --- | --- | --- | --- |
| 参数数量 | Two hundred thousand nine hundred and sixty | Sixty-five thousand seven hundred and ninety-two | Sixty-five thousand seven hundred and ninety-two | Two thousand five hundred and seventy |

那么训练这样一个网络需要多大的内存呢?我们可以简单地模拟现代 GPU 设备上的资源消耗。在 TensorFlow 中,如果不设置 GPU 内存占用方式,默认会占用所有 GPU 内存。这里 TensorFlow 内存使用量设置为按需分配,其占用的 GPU 内存资源观察如下:

# List all GPU devices
gpus = tf.config.experimental.list_physical_devices('GPU')
if gpus:
  try:
    # Set GPU occupation as on demand
    for gpu in gpus:
      tf.config.experimental.set_memory_growth(gpu, True)
  except RuntimeError as e:
    # excepting handling

    print(e)

前面的代码在导入 TensorFlow 库之后、创建模型之前插入。TensorFlow 配置为通过 TF . config . experimental . set _ memory _ growth(GPU,True)按需申请 GPU 内存资源。这样 TensorFlow 占用的 GPU 内存量就是运算所需的量。当批量大小设置为 32 时,我们观察到训练过程中 GPU 内存占用约 708MB,CPU 内存占用约 870MB。因为深度学习框架有不同的设计考虑,所以这个数字仅供参考。即便如此,我们也能感觉到四层全连通层的计算量并不小。

回到 80 年代,1.3MB 网络参数是什么概念?1989 年,Yann LeCun 在关于手写邮政编码识别的论文中使用 256KB 内存的计算机实现了他的算法[2]。这台计算机还配有美国电话电报公司 DSP-32C DSP 计算卡(浮点计算能力约为 25 兆浮点运算)。对于 1.3MB 的网络参数,256KB 内存的电脑连网络参数都加载不了,更别说网络训练了。可以看出,全连接层的较高存储器使用率严重限制了神经网络向更大规模和更深层的发展。

局部相关性

接下来,我们探讨如何避免全连接网络参数过大的缺陷。为了讨论方便,我们以图片类型数据的场景为例。对于 2D 图像数据,在进入全连通层之前,需要将矩阵数据展平成一个 1D 向量,然后将每个像素成对连接到每个输出节点,如图 10-2 所示。

img/515226_1_En_10_Fig2_HTML.png

图 10-2

2D 特征全连通图

可以看出,网络层的每个输出节点都连接到所有输入节点,用于提取所有输入节点的特征信息。这种密集的连接方式是全连接层参数数量大、计算成本高的根本原因。全连接层也叫密集连接层(dense layer),输出和输入的关系为:

$$ {o}_j=\sigma \left({\sum}_{i\in nodes(I)}{w}_{ij}{x}_i+{b}_j\right) $$

其中节点 ( I )表示第一层的节点集合

那么,有必要将输出节点与所有输入节点连接起来吗?有没有近似的简化模型?我们可以分析输入节点对输出节点的重要性分布,只考虑输入节点中比较重要的部分,舍弃节点中不太重要的部分,这样输出节点只需要连接一些输入节点,表示为:

$$ {o}_j=\sigma \left({\sum}_{i\in top\left(I,j,k\right)}{w}_{ij}{x}_i+{b}_j\right) $$

其中 top ( Ijk )表示第 I 层中的 top k 节点集合,该集合对于第 j 层中的编号节点具有最高的重要性。这样,全连通层的加权连接可以从第 I 层中的‖ I ‖ ⋅ ‖ J 减少到第 14 层中的 k ⋅ 其中‖ IJ 分别表示 I 层和 J 层的节点数。

那么问题就转变为探究第 I 层输入节点对数字输出节点 j 的重要性分布。然而,很难找出每个中间节点的重要性分布。我们可以利用先验知识进一步简化这个问题。

在现实生活中,有很多数据使用位置或距离作为重要性分布的度量。比如,住的离自己比较近的人,更容易对自己产生较大的影响(位置相关性),股票走势预测要更关注近期的走势(时间相关性);图片的每个像素与周围像素的关联度更大(位置关联)。以 2D 图像数据为例,如果我们简单地认为与当前像素的欧氏距离小于等于$$ \frac{k}{\sqrt{2}} $$的像素更重要,欧氏距离大于$$ \frac{k}{\sqrt{2}} $$的像素更不重要,那么我们就很容易把求每个像素重要性分布的问题简单化。如图 10-3 所示,实心网格所在的像素作为参考点,欧氏距离小于等于$$ \frac{k}{\sqrt{2}} $$的像素用矩形网格表示。网格内的像素比较重要,网格外的像素不太重要。这个窗口被称为感受野,它表征了每个像素对中心像素的重要性分布。对于中心像素,将考虑网格内的像素,而忽略网格外的像素。

img/515226_1_En_10_Fig3_HTML.png

图 10-3

像素的重要性分布

这种基于距离的重要性分布的假设特征被称为局部相关性。它只关注一些离自己近的节点,而忽略了离自己远的节点。在这种重要性分布的假设下,全连接层的连接方式变成如图 10-4 所示。输出节点 j 只连接到以 j 为中心的局部区域(感受野),不连接其他像素。

img/515226_1_En_10_Fig4_HTML.jpg

图 10-4

本地连接网络

利用局部相关的思想,我们将感受野窗口的高度和宽度记为 k (感受野的高度和宽度不一定相等;为方便起见,我们只考虑高度和宽度相等的情况)。当前节点与感受野中的所有像素相连,不考虑外部的其他像素。网络层的输入和输出关系表示如下:

$$ {o}_j=\sigma \left({\sum}_{dist\left(i,j\right)\le \frac{k}{\sqrt{2}}}{w}_{ij}{x}_i+{b}_j\right) $$

其中 dist ( ij )表示 ij 节点之间的欧氏距离。

重量分担

每个输出节点只连接感受野中的 k × k 个输入节点,输出层节点数为‖ J 。所以当前层的参数个数为k×k×J。与全连接层相比,由于 k 通常较小,如 1、3 和 5,因此k×k≪‖I成功减少了参数数量。

参数的数量是否可以进一步减少,比如我们是否只需要 k × k 个参数就可以完成当前层的计算?答案是肯定的。通过权重分担的思想,对于每个输出节点 o j ,使用相同的权重矩阵 W ,那么无论输出节点‖ J 会有多少,网络层参数的个数总是 k × k 。如图 10-5 所示,计算左上角的输出像素时,使用权重矩阵:

$$ W=\left[{w}_{11}\ {w}_{12}\ {w}_{13}\ {w}_{21}\ {w}_{22}\ {w}_{23}\ {w}_{31}\ {w}_{32}\ {w}_{33}\ \right] $$

与相应感受野内的像素相乘累加,作为左上像素的输出值。计算右下感受野时,共享权重参数 W ,即使用相同的权重参数 W 相乘累加得到右下像素值的输出。此时网络层只有 3 × 3 = 9 个参数,与输入输出节点数无关。

img/515226_1_En_10_Fig5_HTML.png

图 10-5

重量分配矩阵图

通过应用局部相关和权重共享的思想,我们成功地将网络参数的数量从‖I‖×J‖减少到 k × k (准确地说,是在单输入通道和单卷积核的条件下)。这种加权的“局部连接层”网络实际上是一种卷积神经网络。接下来,我们将从数学的角度介绍卷积运算,然后正式学习卷积神经网络的原理和实现。

卷积运算

在局部相关性的先验下,我们提出了一个简化的“局部连接层”对于窗口 k × k 中的所有像素,通过相乘和累加权重提取特征信息,每个输出节点提取感受野区域对应的特征。信息。这个运算其实是信号处理领域的一个标准运算:离散卷积运算。离散卷积运算在计算机视觉中有着广泛的应用。下面是卷积神经网络层的数学解释。

在信号处理领域,1D 连续信号的卷积运算定义为两个函数的积分:函数 f ( τ ,函数 g ( τ ,其中中 g ( τ )翻转平移后变成g(n-τ)。1D 连续卷积被定义为:

$$ \left(f\bigotimes g\right)(n)={\int}_{-\infty}^{\infty }f\left(\tau \right)g\left(n-\tau \right) d\tau $$

离散卷积用累加运算代替了积分运算:

$$ \left(f\bigotimes g\right)(n)={\sum}_{\tau =-\infty}^{\infty }f\left(\tau \right)g\left(n-\tau \right) $$

至于卷积为什么这样定义,限于篇幅我就不细说了。我们集中讨论 2D 离散卷积运算。在计算机视觉中,卷积运算是基于 2D 图像函数 f ( mn )和 2D 卷积核 g ( mn ),其中 f ( ij )和 g ( ij2D 离散卷积定义为:

$$ \left[f\bigotimes g\right]\left(m,n\right)={\sum}_{i=-\infty}^{\infty }{\sum}_{j=-\infty}^{\infty }f\left(i,j\right)g\left(m-i,n-j\right) $$

img/515226_1_En_10_Fig6_HTML.png

图 10-6

2D 图像函数 f ( ij )和卷积核函数 g ( ij )

让我们详细介绍一下 2D 离散卷积运算。先将卷积核函数 g ( ij )(每次沿 xy 方向反转)变成g(-) I,-j)。当( mn)=(1,1);这意味着卷积核函数g(1I,1j)翻转,然后向左上方移动一个单位。此时:

$$ \left[f\bigotimes g\right]\left(-1,-1\right)={\sum}_{i=-\infty}^{\infty }{\sum}_{j=-\infty}^{\infty }f\left(i,j\right)g\left(-1-i,-1-j\right)={\sum}_{i\in \left[-1,1\right]}{\sum}_{j\in \left[-1,1\right]}f\left(i,j\right)g\left(-1-i,-1-j\right) $$

2D 函数只有在I∈[1,1],j∈[1,1]时才有有效值。在其他位置,则为 0。根据计算公式,我们可以得到 fg = 7,如图 10-7 。

img/515226_1_En_10_Fig7_HTML.jpg

图 10-7

离散卷积运算-1

同样,当( mn ) = (0,1):fg](0,1)=∑I∈【1,1】∈j∈【1,1】f(I

*即卷积核翻转后,单位上移,对应位置相乘累加, fg = 7,如图 10-8 。

img/515226_1_En_10_Fig8_HTML.jpg

图 10-8

离散卷积运算-2

当( mn ) = (1,1):

$$ \left[f\bigotimes g\right]\left(1,-1\right)={\sum}_{i\in \left[-1,1\right]}{\sum}_{j\in \left[-1,1\right]}f\left(i,j\right)g\left(1-i,-1-j\right) $$

即卷积核翻转后向右上方平移一个单位,对应位置相乘累加, fg = 1,如图 10-9 。

img/515226_1_En_10_Fig9_HTML.jpg

图 10-9

离散卷积运算-3

当( mn)=(1,0):

$$ \left[f\bigotimes g\right]\left(-1,0\right)={\sum}_{i\in \left[-1,1\right]}{\sum}_{j\in \left[-1,1\right]}f\left(i,j\right)g\left(-1-i,-j\right) $$

即卷积核翻转后向左平移一个单位,对应位置相乘累加,fg = 1,如图 10-10 所示。

img/515226_1_En_10_Fig10_HTML.jpg

图 10-10

离散卷积运算-4

这样循环计算,我们就可以得到函数 fgm∈[1,1],n∈[1,1]]的所有值,如图 10-11 所示。

img/515226_1_En_10_Fig11_HTML.png

图 10-11

2D 离散卷积运算

到目前为止,我们已经成功地完成了图像函数和卷积核函数的卷积运算,以获得新的特征图。

回想一下“权重乘累加”的运算,我们记为 fg :fg=∑I∑-w/2, h/2】f(Ij)g(Imjm)

仔细对比标准的 2D 卷积运算,不难发现“权乘累加”中的卷积核函数 g ( mn )并没有翻转。对于神经网络,目标是学习一个函数 g ( mn ),使 L 尽可能小。至于是不是正好是卷积运算中定义的“卷积核”函数,并不是很重要,因为我们不会直接用到。在深度学习中,函数 g ( mn )统称为卷积核(kernel),有时也称为滤波器、权重等。由于总是使用函数 g ( mn )来完成卷积运算,所以卷积运算实际上已经实现了重量共享的思想。

我们来总结一下 2D 离散卷积的运算过程:每次通过移动卷积核,与画面对应位置的感受野像素相乘累加,得到该位置的输出值。卷积核是一个行和列的大小为 k 的权重矩阵 W 。特征图上与尺寸 k 相对应的窗口为感受野。感受野和权重矩阵相乘并累加,得到该位置的输出值。通过权重共享,我们逐渐将卷积核从左上向右下移动,提取每个位置的像素特征,直到右下,完成卷积运算。可见两种理解方式是一致的。从数学的角度来看,卷积神经网络是完成 2D 函数的离散卷积运算;从局部相关性和权重分担的角度,也可以得到同样的效果。通过这两个视角,我们不仅可以直观地理解卷积神经网络的计算过程,而且可以从数学的角度进行严密的推导。正是基于卷积运算,卷积神经网络才能如此命名。

在计算机视觉领域,2D 卷积运算可以提取数据的有用特征,用特定的卷积核对输入图像进行卷积运算,得到具有不同特征的输出图像。如表 [10-2 所示,列出了一些常见的卷积核以及相应的效果。

表 10-2

常见卷积核及其作用

| ![img/515226_1_En_10_Figa_HTML.gif](https://gitee.com/OpenDocCN/vkdoc-dl-zh/raw/master/docs/begin-dl-tf/img/515226_1_En_10_Figa_HTML.gif) |

10.2 卷积神经网络

卷积神经网络充分利用了局部相关和权重共享的思想,大大减少了网络参数的数量,从而提高了训练效率,更容易实现超大规模的深度网络。2012 年,加拿大多伦多大学的 Alex Krizhevsky 将深度卷积神经网络应用于大规模图像识别挑战 ILSVRC-2012,在 ImageNet 数据集上取得了 15.3%的 Top-5 错误率,排名第一。与第二名相比,Alex 将前 5 名的错误率降低了 10.9% [3]。这一巨大突破引起了业界的强烈关注。卷积神经网络迅速成为计算机视觉领域的新宠。随后,在一系列的工作中,基于卷积的神经网络模型相继被提出,并在原有的性能上取得了巨大的改善。

现在我们来介绍一下卷积神经网络层的具体计算过程。以 2D 影像数据为例,卷积层接受输入特征图 X ,高度 h ,宽度 w ,通道数 c 。在couthw 和 中通道数 c 的作用下,特征映射为高hw*′和 *c 应当注意,卷积核的高度和宽度可以不相等。为了简化讨论,我们只考虑等高和等宽的情况,然后可以很容易地推广到等高和不等宽的情况。**

我们首先讨论单通道输入和单卷积核,然后推广到多通道输入和单卷积核,最后讨论多通道输入和多卷积核的最常用和最复杂的卷积层实现。

10.2.1 单通道输入和单卷积内核

先讨论单通道输入 c = 1,比如一幅灰度图像只有一个通道的灰度值,单卷积核cout= 1。以大小为 5 × 5 的输入矩阵 X 和大小为 3 × 3 的卷积核矩阵为例,如图 10-12 所示。与卷积核大小相同的感受野(输入 X 上方的绿框)首先移动到输入 X 的左上方。选择输入上的感受野元素,乘以卷积核的对应元素(图中中间的方框):

$$ \left[1-1\ 0-1-2\ 2\ 1\ 2-2\ \right]\bigodot \left[-1\ 1\ 2\ 1-1\ 3\ 0-1-2\ \right]=\left[-1-1\ 0-1\ 2\ 6\ 0-2\ 4\ \right] $$

⨀符号表示哈达玛乘积,即矩阵的相应元素相乘。符号@(矩阵乘法)是矩阵运算的另一种常见形式。矩阵运算后,所有 9 个值相加:

$$ -1-1+0-1+2+6+0-2+4=7 $$

我们得到标量 7,写入输出矩阵第一行第一列的位置,如图 10-12 所示。

img/515226_1_En_10_Fig12_HTML.png

图 10-12

3 × 3 卷积运算-1

第一个感受野区域的特征提取完成后,感受野窗口向右移动一个步长单位(步长,记为 s ,默认为 1),选择图 10-13 中绿色框内的 9 个感受野元素。同样,将卷积核的相应元素相乘并累加,可以得到输出 10,写入第一行第二列位置。

img/515226_1_En_10_Fig13_HTML.png

图 10-13

3 × 3 卷积运算-2

将感受野窗口再次向右移动一个步长单位,选择图 10-14 中绿色方框内的元素,与卷积核相乘累加,得到输出 3,写入输出的第一行第三列,如图 10-14 所示。

img/515226_1_En_10_Fig14_HTML.png

图 10-14

3 × 3 卷积运算-3

此时感受野已经移动到有效像素输入的最右侧,不能继续向右移动(不填充无效元素),所以感受野窗口下移一个步长单位( s = 1),回到当前行的开头,继续选择新的感受野元素区域,如图 10-15 所示,卷积核运算得到 output -1。因为感受野下移一步,所以输出值-1 被写入第二行第一列位置。

img/515226_1_En_10_Fig15_HTML.png

图 10-15

3 × 3 卷积运算-4

按照前面的方法,感受野每右移一步( s = 1),如果超出输入边界,则下移一步( s = 1),返回到行首,直到感受野移动到最右最底的位置,如图 10-16 所示。每个选择的感受野元素乘以卷积核的相应元素,并写入输出的相应位置。最后,我们得到一个 3 × 3 的矩阵,比输入的 5 × 5 略小,这是因为感受野不能超出单元边界。可以看出,卷积运算的输出矩阵的大小是由卷积核的大小 k 、输入 X 的高度 h 和宽度 w 、移动步长 s 以及是否填充边界决定的。

img/515226_1_En_10_Fig16_HTML.png

图 10-16

3 × 3 卷积运算-5

现在我们介绍了单通道输入和单卷积核的计算过程。神经网络输入通道的实际数量通常很大。接下来,我们将学习多通道输入和单个卷积核的卷积运算方法。

10.2.2 多通道输入和单卷积内核

多通道输入卷积层更常见。例如,彩色图像包含三个通道(R/G/B)。每个通道上的像素值表示 R/G/B 颜色的强度。下面我们以三通道输入和单卷积核为例,将单通道输入的卷积运算扩展到多通道。如图 10-17 所示,每行最左边的 5 × 5 矩阵代表输入通道 1~3,第二列的 3 × 3 矩阵代表卷积核的通道 1~3,第三列的矩阵代表当前通道上计算的中间矩阵;最右边的矩阵表示卷积层运算的最终输出。

在多通道输入的情况下,卷积核的通道数需要与输入通道数相匹配。计算卷积核的第到第个通道和输入 X 的第到第个通道,得到第一个中间矩阵,然后可以看作单输入单卷积核的情况。所有通道的中间矩阵的相应元素被再次相加,作为最终输出。

具体计算过程如下:初始状态下,如图 10-17 所示,每个通道上的感受野窗口同步落在相应通道上最左边和最上面的位置。感受野区域元素和每个通道上的卷积核相乘并累加相应通道上的矩阵,得到三个通道上输出 7,-11,-1 的中间变量,然后我们可以将这些中间变量相加得到输出-5,并写入相应的位置。

img/515226_1_En_10_Fig17_HTML.png

图 10-17

多通道输入和单卷积核-1

然后,感受野窗口在每个通道上同步向右移动一步( s = 1)。此时感受野区域元素如图 10-18 所示。每个通道上的感受野乘以卷积核的相应通道上的矩阵,然后累加得到中间变量 10、20 和 20。然后,我们将它们相加得到输出 50,并写入第一行和第二列的元素位置。

img/515226_1_En_10_Fig18_HTML.png

图 10-18

多通道输入和单卷积核-2

这样,感受野窗口同步移动到最右边和最底部的位置。完成输入和卷积核的所有卷积运算,得到的 3 × 3 输出矩阵如图 10-19 所示。

img/515226_1_En_10_Fig19_HTML.png

图 10-19

多通道输入和单卷积核-3

整个计算框图如图 10-20 所示。每个输入通道的感受野乘以卷积核的相应通道,以获得与通道数量相等的中间变量。将所有这些中间变量相加以获得当前位置的输出值。输入通道的数量决定了卷积核通道的数量。一个卷积核只能得到一个输出矩阵,与输入通道的数量无关。

img/515226_1_En_10_Fig20_HTML.png

图 10-20

多通道输入和单卷积核图

一般来说,一个卷积核只能完成某个逻辑特征的提取。当需要同时提取多个逻辑特征时,可以通过增加多个卷积核来提高神经网络的表达能力。多声道输入和多卷积核就是这种情况。

10.2.3 多通道输入和多重卷积内核

多通道输入和多卷积核是卷积神经网络最常见的形式。我们已经介绍了单卷积核的运算过程。每个卷积核和输入被卷积以获得输出矩阵。当有多个卷积核时,将第 i th ( i ∈ 1, nn 为卷积核的个数)卷积核和输入 X 得到第 i 个输出矩阵(也称为输出张量 O 的通道 i ,最后将通道维中的所有输出矩阵缝合在一起(堆栈操作创建一个新的

以一个具有三个输入通道和两个卷积核的卷积层为例。第一个卷积核与输入 X 得到第一个输出通道,第二个卷积核与输入 X 得到第二个输出通道,如图 [10-21 。两个输出通道缝合在一起,形成最终输出 O 。统一设置每个卷积核的大小 k 、步长 s 和填充设置,以保证每个输出通道具有相同的大小,满足拼接的条件。

img/515226_1_En_10_Fig21_HTML.png

图 10-21

多重卷积核图

步幅大小

在卷积运算中,如何控制感受野布局的密度?对于具有高信息密度的输入,例如具有大量对象的图片,为了最大化有用的信息,在网络设计期间,期望更密集地布置感受野窗口。对于信息密度较低的输入,比如海洋的图片,我们可以适当减少感受野的数量。感受野密度的控制方法一般通过移动步幅来实现。

步幅大小是指感受野窗口每次移动的长度单位。对于 2D 输入,分为 x (向右)方向和 y (向下)方向的移动长度。为了简化讨论,我们只考虑两个方向的步长相同的情况,这也是神经网络中最常见的设置。如图 10-22 所示,绿色实线代表感受野窗口的位置,绿色虚线代表最后一个感受野的位置。从最后位置到当前位置的移动长度是步幅大小的定义。在图 10-22 中,感受野在 x 方向的步长为 2,表示为 s = 2。

img/515226_1_En_10_Fig22_HTML.png

图 10-22

步长图(即步幅)

当感受野到达输入 X 的右边界时,它向下移动一步( s = 2)并返回到行首,如图 10-23 所示。

img/515226_1_En_10_Fig23_HTML.png

图 10-23

卷积运算步长解算-1

如图 10-24 所示,来回循环直至到达底部和右侧边缘。卷积层的最终输出高度和宽度只有 2 × 2。与以前的情况( s = 1)相比,输出高度和宽度从 3 × 3 减少到 2 × 2,感受野的数量减少到只有 4 个。

img/515226_1_En_10_Fig24_HTML.png

图 10-24

卷积运算步长解算-2

可以看出,通过设置步幅大小,可以有效地控制信息密度的提取。步长较小时,感受野移动窗口较小,有助于提取更多的特征信息,输出张量的大小较大;当步长较大时,感受野移动窗口较大,有助于降低计算成本和过滤冗余信息,当然输出张量的大小也较小。

填料

卷积运算后,输出的高度和宽度通常会小于输入的高度和宽度。即使步幅大小为 1,输出的高度和宽度也将略小于输入的高度和宽度。当设计网络模型时,有时希望输出的高度和宽度可以与输入的高度和宽度相同,从而便于网络参数和剩余连接的设计。为了使输出的高度和宽度等于输入的高度和宽度,通常通过在原始输入的高度和宽度上填充几个无效元素来增加输入。通过仔细设计填充单元的数量,卷积运算后输出的高度和宽度可以等于原始输入,甚至更大。

如图 10-25 所示,我们可以在顶部、底部、左侧或右侧边界填充一个不确定的数字。默认填充数为 0,也可以用自定义数据填充。在图 10-25 中,上下方向填充一行,左右方向填充两列。

img/515226_1_En_10_Fig25_HTML.png

图 10-25

矩阵填充图

那么如何计算填充后的卷积层数呢?我们可以简单地用填充后得到的新张量X代替输入 X 。如图 10-26 所示,感受野的初始位置在X′的左上方。与前面类似,获得输出 1 并写入输出张量的相应位置。

img/515226_1_En_10_Fig26_HTML.png

图 10-26

填充-1 后的卷积运算

将步幅移动一个单位,重复操作得到输出 0,如图 10-27 所示。

img/515226_1_En_10_Fig27_HTML.png

图 10-27

填充-2 后的卷积运算

来回循环,得到的输出张量如图 10-28 所示。

img/515226_1_En_10_Fig28_HTML.png

图 10-28

填充-3 后的卷积运算

通过精心设计的填充方案,即向上、向下、向左、向右填充一个单元( p = 1),可以得到与输入高度和宽度相同的结果 O 。没有填充,如图 10-29 所示,我们只能得到略小于输入的输出。

img/515226_1_En_10_Fig29_HTML.png

图 10-29

无填充的卷积输出

卷积神经层的输出大小 bhwcout由卷积核的个数cout、卷积核的大小 k 、步长 s 决定 填充数 p (仅考虑上下填充数 p h ,左右填充数 p w ),以及输入 X 的高度 h 和宽度 w 。 之间的数学关系可以表示为:

![img/515226_1_En_10_Figb_HTML.png 其中 p hp w 分别表示高度和宽度方向的填充量,⌊⋅⌋表示向下舍入。以前面的例子为例, h = w = 5, k = 3,ph=pw= 1, s = 1,则输出为:$$ {h}^{\prime }=\left\lfloor \frac{5+2\ast 1-3}{1}\right\rfloor +1=\left\lfloor 4\right\rfloor +1=5 $$

$$ {w}^{\prime }=\left\lfloor \frac{5+2\ast 1-3}{1}\right\rfloor +1=\left\lfloor 4\right\rfloor +1=5 $$

在 TensorFlow 中,当在 s = 1,如果想让输出 O 和输入 X 的高度和宽度相等,只需要简单设置参数 padding="SAME "就可以让 TensorFlow 自动计算填充数,非常方便。

10.3 卷积层实现

在 TensorFlow 中,你既可以通过自定义权重的底层实现来构建神经网络,也可以直接调用卷积层的高层 API 来快速构建复杂的网络。我们主要以 2D 卷积为例介绍如何实现一个卷积神经网络层。

定制重量

在 TensorFlow 中,2D 卷积运算可以通过 tf.nn.conv2d 函数轻松实现。tf.nn.conv2d 根据输入X:中的 bhwc 中的卷积核W:kkc 进行卷积运算 h??’, w ,,cout其中 中的 c 表示输入通道的数量,cout表示卷积核的数量

In [1]:
x = tf.random.normal([2,5,5,3]) # input with 3 channels with height and width 5
# Create w using [k,k,cin,cout] format, 4 3x3 kernels
w = tf.random.normal([3,3,3,4])
# Stride is 1, padding is 0,
out = tf.nn.conv2d(x,w,strides=1,padding=[[0,0],[0,0],[0,0],[0,0]])
Out[1]: #  shape of output tensor
TensorShape([2, 3, 3, 4])

填充参数的格式为:

padding=[[0,0],[top,bottom],[left,right],[0,0]]

例如,如果一个单元在所有方向(上、下、左、右)都被填满,则填充参数如下:

In [2]:
x = tf.random.normal([2,5,5,3]) # input with 3 channels with height and width 5
# Create w using [k,k,cin,cout] format, 4 3x3 kernels
w = tf.random.normal([3,3,3,4])
# Stride is 1, padding is 0,
out = tf.nn.conv2d(x,w,strides=1,padding=[[0,0],[1,1],[1,1],[0,0]])
Out[2]: # shape of output tensor
TensorShape([2, 5, 5, 4])

具体来说,通过设置参数 padding='SAME '和 strides=1,我们可以得到卷积层输入和输出的相同大小,其中填充的具体数目由 TensorFlow 自动计算。例如:

In [3]:
x = tf.random.normal([2,5,5,3]) # input
w = tf.random.normal([3,3,3,4]) # 4 3x3 kernels
# Stride is 1,padding is "SAME"
# padding="SAME" gives use same size only when stride=1
out = tf.nn.conv2d(x,w,strides=1,padding='SAME')
Out[3]: TensorShape([2, 5, 5, 4])

s 例如:

In [4]:
x = tf.random.normal([2,5,5,3])
w = tf.random.normal([3,3,3,4])
out = tf.nn.conv2d(x,w,strides=3,padding='SAME')
Out [4]:TensorShape([2, 2, 2, 4])

卷积神经网络层和全连接层一样,网络可以设置一个偏置向量。tf.nn.conv2d 函数不实现偏置向量的计算。我们可以手动添加偏差。例如:

# Create bias tensor
b = tf.zeros([4])
# Add bias to convolution output. It’ll broadcast to size of [b,h',w',cout]
out = out + b

卷积层类别

通过卷积层类层。Conv2D,可以直接定义卷积核 W 和偏置张量 b 并直接调用类实例完成卷积层的正演计算。在 TensorFlow 中,API 的命名有一定的规则。大写字母的对象一般代表类,所有小写一般代表功能,比如层。Conv2D 表示卷积层类,nn.conv2d 表示卷积函数。使用类方法将自动创建所需的权重张量和偏差向量。用户不需要记忆卷积核张量的定义格式,因此使用起来更加简单方便,但我们也失去了一些灵活性。函数接口需要自己定义权重和偏置,更加灵活。

当创建一个新的卷积层类时,只需要指定卷积核参数过滤器的数量、卷积核的大小 kernel_size、步距、填充等。具有 4 个 3 × 3 卷积核的卷积层创建如下(步长为 1,填充方案为“相同”):

layer = layers.Conv2D(4,kernel_size=3,strides=1,padding='SAME')

如果卷积核的高度和宽度不相等,沿不同方向的步距也不相等,则需要设计元组格式的 kernel_size 参数( k hk w )和步距参数( s hs w 创建 4 个 3 × 4 卷积核如下(sh= 2 在垂直方向,sw= 1 在水平方向):

layer = layers.Conv2D(4,kernel_size=(3,4),strides=(2,1),padding='SAME')

创建完成后,可以通过调用实例(call method)来完成正向计算,例如:

In [5]:
layer = layers.Conv2D(4,kernel_size=3,strides=1,padding='SAME')
out = layer(x) # forward calculation
out.shape # shape of output
Out[5]:TensorShape([2, 5, 5, 4])

在 Conv2D 类中保存了卷积核张量 W 和偏差 b ,通过类成员 trainable _ variables 可以直接返回 Wb 的列表。例如:

In [6]:
# Return all trainable variables
layer.trainable_variables
Out[6]:
[<tf.Variable 'conv2d/kernel:0' shape=(3, 3, 3, 4) dtype=float32, numpy=
 array([[[[ 0.13485974, -0.22861657,  0.01000655,  0.11988598],
          [ 0.12811887,  0.20501086, -0.29820845, -0.19579397],
          [ 0.00858489, -0.24469738, -0.08591779, -0.27885547]], ...
 <tf.Variable 'conv2d/bias:0' shape=(4,) dtype=float32, numpy=array([0., 0., 0., 0.], dtype=float32)>]

这个 layer.trainable _ variables 类成员对于获取网络层中要优化的变量非常有用。也可以直接调用类实例 layer.kernel、layer.bias 来访问 Wb

10.4 动手操作 LeNet-5

20 世纪 90 年代,Yann LeCun 等人提出了一种用于识别手写数字和机印字符图片的神经网络,命名为 LeNet-5 [4]。LeNet-5 的提出使卷积神经网络在当时成功商业化,并广泛应用于邮政编码和支票号码识别等任务中。图 10-30 是 LeNet-5 的网络结构图。它接受大小为 32 × 32 的数字和字符图片作为输入,然后通过第一个卷积层获得形状为[ b ,28,28,6]的张量。在下采样层之后,张量大小被减小到[b,14,14,6]。在第二个卷积层之后,张量形状变成[ b ,10,10,16]。经过类似的下采样层,张量大小减少到[ b ,5,5,16]。在进入全连接层之前,张量被转换成形状[ b ,400]并馈入两个全连接层,输入节点数分别为 120 和 84。获得形状为[ b ,84]的张量,并最终通过高斯连接层。

img/515226_1_En_10_Fig30_HTML.png

图 10-30

LeNet-5 结构[4]

现在看来,LeNet-5 网络的层数更少(两个卷积层和两个全连接层),参数更少,计算成本更低,特别是在现代 GPU 的支持下,可以在几分钟内训练完成。

我们基于 LeNet-5 做了一些调整,使其更容易使用现代深度学习框架实现。首先,我们将输入形状从 32 × 32 调整为 28 × 28,然后将两个下采样层实现为最大池层(降低特征图的高度和宽度,这将在后面介绍),最后将高斯连接层替换为全连接层。修改后的网络在下文中也被称为 LeNet-5 网络。网络结构图如图 10-31 所示。

img/515226_1_En_10_Fig31_HTML.png

图 10-31

改进的 LeNet-5 结构

我们基于 MNIST 手写数字图片数据集训练 LeNet-5 网络,并测试其最终精度。我们已经介绍了如何在 TensorFlow 中加载 MNIST 数据集,所以在此不再赘述。

首先通过顺序容器创建 LeNet-5,如下所示:

from tensorflow.keras import Sequential

network = Sequential([
    layers.Conv2D(6,kernel_size=3,strides=1), # Convolutional layer with 6 3x3 kernels
    layers.MaxPooling2D(pool_size=2,strides=2), # Pooling layer with size 2
    layers.ReLU(), # Activation function
    layers.Conv2D(16,kernel_size=3,strides=1), # Convolutional layer with 16 3x3 kernels

    layers.MaxPooling2D(pool_size=2,strides=2), # Pooling layer with size 2
    layers.ReLU(), # Activation function
    layers.Flatten(), # Flatten layer

    layers.Dense(120, activation='relu'), # Fully-connected layer
    layers.Dense(84, activation='relu'), # Fully-connected layer
    layers.Dense(10) # Fully-connected layer
                    ])
# build the network
network.build(input_shape=(4, 28, 28, 1))
# network summary

network.summary()

summary()函数统计各层的参数并打印出网络结构信息和各层参数的详细情况,如表 10-3 所示,我们可以和全连通网络 10.1 的参数标度进行比较。

表 10-3

网络参数统计

|

|

卷积层 1

|

卷积层 2

|

完全连接的第 1 层

|

完全连接的第 2 层

|

完全连接的第 3 层

|
| --- | --- | --- | --- | --- | --- |
| 参数数量 | Sixty | Eight hundred and eighty | Forty-eight thousand one hundred and twenty | Ten thousand one hundred and sixty-four | Eight hundred and fifty |

可以看出,卷积层的参数量很小,主要参数量集中在全连接层。因为卷积层降低了输入特征维数很多,所以全连接层的参数量不会太大。整个模型的参数数量约为 60K,表 10.1 中的全连通网络参数数量达到 340000 个,因此卷积神经网络可以在增加网络深度的同时显著减少网络参数数量。

在训练阶段,首先在数据集中 shape[ b ,28,28,1]的原始输入上增加一个维度(b,28,28,1】,并发送给模型进行正演计算,得到 shape [ b ,10]的输出张量。我们创建了一个新的交叉熵损失函数类来处理分类任务。通过设置 from_logits=True 标志,在损失函数中实现 softmax 激活函数,无需手动添加损失函数,提高了数值稳定性。代码如下:

from tensorflow.keras import losses, optimizers
# Create loss function
criteon = losses.CategoricalCrossentropy(from_logits=True)

培训实施如下:

    # Create Gradient tape environment
    with tf.GradientTape() as tape:
        # Expand input dimension =>[b,28,28,1]
        x = tf.expand_dims(x,axis=3)
        # Forward calculation, [b, 784] => [b, 10]
        out = network(x)
        # One-hot encoding, [b] => [b, 10]
        y_onehot = tf.one_hot(y, depth=10)
        # Calculate cross-entropy
        loss = criteon(y_onehot, out)

获得损耗值后,损耗和网络参数 network.trainable _ variables 之间的梯度由 TensorFlow 的梯度记录器 tf 计算。GradientTape(),网络权重参数由优化器对象自动更新,如下所示:

    # Calcualte gradient
    grads = tape.gradient(loss, network.trainable_variables)
    # Update paramaters
    optimizer.apply_gradients(zip(grads, network.trainable_variables))

重复上述步骤几次后,即可完成训练。

在测试阶段,由于不需要记录梯度信息,代码一般不需要在“有 tf 的环境”中编写。GradientTape()作为磁带”。正向计算得到的输出通过 Softmax 函数后,我们得到网络预测当前图片 x 属于类别I(I∈【0,9】)的概率 P 。使用 argmax 函数选择概率最高的元素的索引作为当前预测类别,与真实标签进行比较,计算比较结果中真实样本的个数。具有正确预测的样本数除以总样本数,得到网络的测试精度。

        # Use correct to record the number of correct predictions
        # Use total to record the total number
        correct, total = 0,0
        for x,y in db_test: # Loop through all samples
            # Expand dimension =>[b,28,28,1]
            x = tf.expand_dims(x,axis=3)
            # Forward calculation to get probability, [b, 784] => [b, 10]
            out = network(x)
            # Technically, we should pass out to softmax() function firs.
 # But because softmax() doesn’t change the order the numbers, we omit the softmax() part.
            pred = tf.argmax(out, axis=-1)
            y = tf.cast(y, tf.int64)
            # Calculate the correct prediction number
            correct += float(tf.reduce_sum(tf.cast(tf.equal(pred, y),tf.float32)))
            # Total sample number
            total += x.shape[0]
        # Calculate accuracy
        print('test acc:', correct/total)

在数据集上循环训练 30 个历元后,网络的训练准确率达到 98.1%,测试准确率也达到 97.7%。对于简单的手写数字图片识别任务,老的 LeNet-5 网络已经可以取得不错的效果,但是对于稍微复杂一点的任务,比如彩色动物图片识别,LeNet-5 的性能会急剧下降。

10.5 表征学习

我们介绍了卷积神经网络层的工作原理和实现方法。复杂的卷积神经网络模型也是基于卷积层的堆叠。在过去,研究人员已经发现,网络层越深,模型的表达能力越强,越有可能实现更好的性能。那么堆叠卷积网络的特点是什么,使得层越深,网络的表达能力越强呢?

2014 年,马修·d·泽勒等人[5]试图用可视化的方法来准确理解卷积神经网络学习了什么。通过使用“反进化网络”将每一层的特征映射回输入图片,我们可以查看学习到的特征分布,如图 10-32 所示。可以观察到,第二层的特征对应于底层图像的提取,例如边缘、角和颜色;第三层开始捕捉纹理的中间特征;第四层和第五层呈现对象的一些特征,例如小狗的脸、鸟的脚和其他高级特征。通过这些可视化,我们可以在一定程度上体验卷积神经网络的特征学习过程。

img/515226_1_En_10_Fig32_HTML.jpg

图 10-32

卷积神经网络特征的可视化[5]

图像识别过程通常被认为是表征学习过程。它从接收到的原始像素特征开始,逐步提取边缘、角点等低层特征,然后是纹理等中层特征,最后是物体部分等高层特征。最后的网络层基于这些学习到的抽象特征表示来学习分类逻辑。层越高,学习的特征越准确,分类器的分类就越有利,从而获得更好的性能。从表征学习的角度来看,卷积神经网络是逐层提取特征的,网络训练的过程可以认为是一个特征学习的过程。基于学习到的高级抽象特征,可以方便地执行分类任务。

应用表示学习的思想,一个训练有素的卷积神经网络往往可以学习到更好的特征。这种特征提取方法一般是通用的。例如,在猫和狗的任务中学习头、脚、身体和其他特征的表征在某种程度上也可以用于其他动物。基于这种思想,在任务 A 上训练的深度神经网络的前几个特征提取层可以迁移到任务 B 上,只需要训练任务 B 的分类逻辑(表示为网络的最后一层)。这种方法是一种迁移学习,也称为微调。

10.6 梯度传播

完成手写数字图像识别练习后,我们对卷积神经网络的使用有了初步的了解。现在我们来解决一个关键问题。卷积层通过移动感受野来实现离散卷积运算。那么它的梯度传播是如何工作的呢?

考虑一个简单的例子,输入是一个 3 × 3 单通道矩阵,使用一个 2 × 2 卷积内核来执行卷积运算。然后,我们计算展平输出和相应标签之间的误差,如图 10-33 所示。让我们讨论一下这种情况下的梯度更新方法。

img/515226_1_En_10_Fig33_HTML.png

图 10-33

卷积层的梯度传播示例

首先导出输出张量 O 的表达式:

o00=x00w00+x01w01+x10w10+x

o01=x01w00+x02w01+x11w10+x

o10=x10w00+x11w01+x20w10+x

o11=x11w00+x12w01+x21w10+x

w 00 梯度计算为例,按链式法则分解:

$$ \frac{\partial L}{\partial {w}_{00}}={\sum}_{i\in \left{00,01,10,11\right}}\frac{\partial L}{\partial {o}_i}\frac{\partial {o}_i}{\partial {w}_{00}} $$

其中$$ \frac{\partial L}{\partial {O}_i} $$可以直接从误差函数中导出。我们来考虑一下$$ \frac{\partial {O}_i}{\partial {w}_i} $$:

$$ \frac{\partial {o}_{00}}{\partial {w}_{00}}=\frac{\partial \left({x}_{00}{w}_{00}+{x}_{01}{w}_{01}+{x}_{10}{w}_{10}+{x}_{11}{w}_{11}+b\right)}{w_{00}}={x}_{00} $$

类似地,可以推导出:

$$ \frac{\partial {o}_{01}}{\partial {w}_{00}}=\frac{\partial \left({x}_{01}{w}_{00}+{x}_{02}{w}_{01}+{x}_{11}{w}_{10}+{x}_{12}{w}_{11}+b\right)}{w_{00}}={x}_{01} $$

$$ \frac{\partial {o}_{10}}{\partial {w}_{00}}=\frac{\partial \left({x}_{10}{w}_{00}+{x}_{11}{w}_{01}+{x}_{20}{w}_{10}+{x}_{21}{w}_{11}+b\right)}{w_{00}}={x}_{10} $$

$$ \frac{\partial {o}_{11}}{\partial {w}_{00}}=\frac{\partial \left({x}_{11}{w}_{00}+{x}_{12}{w}_{01}+{x}_{21}{w}_{10}+{x}_{22}{w}_{11}+b\right)}{w_{00}}={x}_{11} $$

可以观察到,循环移动感受野的方法并没有改变网络层的衍生性,梯度的推导也并不复杂。但是当网络层数增加时,人工的梯度推导会变得非常繁琐。不过不用担心,深度学习框架可以帮助我们自动完成所有参数的梯度计算和更新,我们只需要设计好网络结构。

10.7 汇集层

在卷积层,可以通过调整步长参数 s 来降低特征图的高度和宽度,从而减少网络参数的数量。事实上,除了设置步幅大小,还有一个特殊的网络层也可以减少参数数量,这就是所谓的池层。

池层也是基于本地相关性的思想。通过从一组局部相关的元素中取样或聚集信息,我们可以获得新的元素值。特别是,最大池层从本地相关元素集中选择最大的元素值,平均池层从本地相关元素集中计算平均值。以一个 5 × 5 max 池层为例,假设感受野窗口大小 k = 2,步幅 s = 1,如图 10-34 所示。绿色虚线框代表第一个感受野的位置,感受野元素组为:

$$ \left{1,-1,-1,-2\right} $$

根据最大池,我们有:

$$ {x}^{\prime }=\mathit{\max}\left(\left{1,-1,-1,-2\right}\right)=1 $$

如果使用平均池操作,输出值将为:

$$ {x}^{\prime }= avg\left(\left{1,-1,-1,-2\right}\right)=-0.75 $$

在计算当前位置的感受野之后,类似于卷积层的计算步骤,感受野根据步幅大小向右移动几个单位。输出变成:

$$ {x}^{\prime }=\mathit{\max}\left(-1,0,-2,2\right)=2 $$

img/515226_1_En_10_Fig34_HTML.png

图 10-34

最大池示例-1

同理,逐渐将感受野窗口移至最右侧,计算输出x=max(2,0,3,1) = 1。此时,窗口已经到达输入边缘。感受野窗口向下移动一步,回到行首,如图 10-35 所示。

img/515226_1_En_10_Fig35_HTML.png

图 10-35

最大池示例-2

来回循环,直到我们到达底部和右侧,我们得到最大池层的输出,如图 10-36 所示。长度和宽度略小于输入的高度和宽度。

img/515226_1_En_10_Fig36_HTML.png

图 10-36

最大池示例-3

由于 pooling 层没有需要学习的参数,计算简单,可以有效减小特征图的大小;它广泛应用于计算机视觉相关的任务。

通过精心设计池层感受野的高度、宽度 k、和步幅参数 s ,可以实现各种降维操作。比如一个常见的池层设置是 k = 2, s = 2,可以达到只输出输入高度和宽度一半的目的。如图 10-37 和图 10-38 所示,感受野 k = 3,步长 s = 2,输入 X 的高度和宽度为 5 × 5,但输出只有高度和宽度 2 × 2。

img/515226_1_En_10_Fig38_HTML.png

图 10-38

池层示例(一半大小输出)-2

img/515226_1_En_10_Fig37_HTML.png

图 10-37

池层示例(一半大小输出)-1

10.8 批处理正则层

随着卷积神经网络的出现,网络参数的数量大大减少,使得几十层的深度网络成为可能。但是在残差网络出现之前,不断增加的神经网络层数使得训练非常不稳定,有时网络长时间不更新甚至不收敛。同时,网络对超参数更加敏感,超参数的微小变化将完全改变网络的训练轨迹。

2015 年,Google 研究人员 Sergey Ioffe 等人提出了一种参数归一化的方法,并设计了批处理归一化(BatchNorm,或 BN)层[6]。BN 层的提出使得网络超参数的设置更加自由,比如更大的学习速率,更随机的网络初始化。同时,网络具有更快的收敛速度和更好的性能。BN 层提出后,被广泛应用于各种深度网络模型中。卷积层、BN 层、ReLU 层、pooling 层一度成为网络模型的标准单元块。堆叠 Conv-BN-ReLU-Pooling 方法通常会产生良好的模型性能。

为什么我们需要对网络中的数据进行规范化?很难从理论层面彻底解释这个问题,即使是 BN 层作者给出的解释也未必能说服所有人。与其纠结原因,不如通过具体问题来体验数据规范化的好处。

考虑 Sigmoid 激活函数及其梯度分布。如图 10-39 所示,Sigmoid 函数在区间x∈[2,2]的导数值分布在区间【0.1,0.25】。当 x > 2 或 x < -2 时,Sigmoid 函数的导数变得很小,趋近于 0,容易出现梯度弥散。为了避免 Sigmoid 函数因输入过大或过小而出现梯度分散现象,将函数输入归一化到 0 附近的小区间是非常重要的。从图 10-39 可以看出,归一化后数值映射到 0 附近,这里的导数值不会太小,不易出现梯度分散。这是规范化好处的一个例子。

img/515226_1_En_10_Fig39_HTML.png

图 10-39

Sigmoid 函数及其导数

让我们看另一个例子。考虑一个有两个输入节点的线性模型,如图 10-40(a) 所示:

$$ L=a={x}_1{w}_1+{x}_2{w}_2+b $$

讨论以下两种输入分布下的优化问题:

  • x1∈【1,10】,x2∈【1,10】

  • x1∈【1,10】,x2∈【100,1000】

因为模型相对简单,所以可以绘制两种类型的损失函数等值线图。图 10-40(b) 为x1∈【1,10】和 x2∈【100,1000】时的优化轨迹示意图,图 10-40(c) 为x1∈【1,11】时的优化轨迹示意图图中圆环的中心是全局极值点。

img/515226_1_En_10_Fig40_HTML.png

图 10-40

数据规范化的一个例子

考虑:

$$ \frac{\partial L}{\partial {w}_1}={x}_1 $$

$$ \frac{\partial L}{\partial {w}_2}={x}_2 $$

当输入分布相似,偏导数值相同时,函数的优化轨迹如图 10-40(c) 所示;当输入分布相差很大时,例如x1≪x2,

$$ \frac{\partial L}{\partial {w}_1}\ll \frac{\partial L}{\partial {w}_2} $$

损失函数的等势线在轴上更陡,一个可能的优化轨迹如图 10-40(b) 所示。对比两种优化轨迹可以看出,当 x 1 和 x 2 的分布相似时,图 10-40(c) 中的收敛更快,优化轨迹更理想。

通过前面两个例子,我们可以从经验上得出结论:当网络层输入分布相似,且分布在小范围内(如接近 0)时,更有利于函数优化。那么如何保证投入分布是相似的呢?数据规范化可以达到这个目的,数据可以映射到:

$$ \hat{x}=\frac{x-{\mu}_r}{\sqrt{{\sigma_r}²+\epsilon }} $$

其中 μ r 为均值,σr2ϵ为小数值,如 1e—8。

在基于批次的训练阶段,如何获取各网络层的所有输入统计量 μ r 和σr2?考虑批内均值 μ B 和方差σB2:

$$ {\mu}_B=\frac{1}{m}{\sum}_{i=1}^m{x}_i $$

$$ {\sigma_B}²=\frac{1}{m}{\sum}_{i=1}^m{\left({x}_i-{\mu}_B\right)}² $$

可以看作是μr和σr2的近似值,其中 m 为批样本数。因此,在培训阶段,通过规范化:

$$ {\hat{x}}_{train}=\frac{x_{train}-{\mu}_B}{\sqrt{{\sigma_B}²+\epsilon }} $$

以及近似的总体均值μr和方差σr2利用每批的均值 μ B 和方差σB2

在测试阶段,我们可以使用以下方法标准化测试数据:

$$ {\hat{x}}_{test}=\frac{x_{test}-{\mu}_r}{\sqrt{{\sigma_r}²+\epsilon }} $$

前面的运算没有引入额外的变量进行优化,均值和方差都是通过已有数据得到的,不需要参与梯度更新。事实上,为了提高 BN 层的表达能力,BN 层的作者引入了“缩放和移位”技术来再次映射和转换变量:

$$ \overset{\sim }{x}=\hat{x}\bullet \gamma +\beta $$

其中参数 γ 再次缩放归一化变量,参数 β 实现平移操作。不同的是,参数 γ和β 由反向传播算法自动优化,以达到在网络层“按需”缩放和平移数据分发的目的。

我们来学习一下如何在 TensorFlow 中实现 BN 层。

向前传播

我们将 BN 层的输入表示为 x ,输出表示为$$ \overset{\sim }{x} $$。前向传播过程在训练阶段和测试阶段讨论。

训练阶段:首先计算当前批次的均值μB和方差σB2,然后根据以下公式将数据归一化:

img/515226_1_En_10_Figaj_HTML.png

然后,我们使用:

$$ {\mu}_r\leftarrow momentum\bullet {\mu}_r+\left(1- momentum\right)\bullet {\mu}_B $$

$$ {\sigma_r}²\leftarrow momentum\bullet {\sigma_r}²+\left(1- momentum\right)\bullet {\sigma_B}² $$

迭代更新全局训练数据的统计值 μ rσr2,其中动量是一个超参数,需要设置它来平衡更新幅度:当动量 = 0, μ rσ 动量 = 1 时, μ rσr2保持不变。在 TensorFlow 中,动量默认设置为 0.99。

测试阶段:BN 层使用

img/515226_1_En_10_Figam_HTML.png

计算img/515226_1_En_10_Figc_HTML.gif,其中 μ * r σr*2γβ 来自训练阶段的统计或优化结果,直接用于测试阶段,这些参数不更新。

反向传播

在反向更新阶段,反向传播算法求解损失函数的梯度$$ \frac{\partial L}{\partial \gamma } $$$$ \frac{\partial L}{\partial \beta } $$,并根据梯度更新规则自动优化参数 γ和β

需要注意的是,对于 2D 特征图输入 X : [ bhwc ],BN 层不计算μB和σB2的每一个点;而是在通道轴 c 上的每个通道上计算μB和σB2,所以μB和σB以形状[100,32,32,3]的输入为例,通道轴上的平均值 c 计算如下:

In [7]:
x=tf.random.normal([100,32,32,3])
# Combine other dimensions except the channel dimension
x=tf.reshape(x,[-1,3])
# Calculate mean
ub=tf.reduce_mean(x,axis=0)
ub
Out[7]:
<tf.Tensor: id=62, shape=(3,), dtype=float32, numpy=array([-0.00222636, -0.00049868, -0.00180082], dtype=float32)>

有 c 个通道,因此产生 c 个平均值。

除了在 c 轴上统计数据的方法,我们还可以很容易地将该方法扩展到其他维度,如图 10-41 所示:

  • 层范数:计算每个样本所有特征的均值和方差。

  • 实例范数:计算每个样本每个通道上特征的均值和方差。

  • 分组范数:将 c 通道分成若干组,统计每个样本在通道组中的特征均值和方差。

前面提到的归一化方法是由几篇独立的论文提出的,并且已经被证实在某些应用中它等同于或者优于 BatchNorm 算法。可见深度学习算法的研究并不难。只要多思考,多实践自己的工程能力,每个人都有机会发表创新成果。

img/515226_1_En_10_Fig41_HTML.jpg

图 10-41

不同的规范化插图[7]

10.8.3 批量标准化层的实现

在 TensorFlow 中,BN 层可以通过各层轻松实现。BatchNormalization()类:

# Create BN layer
layer=layers.BatchNormalization()

与全连接层和卷积层不同,BN 层在训练阶段和测试阶段的行为是不同的。有必要通过设置训练标志来区分训练模式和测试模式。

以 LeNet-5 的网络模型为例,在卷积层之后增加 BN 层;代码如下:

network = Sequential([
    layers.Conv2D(6,kernel_size=3,strides=1),
    # Insert BN layer
    layers.BatchNormalization(),
    layers.MaxPooling2D(pool_size=2,strides=2),
    layers.ReLU(),
    layers.Conv2D(16,kernel_size=3,strides=1),
    # Insert BN layer
    layers.BatchNormalization(),
    layers.MaxPooling2D(pool_size=2,strides=2),
    layers.ReLU(),
    layers.Flatten(),
    layers.Dense(120, activation='relu'),
    layers.Dense(84, activation='relu'),
    layers.Dense(10)
                    ])

在训练阶段,你需要设置网络参数training=True来区分 BN 层是训练还是测试模型。代码如下:

    with tf.GradientTape() as tape:
        # Insert channel dimension
        x = tf.expand_dims(x,axis=3)
        # Forward calculation, [b, 784] => [b, 10]
        out = network(x, training=True)

在测试阶段,你需要设置training=False来避免 BN 层的错误行为。代码如下:

        for x,y in db_test:
            # Insert channel dimension
            x = tf.expand_dims(x,axis=3)
            # Forward calculation
            out = network(x, training=False)

10.9 经典卷积网络

自 2012 年 AlexNet [3]问世以来,人们提出了多种深度卷积神经网络模型,其中比较有代表性的有 VGG 系列[8]、GoogLeNet 系列[9]、ResNet 系列[10]、DenseNet 系列[11]。他们网络层的整体趋势是逐渐增加的。以网络模型在 ILSVRC 挑战赛 ImageNet 数据集上的分类性能为例。如图 10-42 所示,AlexNet 出现之前的网络模型都是浅层神经网络,Top-5 错误率在 25%以上。AlexNet 8 层深度神经网络将 Top-5 错误率降至 16.4%,性能大幅提升。随后的 VGG 和谷歌网络模型继续将错误率降至 6.7%;ResNet 的出现,第一次把网络层的数量增加到了 152 层。错误率也降低到 3.57%。

img/515226_1_En_10_Fig42_HTML.png

图 10-42

imagenes 数据集分类任务的模型性能

本节将重点介绍这些网络模型的特征。

10.9.1 AlexNet

2012 年,ILSVRC12 挑战赛 ImageNet 数据集分类任务的冠军 Alex Krizhevsky 提出了一个八层深度神经网络模型 AlexNet,它接收 224 × 224 的彩色图像数据的输入规模,经过五个卷积层和三个全连接层后得到 1000 个类别的概率分布。为了降低特征图的维数,AlexNet 在第一、第二、第五卷积层之后增加了 Max Pooling 层。如图 10-43 所示,网络的参数数量达到 6000 万。为了在当时的 NVIDIA GTX 580 GPU (3GB GPU 内存)上训练模型,Alex Krizhevsky 将卷积层和前两个全连接层分别在两个 GPU 上拆解进行训练,最后一层合并到一个 GPU 上做反向更新。AlexNet 在 ImageNet 中取得了 15.3%的 Top-5 错误率,比第二名低了 10.9%。

AlexNet 的创新之处在于:

img/515226_1_En_10_Fig43_HTML.jpg

图 10-43

AlexNet 架构[3]

  • 层数达到了八层。

  • 使用 ReLU 激活功能。以前的神经网络大多使用 Sigmoid 激活函数,计算相对复杂,容易出现梯度分散。

  • 引入漏失层。剔除提高了模型的泛化能力,防止了过拟合。

10.9.2 VGG 系列

AlexNet 模型的卓越性能激发了行业向更深层次的网络模型方向发展。2014 年,ILSVRC14 挑战赛的 ImageNet 分类任务亚军——牛津大学 VGG 实验室提出了 VGG11、VGG13、VGG16、VGG19 等一系列网络模型(图 10-45 ),并将网络深度提高到了 19 层[8]。以 VGG16 为例,它接受大小为 224 × 224 的彩色图片数据,然后经过 2 个 Conv-Conv 池单元和 3 个 Conv-Conv-Conv 池单元,最后通过 3 个全连通层输出当前图片属于 1000 个类别的概率,如图 10-44 所示。VGG16 在 ImageNet 上取得了 7.4%的 Top-5 错误率,比 AlexNet 的错误率低 7.9%。

VGG 系列网络的创新之处在于:

img/515226_1_En_10_Fig45_HTML.jpg

图 10-45

VGG 系列网络架构[8]

img/515226_1_En_10_Fig44_HTML.jpg

图 10-44

VGG16 体系结构

  • 层数增加到 19 层。

  • 使用更小的 3×3 卷积核,与 AlexNet 中的 7×7 卷积核相比,参数更少,计算成本更低。

  • 使用较小的池层窗口 2 × 2,步长大小 s = 2,而在 AlexNet 中s= 2,池窗口为 3×3。

10.9.3 GoogLeNet

3×3 卷积核的个数参数更少,计算成本更低,性能更好。因此,业界开始探索最小的卷积核:1x1 卷积核。如图 10-46 所示,输入为三通道 5x5 画面,用单个 1x1 卷积核进行卷积运算。用对应通道的卷积核计算每个通道的数据,得到三个通道的中间矩阵,将对应的位置相加,得到最终的输出张量。对于 中 bhwc 的输入形状,1x1 卷积层的输出为[ bhwcout,其中c**1x1 卷积核的一个特殊特性是,它只能变换通道数,而不改变特征图的宽度和高度。

![img/515226_1_En_10_Fig46_HTML.png

图 10-46

1 × 1 卷积内核示例

2014 年,ILSVRC14 挑战赛冠军 Google 提出了大量使用 3×3 和 1×1 卷积核的网络模型:GoogLeNet,网络层数为 22 [9]。GoogLeNet 的层数虽然比 AlexNet 多很多,但参数量只有 AlexNet 的一半,性能也比 AlexNet 好很多。在 ImageNet 数据集分类任务上,GoogLeNet 取得了 6.7%的 Top-5 错误率,在错误率上比 VGG16 低 0.7%。

GoogLeNet 网络采用模块化设计的思想,通过堆叠大量的初始模块形成复杂的网络结构。如图 10-47 所示,初始模块的输入为 X ,然后经过四个子网络,最后在通道轴上拼接合并,形成初始模块的输出。这四个子网络是:

img/515226_1_En_10_Fig47_HTML.png

图 10-47

初始模块

  • 1 × 1 卷积层。

  • 1 × 1 卷积层,然后通过一个 3×3 的卷积层。

  • 1 × 1 卷积层,然后通过一个 5×5 的卷积层。

  • 3 × 3 最大池层,然后通过 1x1 卷积层。

GoogLeNet 的网络结构如图 10-48 所示。红框中的网络结构是图 10-47 中的网络结构。

img/515226_1_En_10_Fig48_HTML.png

图 10-48

GoogLeNet 架构[9]

10.10 实际操作 CIFAR10 和 VGG13

MNIST 是机器学习最常用的数据集之一,但由于手写数字图片非常简单,而 MNIST 数据集只保存图像灰度信息,因此不适合输入设计为 RGB 三通道的网络模型。本节将介绍另一个经典的影像分类数据集:CIFAR10。

CIFAR10 数据集由加拿大高级研究所发布。它包含十类物体的彩色图片,如飞机、汽车、鸟和猫。每个类别收集了大小图片 6000 张,共计 60000 张。其中 5 万张作为训练数据集,1 万张作为测试数据集。每种类型的样品如图 10-49 所示。

img/515226_1_En_10_Fig49_HTML.jpg

图 10-49

CIFAR10 数据集 1

同样,在 TensorFlow 中,不需要手动下载、解析和加载 CIFAR10 数据集。训练集和测试集可以通过 datasets.cifar10.load_data()函数直接加载。举个例子,

# Load CIFAR10 data set
(x,y), (x_test, y_test) = datasets.cifar10.load_data()
# Delete one dimension of y, [b,1] => [b]
y = tf.squeeze(y, axis=1)
y_test = tf.squeeze(y_test, axis=1)
# Print the shape of training and testing sets
print(x.shape, y.shape, x_test.shape, y_test.shape)
# Create training set and preprocess
train_db = tf.data.Dataset.from_tensor_slices((x,y))
train_db = train_db.shuffle(1000).map(preprocess).batch(128)
# Create testing set and preprocess
test_db = tf.data.Dataset.from_tensor_slices((x_test,y_test))
test_db = test_db.map(preprocess).batch(128)
# Select a Batch
sample = next(iter(train_db))
print('sample:', sample[0].shape, sample[1].shape,
      tf.reduce_min(sample[0]), tf.reduce_max(sample[0]))

TensorFlow 会自动将数据集下载到路径 C:\Users\username\。keras\datasets,用户可以查看它,或者手动删除不必要的数据集缓存。前面的代码运行后,训练集中的 Xy 的形状为(50000,32,32,3)和(50000),测试集中的 Xy 的形状为(10000,32,32,3)和(10000),表示图片的大小为 32 × 32,这些是彩色图片,训练集中的样本数

CIFAR10 图像识别任务并不简单。这主要是由于 CIFAR10 的图像内容需要大量的细节才能呈现,保存的图像分辨率只有 32 × 32,使得主体信息模糊,甚至人眼难以分辨。浅层神经网络的表达能力有限,难以达到较好的性能。在本节中,我们将根据数据集的特征修改 VGG13 网络结构,以完成 CIFAR10 图像识别,如下所示:

  • 将网络输入调整为 32 × 32。原网络输入为 224 × 224,导致输入特征维数过大,网络参数过大。

  • 对于十个分类任务的设置,三个全连接层的维数是[256,64,10]。

图 10-50 是调整后的 VGG13 网络结构,我们统称为 VGG13 网络模型。

img/515226_1_En_10_Fig50_HTML.png

图 10-50

调整后的 VGG13 模型结构

我们将网络实现为两个子网络:卷积子网络和全连接子网络。卷积子网络由五个子模块组成,每个子模块包含 conv-conv-最大池单元结构。代码如下:

conv_layers = [
    # Conv-Conv-Pooling unit 1
    # 64 3x3 convolutional kernels with same input and output size
    layers.Conv2D(64, kernel_size=[3, 3], padding="same", activation=tf.nn.relu),
    layers.Conv2D(64, kernel_size=[3, 3], padding="same", activation=tf.nn.relu),
    # Reduce the width and height size to half of its original
    layers.MaxPool2D(pool_size=[2, 2], strides=2, padding='same'),

    # Conv-Conv-Pooling unit 2, output channel increases to 128, half width and height
    layers.Conv2D(128, kernel_size=[3, 3], padding="same", activation=tf.nn.relu),
    layers.Conv2D(128, kernel_size=[3, 3], padding="same", activation=tf.nn.relu),
    layers.MaxPool2D(pool_size=[2, 2], strides=2, padding='same'),

    # Conv-Conv-Pooling unit 3, output channel increases to 256, half width and height

    layers.Conv2D(256, kernel_size=[3, 3], padding="same", activation=tf.nn.relu),
    layers.Conv2D(256, kernel_size=[3, 3], padding="same", activation=tf.nn.relu),
    layers.MaxPool2D(pool_size=[2, 2], strides=2, padding='same'),

    # Conv-Conv-Pooling unit 4, output channel increases to 512, half width and height
    layers.Conv2D(512, kernel_size=[3, 3], padding="same", activation=tf.nn.relu),

    layers.Conv2D(512, kernel_size=[3, 3], padding="same", activation=tf.nn.relu),
    layers.MaxPool2D(pool_size=[2, 2], strides=2, padding='same'),

    # Conv-Conv-Pooling unit 5, output channel increases to 512, half width and height
    layers.Conv2D(512, kernel_size=[3, 3], padding="same", activation=tf.nn.relu),
    layers.Conv2D(512, kernel_size=[3, 3], padding="same", activation=tf.nn.relu),
    layers.MaxPool2D(pool_size=[2, 2], strides=2, padding='same')
]
conv_net = Sequential(conv_layers)

全连通子网络包含三个全连通层,除最后一层外,每层都增加了一个 ReLU 非线性激活函数。代码如下所示:

# Create 3 fully connected layer sub-network
fc_net = Sequential([
    layers.Dense(256, activation=tf.nn.relu),
    layers.Dense(128, activation=tf.nn.relu),
    layers.Dense(10, activation=None),
])

创建子网后,使用以下代码查看网络的参数:

# build network and print parameter info
conv_net.build(input_shape=[4, 32, 32, 3])
fc_net.build(input_shape=[4, 512])
conv_net.summary()
fc_net.summary()

卷积网络的参数总数约为 940000,全连接网络的参数总数约为 177000,网络的参数总数约为 950000,比最初版本的 VGG13 少了很多。

由于我们将网络实现为两个子网络,因此在执行梯度更新时,有必要合并两个子网络的参数,如下所示:

# merge parameters of two sub-networks
variables = conv_net.trainable_variables + fc_net.trainable_variables
# calculate gradient for all parameters
grads = tape.gradient(loss, variables)
# update gradients
optimizer.apply_gradients(zip(grads, variables))

运行 CIFS ar 10 _ train . py 文件开始训练模型。经过 50 个历元的训练,网络的测试准确率达到了 77.5%。

10.11 卷积层变体

卷积神经网络的研究已经产生了各种优秀的网络模型,并且已经提出了卷积层的各种变体。本节将重点介绍几种典型的卷积层变体。

扩张/阿特鲁卷积

为了减少网络的参数数目,卷积核的设计通常选择较小的 1 × 1 和 3 × 3 感受野大小。卷积核小使得提取特征时网络的感受野面积有限,但增大感受野面积会增加网络参数的数量和计算成本,因此需要权衡设计。

扩张/阿特鲁卷积是解决这个问题的较好方法。扩张/阿特鲁卷积是在普通卷积的感受野上增加一个扩张率参数来控制感受野区域的采样步长,如图 10-51 所示。感受野采样步长扩张率为 1 时,每个感受野采样点之间的距离为 1,此时的扩张卷积退化为普通卷积;当扩张率为 2 时,在感受野中每两个单位采样一个点。如图 10-51 中间绿色方框中的绿色网格所示,每个采样网格的间距为 2。同样,图 10-51 右侧的膨胀率为 3,采样步长为 3。尽管扩张率的增加会增加感受野的面积,但计算中涉及的实际点数保持不变。

img/515226_1_En_10_Fig51_HTML.png

图 10-51

不同扩张率的感受野步长

以单通道 7 × 7 张量和单个 3 × 3 卷积核为例,如图 10-52 。在初始位置,感受野从顶部和右侧位置取样,每隔一点取样。共采集了 9 个数据点,如图 10-52 中绿色方框所示。这 9 个数据点乘以卷积核,写入输出张量的相应位置。

img/515226_1_En_10_Fig52_HTML.png

图 10-52

扩张卷积样本-1

卷积核窗口按照步长 s = 1 向右移动一个单位,如图 10-53 所示。执行相同的间隔采样。总共采样了 9 个数据点。用卷积核完成乘法和累加运算,输出张量写到相应的位置,直到卷积核移动到最下面最右边的位置。需要注意的是,卷积核窗口的移动步长 s 和感受野区域的采样步长扩张率是不同的概念。

img/515226_1_En_10_Fig53_HTML.png

图 10-53

扩张卷积样本-2

扩展卷积提供了更大的感受野窗口,而不增加网络参数。然而,当使用中空卷积建立网络模型时,需要仔细设计膨胀率参数以避免网格效应。同时,较大的膨胀率参数不利于诸如小对象检测和语义分割的任务。

在 TensorFlow 中,可以通过设置图层的 dilation_rate 参数来选择使用正常卷积或膨胀卷积。Conv2D()类。例如

In [8]:
x = tf.random.normal([1,7,7,1]) # Input
# Dilated convolution, 1 3x3 kernel
layer = layers.Conv2D(1,kernel_size=3,strides=1,dilation_rate=2)
out = layer(x) # forward calculation
out.shape
Out[8]: TensorShape([1, 3, 3, 1])

当 dilation_rate 参数设置为默认值 1 时,使用正常的卷积方法进行计算;当 dilation_rate 参数大于 1 时,对膨胀卷积方法进行采样计算。

转置卷积

转置卷积(或分数步长卷积,有时也称为反卷积)。实际上,反卷积在数学上定义为卷积的逆过程,但转置卷积无法恢复原卷积的输入,所以称之为反卷积并不恰当)通过在输入之间填充大量的填充来达到输出高度和宽度大于输入高度和宽度的效果,从而达到上采样的目的,如图 10-54 所示。我们先介绍转置卷积的计算过程,然后介绍转置卷积和普通卷积的关系。

为了简化讨论,我们只讨论带有 h = w 的输入,即输入高度和宽度相等的情况。

img/515226_1_En_10_Fig54_HTML.png

图 10-54

用于上采样的转置卷积

o + 2p − k = n * s

考虑下面这个例子:单通道特征图有 2 × 2 个输入,转置卷积核为 3 × 3, s = 2,填充 p = 0。首先,在输入数据点之间均匀插入s1 个空白数据点,得到的矩阵为 3 × 3,如图 10-55 第二个矩阵所示。根据填充量k**p1 = 301 = 2 填充 3 × 3 矩阵周围相应的行/列。此时输入张量的高度和宽度为 7 × 7,如图 10-55 第三个矩阵所示。

img/515226_1_En_10_Fig55_HTML.png

图 10-55

输入和填充示例

在 7 × 7 的输入张量上,应用步长s= 1,填充 p = 0 的 3 × 3 卷积核运算(注意这个阶段普通卷积的步长s始终为 1,与转置卷积的步长 s 不同)。根据普通卷积计算公式,输出大小为:

$$ o=\left\lfloor \frac{i+2\ast p-k}{s^{\prime }}\right\rfloor +1=\left\lfloor \frac{7+2\ast 0-3}{1}\right\rfloor +1=5 $$

表示 5 × 5 输出大小。我们直接按照这个计算过程给出最终的转置卷积输出和输入关系。当o+2p-k为 s 的倍数时,满足关系o=(I-1)s+k-2p

转置卷积不是普通卷积的逆过程,但两者有一定的联系,转置卷积也是基于普通卷积实现的。同样设置下,普通卷积运算 o = Conv ( x )后得到输入 x ,将 o 送入转置卷积运算得到x=conv transpose(o,其中x′𕟆我们可以用输入为 5 × 5,步长 s = 2,填充 p = 0,3 × 3 卷积核的普通卷积运算来验证演示,如图 10-56 所示。

img/515226_1_En_10_Fig56_HTML.png

图 10-56

使用普通卷积生成相同大小的输入

可以看出,将转置卷积大小为 5 × 5 的输出发送到相同设定条件下的普通卷积,可以得到大小为 2 × 2 的输出。这个大小正好是转置卷积的输入大小。同时,我们也观察到输出矩阵并不完全是输入到转置卷积中的输入矩阵。转置卷积和普通卷积不是互逆过程,不能恢复对方的输入内容,只能恢复大小相等的张量。所以称之为反卷积是不合适的。

基于 TensorFlow 实现上例的转置卷积运算,代码如下:

In [8]:
# Create matrix X with size 5x5
x = tf.range(25)+1
# Reshape X to certain shape
x = tf.reshape(x,[1,5,5,1])
x = tf.cast(x, tf.float32)
# Create constant matrix
w = tf.constant([[-1,2,-3.],[4,-5,6],[-7,8,-9]])
# Reshape dimension
w = tf.expand_dims(w,axis=2)
w = tf.expand_dims(w,axis=3)
# Regular convolution calculation
out = tf.nn.conv2d(x,w,strides=2,padding='VALID')
out
Out[9]: # Output size is 2x2
<tf.Tensor: id=14, shape=(1, 2, 2, 1), dtype=float32, numpy=
array([[[[ -67.],
         [ -77.]],

        [[-117.],
         [-127.]]]], dtype=float32)>

现在我们用普通卷积的输出作为转置卷积的输入,验证转置卷积的输出是否为 5×5;代码如下:

In [10]:
# Transposed convolution calculation
xx = tf.nn.conv2d_transpose(out, w, strides=2,
    padding='VALID',
    output_shape=[1,5,5,1])
Out[10]: # Output size is 5x5
<tf.Tensor: id=117, shape=(5, 5), dtype=float32, numpy=
array([[   67.,  -134.,   278.,  -154.,   231.],
       [ -268.,   335.,  -710.,   385.,  -462.],
       [  586.,  -770.,  1620.,  -870.,  1074.],
       [ -468.,   585., -1210.,   635.,  -762.],
       [  819.,  -936.,  1942., -1016.,  1143.]], dtype=float32)>

可以看出,转置卷积可以恢复相同大小的普通卷积的输入,但转置卷积的输出并不等同于普通卷积的输入。

o + 2p − k ≠n * s

让我们更深入地分析卷积运算中输入和输出之间关系的细节。考虑卷积运算的输出表达式:

$$ o=\left\lfloor \frac{i+2\ast p-k}{s}\right\rfloor +1 $$

当步长 s > 1 时,$$ \left\lfloor \frac{i+2\ast p-k}{s}\right\rfloor $$的下舍入运算使多个输入大小 i 对应同一个输出大小 o 。例如,考虑输入大小为 6 × 6、卷积核大小为 3 × 3、步长为 1 的卷积运算。代码如下:

In [11]:
x = tf.random.normal([1,6,6,1])
# 6x6 input
out = tf.nn.conv2d(x,w,strides=2,padding='VALID')
out.shape
x = tf.random.normal([1,6,6,1])...
Out[12]: # Output size 2x2, same as when the input size is 5x5
<tf.Tensor: id=21, shape=(1, 2, 2, 1), dtype=float32, numpy=
array([[[[ 20.438847 ],
         [ 19.160788 ]],

        [[  0.8098897],
         [-28.30303  ]]]], dtype=float32)>

在这种情况下,可以得到同样大小 2 × 2 的卷积输出,如图 10-56 所示。因此,不同输入大小的卷积运算可能获得相同的输出。考虑到卷积和转置卷积的输入输出关系是可以互换的,从转置卷积的角度来看,输入大小 i 经过转置卷积运算后,可能会得到不同的输出大小 o 。因此,通过填充图 10-55 中的 a 行和 a 列来实现不同大小的输出 o ,从而恢复不同大小输入的正常卷积,则 a 的关系为:

$$ a=\left(o+2p-k\right)%s $$

转置卷积的输出变为:

$$ o=\left(i-1\right)s+k-2p+a $$

在 TensorFlow 中,不需要手动指定一个。我们只是指定输出大小。TensorFlow 会自动导出需要填充的行数和列数,前提是输出大小合法。例如:

In [13]:
# Get output of size 6x6
xx = tf.nn.conv2d_transpose(out, w, strides=2,
    padding='VALID',
    output_shape=[1,6,6,1])
xx
Out[13]:
<tf.Tensor: id=23, shape=(1, 6, 6, 1), dtype=float32, numpy=
array([[[[ -20.438847 ],
         [  40.877693 ],
         [ -80.477325 ],
         [  38.321575 ],
         [ -57.48236  ],
         [   0\.       ]],...

改变参数 output_shape=[1,5,5,1]也可以得到高、宽为 5 × 5 的张量。

矩阵转置

转置卷积的转置WT是指卷积核矩阵 W 生成的稀疏矩阵W需要先进行转置,然后进行矩阵乘法运算,而普通卷积没有转置的步骤。这就是为什么它被称为转置卷积。

考虑普通的 Conv2d 运算: XW ,卷积核需要按照步长在行列方向上循环移动,以获得运算所涉及的感受野的数据,并串行计算每个窗口的“乘累加”值,效率极低。为了加快运算速度,数学上可以将卷积核 W 按照步距重排为稀疏矩阵W′,然后运算W@X一次完成(其实矩阵W太稀疏,导致很多无用的 0-乘法运算,很多深

以下面的卷积核为例:4 行 4 列的输入 X ,高度和宽度为 3 × 3,步幅为 1,无填充。首先将 X 展平为X??’,如图 10-57 所示。

img/515226_1_En_10_Fig57_HTML.png

图 10-57

转置卷积X??

然后将卷积核 W 转换成稀疏矩阵W,如图 10-58 所示。

img/515226_1_En_10_Fig58_HTML.png

图 10-58

转置卷积W??

这时,普通的卷积运算可以通过一次矩阵乘法来实现:

$$ {O}^{\prime }={W}^{\prime }@{X}^{\prime } $$

如果给定 O ,如何生成与 X it 形状大小相同的张量?将转置后的矩阵W与重排后的矩阵O相乘如图 10-57 :

$$ {X}^{\prime }={W}^{\prime T}@{O}^{\prime } $$

X?? 整形为与原始输入尺寸 X 相同。比如O的形状为[4,1】,WT的形状为[16,4],矩阵乘法得到的X的形状为[16,1],形状为[4,4]的张量经过整形就可以生成。由于转置卷积在矩阵运算时需要先进行转置,然后才能与转置卷积的输入矩阵相乘,所以称为转置卷积。

转置卷积具有“放大特征图”的功能,被广泛应用于对抗网络的生成和语义分割。例如,DCGAN [12]中的生成器通过堆叠转置卷积层来实现逐层“放大”,最终得到非常逼真的生成画面。

img/515226_1_En_10_Fig59_HTML.jpg

图 10-59

DCGAN 架构[12]

转置卷积实现

在 TensorFlow 中,转置卷积运算可以通过 nn.conv2d_transpose()函数实现。我们先通过 nn.conv2d 完成普通的卷积运算,注意转置卷积的卷积核的定义格式是[ kkc outcin]。例如

In [14]:
# Input 4x4
x = tf.range(16)+1
x = tf.reshape(x,[1,4,4,1])
x = tf.cast(x, tf.float32)
# 3x3 kernel
w = tf.constant([[-1,2,-3.],[4,-5,6],[-7,8,-9]])
w = tf.expand_dims(w,axis=2)
w = tf.expand_dims(w,axis=3)
# Regular convolutional operation
out = tf.nn.conv2d(x,w,strides=1,padding='VALID')
Out[14]:
<tf.Tensor: id=42, shape=(2, 2), dtype=float32, numpy=
array([[-56., -61.],
       [-76., -81.]], dtype=float32)>

在步幅=1,填充= '有效',卷积核不变的情况下,我们通过卷积核 w 与输出的转置卷积运算,尝试恢复与输入 x 大小相同的高度和宽度张量。代码如下:

In [15]: # Restore 4x4 input
xx = tf.nn.conv2d_transpose(out, w, strides=1, padding='VALID', output_shape=[1,4,4,1])
tf.squeeze(xx)
Out[15]:
<tf.Tensor: id=44, shape=(4, 4), dtype=float32, numpy=
array([[  56.,  -51.,   46.,  183.],
       [-148.,  -35.,   35., -123.],
       [  88.,   35.,  -35.,   63.],
       [ 532.,  -41.,   36.,  729.]], dtype=float32)>

可以看出,4 × 4 的特征图是由转置卷积生成的,但特征图的数据与输入 x 并不相同。

使用 tf.nn.conv2d_transpose 进行转置卷积运算时,需要手动设置输出高度和宽度。tf.nn.conv2d_transpose 不支持自定义填充设置,它只能设置为 VALID 或 SAME。

当设置了 padding='VALID '时,输出大小为:

$$ o=\left(i-1\right)s+k $$

当设置 padding='SAME '时,输出大小为:

$$ o=i\bullet s $$

如果读者暂时不能理解转置卷积的原理细节,他/她可以记住前面的两个表达式。例如,当计算 2 × 2 转置卷积输入和 3 × 3 卷积内核时,步长=1,填充= '有效',输出大小为:

$$ {h}^{\prime }={w}^{\prime }=\left(2-1\right)\bullet 1+3=4 $$

计算 2 × 2 转置卷积输入和 3 × 3 卷积内核时,步长=3,填充=“相同”,输出大小为:

$$ {h}^{\prime }={w}^{\prime }=2\bullet 3=6 $$

转置卷积也可以和其他层一样。通过图层创建转置卷积图层。Conv2DTranspose 类,然后调用实例完成正向计算:

In [16]:
layer = layers.Conv2DTranspose(1,kernel_size=3,strides=1,padding='VALID')
xx2 = layer(out)
xx2
Out[16]:
<tf.Tensor: id=130, shape=(1, 4, 4, 1), dtype=float32, numpy=
array([[[[  9.7032385 ],
         [  5.485071  ],
         [ -1.6490463 ],
         [  1.6279562 ]],...

分离卷积

这里我们以深度方向可分离卷积为例。当普通卷积对多通道输入进行运算时,卷积核的每个通道与输入的每个通道分别进行卷积,得到一个多通道特征图,然后将相应的元素相加,产生单个卷积核输出的最终结果,如图 10-60 所示。

img/515226_1_En_10_Fig60_HTML.png

图 10-60

普通卷积计算示意图

单独卷积的计算过程是不同的。卷积核的每个通道与每个输入通道进行卷积,得到多个通道的中间特征,如图 10-61 所示。然后,对该多通道中间特征张量进行多个 1 × 1 卷积核的普通卷积运算,以获得具有恒定高度和宽度的多个输出。这些输出在信道轴上拼接,以产生最终分离的卷积层输出。可以看出,分离的卷积层包括两步卷积运算。第一卷积运算是单个卷积核,第二卷积运算包括多个卷积核。

img/515226_1_En_10_Fig61_HTML.png

图 10-61

深度可分卷积计算示意图

那么使用单独卷积有什么好处呢?一个明显的优点是,对于相同的输入输出,可分离卷积的参数约为普通卷积的 1/3。考虑上图中普通卷积和单独卷积的例子。普通卷积的参数数量为:

$$ 3\bullet 3\bullet 3\bullet 4=108 $$

分离卷积的参数的第一部分是:

$$ 3\bullet 3\bullet 3\bullet 1=27 $$

参数的第二部分是:

$$ 1\bullet 1\bullet 3\bullet 4=14 $$

分离卷积的总参数量只有 39,但它可以实现与普通卷积相同的输入输出大小变换。分离卷积已广泛应用于对计算成本敏感的领域,如异常和移动网络。

10.12 深层剩余网络

AlexNet、VGG、GoogLeNet 等网络模型的出现,将神经网络的发展带到了几十层的阶段。研究人员发现,网络越深,越有可能获得更好的泛化能力。但是随着模型的深入,网络越来越难训练,主要是梯度分散和梯度爆炸造成的。在层数较深的神经网络中,当梯度信息从网络的最后一层逐层传递到网络的第一层时,在传递过程中会出现梯度接近 0 或者梯度值很大的现象。网络层越深,这种现象可能越严重。

那么如何解决深度神经网络的梯度分散和梯度爆炸现象呢?一个非常自然的想法是,由于浅层神经网络不容易出现这些梯度,所以可以尝试为深层神经网络添加一个回退机制。当深度神经网络可以容易地回退到浅层神经网络时,深度神经网络可以获得与浅层神经网络相当的模型性能,但不会更差。

通过在输入和输出之间增加一个直接连接——跳过连接——神经网络就有了后退的能力。以 VGG13 深度神经网络为例,假设在 VGG13 模型中观察到了梯度弥散现象,而十层网络模型没有观察到梯度弥散现象,那么可以考虑在最后两个卷积层增加 Skip 连接,如图 10-62 所示。这样网络模型就可以自动选择是通过这两个卷积层完成特征变换,还是跳过这两个卷积层选择跳过连接,或者将两个卷积层的输出合并起来跳过连接。

img/515226_1_En_10_Fig62_HTML.png

图 10-62

跳过连接的 VGG13 架构

2015 年,微软亚洲研究院的何等人发表了基于跳过连接的深度残差网络(residual neural network,简称 ResNet)算法[10],提出了 18 层、34 层、50 层、101 层、152 层网络,即 ResNet-18、ResNet-34、ResNet-50、ResNet-101 和 ResNet-152 模型,甚至成功训练了一个 1202 层的极深度神经网络。ResNet 在 ILSVRC 2015 挑战赛的 ImageNet 数据集上实现了分类和检测等任务的最佳性能。ResNet 的论文至今已被引用超过 25000 次,可见 ResNet 在人工智能界的影响力。

ResNet 原则

ResNet 通过在卷积层的输入和输出之间增加 Skip 连接来实现回退机制,如图 10-63 所示。输入 x 经过两个卷积层得到特征变换后的输出 F ( x ),将 F ( x )的对应元素加到 x 得到最终输出:

$$ H(x)=x+F(x) $$

H ( x )称为残差块(简称 ResBlock)。由于跳过连接包围的卷积神经网络需要学习映射F(x)=H(x)x,所以称为残差网络。

为了满足卷积层的输入 x 和输出 F ( x )的相加,输入的形状需要和输出 F ( x )的形状完全相同。当形状不一致时,输入的 x 一般通过在 Skip 连接上增加额外的卷积运算转换成与 F ( x 相同的形状,如图 10-63 中的函数 identity ( x )所示,其中 identity ( x )主要采取 1 × 1 卷积运算来调整

img/515226_1_En_10_Fig63_HTML.png

图 10-63

剩余模块

图 10-64 对比了 34 层深度残差网络、34 层普通深度网络、19 层 VGG 网络结构。可以看出,深度残差网络通过堆叠残差模块达到更深的网络层,从而获得训练稳定、性能优越的深度网络模型。

img/515226_1_En_10_Fig64_HTML.jpg

图 10-64

网络架构比较[10]

ResBlock 实现

深度残差网络没有添加新的网络层类型,而只是在输入和输出之间添加了一个跳过连接,因此没有 ResNet 的底层实现。残差模块可以通过调用普通卷积层在 TensorFlow 中实现。

首先,创建一个新类。初始化残差块中需要的卷积层和激活功能层,然后创建新的卷积层;代码如下:

class BasicBlock(layers.Layer):
    # Residual block
    def __init__(self, filter_num, stride=1):
        super(BasicBlock, self).__init__()
        # Create Convolutional Layer 1
        self.conv1 = layers.Conv2D(filter_num, (3, 3), strides=stride, padding='same')
        self.bn1 = layers.BatchNormalization()
        self.relu = layers.Activation('relu')
        # Create Convolutional Layer 2
        self.conv2 = layers.Conv2D(filter_num, (3, 3), strides=1, padding='same')
        self.bn2 = layers.BatchNormalization()

F ( x )和 x 形状不同时,不能直接相加。我们需要创建一个新的卷积层身份 ( x )来完成 x 的形状转换。按照前面的代码,实现如下:

        if stride != 1: # Insert identity layer
            self.downsample = Sequential()
            self.downsample.add(layers.Conv2D(filter_num, (1, 1), strides=stride))
        else: # connect directly
            self.downsample = lambda x:x

正向传播时,只需要添加 F ( x )和身份 ( x )并添加 ReLU 激活函数。正向计算功能代码如下:

    def call(self, inputs, training=None):
        # Forward calculation
        out = self.conv1(inputs) # 1st Conv layer
        out = self.bn1(out)
        out = self.relu(out)
        out = self.conv2(out) # 2nd Conv layer
        out = self.bn2(out)
        #  identity() conversion
        identity = self.downsample(inputs)
        # f(x)+x
        output = layers.add([out, identity])
        # activation function
        output = tf.nn.relu(output)
        return output

10.13 密集网络

跳过连接的想法在 ResNet 上取得了巨大的成功。研究人员已经开始尝试不同的跳过连接方案,其中 DenseNet [11]更受欢迎。DenseNet 通过跳过连接将所有先前图层的要素地图信息与当前图层的输出进行聚合。与 ResNet 的相应位置添加方法不同,DenseNet 使用通道轴维度中的拼接操作来聚合特征信息。

如图 10-65 所示,输入 X 0 经过卷积层 H 1 ,输出 X 1 与信道轴拼接得到聚合特征张量,发送到卷积层 H 2 得到输出 X 2 同样, X 2X 1X 0 拼接发送到下一层。如此重复,直到最后一层X4的输出和前面所有层的特征信息:{XI}I= 0,1,2,3 聚合到模块的最终输出。这种基于跳跃连接的密集连接模块称为密集块。

img/515226_1_En_10_Fig65_HTML.jpg

图 10-65

密集块状建筑 2

DenseNet 通过堆叠多个密集块来构建复杂的深度神经网络,如图 10-66 所示。

img/515226_1_En_10_Fig66_HTML.jpg

图 10-66

典型的 DenseNet 架构 3

图 10-67DenseNet 不同版本的性能对比,DenseNet 和 ResNet 的性能对比,dense net 和 ResNet 的训练曲线。

img/515226_1_En_10_Fig67_HTML.jpg

图 10-67

DenseNet 和 ResNet 的性能比较[11]

10.14 实际操作 CIFAR10 和 ResNet18

在本节中,我们将实现 18 层深度残差网络 ResNet18,并在 CIFAR10 图像数据集上对其进行训练和测试。我们将它的性能与 13 层普通神经网络 VGG13 进行比较。

标准 ResNet18 接受大小为 224 × 224 的图像数据。我们适当调整 ResNet18,使其输入尺寸为 32 × 32,输出尺寸为 10。调整后的 ResNet18 网络结构如图 10-68 所示。

img/515226_1_En_10_Fig68_HTML.png

图 10-68

调整后的 ResNet18 架构

首先实现中间两个卷积层的残差模块,以及如下所示的跳过连接 1x1 卷积层的残差块:

class BasicBlock(layers.Layer):
    # Residual block
    def __init__(self, filter_num, stride=1):
        super(BasicBlock, self).__init__()
        # 1st conv layer
        self.conv1 = layers.Conv2D(filter_num, (3, 3), strides=stride, padding='same')
        self.bn1 = layers.BatchNormalization()
        self.relu = layers.Activation('relu')
        # 2nd conv layer
        self.conv2 = layers.Conv2D(filter_num, (3, 3), strides=1, padding='same')
        self.bn2 = layers.BatchNormalization()

        if stride != 1:
            self.downsample = Sequential()
            self.downsample.add(layers.Conv2D(filter_num, (1, 1), strides=stride))
        else:
            self.downsample = lambda x:x

    def call(self, inputs, training=None):
        # Forward calculation
        # [b, h, w, c], 1st conv layer
        out = self.conv1(inputs)
        out = self.bn1(out)
        out = self.relu(out)
        # 2nd conv layer
        out = self.conv2(out)
        out = self.bn2(out)
        # identity()
        identity = self.downsample(inputs)
        # Add two layers
        output = layers.add([out, identity])

        output = tf.nn.relu(output) # activation function

        return output

设计深度卷积神经网络时,一般遵循特征图高度和宽度逐渐减小,通道数逐渐增加的经验法则。高层特征的提取可以通过堆叠通道号逐渐增加的 res 块来实现,通过 build_resblock 可以一次构建多个残差模块,如下所示:

    def build_resblock(self, filter_num, blocks, stride=1):
        # stack filter_num BasicBlocks
        res_blocks = Sequential()
        # Only 1st BasicBlock’s stride may not be 1
        res_blocks.add(BasicBlock(filter_num, stride))

        for _ in range(1, blocks):# Stride of Other BasicBlocks are all 1
            res_blocks.add(BasicBlock(filter_num, stride=1))

        return res_blocks

让我们实现一个通用的 ResNet 网络模型,如下所示:

class ResNet(keras.Model):
    # General ResNet class
    def __init__(self, layer_dims, num_classes=10): # [2, 2, 2, 2]
        super(ResNet, self).__init__()
        self.stem = Sequential([layers.Conv2D(64, (3, 3), strides=(1, 1)),
                                layers.BatchNormalization(),
                                layers.Activation('relu'),
                                layers.MaxPool2D(pool_size=(2, 2), strides=(1, 1), padding='same')
                                ])
        # Stack 4 Blocks
        self.layer1 = self.build_resblock(64,  layer_dims[0])
        self.layer2 = self.build_resblock(128, layer_dims[1], stride=2)
        self.layer3 = self.build_resblock(256, layer_dims[2], stride=2)
        self.layer4 = self.build_resblock(512, layer_dims[3], stride=2)

        # Pooling layer => 1x1
        self.avgpool = layers.GlobalAveragePooling2D()
        # Fully connected layer
        self.fc = layers.Dense(num_classes)

    def call(self, inputs, training=None):
        # Forward calculation
        x = self.stem(inputs)
        # 4 blocks
        x = self.layer1(x)
        x = self.layer2(x)
        x = self.layer3(x)
        x = self.layer4(x)

        # Pooling layer
        x = self.avgpool(x)
        # Fully connected layer

        x = self.fc(x)

        return x

通过调整每个 Res 块的栈数和通道数可以生成不同的 ResNet,比如用 64-64-128-128-256-256-512-512 通道配置,一共 8 个 Res 块,就可以得到 ResNet18 网络模型。每个 ResBlock 包含两个主卷积层,因此卷积层数为 8 ⋅ 2 = 16,加上网络末端的全连通层,共 18 层。创建 ResNet18 和 ResNet34 可以简单地实现如下:

def resnet18():
    return ResNet([2, 2, 2, 2])

def resnet34():
    return ResNet([3, 4, 6, 3])

接下来,按如下方式完成 CIFAR10 数据集的加载:

(x,y), (x_test, y_test) = datasets.cifar10.load_data() # load data
y = tf.squeeze(y, axis=1) # sequeeze data
y_test = tf.squeeze(y_test, axis=1)
print(x.shape, y.shape, x_test.shape, y_test.shape)

train_db = tf.data.Dataset.from_tensor_slices((x,y)) # create training set
train_db = train_db.shuffle(1000).map(preprocess).batch(512)

test_db = tf.data.Dataset.from_tensor_slices((x_test,y_test)) #creat testing set
test_db = test_db.map(preprocess).batch(512)
# sample an example
sample = next(iter(train_db))
print('sample:', sample[0].shape, sample[1].shape,
      tf.reduce_min(sample[0]), tf.reduce_max(sample[0]))

数据预处理逻辑相对简单。我们只需要将数据范围直接映射到区间[1,1]。在这里,您还可以根据 ImageNet 数据图片的平均值和标准偏差执行标准化,如下所示:

def preprocess(x, y):
    x = 2*tf.cast(x, dtype=tf.float32) / 255\. - 1
    y = tf.cast(y, dtype=tf.int32)
    return x,y

网络训练逻辑与正常分类网络训练部分相同,训练 50 个时期如下:

    for epoch in range(50): # Train epoch
        for step, (x,y) in enumerate(train_db):
            with tf.GradientTape() as tape:
                # [b, 32, 32, 3] => [b, 10], forward calculation
                logits = model(x)
                # [b] => [b, 10],one-hot encoding
                y_onehot = tf.one_hot(y, depth=10)
                # Calculate loss
                loss = tf.losses.categorical_crossentropy(y_onehot, logits, from_logits=True)
                loss = tf.reduce_mean(loss)
            # Calculate gradient

            grads = tape.gradient(loss, model.trainable_variables)
            # Update parameters
            optimizer.apply_gradients(zip(grads, model.trainable_variables))

ResNet18 共有 1100 万个网络参数。经过 50 个历元后,网络的准确率达到了 79.3%。我们这里的代码相对来说比较精简。在仔细的超参数和数据增强的支持下,准确率可以更高。

10.15 参考

  1. G.E. Hinton,S. Osindero 和 Y.-W. Teh,“深度信念网络的快速学习算法”,神经计算。,第 18 期,第 1527—1554 页,2006 年第 7 期。

  2. Y.LeCun,B. Boser,J. S. Denker,D. Henderson,R. E. Howard,W. Hubbard 和 L. D. Jackel,“应用于手写邮政编码识别的反向传播”,神经计算。,第 1 卷,第 551—541 页,1989 年第 12 期。

  3. A.Krizhevsky、I. Sutskever 和 G. E. Hinton,“使用深度卷积神经网络的 ImageNet 分类”,神经信息处理系统的进展 25 ,F. Pereira、C. J. C. Burges、L. Bottou 和 K. Q. Weinberger,Curran Associates,Inc .,2012 年,第 1097-1105 页。

  4. Y.Lecun,L. Bottou,Y. Bengio 和 P. Haffner,“基于梯度的学习在文档识别中的应用”,《美国电气和电子工程师协会会议录,1998 年。

  5. 米(meter 的缩写))d .泽勒和 r .弗格斯,“可视化和理解卷积网络”,计算机视觉- ECCV 2014 年,Cham,2014 年。

  6. 南 Ioffe 和 C. Szegedy,“批量标准化:通过减少内部协变量移位来加速深度网络训练”, CoRR, abs/1502.03167,2015。

  7. Y.吴和 K. He,“组规范化”, CoRR, abs/1803.08494,2018。

  8. K.Simonyan 和 A. Zisserman,“用于大规模图像识别的极深度卷积网络”, CoRR, abs/1409.1556,2014 年。

  9. C.Szegedy,W. Liu,Y. Jia,P. Sermanet,S. Reed,D. Anguelov,D. Erhan,V. Vanhoucke 和 A. Rabinovich,“用卷积走得更深”,《计算机视觉与模式识别(CVPR)】,2015。

  10. K.何,x .张,s .任,j .孙,“深度残差学习用于图像识别”, CoRR, abs/1512.03385,2015。

  11. G.黄,刘,温伯格,“密集连接卷积网络”, CoRR, abs/1608.06993,2016。

  12. A.拉德福德,l .梅斯和 s .钦塔拉,深度卷积生成对抗网络的无监督表示学习,2015 年。

*

十一、循环神经网络

人工智能的强大崛起可能是人类历史上最好的事情,也可能是最坏的事情。

—史蒂芬·霍金

卷积神经网络利用数据的局部相关性和权重共享的思想,大大减少了网络参数的数量。非常适合空间和局部相关的图片。它已经成功地应用于计算机视觉领域的一系列任务中。除了空间维度,自然信号还具有时间维度。具有时间维度的信号非常常见,比如我们正在阅读的文本,我们说话时发出的语音信号,以及随时间变化的股票市场。这类数据不一定具有局部相关性,数据在时间维度上的长度也是可变的。卷积神经网络不擅长处理这类数据。

因此,分析和识别这种类型的信号是将人工智能推向通用人工智能必须解决的任务。本章将要介绍的循环神经网络可以较好地解决这类问题。在介绍循环神经网络之前,我们先介绍一下按时间顺序表示数据的方法。

11.1 序列表示方法

有顺序的数据一般称为序列,比如随时间变化的商品价格数据就是非常典型的序列。考虑到某商品 A 在 1 月至 6 月间的价格变化趋势,我们可以将其记录为一维向量:[ x 1x 2x 3x 4x 5x 6 、6 如果想表示 b 商品 1-6 月的价格变化趋势,可以记录为 2 维张量:

$$ \left[\left[{x}_1{(1)},{x}_2{(1)},\cdots, {x}_6{(1)}\right],\left[{x}_1{(2)},{x}_2^{(2)},\cdots, {x}_6^{(2)}\right],\cdots, \left[{x}_1{(b)},{x}_2{(b)},\cdots, {x}_6^{(b)}\right]\right] $$

其中 b 代表商品的数量,张量形状为[ b ,6]。

这样,序列信号就不难表示了,只需要一个形状为[b,s]的张量,其中 b 是序列的个数,s 是序列的长度。然而,许多信号不能直接用标量值来表示。例如,为了表示由每个时间戳生成的长度为 n 的特征向量,需要形状为[b,s,n]的张量。考虑更复杂的文本数据:句子。每个时间戳上生成的字是一个字符,而不是一个数值,因此不能用标量直接表示。我们已经知道,神经网络本质上是一系列数学运算,如矩阵乘法和加法。它们不能直接处理字符串数据。如果希望神经网络用于自然语言处理任务,那么如何将单词或字符转换成数值就变得尤为关键。接下来,我们主要讨论文本序列的表示方法。其他非数字信号请参考文本序列的表示方法。

对于包含 n 个单词的句子,表示单词的一种简单方法是我们前面介绍的一键编码方法。以英语句子为例;假设只考虑最常用的 10000 个单词,那么每个单词都可以表示为一个位置为 1,其他位置为 0,长度为 10000 的稀疏一热向量。如图 11-1 所示,如果只考虑 n 个位置名称,那么每个位置名称可以编码为一个长度为 n 的独热向量。

img/515226_1_En_11_Fig1_HTML.png

图 11-1

位置名称的一键编码

我们把把文本编码成数字的过程称为单词嵌入。一键编码实现单词嵌入简单直观,编码过程不需要学习和训练。而一热编码向量是高维的,极其稀疏,大量位置为 0。因此,它在计算上是昂贵的,并且也不利于神经网络训练。从语义的角度来看,一键编码有一个严重的问题。它忽略了单词固有的语义相关性。例如,对于单词“喜欢”、“不喜欢”、“罗马”、“巴黎”、“喜欢”和“不喜欢”,从语义的角度来看是强烈相关的。两者都表示喜欢的程度。“罗马”和“巴黎”也密切相关。它们都显示了欧洲的两个地点。对于一组这样的词,如果采用一热编码,得到的向量之间没有相关性,不能很好的体现原文的语义相关性。因此,一键编码有明显的缺点。

在自然语言处理领域,有一个关于词向量的专门研究领域,通过词向量可以很好地反映语义的相关程度。衡量词向量之间相关性的一种方法是余弦相似度:

$$ similarity\left(a,b\right)\triangleq coscos\ \left(\theta \right)=\frac{a\cdotp b}{\mid a\mid \bullet \mid b\mid } $$

其中 ab 代表两个字向量。图 11-2 显示了单词“法兰西”和“意大利”之间的相似性,以及单词“球”和“鳄鱼”之间的相似性,并且 θ 是两个单词向量之间的角度。可见 coscos ( θ )更好的体现了语义相关性。

img/515226_1_En_11_Fig2_HTML.jpg

图 11-2

余弦相似图

嵌入层

在神经网络中,可以通过训练直接获得单词的表示向量。我们把单词的表示层叫做嵌入层。嵌入层负责将单词编码成单词向量 v 。它接受使用数字编码的单词数 i ,比如 2 代表“我”,3 代表“我”。系统的总字数记录为 N vocab ,输出为长度为 n :

$$ v={f}_{\theta}\left(i|{N}_{vocab},n\right) $$

的向量 v

嵌入层实现起来非常简单。用 shape[Nvocabn ]构建查找表。对于任意字数 i ,只需要查询相应位置的向量并返回:

$$ v= table\left[i\right] $$

嵌入层是可训练的。可以放在神经网络的前面,完成单词到向量的转换。得到的表征向量可以继续通过神经网络完成后续任务,计算误差 L 。采用梯度下降算法实现端到端的训练。

在 TensorFlow 中,一个单词嵌入层可以由层来定义。嵌入( N vocabn ),其中Nvocab参数指定单词的个数, n 指定单词向量的长度。例如:

x = tf.range(10) # Generate a digital code of 10 words
x = tf.random.shuffle(x) # Shuffle
# Create a layer with a total of 10 words, each word is represented by a vector of length 4
net = layers.Embedding(10, 4)
out = net(x) # Get word vector

前面的代码创建了一个包含十个单词的嵌入层。每个单词由长度为 4 的向量表示。您可以传入一个数字代码为 0–9 的输入,以获得这四个单词的单词向量。这些字向量是随机初始化的,没有经过训练,例如:

<tf.Tensor: id=96, shape=(10, 4), dtype=float32, numpy=
array([[-0.00998075, -0.04006485,  0.03493755,  0.03328368],
       [-0.04139598, -0.02630153, -0.01353856,  0.02804044],…

我们可以直接查看嵌入层内部的查询表:

In [1]: net.embeddings
Out[1]:
<tf.Variable 'embedding_4/embeddings:0' shape=(10, 4) dtype=float32, numpy=
array([[ 0.04112223,  0.01824595, -0.01841902,  0.00482471],
       [-0.00428962, -0.03172196, -0.04929272,  0.04603403],…

net.embeddings 张量的可优化属性是真实的,这意味着它可以通过梯度下降算法来优化。

In [2]: net.embeddings.trainable
Out[2]:True

预先训练的单词向量

嵌入层的查找表是随机初始化的,需要从头开始训练。事实上,我们可以使用预先训练的单词嵌入模型来获得单词表示。基于预训练模型的词向量相当于传递了整个语义空间的知识,往往可以获得更好的性能。

目前广泛使用的预训练模型有 Word2Vec 和 GloVe。他们已经在大规模语料库上接受了训练,以获得更好的词向量表示,并可以直接导出学习到的词向量表,以便于迁移到其他任务。比如手套型号 GloVe.6B.50d 的词汇量为 40 万,每个单词用一个长度为 50 的向量表示。用户只需下载相应的模型文件即可使用。“glove6b50dtxt.zip”型号文件约 69MB。

那么如何使用这些预先训练好的词向量模型来帮助提高 NLP 任务的性能呢?很简单。对于嵌入层,不再使用随机初始化。相反,我们使用预先训练的模型参数来初始化嵌入层的查询表。例如:

# Load the word vector table from the pre-trained model
embed_glove = load_embed('glove.6B.50d.txt')
# Initialize the Embedding layer directly using the pre-trained word vector table
net.set_weights([embed_glove])

预训练的词向量模型初始化的嵌入层可以设置为不参与训练:net.trainable = False,那么预训练的词向量直接应用于这个特定的任务。如果您还想从预训练的单词向量模型中学习不同的表示,则可以通过设置 net.trainable = True 将嵌入层包括在反向传播算法中,然后可以使用梯度下降来微调单词表示。

11.2 循环神经网络

现在让我们考虑如何处理序列信号。以一段文字序列为例,考虑一句话:

“我讨厌这部无聊的电影”

通过嵌入层,可以转换成一个具有形状的张量[ bsn ],其中 b 是句子的数量,s 是句子的长度,n 是词向量的长度。前面的句子可以表示为形状为[1,5,10]的张量,其中 5 表示句子单词的长度,10 表示单词向量的长度。

接下来,我们将逐步探索一种可以处理序列信号的网络模型。我们以情感分类任务为例,如图 11-3 所示。情感分类任务提取由文本数据表达的整体语义特征,并由此预测输入文本的情感类型:积极或消极。从分类的角度来看,情感分类是一个简单的二分类问题。与图像分类不同,由于输入是文本序列,传统的卷积神经网络无法达到很好的效果。那么什么类型的网络擅长处理序列数据呢?

img/515226_1_En_11_Fig3_HTML.png

图 11-3

情感分类任务

11.2.1 全连接层是否可行?

我们首先想到的是,对于每一个词向量,都可以用一个全连通的层网络。

$$ o=\sigma \left({W}_t{x}_t+{b}_t\right) $$

提取语义特征,如图 11-4 所示。通过 s 个全连接层分类网络 1 提取每个单词的单词向量。最后融合所有单词的特征,通过分类网络 2 输出序列的类别概率分布。对于长度为 s 的句子,至少需要 s 个全连接的网络层。

img/515226_1_En_11_Fig4_HTML.png

图 11-4

网络架构 1

这种方案的缺点是:

  • 网络参数数量可观,内存占用和计算成本较高。同时,由于每个序列的长度 s 不相同,网络结构是动态变化的。

  • 各全连通层子网WI和 b i 只能感知当前词向量的输入,无法感知前后的上下文信息,导致句子整体语义缺失。每个子网络只能根据自己的输入提取高级特征。

我们将逐一解决这两个缺点。

共享重量

在介绍卷积神经网络时,我们已经了解到,卷积神经网络之所以在处理局部相关数据方面优于全连接网络,是因为它充分利用了权重分担的思想,大大减少了网络参数的数量,使得网络训练更加高效。那么,我们在处理序列信号时,是否可以借鉴权重分担的思想呢?

在图 11-4 的方案中,s 个全连通层的网络并没有实现权重分担。我们尝试共享这 s 个网络层参数,实际上相当于用一个全连通的网络来提取所有单词的特征信息,如图 11-5 。

img/515226_1_En_11_Fig5_HTML.png

图 11-5

网络架构 2

权重共享后,参数数量大大减少,网络训练变得更加稳定高效。但是,这种网络结构不考虑序列的顺序,通过打乱单词向量的顺序仍然可以获得相同的输出。因此,它不能获得有效的全局语义信息。

全局语义

如何赋予网络提取整体语义特征的能力?换句话说,网络如何将词向量的语义信息按顺序提取出来,并累积成整个句子的全局语义信息?我们想到了记忆机制。如果网络能够提供单独的记忆变量,每次提取词向量的特征并刷新记忆变量,直到最后一次输入完成,此时的记忆变量存储所有序列的语义特征,由于输入序列的顺序,记忆变量的内容与序列顺序密切相关。

img/515226_1_En_11_Fig6_HTML.png

图 11-6

循环神经网络(没有添加偏差)

我们将前面的记忆机制实现为一个状态张量 h ,如图 11-6 所示。除了原有的 W xh 参数共享之外,这里增加了一个额外的Whh参数。每个时间戳 t 的状态张量 h 刷新机制为:

$$ {h}_t=\sigma \left({W}_{xh}{x}_t+{W}_{hh}{h}_{t-1}+b\right) $$

其中状态张量 h 0 为初始内存状态,可以初始化为全 0。输入 s 个字向量后,得到网络的最终状态张量 h sh s 更好的代表了句子的全局语义信息。将 h s 通过一个全连通的层分类器就可以完成情感分类任务。

4 循环神经网络

通过一步步的探索,我们最终提出了一个“新”的网络结构,如图 11-7 所示。在每个时间戳 t,网络层接受当前时间戳的输入xt和前一个时间戳的网络状态向量ht—1,之后:

$$ {h}_t={f}_{\theta}\left({h}_{t-1},{x}_t\right) $$

变换后得到当前时间戳的新状态向量ht并写入内存状态,其中 f θ 代表网络的运行逻辑, θ 为网络参数集。在每一个时间戳,网络层都有一个输出产生 o tot=gϕ(ht),就是输出网络变换后的状态向量。

img/515226_1_En_11_Fig7_HTML.png

图 11-7

扩展的 RNN 模型

前面的网络结构折叠在时间戳上,如图 11-8 所示。网络循环接受序列的每个特征向量xt,刷新内部状态向量 h t ,同时形成输出 o t 。对于这种网络结构,我们称之为循环神经网络(RNN)。

img/515226_1_En_11_Fig8_HTML.png

图 11-8

折叠 RNN 模型

更具体地说,如果我们用张量 W xhW hh 和 bias b 来参数化 f θ 网络,并使用以下方式更新记忆状态,我们称这类网络为基本循环神经网络,除非另有说明;一般来说,循环神经网络指的就是这种实现。

$$ {h}_t=\sigma \left({W}_{xh}{x}_t+{W}_{hh}{h}_{t-1}+b\right) $$

在循环神经网络中,激活函数更多使用的是 Tanh 函数,我们可以选择不使用 bias b 来进一步减少参数的数量。状态向量ht可以直接作为输出,即ot=ht,或者对 h t 做一个简单的线性变换就可以做到 o t

11.3 梯度传播

通过循环神经网络的更新表达式可以看出,输出可导至张量 W xhW hh 和 bias b ,可以用自动梯度下降算法求解网络的梯度。这里我们简单推导 RNN 的梯度传播公式,并探讨其特性。

考虑梯度$$ \frac{\partial L}{\partial {W}_{hh}} $$,其中 L 为网络的误差,只考虑 t 处最后输出 o * t * 与真值之差。由于 W

其中$$ \frac{\partial L}{\partial {o}_t} $$可以根据损失函数直接得到,在ot=ht:

$$ \frac{\partial {o}_t}{\partial {h}_t}=I $$

的情况下

$$ \frac{\partial^{+}{h}_i}{\partial {W}_{hh}} $$的梯度也可以在展开后得到hI:

$$ \frac{\partial^{+}{h}_i}{\partial {W}_{hh}}=\frac{\partial \sigma \left({W}_{xh}{x}_t+{W}_{hh}{h}_{t-1}+b\right)}{\partial {W}_{hh}} $$

其中$$ \frac{\partial^{+}{h}_i}{\partial {W}_{hh}} $$只考虑一个时间戳的梯度传播,即“直接”偏导数,与$$ \frac{\partial L}{\partial {W}_{hh}} $$考虑所有时间戳的梯度传播不同 i = 1, t

所以我们只需要推导出$$ \frac{\partial {h}_t}{\partial {h}_i} $$的表达式,就可以完成循环神经网络的梯度推导。利用链式法则,我们把$$ \frac{\partial {h}_t}{\partial {h}_i} $$分成连续时间戳的梯度表达式:

$$ \frac{\partial {h}_t}{\partial {h}_i}=\frac{\partial {h}_t}{\partial {h}_{t-1}}\frac{\partial {h}_{t-1}}{\partial {h}_{t-2}}\cdots \frac{\partial {h}_{i+1}}{\partial {h}_i}={\prod}_{k=i}^{t-1}\frac{\partial {h}_{k+1}}{\partial {h}_k} $$

考虑:

$$ {h}_{k+1}=\sigma \left({W}_{xh}{x}_{k+1}+{W}_{hh}{h}_k+b\right) $$

然后:

$$ \frac{\partial {h}_{k+1}}{\partial {h}_k}={W}_{hh}T\mathit{\operatorname{diag}}\left({\sigma}{\prime}\left({W}_{xh}{x}_{k+1}+{W}_{hh}{h}_k+b\right)\right) $$

$$ ={W}_{hh}T\mathit{\operatorname{diag}}\left({\sigma}{\prime}\left({h}_{k+1}\right)\right) $$

其中 diag ( x )将向量x的每个元素作为矩阵的对角元素,得到一个其他元素都为 0 的对角矩阵,例如:

$$ \mathit{\operatorname{diag}}\left(\left[3,2,1\right]\right)=\left[3\ 0\ 0\ 0\ 2\ 0\ 0\ 0\ 1\ \right] $$

因此,

$$ \frac{\partial {h}_t}{\partial {h}_i}={\prod}_{j=i}{t-1}\mathit{\operatorname{diag}}\left({\sigma}{\prime}\left({W}_{xh}{x}_{j+1}+{W}_{hh}{h}_j+b\right)\right){W}_{hh} $$

至此,$$ \frac{\partial L}{\partial {W}_{hh}} $$的梯度推导完成。

由于深度学习框架可以帮助我们自动导出梯度,所以我们只需要了解循环神经网络的梯度传播机制。在推导$$ \frac{\partial L}{\partial {W}_{hh}} $$的过程中,我们发现$$ \frac{\partial {h}_t}{\partial {h}_i} $$的梯度包含了Whh的连续乘法运算,这是造成循环神经网络训练困难的根本原因。我们以后再讨论。

11.4 如何使用 RNN 图层

在介绍了循环神经网络的原理之后,让我们学习如何在 TensorFlow 中实现 RNN 层。在 TensorFlow 中,σ(Wxhxt+Whhht—1+b)的计算可以分层完成。SimpleRNNCell()函数。需要注意的是,在 TensorFlow 中,RNN 代表一般意义上的循环神经网络。对于我们目前介绍的基本循环神经网络,一般称为 SimpleRNN。SimpleRNN 和 SimpleRNNCell 的区别在于,有 Cell 的层只完成一个时间戳的转发操作,而没有 cell 的层一般是基于 cell 层实现的,cell 层内部已经完成了多个时间戳循环。所以使用起来更加方便快捷。

我们先介绍 SimpleRNNCell 的使用,再介绍 SimpleRNN 层的使用。

简单电池

以某个输入特征长度 n=4,细胞状态向量特征长度 h=3 为例。首先,我们创建一个 SimpleRNNCell,不指定序列长度 s。代码如下:

In [3]:
cell = layers.SimpleRNNCell(3) # Create RNN Cell, memory vector length is 3
cell.build(input_shape=(None,4)) # Output feature length n=4
cell.trainable_variables # Print wxh, whh, b tensor
Out[3]:
[<tf.Variable 'kernel:0' shape=(4, 3) dtype=float32, numpy=...>,
 <tf.Variable 'recurrent_kernel:0' shape=(3, 3) dtype=float32, numpy=...>,
 <tf.Variable 'bias:0' shape=(3,) dtype=float32, numpy=array([0., 0., 0.], dtype=float32)>]

可以看出 SimpleRNNCell 内部维护了三个张量,核变量是张量 W xh ,recurrent_kernel 变量是张量 W hh ,偏置变量是偏置向量 b 。但是 RNN 的内存向量 h 不是由 SimpleRNNCell 维护的,用户需要初始化向量 h 0 并在每个时间戳上记录 h t

通过调用单元格实例:

$$ {o}_t,\left[{h}_t\right]= Cell\left({x}_t,\left[{h}_{t-1}\right]\right) $$

可以完成正向操作

对于 SimpleRNNCell,ot=ht,是同一个对象。没有额外的线性层转换。[ht]被包裹在一个列表中。此设置是为了与 RNN 变量(如 LSTM 和格鲁)保持一致。在循环神经网络的初始化阶段,状态向量 h 0 通常被初始化为全零向量,例如:

In [4]:
# Initialize state vector. Wrap with list, unified format
h0 = [tf.zeros([4, 64])]
x = tf.random.normal([4, 80, 100]) # Generate input tensor, 4 sentences of 80 words
xt = x[:,0,:] # The first word of all sentences
# Construct a Cell with input feature n=100, sequence length s=80, state length=64
cell = layers.SimpleRNNCell(64)
out, h1 = cell(xt, h0) # Forward calculation
print(out.shape, h1[0].shape)
Out[4]: (4, 64) (4, 64)

可以看出,经过一次时间戳计算,输出的形状和状态张量都是[b,h],两者的 id 打印如下:

In [5]:print(id(out), id(h1[0]))
Out[5]:2154936585256 2154936585256

两个 id 是一样的,就是直接用状态向量作为输出向量。对于长度为 s 的训练,需要遍历信元类 s 次,才能完成网络层的一次正向操作。例如:

h = h0 # Save a list of state vectors on each time stamp
# Unpack the input in the dimension of the sequence length to get xt:[b,n]
for xt in tf.unstack(x, axis=1):
    out, h = cell(xt, h) # Forward calculation, both out and h are covered
# The final output can aggregate the output on each time stamp, or just take the output of the last time stamp
out = out

最后时间戳之外的输出变量将是网络的最终输出。实际上,你也可以在每个时间戳上保存输出,然后求和或平均,作为网络的最终输出。

11.4.2 多层简单网络

和卷积神经网络一样,循环神经网络虽然在时间轴上扩展了很多倍,但也只能算作一个网络层。通过在深度方向堆叠多个细胞类,网络可以达到与深度卷积神经网络相同的效果,大大提高了网络的表达能力。但是,相比于数十或数百个卷积神经网络的深层层数,循环神经网络容易出现梯度扩散和梯度爆炸。深度循环神经网络非常难以训练。目前常见的循环神经网络模型的层数一般小于 10 层。

这里我们以一个两层循环神经网络为例来介绍使用细胞类来构建一个多层 RNN 网络。首先创建两个 SimpleRNNCell 单元格,如下所示:

x = tf.random.normal([4,80,100])
xt = x[:,0,:] # Take first timestamp of the input x0
# Construct 2 Cells, first cell0, then cell1, the memory state vector length is 64
cell0 = layers.SimpleRNNCell(64)
cell1 = layers.SimpleRNNCell(64)
h0 = [tf.zeros([4,64])] # initial state vector of cell0
h1 = [tf.zeros([4,64])] # initial state vector of cell1

在时间轴上多次计算,实现整个网络的正向运行。每个时间戳上的输入 xt 先经过第一层得到输出 out0,再经过第二层得到输出 out1。代码如下:

for xt in tf.unstack(x, axis=1):
    # xt is input and output is out0
    out0, h0 = cell0(xt, h0)
    # The output out0 of the previous cell is used as the input of this cell
    out1, h1 = cell1(out0, h1)

上述方法首先在所有图层上完成一个时间戳的输入传播,然后在一个循环中计算所有时间戳的输入。

其实也可以先完成第一层输入的所有时间戳的计算,并保存第一层在所有时间戳上的输出列表,再计算第二层、第三层等的传播。如下所示:

# Save the output above all timestamps of the previous layer
middle_sequences = []
# Calculate the output on all timestamps of the first layer and save
for xt in tf.unstack(x, axis=1):
    out0, h0 = cell0(xt, h0)
    middle_sequences.append(out0)
# Calculate the output on all timestamps of the second layer
# If it is not the last layer, you need to save the output above all timestamps
for xt in middle_sequences:
    out1, h1 = cell1(xt, h1)

这样我们就需要一个额外的列表来保存上一层所有时间戳的信息:middle_sequences.append(out0)。这两种方法效果相同,可以选择自己喜欢的编码风格。

应该注意的是,在每个时间戳,循环神经网络的每一层都有一个状态输出。对于后续任务,我们应该收集哪种状态输出最有效?一般来说,末级单元的状态可能保留了高层的全局语义特征,所以一般将末级的输出作为后续任务网络的输入。更具体地说,每一层的最后时间戳上的状态输出包含整个序列的全局信息。如果只想用一个状态变量来完成后续任务,比如情感分类问题,一般最后一层在最后一个时间戳的输出是最合适的。

11.4.3 SimpleRNN 图层

通过使用 SimpleRNNCell 层,我们可以了解循环神经网络正向操作的每个细节。在实际使用中,为了简单起见,我们不希望手动实现循环神经网络的内部计算过程,比如各层状态向量的初始化以及时间轴上各层的运算。使用 SimpleRNN 高级接口可以帮助我们非常方便地实现这个目标。

例如,如果我们想完成一个单层循环神经网络的正向运算,可以很容易地实现如下:

In [6]:
layer = layers.SimpleRNN(64) # Create a SimpleRNN layer with a state vector length of 64
x = tf.random.normal([4, 80, 100])
out = layer(x) # Like regular convolutional networks, one line of code can get the output
out.shape
Out[6]: TensorShape([4, 64])

可以看到,SimpleRNN 只用一行代码就可以完成整个正向操作过程,默认情况下返回最后一个时间戳的输出。如果您想要返回所有时间戳的输出列表,您可以如下设置 return_sequences=True:

In [7]:
# When creating the RNN layer, set the output to return all timestamps
layer = layers.SimpleRNN(64,return_sequences=True)
out = layer(x) # Forward calculation
out # Output, automatic concat operation
Out[7]:
<tf.Tensor: id=12654, shape=(4, 80, 64), dtype=float32, numpy=
array([[[ 0.31804922,  0.7904409 ,  0.13204293, ...,  0.02601025,
         -0.7833339 ,  0.65577114],...>

可以看到,返回的输出张量形状是[4,80,64],中间的维度 80 是时间戳维度。同样,我们可以通过堆叠多个 SimpleRNNs 来实现多层循环神经网络,例如两层网络,其用法类似于普通网络。例如:

net = keras.Sequential([ # Build a 2-layer RNN network
# Except for the last layer, the output of all timestamps needs to be returned to be used as the input of the next layer
layers.SimpleRNN(64, return_sequences=True),
layers.SimpleRNN(64),
])
out = net(x) # Forward calculation

每一层都需要前一层在每个时间戳的状态输出,所以除了最后一层,所有 RNN 层都需要返回每个时间戳的状态输出,这是通过设置 return_sequences=True 来实现的。如您所见,使用 SimpleRNN 层类似于卷积神经网络的用法,非常简洁高效。

11.5 RNN 情感分类实践

现在让我们使用基本的 RNN 网络来解决情感分类问题。网络结构如图 11-9 所示。RNN 网络有两层。循环提取序列信号的语义特征。第二 RNN 层的最后时间戳的状态向量$$ {h}_s^{(2)} $$被用作句子的全局语义特征表示。送到全连通层构成的分类网络 3,得到样本 x 是正面情绪 P 的概率(x 是正面情绪│x) ∈[0,1]。

img/515226_1_En_11_Fig9_HTML.png

图 11-9

情感分类任务的网络结构

数据集

这里使用经典的 IMDB 电影评论数据集来完成情感分类任务。IMDB 电影评论数据集包含 50,000 条用户评论。评估标签分为负面和正面。IMDB 评分< 5 的用户评论标记为 0,表示负面;IMDB 评分≥7 的用户评论标为 1,表示正面。25,000 条电影评论用于训练集,25,000 条用于测试集。

可以通过 Keras 提供的数据集工具加载 IMDB 数据集,如下所示:

In [8]:
batchsz = 128 # Batch size
total_words = 10000 # Vocabulary size N_vocab
max_review_len = 80 # The maximum length of the sentence s, the sentence part greater than will be truncated, and the sentence less than will be filled
embedding_len = 100 # Word vector feature length n
# Load the IMDB data set, the data here is coded with numbers, and a number represents a word
(x_train, y_train), (x_test, y_test) = keras.datasets.imdb.load_data(num_words=total_words)
# Print the input shape, the shape of the label
print(x_train.shape, len(x_train[0]), y_train.shape)
print(x_test.shape, len(x_test[0]), y_test.shape)
Out[8]:
(25000,) 218 (25000,)
(25000,) 68 (25000,)

可以看到,x_train 和 x_test 是一维数组,长度为 25000。数组中的每个元素都是一个长度不定的列表,其中存储了用数字编码的每个句子。例如,训练集的第一句共有 218 个单词,测试集的第一句有 68 个单词,每个句子都包含句子开始标记 ID。

那么每个单词是如何编码成数字的呢?我们可以通过查看其编码表来获得编码方案,例如:

In [9]:
# Digital code table
word_index = keras.datasets.imdb.get_word_index()
# Print out the words and corresponding numbers in the coding table
for k,v in word_index.items():
   print(k,v)
Out[10]:
   ...diamiter 88301
   moveis 88302
   mardi 14352
   wells' 11583
   850pm 88303...

由于编码表的关键字是一个字,值是一个 ID,所以编码表被翻转,并加上标志位的编码 ID。代码如下:

# The first 4 IDs are special bits
word_index = {k:(v+3) for k,v in word_index.items()}
word_index["<PAD>"] = 0  # Fill flag
word_index["<START>"] = 1 # Start flag
word_index["<UNK>"] = 2  # Unknown word sign
word_index["<UNUSED>"] = 3
# Flip code table
reverse_word_index = dict([(value, key) for (key, value) in word_index.items()])

对于数字编码的句子,通过以下函数将其转换为字符串数据:

def decode_review(text):
    return ' '.join([reverse_word_index.get(i, '?') for i in text])

例如,要转换一个句子,代码如下:

In [11]:decode_review(x_train[0])
Out[11]:
"<START> this film was just brilliant casting location scenery story direction everyone's...<UNK> father came from...

对于长短不齐的句子,人为设置一个阈值。对于大于这个长度的句子,选择一些要截断的单词,可以选择截掉句首或者句尾。对于小于此长度的句子,可以选择在句首或句尾填充。句子截断功能可以通过 keras . preprocessing . sequence . pad _ sequences()函数方便地实现,例如:

# Truncate and fill sentences so that they are of equal length, here long sentences retain the part behind the sentence, and short sentences are filled in front
x_train = keras.preprocessing.sequence.pad_sequences(x_train, maxlen=max_review_len)
x_test = keras.preprocessing.sequence.pad_sequences(x_test, maxlen=max_review_len)

截断或填充到相同长度后,通过 dataset 类将其包装成 Dataset 对象,并添加常用的数据集处理流程,代码如下:

In [12]:
# Build a data set, break up, batch, and discard the last batch that is not enough batchsz
db_train = tf.data.Dataset.from_tensor_slices((x_train, y_train))
db_train = db_train.shuffle(1000).batch(batchsz, drop_remainder=True)
db_test = tf.data.Dataset.from_tensor_slices((x_test, y_test))
db_test = db_test.batch(batchsz, drop_remainder=True)
# Statistical data set attributes
print('x_train shape:', x_train.shape, tf.reduce_max(y_train), tf.reduce_min(y_train))
print('x_test shape:', x_test.shape)
Out[12]:
x_train shape: (25000, 80) tf.Tensor(1, shape=(), dtype=int64) tf.Tensor(0, shape=(), dtype=int64)
x_test shape: (25000, 80)

可以看出,截断填充后的句子长度统一为 80,这是设定的句子长度阈值。drop_remainder=True 参数丢弃最后一个批次,因为它的实际批次大小可能小于预设的批次大小。

网络模型

我们创建一个自定义模型类 MyRNN,继承自模型基类,我们需要创建一个新的嵌入层、两个 RNN 层和一个分类层,如下所示:

class MyRNN(keras.Model):
    # Use Cell method to build a multi-layer network
    def __init__(self, units):
        super(MyRNN, self).__init__()
        # [b, 64], construct Cell initialization state vector, reuse
        self.state0 = [tf.zeros([batchsz, units])]
        self.state1 = [tf.zeros([batchsz, units])]
        # Word vector encoding [b, 80] => [b, 80, 100]
        self.embedding = layers.Embedding(total_words, embedding_len,
                                          input_length=max_review_len)
        # Construct 2 Cells and use dropout technology to prevent overfitting
        self.rnn_cell0 = layers.SimpleRNNCell(units, dropout=0.5)
        self.rnn_cell1 = layers.SimpleRNNCell(units, dropout=0.5)
        # Construct a classification network to classify the output features of CELL, 2 classification
        # [b, 80, 100] => [b, 64] => [b, 1]
        self.outlayer = layers.Dense(1)

单词向量被编码为长度 n=100,RNN 的状态向量长度是 h =个单位。分类网络完成一个二元分类任务,所以输出节点设置为 1。

正向传播逻辑如下:输入序列通过嵌入层完成词向量编码,循环通过两个 RNN 层提取语义特征,取最后一层最后一个时间戳的状态向量输出,送入分类网络。输出概率在 Sigmoid 激活函数之后获得,如下所示:

    def call(self, inputs, training=None):
        x = inputs # [b, 80]
        # Word vector embedding: [b, 80] => [b, 80, 100]
        x = self.embedding(x)
        # Pass 2 RNN CELLs,[b, 80, 100] => [b, 64]
        state0 = self.state0
        state1 = self.state1
        for word in tf.unstack(x, axis=1): # word: [b, 100]
            out0, state0 = self.rnn_cell0(word, state0, training)
            out1, state1 = self.rnn_cell1(out0, state1, training)
        # Last layer's last time stamp as the network output: [b, 64] => [b, 1]
        x = self.outlayer(out1, training)
        # Pass through activation function, p(y is pos|x)
        prob = tf.sigmoid(x)

        return prob

培训和测试

为简单起见,这里我们使用 Keras 的 Compile&Fit 方法来训练网络。设置优化器为 Adam optimizer,学习率为 0.001,误差函数使用二类交叉熵损失函数 BinaryCrossentropy,测试度量使用准确率。代码如下:

def main():
    units = 64 # RNN state vector length n
    epochs = 20 # Training epochs

    model = MyRNN(units) # Create the model
    # Compile
    model.compile(optimizer = optimizers.Adam(0.001),
                  loss = losses.BinaryCrossentropy(),
                  metrics=['accuracy'])
    # Fit and validate
    model.fit(db_train, epochs=epochs, validation_data=db_test)
    # Test
    model.evaluate(db_test)

经过 20 次历元训练,网络在测试数据集上达到了 80.1%的准确率。

11.6 渐变消失和渐变爆炸

循环神经网络的训练并不稳定,网络的深度不能任意加深。为什么循环神经网络训练困难?我们来简单回顾一下梯度求导中的关键表达式:

$$ \frac{\partial {h}_t}{\partial {h}_i}={\prod}_{j=i}{t-1}\mathit{\operatorname{diag}}\left({\sigma}{\prime}\left({W}_{xh}{x}_{j+1}+{W}_{hh}{h}_j+b\right)\right){W}_{hh} $$

换句话说,从时间戳 i 到时间戳 t 的渐变$$ \frac{\partial {h}_t}{\partial {h}_i} $$包含了 W * hh 的连续乘法运算。当Whh*的最大特征值小于 1 时,多次连续的乘法运算会使$$ \frac{\partial {h}_t}{\partial {h}_i} $$的元素值接近于零;当$$ \frac{\partial {h}_t}{\partial {h}_i} $$的值大于 1 时,多次连续的乘法运算会使$$ \frac{\partial {h}_t}{\partial {h}_i} $$的值爆炸式增加。

我们可以从下面两个例子直观感受到渐变消失和渐变爆炸的产生:

In [13]:
W = tf.ones([2,2]) # Create a matrix
eigenvalues = tf.linalg.eigh(W)[0] # Calculate eigenvalue
eigenvalues
Out[13]:
<tf.Tensor: id=923, shape=(2,), dtype=float32, numpy=array([0., 2.], dtype=float32)>

可以看出全 1 矩阵的最大特征值是 2。计算 W 矩阵的W1~W10并绘制成矩阵的幂和 L2 范数的图形,如图 11-10 所示。可以看出,当 W 矩阵的最大特征值大于 1 时,矩阵相乘会使结果越来越大。

img/515226_1_En_11_Fig10_HTML.jpg

图 11-10

最大特征值大于 1 时的矩阵乘法

val = [W]
for i in range(10): # Matrix multiplication n times
    val.append([val[-1]@W])
# Calculate L2 norm
norm = list(map(lambda x:tf.norm(x).numpy(),val))

考虑最大特征值小于 1 的情况。

In [14]:
W = tf.ones([2,2])*0.4 # Create a matrix
eigenvalues = tf.linalg.eigh(W)[0] # Calculate eigenvalues
print(eigenvalues)
Out[14]:
tf.Tensor([0\.  0.8], shape=(2,), dtype=float32)

可以看出此时 W 矩阵的最大特征值为 0.8。同样,考虑矩阵 W 的多次乘法的结果如下:

val = [W]
for i in range(10):
    val.append([val[-1]@W])
# Calculate the L2 norm
norm = list(map(lambda x:tf.norm(x).numpy(),val))
plt.plot(range(1,12),norm)

其 L2-诺姆曲线如图 11-11 所示。可以看出,当 W 矩阵的最大特征值小于 1 时,矩阵相乘会使结果越来越小,接近于 0。

img/515226_1_En_11_Fig11_HTML.jpg

图 11-11

最大特征值小于 1 时的矩阵乘法

我们把梯度值接近 0 的现象叫做梯度消失,把梯度值远大于 1 的现象叫做梯度爆炸。有关梯度传播机制的详细信息可在第七章中找到。梯度消失和梯度爆炸是神经网络优化过程中出现的两种情况,也不利于网络训练。

考虑梯度下降算法:

$$ {\theta}^{\prime }=\theta -\eta {\nabla}_{\theta }L $$

当出现梯度消失时,∇ θ L ≈ 0,此时θθ表示每次梯度更新后参数保持不变,神经网络的参数长时间不能更新。具体表现为 L 几乎没有变化,其他评价指标如准确度也保持不变。当梯度发生爆炸时,∇θl≫1、梯度ηθl的更新步长很大,以至于更新后的θθ 相差很大,网络 L

通过推导循环神经网络的梯度传播公式,我们发现循环神经网络容易出现梯度消失和梯度爆炸。那么如何解决这两个问题呢?

渐变剪辑

梯度爆炸可以通过梯度裁剪得到一定程度的解决。梯度裁剪非常类似于张量限制。它还将梯度张量的值或范数限制在一个很小的区间内,从而减少远大于 1 的梯度值,避免梯度爆炸。

在深度学习中,常用的梯度裁剪方法有三种。

  • 直接限制张量的值,使张量的所有元素 W 都是Wijminmax 。在 TensorFlow 中,可以通过 tf.clip_by_value()函数来实现。例如:

  • Limit the norm of the gradient tensor W. For example, the L2 norm of W – ‖W2 is constrained between [0,max]. If ‖W2 is greater than the max value, use:

    $$ {W}^{\prime }=\frac{W}{{\left\Vert W\right\Vert}_2}\bullet \mathit{\max} $$

In [15]:
a=tf.random.uniform([2,2])
tf.clip_by_value(a,0.4,0.6) # Gradient value clipping
Out[15]:
<tf.Tensor: id=1262, shape=(2, 2), dtype=float32, numpy=
array([[0.5410726, 0.6      ],
       [0.4      , 0.6      ]], dtype=float32)>

W2限制到最大。这可以通过 tf.clip_by_norm 函数来实现。例如:

In [16]:
a=tf.random.uniform([2,2]) * 5
# Clip by norm
b = tf.clip_by_norm(a, 5)
# Norm before and after clipping

tf.norm(a),tf.norm(b)
Out[16]:
(<tf.Tensor: id=1338, shape=(), dtype=float32, numpy=5.380655>,
 <tf.Tensor: id=1343, shape=(), dtype=float32, numpy=5.0>)

可以看出,对于 L2 范数大于 max 的张量,限幅后范数值减少到 5。

  • 神经网络的更新方向由所有参数的梯度张量 W 表示。前两种方法仅考虑单一梯度张量,因此网络的更新方向可能改变。如果能够考虑到所有参数的梯度 W 的范数,并且能够做到等尺度,那么就可以很好地限制网络的梯度值,而不改变网络的更新方向。这是渐变裁剪的第三种方法:全局范数裁剪。在 TensorFlow 中,整体网络梯度的范数 W 可以通过 tf.clip_by_global_norm 函数快速缩放。

W ( i ) 表示网络参数的第 i 个梯度张量。使用以下公式计算网络的全局范数。

$$ global_\mathit{\operatorname{norm}}=\sqrt{\sum_i\left\Vert {W}^{(i)}\right\Vert {{}_2}²} $$

对于第 i -th 参数 W ( i ) ,用下面的公式进行裁剪。

$$ {W}{(i)}=\frac{W{(i)}\bullet \mathit{\max}_\mathit{\operatorname{norm}}}{\mathit{\max}\left( global_\mathit{\operatorname{norm}},\mathit{\max}_\mathit{\operatorname{norm}}\right)} $$

其中 max_norm 是用户指定的全局最大范数值。例如:

In [17]:
w1=tf.random.normal([3,3]) # Create gradient tensor 1
w2=tf.random.normal([3,3]) # Create gradient tensor 2
# Calculate global norm
global_norm=tf.math.sqrt(tf.norm(w1)**2+tf.norm(w2)**2)
# Clip by global norm and max norm=2
(ww1,ww2),global_norm=tf.clip_by_global_norm([w1,w2],2)
# Calcualte global norm after clipping
global_norm2 = tf.math.sqrt(tf.norm(ww1)**2+tf.norm(ww2)**2)
# Print the global norm before cropping and the global norm after cropping
print(global_norm, global_norm2)
Out[17]:
tf.Tensor(4.1547523, shape=(), dtype=float32)
tf.Tensor(2.0, shape=(), dtype=float32)

可以看出,经过裁剪后,网络参数的梯度组的全局范数减少到 max_norm=2。需要注意的是,tf.clip_by_global_norm 返回裁剪后张量的两个对象——list 和 global_norm,其中 global_norm 表示裁剪前梯度的全局范数和。

通过梯度裁剪,可以抑制梯度爆炸现象。如图 11-12 所示,图中曲面表示的 J ( wb )函数在不同网络参数 wb 下的误差值 J 。存在一个 J ( wb )函数梯度变化较大的区域。当参数进入这个区域时,容易出现梯度爆炸,使网络状态迅速恶化。右图 11-12 显示了添加渐变裁剪后的优化轨迹。由于梯度被有效地限制,每次更新的步长被有效地控制,从而防止网络突然恶化。

img/515226_1_En_11_Fig12_HTML.jpg

图 11-12

渐变裁剪的优化轨迹图[1]

在网络训练期间,通常在计算梯度之后和更新梯度之前执行梯度裁剪。例如:

with tf.GradientTape() as tape:
  logits = model(x) # Forward calculation
  loss = criteon(y, logits) # Calculate error
# Calcualte gradients
grads = tape.gradient(loss, model.trainable_variables)
grads, _ = tf.clip_by_global_norm(grads, 25) # Global norm clipping
# Update parameters using clipped gradient
optimizer.apply_gradients(zip(grads, model.trainable_variables))

渐变消失

梯度消失现象可以通过提高学习速率、减小网络深度、增加跳连接等一系列措施来抑制。

增加学习速率 η 可以在一定程度上防止梯度消失。当梯度消失时,网络∇ θ L 的梯度接近于 0。此时,如果学习率 η 也很小,比如η= 1e5,则梯度更新步长更小。通过提高学习速率,比如让η= 1e2,可以快速更新网络状态,逃离梯度消失区。

对于深度神经网络,梯度从最后一层逐渐传播到第一层,梯度消失一般更容易出现在网络的前几层。在深度残差网络出现之前,训练几十层或者几百层的深度网络是非常困难的。网络前几层的梯度非常容易出现梯度消失,使得网络参数长时间不更新。深度残差网络较好地克服了梯度消失现象,使神经网络层数可达数百或数千。一般来说,降低网络深度可以减少梯度消失现象,但网络层数减少后,网络表达能力会更弱。

11.7 RNN 短期记忆

除了循环神经网络的训练难度,还有一个更严重的问题,就是短时记忆。考虑一个长句子:

今天的天气真好,尽管路上发生了一件不愉快的事情...,我马上调整好状态,开心地准备迎接美好的一天。

按照我们的理解,我们之所以“高高兴兴地准备迎接美好的一天”,是因为句首提到的“今天的天气真美”。可见,人类可以很好地理解长句,但循环神经网络不是必须的。研究人员发现,循环神经网络在处理长句时,只能理解有限长度内的信息,而更大范围内的有用信息却不能很好地利用。我们称这种现象为短期记忆。

那么,这种短时记忆是否可以延长,以便循环神经网络可以在更长的范围内有效地使用训练数据,从而提高模型性能?1997 年,瑞士人工智能科学家 Jürgen Schmidhuber 提出了长短期记忆(LSTM)模型。与基本的 RNN 网络相比,LSTM 拥有更长的内存,更擅长处理更长的序列数据。LSTM 被提出后,已经广泛应用于序列预测、自然语言处理等任务,几乎取代了基本的 RNN 模型。

接下来,我们将介绍更受欢迎和强大的 LSTM 网络。

11.8 LSTM 原则

RNN 的基本网络结构如图 11-13 所示。前一个时间戳的状态向量ht-1与当前时间戳的输入 x t 进行线性变换后,通过激活函数 tanh 得到新的状态向量 h t 。与只有一个状态向量 h t 的基本 RNN 网络相比,LSTM 增加了一个新的状态向量 C t ,同时引入了门控机制,通过门控单元控制信息的遗忘和更新,如图 11-14 所示。

img/515226_1_En_11_Fig14_HTML.png

图 11-14

LSM 结构

img/515226_1_En_11_Fig13_HTML.png

图 11-13

基本 RNN 结构

在 LSTM 中,有两个状态向量 ch ,其中 c 是 LSTM 的内部状态向量,可以理解为 LSTM 的内存状态向量, h 代表 LSTM 的输出向量。与基本的 RNN 相比,LSTM 将内部存储器和输出分成两个变量,并使用三个门,输入门、遗忘门和输出门,来控制内部信息流。

闸门机制可以理解为控制数据流的一种方式,类似于水阀:当水阀全开时,水流畅通无阻;当水阀完全关闭时,水流被完全阻断。在 LSTM,阀门开度由闸门控制值向量 g 表示,如图 11-15 所示,通过 σ ( g 激活函数,闸门控制被压缩到[0,1]之间的区间。当 σ ( g ) = 0 时,所有门关闭,输出为 o = 0。当 σ ( g ) = 1 时,所有门打开,输出为 o = x 。通过 gate 机制,可以更好地控制数据流。

img/515226_1_En_11_Fig15_HTML.png

图 11-15

闸门机制

下面,我们分别介绍这三种门的原理和功能。

忘记入口

遗忘门作用于 LSTM 状态向量 c 来控制前一时间戳的存储器ct—1对当前时间戳的影响。如图 11-16 所示,遗忘门的控制变量 g f

$$ {g}_f=\sigma \left({W}_f\left[{h}_{t-1},{x}_t\right]+{b}_f\right) $$

决定

其中 W fb f 为遗忘门的参数张量,可以通过反向传播算法自动优化。 σ 为激活函数,一般使用 Sigmoid 函数。当gf= 1 时,遗忘门全部打开,LSTM 接受前一状态ct—1的所有信息。当门控gf= 0 时,忘记门关闭,LSTM 直接忽略ct—1,输出为 0 的向量。这就是它被称为遗忘之门的原因。

通过遗忘门后,LSTM 的状态向量变成了gfct—1

img/515226_1_En_11_Fig16_HTML.png

图 11-16

忘记大门

输入门

输入门用于控制 LSTM 接收输入的程度。首先,通过对当前时间戳的输入 x t 和前一时间戳的输出ht—1

$$ \tilde{c}_{t}= tanhtanh\ \left({W}_c\left[{h}_{t-1},{x}_t\right]+{b}_c\right) $$

进行非线性变换,得到新的输入向量$$ \tilde{c}_{t} $$

其中 W cb c 为输入门的参数,需要反向传播算法自动优化,Tanh 为激活函数,用于将输入归一化为[-1,1]。$$ \tilde{c}_{t} $$不完全刷新进入 LSTM 的存储器,但控制通过输入门接收的输入量。输入门的控制变量也来自输入 x * t 和输出ht*—1:

$$ {g}_i=\sigma \left({W}_i\left[{h}_{t-1},{x}_t\right]+{b}_i\right) $$

其中 W ib i 为输入门的参数,需要反向传播算法自动优化, σ 为激活函数,一般使用 Sigmoid 函数。输入门控制变量 g i 决定 LSTM 如何接受当前时间戳的新输入$$ \tilde{c}_{t} $$:当gI= 0 时,LSTM 不接受任何新输入$$ \tilde{c}_{t} $$;当gI= 1 时,LSTM 接受所有新输入$$ \tilde{c}_{t} $$,如图 11-17 所示。

通过输入门后,要写入内存的向量是$$ {g}_i\tilde{c}_{t} $$

img/515226_1_En_11_Fig17_HTML.png

图 11-17

输入门

更新存储器

在遗忘门和输入门的控制下,LSTM 选择性地读取前一个时间戳的存储器c??和当前时间戳的新输入$$ \tilde{c}_{t} $$。状态向量 c * t * 的刷新方式为:

$$ {c}_t={g}_i\tilde{c}_{t}+{g}_f{c}_{t-1} $$

得到的新的状态向量ct就是当前时间戳的状态向量,如图 11-17 所示。

输出门

LSTM 的内部状态向量 c t 不直接用于输出,与基本的 RNN 不同。基本 RNN 网络的状态向量 h 同时用于存储和输出,所以基本 RNN 可以理解为状态向量 c 和输出向量 h 是同一个对象。在 LSTM 中,状态向量不是全部输出,而是在输出门的作用下有选择地输出。输出门的门变量 g o 是:

$$ {g}_o=\sigma \left({W}_o\left[{h}_{t-1},{x}_t\right]+{b}_o\right) $$

其中 W ob o 为输出门的参数,也需要反向传播算法自动优化。 σ 为激活函数,一般使用 Sigmoid 函数。当输出门go= 0 时,输出关闭,LSTM 内部存储器被完全封锁,不能作为输出使用。此时输出的是 0 的向量;当输出门go= 1 时,输出全开,LSTM 状态向量 c t 全部用于输出。LSTM 的输出由:

$$ {h}_t={g}_o\bullet tanhtanh\ \left({c}_t\right) $$

组成

即内存向量ct通过 Tanh 激活函数后与输入门交互,得到 LSTM 的输出。由于go∈【0,1】和tanh tanh(ct)∈【1,1】,LSTM 的输出为ht∈【1,1】。

img/515226_1_En_11_Fig18_HTML.png

图 11-18

输出门

总结

虽然 LSTM 有大量的状态向量和门,但计算过程相对复杂。但是由于每个门的控制功能都很清楚,所以每个状态的作用也更容易理解。这里列出了典型的门控行为,并解释了代码的 LSTM 行为,如表 11-1 所示。

表 11-1

输入门和遗忘门的典型行为

|

输入门控

|

忘记门控

|

LSTM 行为

|
| --- | --- | --- |
| Zero | one | 仅使用内存 |
| one | one | 集成输入和存储器 |
| Zero | Zero | 清除存储器 |
| one | Zero | 输入覆盖内存 |

11.9 如何使用 LSTM 层

在 TensorFlow 中,也有两种实现 LSTM 网络的方法。可以使用 LSTMCell 手动完成时间戳的循环操作,也可以通过 LSTM 层一步完成正向操作。

LSTMCell

LSTMCell 的用法和 SimpleRNNCell 基本相同。不同的是 LSTM 有两个状态变量——list,即[ h tc t ],需要分别初始化。列表的第一个元素是 h t ,第二个元素是 c t 。当调用单元格来完成正向操作时,将返回两个元素。第一个元素是单元格的输出,是 h t ,第二个元素是单元格更新后的状态列表:[htct]。首先创建一个状态向量长度为 h = 64 的新 LSTMCell,其中状态向量 c t 和输出向量 h t 的长度都是 h 。代码如下:

In [18]:
x = tf.random.normal([2,80,100])
xt = x[:,0,:] # Get a timestamp input
cell = layers.LSTMCell(64) # Create LSTM Cell
# Initialization state and output List,[h,c]
state = [tf.zeros([2,64]),tf.zeros([2,64])]
out, state = cell(xt, state) # Forward calculation
# View the id of the returned element
id(out),id(state[0]),id(state[1])
Out[18]: (1537587122408, 1537587122408, 1537587122728)

可以看出,返回的 output out 与 list 的第一个元素 h t 的 id 相同,这与基本 RNN 的初衷是一致的,是为了格式的统一。

通过在时间戳上展开循环操作,可以完成一层的前向传播,写入方法与基本 RNN 相同。例如:

# Untie it in the sequence length dimension, and send it to the LSTM Cell unit in a loop
for xt in tf.unstack(x, axis=1):
    # Forward calculation
    out, state = cell(xt, state)

输出可以只使用最后一个时间戳的输出,也可以聚合所有时间戳的输出向量。

11.9.2 LSTM 层

穿过层层。LSTM 层,整个序列的操作可以方便地一次性完成。首先创建一个新的 LSTM 网络层,例如:

# Create an LSTM layer with a memory vector length of 64
layer = layers.LSTM(64)
# The sequence passes through the LSTM layer and returns the output h of the last time stamp by default

out = layer(x)

通过 LSTM 层向前传播后,默认情况下将只返回最后一个时间戳的输出。如果需要返回每个时间戳以上的输出,需要设置 return_sequences=True。例如:

# When creating the LSTM layer, set to return the output on each timestamp
layer = layers.LSTM(64, return_sequences=True)
# Forward calculation, the output on each timestamp is automatically concated to form a tensor
out = layer(x)

此时返回的 out 包含所有时间戳之上的状态输出,其形状为[2,80,64],其中 80 代表 80 个时间戳。

对于多层神经网络,可以用顺序容器包装多个 LSTM 层,设置所有非最终层网络 return_sequences=True,因为非最终 LSTM 层需要上一层所有时间戳的输出作为输入。例如:

# Like the CNN network, LSTM can also be simply stacked layer by layer
net = keras.Sequential([
    layers.LSTM(64, return_sequences=True), # The non-final layer needs to return all timestamp output
    layers.LSTM(64)
])
# Once through the network model, you can get the output of the last layer and the last time stamp
out = net(x)

11.10 GRU 简介

LSTM 有更长的记忆容量,并且在大多数序列任务上比基本的 RNN 模型有更好的表现。更重要的是,LSTM 不容易出现梯度消失。但是,LSTM 结构相对复杂,计算成本高,模型参数大。因此,科学家们试图简化 LSTM 内部的计算过程,特别是减少门的数量。研究发现遗忘门是 LSTM 中最重要的门控制[2],甚至发现仅具有遗忘门的网络的简化版本在多个基准数据集上优于标准的 LSTM 网络。在 LSTM 的许多简化版本中,门控循环单位(GRU)是使用最广泛的 RNN 变体之一。GRU 将内部状态向量和输出向量合并成一个状态向量 h ,门的数量也减少为两个,复位门和更新门,如图 11-19 所示。

img/515226_1_En_11_Fig19_HTML.png

图 11-19

GRU 网络结构

下面我们分别介绍一下复位门和更新门的原理和作用。

11.10.1 重置时间为

复位门用于控制上一个时间戳的状态h??进入 GRU 的数量,门控向量 g r 是通过变换当前时间戳输入 x t 和上一个时间戳状态ht1得到的

其中Wr和 b r 为复位门的参数,由反向传播算法自动优化, σ 为激活函数,一般使用 Sigmoid 函数。门控向量 g r 只控制状态ht-1,不控制输入xt:

$$ \tilde{h}_{t}= tanhtanh\ \left({W}_h\left[{g}_r{h}_{t-1},{x}_t\right]+{b}_h\right) $$

g r = 0 时,新的输入$$ \tilde{h}_{t} $$全部来自输入 x * t ht—1不被接受,相当于复位ht—1。当gr= 1,ht—1与输入t共同生成一个新的输入$$ \tilde{h}_{t} $$,如图 11-20 所示。*

*img/515226_1_En_11_Fig20_HTML.png

图 11-20

复位门

更新门

更新门控制最后时间戳状态h??和新输入$$ \tilde{h}_{t} $$对新状态向量 h * t 的影响程度。更新门控向量 g z * 由:

$$ {g}_z=\sigma \left({W}_z\left[{h}_{t-1},{x}_t\right]+{b}_z\right) $$

其中 W zb z 为更新门的参数,由反向传播算法自动优化, σ 为激活函数,一般使用 Sigmoid 函数。 g z 用于控制新输入的$$ \tilde{h}_{t} $$信号,1gz用于控制状态ht—1信号:

$$ {h}_t=\left(1-{g}_z\right){h}_{t-1}+{g}_z\tilde{h}_{t} $$

可以看出$$ \tilde{h}_{t} $$ht—1h * t 的更新处于相互竞争的状态。当更新门gz= 0 时,所有 h t 来自上一次时间戳状态ht—1;当更新门gz= 1 时,所有的 h t * 都来自新的输入$$ \tilde{h}_{t} $$

img/515226_1_En_11_Fig21_HTML.png

图 11-21

更新门

如何使用 GRU

同样,在 TensorFlow 中,也有单元和层方法来实现 GRU 网络。格鲁塞尔和 GRU 层的用法与前面的 SimpleRNNCell、LSTMCell、SimpleRNN 和 LSTM 非常相似。首先,使用 GRUCell 创建一个 GRU 单元格对象,并在时间轴上循环展开操作。例如:

In [19]:
# Initialize the state vector, there is only one GRU
h = [tf.zeros([2,64])]
cell = layers.GRUCell(64) # New GRU Cell, vector length is 64
# Untie in the timestamp dimension, loop through the cell
for xt in tf.unstack(x, axis=1):
    out, h = cell(xt, h)
# Out shape
out.shape
Out[19]:TensorShape([2, 64])

您可以通过图层轻松创建 GRU 网络图层。GRU 类,并通过顺序容器堆叠多个 GRU 层的网络。例如:

net = keras.Sequential([
    layers.GRU(64, return_sequences=True),
    layers.GRU(64)
])
out = net(x)

11.11 LSTM/GRU 情感分类实践

前面我们介绍了情感分类问题,并使用 SimpleRNN 模型来解决这个问题。在引入更强大的 LSTM 和 GRU 网络后,我们升级了网络模型。得益于 TensorFlow 循环神经网络相关接口的统一格式,只需对原代码进行少量修改,就可以完美升级到 LSTM 或 GRU 模型。

11 . 11 . 1 lstm 模型

首先,让我们使用细胞方法。LSTM 网络有两个状态表,每层的 hc 向量需要分别初始化。例如:

        self.state0 = [tf.zeros([batchsz, units]),tf.zeros([batchsz, units])]
        self.state1 = [tf.zeros([batchsz, units]),tf.zeros([batchsz, units])]

将模型修改为 LSTMCell 模型,如下所示:

        self.rnn_cell0 = layers.LSTMCell(units, dropout=0.5)
        self.rnn_cell1 = layers.LSTMCell(units, dropout=0.5)

其他代码无需修改即可运行。对于层方法,仅需要修改网络模型的一部分,如下所示:

        # Build RNN, replace with LSTM class
        self.rnn = keras.Sequential([
            layers.LSTM(units, dropout=0.5, return_sequences=True),
            layers.LSTM(units, dropout=0.5)
        ])

GRU 模型

对于单元格方法,只有一个 GRU 状态列表。与基本 RNN 一样,您只需修改创建的单元类型。代码如下:

        # Create 2 Cells
        self.rnn_cell0 = layers.GRUCell(units, dropout=0.5)
        self.rnn_cell1 = layers.GRUCell(units, dropout=0.5)

对于图层方法,只需修改网络层类型,如下所示:

        # Create RNN
        self.rnn = keras.Sequential([
            layers.GRU(units, dropout=0.5, return_sequences=True),
            layers.GRU(units, dropout=0.5)
        ])

11.12 预先训练的单词向量

在情感分类任务中,嵌入层是从头开始训练的。事实上,对于文本处理任务,大部分领域知识是共享的,因此我们可以利用在其他任务上训练的词向量来初始化嵌入层,以完成领域知识的传递。基于预先训练好的嵌入层开始训练,用少量的样本就可以达到很好的效果。

我们以预训练的手套词向量为例,演示如何使用预训练的词向量模型来提高任务绩效。首先从官网下载预先训练好的手套词向量表。我们选择特征长度为 100 的文件 glove.6B.100d.txt,每个单词用长度为 100 的向量表示,下载后可以解压。

img/515226_1_En_11_Fig22_HTML.jpg

图 11-22

手套字向量模型文件

使用 Python 文件 IO 代码读取单词编码向量表,存储在 Numpy 数组中。代码如下所示:

print('Indexing word vectors.')
embeddings_index = {} # Extract words and their vectors and save them in a dictionary
# Word vector model file storage path
GLOVE_DIR = r'C:\Users\z390\Downloads\glove6b50dtxt'
with open(os.path.join(GLOVE_DIR, 'glove.6B.100d.txt'),encoding='utf-8') as f:
    for line in f:
        values = line.split()
        word = values[0]
        coefs = np.asarray(values[1:], dtype='float32')
        embeddings_index[word] = coefs
print('Found %s word vectors.' % len(embeddings_index))

GloVe.6B 版本存储了总共 40 万字的向量表。我们只考虑了 10,000 个常用词。我们根据单词的数字代码表从手套模型中获得单词向量,并将其写入相应的位置,如下所示:

num_words = min(total_words, len(word_index))
embedding_matrix = np.zeros((num_words, embedding_len)) # Word vector table
for word, i in word_index.items():
    if i >= MAX_NUM_WORDS:
        continue # Filter out other words
    embedding_vector = embeddings_index.get(word) # Query word vector from GloVe
    if embedding_vector is not None:
        # words not found in embedding index will be all-zeros.
        embedding_matrix[i] = embedding_vector # Write the corresponding location
print(applied_vec_count, embedding_matrix.shape)

获得词汇数据后,使用词汇初始化嵌入层,设置嵌入层不参与梯度优化,如下:

        # Create Embedding layer
        self.embedding = layers.Embedding(total_words, embedding_len, input_length=max_review_len,
        trainable=False)# Does not participate in gradient updates
        self.embedding.build(input_shape=(None, max_review_len))
        # Initialize the Embedding layer using the GloVe model
        self.embedding.set_weights([embedding_matrix])# initialization

其他部分是一致的。我们可以简单地将预训练手套模型初始化的嵌入层的训练结果与随机初始化的嵌入层的训练结果进行比较。训练 50 个历元后,预训练模型的准确率达到 84.7%,提高了约 2%。

11.13 预先训练的单词向量

在这一章中,我们介绍了循环神经网络(RNN ),它适用于处理序列相关的问题,如语音和股票市场信号。讨论了几种序列表示方法,包括一键编码和单词嵌入。然后我们介绍了开发 RNN 结构的动机以及 SimpleRNNCell 网络的例子。使用 RNN 实现了动手情感分类,以帮助我们熟悉使用 RNN 解决现实世界的问题。梯度消失和爆炸是 RNN 训练过程中的常见问题。幸运的是,梯度裁剪方法可以用来克服梯度爆炸问题。RNN 的不同变体,例如 LSTM 和 GRU,可以用来避免梯度消失的问题。情感分类实例表明,使用 LSTM 和 GRU 模型具有更好的性能,因为它们能够避免梯度爆炸问题。

11.14 参考

  1. I. Goodfellow,Y. Bengio 和 a .库维尔,《深度学习》,麻省理工学院出版社,2016 年。

  2. J.Westhuizen 和 J. Lasenby,《遗忘门的不合理效力》, CoRR, abs/1804.04849,2018。*

十二、自编码器

假设机器学习是一块蛋糕,强化学习是蛋糕上的樱桃,监督学习是外面的糖衣,无监督学习是蛋糕本身。

—杨乐存

前面我们介绍了给定样本及其相应标签的神经网络学习算法。这类算法实际上是学习给定样本 x 的条件概率 P ( y | x )。在社交网络蓬勃发展的今天,获取海量样本数据 x 相对容易,比如照片、语音、文字,但难点在于获取这些数据对应的标签信息。例如,除了收集源语言文本,机器翻译还需要要翻译的目标语言文本数据。数据标注主要基于人类的先验知识。比如亚马逊的 Mechanical Turk 系统负责数据标注,从全球招募兼职人员完成客户数据标注任务。深度学习需要的数据规模一般都很大。这种严重依赖手工数据注释的方法非常昂贵,并且不可避免地引入了注释者的主观偏见。

对于海量的未标注数据,有没有办法从中学习到数据分布 P ( x )?这就是我们将在本章介绍的无监督学习算法。特别是,如果算法将 x 作为监督信号进行学习,这类算法称为自监督学习,本章介绍的自编码器算法就是自监督学习算法的一种。

12.1 自编码器的原理

让我们考虑一下神经网络在监督学习中的作用:

$$ o={f}_{\theta }(x),x\in {R}^{d_{in}},o\in {R}^{d_{out}} $$

d in 是输入特征向量的长度, d out 是网络输出向量的长度。对于分类问题,网络模型将 中长度 d 的输入特征向量 x 转换为长度dout的输出向量 o 。这个过程可以认为是一个特征约简过程,将原来的高维输入向量 x 转化为低维变量 o 。降维在机器学习中有着广泛的应用,如文件压缩和数据预处理。最常见的降维算法是主成分分析(PCA),通过对协方差矩阵进行特征分解来获得数据的主要成分,但 PCA 本质上是线性变换,提取特征的能力有限。

那么是否可以利用神经网络强大的非线性表达能力来学习低维数据表示呢?问题的关键在于,训练神经网络一般需要一个显式的标签数据(或者有监督的信号),而无监督的数据没有额外的标签信息,只有数据 x 本身。

所以我们尝试用数据 x 本身作为监督信号来指导网络的训练,也就是希望神经网络能够学习到映射fθ:xx。我们把网络 f θ 分成两部分。第一个子网尝试学习映射关系:$$ {g}_{\theta_1}:x\to z $$,后一个子网尝试学习映射关系$$ {h}_{\theta_2}:z\to x $$,如图 12-1 所示。我们认为$$ {g}_{\theta_1} $$是将高维输入 x 编码成低维隐变量 z (潜变量或隐变量)的数据编码过程,称为编码器网络。$$ {h}_{\theta_2} $$被认为是数据解码的过程,将编码后的输入 z 解码成高维的 x ,称为解码器网络。

img/515226_1_En_12_Fig1_HTML.png

图 12-1

自编码器模型

编码器和解码器共同完成输入数据 x 的编码和解码过程。我们将整个网络模型fθ简称为自编码器。如果使用深度神经网络来参数化$$ {g}_{\theta_1} $$$$ {h}_{\theta_2} $$函数,则称为深度自编码器,如图 12-2 所示。

img/515226_1_En_12_Fig2_HTML.png

图 12-2

使用神经网络参数化的自编码器

自编码器可以将输入转换为隐藏向量 z ,通过解码器重构$$ \underset{_}{x} $$。我们希望解码器的输出能够完美地或者近似地恢复原始输入,也就是$$ \underset{_}{x}\approx x $$,那么自编码器的优化目标可以写成:

$$ \mathit{\min}\ L= dist\left(x,\underset{_}{x}\right) $$

$$ \underset{_}{x}={h}_{\theta_2}\left({g}_{\theta_1}(x)\right) $$

其中$$ dist\left(x,\underset{_}{x}\right) $$代表 x$$ \underset{_}{x} $$之间的距离测量,称为重建误差函数。最常见的测量方法是欧几里德距离的平方。计算方法如下:

$$ L={\sum}_i{\left({x}_i-{\underset{_}{x}}_i\right)}² $$

原则上它等同于均方误差。自编码器网络和普通的神经网络没有本质区别,只是训练好的监督信号从标签 y 变成了自己的 x 。借助于深度神经网络的非线性特征提取能力,自编码器可以获得良好的数据表示,例如,比原始输入数据更小尺寸和维度的数据表示。这对数据和信息压缩非常有用。与 PCA 等线性方法相比,自编码器具有更好的性能,甚至可以更完美地恢复输入 x

在图 12-3(a) 中,第一行是从测试集中随机采样的真实 MNIST 手写数字图片,第二、第三和第四行分别使用自编码器、逻辑 PCA 和标准 PCA,使用长度为 30 的隐藏向量进行重建。在图 12-3(b) 中,第一行是真实的肖像图像,第二和第三行是基于长度为 30 的隐藏向量,使用自编码器和标准 PCA 算法恢复。可以看出,自编码器重建的图像比较清晰,复原程度高,而 PCA 算法重建的图像比较模糊。

img/515226_1_En_12_Fig3_HTML.png

图 12-3

自锚对 PCA [1]

12.2 亲身实践时尚 MNIST 形象重建

自编码器算法的原理非常简单,易于实现,并且训练稳定。与 PCA 算法相比,神经网络强大的表达能力可以学习输入的高层抽象隐藏特征向量 z ,也可以基于 z 重构输入。这里,我们基于时尚 MNIST 数据集执行实际的图片重建。

12.2.1 时尚 MNIST 数据集

时尚 MNIST 是一个比 MNIST 图像识别稍微复杂一点的数据集。它的环境几乎和 MNIST 一样。它包含了十种不同类型的衣服、鞋子、包包的灰度图像,图像大小为 28 × 28,共有 7 万张图片,其中 6 万张用于训练集,1 万张用于测试集,如图 12-4 所示。每一行都是一类图片。正如你所看到的,时尚 MNIST 有相同的设置,除了图片内容不同于 MNIST。大多数情况下,基于 MNIST 的原算法代码可以直接替换,无需额外修改。由于时尚 MNIST 图像识别比 MNIST 图像识别更困难,因此它可以用于测试稍微复杂一些的算法的性能。

img/515226_1_En_12_Fig4_HTML.jpg

图 12-4

时尚 MNIST 数据集

在 TensorFlow 中,加载时尚 MNIST 数据集也非常方便,可以使用 keras . datasets . Fashion _ mnist . load _ data()函数在线下载、管理和加载,如下所示:

# Load Fashion MNIST data set
(x_train, y_train), (x_test, y_test) = keras.datasets.fashion_mnist.load_data()
# Normalize
x_train, x_test = x_train.astype(np.float32) / 255., x_test.astype(np.float32) / 255.
# Only need to use image data to build data set objects, no tags required
train_db = tf.data.Dataset.from_tensor_slices(x_train)
train_db = train_db.shuffle(batchsz * 5).batch(batchsz)
#  Build test set objects
test_db = tf.data.Dataset.from_tensor_slices(x_test)
test_db = test_db.batch(batchsz)

编码器

我们使用编码器将输入图片 xR 784 降维为更低维的隐藏向量 hR 20 ,使用解码器基于隐藏向量 h 重构图片。自编码器型号如图 12-5 所示。解码器由一个 3 层全连接网络组成,输出节点分别为 256、128 和 20。解码器也由三层全连接网络组成,输出节点分别为 128、256 和 784。

img/515226_1_En_12_Fig5_HTML.png

图 12-5

时尚 MNIST 自编码器网络架构

首先是编码器子网络的实现。使用三层神经网络将图像向量的维数从 784 降低到 256,128,并最终降低到 h_dim。每一层都使用 ReLU 激活函数,最后一层不使用任何激活函数。

        # Create Encoders network, implemented in the initialization function of the autoencoder class
        self.encoder = Sequential([
            layers.Dense(256, activation=tf.nn.relu),
            layers.Dense(128, activation=tf.nn.relu),
            layers.Dense(h_dim)
        ])

解码器

让我们创建解码器子网。这里隐藏向量 h_dim 依次升级到 128、256、784 的长度。除了最后一层,使用 ReLU 激活功能。解码器的输出是一个长度为 784 的向量,它代表展平后的 28 × 28 大小的图片,可以通过整形操作恢复为图片矩阵,如下所示:

        # Create Decoders network
        self.decoder = Sequential([
            layers.Dense(128, activation=tf.nn.relu),
            layers.Dense(256, activation=tf.nn.relu),
            layers.Dense(784)
        ])

自编码器

前面两个编码器和解码器的子网是在 autoencoder 类 AE 中实现的,我们同时在初始化函数中创建这两个子网。

class AE(keras.Model):
    # Self-encoder model class, including Encoder and Decoder 2 subnets
    def __init__(self):
        super(AE, self).__init__()
        #  Create Encoders network
        self.encoder = Sequential([
            layers.Dense(256, activation=tf.nn.relu),
            layers.Dense(128, activation=tf.nn.relu),
            layers.Dense(h_dim)
        ])
        #  Create Decoders network
        self.decoder = Sequential([
            layers.Dense(128, activation=tf.nn.relu),
            layers.Dense(256, activation=tf.nn.relu),
            layers.Dense(784)
        ])

接下来,在调用函数中实现正向传播过程。输入图像首先通过编码器子网络获得隐向量 h,然后通过解码器获得重构图像。只需如下依次调用编码器和解码器的正向传播函数:

    def call(self, inputs, training=None):
        # Forward propagation function
        # Encoding to obtain hidden vector h,[b, 784] => [b, 20]
        h = self.encoder(inputs)
        # Decode to get reconstructed picture, [b, 20] => [b, 784]
        x_hat = self.decoder(h)

        return x_hat

网络培训

自编码器的训练过程基本上与分类器的训练过程相同。通过误差函数计算重构向量$$ \underset{_}{x} $$与原始输入向量 x 之间的距离,然后利用 TensorFlow 的自动求导机制同时计算编码器和解码器的梯度。

首先,创建一个 autoencoder 和 optimizer 的实例,并设置一个适当的学习速率。例如:

#  Create network objects
model = AE()
#  Specify input size
model.build(input_shape=(4, 784))
#  Print network information
model.summary()
#  Create an optimizer and set the learning rate
optimizer = optimizers.Adam(lr=lr)

这里训练 100 个历元,每次通过正演计算得到重建图像向量,使用 TF . nn . sigmoid _ cross _ entropy _ with _ logits 损失函数计算重建图像与原始图像之间的直接误差。事实上,使用 MSE 误差函数也是可行的,如下所示:

for epoch in range(100): # Train 100 Epoch
    for step, x in enumerate(train_db): #  Traverse the training set
        # Flatten, [b, 28, 28] => [b, 784]
        x = tf.reshape(x,    [-1, 784])
        # Build a gradient recorder
        with tf.GradientTape() as tape:
            # Forward calculation to obtain the reconstructed picture
            x_rec_logits = model(x)
            # Calculate the loss function between the reconstructed picture and the input

            rec_loss = tf.nn.sigmoid_cross_entropy_with_logits(labels=x, logits=x_rec_logits)
            # Calculate the mean
            rec_loss = tf.reduce_mean(rec_loss)
        # Automatic derivation, including the gradient of 2 sub-networks
        grads = tape.gradient(rec_loss, model.trainable_variables)
        # Automatic update, update 2 subnets at the same time
        optimizer.apply_gradients(zip(grads, model.trainable_variables))
        if step % 100 ==0:
            # Interval print training error
            print(epoch, step, float(rec_loss))

图像重建

与分类问题不同,自编码器的模型性能通常不容易量化。虽然 L 值在一定程度上可以代表网络的学习效果,但我们最终还是希望获得还原程度更高、风格更丰富的重构样本。因此,通常需要根据具体问题来讨论自编码器的学习效果。对于图像重建,它通常取决于对图像生成的人工主观评价的质量,或者使用某些图像保真度计算方法,如初始得分和 Frechet 初始距离。

为了测试图像重建的效果,我们将数据集分为训练集和测试集,其中测试集不参与训练。我们从测试集中随机抽取测试图片xDtest,通过自编码器计算重建图片,然后将真实图片和重建图片保存为图片数组并可视化,以便于比较,如下所示:

    # Reconstruct pictures, sample a batch of pictures from the test set
    x = next(iter(test_db))
    logits = model(tf.reshape(x, [-1, 784])) # Flatten and send to autoencoder
    x_hat = tf.sigmoid(logits) # Convert the output to pixel values, using the sigmoid function
    # Recover to 28x28,[b, 784] => [b, 28, 28]
    x_hat = tf.reshape(x_hat, [-1, 28, 28])

    # The first 50 input + the first 50 reconstructed pictures merged, [b, 28, 28] => [2b, 28, 28]
    x_concat = tf.concat([x[:50], x_hat[:50]], axis=0)
    x_concat = x_concat.numpy() * 255\. #  Revert to 0~255 range
    x_concat = x_concat.astype(np.uint8)  #  Convert to integer
save_images(x_concat, 'ae_images/rec_epoch_%d.png'%epoch)  # Save picture

图像重建的效果如图 12-6 ,图 12-7 ,图 12-8 所示。每张图片左边的五列是实景图,右边的五列是对应的重建图。可以看出,在第一个历元,画面重建效果差,画面非常模糊,保真度差。随着训练的进行,重建图片的边缘越来越清晰。在第 100 个时期,重建的图像效果已经更接近真实图像。

img/515226_1_En_12_Fig8_HTML.jpg

图 12-8

第一百个纪元

img/515226_1_En_12_Fig7_HTML.jpg

图 12-7

第十纪元

img/515226_1_En_12_Fig6_HTML.jpg

图 12-6

第一纪元

这里的 save_images 函数负责合并多张图片,保存为大图。这是使用 PIL 图片库完成的。代码如下:

def save_images(imgs, name):
    #  Create 280x280 size image array
    new_im = Image.new('L', (280, 280))
    index = 0
    for i in range(0, 280, 28): # 10-row image array
        for j in range(0, 280, 28): # 10-column picture array
            im = imgs[index]
            im = Image.fromarray(im, mode='L')
            new_im.paste(im, (i, j)) # Write the corresponding location
            index += 1
    # Save picture array
    new_im.save(name)

12.3 自组装变体

总的来说,autoencoder 网络的训练相对稳定,但由于损失函数直接衡量重建样本与真实样本底层特征之间的距离,而不是评估重建样本的保真度和多样性等抽象指标,因此在一些任务上的效果很一般,例如重建图像边缘容易模糊的图像重建,保真度与真实图像相比并不好。为了学习数据的真实分布,产生了一系列自编码器变体网络:去噪自编码器。

为了防止神经网络记住输入数据的底层特征,去噪自编码器将随机噪声干扰添加到输入数据,例如将从高斯分布采样的噪声 ε 添加到输入 x :

$$ \overset{\sim }{x}=x+\varepsilon, \varepsilon \sim N\left(0,\mathit{\operatorname{var}}\right) $$

加入噪声后,网络需要从 x 中学习数据的真实隐变量 z ,还原原始输入 x ,如图 12-9 所示。该模型的优化目标是:

img/515226_1_En_12_Fig9_HTML.png

图 12-9

去噪自编码器图

img/515226_1_En_12_Figa_HTML.png

12.3.1 压差自编码器

自编码器网络也面临过拟合的风险。Dropout autoencoder 通过随机断开网络来降低网络的表达能力,并防止过拟合。dropout autoencoder 的实现非常简单。可以通过在网络层中插入脱落层来实现网络连接的随机断开。

12.3.2 敌方自锚

为了能够方便地从一个已知的先验分布 p ( z 中采样隐变量 z ,可以方便地使用 p ( z )来重构输入,对抗自编码器使用一个附加的鉴别器网络(discriminator,简称 D 网络)来确定降维的隐变量 z 是否是从先验分布 p ( 中采样的鉴别器网络的输出是一个属于区间[0,1]的变量,代表隐向量是否从先验分布 p ( z )中采样:所有来自先验分布 p ( z )的样本标记为真,由条件概率q(z|x产生的样本标记为假。这样,除了重构样本,还可以约束条件概率分布 q ( x )来近似先验分布 p ( z )。

img/515226_1_En_12_Fig10_HTML.png

图 12-10

对手自编自编自编自编自编自编自编自编自编自编自编自编自编自编自编自编自编自编自演

对抗式自编码器由下一章介绍的生成式对抗网络算法衍生而来。学习完对抗性生成网络后,可以加深对对抗性自编码器的理解。

12.4 可变自编码器

基本的自编码器本质上学习输入 x 和隐藏变量 z 之间的映射关系。这是一个判别模型,而不是一个生成模型。那么 autoencoder 可以调整到创成式模型来轻松生成样本吗?

已知隐变量的分布 P ( z ),如果条件概率分布 P ( z ),那么我们就可以对联合概率分布 P ( xz)=P(z)P(z)进行采样变型自编码器(VAE)可以实现这一目标,如图 12-11 所示。如果从神经网络的角度来理解,VAE 和之前的自编码器是一样的,非常直观,容易理解;但是 VAE 的理论推导有点复杂。接下来,我们先从神经网络的角度解释 VAE,再从概率的角度推导 VAE。

img/515226_1_En_12_Fig11_HTML.png

图 12-11

VAE 模型结构

从神经网络的角度来看,与自编码器模型相比,VAE 还有编码器和解码器两个子网络。解码器接受输入 x,输出是潜变量 z;解码器负责将隐变量 z 解码成重构的 x,不同的是,VAE 模型对隐变量 z 的分布有显式约束,希望隐变量 z 符合预设的先验分布 P(z)。因此,在损失函数的设计中,除了原有的重构误差项之外,还增加了对隐变量 z 分布的约束项。

VAE 原则

从概率的角度来说,我们假设任何一个数据集都是从某个分布 p ( x | z )中抽样得到的; z 是隐藏变量,代表某种内部特征,比如手写数字的图片xz 可以表示字体大小、书写风格、粗体、斜体等设置,符合一定的先验分布 p ( z )。给定一个特定的隐藏变量 z ,我们可以从学习到的分布p(x|z)中抽取一系列样本。这些样本都具有以 z 为代表的共性。

通常假设 p ( z )遵循一个已知的分布,比如 N (0,1)。在 p ( z )已知的情况下,我们的目标是学习到一个生成概率模型p(x|z)。这里可以使用最大似然估计法:一个好的模型应该有很高的概率产生真实样本 xD 。如果我们的生成模型p(x|z)用 θ 参数化,那么我们神经网络的优化目标就是:

$$ p\ (x)={\int}_zp\left(x|z\right)p(z) dz $$

遗憾的是,由于 z 是连续变量,前面的积分无法转化为离散形式,很难直接优化。

另一种思路是利用变分推理的思想,我们通过分布qϕ(x)来近似 p ( z | x ,也就是我们需要最小化q**(x)和 p 之间的距离

KL 散度 D KL* 是分布 qp 之间差距的度量,定义为:

$$ {D}_{KL}\left(q\Big\Vert p\right)={\int}_xq(x) loglog\ \frac{q(x)}{p(x)}\  dx $$

严格来说,距离一般是对称的,而 KL 散度是不对称的。将 KL 发散展开为:

$$ {D}_{KL}\left({q}_{\phi }(x)\Big\Vert p(x)\right)={\int}_z{q}_{\phi }(x) loglog\ \frac{q_{\phi }(x)}{p(x)}\  dz $$

使用

$$ p\left(z|x\right)\bullet p(x)=p\left(x,z\right) $$

得到

$$ {D}_{KL}\left({q}_{\phi }(x)\Big\Vert p(x)\right)={\int}_z{q}_{\phi }(x) loglog\ \frac{q_{\phi }(x)p(x)}{p\left(x,z\right)}\  dz $$

$$ ={\int}_z{q}_{\phi }(x) loglog\ \frac{q_{\phi }(x)}{p\left(x,z\right)}\  dz+{\int}_z{q}_{\phi }(x) loglog\ p\ (x) dz $$

img/515226_1_En_12_Figb_HTML.png

我们将$$ -{\int}_z{q}_{\phi }(x) loglog\ \frac{q_{\phi }(x)}{p\left(x,z\right)}\  dz $$定义为 L ( ϕθ ),那么前面的等式就变成:

$$ {D}_{KL}\left({q}_{\phi }(x)\Big\Vert p(x)\right)=-L\left(\phi, \theta \right)+ loglog\ p\ (x) $$

其中

$$ L\left(\phi, \theta \right)=-{\int}_z{q}_{\phi }(x) loglog\ \frac{q_{\phi }(x)}{p\left(x,z\right)}\  dz $$

考虑

$$ {D}_{KL}\left({q}_{\phi }(x)\Big\Vert p(x)\right)\ge 0 $$

我们有

$$ L\left(\phi, \theta \right)\le loglog\ p\ (x) $$

换句话说, L ( ϕθ )是 loglog p ( x )的下界,优化目标 L ( ϕθ )称为证据下界目标(ELBO)。我们的目标是最大化似然概率 p ( x ),或者最大化 loglog p ( x ),可以通过最大化其下界 L ( ϕθ )来实现。

现在我们来分析一下如何最大化 L ( ϕθ )函数,并将其展开得到:

$$ L\left(\theta, \phi \right)={\int}_z{q}_{\phi }(x) loglog\ \frac{p_{\theta}\left(x,z\right)}{q_{\phi }(x)} $$

$$ ={\int}_z{q}_{\phi }(x) loglog\ \frac{p(z){p}_{\theta }(z)}{q_{\phi }(x)} $$

$$ ={\int}_z{q}_{\phi }(x) loglog\ \frac{p(z)}{q_{\phi }(x)}+{\int}_z{q}_{\phi }(x) loglog\ {p}_{\theta }(z) $$

$$ =-{\int}_z{q}_{\phi }(x) loglog\ \frac{q_{\phi }(x)}{p(z)}+{E}_{z\sim q}\left[ loglog\ {p}_{\theta }(z)\ \right] $$

$$ =-{D}_{KL}\left({q}_{\phi }(x)\Big\Vert p(z)\right)+{E}_{z\sim q}\left[ loglog\ {p}_{\theta }(z)\ \right] $$

所以,

$$ L\left(\theta, \phi \right)=-{D}_{KL}\left({q}_{\phi }(x)\Big\Vert p(z)\right)+{E}_{z\sim q}\left[ loglog\ {p}_{\theta }(z)\ \right] $$

(12-1)

可以用编码器网络参数化qϕ(x)函数,解码器网络参数化pθ(z)函数。目标函数 L ( θϕ )可以通过计算解码器q**(x)的输出分布与先验分布 p ( z )之间的 KL 散度,以及似然概率 loglog p )来优化

特别是当qϕ(x)和 p ( z )都假定为正态分布时,dKL(qϕ(x*)的计算

更具体地说,当qϕ(x)为正态分布 N ( μ 1σ 1p ( z )为正态分布 N (0,1),即

前面的过程使得dKL(qϕ(x)p(z)l(θϕ )中的项更容易计算,而 E

因此,VAE 模型的优化目标由最大化 L ( ϕθ )函数转化为:

$$ \mathit{\min}\ {D}_{KL}\left({q}_{\phi }(x)\Big\Vert p(z)\right) $$

还有

$$ \mathit{\max}\ {E}_{z\sim q}\left[ loglog\ {p}_{\theta }(z)\ \right] $$

第一个优化目标可以理解为约束潜变量 z 的分布,第二个优化目标可以理解为提高网络的重构效果。可以看出,经过我们的推导,VAE 模型也非常直观,易于理解。

重新参数化技巧

现在考虑在实施上述 VAE 模型时遇到的一个严重问题。从编码器的输出qϕ(x)中采样隐变量 z ,如图 12-12 左图所示。当qϕ(x)和 p ( z )均假定为正态分布时,编码器输出正态分布的均值 μ 和方差 σ 2 ,解码器的输入从 N ( 采样由于采样操作的存在,梯度传播是不连续的,VAE 网络不能通过梯度下降算法进行端到端的训练。

img/515226_1_En_12_Fig12_HTML.png

图 12-12

重新参数化技巧图

文献[2]提出了一种连续可导的解决方案,称为重新参数化技巧。它通过z=μ+σε对隐变量 z 进行采样,其中$$ \frac{\partial z}{\partial \mu } $$$$ \frac{\partial z}{\partial \sigma } $$都是连续且可微的,从而连接梯度传播。如图 12-12 右图所示, ε 变量是从标准正态分布 N (0,I)μσ 由编码器网络产生。采样后的隐变量可以通过z=μ+σε得到,保证了梯度传播的连续性。

VAE 网络模型如图 12-13 所示,输入 x 通过编码器网络qϕ(x)计算得到隐变量 z 的均值和方差,隐变量 z 通过重新参数化的技巧方法采样得到,送到解码器网络得到分布(

img/515226_1_En_12_Fig13_HTML.png

图 12-13

VAE 建筑模型

12.5 VAE 图像重建实践

在这一部分,我们将基于 VAE 模型重建和生成时尚 MNIST 图片。如图 12-13 所示,输入的是时尚 MNIST 图片矢量。三个全连接层后,得到隐向量 z 的均值和方差,用两个全连接层 20 个输出节点表示。FC2 的 20 个输出节点代表 20 个特征分布的均值向量 μ ,FC3 的 20 个输出节点代表 20 个特征分布的对数方差向量。通过重新参数化技巧采样获得长度为 20 的隐藏向量 z ,通过 FC4 和 FC5 重构样本图像。

作为一种生成模型,VAE 不仅可以重构输入样本,还可以单独使用解码器来生成样本。隐向量 z 由先验分布 p ( z )直接采样得到,生成的样本可以解码后生成。

img/515226_1_En_12_Fig14_HTML.png

图 12-14

VAE 建筑模型

VAE 模型

我们实现了 VAE 类别的编码器和解码器子网络。在初始化函数中,我们分别创建编码器和解码器所需的网络层,如下所示:

class VAE(keras.Model):
    #  Variational Encoder
    def __init__(self):
        super(VAE, self).__init__()

        # Encoder
        self.fc1 = layers.Dense(128)
        self.fc2 = layers.Dense(z_dim) # output mean
        self.fc3 = layers.Dense(z_dim) # output variance

        # Decoder
        self.fc4 = layers.Dense(128)
        self.fc5 = layers.Dense(784)

编码器的输入首先经过共享层 FC1,然后分别经过 FC2 和 FC3 网络,得到隐向量分布的均值向量和方差的对数向量值。

    def encoder(self, x):
        # Get mean and variance
        h = tf.nn.relu(self.fc1(x))
        # Mean vector
        mu = self.fc2(h)
        # Log of variance
        log_var = self.fc3(h)

        return mu, log_var

解码器接受采样后的隐藏向量 z ,解码成图片输出。

    def decoder(self, z):
        #  Generate image data based on hidden variable z
        out = tf.nn.relu(self.fc4(z))
        out = self.fc5(out)
        #  Return image data, 784 vector
        return out

在 VAE 的正向计算过程中,首先由编码器获得输入的潜在向量 z 的分布,然后通过采样由重新参数化技巧实现的重新参数化函数获得潜在向量 z ,最后由解码器恢复重建的图像向量。实现如下:

    def call(self, inputs, training=None):
        # Forward calculation
        # Encoder [b, 784] => [b, z_dim], [b, z_dim]
        mu, log_var = self.encoder(inputs)
        # Sampling - reparameterization trick
        z = self.reparameterize(mu, log_var)
        # Decoder
        x_hat = self.decoder(z)
        # Return sample, mean and log variance
        return x_hat, mu, log_var

重新参数化技巧

reparameterize 函数接受均值和方差参数,通过从正态分布 N (0, I 采样得到 ε ,通过z=μ+σε返回采样后的隐藏向量。

    def reparameterize(self, mu, log_var):
        # reparameterize trick
        eps = tf.random.normal(log_var.shape)
        # calculate standard variance
        std = tf.exp(log_var)**0.5
        # reparameterize trick
        z = mu + std * eps
        return z

网络培训

网络训练 100 个历元,每次重建样本由 VAE 模型正演计算得到。重建误差项Ez~q[log log pθ(z)】是基于交叉熵损失函数计算的。误差项dKL(qϕ(x)‖p(z))根据式(12-2)计算。

# Create network objects
model = VAE()
model.build(input_shape=(4, 784))
# Optimizer
optimizer = optimizers.Adam(lr)

for epoch in range(100): # Train 100 Epochs
    for step, x in enumerate(train_db): #  Traverse the training set
        # Flatten, [b, 28, 28] => [b, 784]
        x = tf.reshape(x, [-1, 784])
        #  Build a gradient recorder
        with tf.GradientTape() as tape:
            # Forward calculation
            x_rec_logits, mu, log_var = model(x)
            #  Reconstruction loss calculation
            rec_loss = tf.nn.sigmoid_cross_entropy_with_logits(labels=x, logits=x_rec_logits)
            rec_loss = tf.reduce_sum(rec_loss) / x.shape[0]
            # Calculate KL convergence N(mu, var) VS N(0, 1)
            # Refernece:https://stats.stackexchange.com/questions/7440/kl-divergence-between-two-univariate-gaussians
            kl_div = -0.5 * (log_var + 1 - mu**2 - tf.exp(log_var))
            kl_div = tf.reduce_sum(kl_div) / x.shape[0]
            # Combine error
            loss = rec_loss + 1\. * kl_div
        # Calculate gradients
        grads = tape.gradient(loss, model.trainable_variables)
        # Update parameters
        optimizer.apply_gradients(zip(grads, model.trainable_variables))

        if step % 100 == 0:
            # Print error

            print(epoch, step, 'kl div:', float(kl_div), 'rec loss:', float(rec_loss))

图像生成

图像生成仅使用解码器网络。首先从先验分布 N (0, I 中采样隐藏向量,然后通过解码器得到图像向量,最后整形为图像矩阵。例如:

    #  Test generation effect, randomly sample z from normal distribution
    z = tf.random.normal((batchsz, z_dim))
    logits = model.decoder(z) #  Generate pictures only by decoder
    x_hat = tf.sigmoid(logits) #  Convert to pixel range
    x_hat = tf.reshape(x_hat, [-1, 28, 28]).numpy() *255.
    x_hat = x_hat.astype(np.uint8)
    save_images(x_hat, 'vae_images/epoch_%d_sampled.png'%epoch) # Save pictures

    # Reconstruct the picture, sample pictures from the test set
    x = next(iter(test_db))
    logits, _, _ = model(tf.reshape(x, [-1, 784])) # Flatten and send to autoencoder
    x_hat = tf.sigmoid(logits) #  Convert output to pixel value
    # Restore to 28x28,[b, 784] => [b, 28, 28]
    x_hat = tf.reshape(x_hat, [-1, 28, 28])
    # The first 50 input + the first 50 reconstructed pictures merged, [b, 28, 28] => [2b, 28, 28]
    x_concat = tf.concat([x[:50], x_hat[:50]], axis=0)
    x_concat = x_concat.numpy() * 255.
    x_concat = x_concat.astype(np.uint8)
    save_images(x_concat, 'vae_images/epoch_%d_rec.png'%epoch)

图片重建的效果如图 12-15 、图 12-16 和图 12-17 所示,分别显示了输入第一、第十和第 100 个历元的测试集图片得到的重建效果。每张图片左边五列是实拍图,右边五列是对应的重建效果。图像生成的效果如图 12-18 、图 12-19 和图 12-20 所示,分别显示了第一个、第十个和第 100 个历元的图像生成效果。

img/515226_1_En_12_Fig20_HTML.jpg

图 12-20

图像生成:纪元=100

img/515226_1_En_12_Fig19_HTML.jpg

图 12-19

图像生成:纪元=10

img/515226_1_En_12_Fig18_HTML.jpg

图 12-18

图像生成:纪元=1

img/515226_1_En_12_Fig17_HTML.jpg

图 12-17

图像重建:epoch=100

img/515226_1_En_12_Fig16_HTML.jpg

图 12-16

图像重建:epoch=10

img/515226_1_En_12_Fig15_HTML.jpg

图 12-15

图像重建:epoch=1

可以看出,图像重建的效果略好于图像生成,这也说明图像生成是一项更复杂的工作。虽然 VAE 模型具有生成图像的能力,但生成的效果仍然不够好,人眼仍然可以区分机器生成的图片样本和真实图片样本之间的差异。下一章将要介绍的生成式对抗网络在图像生成方面表现更好。

12.6 摘要

在这一章中,我们介绍了强大的自我监督学习算法——自编码器及其变体。我们从自编码器的原理开始,以便理解它的数学机制,然后我们通过时尚 MNIST 图像重建练习完成自编码器的实际实现。按照类似的步骤,讨论了 VAE 模型,并将其应用于时尚 MNIST 图像数据集,以演示图像生成过程。在开发机器学习或深度学习模型时,一个常见的挑战是输入数据的高维度。与传统的降维方法(例如 PCA)相比,自编码器及其变体通常在以较低的维度和大小生成数据表示方面具有更好的性能。

12.7 参考

  1. G.E. Hinton,“用神经网络降低数据的维数”,2008 年。

  2. D.P. Kingma 和 M. Welling,“自编码变分贝叶斯”,第二届学习表示国际会议,2014 年,ICLR,班夫,AB,加拿大,2014 年 4 月 14-16 日,会议记录,2014 年。**

十三、生成对抗网络

我不能创造的,我还没有完全理解。

理查德·费曼

在生成对抗网络(GAN)发明之前,变分自编码器被认为是理论上完整且实现简单的。使用神经网络训练时非常稳定,得到的图像更加近似,但人眼仍然可以轻松区分真实图片和机器生成的图片。

2014 年,蒙特利尔大学 yo shua beng io(2018 年图灵奖获得者)的学生 Ian Goodfellow 提出了 GAN [1],开启了深度学习最热门的研究方向之一。2014-2019 年,GAN 研究稳步推进,研究成果捷报频传。最新的 GAN 算法在图像生成上的效果已经达到了肉眼难以分辨的程度,实在令人兴奋。由于 GAN 的发明,Ian Goodfellow 被授予 GAN 之父的称号,并于 2017 年被麻省理工学院评论授予 35 名 35 岁以下创新者奖。图 13-1 从 2014 年到 2018 年,GAN 模型达到了书代的效果。可以看出,无论是画面的大小,还是画面的保真度,都有了很大的提升。 1

img/515226_1_En_13_Fig1_HTML.jpg

图 13-1

2014 年至 2018 年 GAN 生成图像效果

接下来我们就从生活中的游戏学习这个例子开始,一步步的介绍 GAN 算法的设计思路和模型结构。

13.1 游戏学习的例子

我们用一个漫画家的成长轨迹来形象地介绍甘的想法。考虑一对双胞胎兄弟,分别叫 G 和 D,G 学习如何画漫画,D 学习如何欣赏画作。两兄弟在很小的时候就学会了如何使用画笔和纸张。g 画了一幅不知名的画,如图 13-2(a) 。这个时候 D 的辨别能力不高,所以 D 觉得 G 的作品还可以,只是主角不够清晰。在 D 的指导和鼓励下,G 开始学习如何画出主体的轮廓,使用简单的色彩组合。

一年后,G 提高了绘画基本功,D 也初步掌握了通过分析名作和 G 的作品来鉴别作品的能力,此时 D 觉得 G 的作品有了主要人物,如图 13-2(b) ,但对色彩的运用还不够成熟。几年后,G 的绘画基本功已经非常扎实,可以轻松地画出主题鲜明、色彩搭配恰当、逼真度高的画作,如图 13-2(c) 所示,但 D 也观察到了 G 与其他名作的差异,提高了辨别画作的能力。这时 D 觉得 G 的绘画技巧已经成熟,但是对生活的观察还不够。g 的作品没有传达出表情,一些细节也不完美。又过了几年,G 的画技已经到了炉火纯青的地步。画作细节完美,风格迥异,栩栩如生,宛如大师级,如图 13-2(d) 。即使在这个时候,D 的辨别能力也是相当出色的。D 也很难区分 G 和其他大作。

上述画家的成长过程,其实就是一个生活中共同的学习过程,通过双方的学习和相互提高的博弈,最终达到一个平衡点。GAN 网络借鉴了游戏学习的思想,设置了两个子网络:负责生成样本的生成器 G 和负责认证的鉴别器 D。鉴别器 D 通过观察真实样本和发生器 G 产生的样本之间的差异来学习如何区分真假,其中真实样本为真,发生器 G 产生的样本为假。发电机 G 也在学习。它希望生成的样本能够被鉴别器 D 识别为真。因此,生成器 G 试图使其生成的样本被判别式 D 认为是真的,生成器 G 和判别式 D 相互博弈,共同改进,直到达到一个平衡点。此时发生器 G 产生的样本非常逼真,使得鉴别器 D 难以辨别真假。

img/515226_1_En_13_Fig2_HTML.png

图 13-2

画家成长轨迹速写

在最初的 GAN 论文中,Ian Goodfellow 用了另一个生动的比喻来介绍 GAN 模型:生成器网络 G 的作用是生成一系列非常逼真的假钞来试图欺骗鉴别器 D,鉴别器 D 通过学习生成器 G 生成的真钱和假钞的区别来掌握钞票鉴别方法。这两个网络在相互博弈的过程中同步,直到生成器 G 产生的假钞非常真实,连鉴别器 D 都勉强能分辨出来。

这种游戏学习的思想使得 GAN 的网络结构和训练过程与之前的网络模型略有不同。下面我们来详细介绍一下 GAN 的网络结构和算法原理。

13.2 甘原则

现在我们将正式介绍 GAN 的网络结构和训练方法。

网络结构

GAN 包含两个子网络:发生器网络(称为 G)和鉴别器网络(称为 D)。生成器网络 G 负责学习样本的真实分布,而鉴别器网络 D 负责将生成器网络生成的样本与真实样本区分开。

发电机 G ( z )发电机网络 G 类似于自编码器的解码器的作用。隐变量z*p*<sub>*z*</sub>(∙)从先验分布*p*<sub>*z*</sub>(∙)中采样。生成的样本*x*pG(x|z)由发电机网络 G 的参数化分布pG(x|z得到,如图 13-3 所示隐变量 z 的先验分布pz(∙)可以假设为已知分布,比如多元均匀分布 z ~ 均匀(-1,1)。

img/515226_1_En_13_Fig3_HTML.png

图 13-3

发电机 G

pg(x|z)可以通过深度神经网络进行参数化。如图 13-4 所示,从均匀分布pz(∙)中抽取隐变量 z ,然后从pg(x|从输入输出的角度来看,生成器 G 的作用是通过神经网络将隐向量 z 转化为样本向量 x f ,下标 f 代表伪样本。

img/515226_1_En_13_Fig4_HTML.png

图 13-4

转置卷积构成的生成网络

鉴频器 D ( x )鉴频器网络的作用类似于普通的二进制分类网络。它接受输入样本 x 的数据集,包括从真实数据分布pr(∙)中采样的样本xr~pr(∙)以及从发电网络 x 中采样的伪样本 x rx f 共同构成鉴别器网络的训练数据集。鉴别器网络的输出是 x 属于真实样本 P ( x 是真实的 | x )。我们将所有真实样本 x r 标注为真(1),生成网络生成的所有样本 x f 标注为假(0)。鉴频器网络 D 的预测值和标签之间的误差用于优化鉴频器网络参数,如图 13-5 所示。

img/515226_1_En_13_Fig5_HTML.png

图 13-5

发电机网络和鉴别器网络

网络培训

甘游戏学习的思想体现在其训练方法上。由于生成器 G 和鉴别器 D 的优化目标不同,不能和前面的网络模型训练一样,只用一个损失函数。下面我们分别介绍一下如何训练生成器 G 和鉴别器 D。

对于鉴别器网络 D 来说,它的目标是能够区分真样本xr和假样本 x f 。以图片生成为例,其目标是最小化图片的预测值和真实值之间的交叉熵损失函数:

$$ L= CE\left({D}_{\theta}\left({x}_r\right),{y}_r,{D}_{\theta}\left({x}_f\right),{y}_f\right) $$

其中Dθ(xr代表判别网络中真实样本的输出xrDθθ 为判别网络的参数集, Dθ(xf)是鉴频器网络中生成样本 x f 的输出, yx r 的标签。 因为真实样本被标注为真实,所以yr= 1。 y f 是生成样本的f的标签。由于生成的样本被标注为假,yf= 0。CE 函数表示交叉熵损失函数 CrossEntropy。两个分类问题的交叉熵损失函数定义为:

$$ L=-{\sum}_{x_r\sim {p}_r\left(\bullet \right)}\mathit{\log}{D}_{\theta}\left({x}_r\right)-{\sum}_{x_f\sim {p}_g\left(\bullet \right)}\mathit{\log}\left(1-{D}_{\theta}\left({x}_f\right)\right) $$

*因此,鉴频器网络 D 的优化目标是:

$$ {\theta}^{\ast }=-{\sum}_{x_r\sim {p}_r\left(\bullet \right)}\mathit{\log}{D}_{\theta}\left({x}_r\right)-{\sum}_{x_f\sim {p}_g\left(\bullet \right)}\mathit{\log}\left(1-{D}_{\theta}\left({x}_f\right)\right) $$

L 转换为 L ,并将其写在期望表中:

$$ {\theta}^{\ast }={E}_{x_r\sim {p}_r\left(\bullet \right)}\mathit{\log}{D}_{\theta}\left({x}_r\right)+{E}_{x_f\sim {p}_g\left(\bullet \right)}\mathit{\log}\left(1-{D}_{\theta}\left({x}_f\right)\right) $$

对于发生器网络 G ( z ),我们希望xf=G(z)能够很好地欺骗鉴频器网络 D,使假样本 x f 的输出尽可能接近真实标签。也就是说,在训练发生器网络时,希望鉴频器网络的输出D(G(z))尽可能接近 1,并使D(G(z))与 1 之间的交叉熵损失函数最小:

$$ L= CE\left(D\left({G}_{\phi }(z)\right),1\right)=- logD\left({G}_{\phi }(z)\right) $$

L 转换为 L ,并将其写在期望表中:

$$ {\phi}^{\ast }={E}_{z\sim {p}_z\left(\bullet \right)} logD\left({G}_{\phi }(z)\right) $$

它可以等效地转化为:

$$ {\phi}^{\ast }=L={E}_{z\sim {p}_z\left(\bullet \right)}\mathit{\log}\left[1-D\left({G}_{\phi }(z)\right)\right] $$

其中 ϕ 是发电机网络 g 的参数集,可以用梯度下降算法来优化参数 ϕ

统一的目标函数

我们可以合并生成器和鉴别器网络的目标函数,并将其写成最小-最大博弈的形式:

$$ \underset{\phi }{\mathit{\min}}\ {\mathit{\max}}_{\theta }L\left(D,G\right)={E}_{x_r\sim {p}_r\left(\bullet \right)}\mathit{\log}{D}_{\theta}\left({x}_r\right)+{E}_{x_f\sim {p}_g\left(\bullet \right)}\mathit{\log}\left(1-{D}_{\theta}\left({x}_f\right)\right) $$

$$ ={E}_{x\sim {p}_r\left(\bullet \right)}\mathit{\log}{D}_{\theta }(x)+{E}_{z\sim {p}_z\left(\bullet \right)}\mathit{\log}\left(1-{D}_{\theta}\left({G}_{\phi }(z)\right)\right) $$

(13-1)

算法如下:

| **算法 1:GAN 训练算法** | | 随机初始化参数 ***θ和ϕ*****重复****为** k 次**做**随机采样隐藏向量***z ~ p******z***(***∙***)随机抽取真实样本***x******r******~ p******r***(**)** **根据梯度下降算法更新三维网络:

$$ {\boldsymbol{\nabla}}_{\boldsymbol{\theta}}{\boldsymbol{E}}_{{\boldsymbol{x}}_{\boldsymbol{r}}\sim {\boldsymbol{p}}_{\boldsymbol{r}}\left(\bullet \right)}\boldsymbol{\log}{\boldsymbol{D}}_{\boldsymbol{\theta}}\left({\boldsymbol{x}}_{\boldsymbol{r}}\right)+{\boldsymbol{E}}_{{\boldsymbol{x}}_{\boldsymbol{f}}\sim {\boldsymbol{p}}_{\boldsymbol{g}}\left(\bullet \right)}\boldsymbol{\log}\left(\mathbf{1}-{\boldsymbol{D}}_{\boldsymbol{\theta}}\left({\boldsymbol{x}}_{\boldsymbol{f}}\right)\right) $$

随机采样隐藏向量z ~ pz()

根据梯度下降算法更新 G 网络:

$$ {\boldsymbol{\nabla}}_{\boldsymbol{\phi}}{\boldsymbol{E}}_{\boldsymbol{z}\sim {\boldsymbol{p}}_{\boldsymbol{z}}\left(\bullet \right)}\boldsymbol{\log}\left(\mathbf{1}-{\boldsymbol{D}}_{\boldsymbol{\theta}}\left({\boldsymbol{G}}_{\boldsymbol{\phi}}\left(\boldsymbol{z}\right)\right)\right) $$

结束于

直到训练轮数达到要求

输出:受训发电机gϕ** |

13.3 动手 DCGAN

在本节中,我们将完成卡通化身图像的实际生成。参考 DCGAN [2]的网络结构,其中鉴别器 D 由普通卷积层实现,生成器 G 由转置卷积层实现,如图 13-6 。

img/515226_1_En_13_Fig6_HTML.png

图 13-6

DCGAN 网络结构

13.3.1 卡通头像数据集

这里我们用的是卡通头像的数据集,总共 51223 张图片,没有标注信息。图片的主体已被裁剪、对齐并统一缩放到 96 × 96 的大小。一些样品如图 13-7 所示。

img/515226_1_En_13_Fig7_HTML.jpg

图 13-7

卡通头像数据集

对于定制数据集,需要自己完成数据加载和预处理工作。我们在这里关注 GAN 算法本身。关于自定义数据集的后续章节将详细介绍如何加载您自己的数据集。这里直接通过预先编写的 make_anime_dataset 函数获取处理后的数据集。

    # Dataset path. URL: https://drive.google.com/file/d/1lRPATrjePnX_n8laDNmPkKCtkf8j_dMD/view?usp=sharing
    img_path = glob.glob(r'C:\Users\z390\Downloads\faces\*.jpg')
    # Create dataset object, return Dataset class and size
    dataset, img_shape, _ = make_anime_dataset(img_path, batch_size, resize=64)

dataset 对象是 tf.data.Dataset 类的一个实例。已经完成了随机分散、预处理、批处理等操作,可以直接获得样本批次,img_shape 为预处理后的图像大小。

发电机

生成网络 G 由五个转置卷积层叠加而成,以实现特征图高度和宽度的逐层放大和特征图通道数的逐层减少。首先通过整形操作将长度为 100 的隐向量 z 调整为一个[ b ,1,1,100]的四维张量,并对卷积层进行转置,以便放大高度和宽度维度,减少通道数,最终得到宽度为 64,通道数为 3 的彩色图片。在每个卷积层之间插入一个 BN 层以提高训练稳定性,卷积层选择不使用偏置向量。生成器类代码实现如下:

class Generator(keras.Model):
    # Generator class
    def __init__(self):
        super(Generator, self).__init__()
        filter = 64
        # Transposed convolutional layer 1, output channel is filter*8, kernel is 4, stride is 1, no padding, no bias.
        self.conv1 = layers.Conv2DTranspose(filter*8, 4,1, 'valid', use_bias=False)
        self.bn1 = layers.BatchNormalization()
        # Transposed convolutional layer 2
        self.conv2 = layers.Conv2DTranspose(filter*4, 4,2, 'same', use_bias=False)
        self.bn2 = layers.BatchNormalization()
        # Transposed convolutional layer 3
        self.conv3 = layers.Conv2DTranspose(filter*2, 4,2, 'same', use_bias=False)
        self.bn3 = layers.BatchNormalization()
        # Transposed convolutional layer 4
        self.conv4 = layers.Conv2DTranspose(filter*1, 4,2, 'same', use_bias=False)
        self.bn4 = layers.BatchNormalization()
        # Transposed convolutional layer 5
        self.conv5 = layers.Conv2DTranspose(3, 4,2, 'same', use_bias=False)

发电机网络 G 的正向传播实现如下:

    def call(self, inputs, training=None):
        x = inputs # [z, 100]
        # Reshape to 4D tensor:(b, 1, 1, 100)
        x = tf.reshape(x, (x.shape[0], 1, 1, x.shape[1]))
        x = tf.nn.relu(x) # activation function
        # Transposed convolutional layer-BN-activation function:(b, 4, 4, 512)
        x = tf.nn.relu(self.bn1(self.conv1(x), training=training))
        # Transposed convolutional layer-BN-activation function:(b, 8, 8, 256)
        x = tf.nn.relu(self.bn2(self.conv2(x), training=training))
        # Transposed convolutional layer-BN-activation function:(b, 16, 16, 128)
        x = tf.nn.relu(self.bn3(self.conv3(x), training=training))
        # Transposed convolutional layer-BN-activation function:(b, 32, 32, 64)
        x = tf.nn.relu(self.bn4(self.conv4(x), training=training))
        # Transposed convolutional layer-BN-activation function:(b, 64, 64, 3)
        x = self.conv5(x)
        x = tf.tanh(x) # output x range -1~1

        return x

生成的网络输出大小为[ b ,64,64,3],取值范围为 1~1。

鉴别器

鉴别器网络 D 与普通分类网络相同。它接受大小为[b,64,64,3]的图像张量,并通过五个卷积层连续提取特征。卷积层的最终输出大小为[b,2,2,1024],然后通过池层 GlobalAveragePooling2D 将特征大小转换为[b,1024],最后通过一个全连通层得到二叉分类任务的概率。鉴频器网络 D 类的代码实现如下:

class Discriminator(keras.Model):
    # Discriminator class
    def __init__(self):
        super(Discriminator, self).__init__()
        filter = 64
        # Convolutional layer 1
        self.conv1 = layers.Conv2D(filter, 4, 2, 'valid', use_bias=False)
        self.bn1 = layers.BatchNormalization()
        # Convolutional layer 2
        self.conv2 = layers.Conv2D(filter*2, 4, 2, 'valid', use_bias=False)
        self.bn2 = layers.BatchNormalization()
        # Convolutional layer 3
        self.conv3 = layers.Conv2D(filter*4, 4, 2, 'valid', use_bias=False)
        self.bn3 = layers.BatchNormalization()
        # Convolutional layer 4
        self.conv4 = layers.Conv2D(filter*8, 3, 1, 'valid', use_bias=False)
        self.bn4 = layers.BatchNormalization()
        # Convolutional layer 5
        self.conv5 = layers.Conv2D(filter*16, 3, 1, 'valid', use_bias=False)
        self.bn5 = layers.BatchNormalization()
        # Global pooling layer
        self.pool = layers.GlobalAveragePooling2D()
        # Flatten feature layer
        self.flatten = layers.Flatten()
        # Binary classification layer
        self.fc = layers.Dense(1)

鉴别器 D 的正向计算过程实现如下:

    def call(self, inputs, training=None):
        # Convolutional layer-BN-activation function:(4, 31, 31, 64)
        x = tf.nn.leaky_relu(self.bn1(self.conv1(inputs), training=training))
        # Convolutional layer-BN-activation function:(4, 14, 14, 128)
        x = tf.nn.leaky_relu(self.bn2(self.conv2(x), training=training))
        # Convolutional layer-BN-activation function:(4, 6, 6, 256)
        x = tf.nn.leaky_relu(self.bn3(self.conv3(x), training=training))
        # Convolutional layer-BN-activation function:(4, 4, 4, 512)
        x = tf.nn.leaky_relu(self.bn4(self.conv4(x), training=training))
        # Convolutional layer-BN-activation function:(4, 2, 2, 1024)
        x = tf.nn.leaky_relu(self.bn5(self.conv5(x), training=training))
        # Convolutional layer-BN-activation function:(4, 1024)
        x = self.pool(x)
        # Flatten
        x = self.flatten(x)
        # Output, [b, 1024] => [b, 1]
        logits = self.fc(x)

        return logits

鉴频器的输出大小为[b,1]。类内部不使用 Sigmoid 激活函数,b 样本属于真实样本的概率可以通过 Sigmoid 激活函数得到。

培训和可视化

鉴别器根据公式( 13-1 ,鉴别器网络的目标是最大化函数 L ( DG ),使真实样本预测的概率接近 1,生成样本预测的概率接近 0。我们在 d_loss_fn 函数中实现鉴别器的误差函数,将所有真实样本标记为 1,将所有生成样本标记为 0,通过最小化对应的交叉熵损失函数来最大化函数 L(D,G)。d_loss_fn 函数实现如下:

def d_loss_fn(generator, discriminator, batch_z, batch_x, is_training):
    # Loss function for discriminator
    # Generate images from generator
    fake_image = generator(batch_z, is_training)
    # Distinguish images
    d_fake_logits = discriminator(fake_image, is_training)
    # Determine whether the image is real or not
    d_real_logits = discriminator(batch_x, is_training)
    # The error between real image and 1
    d_loss_real = celoss_ones(d_real_logits)
    # The error between generated image and 0
    d_loss_fake = celoss_zeros(d_fake_logits)
    # Combine loss
    loss = d_loss_fake + d_loss_real

    return loss

celoss_ones 函数计算当前预测概率和标签 1 之间的交叉熵损失。代码如下:

def celoss_ones(logits):
    # Calculate the cross entropy belonging to and label 1
    y = tf.ones_like(logits)
    loss = keras.losses.binary_crossentropy(y, logits, from_logits=True)
    return tf.reduce_mean(loss)
The celoss_zeros function calculates the cross entropy loss between the current predicted probability and label 0\. The code is as follows:
def celoss_zeros(logits):
    # Calculate the cross entropy that belongs to and the note is 0
    y = tf.zeros_like(logits)
    loss = keras.losses.binary_crossentropy(y, logits, from_logits=True)
     return tf.reduce_mean(loss)

发电机发电机网络的训练目标是最小化 L ( DG )目标函数。由于真实样本与发生器无关,误差函数只需要最小化$$ {E}_{z\sim {p}_z\left(\bullet \right)}\mathit{\log}\left(1-{D}_{\theta}\left({G}_{\phi }(z)\right)\right) $$。此时的交叉熵误差可以通过将生成的样本标记为 1 来最小化。需要注意的是,在误差反向传播的过程中,鉴别器也参与了计算图的构建,但在这个阶段只需要更新发电机网络参数。发生器的误差函数如下:

def g_loss_fn(generator, discriminator, batch_z, is_training):
    # Generate images
    fake_image = generator(batch_z, is_training)
    #  When training the generator network, it is necessary to force the generated image to be judged as true
    d_fake_logits = discriminator(fake_image, is_training)
    # Calculate error between generated images and 1
    loss = celoss_ones(d_fake_logits)

    return loss

网络训练在每个历元中,先从先验分布pz(∙)中随机采样隐向量,从真实数据集中随机采样真实图片,通过发生器和鉴频器计算鉴频器网络损耗,优化鉴频器网络参数 θ 。当训练发生器时,需要鉴别器来计算误差,但只计算发生器的梯度信息并更新 ϕ 。这里设置鉴频器训练次数 k = 5,设置发电机训练时间为 1。

首先,创建生成器网络和鉴别器网络,并分别创建相应的优化器,如下所示:

    generator = Generator() #  Create generator
    generator.build(input_shape = (4, z_dim))
    discriminator = Discriminator() #  Create discriminator
    discriminator.build(input_shape=(4, 64, 64, 3))
    # Create optimizers for generator and discriminator respectively
    g_optimizer = keras.optimizers.Adam(learning_rate=learning_rate, beta_1=0.5)
    d_optimizer = keras.optimizers.Adam(learning_rate=learning_rate, beta_1=0.5)

代码的主要培训部分实现如下:

    for epoch in range(epochs): #  Train epochs times
        # 1\. Train discriminator
        for _ in range(5):
            # Sample hidden vectors
            batch_z = tf.random.normal([batch_size, z_dim])
            batch_x = next(db_iter) # Sample real images
            # Forward calculation - discriminator
            with tf.GradientTape() as tape:
                d_loss = d_loss_fn(generator, discriminator, batch_z, batch_x, is_training)
            grads = tape.gradient(d_loss, discriminator.trainable_variables)
            d_optimizer.apply_gradients(zip(grads, discriminator.trainable_variables))
        # 2\. Train generator
        # Sample hidden vectors
        batch_z = tf.random.normal([batch_size, z_dim])
        batch_x = next(db_iter) # Sample real images
        # Forward calculation - generator
        with tf.GradientTape() as tape:
            g_loss = g_loss_fn(generator, discriminator, batch_z, is_training)
        grads = tape.gradient(g_loss, generator.trainable_variables)
        g_optimizer.apply_gradients(zip(grads, generator.trainable_variables))

每 100 个时期,执行一次图像生成测试。从先验分布中随机抽取隐藏向量,发送给生成器,得到生成的图片,保存为文件。

如图 13-8 所示,为 DCGAN 模型在训练过程中保存的生成图片样本。可以观察到,大部分图片主体清晰,色彩鲜艳,图片多样性丰富,生成的图片接近数据集中的真实图片。同时可以发现,生成的图片仍有少量受损,人眼无法识别图片主体。为了获得图 13-8 所示的图像生成效果,需要精心设计网络模型结构,微调网络超参数。

img/515226_1_En_13_Fig8_HTML.jpg

图 13-8

DCGAN 图像生成效果

13.4 氮化镓变体

在最初的 GAN 论文中,Ian Goodfellow 从理论层面分析了 GAN 网络的收敛性,并在多个经典图像数据集上测试了图像生成的效果,如图 13-9 所示,其中图 13-9 (a)是 MNIST 数据集,图 13-9 (b)是多伦多人脸数据集,图 13-9 (c)和图 13-9 (d)是 CIFAR1

img/515226_1_En_13_Fig9_HTML.jpg

图 13-9

原始 GAN 图像生成效果[1]

可以看出,最初的 GAN 模型在图像生成效果方面并不突出,与的差异并不明显。这个时候并没有表现出它强大的分布近似能力。然而,由于 GAN 在理论上相对较新,有许多需要改进的地方,这极大地激发了学术界的研究兴趣。在接下来的几年里,GAN 的研究如火如荼,也取得了实质性的进展。接下来,我们将介绍几种重要的 GAN 变体。

13.4.1 DCGAN

最初的 GAN 网络主要基于全连通层来实现生成器 G 和鉴别器 d,由于图片的高维性和网络参数的海量性,训练效果并不优秀。DCGAN [2]提出了用转置卷积层实现的生成器网络,和用普通卷积层实现的鉴别器网络,大大减少了网络参数的数量,大大提高了图像生成的效果,显示出 GAN 模型在图像生成方面具有优于 VAE 模型的潜力。此外,DCGAN 作者还提出了一系列经验性的 GAN 网络训练技术,这些技术被证明有利于 GAN 网络的稳定训练。我们已经使用 DCGAN 模型来完成动画化身的实际图片生成。

InfoGAN

InfoGAN [3]试图用一种无监督的方式来学习输入 x 的可解释隐向量 z 的可解释表示,即希望隐向量 z 能够对应数据的语义特征。例如,对于 MNIST 手写的数字图片,我们可以认为数字的类别、字体大小和书写风格是图片的隐藏变量。我们希望模型可以学习这些解开的可解释的特征表示方法,以便可以人工控制隐藏变量来生成指定内容的样本。对于 CelebA 名人照片数据集,希望模型可以分离发型、眼镜佩戴情况、面部表情等特征,生成指定形状的人脸图像。

解开可解释特征的好处是什么?它可以使神经网络更具可解释性。比如 z 包含了一些单独的可解释特征,那么我们只需要改变这个位置的特征就可以获得不同语义的生成数据。如图 13-10 所示,减去“戴眼镜的男人”和“不戴眼镜的男人”的隐向量,再加上“不戴眼镜的女人”的隐向量,就可以生成一张“戴眼镜的女人”的图片。

img/515226_1_En_13_Fig10_HTML.jpg

图 13-10

分离特征的示意图[3]

13.4.3 摆明

CycleGAN [4]是由朱俊彦提出的用于图像风格转换的无监督算法。因为算法清晰简单,效果更好,所以这部作品获得了很多好评。CycleGAN 的基本假设是,如果你从 A 图切换到 B 图,再从 B 图切换到 A ',那么 A '应该和 A 是同一个图,所以 CycleGAN 除了设置标准的 GAN 损失项,还增加了循环一致性损失,保证 A '尽可能接近 A。CycleGAN 图片的转换效果如图 13-11 所示。

img/515226_1_En_13_Fig11_HTML.png

图 13-11

图像转换效果[4]

13.4.4 WGAN

甘的训练问题一直为人诟病,容易出现训练不收敛和模式崩溃的现象。WGAN [5]从理论层面分析了原 GAN 使用 JS 散度的缺陷,并提出 Wasserstein 距离可以用来解决这个问题。在 WGAN-GP [6]中,作者提出通过添加梯度惩罚项,从工程层面很好地实现了 WGAN 算法,证实了 WGAN 训练稳定性的优势。

13.4.5 同等氮化镓

从 GAN 诞生到 2017 年底,GAN 动物园已经收集了超过 214 个 GAN 网络变种。这些 GAN 变体或多或少地提出了一些创新,但来自 Google Brain 的几位研究人员在一篇论文中提供了另一个观点[7]:没有证据表明我们测试的 GAN 变体算法一直比最初的 GAN 论文更好。在该论文中,对这些 GAN 变体进行了公正和全面的比较。在计算资源充足的情况下,发现几乎所有的 GAN 变体都可以达到相似的性能(FID 分数)。这项工作提醒业界这些 GAN 变体是否具有本质上的创新性。

甘的自我关注

注意机制在自然语言处理中得到了广泛的应用。自我注意 GAN (SAGAN) [8]借鉴注意机制,提出了一种基于自我注意机制的 GAN 变体。萨根提高了画面的保真度指数:盗梦空间得分从 36.8 提高到 52.52,弗雷歇盗梦空间距离从 27.62 提高到 18.65。从图像生成的效果来看,萨根的突破非常显著,它也激发了业界对自我关注机制的关注。

img/515226_1_En_13_Fig12_HTML.jpg

图 13-12

萨根的注意机制[8]

比根

在 SAGAN 的基础上,BigGAN [9]试图将 GAN 的训练扩展到大规模,使用正交正则化等技术来保证训练过程的稳定性。BigGAN 的意义在于启发人们 GAN 网络的训练也可以受益于大数据和大计算能力。比根影像生成的效果达到了前所未有的高度:盗梦空间评分记录提升至 166.5(提升 52.52);弗雷歇起始距离下降到 7.4,减少了 18.65。如图 13-13 所示,图像分辨率可达 512×512,图像细节极其逼真。

img/515226_1_En_13_Fig13_HTML.jpg

图 13-13

比根生成的图像

13.5 纳什均衡

现在我们从理论层面分析,通过博弈学习的训练方法,生成器 G 和判别器 D 会达到什么均衡状态。具体来说,我们将探讨以下两个问题:

  • 修正 G,D 会收敛到什么最优状态D

  • D 达到最优状态D后,G 会收敛到什么状态?

我们先通过一维正态分布的例子xr~pr(∙)进行直观的解释。如图 13-14 所示,黑色虚线代表真实的数据分布pr(∙),为正态分布 N ( μσ 2 ),绿色实线代表分布xf~蓝色虚线表示鉴频器的决策边界曲线。图 13-14 (a)、(b)、(c)和(d)分别表示发电机网络的学习轨迹。在初始状态下,如图 13-14 (a)所示,pg(∙)的分布与pr(∙)有很大的不同,鉴别器很容易学习到一个清晰的判定边界,就是图 13-14(a) 中的蓝色虚线,它设定了随着生成网络的分布pg(∙)越来越接近真实分布pr(∙),鉴别器区分真假样本变得越来越困难,如图 13.14(b)(c)所示。最后,当发生器网络学习到的分布pg(∙) =pr(∙)时,从发生器网络提取的样本非常逼真,鉴别器无法区分其中的差别,即判定样本真假的概率相等,如图 13-14( d)所示。

img/515226_1_En_13_Fig14_HTML.jpg

图 13-14

纳什均衡[1]

这个例子直观地解释了 GAN 网络的训练过程。

鉴别器状态

现在我们来推导第一个问题。回顾 GAN 的损失函数:

$$ L\left(G,D\right)={\int}_x{p}_r(x) loglog\ \left(D(x)\right)\ dx+{\int}_z{p}_z(z) loglog\ \left(1-D\left(g(z)\right)\right)\ dz $$

$$ ={\int}_x{p}_r(x) loglog\ \left(D(x)\right)+{p}_g(x) loglog\ \left(1-D(x)\right)\ dx $$

对于鉴别器 D,优化目标是最大化 L ( GD )函数,需要找到以下函数的最大值:

$$ {f}_{\theta }={p}_r(x) loglog\ \left(D(x)\right)+{p}_g(x) loglog\ \left(1-D(x)\right) $$

其中 θ 为鉴频器 d 的网络参数。

让我们考虑更一般的函数 f θ 的最大值:

$$ f(x)= Aloglog\ x+ Bloglog\ \left(1-x\right) $$

需要函数 f ( x )的最大值。考虑 f ( x )的导数:

$$ \frac{df(x)}{dx}=A\frac{1}{lnln\ 10\ }\frac{1}{x}-B\frac{1}{lnln\ 10\ }\frac{1}{1-x} $$

$$ =\frac{1}{lnln\ 10\ }\left(\frac{A}{x}-\frac{B}{1-x}\right) $$

$$ =\frac{1}{lnln\ 10\ }\frac{A-\left(A+B\right)x}{x\left(1-x\right)} $$

$$ \frac{df(x)}{dx}=0 $$,我们可以找到 f ( x )函数的极值点:

$$ x=\frac{A}{A+B} $$

因此,可以知道fθ函数的极值点也是:

$$ {D}_{\theta }=\frac{p_r(x)}{p_r(x)+{p}_g(x)} $$

也就是说,当鉴频器网络Dθ处于$ {D}_{\theta^{\ast }} $状态时, f * θ * 函数取最大值, L ( GD )函数也取最大值。

现在回到最大化 L ( GD )的问题,最大值点 L ( GD )在:

$$ {D}^{\ast }=\frac{A}{A+B}=\frac{p_r(x)}{p_r(x)+{p}_g(x)} $$

这也是 D θ 的最优状态D

发电机状态

在导出第二个问题之前,我们先引入另一个类似于 KL 散度的分布距离度量:JS 散度,它被定义为 KL 散度的组合:

$$ {D}_{KL}\left(p\Big\Vert q\right)={\int}_xp(x) loglog\ \frac{p(x)}{q(x)}\ dx $$

$$ {D}_{JS}\left(p\Big\Vert q\right)=\frac{1}{2}{D}_{KL}\left(p\Big\Vert \frac{p+q}{2}\right)+\frac{1}{2}{D}_{KL}\left(q\Big\Vert \frac{p+q}{2}\right) $$

JS 发散克服了 KL 发散的不对称性。

当 D 达到最优状态D时,我们来考虑一下此时 p rp g 的 JS 散度:

$$ {D}_{JS}\left({p}_r\Big\Vert {p}_g\right)=\frac{1}{2}{D}_{KL}\left({p}_r\Big\Vert \frac{p_r+{p}_g}{2}\right)+\frac{1}{2}{D}_{KL}\left({p}_g\Big\Vert \frac{p_r+{p}_g}{2}\right) $$

根据 KL 散度的定义:

$$ {D}_{JS}\left({p}_r\Big\Vert {p}_g\right)=\frac{1}{2}\left( loglog\ 2+{\int}_x{p}_r(x) loglog\ \frac{p_r(x)}{p_r+{p}_g(x)}\ dx\right) $$

$$ +\frac{1}{2}\left( loglog\ 2+{\int}_x{p}_g(x) loglog\ \frac{p_g(x)}{p_r+{p}_g(x)}\ dx\right) $$

结合常数项,我们可以得到:

$$ {D}_{JS}\left({p}_r\Big\Vert {p}_g\right)=\frac{1}{2}\left( loglog\ 2+ loglog\ 2\ \right) $$

$$ +\frac{1}{2}\left({\int}_x{p}_r(x) loglog\ \frac{p_r(x)}{p_r+{p}_g(x)}\ dx+{\int}_x{p}_g(x) loglog\ \frac{p_g(x)}{p_r+{p}_g(x)}\ dx\right) $$

那就是:

$$ {D}_{JS}\left({p}_r\Big\Vert {p}_g\right)=\frac{1}{2}\left( loglog\ 4\ \right) $$

$$ +\frac{1}{2}\left({\int}_x{p}_r(x) loglog\ \frac{p_r(x)}{p_r+{p}_g(x)}\ dx+{\int}_x{p}_g(x) loglog\ \frac{p_g(x)}{p_r+{p}_g(x)}\ dx\right) $$

考虑当网络达到D时,此时的损失函数为:

$$ L\left(G,{D}^{\ast}\right)={\int}_x{p}_r(x) loglog\ \left({D}^{\ast }(x)\right)+{p}_g(x) loglog\ \left(1-{D}^{\ast }(x)\right)\ dx $$

$$ ={\int}_x{p}_r(x) loglog\ \frac{p_r(x)}{p_r+{p}_g(x)}\ dx+{\int}_x{p}_g(x) loglog\ \frac{p_g(x)}{p_r+{p}_g(x)}\ dx $$

因此,当鉴频器网络达到DDJS(prpG)和 L ( GD

$$ {D}_{JS}\left({p}_r\Big\Vert {p}_g\right)=\frac{1}{2}\left( loglog\ 4+L\left(G,{D}^{\ast}\right)\right) $$

那就是:

$$ L\left(G,{D}^{\ast}\right)=2{D}_{JS}\left({p}_r\Big\Vert {p}_g\right)-2 loglog\ 2 $$

对于发电机网络 G,考虑到 JS 发散的性质,训练目标为 L ( GD ):

$$ {D}_{JS}\left({p}_r\Big\Vert {p}_g\right)\ge 0 $$

因此, L ( GD)只有在DJS(prpG)= 0(此时 p

$$ L\left({G}^{\ast },{D}^{\ast}\right)=-2 loglog\ 2 $$

此时,发电机网络的状态G为:

$$ {p}_g={p}_r $$

G的学习分布 p g 与真实分布 p r 一致,网络达到一个平衡点。此时:

$$ {D}^{\ast }=\frac{p_r(x)}{p_r(x)+{p}_g(x)}=0.5 $$

纳什均衡点

通过前面的推导,我们可以得出结论,生成网络 G 最终会收敛到真实分布,即:pG=pr

此时生成的样本和真实样本来自同一个分布,真假难辨。鉴别器有相同的概率判断为真或假,即:

$$ D\left(\bullet \right)=0.5 $$

这时,损失函数是

$$ L\left({G}^{\ast },{D}^{\ast}\right)=-2 loglog\ 2 $$

13.6 甘训练难度

虽然 GAN 网络可以从理论层面学习数据的真实分布,但在工程实现中经常出现 GAN 网络训练困难的问题,主要体现在 GAN 模型对超参数更敏感,需要仔细选择能使模型工作的超参数。超参数设置也容易出现模式崩溃。

超参数灵敏度

超参数敏感性是指网络的结构设置、学习速率、初始化状态等超参数对网络的训练过程有较大的影响。少量的超参数调整可能会导致完全不同的网络训练结果。图 13-15 (a)显示了从 GAN 模型的良好训练中获得的生成样本。图 13-15 (b)中的网络没有使用批量归一化层等设置,导致 GAN 网络训练不稳定,无法收敛。生成的样本互不相同。真实样本差距很大。

img/515226_1_En_13_Fig15_HTML.png

图 13-15

超参数敏感示例[5]

为了训练好 GAN 网络,DCGAN 论文作者提出不使用池化层,不使用全连通层,多使用批量归一化层,生成网络中的激活函数要使用 ReLU。最后一层的激活函数应该是 Tanh,鉴别器网络的激活函数应该使用 LeakyLeLU 等一系列经验训练技术。然而,这些技术只能在一定程度上避免训练不稳定的现象,并没有从理论层面解释为什么会有训练困难以及如何解决训练不稳定的问题。

模型折叠

模式崩溃是指模型生成的样本单一,多样性差的现象。由于鉴别器只能识别单个样本是否是从真实分布中采样的,并且不对样本多样性施加显式约束,所以生成模型可能倾向于在真实分布的部分区间中生成少量高质量样本,而不学习所有真实分布。模型坍塌现象在 GAN 中较为常见,如图 13-16 所示。在训练过程中,通过可视化生成器网络的样本可以观察到,生成的图片类型非常单一,生成器网络总是倾向于生成某种单一风格的样本来忽悠鉴别者。

img/515226_1_En_13_Fig16_HTML.jpg

图 13-16

图像生成–模型折叠[10]

图 13-17 显示了另一个直观理解模式崩溃的例子。第一行是无模式崩溃的发电机网络的训练过程,最后一列是真实分布,即 2D 高斯混合模型。第二行显示了模型折叠的发电机网络的训练过程。最后一栏是真实分布。可以看出,真实的分布是八个高斯模型的混合。模型崩溃发生后,发电机网络总是趋向于接近真实分布的一个狭窄区间,如图 13-17 第二行的前六列所示。这个区间的样本在鉴别器中往往能以较高的概率判断为真实样本,从而欺骗鉴别器。但这种现象并不是我们希望看到的。我们希望发电机网络能近似真实分布,而不是真实分布的某一部分。

img/515226_1_En_13_Fig17_HTML.jpg

图 13-17

模型坍塌示意图[10]

那么如何解决 GAN 的训练问题,使 GAN 能像普通神经网络一样被更稳定地训练呢?WGAN 模型提供了一个解决方案。

13.7 WGAN 原则

WGAN 算法从理论层面分析了 GAN 训练不稳定的原因,并提出了有效的解决方案。那么是什么让 GAN 训练如此不稳定呢?WGAN 提出非重叠分布 pq 上的 JS 散度梯度面始终为 0。如图 13-18 所示,当分布 pq 不重叠时,JS 散度的梯度值始终为 0,导致梯度消失现象;所以参数长期无法更新,网络无法收敛。

img/515226_1_En_13_Fig18_HTML.jpg

图 13-18

p 和 q 分布示意图

接下来我们将详细阐述 JS 发散的缺陷以及如何解决这个缺陷。

13.7.1 JS 发散劣势

为了避免过多的理论推导,我们用一个简单的分布例子来说明 JS 发散的缺陷。考虑两个完全不重叠的分布 p 和 q(θ≠0),其中分布 p 为:

$$ \forall \left(x,y\right)\in p,x=0,y\sim U\left(0,1\right) $$

并且 q 的分布为:

$$ \forall \left(x,y\right)\in q,x=\theta, y\sim U\left(0,1\right) $$

其中 θR ,当 θ = 0 时,分布 p 和 q 重叠,两者相等;当 θ ≠ 0 时,分布 p 和 q 不重叠。

让我们用 θ 来分析前面的分布 p 和 q 之间的 JS 散度的变化。根据 KL 散度和 JS 散度的定义,计算出 JS 散度DJS(pq)当 θ = 0:

$$ {D}_{KL}\left(p\Big\Vert q\right)={\sum}_{x=0,y\sim U\left(0,1\right)}1\cdotp loglog\ \frac{1}{0}=+\infty $$

$$ {D}_{KL}\left(q\Big\Vert p\right)={\sum}_{x=\theta, y\sim U\left(0,1\right)}1\cdotp loglog\ \frac{1}{0}=+\infty $$

$$ {D}_{JS}\left(p\Big\Vert q\right)=\frac{1}{2}\left({\sum}_{x=0,y\sim U\left(0,1\right)}1\cdotp loglog\ \frac{1}{1/2}+{\sum}_{x=0,y\sim U\left(0,1\right)}1\cdotp loglog\ \frac{1}{1/2}\ \right)= loglog\ 2 $$

θ = 0 时,两个分布完全重叠。此时 JS 散度和 KL 散度都达到最小值,为 0:

$$ {D}_{KL}\left(p\Big\Vert q\right)={D}_{KL}\left(q\Big\Vert p\right)={D}_{JS}\left(p\Big\Vert q\right)=0 $$

由前面的推导,我们可以得到DJS(pq)与 θ 的走势:

$$ {D}_{JS}\left(p\Big\Vert q\right)=\Big{ loglog\ 2\kern0.5em \theta \ne 0\ 0\ \theta =0 $$

换句话说,当两个分布完全不重叠时,无论分布之间的距离如何,JS 散度都是一个恒定值 log log 2,那么 JS 散度将无法产生有效的梯度信息。当两个分布重叠时,JS 散度平滑变化,产生有效的梯度信息。当两个分布完全重合时,JS 散度取最小值 0。如图 13-19 所示,红色曲线将两个正态分布分开。由于两个分布不重叠,生成样本位置的梯度值始终为 0,生成网络的参数无法更新,导致网络训练困难。

img/515226_1_En_13_Fig19_HTML.jpg

图 13-19

JS 散度的梯度消失[5]

因此,当分布 p 和 q 不重叠时,JS 散度不能平滑地测量分布之间的距离。结果,在该位置不能产生有效的梯度信息,GAN 训练不稳定。为了解决这个问题,我们需要使用更好的分布距离度量,这样即使分布 p 和 q 不重叠,它也能平滑地反映分布之间的真实距离变化。

13.7.2 远程

WGAN 论文发现 JS 发散导致 GAN 训练不稳定,并引入了一种新的分布距离度量方法:Wasserstein 距离,也称为地球移动器距离(EM distance),它代表了将一种分布转换为另一种分布的最小成本。它被定义为:

$$ W\left(p,q\right)={E}_{\left(x,y\right)\sim \gamma}\left[\left\Vert x-y\right\Vert \right] $$

其中∏( pq )是由分布 pq 组合而成的所有可能的联合分布的集合。对于每个可能的联合分布γ∩∏(pq ),计算期望距离 E ( xy)∩γx-y不同的联合分布 γ 有不同的期望 E ( xy)∩γxy】,这些期望的下确界定义为分布 pq【的瓦瑟斯坦距离

继续考虑图 13-18 中的例子,我们直接给出分布 pq 之间的 EM 距离的表达式:

$$ W\left(p,q\right)=\left|\theta \right| $$

绘制 JS 散度和 EM 距离曲线,如图 13-20 所示。可以看出,JS 散度在 θ = 0 处不连续,其他位置导数都为 0,EM 距离总能产生有效的导数信息。所以 EM 距离比 JS 散度更适合指导 GAN 网络的训练。

img/515226_1_En_13_Fig20_HTML.png

图 13-20

JS 散度和 EM 距离随 θ WGAN-GP 的变化曲线

考虑到几乎不可能遍历所有的联合分布 γ 来计算距离期望 E ( xy)∩γxy】of‖xy 所以计算发电机网络的分布 p gW(prp g )之间的距离是不现实的。 基于 Kantorovich-Rubinstein 对偶,WGAN 作者将直接计算的W(prp g )转化为:

$$ W\left({p}_r,{p}_g\right)=\frac{1}{K}{E}_{x\sim {p}_r}\ \left[f(x)\right]-{E}_{x\sim {p}_g}\left[f(x)\right] $$

其中 sup {∙}表示集合的上确界,fLK表示满足 K 阶李普希兹连续性的函数f:RR,即

$$ \left|f\left({x}_1\right)-f\left({x}_2\right)\right|\le K\bullet \left|{x}_1-{x}_2\right| $$

因此,我们使用判别网络Dθ(x)来参数化 f ( x )函数,在 D θ 满足 1-Lipschitz 约束的条件下,即 K = 1,此时:

$$ W\left({p}_r,{p}_g\right)={E}_{x\sim {p}_r}\ \left[{D}_{\theta }(x)\right]-{E}_{x\sim {p}_g}\left[{D}_{\theta }(x)\right] $$

因此,求解W(prp g )的问题可以转化为:

$$ {E}_{x\sim {p}_r}\left[{D}_{\theta }(x)\right]-{E}_{x\sim {p}_g}\left[{D}_{\theta }(x)\right] $$

这是鉴别器 D 的优化目标.鉴别网络函数Dθ(x)需要满足 1-Lipschitz 约束:

$$ {\nabla}_{\hat{x}}D\left(\hat{x}\right)\le I $$

在 WGAN-GP 论文中,作者提出增加梯度罚函数法来强制鉴频器网络满足一阶-Lipschitz 函数约束,并且作者发现当梯度值约束在 1 左右时工程效果更好,因此梯度罚项定义为:

$$ GP\triangleq {E}_{\hat{x}\sim {P}_{\hat{x}}}\left[{\left({\left\Vert {\nabla}_{\hat{x}}D\left(\hat{x}\right)\right\Vert}_2-1\right)}²\right] $$

因此,WGAN 鉴频器 D 的训练目标是:

img/515226_1_En_13_Figba_HTML.png

其中$$ \hat{x} $$来自于 x * r x f * 的线性差:

$$ \hat{x}=t{x}_r+\left(1-t\right){x}_f,t\in \left[0,1\right] $$

鉴别器 D 的目标是使上述误差 L ( GD )最小化,即迫使 EM 距离$$ {E}_{x_r\sim {p}_r}\left[D\left({x}_r\right)\right]-{E}_{x_f\sim {p}_g}\left[D\left({x}_f\right)\right] $$尽可能大,$$ {\left\Vert {\nabla}_{\hat{x}}D\left(\hat{x}\right)\right\Vert}_2 $$接近 1。

WGAN 发电机 G 的培训目标是:

img/515226_1_En_13_Figa_HTML.png

即发电机的分布pg与真实分布 p r 之间的 EM 距离尽可能小。考虑到$ {E}_{x_r\sim {p}_r}\left[D\left({x}_r\right)\right] $与发电机无关,发电机的培养目标缩写为:

$$ \underset{\phi }{\mathit{\min}}\ L\left(G,D\right)=-{E}_{x_f\sim {p}_g}\left[D\left({x}_f\right)\right] $$

$$ =-{E}_{z\sim {p}_z\left(\bullet \right)}\left[D\left(G(z)\right)\right] $$

从实现的角度来看,鉴频器网络 D 的输出不需要添加 Sigmoid 激活函数。这是因为最初版本的鉴别器是一个二进制分类网络,增加了 Sigmoid 函数来获得属于某一类别的概率;而 WGAN 中的鉴频器用于测量发电机网络的分布 p g 与真实分布 p r 之间的电磁距离。属于实数空间,不需要添加 Sigmoid 激活函数。在计算误差函数时,WGAN 也没有对数函数。在训练 WGAN 时,WGAN 作者推荐使用 RMSProp 或 SGD 等没有动量的优化器。

WGAN 从理论层面发现了原 GAN 容易出现训练不稳定的原因,并给出了新的距离度量和工程实现方案,取得了良好的效果。WGAN 也在一定程度上缓解了模型崩溃的问题,使用 WGAN 的模型不容易出现模型崩溃。需要注意的是,WGAN 一般不会提高模型的生成效果,只是保证模型训练的稳定性。当然,训练的稳定性也是模特表现好的前提。如图 13-21 所示,DCGAN 原版在不使用 BN 层等设置的情况下,显示训练不稳定。在相同的设置下,使用 WGAN 训练鉴频器可以避免这种现象,如图 13-22 所示。

img/515226_1_En_13_Fig22_HTML.jpg

图 13-22

无 BN 层的 WGAN 发生器效应[5]

img/515226_1_En_13_Fig21_HTML.jpg

图 13-21

无 BN 层的 DCGAN 发生器效应[5]

13.8 动手操作 WGAN-GP

WGAN-GP 模型可以在原始 GAN 实现的基础上稍加修改。WGAN-GP 模型的鉴别器 D 的输出不再是样本类别的概率,输出不需要加入 Sigmoid 激活函数。同时,我们需要添加一个梯度惩罚项,如下所示:

def gradient_penalty(discriminator, batch_x, fake_image):
    # Gradient penalty term calculation function
    batchsz = batch_x.shape[0]

    # Each sample is randomly sampled at t for interpolation
    t = tf.random.uniform([batchsz, 1, 1, 1])
    # Automatically expand to the shape of x, [b, 1, 1, 1] => [b, h, w, c]
    t = tf.broadcast_to(t, batch_x.shape)
    # Perform linear interpolation between true and false pictures
    interplate = t * batch_x + (1 - t) * fake_image
    # Calculate the gradient of D to interpolated samples in a gradient environment
    with tf.GradientTape() as tape:
        tape.watch([interplate]) # Add to the gradient watch list
        d_interplote_logits = discriminator(interplate)
    grads = tape.gradient(d_interplote_logits, interplate)

    # Calculate the norm of the gradient of each sample:[b, h, w, c] => [b, -1]
    grads = tf.reshape(grads, [grads.shape[0], -1])
    gp = tf.norm(grads, axis=1) #[b]
    # Calculate the gradient penalty
    gp = tf.reduce_mean( (gp-1.)**2 )

    return gp

WGAN 鉴频器的损耗函数计算与 GAN 不同。WGAN 直接最大化真实样本的输出值,最小化生成样本的输出值。没有交叉熵计算过程。代码实现如下:

def d_loss_fn(generator, discriminator, batch_z, batch_x, is_training):
    # Calculate loss function for D
    fake_image = generator(batch_z, is_training) # Generated sample
    d_fake_logits = discriminator(fake_image, is_training) # Output of generated sample
    d_real_logits = discriminator(batch_x, is_training) # Output of real sample
    # Calculate gradient penalty term
    gp = gradient_penalty(discriminator, batch_x, fake_image)
    # WGAN-GP loss function of D. Here is not to calculate the cross entropy, but to directly maximize the output of the positive sample
    # Minimize the output of false samples and the gradient penalty term
    loss = tf.reduce_mean(d_fake_logits) - tf.reduce_mean(d_real_logits) + 10\. * gp

    return loss, gp

WGAN 发生器 G 的损失函数只需要最大化鉴别器 D 中生成样本的输出值,同样没有交叉熵计算步骤。代码实现如下:

def g_loss_fn(generator, discriminator, batch_z, is_training):
    # Generator loss function
    fake_image = generator(batch_z, is_training)
    d_fake_logits = discriminator(fake_image, is_training)
    # WGAN-GP G loss function. Maximize the output value of false samples
    loss = - tf.reduce_mean(d_fake_logits)

    return loss

与原始 GAN 相比,WGAN 的主要训练逻辑基本相同。WGAN 的鉴频器 D 的作用是测量电磁距离。所以鉴别器越精确,对发生器越有利。对于一个步长,鉴别器 D 可以被训练多次,而发生器 G 可以被训练一次,以获得更精确的 EM 距离估计。

13.9 参考

  1. I. Goodfellow、J. Pouget-Abadie、M. Mirza、B. Xu、D. Warde-Farley、S. Ozair、a .库维尔和 Y. Bengio,《生成性对抗性网络》,神经信息处理系统进展 27 ,Z. Ghahramani、M. Welling、C. Cortes、N. D. Lawrence 和 K. Q. Weinberger,Curran Associates,Inc .,2014 年,第 2672-2680 页。

  2. A.拉德福德,l .梅斯和 s .钦塔拉,深度卷积生成对抗网络的无监督表示学习,2015 年。

  3. X.陈,y .段,R. Houthooft,j .舒尔曼,I. Sutskever 和 P. Abbeel,“信息根:通过信息最大化生成对抗网络的可解释表示学习”,神经信息处理系统进展 29 ,D. D. Lee,M. Sugiyama,U. V. Luxburg,I. Guyon 和 R. Garnett,Curran Associates,Inc .,2016 年,第 2172-2180 页。

  4. J.-Y. Zhu,T. Park,P. Isola 和 A. A. Efros,“使用循环一致对抗网络的不成对图像到图像翻译”,计算机视觉(),2017 IEEE 国际会议关于,2017。

  5. 米(meter 的缩写))Arjovsky,S. Chintala 和 L. Bottou,“Wasserstein 生成对抗网络”,第 34 届机器学习国际会议论文集,澳大利亚悉尼国际会议中心,2017 年。

  6. I. Gulrajani,F. Ahmed,M. Arjovsky,V. Dumoulin 和 A. C .库维尔,“Wasserstein GANs 的改进训练”,神经信息处理系统进展 30 ,I. Guyon,U. V. Luxburg,S. Bengio,H. Wallach,R. Fergus,S. Vishwanathan 和 R. Garnett,Curran Associates,Inc .,2017 年,第 5767-5777 页。

  7. 米(meter 的缩写))Lucic、K. Kurach、M. Michalski、O. Bousquet 和 S. Gelly,《甘人生来平等吗?大规模研究”,第 32 届神经信息处理系统国际会议论文集,美国,2018。

  8. H.张,I. Goodfellow,d .,A. Odena,“自我注意生成对抗网络”,第 36 届机器学习国际会议论文集,美国加州长滩,2019。

  9. A.Brock,J. Donahue 和 K. Simonyan,“高保真自然图像合成的大规模 GAN 训练”,国际学习表示会议,2019。

  10. 长度梅茨,b .普尔,d .普法乌和 j .索尔-迪克斯坦,“展开的生成性对抗性网络”, CoRR, abs/1611.02163,2016。

*

十四、强化学习

人工智能=深度学习+强化学习

—大卫·西尔弗

强化学习是除了监督学习和非监督学习之外的另一个机器学习领域。它主要使用智能体与环境进行交互,以便学习能够取得良好效果的策略。与监督学习不同,强化学习的动作没有明确的标签信息。它只有来自环境反馈的奖励信息。通常具有一定的滞后性,用于反映动作的“好与坏”。

随着深度神经网络的兴起,强化学习领域也蓬勃发展。2015 年,英国公司 DeepMind 提出了一种基于深度神经网络的强化学习算法 DQN,在《太空入侵者》、《砖块》、《乒乓球》等 49 款雅达利游戏中取得了人类水平的性能[1]。2017 年,DeepMind 提出的 AlphaGo 程序以 3:0 的比分击败了当时排名第一的围棋选手柯洁。同年,AlphaGo 的新版本 AlphaGo Zero 用没有任何人类知识的自玩训练 100:0 击败 alpha go[3]。2019 年,OpenAI Five 计划 2:0 击败 Dota2 世界冠军 OG 战队。虽然这个游戏的游戏规则受到限制,但是对于 Dota2 来说,它需要一个超个人的智力水平。凭借一场出色的团队合作比赛,这场胜利无疑坚定了人类对 AGI 的信念。

本章将介绍强化学习中的主流算法,包括《太空入侵者》等游戏中达到类人水平的 DQN 算法,以及 Dota2 获胜的 PPO 算法。

14.1 不久后见

强化学习算法的设计不同于传统的监督学习,包含了大量新的数学公式推导。在进入强化学习算法的学习过程之前,让我们先通过一个简单的例子来体验一下强化学习算法的魅力。

在这一部分,你不需要掌握每一个细节,但应该注重直观体验,获得第一印象。

平衡杆游戏

平衡杆游戏系统包含三个对象:滑轨、小车和杆子。如图 14-1 所示,小车可以在滑轨上自由移动,杆的一侧通过轴承固定在小车上。在初始状态下,小车位于滑轨的中心,杆立在小车上。代理通过控制小车的左右移动来控制杆的平衡。当拉杆与垂线的夹角大于一定角度或小车偏离滑轨中心一定距离后,视为比赛结束。游戏时间越长,游戏给予的奖励越多,代理的控制水平也越高。

为了简化环境的表示,我们直接将高层环境特征向量 s 作为智能体的输入。它总共包含四个高级特征,即汽车位置、汽车速度、杆角度和杆速度。代理的输出动作 a 是向左或向右移动。应用到平衡杆系统的动作会生成一个新的状态,系统也会返回一个奖励值。这个奖励值可以简单记为 1,也就是瞬间增加 1 个单位时间。在每个时间戳 t ,代理通过观察环境状态 s t 产生一个动作 a t 。环境收到动作后,状态变为st+1并返回奖励 r t

img/515226_1_En_14_Fig1_HTML.png

图 14-1

平衡杆游戏系统

健身房平台

在强化学习中,机器人可以直接与真实环境进行交互,更新的环境状态和奖励可以通过传感器获得。但是,考虑到真实环境的复杂性和实验成本,一般倾向于在虚拟软件环境中测试算法,然后考虑迁移到真实环境中。

强化学习算法可以通过大量的虚拟游戏环境进行测试。为了方便研究人员调试和评估算法模型,OpenAI 开发了一个健身房游戏交互平台。用户只需少量代码,就可以使用 Python 语言完成游戏创作和交互。很方便。

OpenAI 健身房环境包括很多简单经典的控制游戏,比如平衡杆、过山车(图 14-2 )。它还可以调用 Atari 游戏环境和复杂的 MuJoCo 物理环境模拟器(图 14-4 )。在雅达利的游戏环境中,有大家熟悉的迷你游戏,比如太空入侵者、碎砖机(图 14-3 )和赛车。这些游戏虽然规模不大,但对决策能力要求很高,非常适合评估算法的智能。

img/515226_1_En_14_Fig4_HTML.jpg

图 14-4

步行机器人

img/515226_1_En_14_Fig3_HTML.jpg

图 14-3

碎砖机

img/515226_1_En_14_Fig2_HTML.png

图 14-2

过山车

目前你在 Windows 平台上安装健身房环境可能会遇到一些问题,因为有些软件库对 Windows 平台并不友好。建议您使用 Linux 系统进行安装。本章用到的平衡杆游戏环境在 Windows 平台上可以完美使用,其他复杂的游戏环境就不一定了。

运行 pip install gym 命令只会安装 gym 环境的基本库,而平衡杆游戏已经包含在基本库中了。如果您需要使用 Atari 或 MuJoCo 模拟器,则需要额外的安装步骤。让我们以安装 Atari 模拟器为例:

git clone https://github.com/openai/gym.git # Pull the code
cd gym # Go to directory
pip install -e '.[all]' # Install Gym

一般来说,创建一个游戏并在健身房环境中进行交互主要包括五个步骤:

  1. 创建一个游戏。通过 gym.make(name)可以创建一个指定名称的游戏,并返回游戏对象 env。

  2. 重置游戏状态。一般游戏环境都有一个初始状态。您可以通过调用 env.reset()来重置游戏状态,并返回到游戏的初始状态观察。

  3. 显示游戏画面。每个时间戳的游戏画面可以通过调用 env.render()来显示,一般用于测试。在训练期间渲染图像会引入一定的计算成本,因此在训练期间可能不会显示图像。

  4. 与游戏环境互动。动作可以通过 env.step(action)执行,系统可以返回新的状态观察、当前奖励、游戏结束标志 done 和附加信息载体。通过循环这个步骤,你可以继续与环境互动,直到游戏结束。

  5. 破坏游戏。只需调用 env.close()。

下面演示了平衡杆游戏 CartPole-v1 的一段交互式代码。每次交互时,在动作空间随机采样一个动作:{left,right},与环境交互,直到游戏结束。

import gym # Import gym library
env = gym.make("CartPole-v1") # Create game environment
observation = env.reset() # Reset game state
for _ in range(1000): # Loop 1000 times
  env.render() # Render game image
  action = env.action_space.sample() # Randomly select an action
  # Interact with the environment, return new status, reward, end flag, other information
  observation, reward, done, info = env.step(action)
  if done:# End of game round, reset state
    observation = env.reset()
env.close() # End game environment

政策网络

我们来讨论一下强化学习中最关键的环节:如何判断和决策?我们称之为判断和决策政策。策略的输入是状态 s ,输出是具体的动作 a 或动作的分布πθ(a|s),其中 θ 是策略函数 π 的参数, π θ 神经网络 π θ 的输入是平衡杆系统的状态 s ,即一个长度为 4 的向量,输出是所有动作的概率πθ(a|s):向左的概率 P (所有行动概率之和为 1:

$$ {\sum}_{a\in A}{\pi}_{\theta}\left(a|s\right)=1 $$

其中 A 是所有动作的集合。πθ网络代表代理的策略,称为策略网络。自然地,我们可以将策略函数体现为一个神经网络,它有四个输入节点,中间有多个全连接的隐含层,输出层有两个输出节点,表示这两个动作的概率分布。互动时,选择概率最高的动作:

$$ {a}_t={\pi}_{\theta}\left({s}_t\right) $$

决策的结果是,它在环境中动作,得到新的状态 s t + 1 和奖励 r t ,以此类推,直到游戏结束。

img/515226_1_En_14_Fig5_HTML.png

图 14-5

战略网络

我们将策略网络实现为两层全连接网络。第一层将长度为 4 的向量转换为长度为 128 的向量,第二层将长度为 128 的向量转换为 2 的向量,这是动作的概率分布。就像普通神经网络的创建过程一样,代码如下:

class Policy(keras.Model):
    # Policy network, generating probability distribution of actions
    def __init__(self):
        super(Policy, self).__init__()
        self.data = [] # Store track
        # The input is a vector of length 4, and the output is two actions - left and right, specifying the initialization scheme of the W tensor
        self.fc1 = layers.Dense(128, kernel_initializer='he_normal')
        self.fc2 = layers.Dense(2, kernel_initializer='he_normal')
        # Network optimizer
        self.optimizer = optimizers.Adam(lr=learning_rate)

    def call(self, inputs, training=None):
        # The shape of the state input s is a vector:[4]
        x = tf.nn.relu(self.fc1(inputs))
        x = tf.nn.softmax(self.fc2(x), axis=1) # Get the probability distribution of the action
        return x

在交互过程中,我们记录每个时间戳的状态输入st,动作分发输出 a t ,环境奖励 r t ,新状态st+1作为训练策略网络的四元组项。

    def put_data(self, item):
        # Record r,log_P(a|s)
        self.data.append(item)

梯度更新

如果需要使用梯度下降算法优化网络,需要知道每个输入 s t 的标签信息 a t 并保证损耗值从输入到损耗连续可微。但是,强化学习并不等同于传统的监督学习,这主要体现在强化学习在每个时间戳 t 的动作 a t 并没有明确的好坏标准。奖励 r t 能在一定程度上反映动作的好坏,但不能直接决定动作的好坏。甚至有些游戏交互过程只有一个代表游戏结果的最终奖励 r t 信号,比如围棋。那么为每个状态定义一个最优动作$$ {a}_t^{\ast } $$作为神经网络输入 s * t * 的标签是否可行呢?首先是游戏中的状态总数通常是巨大的。比如围棋的总状态数大概是 10 170 。此外,很难为每个状态定义一个最佳动作。虽然有些行动短期回报低,但长期回报更好,有时甚至人类都不知道哪个行动是最好的。

因此,策略的优化目标不应该是使投入的产出 s t 尽可能接近标号动作,而是使总回报的期望值最大化。总奖励可以定义为从游戏开始到游戏结束的激励∑ r t 的总和。一个好的策略应该是能够在环境中获得总回报的最高期望值J(πθ)。根据梯度上升算法的原理,如果能找到$$ \frac{\partial J\left(\theta \right)}{\partial \theta } $$,那么策略网络只需要跟随:

$$ {\theta}^{\prime }=\theta +\eta \bullet \frac{\partial J\left(\theta \right)}{\partial \theta } $$

更新网络参数以最大化期望回报。

可惜总回报预期J(πθ)是游戏环境给定的。如果环境模型未知,则$$ \frac{\partial J\left(\theta \right)}{\partial \theta } $$无法通过自动微分计算。那么即使J(πθ)的表达式未知,偏导数$$ \frac{\partial J\left(\theta \right)}{\partial \theta } $$是否可以直接求解?

答案是肯定的。我们这里直接给出$$ \frac{\partial J\left(\theta \right)}{\partial \theta } $$的推导结果。具体的推导过程将在 14.3 中详细介绍:

$$ \frac{\partial J\left(\theta \right)}{\partial \theta }={E}_{\tau \sim {p}_{\theta}\left(\tau \right)}\left[\left({\sum}_{t=1}^T\frac{\partial }{\partial \theta } loglog\ {\pi}_{\theta }\ \left({s}_t\right)\right)R\left(\tau \right)\right] $$

利用前面的公式,只需要计算出$$ \frac{\partial }{\partial \theta } loglog\ {\pi}_{\theta }\ \left({s}_t\right) $$,再乘以 R ( τ )就可以更新计算出$$ \frac{\partial J\left(\theta \right)}{\partial \theta } $$。根据$$ {\theta}^{\prime }=\theta -\eta \bullet \frac{\partial L\left(\theta \right)}{\partial \theta } $$可以更新策略网络,最大化 J ( θ )函数,其中 R ( τ )为某次交互的总回报; τ 是交互轨迹s1,a1,r1, s 2a2, r 2T 是交互的时间戳或步骤数;而log logπθ(st)是策略网络输出中 a * t * 动作的概率值的对数函数。$$ \frac{\partial }{\partial \theta } loglog\ {\pi}_{\theta }\ \left({s}_t\right) $$可通过 TensorFlow 自动微分解决。损失函数的代码实现为:

        for r, log_prob in self.data[::-1]:# Get trajectory data in reverse order
            R = r + gamma * R # Accumulate the return on each time stamp
            # The gradient is calculated once for each timestamp
            # grad_R=-log_P*R*grad_theta
            loss = -log_prob * R

整个训练和更新代码如下:

    def train_net(self, tape):
        # Calculate the gradient and update the policy network parameters. tape is a gradient recorder
        R = 0 # The initial return of the end state is 0
        for r, log_prob in self.data[::-1]:# Reverse order
            R = r + gamma * R # Accumulate the return on each time stamp
            # The gradient is calculated once for each timestamp
            # grad_R=-log_P*R*grad_theta
            loss = -log_prob * R
            with tape.stop_recording():
                # Optimize strategy network
                grads = tape.gradient(loss, self.trainable_variables)
                # print(grads)
                self.optimizer.apply_gradients(zip(grads, self.trainable_variables))
        self.data = [] # Clear track

14.1.5 动手平衡杆游戏

我们总共训练 400 轮。在回合开始时,我们重置游戏状态,通过发送输入状态采样动作,与环境交互,记录每个时间戳的信息,直到游戏结束。

代码的交互和培训部分如下:

    for n_epi in range(10000):
        s = env.reset() # Back to the initial state of the game, return to s0
        with tf.GradientTape(persistent=True) as tape:
            for t in range(501): # CartPole-v1 forced to terminates at 500 step.
                # Send the state vector to get the strategy
                s = tf.constant(s,dtype=tf.float32)
                # s: [4] => [1,4]
                s = tf.expand_dims(s, axis=0)
                prob = pi(s) # Action distribution: [1,2]
                # Sample 1 action from the category distribution, shape: [1]
                a = tf.random.categorical(tf.math.log(prob), 1)[0]
                a = int(a) # Tensor to integer
                s_prime, r, done, info = env.step(a) # Interact with the environment
                # Record action a and the reward r generated by the action
                # prob shape:[1,2]
                pi.put_data((r, tf.math.log(prob[0][a])))
                s = s_prime # Refresh status
                score += r # Cumulative reward

                if done:  # The current episode is terminated
                    break
            # After the episode is terminated, train the network once
            pi.train_net(tape)
        del tape

模型的训练过程如图 14-6 所示。横轴是训练回合数,纵轴是回合的平均返回值。可以看出,随着训练的进行,网络获得的平均回报越来越高,策略也越来越好。事实上,强化学习算法对参数极其敏感,修改随机种子会导致完全不同的性能。在实现过程中,需要仔细选择参数,以实现算法的潜力。

img/515226_1_En_14_Fig6_HTML.jpg

图 14-6

平衡杆游戏训练流程

通过这个例子,我们对强化学习算法和强化学习的交互过程有了初步的印象和了解,然后我们将对强化学习问题进行形式化描述。

14.2 强化学习问题

在强化学习问题中,具有感知和决策能力的对象称为智能体,它可以是一段算法代码,也可以是具有机械结构的机器人软硬件系统。代理通过与外部环境的交互来完成某项任务。这里的环境是指主体的行动所能影响并给予相应反馈的外部环境的总和。对于智能体来说,它通过感知环境的状态(state)来产生决策动作(action)。对于环境,它从一个初始状态 s 1 开始,通过接受智能体的动作来动态改变其状态,并给出相应的奖励信号(reward)。

我们从概率的角度描述强化学习过程。它包含以下五个基本对象:

  • 状态 s 反映环境的状态特征。时间戳上的状态 t 标记为 s t 。可以是原始的视觉图像、语音波形、其他信号,也可以是经过高级抽象后的特征,比如汽车的速度、位置等。所有(有限)状态构成状态空间 s。

  • 动作 a 是代理采取的动作。时间戳 t 上的状态记录为 a t ,可以是左右等离散动作,也可以是力度、位置等连续动作。所有(有限)动作构成动作空间 A

  • Policy π(a| s) represents the decision model of the agent. It accepts the input as the state s and gives the probability distribution p(a| s) of the action executed after the decision, which satisfies:

    $$ {\sum}_{a\in A}\pi (s)=1 $$

这种具有一定随机性的行动概率输出称为随机策略。特别是当策略模型总是输出某个动作的概率为 1,其他为 0 时,这种策略模型称为确定性策略,即:

$$ a=\pi (s) $$

  • 奖励 r ( sa )表示在状态 s 下接受动作 a 后环境给出的反馈信号。一般是标量值,在一定程度上反映了动作的好坏。在时间戳 t 获得的奖励记为 r t (有些资料中记为rt+1,因为奖励往往有一定的滞后性)

  • The state transition probability p(s| s, a) expresses the changing law of the state of the environment model, that is, after the environment of the current state s accepts the action a, the probability distribution that the state changes to s satisfies:

    $$ {\sum}_{s\prime \in S}p\left({s}^{\prime }|s,a\right)=1 $$

主体与环境的交互过程可以用图 14-7 来表示。

img/515226_1_En_14_Fig7_HTML.png

图 14-7

主体与环境之间的相互作用过程

马尔可夫决策过程

代理从环境的初始状态 s 1 开始,通过策略模型π(a|s)执行一个特定的动作 a 1 。环境受动作 a 1 影响,状态 s 1 根据内部状态转移模型p(s|sa )变化为 s 2 。同时给出代理的反馈信号:奖励r1,由奖励函数r(s1,a1 产生。这种循环互动一直持续到游戏达到终止状态 s T 。这个过程产生一系列有序的数据:

$$ \tau ={s}_1,{a}_1,{r}_1,{s}_2,{a}_2,{r}_2,\cdots, {s}_T $$

这个序列代表了代理和环境之间的交换过程,称为轨迹,表示为 τ 。一个交互过程称为一集, T 代表时间戳(或步骤数)。有些环境有明确的终端状态。比如《太空入侵者》中的小飞机被击中游戏就结束了,而有些环境没有明确的终止标志。比如,有些游戏只要保持健康就可以无限期玩下去。此时 T 代表∞。

条件概率P(st+1|s1s 2 ,…, s t )非常重要,但是需要多个历史状态,计算起来非常复杂。为简单起见,我们假设下一个时间戳上的状态st+1只受当前时间戳 s t 影响,与其他历史状态 s 1s 2 、…、 s 无关

下一个状态 s t + 1 只与当前状态 s t 相关的性质称为马尔可夫性质,具有马尔可夫性质的序列 s 1s 2 、…、 s T 称为

如果将动作 a 也考虑到状态转移概率,则马尔可夫假设也被应用:下一个时间戳的状态st+1只与当前状态 s t 相关,并且动作 a t 对当前状态执行,则条件概率变为:

我们把状态和动作的序列 s 1a 1 ,…, s T 称为马尔可夫决策过程(MDP)。在某些场景下,智能体只能观察到环境的部分状态,这被称为部分可观测马尔可夫决策过程(POMDP)。虽然马尔可夫假设不一定对应实际情况,但它是强化学习中大量理论推导的基石。我们将在以后的推导中看到马尔可夫性的应用。

现在让我们考虑一个确定的轨迹:

$$ \tau ={s}_1,{a}_1,{r}_1,{s}_2,{a}_2,{r}_2,\cdots, {s}_T $$

就是发生的概率P(τ):

$$ P\left(\tau \right)=P\left({s}_1,{a}_1,{s}_2,{a}_2,\cdots, {s}_T\right) $$

$$ =P\left({s}_1\right)\pi \left({s}_1\right)P\left({s}_1,{a}_1\right)\pi \left({s}_2\right)P\left({s}_1,{a}_1,{s}_2,{a}_2\right)\cdots $$

$$ =P\left({s}_1\right){\prod}_{t=1}^{T-1}\pi \left({s}_t\right)p\left({s}_1,{a}_1,\dots, {s}_t,{a}_t\right) $$

应用马尔可夫性后,我们将前面的表达式简化为:

$$ P\left(\tau \right)=P\left({s}_1\right){\prod}_{t=1}^{T-1}\pi \left({s}_t\right)p\left({s}_t,{a}_t\right) $$

马尔可夫决策过程图如图 14-8 所示。

img/515226_1_En_14_Fig8_HTML.png

图 14-8

马尔可夫决策过程

如果可以得到环境的状态转移概率 p ( s ʹ| sa )和报酬函数 r ( sa ),就可以直接迭代计算价值函数。这种已知环境模型的方法统称为基于模型的强化学习。然而,现实世界中的环境模型大多是复杂和未知的。这种模型未知的方法统称为无模型强化学习。接下来主要介绍无模型强化学习算法。

目标函数

智能体每次与环境交互,都会得到一个(滞后的)奖励信号:

$$ {r}_t=r\left({s}_t,{a}_t\right) $$

一个交互轨迹 τ 的累积回报称为总回报:

$$ R\left(\tau \right)={\sum}_{t=1}^{T-1}{r}_t $$

其中 T 是轨迹中的步数。如果只考虑sTs t + 1 ,…, s T 从轨迹的中间状态 s t 开始的累计收益,可以记为:

$$ R\left({s}_t\right)={\sum}_{k=1}^{T-t-1}{r}_{t+k} $$

在某些环境下,刺激信号是很稀疏的,比如围棋,上一步棋的刺激是 0,只有在比赛结束时才会有代表胜负的奖励信号。

因此,为了权衡短期和长期回报的重要性,可以使用随时间衰减的贴现回报(贴现回报):

$$ R\left(\tau \right)={\sum}_{t=1}{T-1}{\gamma}{t-1}{r}_t $$

其中 γ ∈ [0,1]称为折现率。可以看出,近期激励 r 1 全部用于总回报,而长期激励RT-1可以用来贡献衰减γT-2后的总回报 R ( τ )。当 γ ≈ 1 时,短期和长期奖励权重大致相同,算法更具前瞻性;当 γ ≈ 0 时,后期长期回报衰减接近 0,短期回报变得更重要。对于没有终止状态的环境,即 T = ∞,折现回报变得非常重要,因为$$ {\sum}_{t=1}^{\infty }{\gamma}^{t-1}{r}_t $$可能增加到无穷大,对于长期奖励可以近似忽略折现回报,以方便算法实现。

我们希望找到一个策略 π ( a | s )模型,使得策略π(a|s控制下的智能体与环境相互作用产生的轨迹 τ 的总收益 R ( τ )越高越好。由于环境状态转移和策略的随机性,同样的策略模型作用于初始状态相同的环境,也可能产生完全不同的轨迹序列 τ 。因此,强化学习的目标是期望收益最大化:

$$ J\left({\pi}_{\theta}\right)={E}_{\tau \sim p\left(\tau \right)}\left[R\left(\tau \right)\right]={E}_{\tau \sim p\left(\tau \right)}\left[{\sum}_{t=1}{T-1}{\gamma}{t-1}{r}_t\right] $$

训练的目标是找到一组参数 θ 所代表的策略网络πθ,使得J(πθ)最大:

$$ {\theta}^{\ast }={E}_{\tau \sim p\left(\tau \right)}\left[R\left(\tau \right)\right] $$

其中 p ( τ )代表轨迹 τ 的分布,由状态转移概率p(s|sa )和策略π(a|s)共同决定。策略 π 的好坏可以用J(πθ)来衡量。预期收益越大,政策越好;否则,策略越糟糕。

14.3 政策梯度法

由于强化学习的目标是找到一个最优策略πθ(s),使得期望收益 J ( θ ),这类优化问题类似于监督学习。需要用网络参数$ \frac{\partial J}{\partial \theta } $求解期望收益的偏导数,用梯度上升算法更新网络参数:

$$ {\theta}^{\prime }=\theta +\eta \bullet \frac{\partial J}{\partial \theta } $$

即其中 η 为学习率。

策略模型πθ(s)可以使用多层神经网络参数化πθ(s)。网络的输入是状态 s ,输出是动作 a 的概率分布。这种网络被称为政策网络。

为了优化这个网络,你只需要获得每个参数的偏导数$$ \frac{\partial J}{\partial \theta } $$。现在我们来推导一下$$ \frac{\partial J}{\partial \theta } $$的表达式。先按轨迹分布展开:

$$ \frac{\partial J}{\partial \theta }=\frac{\partial }{\partial \theta}\int {\pi}_{\theta}\left(\tau \right)R\left(\tau \right) d\tau $$

将导数符号移动到整数符号:

$$ =\int \left(\frac{\partial }{\partial \theta }{\pi}_{\theta}\left(\tau \right)\right)R\left(\tau \right) d\tau $$

添加$$ {\pi}_{\theta}\left(\tau \right)\bullet \frac{1}{\pi_{\theta}\left(\tau \right)} $$不改变结果:

$$ =\int {\pi}_{\theta}\left(\tau \right)\left(\frac{1}{\pi_{\theta}\left(\tau \right)}\frac{\partial }{\partial \theta }{\pi}_{\theta}\left(\tau \right)\right)R\left(\tau \right) d\tau $$

考虑:

$$ \frac{dlog\left(f(x)\right)}{dx}=\frac{1}{f(x)}\frac{df(x)}{dx} $$

所以:

$$ \frac{1}{\pi_{\theta}\left(\tau \right)}\frac{\partial }{\partial \theta }{\pi}_{\theta}\left(\tau \right)=\frac{\partial }{\partial \theta } loglog\ {\pi}_{\theta }\ \left(\tau \right) $$

我们可以得到:

$$ =\int {\pi}_{\theta}\left(\tau \right)\left(\frac{\partial }{\partial \theta } loglog\ {\pi}_{\theta }\ \left(\tau \right)\right)R\left(\tau \right) d\tau $$

也就是:

$$ \frac{\partial J}{\partial \theta }={E}_{\tau \sim {p}_{\theta}\left(\tau \right)}\left[\frac{\partial }{\partial \theta } loglog\ {\pi}_{\theta }\ \left(\tau \right)R\left(\tau \right)\right] $$

其中log logπθ(τ)代表轨迹的对数概率值 τ = s 1a 1s 2a 2考虑到 R ( τ )可以通过采样得到,关键就变成了求解$$ \frac{\partial }{\partial \theta } loglog\ {\pi}_{\theta }\ \left(\tau \right) $$,我们可以分解πθ(τ)得到:

$$ \frac{\partial }{\partial \theta } loglog\ {\pi}_{\theta }\ \left(\tau \right)=\frac{\partial }{\partial \theta } loglog\ \left(p\left({s}_1\right){\prod}_{t=1}^{T-1}{\pi}_{\theta}\left({s}_t\right)p\left({s}_t,{a}_t\right)\right) $$

日志 ∏转换为∑ 日志 ( ):

$$ =\frac{\partial }{\partial \theta}\left( loglog\ p\ \left({s}_1\right)+{\sum}_{t=1}^{T-1} loglog\ {\pi}_{\theta }\ \left({s}_t\right)+ loglog\ p\ \left({s}_t,{a}_t\right)\right) $$

考虑到log log p(sta t )和log log p(s1)都与 θ 无关,前面的公式变成:

$$ \frac{\partial }{\partial \theta } loglog\ {\pi}_{\theta }\ \left(\tau \right)={\sum}_{t=1}^{T-1}\frac{\partial }{\partial \theta } loglog\ {\pi}_{\theta }\ \left({s}_t\right) $$

可以看出,偏导数$$ \frac{\partial }{\partial \theta } loglog\ {\pi}_{\theta }\ \left(\tau \right) $$最终可以转化为log logπθ(st)即策略网络输出对网络参数 θ 的导数。与状态概率转移p(s|sa )无关,即不知道环境模型$$ \frac{\partial }{\partial \theta } loglog\ {p}_{\theta }\ \left(\tau \right) $$即可求解。

把它分成$$ \frac{\partial J}{\partial \theta } $$ :

$$ \frac{\partial J\left(\theta \right)}{\partial \theta }={E}_{\tau \sim {p}_{\theta}\left(\tau \right)}\left[\frac{\partial }{\partial \theta } loglog\ {\pi}_{\theta }\ \left(\tau \right)R\left(\tau \right)\right] $$

$$ ={E}_{\tau \sim {p}_{\theta}\left(\tau \right)}\left[\left({\sum}_{t=1}^{T-1}\frac{\partial }{\partial \theta } loglog\ {\pi}_{\theta }\ \left({s}_t\right)\right)R\left(\tau \right)\right] $$

让我们直观地理解前面的公式。当某一轮的总回报 R ( τ ) > 0、$$ \frac{\partial J\left(\theta \right)}{\partial \theta } $$$$ \frac{\partial }{\partial \theta } loglog\ {\pi}_{\theta }\ \left(\tau \right) $$同向时。根据梯度上升算法, θ 参数朝着增加 J ( θ )的方向更新,也朝着增加log log logπθ(st)的方向更新,这鼓励了更多这样的轨迹 τ 。当总回报 R ( * τ * ) < 0、$$ \frac{\partial J\left(\theta \right)}{\partial \theta } $$$$ \frac{\partial }{\partial \theta } loglog\ {\pi}_{\theta }\ \left(\tau \right) $$反转时,那么当 θ 参数根据梯度上升算法更新时。朝着增加 J ( θ )和减少log logπθ(st)的方向更新,即避免产生更多这样的轨迹 τ 。通过这一点,可以直观地了解网络如何自我调整以获得更大的预期回报。

有了前面的$$ \frac{\partial J}{\partial \theta } $$表达式,我们就可以通过 TensorFlow 的自动微分工具轻松求解$$ \frac{\partial }{\partial \theta } loglog\ {\pi}_{\theta }\ \left({s}_t\right) $$来计算$$ \frac{\partial J}{\partial \theta } $$。最后,我们可以使用梯度上升算法来更新参数。策略梯度算法的一般流程如图 14-9 所示。

img/515226_1_En_14_Fig9_HTML.png

图 14-9

政策梯度法培训流程

14.3.1 加固算法

根据大数定律,将期望写成多个采样轨迹的平均值τN,N∈【1,N:

$$ \frac{\partial J\left(\theta \right)}{\partial \theta}\approx \frac{1}{N}{\sum}_{n=1}N\left(\left({\sum}_{t=1}{T-1}\frac{\partial }{\partial \theta } loglog\ {\pi}_{\theta }\ \left({s}_t{(n)}\right)\right)R\left({\tau}{(n)}\right)\right) $$

其中 N 为轨迹数,$$ {a}_t^{(n)} $$$$ {s}_t^{(n)} $$代表第 N 条轨迹的第 t 个时间戳的动作和输入状态τN。然后通过梯度上升算法更新 θ 参数。这个算法被称为加强算法[4],也是最早使用策略梯度思想的算法。

| **算法 1:加固算法** | | 随机初始化***θ*****重复**根据策略(***s******t***)与环境交互,生成多条轨迹{**(***n***)}** **计算***R***(***τ***(***n***))

算出 $$ \frac{\boldsymbol{\partial J}\left(\boldsymbol{\theta} \right)}{\boldsymbol{\partial \boldsymbol{\theta}}}\approx \frac{\mathbf{1}}{\boldsymbol{N}}{\sum}_{\boldsymbol{n}=\mathbf{1}}{\boldsymbol{N}}\left(\left({\sum}_{\boldsymbol{t}=\mathbf{1}}{\boldsymbol{T}-\mathbf{1}}\frac{\boldsymbol{\partial}}{\boldsymbol{\partial \boldsymbol{\theta}}}\boldsymbol{loglog}\ {\boldsymbol{\pi}}_{\boldsymbol{\theta}}\ \left({\boldsymbol{s}}_{\boldsymbol{t}}{\left(\boldsymbol{n}\right)}\right)\right)\boldsymbol{R}\left({\boldsymbol{\tau}}{\left(\boldsymbol{n}\right)}\right)\right) $$

更新参数 $$ {\boldsymbol{\theta}}^{\prime}\boldsymbol{\leftarrow}\boldsymbol{\theta } +\boldsymbol{\eta} \bullet \frac{\boldsymbol{\partial J}}{\boldsymbol{\partial \boldsymbol{\theta}}} $$

直到达到一定的训练次数

输出:策略网络(st)** |

14.3.2 对原有政策梯度法的改进

由于原有的强化算法在优化轨迹之间的方差较大,收敛速度较慢,训练过程不够平滑。我们可以利用方差缩减的思想,从因果关系和基线的角度进行改进。

因果关系。考虑到$$ \frac{\partial J\left(\theta \right)}{\partial \theta } $$的偏导数表达式,对于时间戳为 t 的动作 a * t ,对τ1:T—1没有影响,仅对后续轨迹τT:T有影响。所以对于πθ(sT),我们只考虑从时间戳 t 开始的累计回报R(τT:T*)。$$ \frac{\partial J\left(\theta \right)}{\partial \theta } $$的表达式由

$$ \frac{\partial J\left(\theta \right)}{\partial \theta }={E}_{\tau \sim {p}_{\theta}\left(\tau \right)}\left[\left({\sum}_{t=1}^{T-1}\frac{\partial }{\partial \theta } loglog\ {\pi}_{\theta }\ \left({s}_t\right)\right)R\left({\tau}_{1:T}\right)\right] $$

给出

可以写成:

$$ \frac{\partial J\left(\theta \right)}{\partial \theta }={E}_{\tau \sim {p}_{\theta}\left(\tau \right)}\left[{\sum}_{t=1}^{T-1}\left(\frac{\partial }{\partial \theta } loglog\ {\pi}_{\theta }\ \left({s}_t\right)R\left({\tau}_{t:T}\right)\right)\right] $$

$$ ={E}_{\tau \sim {p}_{\theta}\left(\tau \right)}\left[{\sum}_{t=1}^{T-1}\left(\frac{\partial }{\partial \theta } loglog\ {\pi}_{\theta }\ \left({s}_t\right)\hat{Q}\left({s}_t,{a}_t\right)\right)\right] $$

其中$$ \hat{Q}\left({s}_t,{a}_t\right) $$函数代表从状态 s * t 执行 a t 动作后 π θ 的预计奖励值。Q 函数的定义也将在 14.4 节中介绍。由于只考虑从 a t 开始的轨迹τT:TT 所以R(τT:T*的方差变小。

偏置。真实环境中的奖励rt并不是围绕 0 分布的。很多游戏的奖励都是正的,以至于 R ( τ )总是大于 0。网络倾向于增加所有采样动作的概率。未采样动作的概率相对降低。这不是我们想要的。我们希望 R ( τ )能分布在 0 附近,所以我们引入一个偏差变量 b ,叫做基线,它代表平均收益水平 R ( τ )。$$ \frac{\partial J\left(\theta \right)}{\partial \theta } $$的表达式转换为:

$$ \frac{\partial J\left(\theta \right)}{\partial \theta }={E}_{\tau \sim {p}_{\theta}\left(\tau \right)}\left[{\sum}_{t=1}^{T-1}\frac{\partial }{\partial \theta } loglog\ {\pi}_{\theta }\ \left({s}_t\right)\left(R\left(\tau \right)-b\right)\right] $$

考虑到因果关系,$$ \frac{\partial J\left(\theta \right)}{\partial \theta } $$可以写成:

$$ \frac{\partial J\left(\theta \right)}{\partial \theta }={E}_{\tau \sim {p}_{\theta}\left(\tau \right)}\left[{\sum}_{t=1}^{T-1}\left(\frac{\partial }{\partial \theta } loglog\ {\pi}_{\theta }\ \left({s}_t\right)\left(\hat{Q}\left({s}_t,{a}_t\right)-b\right)\right)\right] $$

其中δ=R(τ)—b称为优势函数,代表当前动作序列相对于平均收益的优势。

加上 bias b 后,$$ \frac{\partial J\left(\theta \right)}{\partial \theta } $$的值会发生变化吗?要回答问题,我们只需要考虑$$ {E}_{\tau \sim {p}_{\theta}\left(\tau \right)}\left[{\nabla}_{\theta } loglog\ {\pi}_{\theta }\ \left(\tau \right)\bullet b\right] $$能否为 0。如果是 0,那么$$ \frac{\partial J\left(\theta \right)}{\partial \theta } $$的值不会改变。将$$ {E}_{\tau \sim {p}_{\theta}\left(\tau \right)}\left[{\nabla}_{\theta } loglog\ {\pi}_{\theta }\ \left(\tau \right)\bullet b\right] $$展开为:

$$ {E}_{\tau \sim {p}_{\theta}\left(\tau \right)}\left[{\nabla}_{\theta } loglog\ {\pi}_{\theta }\ \left(\tau \right)\bullet b\right]=\int {\pi}_{\theta}\left(\tau \right){\nabla}_{\theta } loglog\ {\pi}_{\theta }\ \left(\tau \right)\bullet b\ d\tau $$

因为:

$$ {\pi}_{\theta}\left(\tau \right){\nabla}_{\theta } loglog\ {\pi}_{\theta }\ \left(\tau \right)={\nabla}_{\theta }{\pi}_{\theta}\left(\tau \right) $$

我们有:

$$ {E}_{\tau \sim {p}_{\theta}\left(\tau \right)}\left[{\nabla}_{\theta } loglog\ {\pi}_{\theta }\ \left(\tau \right)\bullet b\right]=\int {\nabla}_{\theta }{\pi}_{\theta}\left(\tau \right) bd\tau $$

$$ =b{\nabla}_{\theta}\int {\pi}_{\theta}\left(\tau \right) d\tau $$

考虑∫πθ(τ)= 1,

$$ {E}_{\tau \sim {p}_{\theta}\left(\tau \right)}\left[{\nabla}_{\theta } loglog\ {\pi}_{\theta }\ \left(\tau \right)\bullet b\right]=b{\nabla}_{\theta }1=0 $$

因此,增加 bias b 并不会改变$$ \frac{\partial J\left(\theta \right)}{\partial \theta } $$的值,但确实减少了$$ {\sum}_{t=1}^{T-1}\left(\frac{\partial }{\partial \theta } loglog\ {\pi}_{\theta }\ \left({s}_t\right)\left(\hat{Q}\left({s}_t,{a}_t\right)-b\right)\right) $$的方差。

14.3.3 用偏差加强算法

偏差 b 可以用蒙特卡罗方法估计:

$$ b=\frac{1}{N}{\sum}_{n=1}NR\left({\tau}{(n)}\right) $$

如果考虑因果关系,那么:

$$ b=\frac{1}{N}{\sum}_{n=1}NR\left({\tau}_{t:T}{(n)}\right) $$

偏差 b 也可以使用另一个神经网络来估计,这也是 14.5 节中介绍的行动者-评论家方法。事实上,许多政策梯度算法经常使用神经网络来估计偏差 b 。算法可以灵活调整,掌握算法思路最重要。在算法 2 中示出了具有偏差增强算法流程。

| **算法 2:用偏差加强算法流程** | | **随机初始化***θ***重复****根据策略与环境交互**(***s******t***)***,*** **生成多条轨迹**{***【τ******n*****算出** ![$$ \hat{\boldsymbol{Q}}\left({\boldsymbol{s}}_{\boldsymbol{t}},{\boldsymbol{a}}_{\boldsymbol{t}}\right) $$](https://gitee.com/OpenDocCN/vkdoc-dl-zh/raw/master/docs/begin-dl-tf/img/515226_1_En_14_Chapter_TeX_IEq41.png)**通过蒙特卡罗方法估计偏差** ***b*** ****算出** ![$$ \frac{\boldsymbol{\partial J}\left(\boldsymbol{\theta} \right)}{\boldsymbol{\partial \boldsymbol{\theta}}}\approx \frac{\mathbf{1}}{\boldsymbol{N}}{\sum}_{\boldsymbol{n}=\mathbf{1}}^{\boldsymbol{N}}\left(\left({\sum}_{\boldsymbol{t}=\mathbf{1}}^{\boldsymbol{T}-\mathbf{1}}\frac{\boldsymbol{\partial}}{\boldsymbol{\partial \boldsymbol{\theta}}}\boldsymbol{loglog}\ {\boldsymbol{\pi}}_{\boldsymbol{\theta}}\ \left({\boldsymbol{s}}_{\boldsymbol{t}}^{\left(\boldsymbol{n}\right)}\right)\right)\left(\hat{\boldsymbol{Q}}\left({\boldsymbol{s}}_{\boldsymbol{t}},{\boldsymbol{a}}_{\boldsymbol{t}}\right)-\boldsymbol{b}\right)\right) $$](https://gitee.com/OpenDocCN/vkdoc-dl-zh/raw/master/docs/begin-dl-tf/img/515226_1_En_14_Chapter_TeX_IEq42.png)

更新参数 $$ {\boldsymbol{\theta}}^{\prime}\boldsymbol{\leftarrow}\boldsymbol{\theta } +\boldsymbol{\eta} \bullet \frac{\boldsymbol{\partial J}}{\boldsymbol{\partial \boldsymbol{\theta}}} $$

直到达到训练次数

输出:政策网(st)** |

重要性抽样

使用策略梯度法更新网络参数后,策略网络πθ(s)也发生了变化,必须使用新的策略网络进行采样。导致之前的历史轨迹数据无法重用,采样效率很低。如何提高采样效率,复用旧策略生成的轨迹数据?

在统计学中,重要抽样技术可以从另一个分布 q 估计原始分布 p 的期望值。考虑到轨迹 τ 是从原始分布 p 中采样的,我们希望估计出轨迹 τ ~ p 函数的期望Eτp[f(τ)。

$$ {E}_{\tau \sim p}\left[f\left(\tau \right)\right]=\int p\left(\tau \right)f\left(\tau \right) d\tau $$

$$ =\int \frac{p\left(\tau \right)}{q\left(\tau \right)}q\left(\tau \right)f\left(\tau \right) d\tau $$

$$ ={E}_{\tau \sim q}\left[\frac{p\left(\tau \right)}{q\left(\tau \right)}f\left(\tau \right)\right] $$

通过推导,我们发现 f ( τ )的期望可以不从原始分布 p 中采样,而是从另一个分布 q 中采样,只需要乘以比例$$ \frac{p\left(\tau \right)}{q\left(\tau \right)} $$。这在统计学中称为重要抽样。

设目标政策分布为pθ(τ),某个历史政策分布为$$ {p}_{\underset{_}{\theta }}\left(\tau \right) $$,我们希望用历史采样轨迹$$ \tau \sim {p}_{\underset{_}{\theta }}\left(\tau \right) $$来估计目标政策网络的预期收益:

$$ J\left(\theta \right)={E}_{\tau \sim {p}_{\theta}\left(\tau \right)}\left[R\left(\tau \right)\right] $$

$$ ={\sum}_{t=1}^{T-1}{E}_{\left({s}_t,{a}_t\right)\sim {p}_{\theta}\left({s}_t,{a}_t\right)}\left[r\left({s}_t,{a}_t\right)\right] $$

$$ ={\sum}_{t=1}^{T-1}{E}_{s_t\sim {p}_{\theta}\left({s}_t\right)}{E}_{a_t\sim {\pi}_{\theta}\left({s}_t\right)}\left[r\left({s}_t,{a}_t\right)\right] $$

应用重要性抽样技术,我们可以得到:

$$ {J}_{\underset{_}{\theta }}\left(\theta \right)={\sum}_{t=1}^{T-1}{E}_{s_t\sim {p}_{\underset{_}{\theta }}\left({s}_t\right)}\left[\frac{p_{\theta}\left({s}_t\right)}{p_{\underset{_}{\theta }}\left({s}_t\right)}{E}_{a_t\sim {\pi}_{\underset{_}{\theta }}\left({s}_t\right)}\left[\frac{\pi_{\theta}\left({s}_t\right)}{\pi_{\underset{_}{\theta }}\left({s}_t\right)}r\left({s}_t,{a}_t\right)\right]\right] $$

其中$$ {J}_{\underset{_}{\theta }}\left(\theta \right) $$代表原始分布pθ(τ)通过分布$$ {p}_{\underset{_}{\theta }}\left(\tau \right) $$估算的 J ( θ )的值。在近似忽略$$ \frac{p_{\theta}\left({s}_t\right)}{p_{\underset{_}{\theta }}\left({s}_t\right)} $$项的假设下,认为状态 s * t * 在不同策略下出现的概率近似相等,即$$ \frac{p_{\theta}\left({s}_t\right)}{p_{\underset{_}{\theta }}\left({s}_t\right)}\approx 1 $$,所以:

$$ {J}_{\underset{_}{\theta }}\left(\theta \right)={\sum}_{t=1}^{T-1}{E}_{s_t\sim {p}_{\underset{_}{\theta }}\left({s}_t\right)}\left[{E}_{a_t\sim {\pi}_{\underset{_}{\theta }}\left({s}_t\right)}\left[\frac{\pi_{\theta}\left({s}_t\right)}{\pi_{\underset{_}{\theta }}\left({s}_t\right)}r\left({s}_t,{a}_t\right)\right]\right] $$

$$ ={\sum}_{t=1}^{T-1}{E}_{\left({s}_t,{a}_t\right)\sim {p}_{\underset{_}{\theta }}\left({s}_t,{a}_t\right)}\left[\frac{\pi_{\theta}\left({s}_t\right)}{\pi_{\underset{_}{\theta }}\left({s}_t\right)}r\left({s}_t,{a}_t\right)\right] $$

要优化的采样策略$$ {p}_{\underset{_}{\theta }}\left(\tau \right) $$和目标策略pθ(τ)不相同的方法称为偏策略方法。相反,采样策略和要优化的目标策略是同一策略的方法称为 on-policy 方法。强化算法属于策略方法范畴。off-policy 方法可以使用历史采样数据来优化当前策略网络,这大大提高了数据利用率,但也引入了计算复杂性。特别地,当用蒙特卡罗抽样方法实施重要性抽样时,如果分布 p 和 q 之间的差值太大,期望估计就会有很大的偏差。因此,实现需要确保 p 和 q 的分布尽可能相似,例如添加 KL 散度约束来限制 p 和 q 之间的差异。

我们也称原政策梯度法的训练目标函数LPG(θ):

$$ {L}^{PG}\left(\theta \right)={\hat{E}}_t\left[ loglog\ {\pi}_{\theta }\ \left({s}_t\right){\hat{A}}_t\right] $$

其中 PG 代表政策梯度,$$ {\hat{E}}_t $$$$ {\hat{A}}_t $$代表经验估计值。基于重要性抽样的目标函数称为$$ {L}_{\underset{_}{\theta}}^{IS}\left(\theta \right) $$ :

$$ {L}_{\underset{_}{\theta}}^{IS}\left(\theta \right)={\hat{E}}_t\left[\frac{\pi_{\theta}\left({s}_t\right)}{\pi_{\underset{_}{\theta }}\left({s}_t\right)}{\hat{A}}_t\right] $$

其中 IS 代表重要性抽样, θ 代表目标策略分布pθ$$ \underset{_}{\theta } $$代表抽样策略分布$$ {p}_{\underset{_}{\theta }} $$

PPO 算法

应用重要性采样后,策略梯度算法大大提高了数据利用率,大大提高了性能和训练稳定性。比较流行的离策梯度算法有 TRPO 算法和 PPO 算法,其中 TRPO 是 PPO 算法的前身,PPO 算法可以看作是 TRPO 算法的近似简化版。

TRPO 算法为了约束目标策略 π 距离期望被用作优化问题的约束项。TRPO 算法的实现更加复杂并且计算量大。TRPO 算法的优化目标是:

$$ {\theta}^{\ast }={\hat{E}}_t\left[\frac{\pi_{\theta}\left({s}_t\right)}{\pi_{\underset{_}{\theta }}\left({s}_t\right)}{\hat{A}}_t\right] $$

$$ s.t.{\hat{\ E}}_t\left[{D}_{KL}\left({\pi}_{\theta}\left({s}_t\right)\Big\Vert {\pi}_{\underset{_}{\theta }}\left({s}_t\right)\right)\right]\le \delta $$

PPO 算法。为了解决 TRPO 计算成本高的缺点,PPO 算法在损失函数中增加了 KL 散度约束作为惩罚项。优化目标是:

$$ {\theta}^{\ast }={\hat{E}}_t\left[\frac{\pi_{\theta}\left({s}_t\right)}{\pi_{\underset{_}{\theta }}\left({s}_t\right)}{\hat{A}}_t\right]-\beta {\hat{E}}_t\left[{D}_{KL}\left({\pi}_{\theta}\left({s}_t\right)\Big\Vert {\pi}_{\underset{_}{\theta }}\left({s}_t\right)\right)\right] $$

其中$$ {D}_{KL}\left({\pi}_{\theta}\left({s}_t\right)\Big\Vert {\pi}_{\underset{_}{\theta }}\left({s}_t\right)\right) $$是指保单分布π??θ??st与$$ {\pi}_{\underset{_}{\theta }}\left({s}_t\right) $$之间的距离,超参数 β 用于平衡原损失项和 KL 散度惩罚项。

自适应 KL 惩罚算法。通过设置 KL 散度的阈值 KL max 来动态调整超参数 β 。调整规则如下:如果$$ {\hat{E}}_t\left[{D}_{KL}\left({\pi}_{\theta}\left({s}_t\right)\Big\Vert {\pi}_{\underset{_}{\theta }}\left({s}_t\right)\right)\right]>K{L}_{max} $$,增加β;如果$$ {\hat{E}}_t\left[{D}_{KL}\left({\pi}_{\theta}\left({s}_t\right)\Big\Vert {\pi}_{\underset{_}{\theta }}\left({s}_t\right)\right)\right]<K{L}_{max} $$,则减小 β

PPO2 算法。基于 PPO 算法,PPO2 算法调整损失函数:

$$ {L}_{\underset{_}{\theta}}^{CLIP}\left(\theta \right)={\hat{E}}_t\left[\left(\frac{\pi_{\theta}\left({s}_t\right)}{\pi_{\underset{_}{\theta }}\left({s}_t\right)}{\hat{A}}_t, clip\left(\frac{\pi_{\theta}\left({s}_t\right)}{\pi_{\underset{_}{\theta }}\left({s}_t\right)},1-\epsilon, 1+\epsilon \right){\hat{A}}_t\right)\ \right] $$

误差函数的原理图如图 14-10 所示。

img/515226_1_En_14_Fig10_HTML.jpg

图 14-10

PPO2 算法误差函数示意图

14.3.6 动手 PPO

在本节中,我们实现了基于重要性采样技术的 PPO 算法,并在平衡杆游戏环境中测试了 PPO 算法的性能。

政策网。政策网络也称为行动者网络。策略网络的输入是状态 s t ,四个输入节点,输出是动作at的概率分布πθ(st),由两层全连通网络实现。

class Actor(keras.Model):
    def __init__(self):
        super(Actor, self).__init__()
        # The policy network is also called the Actor network. Output probability p(a|s)
        self.fc1 = layers.Dense(100, kernel_initializer='he_normal')
        self.fc2 = layers.Dense(2, kernel_initializer='he_normal')

    def call(self, inputs):
        # Forward propagation
        x = tf.nn.relu(self.fc1(inputs))
        x = self.fc2(x)
        # Output action probability
        x = tf.nn.softmax(x, axis=1) # Convert to probability
        return x

偏差 b 网络偏差 b 网络也叫评论家网络,或 V 值函数网络。网络的输入是状态 s t ,四个输入节点,输出是标量值 b 。使用两层全连接网络来估计 b 。代码实现如下:

class Critic(keras.Model):
    def __init__(self):
        super(Critic, self).__init__()
        # Bias b network is also called Critic network, output is v(s)
        self.fc1 = layers.Dense(100, kernel_initializer='he_normal')
        self.fc2 = layers.Dense(1, kernel_initializer='he_normal')

    def call(self, inputs):
        x = tf.nn.relu(self.fc1(inputs))
        x = self.fc2(x)  # Output b's estimate
        return x

接下来,完成策略网络和价值函数网络的创建,并分别创建两个优化器来优化策略网络和价值函数网络的参数。我们在 PPO 算法的主类的初始化方法中创建它。

class PPO():
    # PPO algorithm
    def __init__(self):
        super(PPO, self).__init__()
        self.actor = Actor() # Create Actor network
        self.critic = Critic() # Create Critic network
        self.buffer = [] # Data buffer
        self.actor_optimizer = optimizers.Adam(1e-3) # Actor optimizer
        self.critic_optimizer = optimizers.Adam(3e-3) # Critic optimizer

动作采样。select_action 函数可以计算当前状态的动作分布πθT7(st),并根据概率随机抽取动作,返回动作及其概率。

    def select_action(self, s):
        # Send the state vector to get the strategy: [4]
        s = tf.constant(s, dtype=tf.float32)
        # s: [4] => [1,4]
        s = tf.expand_dims(s, axis=0)
        # Get strategy distribution: [1, 2]
        prob = self.actor(s)
        # Sample 1 action from the category distribution, shape: [1]
        a = tf.random.categorical(tf.math.log(prob), 1)[0]
        a = int(a)  # Tensor to integer
        return a, float(prob[0][a]) # Return action and its probability

环境交互。在主功能中,与环境互动 500 回合。在每一轮中,策略由 select_action 函数采样并保存在缓冲池中。不时调用 agent.optimizer()函数来优化策略。

def main():
    agent = PPO()
    returns = [] # total return
    total = 0 #  Average return over time
    for i_epoch in range(500): # Number of training rounds
        state = env.reset() # Reset environment
         for t in range(500): # at most 500 rounds
            # Interact with environment with new policy
            action, action_prob = agent.select_action(state)
            next_state, reward, done, _ = env.step(action)
            # Create and store samples
            trans = Transition(state, action, action_prob, reward, next_state)
            agent.store_transition(trans)
            state = next_state # Update state
            total += reward # Accumulate rewards
            if done: # Train network
                if len(agent.buffer) >= batch_size:
                    agent.optimize() # Optimize
                break

网络优化。当缓冲池达到一定容量时,通过 optimizer()函数构造策略网络的误差和值网络的误差,优化网络的参数。先将数据按类别转换为张量类型,然后用 MC 方法计算累计回报R(τT:T)。

    def optimize(self):
        # Optimize the main network function
        # Take sample data from the cache and convert it into tensor
        state = tf.constant([t.state for t in self.buffer], dtype=tf.float32)
        action = tf.constant([t.action for t in self.buffer], dtype=tf.int32)
        action = tf.reshape(action,[-1,1])
        reward = [t.reward for t in self.buffer]
        old_action_log_prob = tf.constant([t.a_log_prob for t in self.buffer], dtype=tf.float32)
        old_action_log_prob = tf.reshape(old_action_log_prob, [-1,1])
        # Calculate R(st) using MC method
        R = 0
        Rs = []
        for r in reward[::-1]:
            R = r + gamma * R
            Rs.insert(0, R)
        Rs = tf.constant(Rs, dtype=tf.float32)
...

然后根据批量取出缓冲池中的数据。迭代训练网络十次。对于策略网络,$$ {L}_{\underset{_}{\theta}}^{CLIP}\left(\theta \right) $$是根据 PPO2 算法的误差函数计算的。对于价值网来说,通过均方差计算价值网的预测与R(τT:T)的距离,使网络估计的价值越来越准确。

    def optimize(self):
...
        # Iterate roughly 10 times on the buffer pool data
        for _ in range(round(10*len(self.buffer)/batch_size)):
            # Randomly sample batch size samples from the buffer pool
            index = np.random.choice(np.arange(len(self.buffer)), batch_size, replace=False)
            # Build a gradient tracking environment
            with tf.GradientTape() as tape1, tf.GradientTape() as tape2:
                # Get R(st), [b,1]
                v_target = tf.expand_dims(tf.gather(Rs, index, axis=0), axis=1)
                # Calculate the predicted value of v(s), which is the bias b, we will introduce why it is written as v later
                v = self.critic(tf.gather(state, index, axis=0))
                delta = v_target - v # Calculating advantage value
                advantage = tf.stop_gradient(delta) # Disconnect the gradient
                #  Because TF's gather_nd and pytorch's gather function are different, it needs to be constructed
                 # Coordinate parameters required by gather_nd need to be constructed, indices:[b, 2]
                # pi_a = pi.gather(1, a) # pytorch only need oneline implementation
                a = tf.gather(action, index, axis=0) # Take out the action
                # batch's action distribution pi(a|st)
                pi = self.actor(tf.gather(state, index, axis=0))
                indices = tf.expand_dims(tf.range(a.shape[0]), axis=1)
                indices = tf.concat([indices, a], axis=1)
                pi_a = tf.gather_nd(pi, indices)  # The probability of action, pi(at|st), [b]
                pi_a = tf.expand_dims(pi_a, axis=1)  # [b]=> [b,1]
                # Importance sampling
                ratio = (pi_a / tf.gather(old_action_log_prob, index, axis=0))
                surr1 = ratio * advantage
                surr2 = tf.clip_by_value(ratio, 1 - epsilon, 1 + epsilon) * advantage
                # PPO error function
                policy_loss = -tf.reduce_mean(tf.minimum(surr1, surr2))
                # For the bias v, it is hoped that the R(st) estimated by MC is as close as possible
                value_loss = losses.MSE(v_target, v)
            # Optimize policy network
            grads = tape1.gradient(policy_loss, self.actor.trainable_variables)
            self.actor_optimizer.apply_gradients(zip(grads, self.actor.trainable_variables))
            # Optimize bias network
            grads = tape2.gradient(value_loss, self.critic.trainable_variables)
            self.critic_optimizer.apply_gradients(zip(grads, self.critic.trainable_variables))

        self.buffer = []  # Empty trained data

训练结果。经过 500 轮训练后,我们绘制总回报曲线,如图 14-11 所示,我们可以看到,对于一个简单的游戏如平衡杆,PPO 算法显得很容易使用。

img/515226_1_En_14_Fig11_HTML.jpg

图 14-11

PPO 算法的返回曲线

14.4 价值函数法

使用策略梯度方法,通过直接优化策略网络参数,可以获得更好的策略模型。在强化学习领域,除了策略梯度法,还有一类方法是通过对价值函数建模来间接获取策略的,我们统称为价值函数法。

接下来,我们将介绍常见价值函数的定义,如何估计价值函数,以及价值函数如何帮助生成策略。

价值函数

在强化学习中,有两种类型的价值函数:状态价值函数和状态-动作价值函数,这两种函数都表示策略π下期望收益轨迹起点的定义不同。

状态值函数(简称 V 函数),定义为在策略π:

$$ {V}^{\pi}\left({s}_t\right)={E}_{\tau \sim p\left(\tau \right)}\left[R\left({\tau}_{t:T}\right)|{\tau}_{s_t}={s}_t\right] $$

的控制下,从状态st所能获得的期望收益值

展开R(τT:T??)为:

$$ R\left(\left({\tau}_{t:T}\right)\right)={r}_t+\gamma {r}_{t+1}+{\gamma}²{r}_{t+2}+\dots $$

$$ ={r}_t+\gamma \left({r}_{t+1}+{\gamma}¹{r}_{t+2}+\dots \right) $$

$$ ={r}_t+\gamma R\left(\left({\tau}_{t+1:T}\right)\right) $$

所以:

$$ {V}^{\pi}\left({s}_t\right)={E}_{\tau \sim p\left(\tau \right)}\left[{r}_t+\gamma R\left({\tau}_{t+1:T}\right)\right] $$

$$ ={E}_{\tau \sim p\left(\tau \right)}\left[{r}_t+\gamma {V}^{\pi}\left({s}_{t+1}\right)\right] $$

这也被称为状态值函数的贝尔曼方程。在所有策略中,最优策略π是指能够获得Vπ(s)最大值的策略,即:

$$ {\pi}^{\ast }={V}^{\pi }(s)\kern0.5em \forall s\in S $$

此时,状态值函数达到最大值:

$$ {V}^{\ast }(s)={V}^{\pi }\ (s)\forall s\in S $$

对于最优策略,贝尔曼方程也得到满足:

$$ {V}^{\ast}\left({s}_t\right)={E}_{\tau \sim p\left(\tau \right)}\left[{r}_t+\gamma {V}^{\ast}\left({s}_{t+1}\right)\right] $$

该方程被称为状态值函数的贝尔曼最优方程。

考虑图 14-12 中的迷宫问题。在 3 × 4 网格中,坐标为(2,2)的网格不可通行,坐标为(4,2)的网格奖励为-10,坐标为(4,3)的网格奖励为 10。代理人可以从任何位置开始,每增加一步,奖励为-1。游戏的目标是回报最大化。对于这个简单的迷宫,可以直接画出每个位置的最优向量,即在任意起点,最优策略π∫(a|s)是确定性策略,动作如图 14-12(b) 所示。设 γ = 0.9,则:

  • s (4,3) 出发,即坐标(4,3),最优策略为V(s【4,3】)= 10

  • s (3,3)V(s【4,3】)= 1+0.9 10 = 8

s (2,1)V(s(2,1))= 1 0.9 1 0.921 0.931+0.9410 = 3.122

需要注意的是,状态值函数的前提是在某个策略π下,前面所有的计算都是为了计算最优策略下的状态值函数。

img/515226_1_En_14_Fig12_HTML.png

图 14-12

迷宫问题-V 函数

状态值函数的值反映了当前策略下状态的质量。Vπ(st)越大,当前状态的总回报预期越大。以更符合实际情况的太空入侵者游戏为例。代理人需要向飞碟、鱿鱼、螃蟹、章鱼和其他物体开火,并在击中它们时得分。同时,它必须避免被这些物体集中。红色护盾可以保护特工,但是护盾会被击中逐渐破坏。在图 14-13 中,游戏初始状态下,图中有很多物体。在一个好的政策π下,应该获得一个较大的Vπ(s)值。图 14-14 中,物体较少。再好的政策也不可能获得更大的Vπ(s)。策略的好坏也会影响Vπ(s)的值。如图 14-15 所示,一个不好的策略(比如向右移动)会导致代理被击中。因此,Vπ(s)= 0。好的政策可以击落画面中的物体,获得一定的奖励。

img/515226_1_En_14_Fig15_HTML.jpg

图 14-15

不好的政策(如向右)会结束博弈Vπ(s)= 0,好的政策还是可以获得小回报的

img/515226_1_En_14_Fig14_HTML.jpg

图 14-14

Vπ(s)在任何政策下都是小π

img/515226_1_En_14_Fig13_HTML.jpg

图 14-13

Vπ(s)在政策π下可能更大

状态-动作值函数(简称 Q 函数),定义为状态st的双重设定和动作at:

$$ {Q}^{\pi}\left({s}_t,{a}_t\right)={E}_{\tau \sim p\left(\tau \right)}\left[R\left({\tau}_{t:T}\right)|{\tau}_{a_t}={a}_t,{\tau}_{s_t}={s}_t\right] $$

的执行在策略π的控制下所能获得的期望返回值

虽然 Q 函数和 V 函数都是预期返回值,但是 Q 函数的动作 a t 是前提条件,和 V 函数的定义不同。将 Q 函数扩展为:

$$ {Q}^{\pi}\left({s}_t,{a}_t\right)={E}_{\tau \sim p\left(\tau \right)}\left[r\left({s}_t,{a}_t\right)+\gamma {r}_{t+1}+{\gamma}²{r}_{t+2}+\dots \right] $$

$$ ={E}_{\tau \sim p\left(\tau \right)}\left[r\left({s}_t,{a}_t\right)+{r}_t+\gamma \left({r}_{t+1}+{\gamma}¹{r}_{t+2}+\dots \right)\right] $$

所以:

$$ {Q}^{\pi}\left({s}_t,{a}_t\right)={E}_{\tau \sim p\left(\tau \right)}\left[r\left({s}_t,{a}_t\right)+\gamma {V}^{\pi}\left({s}_{t+1}\right)\right] $$

因为 s t 和 a t 是固定的,r(sta t )也是固定的。

Q 函数和 V 函数有如下关系:

$$ {V}^{\pi}\left({s}_t\right)={E}_{a_t\sim \pi \left({s}_t\right)}\left[{Q}^{\pi}\left({s}_t,{a}_t\right)\right] $$

即当 a t 从策略π(st)Qπ(s**ta t 在最优策略下π∫(a|s,有如下关系:

$$ {Q}{\ast}\left({s}_t,{a}_t\right)={Q}{\pi}\left({s}_t,{a}_t\right) $$

$$ {\pi}^{\ast }={Q}^{\ast}\left({s}_t,{a}_t\right) $$

它也表示:

$$ {V}{\ast}\left({s}_t\right){Q}{\ast}\left({s}_t,{a}_t\right) $$

此时:

$$ {Q}^{\ast}\left({s}_t,{a}_t\right)={E}_{\tau \sim p\left(\tau \right)}\left[r\left({s}_t,{a}_t\right)+\gamma {V}^{\ast}\left({s}_{t+1}\right)\right] $$

$$ ={E}_{\tau \sim p\left(\tau \right)}\left[r\left({s}_t,{a}_t\right)+\gamma {Q}^{\ast}\left({s}_{t+1},{a}_{t+1}\right)\ \right] $$

前面的公式称为 Q 函数的贝尔曼最优方程。

我们定义Qπ(sta t )和Vπ(s)为优势值函数:

$$ {A}^{\pi}\left(s,a\right)\triangleq {Q}{\pi}\left(s,a\right)-{V}{\pi }(s) $$

表示在状态 s 中采取行动 a 超过平均水平的程度:AAπ(sa ) > 0 表示采取行动 a 优于平均水平;否则比平均水平差。事实上,我们已经将优势价值函数的思想应用于有偏强化算法部分。

继续考虑迷宫的例子,设初始状态为s【2,1】at可右可左。对于函数Q(sta t ),Q(s(2,1))= 1 Q(s(2,1))= 1 0.9 1 0.921 0.931 0.941 0.951+0.96】 我们已经计算出V(s(2,1) ) = 3.122,可以直观的看到它们满足V(st)

*img/515226_1_En_14_Fig16_HTML.png

图 14-16

迷宫问题-Q 函数

以太空入侵者游戏为例,直观理解 Q 函数的概念。在图 14-17 中,图中的药剂在防护罩下面。如果你选择在这个时候开火,通常被认为是一个糟糕的行动。因此,良策π下,Qπ(s无火)>Qπ(s)。如果此时在图 14-18 中选择向左移动,可能会因为时间不够错过右边的物体,所以Qπ(s)可能会小。如果代理向右移动并在图 14-19 中开火,Qπ(s)会更大。

img/515226_1_En_14_Fig19_HTML.jpg

图 14-19

在好的政策下 πQπ(s)还是可以获得一些奖励的

img/515226_1_En_14_Fig18_HTML.jpg

图 14-18

Qπ(s)可能会小一些

img/515226_1_En_14_Fig17_HTML.jpg

图 14-17

Qπ(s无火)可能比Qπ(s无火)

在介绍了 Q 函数和 V 函数的定义后,我们将主要回答以下两个问题:

  • 价值函数是如何估计的?

  • 如何从价值函数推导出政策?

价值函数估计

价值函数的估计主要有蒙特卡罗方法和时间差分方法。

蒙特卡洛法

蒙特卡罗方法实际上就是通过采样策略 π ( a | s )产生的多个轨迹{ τ ( n ) }来估计 V 函数和 Q 函数。考虑一下 Q 函数的定义:

$$ {Q}^{\pi}\left(s,a\right)={E}_{\tau \sim p\left(\tau \right)}\left[R\left({\tau}_{s_0=s,{a}_0=a}\right)\right] $$

根据大数定律,可以通过抽样估算:

$$ {Q}^{\pi}\left(s,a\right)\approx {\hat{Q}}{\pi}\left(s,a\right)=\frac{1}{N}{\sum}_{n=1}NR\left({\tau}_{s_0=s,{a}_0=a}^{(n)}\right) $$

其中$$ {\tau}_{s_0=s,{a}_0=a}^{(n)} $$代表第 N 个采样轨迹,N∈【1, N 。每个采样轨迹的实际状态为 s ,初始动作为 aN 为轨迹总数。V 函数可以按照同样的方法估算:

$$ {V}^{\pi }(s)\approx {\hat{V}}^{\pi }(s)=\frac{1}{N}{\sum}_{n=1}NR\left({\tau}_{s_0=s}{(n)}\right) $$

这种通过对轨迹的总收益进行采样来估计期望收益的方法被称为蒙特卡罗方法(简称 MC 方法)。

当 Q 函数或 V 函数通过神经网络参数化时,网络的输出被记录为Qπ(sa )或Vπ(s),其真实标号被记录为蒙特卡洛估计值$$ {\hat{Q}}^{\pi}\left(s,a\right) $$$$ {\hat{V}}^{\pi }(s) $$,即网络输出值与梯度下降算法用于优化神经网络。从这个角度看,价值函数的估计可以理解为一个回归问题。蒙特卡罗方法简单易行,但需要获得完整的轨迹,因此计算效率较低,在某些环境下没有明确的结束状态。

时间差异

时间差分法(简称 TD 法)利用了价值函数的贝尔曼方程性质。在计算公式中,只需要一步或多步就可以得到价值函数的误差,优化更新价值函数网络。卡罗方法计算效率更高。

回忆一下 V 函数的贝尔曼方程:

$$ {V}^{\pi}\left({s}_t\right)={E}_{\tau \sim p\left(\tau \right)}\left[{r}_t+\gamma {V}^{\pi}\left({s}_{t+1}\right)\right] $$

因此,TD 误差项δ=rt+γVπ(st+1)Vπ(st

其中 α ∈ [0,1]为更新步长。

Q 函数的贝尔曼最优方程是:

$$ {Q}^{\ast}\left({s}_t,{a}_t\right)={E}_{\tau \sim p\left(\tau \right)}\left[r\left({s}_t,{a}_t\right)+\gamma {Q}^{\ast}\left({s}_{t+1},{a}_{t+1}\right)\ \right] $$

同样,构造 TD 误差项δ=r(stat)+γQ(st+1a

政策的改进

价值函数估计法可以得到更精确的价值函数估计,但没有直接给出政策模型。因此,需要基于价值函数间接导出策略模型。

首先看如何从 V 函数中导出策略模型:

$$ {\pi}^{\ast }={V}^{\pi }(s)\kern0.5em \forall s\in S $$

考虑到状态空间 S 和动作空间 A 通常是巨大的,这种通过遍历来获得最优策略的方式是不可行的。那么政策模型可以从 Q 函数推导出来吗?考虑:

$$ {\pi}^{\prime }(s)=\mathit{\arg}\ \underset{a}{\mathit{\max}}\ {Q}^{\pi}\left(s,a\right) $$

这样,可以通过在任何状态 s 下遍历离散动作空间 A 来选择动作。这个策略π’(s)是一个确定性的策略。因为:

$$ {V}^{\pi}\left({s}_t\right)={E}_{a_t\sim \pi \left({s}_t\right)}\left[{Q}^{\pi}\left({s}_t,{a}_t\right)\right] $$

所以:

$$ {V}{\pi{\prime }}\left({s}_t\right)\ge {V}^{\pi}\left({s}_t\right) $$

即策略π总是优于或等于策略 π ,从而实现政策改进。

确定性策略在相同的状态下产生相同的动作,所以每次交互产生的轨迹可能是相似的。政策模型总是倾向于剥削而缺乏探索,从而使得政策模型局限于局部地区,缺乏对全球状况和行动的了解。为了能够给π??’(s)确定性策略增加探索能力,我们可以让π??’(s)策略有小概率 ϵ 采用随机策略来探索未知的动作和状态。

$$ {\pi}{\epsilon}\left({s}_t\right)=\Big{\mathit{\arg} \underset{a}{\mathit{\max}} {Q}{\pi}\left(s,a\right),\kern0.5em probability\ of\ 1-\epsilon\ random\ action,\kern0.5em probability\ of\ \epsilon $$

这个政策叫做ϵ——贪婪法。它在原有策略的基础上做了少量的修改,通过控制超参数 ϵ 来平衡利用和探索,简单高效。

值函数的训练过程如图 14-20 所示。

img/515226_1_En_14_Fig20_HTML.png

图 14-20

价值函数法培训流程

SARSA 算法

SARSA 算法[5]用途:

$$ {Q}^{\pi}\left({s}_t,{a}_t\right)\leftarrow {Q}^{\pi}\left({s}_t,{a}_t\right)+\alpha \left(r\left({s}_t,{a}_t\right)+\gamma {Q}{\pi}\left({s}_{t+1},{a}_{t+1}\right)-{Q}{\pi}\left({s}_t,{a}_t\right)\right) $$

估算 Q 函数的方法是,在轨迹的每一步,只有st,atr tst+1和 a t s tatr tst+1和 a+1

*### DQN 算法

2015 年,DeepMind 提出了利用深度神经网络实现的 Q 学习[4]算法,发表在 Nature [1]上,并在 Atari 游戏环境下的 49 款迷你游戏上进行训练和学习,达到了相当于甚至优于人类的水平。人类水平的表现引起了业界和公众对强化学习研究的浓厚兴趣。

q 学习算法用途:

$$ {Q}^{\ast}\left({s}_t,{a}_t\right)\leftarrow {Q}^{\ast}\left({s}_t,{a}_t\right)+\alpha \left(r\left({s}_t,{a}_t\right)+\gamma {Q}{\ast}\left({s}_{t+1},{a}_{t+1}\right)-{Q}{\ast}\left({s}_t,{a}_t\right)\right) $$

估算q(sta t )函数并使用πϵ(st)策略获得策略改进。深度 Q 网络(DQN)使用深度神经网络参数化Q(sta t )函数,并使用梯度下降算法更新 Q 网络。损失函数是:

$$ L={\left({r}_t+\gamma {Q}_{\theta}\left({s}_{t+1},a\right)-{Q}_{\theta}\left({s}_t,{a}_t\right)\right)}² $$

由于既有训练目标值rt+γQθ(st+1a 和预测值Qθ(s 并且训练数据具有很强的相关性,[1]提出了两种解决问题的措施:通过添加经验中继缓冲区来降低数据的强相关性和通过冻结目标网络技术来固定目标估计网络,稳定训练过程。

重放缓冲池相当于一个大型数据样本缓冲池。每次训练时,最新策略生成的数据对( sars)存储在重放缓冲池中,然后从缓冲池中随机抽取多个数据对( sars)进行训练。这样,可以减少训练数据的强相关性。还可以发现,DQN 算法是一种采样效率高的非策略算法。

冻结目标网络是一种训练技术。训练时,目标网络$$ {Q}_{\underset{_}{\theta }}\left({s}_{t+1},a\right) $$和预测网络Qθ(sta * t )来自同一个网络,但$$ {Q}_{\underset{_}{\theta }}\left({s}_{t+1},a\right) $$网络的更新频率会在Qθ(s 这相当于$$ {Q}_{\underset{_}{\theta }}\left({s}_{t+1},a\right) $$没有更新时处于冻结状态,然后在冻结结束后从Qθ(stat$ L={\left({r}_t+\gamma {Q}_{\underset{_}{\theta }}\left({s}_{t+1},a\right)-{Q}_{\theta}\left({s}_t,{a}_t\right)\right)}² $拉最新的网络参数*

这样,训练过程可以变得更加稳定。

DQN 算法如算法 3 所示。

| **算法三:DQN 算法** | | 随机初始化***θ*****重复****复位并得到游戏初始状态** ***s*****重复**样本行动**【a】****=** ***与环境互动获得奖励 ***r*** 和状态***s***

优化 Q 网络:

【r】( a+1)

更新状态s←**s

直到游戏结束

直到达到要求的训练次数

输出:策略网络(st)*********** |

14.4.6 DQN 变体

虽然 DQN 算法在雅达利游戏平台上取得了巨大突破,但后续研究发现,DQN 的 Q 值往往被高估。鉴于 DQN 算法的缺陷,人们提出了一些不同的算法。

双 DQN 在[6]中,根据损失函数:

$$ L={\left({r}_t+\gamma \underset{_}{Q}\left({s}_{t+1},\underset{a}{\mathit{\max}}Q\left({s}_{t+1},a\right)\right)-Q\left({s}_t,{a}_t\right)\right)}² $$

分离并更新目标$$ {r}_t+\gamma \underset{_}{Q}\left({s}_{t+1},\underset{a}{\mathit{\max}}Q\left({s}_{t+1},a\right)\right) $$的 Q 网络和估计$$ \underset{_}{Q} $$网络

决斗 DQN 。[7]将网络输出分成 V ( s )和 A ( sa ),如图 14-21 所示。然后使用:

$$ Q\left(s,a\right)=V(s)+A\left(s,a\right) $$

生成 Q 函数估计值 Q ( sa )。其余的和 DQN 保持不变。

img/515226_1_En_14_Fig21_HTML.jpg

图 14-21

DQN 网络(上)和决斗 DQN 网络(下)[7]

DQN 实践

这里我们继续实现基于平衡杆游戏环境的 DQN 算法。

Q 网。平衡杆游戏的状态是一个长度为 4 的向量。因此,Q 网络的输入被设计为四个节点。经过 256-256-2 全连接层,得到输出节点数为 2 的 Q 函数估计 Q ( sa )的分布。网络的实现如下:

class Qnet(keras.Model):
    def __init__(self):
        # Create a Q network, the input is the state vector, and the output is the Q value of the action
        super(Qnet, self).__init__()
        self.fc1 = layers.Dense(256, kernel_initializer='he_normal')
        self.fc2 = layers.Dense(256, kernel_initializer='he_normal')
        self.fc3 = layers.Dense(2, kernel_initializer='he_normal')

    def call(self, x, training=None):
        x = tf.nn.relu(self.fc1(x))
        x = tf.nn.relu(self.fc2(x))
        x = self.fc3(x)
        return x

重放缓冲池。DQN 算法中使用重放缓冲池来降低数据之间的强相关性。我们使用 ReplayBuffer 类中的 Deque 对象来实现缓冲池函数。训练时,最新的数据( sars)通过 put (transition)方法存储在 Deque 对象中,n 个数据( sars 、??’)使用 sample 从 Deque 对象中随机抽取重放缓冲池的实现如下:

class ReplayBuffer():
    # Replay buffer pool
    def __init__(self):
        # Deque
        self.buffer = collections.deque(maxlen=buffer_limit)

    def put(self, transition):
        self.buffer.append(transition)

    def sample(self, n):
        # Sample n samples
        mini_batch = random.sample(self.buffer, n)
        s_lst, a_lst, r_lst, s_prime_lst, done_mask_lst = [], [], [], [], []
        # Organize by category
        for transition in mini_batch:
            s, a, r, s_prime, done_mask = transition
            s_lst.append(s)
            a_lst.append([a])
            r_lst.append([r])
            s_prime_lst.append(s_prime)
            done_mask_lst.append([done_mask])
        # Convert to tensor
        return tf.constant(s_lst, dtype=tf.float32),\
                      tf.constant(a_lst, dtype=tf.int32), \
                      tf.constant(r_lst, dtype=tf.float32), \
                      tf.constant(s_prime_lst, dtype=tf.float32), \
                      tf.constant(done_mask_lst, dtype=tf.float32)

政策完善。这里实现了ϵ-贪婪方法。在对行动进行抽样时,有 1 个 ϵ 选择arg arg qπ(sa )的概率,以及 ϵ 随机选择一个行动的概率。

    def sample_action(self, s, epsilon):
        # Send the state vector to get the strategy: [4]
        s = tf.constant(s, dtype=tf.float32)
        # s: [4] => [1,4]
        s = tf.expand_dims(s, axis=0)
        out = self(s)[0]
        coin = random.random()
        # Policy improvement: e-greedy way
        if coin < epsilon:
            # epsilon larger
            return random.randint(0, 1)
        else:  # Q value is larger
            return int(tf.argmax(out))

网络主进程。网络最多训练 10,000 轮。回合开始时,先将游戏复位得到初始状态 s ,从当前 Q 网中采样一个动作与环境交互得到数据对( sars),存储在重放缓冲池中。如果当前重放缓冲池中的样本数量足够,则采样一批数据,根据 TD 误差优化 Q 网络的估计,直到比赛结束。

for n_epi in range(10000):  # Training times
        # The epsilon probability will also be attenuated by 8% to 1%. The more you go, the more you use the action with the highest Q value.
        epsilon = max(0.01, 0.08 - 0.01 * (n_epi / 200))
        s = env.reset()  # Reset environment
        for t in range(600):  # Maximum timestamp of a round
            # if n_epi>1000:
            #     env.render()
            # According to the current Q network, extract and improve the policy.
            a = q.sample_action(s, epsilon)
            # Use improved strategies to interact with the environment
            s_prime, r, done, info = env.step(a)
            done_mask = 0.0 if done else 1.0  # End flag mask
            # Save
            memory.put((s, a, r / 100.0, s_prime, done_mask))
            s = s_prime  # Update state
            score += r  # Record return
            if done:  # End round
                break
        if memory.size() > 2000:  # train if size is greater than 2000
            train(q, q_target, memory, optimizer)
        if n_epi % print_interval == 0 and n_epi != 0:
            for src, dest in zip(q.variables, q_target.variables):
                dest.assign(src)  # weights come from Q

在训练过程中,只有 Q θ 网络会被更新,而$$ {Q}_{\underset{_}{\theta }} $$网络会被冻结。在 Q * θ 网络多次更新后,使用下面的代码将最新的参数从 Q θ * 复制到$$ {Q}_{\underset{_}{\theta }} $$

for src, dest in zip(q.variables, q_target.variables):
                dest.assign(src)  # weights come from Q

优化 Q 网。优化 Q 网的时候会一次训练更新十次。每次从重放缓冲池中随机取样,选择动作$$ {Q}_{\underset{_}{\theta }}\left({s}_{t+1},a\right) $$构造 TD 差。这里我们用平滑 L1 误差来构造 TD 误差:

$$ L=\Big{0.5\ast {\left(x-y\right)}²,\kern0.5em \left|x-y\right|<1\ \left|x-y\right|-0.5,\kern0.5em \left|x-y\right|\ge 1 $$

在 TensorFlow 中,平滑 L1 误差可以使用 Huber 误差来实现,如下所示:

def train(q, q_target, memory, optimizer):
    # Construct the error of Bellman equation through Q network and shadow network.
    # And only update the Q network, the update of the shadow network will lag behind the Q network
    huber = losses.Huber()
    for i in range(10):  # Train 10 times
        # Sample from buffer pool
        s, a, r, s_prime, done_mask = memory.sample(batch_size)
        with tf.GradientTape() as tape:
            # s: [b, 4]
            q_out = q(s)  # Get Q(s,a) distribution
            # Because TF’s gather_nd is different from pytorch’s gather, we need to the coordinates of gather_nd, indices:[b, 2]
            # pi_a = pi.gather(1, a) # pytorch only needs one line.
            indices = tf.expand_dims(tf.range(a.shape[0]), axis=1)
            indices = tf.concat([indices, a], axis=1)
            q_a = tf.gather_nd(q_out, indices) # The probability of action, [b]
            q_a = tf.expand_dims(q_a, axis=1) # [b]=> [b,1]
            # Get the maximum value of Q(s',a). It comes from the shadow network! [b,4]=>[b,2]=>[b,1]
            max_q_prime = tf.reduce_max(q_target(s_prime),axis=1,keepdims=True)
            # Construct the target value of Q(s,a_t)
            target = r + gamma * max_q_prime * done_mask
            # Calcualte error between Q(s,a_t) and target
            loss = huber(q_a, target)
        # Update network
        grads = tape.gradient(loss, q.trainable_variables)
        optimizer.apply_gradients(zip(grads, q.trainable_variables))

14.5 演员-评论家方法

在引入原有的策略梯度算法时,为了减少方差,我们引入了偏差 b 机制:

$$ \frac{\partial J\left(\theta \right)}{\partial \theta }={E}_{\tau \sim {p}_{\theta}\left(\tau \right)}\left[{\sum}_{t=1}^{T-1}\frac{\partial }{\partial \theta } loglog\ {\pi}_{\theta }\ \left({s}_t\right)\left(R\left(\tau \right)-b\right)\right] $$

其中 b 可以用蒙特卡罗方法$$ b=\frac{1}{N}{\sum}_{n=1}NR\left({\tau}{(n)}\right) $$估算。如果将 R ( τ )理解为Qπ(sta * t )的估计值,则将偏差 b 理解为平均水平 V * π * 那么R(τ)—b就是(近似)优势值函数Aπ(sa )。 其中,如果偏置值函数Vπ(st)用神经网络估计,就是演员-评论家法(简称 AC 法)。策略网络πθ(st*)称为 Actor,用于生成策略并与环境交互。$ {V}_{\phi}^{\pi}\left({s}_t\right) $价值网络叫 Critic,用来评价当前状态。 θϕ 分别是演员网和评论家网的参数。

对于演员网络πθ,目标是收益期望最大化,通过$ \frac{\partial J\left(\theta \right)}{\partial \theta } $的偏导数更新策略网络的参数θ:

$$ {\theta}^{\prime}\leftarrow \theta +\eta \bullet \frac{\partial J}{\partial \theta } $$

对于评论家网络$$ {V}_{\phi}^{\pi } $$,目标是通过 MC 方法或 TD 方法获得准确的$$ {V}_{\phi}^{\pi}\left({s}_t\right) $$价值函数估计:

$$ \phi = dist\left({V}_{\phi}{\pi}\left({s}_t\right),{V}_{target}{\pi}\left({s}_t\right)\right) $$

其中 dist(a,b)是 a 和 b 的距离测量器,比如欧几里德距离。$$ {V}_{target}^{\pi}\left({s}_t\right) $$$$ {V}_{\phi}^{\pi}\left({s}_t\right) $$的目标值。用 MC 法估算时,

$$ {V}_{target}^{\pi}\left({s}_t\right)=R\left({\tau}_{t:T}\right) $$

用 TD 法估算时,

$$ {V}_{target}^{\pi}\left({s}_t\right)={r}_t+\gamma {V}^{\pi}\left({s}_{t+1}\right) $$

优势交流算法

使用优势值函数AπT5(sa )的演员-评论家算法称为优势演员-评论家算法。这是目前使用演员-评论家思想的主流算法之一。其实演员-评论家系列算法并不一定要用优势值函数Aπ(sa )。还有其他变种。

优势演员-评论家算法训练时,演员根据当前状态 s t 和策略 π θ 采样获得动作at,然后与环境交互获得下一个状态st+1和奖励TD 方法可以估计每一步的目标值$$ {V}_{target}^{\pi}\left({s}_t\right) $$,从而更新 Critic 网络,使价值网络的估计更接近真实环境的期望收益。$$ {\hat{A}}_t={r}_t+\gamma {V}{\pi}\left({s}_{t+1}\right)-{V}{\pi}\left({s}_t\right) $$用于估计当前动作的优势值,下面的等式用于计算演员网络的梯度 info。$$ {L}^{PG}\left(\theta \right)={\hat{E}}_t\left[ loglog\ {\pi}_{\theta }\ \left({s}_t\right){\hat{A}}_t\right] $$

重复这个过程,评论家网会越来越准,演员网也会调整政策,下次做得更好。

A3C 算法

A3C 算法的全称是异步优势行动者-批评家算法。它是 DeepMind 基于优势行动者-批评家算法[8]提出的异步版本。演员-评论家网络部署在多个线程中进行同步训练,参数通过全局网络同步。。这种异步训练模式大大提高了训练效率;因此训练速度更快,算法性能更好。

如图 14-22 所示,该算法将创建一个新的全局网络和 M 个工作线程。全球网络包含演员和评论家网络,每个线程创建一个新的交互环境,演员和评论家网络。在初始化阶段,全局网络随机初始化参数 θϕ 。Worker 中的演员-评论家网络同步地从全局网络中提取参数来初始化网络。训练时,工人中的演员-评论家网络首先从全局网络中拉取最新参数,然后最新策略πθ(stt17)将采样动作与私人环境进行交互,根据优势演员-评论家算法计算参数 θϕ 的梯度。在完成梯度计算后,每个工人将梯度信息提交给全局网络,并使用全局网络的优化器来完成参数更新。在算法测试阶段,只有全局网络与环境交互。

img/515226_1_En_14_Fig22_HTML.png

图 14-22

A3C 算法

A3C 动手实践

接下来,我们实现异步 A3C 算法。像普通的 Advantage AC 算法一样,需要创建演员-评论家网络。它包含一个演员子网络和一个评论家子网络。有时演员和评论家会共享以前的网络层,以减少网络参数的数量。平衡杆游戏比较简单。我们用一个两层全连通网络来参数化 Actor 网络,另一个两层全连通网络来参数化 Critic 网络。

演员-评论家网络代码如下:

class ActorCritic(keras.Model):
    # Actor-Critic model
    def __init__(self, state_size, action_size):
        super(ActorCritic, self).__init__()
        self.state_size = state_size # state vector length
        self.action_size = action_size # action size
        # Policy network Actor
        self.dense1 = layers.Dense(128, activation='relu')
        self.policy_logits = layers.Dense(action_size)
        # V network Critic
        self.dense2 = layers.Dense(128, activation='relu')
        self.values = layers.Dense(1)

演员-评论家正向传播过程分别计算策略分布πθ(st)和 V 函数估计Vπ(st)。代码如下:

    def call(self, inputs):
        # Get policy distribution Pi(a|s)
        x = self.dense1(inputs)
        logits = self.policy_logits(x)
        # Get v(s)
        v = self.dense2(inputs)
        values = self.values(v)
        return logits, values

工作者线程类。在 Worker 线程中,实现了与 Advantage AC 算法相同的计算过程,只是参数 θϕ 的梯度信息不是直接用于更新 Worker 的演员-评论家网络,而是提交给全局网络进行更新。具体来说,在 Worker 类的初始化阶段,server 对象和 opt 对象分别代表全局网络模型和优化器,并创建私有 ActorCritic 类 client 和交互环境 env。

class Worker(threading.Thread):
    # The variables created here belong to the class, not to the instance, and are shared by all instances
    global_episode = 0 # Round count
    global_avg_return = 0 # Average return
    def __init__(self,  server, opt, result_queue, idx):
        super(Worker, self).__init__()
        self.result_queue = result_queue # Shared queue
        self.server = server # Central model
        self.opt = opt # Central optimizer
        self.client = ActorCritic(4, 2) # Thread private network
        self.worker_idx = idx # Thread id
        self.env = gym.make('CartPole-v0').unwrapped
        self.ep_loss = 0.0

在线程运行阶段,每个线程最多与环境交互 400 轮。在该轮开始时,客户端网络采样动作用于与环境进行交互,并保存到内存对象中。回合结束,训练演员网络和评论家网络,获取参数 θϕ 的梯度信息,调用 opt 优化器对象更新全局网络。

    def run(self):
        total_step = 1
        mem = Memory() # Each worker maintains a memory
        while Worker.global_episode < 400: # Maximum number of frames not reached
            current_state = self.env.reset() # Reset client state
            mem.clear()
            ep_reward = 0.
            ep_steps = 0
            self.ep_loss = 0
            time_count = 0
            done = False
            while not done:
                # Get Pi(a|s),no softmax
                logits, _ = self.client(tf.constant(current_state[None, :],
                                         dtype=tf.float32))
                probs = tf.nn.softmax(logits)
                # Random sample action
                action = np.random.choice(2, p=probs.numpy()[0])
                new_state, reward, done, _ = self.env.step(action) # Interact
                if done:
                    reward = -1
                ep_reward += reward
                mem.store(current_state, action, reward) # Record

                if time_count == 20 or done:
                    # Calculate the error of current client
                    with tf.GradientTape() as tape:
                        total_loss = self.compute_loss(done, new_state, mem)
                    self.ep_loss += float(total_loss)
                    # Calculate error
                    grads = tape.gradient(total_loss, self.client.trainable_weights)
                    # Submit gradient info to server, and update gradient
                    self.opt.apply_gradients(zip(grads,
                                                 self.server.trainable_weights))
                    # Pull latest gradient info from server
                    self.client.set_weights(self.server.get_weights())
                    mem.clear() # Clear Memory
                    time_count = 0

                    if done:  # Calcualte return
                        Worker.global_avg_return = \
                            record(Worker.global_episode, ep_reward, self.worker_idx,
                                   Worker.global_avg_return, self.result_queue,
                                   self.ep_loss, ep_steps)
                        Worker.global_episode += 1
                ep_steps += 1
                time_count += 1
                current_state = new_state
                total_step += 1
        self.result_queue.put(None) # End thread

影评误差计算。当训练每个工人类时,演员和评论家网络的误差计算实现如下。这里我们用蒙特卡罗方法估计目标值$$ {V}_{target}^{\pi}\left({s}_t\right) $$,用$$ {V}_{target}^{\pi}\left({s}_t\right) $$$$ {V}_{\phi}^{\pi}\left({s}_t\right) $$两者之间的距离作为评论家网络的误差函数值 _loss。演员网的策略损失函数 policy_loss 来自$$ -{L}^{PG}\left(\theta \right)=-{\hat{E}}_t\left[ loglog\ {\pi}_{\theta }\ \left({s}_t\right){\hat{A}}_t\right] $$

其中$$ -{\hat{E}}_t\left[ loglog\ {\pi}_{\theta }\ \left({s}_t\right){\hat{A}}_t\right] $$由 TensorFlow 的交叉熵函数实现。各种损失函数汇总后,形成总损失函数并返回。

def compute_loss(self,
                     done,
                     new_state,
                     memory,
                     gamma=0.99):
        if done:
            reward_sum = 0.
        else:
            reward_sum = self.client(tf.constant(new_state[None, :],
                                     dtype=tf.float32))[-1].numpy()[0]
        # Calculate return
        discounted_rewards = []
        for reward in memory.rewards[::-1]:  # reverse buffer r
            reward_sum = reward + gamma * reward_sum
            discounted_rewards.append(reward_sum)
        discounted_rewards.reverse()
        # Get Pi(a|s) and v(s)
        logits, values = self.client(tf.constant(np.vstack(memory.states),
                                 dtype=tf.float32))
        # Calculate advantage = R() - v(s)
        advantage = tf.constant(np.array(discounted_rewards)[:, None],
                                         dtype=tf.float32) - values
        # Critic network loss
        value_loss = advantage ** 2
        # Policy loss
        policy = tf.nn.softmax(logits)
        policy_loss = tf.nn.sparse_softmax_cross_entropy_with_logits(
                        labels=memory.actions, logits=logits)
        # When calculating the policy network loss, the V network is not calculated
        policy_loss *= tf.stop_gradient(advantage)

        entropy = tf.nn.softmax_cross_entropy_with_logits(labels=policy,
                                                 logits=logits)
        policy_loss -= 0.01 * entropy
        # Aggregate each error
        total_loss = tf.reduce_mean((0.5 * value_loss + policy_loss))
        return total_loss

代理人。代理负责整个 A3C 算法的训练。在初始化阶段,代理类创建一个新的全局网络对象服务器及其优化器对象 opt。

class Agent:
    # Agent, include server
    def __init__(self):
        # server optimizer, no client, pull parameters from server
        self.opt = optimizers.Adam(1e-3)
        # Sever model
        self.server = ActorCritic(4, 2) # State vector, action size
        self.server(tf.random.normal((2, 4)))

在训练开始时,创建每个工作者线程对象,并且开始每个线程对象与环境进行交互。当每个 Worker 对象交互时,它将从全局网络中拉出最新的网络参数,并使用最新的策略与环境进行交互,并计算自己的损失。最后,每个工人向全局网络提交梯度信息,并调用 opt 对象优化全局网络。培训代码如下:

    def train(self):
        res_queue = Queue() # Shared queue
        # Create interactive environment
        workers = [Worker(self.server, self.opt, res_queue, i)
                   for i in range(multiprocessing.cpu_count())]
        for i, worker in enumerate(workers):
            print("Starting worker {}".format(i))
            worker.start()
        # Plot return curver
        moving_average_rewards = []
        while True:
            reward = res_queue.get()
            if reward is not None:
                moving_average_rewards.append(reward)
            else: # End
                break
        [w.join() for w in workers] # Quit threads

14.6 摘要

本章介绍了强化学习的问题设置和基本理论,并介绍了解决强化学习问题的两大系列算法:策略梯度法和价值函数法。策略梯度法直接优化策略模型,简单直接,但采样效率低。重要抽样技术可以提高算法的抽样效率。价值函数法采样效率高,易于训练,但需要从价值函数间接推导出策略模型。最后,介绍了结合政策梯度法和价值函数法的行动者-批评家法。我们还介绍了几种典型算法的原理,并利用平衡杆游戏环境进行了算法实现和测试。

14.7 参考

  1. 动词 (verb 的缩写)Mnih、K. Kavukcuoglu、D. Silver、A. A .鲁苏、J. Veness、M. G. Bellemare、A. Graves、M. Riedmiller、A. K. Fidjeland、G. Ostrovski、S. Petersen、C. Beattie、A. Sadik、I. Antonoglou、H. King、D. Kumaran、D. Wierstra、S. Legg 和 D. Hassabis,“通过深度强化学习实现人类水平的控制”,《自然》, 518

  2. D.Silver、A. Huang、C. J、A. Guez、L. Sifre、G. Driessche、J. Schrittwieser、I. Antonoglou、V. Panneershelvam、M. Lanctot、S. Dieleman、D. Grewe、J. Nham、N. Kalchbrenner、I. Sutskever、T. Lillicrap、M. Leach、K. Kavukcuoglu、T. Graepel 和 D. Hassabis,“利用深度神经网络和树搜索掌握围棋

  3. D.Silver、J. Schrittwieser、K. Simonyan、I. Antonoglou、A. Huang、A. Guez、T. Hubert、L. Baker、M. Lai、A. Bolton、Y. Chen、T. Lillicrap、F. Hui、L. Sifre、G. Driessche、T. Graepel 和 D. Hassabis,“掌握没有人类知识的围棋游戏”,《自然》, 550,第 354-10 页 2017。

  4. R.J. Williams,“联结主义强化学习的简单统计梯度跟踪算法”,机器学习, 8,第 229-256 页,1992 年 01 月 5 日。

  5. G.A. Rummery 和 M. Niranjan,“使用连接主义系统的在线 Q-学习”,1994 年。

  6. H.Hasselt,A. Guez 和 D. Silver,“双 Q 学习的深度强化学习”, CoRR, abs/1509.06461,2015。

  7. Z.王,N. Freitas 和 M. Lanctot,“用于深度强化学习的决斗网络架构”, CoRR, abs/1511.06581,2015。

  8. 动词 (verb 的缩写)Mnih,A. P. Badia,M. Mirza,A. Graves,T. P. Lillicrap,T. Harley,D. Silver 和 K. Kavukcuoglu,“深度强化学习的异步方法”, CoRR, abs/1602.01783,2016。

  9. C.J. C. H. Watkins 和 P. Dayan,“Q-learning”,机器学习,1992。

  10. J.舒尔曼,s .莱文,p .阿贝耳,m .乔丹和 p .莫里茨,“信任区域政策优化”,第 32 届机器学习国际会议论文集,里尔,2015 年。

  11. J.舒尔曼,f .沃尔斯基,p .达里瓦尔,a .拉德福德和 o .克里莫夫,“近似政策优化算法”, CoRR, abs/1707.06347,2017。**

十五、定制数据集

花一年时间在人工智能上,足以让人相信上帝的存在。

—阿兰·佩利

深度学习已被广泛用于各种行业,如医学、生物学和金融,并被部署在各种平台上,如互联网和移动终端。前面介绍算法的时候,大部分数据集都是常用的经典数据集。只需几行 TensorFlow 代码就可以完成数据集的下载、加载和预处理,大大提高了研究效率。在实际应用中,对于不同的应用场景,数据集是不同的。对于定制化数据集,使用 TensorFlow 完成数据加载,设计优秀的网络模型训练流程,并将训练好的模型部署到移动和互联网网络等平台,是深度学习算法实现不可或缺的环节。

本章将以图像分类的一个具体应用场景为例,介绍定制数据集下载、数据处理、网络模型设计、迁移学习等一系列实用技术。

15.1 pokemon go 数据集

Pokémon Go 是一款手机游戏,它使用增强现实(AR)技术在户外捕捉和训练神奇宝贝精灵,并使用它们进行战斗。该游戏于 2016 年 7 月在 Android 和 IOS 上推出。一经发布就受到了全世界玩家的追捧。服务器一度因玩家太多而瘫痪。如图 15-1 所示,一个玩家用手机扫描了现实环境,收集了虚拟的神奇宝贝“皮卡丘”。

img/515226_1_En_15_Fig1_HTML.jpg

图 15-1

pokemon 游戏画面

我们使用从网上抓取的神奇宝贝数据集来演示如何使用定制数据集。神奇宝贝数据集总共收集了五种精灵生物:皮卡丘、梅特沃、杰尼龟、小火龙和妙蛙种子。每个精灵的信息见表 15-1 ,共 1168 张图片。这些图片中存在错误标注的样本,因此对错误标注的样本进行人工剔除,共获得 1122 张有效图片。

表 15-1

神奇宝贝数据集信息

| ![img/515226_1_En_15_Figa_HTML.gif](https://gitee.com/OpenDocCN/vkdoc-dl-zh/raw/master/docs/begin-dl-tf/img/515226_1_En_15_Figa_HTML.gif) |

读者可以自行下载提供的数据集文件(链接: https://drive.google.com/file/d/1Db2O4YID7VDcQ5lK0ObnkKy-U1ZZVj7c/view?usp=sharing ,解压后我们可以得到名为 pokemon 的根目录,其中包含五个子文件夹,每个子文件夹的文件名代表图片的类别名称,每个子文件夹下存储有对应的类别如图 15-2 所示。

img/515226_1_En_15_Fig2_HTML.jpg

图 15-2

神奇宝贝数据集存储目录

15.2 定制数据集加载

在实际应用中,样品和样品标签的储存方法可能会有所不同。比如在某些场合,所有的图片都存储在同一个目录下,可以从图片名称中导出类别名称,比如一张图片的文件名为“皮卡丘 _asxes0132.png”。类别信息可以从文件名 pikachu 中提取。有些数据样本的标签信息保存在 JSON 格式的文本文件中,每个样本的标签都需要用 JSON 格式查询。无论数据集如何存储,我们总是可以使用逻辑规则来获取所有样本的路径和标签信息。

我们将定制数据的加载过程抽象为以下步骤。

创建代码表

样本的类别一般用字符串类型的类别名来标记,但对于神经网络来说,类别名需要进行数字编码,然后在适当的时候转换成一键编码或其他编码格式。考虑一个有 n 个类别的数据集,我们将每个类别随机编码成一个数l∈【0,n—1】。类别名称和编号之间的映射关系称为编码表。一旦创建,通常无法更改。

对于神奇宝贝数据集的存储格式,我们通过以下方式创建一个编码表。首先,按顺序遍历口袋妖怪根目录下的所有子目录。对于每个子目标,使用类别名称作为码表字典对象 name2label 的键,使用码表中已有的键-值对的个数作为类别的标签映射号,保存到 name2label 字典对象中。实现如下:

def load_pokemon(root, mode='train'):
    # Create digital dictionary table
    name2label = {}  # Coding dictionary, "sq...":0
    # Traverse the subfolders under the root directory and sort them to ensure that the mapping relationship is fixed
    for name in sorted(os.listdir(os.path.join(root))):
        # Skip non-folder objects
        if not os.path.isdir(os.path.join(root, name)):
            continue
        #  Code a number for each category
        name2label[name] = len(name2label.keys())
          ...

15.2.2 创建样品和标签表单

编码表确定后,我们需要根据实际的数据存储方式,获取每个样本的存储路径及其标签号,分别表示为两个列表对象,图像和标签。图像列表存储每个样本的路径字符串,标签列表存储样本的类别号。两者长度相同,对应位置的元素相互关联。

我们将图像和标签信息存储在 csv 格式文件中,其中 csv 文件格式是纯文本文件格式,数据用逗号分隔,可以用记事本或 MS Excel 软件打开。将所有样本信息存储在一个 csv 文件中有许多优点,例如直接数据集分割和批量采样。csv 文件可以保存数据集中所有样本的信息,或者您可以基于定型集、验证集和测试集创建三个 csv 文件。生成的 csv 文件的内容如图 15-3 所示。每行的第一个元素存储当前样本的存储路径,第二个元素存储样本的类别号。

img/515226_1_En_15_Fig3_HTML.jpg

图 15-3

路径和标签保存在 CSV 文件中

创建 csv 文件的过程是:遍历 pokemon 根目录下的所有图片,记录图片的路径,根据编码表获取编码号,作为一行写入 csv 文件。代码如下:

def load_csv(root, filename, name2label):
    # Return images,labels Lists from csv file
    # root: root directory, filename:csv file name,  name2label:category coding table
    if not os.path.exists(os.path.join(root, filename)):
        # Create csv file if not exist.
        images = []
        for name in name2label.keys(): # Traverse all subdirectories to get all pictures
            # Only consider image files with suffix png,jpg,jpeg:'pokemon\\mewtwo\\00001.png
            images += glob.glob(os.path.join(root, name, '*.png'))
            images += glob.glob(os.path.join(root, name, '*.jpg'))
            images += glob.glob(os.path.join(root, name, '*.jpeg'))
        # Print data info:1167, 'pokemon\\bulbasaur\\00000000.png'
        print(len(images), images)
        random.shuffle(images) # Randomly shuffle
        # Create csv file, and store image path and corresponding label info
        with open(os.path.join(root, filename), mode='w', newline='') as f:
            writer = csv.writer(f)
            for img in images:  # 'pokemon\\bulbasaur\\00000000.png'
                name = img.split(os.sep)[-2]
                label = name2label[name]
                # 'pokemon\\bulbasaur\\00000000.png', 0
                writer.writerow([img, label])
            print('written into csv file:', filename)
              ...

创建 csv 文件后,下次只需要从 csv 文件中读取样本路径和标签信息,而不用每次都生成 csv 文件,提高了计算效率。代码如下:

def load_csv(root, filename, name2label):
    ...
    # At this time there is already a csv file on the file system, read directly
    images, labels = [], []
    with open(os.path.join(root, filename)) as f:
        reader = csv.reader(f)
        for row in reader:
            # 'pokemon\\bulbasaur\\00000000.png', 0
            img, label = row
            label = int(label)
            images.append(img)
            labels.append(label)
    # Return image path list and tag list
    return images, labels

数据集划分

数据集的划分需要根据实际情况灵活调整。当数据集中的样本数量较大时,可以选择 80%-10%-10%的比例分配给训练集、验证集、测试集;当样本数量较少时,比如这里的神奇宝贝数据集中的图片总数只有 1000 张;如果验证集和测试集的比例只有 10%,图片的数量大概是 100 张,那么验证精度和测试精度可能会有很大的波动。对于小数据集,虽然样本量小,但要适当增加验证集和测试集的比例,以保证测试结果准确。这里我们把验证集和测试集的比例设为 20%,也就是有 200 张左右的图片进行验证和测试。

首先调用 load_csv 函数加载图片和标签列表,根据当前模型参数加载相应的图片和标签。具体来说,如果模型参数为 train,则将图像和标签的前 60%数据作为训练集;如果模型参数为 val,图像和标签的 60%到 80%的面积数据作为验证集;如果模型参数是 test,则最后 20%的图像和标签将作为测试集。代码实现如下:

def load_pokemon(root, mode='train'):
    ...
    # Read Label info
    # [file1,file2,], [3,1]
    images, labels = load_csv(root, 'images.csv', name2label)
# Dataset division
    if mode == 'train':  # 60%
        images = images[:int(0.6 * len(images))]
        labels = labels[:int(0.6 * len(labels))]
    elif mode == 'val':  # 20% = 60%->80%
        images = images[int(0.6 * len(images)):int(0.8 * len(images))]
        labels = labels[int(0.6 * len(labels)):int(0.8 * len(labels))]
    else:  # 20% = 80%->100%
        images = images[int(0.8 * len(images)):]
        labels = labels[int(0.8 * len(labels)):]
    return images, labels, name2label

需要注意的是,每次运行的数据集划分方案需要固定,以防止使用测试集进行训练,导致模型泛化性能不准确。

15.3 实践神奇宝贝数据集

介绍完自定义数据集的加载过程,我们再来加载和训练神奇宝贝数据集。

创建数据集对象

首先,通过 load_pokemon 函数返回图像、标签和编码表信息,如下所示:

    # Load the pokemon dataset, specify to load the training set
    # Return the sample path list of the training set, the label number list and the coding table dictionary
    images, labels, table = load_pokemon('pokemon', 'train')
    print('images:', len(images), images)
    print('labels:', len(labels), labels)
    print('table:', table)

构造 Dataset 对象,完成数据集的随机拆分、预处理、批量操作。代码如下:

    # images: string path
    # labels: number
    db = tf.data.Dataset.from_tensor_slices((images, labels))
    db = db.shuffle(1000).map(preprocess).batch(32)

当我们使用 TF . data . dataset . from _ tensor _ slices 构造数据集时,传入的参数是一个由图像和标签组成的元组,所以在迭代 db 对象时,返回( X iY i )的元组对象,其中 X i 是第个的图像张量我们可以通过 TensorBoard 可视化查看每次遍历的图像样本,如下所示:

    # Create TensorBoard summary object
    writter = tf.summary.create_file_writer('logs')
    for step, (x,y) in enumerate(db):
        # x: [32, 224, 224, 3]
        # y: [32]
        with writter.as_default():
            x = denormalize(x) # Denormalize
            # Write in image data
            tf.summary.image('img',x,step=step,max_outputs=9)
            time.sleep(5) # Delay 5s

数据预处理

我们通过调用。构造数据集时的 map(预处理)函数。由于我们的图像列表目前只保存所有图像的路径信息,而不是图像的内容张量,所以需要在预处理功能中完成图像读取和张量转换。

对于预处理函数(x,y) = preprocess(x,y),其传入参数需要以与创建数据集时给出的参数相同的格式保存,返回参数需要以与传入参数相同的格式保存。具体来说,我们在构造数据集时传入( xy )元组对象,其中 x 是所有图片的路径列表, y 是所有图片的标签号列表。考虑到 map 函数的位置是 db = db.shuffle(1000)。映射(预处理)。batch(32),那么预处理的传入参数是( x iy**I),其中 x iy i 分别是第 I 个图片路径字符串和标号。如果映射函数的位置是 db = db.shuffle(1000)。批次(32)。map(预处理),那么预处理的传入参数为( x iyI),其中 x iy i 分别为第 I 批的路径和标签列表。代码如下:

def preprocess(x,y): # preprocess function
    # x: image path, y:image coding number
    x = tf.io.read_file(x) # Read image
    x = tf.image.decode_jpeg(x, channels=3) # Decode image
    x = tf.image.resize(x, [244, 244]) # Resize to 244x244

    # Data augmentation
    # x = tf.image.random_flip_up_down(x)
    x= tf.image.random_flip_left_right(x) # flip left and right
    x = tf.image.random_crop(x, [224, 224, 3]) # Crop to 224x224
    # Convert to tensor and [0, 1] range
    # x: [0,255]=> 0~1
    x = tf.cast(x, dtype=tf.float32) / 255.
    # 0~1 => D(0,1)
    x = normalize(x) # Normalize
    y = tf.convert_to_tensor(y) # To tensor

    return x, y

考虑到我们的数据集规模很小,为了防止过拟合,我们做了少量的数据增强变换,以获得更多的数据。最后,我们将 0~255 范围内的像素值缩放到 0~1 范围内,并对数据进行归一化处理,将像素映射到 0 附近的分布,有利于网络的优化。最后,将数据转换为张量数据并返回。此时,在对 db 对象进行迭代时,返回的数据将是批量形式的张量数据。

标准化后的数据适用于网络训练和预测,但可视化时需要将数据映射回 0~1 的范围。标准化和规范化的逆过程如下:

# The mean and std here are calculated based on real data, such as ImageNet
img_mean = tf.constant([0.485, 0.456, 0.406])
img_std = tf.constant([0.229, 0.224, 0.225])
def normalize(x, mean=img_mean, std=img_std):
    # Normalization function
    # x: [224, 224, 3]
    # mean: [224, 224, 3], std: [3]
    x = (x - mean)/std
    return x

def denormalize(x, mean=img_mean, std=img_std):
    # Denormalization function
    x = x * std + mean
    return x

使用前面的方法,分发创建定型集、验证集和测试集的数据集对象。一般来说,验证集和测试集不直接参与网络参数的优化,不需要随机打乱样本的顺序。

batchsz = 128
# Create training dataset
images, labels, table = load_pokemon('pokemon',mode='train')
db_train = tf.data.Dataset.from_tensor_slices((images, labels))
db_train = db_train.shuffle(1000).map(preprocess).batch(batchsz)
# Create validation dataset
images2, labels2, table = load_pokemon('pokemon',mode='val')
db_val = tf.data.Dataset.from_tensor_slices((images2, labels2))
db_val = db_val.map(preprocess).batch(batchsz)
# Create testing dataset
images3, labels3, table = load_pokemon('pokemon',mode='test')
db_test = tf.data.Dataset.from_tensor_slices((images3, labels3))
db_test = db_test.map(preprocess).batch(batchsz)

创建模型

VGG13、ResNet18 等主流网络模型之前已经介绍并实现,这里不再赘述模型的具体实现细节。常用的网络模型都是在 keras.applications 模块中实现的,比如 VGG 系列、ResNet 系列、DenseNet 系列、MobileNet 系列,这些模型网络只需要一行代码就可以创建。例如:

# Load the DenseNet network model, remove the last fully connected layer, and set the last pooling layer to max pooling
net = keras.applications.DenseNet121(weights=None, include_top=False, pooling='max')
# Set trainable to True, i.e. DenseNet’s parameters will be updated.
net.trainable = True
newnet = keras.Sequential([
    net, # Remove last layer of DenseNet121
    layers.Dense(1024, activation='relu'), # Add fully connected layer
    layers.BatchNormalization(), # Add BN layer
    layers.Dropout(rate=0.5), # Add Dropout layer
    layers.Dense(5) # Set last layer node to 5 according to output categories
])
newnet.build(input_shape=(4,224,224,3))
newnet.summary()

DenseNet121 模型用于创建网络。由于 DenseNet121 最后一层的输出节点设计为 1000,我们去掉了 DenseNet121 的最后一层,根据定制数据集的类别数增加了一个输出节点数为 5 的全连通层。整个设置通过顺序容器重新打包成一个新的网络模型,其中 include_top=False 表示最后一个全连接层被删除,pooling='max '表示 DenseNet121 的最后一个池层被设计为 Max Polling。网络模型结构如图 15-4 所示。

img/515226_1_En_15_Fig4_HTML.png

图 15-4

模型结构图

网络培训和测试

我们直接使用 Keras 提供的 Compile&Fit 方法对网络进行编译和训练。优化器使用最常用的 Adam 优化器,误差函数使用交叉熵损失函数,设置 from_logits=True。我们在训练过程中关注的衡量指标是准确率。网络模型编译代码如下:

# Compile model
newnet.compile(optimizer=optimizers.Adam(lr=5e-4),
               loss=losses.CategoricalCrossentropy(from_logits=True),
               metrics=['accuracy'])

使用拟合函数在训练集上训练模型。历元的每次迭代测试一个验证集。训练时期的最大数量是 100。为了防止过拟合,我们使用提前停止技术,并将提前停止传递到拟合函数的回调参数中,如下所示:

# Model training, support early stopping
history  = newnet.fit(db_train, validation_data=db_val, validation_freq=1, epochs=100,
           callbacks=[early_stopping])

其中 early_stopping 是标准的 EarlyStopping 类。它监控的指标是验证集的准确性。如果验证集的测量结果连续三次没有增加 0.001,则触发提前停止条件,训练结束。

# Create Early Stopping class
early_stopping = EarlyStopping(
    monitor='val_accuracy',
    min_delta=0.001,
    patience=3
)

我们把训练过程中的训练准确率、验证准确率、最终测试集上得到的准确率绘制成曲线,如图 15-5 所示。可以看出,训练准确率增长很快,维持在较高的状态,但验证准确率相对较低,同时也没有得到很大的提升。触发提前停止条件,训练过程快速终止。网络有一点过拟合的问题。

img/515226_1_En_15_Fig5_HTML.jpg

图 15-5

从随机初始化中训练 DenseNet

那么为什么会出现过拟合呢?DensetNet121 模型的层数达到了 121 层,参数数量达到了 700 万,是一个很大的网络模型,而我们的数据集只有 1000 个左右的样本。根据经验,这远远不够训练这么大规模的网络模型,容易出现过拟合。为了减少过拟合,可以使用层数较浅、参数较少的网络模型,或者增加正则化项,甚至可以增加数据集的大小。除了这些方法,另一个有效的方法是迁移学习技术。

15.4 迁移学习

迁移学习的原则

迁移学习是机器学习的一个研究方向。主要研究如何将任务 A 上学习到的知识迁移到任务 B 上,以提高任务 B 上的泛化性能,比如任务 A 是猫狗分类问题,需要训练一个分类器来更好的区分猫狗的图片,任务 B 是牛羊分类问题。可以发现任务 A 和任务 b 中有很多共享的知识,比如可以从毛发、体型、形状、毛色等方面来区分这些动物。所以任务 A 中获得的分类器已经掌握了这部分知识。在训练任务 B 的分类器时,不需要从头开始训练,而是可以对任务 A 上获得的知识进行训练或微调,这与“站在巨人的肩膀上”的想法非常相似。通过迁移在任务 A 上学习到的知识,在任务 B 上训练分类器可以使用更少的样本和更低的训练成本,获得良好的性能。

我们介绍一种相对简单,但是非常常用的迁移学习方法:网络微调技术。对于卷积神经网络,一般认为它可以逐层提取特征。层末网络的抽象特征提取能力更强。输出层一般使用输出节点数与分类网络相同的全连通层作为概率分布预测。对于相似的任务 A 和 B,如果它们的特征提取方法相似,网络的前几层可以重用,后面几层可以根据具体的任务设置从零开始训练。

如图 15-6 所示,左边的网络在任务 A 上训练,学习任务 A 的知识,迁移到任务 B 时,网络模型早期各层的参数可以重用,后期各层可以换成新的网络,从头开始训练。我们称在任务 A 中训练的模型为预训练模型。对于图像分类,在 ImageNet 数据集上预训练的模型是更好的选择。

img/515226_1_En_15_Fig6_HTML.png

图 15-6

神经网络迁移学习示意图

15.4.2 动手迁移学习

基于 DenseNet121,我们用 ImageNet 数据集上预训练的模型参数初始化网络,去掉最后一个全连通层,添加一个新的分类子网络,最后一层的输出节点数设置为 5。

# Load DenseNet model, remove last layer, set last pooling layer as max pooling
# Initilize with pre-trained parameters
net = keras.applications.DenseNet121(weights='imagenet', include_top=False, pooling='max')
# Set trainable to False, i.e. fix the DenseNet parameters
net.trainable = False
newnet = keras.Sequential([
    net, #  DenseNet121 with last layer
    layers.Dense(1024, activation='relu'), # Add fully connected layer
    layers.BatchNormalization(), # Add BN layer
    layers.Dropout(rate=0.5), # Add Dropout layer
    layers.Dense(5) # Set the nodes of last layer to 5
])
newnet.build(input_shape=(4,224,224,3))
newnet.summary()

前面的代码创建 DenseNet121 时,可以通过设置 weights='imagenet '参数返回预训练的 DenseNet121 模型对象,重用的网络层和新的子分类网络通过顺序容器重新打包成新的模型 newnet。在微调阶段,可以通过设置 net.trainable = False 来固定 DenseNet121 部分的参数,即网络的 DenseNet121 部分不需要更新参数,只需要训练新加入的子分类网络,大大减少了实际参与训练的参数数量。当然,你也可以像普通网络一样,通过设置 net.trainable = True 来训练所有参数。即便如此,由于网络的复用部分已经用良好的参数状态初始化,网络仍然可以快速收敛,达到更好的性能。

基于预训练的 DenseNet121 模型,我们在图 15-7 中绘制了训练精度、验证精度和测试精度。与从零开始训练的方法相比,在迁移学习的帮助下,网络学习的速度要快得多,并且只需要很少的样本就可以达到更好的性能,改善非常显著。

img/515226_1_En_15_Fig7_HTML.jpg

图 15-7

根据预先训练的 ImageNet 权重训练 DenseNet

至此,你已经到了这本书的结尾。然而,你的机器学习之旅才刚刚开始。希望这本书能作为你研究或工作时的参考书之一,对你有所帮助!

15.5 摘要

恭喜你!使用流行的深度学习框架 TensorFlow 2,您已经走了很长一段路来学习深度学习的理论和实现。现在你应该不仅能够理解深度学习的基本原理,而且能够使用 TensorFlow 2 开发自己的深度学习模型来解决现实世界的问题。对于真实世界的应用程序,好的模型是不够的。我们需要可靠的操作系统来持续产生高质量的模型结果。鉴于真实世界的数据一直在变化,并且经常包含噪声或误差,这是非常具有挑战性的。因此,一个可靠的机器学习操作系统需要一个健壮的数据处理流水线,实时的模型性能监控,以及适当的机制来重新训练或切换模型,这就引出了机器学习操作(MLOps)的概念。对于有兴趣了解更多关于 MLOps 的信息并了解深度学习的最新应用和发展的读者,deeplearning.ai 提供了很多很好的资源和课程,以及其每周简讯——、批次 。希望这本书能带给你自己有趣的深度学习之旅,促进你的事业和生活!

posted @ 2024-10-02 14:17  绝不原创的飞龙  阅读(29)  评论(0编辑  收藏  举报