Keras-神经网络实用指南-全-
Keras 神经网络实用指南(全)
原文:
annas-archive.org/md5/d340e8e4b7f611ff61fca2a8b0b525a6译者:飞龙
前言
神经网络是一种数学函数,广泛应用于人工智能(AI)和深度学习的各个领域,用来解决各种问题。《动手实践 Keras 神经网络》将从介绍神经网络的核心概念开始,让你深入理解各种神经网络模型的组合应用,并结合真实世界的用例,帮助你更好地理解预测建模和函数逼近的价值。接下来,你将熟悉多种最著名的架构,包括但不限于卷积神经网络(CNNs)、递归神经网络(RNNs)、长短期记忆(LSTM)网络、自编码器和生成对抗网络(GANs),并使用真实的训练数据集进行实践。
我们将探索计算机视觉和自然语言处理(NLP)等认知任务背后的基本理念和实现细节,采用最先进的神经网络架构。我们将学习如何将这些任务结合起来,设计出更强大的推理系统,极大地提高各种个人和商业环境中的生产力。本书从理论和技术角度出发,帮助你直观理解神经网络的内部工作原理。它将涵盖各种常见的用例,包括监督学习、无监督学习和自监督学习任务。在本书的学习过程中,你将学会使用多种网络架构,包括用于图像识别的 CNN、用于自然语言处理的 LSTM、用于强化学习的 Q 网络等。我们将深入研究这些具体架构,并使用行业级框架进行动手实践。
到本书的最后,你将熟悉所有著名的深度学习模型和框架,以及你在将深度学习应用于现实场景、将 AI 融入组织核心的过程中所需的所有选项。
本书适合谁阅读
本书适用于机器学习(ML)从业者、深度学习研究人员和希望通过 Keras 熟悉不同神经网络架构的 AI 爱好者。掌握 Python 编程语言的基本知识是必需的。
为了从本书中获得最大收获
具有一定的 Python 知识将大有裨益。
下载示例代码文件
你可以从 www.packt.com 的账户中下载本书的示例代码文件。如果你是在其他地方购买的本书,可以访问 www.packt.com/support 并注册,文件将直接通过电子邮件发送给你。
你可以通过以下步骤下载代码文件:
- 
登录或在 www.packt.com 注册。 
- 
选择“SUPPORT”标签。 
- 
点击“代码下载与勘误”。 
- 
在搜索框中输入书名,并按照屏幕上的说明操作。 
下载文件后,请确保使用以下最新版本解压或提取文件夹:
- 
WinRAR/7-Zip for Windows 
- 
Zipeg/iZip/UnRarX for Mac 
- 
7-Zip/PeaZip for Linux 
本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Hands-On-Neural-Networks-with-Keras。如果代码有更新,它将会更新到现有的 GitHub 仓库中。
我们还提供了来自我们丰富图书和视频目录的其他代码包,您可以在github.com/PacktPublishing/查看!
下载彩色图片
我们还提供了一个 PDF 文件,包含了本书中使用的截图/图示的彩色图片。您可以在此下载:www.packtpub.com/sites/default/files/downloads/9781789536089_ColorImages.pdf。
使用的约定
本书中使用了多种文本约定。
CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟网址、用户输入和 Twitter 账号。例如:“这指的是张量中存储的数据类型,可以通过调用张量的type()方法进行检查。”
代码块如下所示:
import numpy as np
import keras
from keras.datasets import mnist
from keras.utils import np_utils
当我们希望引起您对代码块中特定部分的注意时,相关的行或项会用粗体显示:
keras.utils.print_summary(model, line_length=None, positions=None,    
                          print_fn=None)
任何命令行输入或输出如下所示:
! pip install keras-vis
粗体:表示新术语、重要单词或屏幕上显示的单词。
警告或重要说明如下所示。
提示和技巧如下所示。
联系我们
我们始终欢迎读者的反馈。
一般反馈:如果您对本书的任何部分有疑问,请在邮件主题中提及书名,并通过customercare@packtpub.com联系我们。
勘误:尽管我们已经尽力确保内容的准确性,但难免会有错误。如果您在本书中发现错误,我们将非常感激您向我们报告。请访问www.packt.com/submit-errata,选择您的书籍,点击“勘误提交表单”链接,并输入相关细节。
盗版:如果您在互联网上发现我们作品的任何非法复制版本,我们将非常感激您提供该位置地址或网站名称。请通过copyright@packt.com联系我们,并提供相关材料的链接。
如果您有兴趣成为作者:如果您在某个主题上有专业知识,并且有兴趣撰写或参与一本书的编写,请访问authors.packtpub.com。
书评
请留下评论。阅读并使用本书后,为什么不在您购买书籍的网站上留下评论呢?潜在的读者可以看到并参考您公正的意见来做出购买决策,我们在 Packt 也能了解您对我们产品的看法,而我们的作者也能看到您对他们书籍的反馈。感谢您!
欲了解更多关于 Packt 的信息,请访问packt.com。
第一部分:神经网络基础
本节将帮助读者熟悉神经网络的基础操作,如何选择合适的数据,标准化特征,并从零开始执行数据处理管道。读者将学习如何将理想的超参数与适当的激活函数、损失函数和优化器进行配对。完成后,读者将能够使用真实世界的数据,在最重要的框架上架构和测试深度学习模型。
本节包含以下章节:
- 
第一章,神经网络概述 
- 
第二章,深入探讨神经网络 
- 
第三章,信号处理——使用神经网络进行数据分析 
第一章:神经网络概述
向你问好,同胞;欢迎加入我们这段激动人心的旅程。这段旅程的核心是理解一个极具潜力的计算范式背后的概念和内在运作:人工神经网络(ANN)。虽然这个概念已经存在近半个世纪,但其诞生时的思想(例如什么是智能体,或智能体如何从环境中学习)可以追溯到亚里士多德时代,甚至可能追溯到文明的黎明。遗憾的是,亚里士多德时代的人们并未拥有今天我们所拥有的大数据普及,或者图形处理单元(GPU)加速和大规模并行计算的速度,这为我们打开了非常有前景的道路。如今我们生活在一个时代,在这个时代,大多数人类种群都能接触到构建人工智能系统所需的基础工具和资源。虽然涵盖从过去到今天的整个发展历程稍微超出了本书的范围,但我们将尝试简要总结一些关键概念和思想,帮助我们直观地思考我们在此面临的问题。
本章我们将涵盖以下主题:
- 
定义我们的目标 
- 
了解我们的工具 
- 
理解神经网络 
- 
观察大脑 
- 
信息建模与功能表示 
- 
数据科学中的一些基础复习 
定义我们的目标
本质上,我们的任务是构思一个能够处理任何输入数据的机制。在这个过程中,我们希望这个机制能够检测数据中潜在的模式,并将其利用为我们带来利益。成功完成这个任务意味着我们将能够将任何形式的原始数据转化为知识,进而形成可操作的商业洞察、减轻负担的服务,或是救命的药物。因此,我们真正想要的是构建一个能够普遍近似任何可能代表我们数据的函数的机制;如果你愿意,可以称之为知识的灵丹妙药。请暂时退后一步,想象一下这样的世界;一个能够在几分钟内治愈最致命疾病的世界。一个所有人都能获得食物、每个人都可以选择无惧迫害、骚扰或贫困地追求任何学科人类成就巅峰的世界。这个承诺是否过于遥不可及?或许吧。实现这个理想社会不仅仅需要设计高效的计算机系统。它还需要我们在道德观念上同步进化,重新思考作为个体、物种和整体我们在这个星球上的位置。但你会惊讶于计算机能在多大程度上帮助我们实现这一目标。
这里需要理解的是,我们谈论的并不仅仅是任何一种计算机系统。这与我们的计算先驱们(如巴贝奇和图灵)所处理的内容截然不同。这并不是一个简单的图灵机或差分机(尽管我们将要回顾的许多概念直接与这些伟大的思想家和他们的发明相关)。因此,我们的目标是涵盖从几百年、如果不是几十年,科学研究中关于生成智能这一基本概念的学术贡献、实际实验和实现见解;这个概念可以说是最本能地属于我们人类,但却少有人真正理解。
了解我们的工具
我们将主要使用目前最受欢迎的两个深度学习框架,这些框架对公众免费开放。这并不意味着我们将完全局限于这两个平台进行实现和练习。也可能会遇到我们尝试其他著名的深度学习框架和后端。不过,由于 TensorFlow 和 Keras 的广泛流行、大量的支持社区以及它们在与其他著名后端和前端框架(如 Theano、Caffe 或 Node.js)接口的灵活性,我们将尽量使用它们。接下来我们将提供一些关于 Keras 和 TensorFlow 的背景信息:

Keras
许多人称 Keras 为深度学习的通用语言,因为它的用户友好性、模块化和可扩展性。Keras 是一个高层次的神经网络应用程序编程接口,专注于快速实验的实现。它是用 Python 编写的,可以在 TensorFlow 或 Keras 等后端上运行。Keras 最初是作为 ONEIROS(开放式神经电子智能机器人操作系统)项目的研究工作的一部分开发的。它的名字来源于希腊语单词,  ,字面意思是角。这个词暗指一段古希腊文学中的文字游戏,指的是阿马尔忒亚的角(也称为丰饶之角),这是丰盈和繁荣的永恒象征。
,字面意思是角。这个词暗指一段古希腊文学中的文字游戏,指的是阿马尔忒亚的角(也称为丰饶之角),这是丰盈和繁荣的永恒象征。
Keras 的一些功能包括以下内容:
- 
简单快速的原型开发 
- 
支持多种最新神经网络架构的实现,以及预训练模型和练习数据集 
- 
在 CPU 和 GPU 上完美执行 
TensorFlow
TensorFlow 是一个开源软件库,用于高性能数值计算,使用一种叫做张量的数据表示方法。它让像我和你这样的人能够实现所谓的数据流图。数据流图本质上是一种结构,描述了数据如何在网络中移动,或者在一系列处理神经元中流动。网络中的每个神经元代表一个数学运算,每个神经元之间的连接(或边)是一个多维数据数组,或称为张量。通过这种方式,TensorFlow 提供了一个灵活的 API,允许在各种平台(如 CPU、GPU 及其自有的张量处理单元(TPUs))上轻松部署计算,涵盖从桌面到服务器集群,再到移动设备和边缘设备。最初由 Google Brain 团队的研究人员和工程师开发,它提供了一个出色的编程接口,支持神经网络设计和深度学习。
神经学习的基础知识
我们的旅程从试图获得学习概念的基本理解开始。此外,我们真正感兴趣的是,像学习这样一个丰富而复杂的现象是如何在被许多人称为人类已知最先进的计算机上实现的。正如我们将观察到的那样,科学家们似乎不断从我们自身生物神经网络的内部运作中获得灵感。如果大自然确实已经找到了利用外部世界的松散连接信号,并将其拼接成一个连续的响应和适应性意识流的方法(这是大多数人类都会认同的),我们确实希望了解它是如何做到这一点的。然而,在我们进入这些话题之前,我们必须建立一个基准,以理解为什么神经网络的概念与大多数现代机器学习(ML)技术有如此大的不同。
什么是神经网络?
将神经网络与我们目前为止所知道的任何其他算法问题解决方式进行比较是非常困难的。例如,线性回归仅仅处理计算一条最佳拟合线,该线是相对于从绘制的观察点中计算的平方误差的均值。而类似地,质心聚类则是通过计算相似点之间的理想距离,递归地分离数据,直到达到渐近配置。
而神经网络则没有那么容易解释,原因有很多。一个看待这个问题的方式是,神经网络本身是一个由不同算法组成的算法,在数据传播的过程中执行更小的局部计算。这里所描述的神经网络定义,当然不是完整的。我们将在本书中通过讨论更复杂的概念和神经网络架构,逐步完善这一定义。不过,现在我们可以从一个外行的定义开始:神经网络是一种机制,能够自动学习你提供的输入(如图像)与你关心的输出之间的关联(也就是判断图像中是狗、猫还是攻击直升机)。
现在,我们对神经网络有了初步的理解——它是一种接受输入并学习关联以预测输出的机制。这个多功能的机制当然不限于仅仅接收图像作为输入。事实上,这样的网络同样能够接收一些文本或录制的音频作为输入,并猜测它是在看《哈姆雷特》还是在听《比莉·简》。但是,如何使这样的机制能够应对数据在形式和大小上的多样性,同时仍然产生相关的结果呢?为了理解这一点,许多学者发现,研究自然界是解决这一问题的一个有效途径。实际上,地球上经过数百万年的进化,经历了基因突变和环境变化,已经产生了一种非常相似的机制。更妙的是,大自然甚至为我们每个人配备了一个这种通用功能逼近器的版本,就在我们的双耳之间!我们当然在说的是人类大脑。
观察大脑
在我们简要探讨这一著名类比之前,有必要在此澄清,这确实只是一个类比,而不是平行比较。我们并不提议神经网络的工作方式完全与我们的大脑相同,因为这样不仅会激怒不少神经科学家,还无法公正地评价哺乳动物大脑解剖学所代表的工程奇迹。然而,这一类比有助于我们更好地理解工作流程,以便设计能够从数据中提取相关模式的系统。人类大脑的多功能性,无论是在创作音乐乐团、艺术杰作,还是在开创科学设备如大型强子对撞机方面,展示了同一结构如何学习并应用高度复杂和专业的知识,完成伟大的壮举。事实证明,大自然真的是一个相当聪明的存在,因此,我们可以通过观察它如何实现像学习代理这样新颖的事物,获得许多宝贵的经验。
构建生物大脑
夸克构成原子,原子构成分子,分子聚集在一起,偶尔可能构成可电刺激的生物机械单元。我们将这些单元称为细胞;它们是所有生物生命形式的基本构建模块。现在,细胞本身种类繁多,但其中有一种特定类型对我们来说很有意义。那就是一类特定的细胞,叫做神经细胞或神经元。为什么呢?事实证明,如果你将约 10¹¹个神经元按照特定且互补的配置排列,它们就能组成一个能够发现火、农业和太空旅行的器官。然而,要了解这些神经元如何学习,我们首先需要理解单个神经元是如何工作的。正如你将看到的,正是我们大脑中这些神经元所组成的重复架构,催生了我们(自负地)称之为智能的更宏大的现象。
神经元的生理学
神经元只是一个能够接受、处理和通过电信号和化学信号传递信息的电刺激细胞。树突从神经元细胞体延伸出来,接收来自其他神经元的信息。当我们说神经元接收或发送信息时,实际上是指它们沿着轴突传递电信号。最后,神经元是可兴奋的。换句话说,向神经元提供合适的电刺激将引发电事件,这些电事件被称为动作电位。当神经元达到其动作电位(或尖峰)时,它会释放神经递质,这是一种通过突触传播到其他神经元的化学物质。每当神经元发生尖峰时,神经递质会从它的数百个突触中释放出来,进入其他神经元的树突,这些神经元可能会或可能不会发生尖峰,这取决于刺激的性质。这正是让这些庞大的神经网络相互通信、计算并协同工作来解决我们每天面对的复杂任务的方式:

所以,神经元真正做的事情就是接收一些电输入,进行某种处理,然后如果结果是积极的,就发射信号,或者如果处理结果是消极的,则保持不活跃。我们这里所说的结果是积极的是什么意思呢?要理解这一点,有必要稍微了解一下我们大脑中信息和知识是如何表示的。
信息表示
假设有一个任务,你需要正确地对狗、猫和攻击直升机的图像进行分类。可以这样理解神经学习系统:我们为这三类图像的不同特征分配了多个神经元。换句话说,假设我们为分类任务分配了三个专家神经元。每一个神经元都是狗、猫和攻击直升机的外观方面的专家。
他们为什么是专家呢?嗯,目前我们可以认为,我们每个领域专家的神经元都有自己的员工和支持人员来提供支持,所有人都在为这些专家勤奋工作,收集和表示不同种类的狗、猫和攻击直升机。但我们暂时不涉及他们的支持人员。目前,我们仅仅将任何图像呈现给我们的三位领域专家。如果图像是一只狗,我们的狗专家神经元立刻识别出这种生物并激活,几乎就像它在说,你好,我相信这是一只狗。相信我,我是专家。类似地,当我们将一张猫的图片呈现给我们的三位专家时,我们的猫神经元会通过激活告诉我们它们已经检测到图像中的猫。虽然这并不完全代表每个神经元如何表示现实世界中的物体,如猫和狗,但它帮助我们获得对基于神经元的学习系统的功能性理解。希望你现在有足够的信息,可以被介绍给生物神经元的“更不复杂”的兄弟——人工神经元。
神经编码的奥秘
实际上,许多神经科学家认为,像我们的猫专家神经元这样统一代表性神经元的想法并不真实存在于我们的大脑中。他们指出,这样的机制要求我们的脑袋拥有成千上万只神经元,只专门用于识别我们已知的特定面孔,比如我们的祖母、街角的面包师或唐纳德·特朗普。相反,他们假设了一个更分布式的表示架构。这种分布式理论认为,特定的刺激,比如一张猫的图片,是通过一组独特的神经元激活模式来表示的,这些神经元广泛分布在大脑中。换句话说,一只猫可能由大约(这只是一个猜测)100 个不同的神经元表示,每个神经元都专门负责识别图片中的特定猫类特征(比如耳朵、尾巴、眼睛和总体体型)。这里的直觉是,这些猫神经元中的一些可能与其他神经元重新组合,用于表示包含猫元素的其他图像。例如, jaguar 的图片,或卡通猫加菲猫,可能通过使用相同猫神经元的一个子集来重建,同时结合一些已经学习了关于美洲豹体型或加菲猫著名的橙色和黑色条纹的神经元。
分布式表示与学习
在一些令人好奇的医学案例中,头部受到身体创伤的患者不仅未能与他们的亲人产生联系,甚至声称这些亲人只是伪装成他们的亲人!虽然这是一个离奇的事件,但这种情况可能为我们揭示神经学习的具体机制。显然,患者能够识别这个人,因为一些编码视觉模式的神经元(如面部和衣物特征)被激活了。然而,由于他们尽管能够识别这些亲人,却报告自己与这些亲人失去了联系,这意味着当患者遇到他们的亲人时,所有正常情况下会被激活的神经元(包括编码情感反应的神经元)都没有被激活。
这些分布式表示方式可能让我们的脑袋在从极少的数据中推断模式时具有灵活性,正如我们观察到自己能够做到的那样。例如,现代神经网络仍然需要你提供数百张(如果不是数千张)图像,才能可靠地预测它是否在看一辆公交车或一台烤面包机。而我的三岁侄女,另一方面,能够凭借大约三到五张公交车和烤面包机的图片,就能达到相同的准确性。更令人着迷的是,运行在你电脑上的神经网络,有时需要耗费数千瓦的能源来进行计算。而我的侄女只需要 12 瓦特。她会从几块饼干中获得所需的能量,或者也许从她小心翼翼地从厨房偷走的一小块蛋糕中得到。
数据科学基础
让我们来了解一下数据科学的一些基本术语和概念。我们将探讨一些理论内容,然后继续理解一些复杂的术语,如熵和维度。
信息理论
在深入探讨各种网络架构和一些实践案例之前,如果我们不对通过处理现实世界信号来获取信息这一关键概念进行一些阐述,那就太遗憾了。我们讨论的是量化信号中信息量的科学,也就是信息理论。虽然我们不打算提供关于这一概念的深度数学概述,但了解一些基于概率的学习背景是很有用的。
直观上,得知一个不太可能发生的事件已经发生,比得知一个预期事件已经发生更具信息量。如果我告诉你今天你可以在所有超市购买食物,你不会感到惊讶。为什么?嗯,我实际上并没有告诉你一些超出预期的信息。相反,如果我告诉你今天不能在所有超市购买食物,可能是因为某种大规模罢工,那么你可能会感到惊讶。你会感到惊讶是因为呈现了一个不太可能的信息(在我们的例子中,就是“不”这个词出现在之前的句子配置中)。这种直观的知识是我们试图在信息理论领域中加以规范的。其他类似的概念包括以下几点:
- 
一个发生可能性较低的事件应该具有较低的信息内容。 
- 
一个发生可能性更高的事件应该具有更高的信息内容。 
- 
一个必定发生的事件应该没有信息内容。 
- 
一个具有独立发生可能性的事件应该具有加法性的信息内容。 
在数学上,我们可以通过使用简单的方程来满足所有这些条件,该方程用于建模事件(x)的自信息,公式如下:

l(x)以nat为单位,表示通过观察概率为1/e的事件所获得的信息量。尽管前面的方程式简洁明了,但它仅允许我们处理单一结果;这在建模现实世界的复杂依赖性时并不太有帮助。如果我们想量化整个事件概率分布中的不确定性怎么办?那么,我们就使用另一种度量方法,称为香农熵,如下方程所示:

熵
假设你是一名被困在敌军后方的士兵。你的目标是让盟友知道敌人正在朝他们进发的方向。敌人有时会派出坦克,但更常见的是派出巡逻队。现在,你唯一能通知朋友们的方法是使用简单的二进制信号发射器。你需要弄清楚最好的沟通方式,以免浪费宝贵的时间并被敌人发现。你该如何做呢?首先,你需要规划出许多二进制信号序列,每个特定的序列对应一种特定类型的敌人(如巡逻队或坦克)。凭借对环境的一些了解,你已经知道巡逻队比坦克更常见。因此,合理的推断是,你很可能会比使用 坦克 信号更频繁地使用 巡逻队 信号。因此,你会分配较少的二进制比特来传达巡逻队的存在,因为你知道你会比其他信号更频繁地发送这个信号。你正在利用你对敌人类型分布的了解,减少平均需要发送的比特数。事实上,如果你能够访问来袭巡逻队和坦克的整体基础分布,那么理论上你可以使用最少的比特数来最有效地与对面友军沟通。我们通过在每次传输时使用最佳比特数来实现这一点。表示一个信号所需的比特数被称为数据的熵,可以用以下方程来表示:

这里,H(y) 表示一个函数,表示用概率分布 y 来表示一个事件的最佳比特数。y[i] 只是指另一个事件 i 的概率。因此,假设看到敌方巡逻队的概率是看到敌方坦克的 256 倍,我们将按如下方式建模用于编码敌方巡逻队存在的比特数:
巡逻队比特 = log(1/256pTank)
= log(1/pTank) + log(1/(2^8))
= 坦克比特 - 8
交叉熵
交叉熵是另一个数学概念,它允许我们比较两个不同的概率分布,分别用p和q表示。事实上,正如你稍后会看到的,当处理分类特征时,我们经常在神经网络中使用基于熵的损失函数。从本质上讲,两个概率分布(en.wikipedia.org/wiki/Probability_distribution)之间的交叉熵,(p, q),是在相同的事件集合上,衡量为了识别从该集合中随机选出的一个事件所需的平均信息量,前提是所使用的编码方案是针对预测的概率分布进行优化,而不是真实分布。我们将在后续章节中重新探讨这一概念,以澄清并实现我们的理解:

数据处理的本质
之前,我们讨论了神经元如何通过电信号传播信息,并利用化学反应与其他神经元进行交流。这些神经元帮助我们判断猫或狗长什么样。但这些神经元从来没有真正看到过完整的猫的图像。它们处理的只是化学和电信号。这些神经元网络能够完成任务,仅仅因为有其他感官预处理器官(如我们的眼睛和视神经)在为神经元提供适当格式的数据,使其能够进行解读。我们的眼睛接收表示猫图像的电磁辐射(或光),并将其转换为高效的表示形式,通过电信号传递出去。因此,人工神经元与生物神经元的一个主要区别就在于它们之间交流的媒介。如我们所见,生物神经元通过化学物质和电信号进行通信。类似地,人工神经元依赖于数学这一通用语言来表示数据中的模式。实际上,围绕着将现实世界现象以数学形式表示的概念,存在一个完整的学科,旨在从中提取知识。正如许多人所熟悉的,这个学科被称为数据科学。
从数据科学到机器学习
拿起任何一本关于数据科学的书;你很可能会遇到一些复杂的解释,涉及到统计学、计算机科学等领域的交叉,以及一些领域知识。当你快速翻阅书页时,你会注意到一些漂亮的可视化图表、条形图——这些都是数据科学的经典呈现形式。你会接触到统计模型、显著性检验、数据结构和算法,每一种都能为某些演示案例提供令人印象深刻的结果。这些并不是数据科学。这些确实是你作为一名成功数据科学家将使用的工具。然而,数据科学的本质可以用更简单的方式来概括:数据科学是一个科学领域,专注于从原始数据中生成可操作的知识。这是通过反复观察现实世界的问题,量化不同维度或特征中的整体现象,并预测未来的结果,以实现期望的目标。机器学习(ML)仅仅是教机器数据科学的学科。
虽然一些计算机科学家可能会欣赏这种递归定义,但你们中的一些人可能会思考什么是量化现象。嗯,你看,现实世界中的大多数观察,无论是你吃了多少食物、你看什么类型的节目,还是你喜欢穿什么颜色的衣服,都可以定义为(近似的)其他某些准依赖特征的函数。例如,你在某一天会吃多少食物,可以定义为其他因素的函数,比如你在上一餐吃了多少食物、你对某些类型食物的偏好,甚至是你进行的体力活动量。
类似地,你喜欢观看的节目可以通过一些特征来近似,例如你的个性特征、兴趣和日程中可用的空闲时间。简言之,我们通过量化和表示观察之间的差异(例如,不同人群的观看习惯),来推导出机器可以使用的功能性预测规则。
我们通过定义我们试图预测的可能结果(即某人是否喜欢喜剧或惊悚片)来诱导这些规则,作为输入特征的函数(即我们在观察现象时收集的关于此人的大五人格测试排名),这在广义上涉及对现象的观察(例如,人口的个性特征和观看习惯):

如果你选择了正确的特征集,你将能够推导出一个可靠的函数,该函数能够准确预测你感兴趣的输出类别(在我们的例子中,这是观众的偏好)。我所说的正确特征是什么意思?很显然,观看习惯更多地与一个人的个性特征相关,而不是与他们的旅行习惯相关。例如,通过眼睛颜色和实时 GPS 坐标来预测某人是否倾向于观看恐怖片几乎毫无意义,因为这些信息与我们试图预测的内容没有关联。因此,我们总是选择相关的特征(通过领域知识或显著性检验)来简洁地表示一个现实世界的现象。然后,我们只是用这个表示来预测我们感兴趣的未来结果。这个表示本身就是我们所称的预测模型。
在高维空间中建模数据
如你所见,我们可以通过将实际世界的观察结果重新定义为不同特征的函数来表示它们。例如,一个物体的速度是它在给定时间内所行驶的距离的函数。类似地,电视屏幕上像素的颜色实际上是由构成该像素的红、绿、蓝三种颜色强度值所决定的。这些元素就是数据科学家所称的数据特征或维度。当我们拥有已标记的维度时,我们处理的是监督学习任务,因为我们可以根据实际情况检查我们模型的学习效果。当我们拥有未标记的维度时,我们通过计算观察点之间的距离来找出数据中相似的群体。这就是所谓的无监督机器学习。因此,通过这种方式,我们可以通过简单地使用信息特征来表示它,从而开始构建一个现实世界现象的模型。
维度的诅咒
接下来的自然问题是:我们到底是如何构建一个模型的?简而言之,我们在观察一个结果时选择收集的特征都可以绘制在高维空间中。虽然这听起来很复杂,但它仅仅是你在高中数学中可能熟悉的笛卡尔坐标系统的扩展。让我们回忆一下如何使用笛卡尔坐标系统在图表上表示一个点。对于这个任务,我们需要两个值,x 和 y。这是一个二维特征空间的示例,其中 x 和 y 轴分别是表示空间中的一个维度。如果再加上一个 z 轴,我们就得到了一个三维特征空间。本质上,我们在一个 n 维特征空间中定义机器学习问题,其中 n 表示我们试图预测的现象中的特征数量。在我们之前预测观众偏好的例子中,如果我们仅使用大五人格测试的分数作为输入特征,那么我们实际上拥有一个五维特征空间,其中每个维度对应一个人五个性维度之一的得分。事实上,现代机器学习问题的维度可以从 100 到 100,000(有时甚至更多)。由于特征数量的不同配置的可能性随着特征数量的增加而指数级增长,因此即使是计算机,也很难在如此大的比例下进行构思和计算。这个在机器学习中出现的问题通常被称为 维度诅咒。
算法计算与预测模型
一旦我们拥有了相关数据的高维表示,我们就可以开始推导预测函数的任务。我们通过使用算法来实现这一点,算法本质上是一组预编程的递归指令,用于以某种方式对我们的高维数据表示进行分类和划分。这些算法(最常见的有聚类、分类和回归)递归地将我们的数据点(即每个人的个性排名)在特征空间中划分成更小的组,在这些组内数据点相对更相似。通过这种方式,我们使用算法迭代地将我们的高维特征空间划分成更小的区域,这些区域最终将对应我们的输出类别(理想情况下)。因此,我们可以通过简单地将任何未来的数据点放置到我们的高维特征空间中,并将其与模型预测输出类别对应的区域进行比较,从而可靠地预测其输出类别。恭喜,我们已经有了一个预测模型!
匹配模型与使用案例
每次我们选择将一个观察定义为某些特征的函数时,我们就打开了一个潘多拉魔盒,里面是半因果相关的特征,其中每个特征本身也可能被重新定义(或量化)为其他特征的函数。这样做时,我们可能需要退一步,思考我们到底在尝试表示什么。我们的模型是否捕捉到了相关的模式?我们可以依赖我们的数据吗?我们的资源——无论是算法还是计算能力——是否足够从我们拥有的数据中学习?
回忆我们之前讨论的预测个人每餐可能消耗的食物数量的场景。我们讨论过的特征,例如他们的体力活动,可以重新定义为其代谢和荷尔蒙活动的函数。类似地,饮食偏好可以重新定义为肠道细菌和大便组成的函数。每一次这样的重新定义都会为我们的模型增加新的特征,并带来额外的复杂性。
也许我们甚至可以更准确地预测你应该点多少外卖。是否值得为了每天做一次胃部活检?或者在你的厕所里安装一台最先进的电子显微镜?你们大多数人会同意:不,完全不值得。我们是如何达成这个共识的?仅仅通过评估我们的饮食预测用例,并选择足够相关的特征来预测我们想要预测的内容,以一种可靠且与我们的情况相称的方式。一个复杂的模型,配合高质量的硬件(比如厕所传感器),对于饮食预测这个用例来说既不必要也不现实。你完全可以基于一些易于获取的特征,例如购买历史和以往偏好,来实现一个功能性预测模型。
这个故事的本质是,你可以将任何可观察现象定义为其他现象的函数,以递归的方式进行,但聪明的数据科学家会知道何时停止,通过选择适合你的用例的合适特征来停止;这些特征是易于观察和验证的;并且能稳健地处理所有相关情况。我们所需要的仅仅是逼近一个函数,能够可靠地预测数据点的输出类别。过于复杂或过于简单的现象表示自然会导致我们的机器学习项目失败。
函数表示
在我们继续进行理解、构建和掌握神经网络的旅程之前,至少要刷新一下我们对一些基础机器学习概念的认识。例如,理解一个关键点:你从来不会完全地建模一个现象,你只是在功能上表示它的一部分。这有助于你直观地思考数据,将其视为理解过程中大拼图中的一小部分。它还帮助你意识到,时间在变化,特征的重要性以及周围环境的变化都可能影响模型的预测能力。这种直觉通过实践和领域知识自然形成。
在接下来的部分,我们将通过一些简单的场景驱动示例,简要回顾机器学习应用中的一些经典陷阱。这样做很重要,因为在我们将神经网络应用到各种用例时,我们会发现这些相同的问题再次出现。
机器学习的陷阱
设想一个天气预报预测问题。我们将通过进行特征选择来构建我们的预测模型。凭借一些领域知识,我们首先识别出特征气压是一个相关的预测因子。我们将在夏威夷岛记录不同天数的Pa值(帕斯卡,气压的测量单位),其中一些日子是晴天,另一些是雨天。
不平衡的类别先验
在经历了几天的晴天后,你的预测模型告诉你第二天也有很高的可能性是晴天,但实际却下雨了。为什么?这只是因为你的模型没有看到足够的两种预测类别(晴天和雨天)的实例,无法准确评估下雨的概率。在这种情况下,模型存在不平衡的类别先验,这会误导整体天气模式的判断。根据你的模型,只有晴天,因为它到目前为止只看到了晴天。
欠拟合
你收集了大约两个月的气压数据,并平衡了每个输出类别中的观测值数量。你的预测准确率稳步提升,但在达到一个次优水平后(假设是 61%)开始趋于平稳。突然之间,随着外面越来越冷,你的模型准确率开始再次下降。这里我们面临的是欠拟合问题,因为我们简单的模型无法捕捉到数据的潜在模式,这种模式是由冬季的季节性变化引起的。对此情况有一些简单的解决办法。最明显的做法是通过增加更多的预测特征来改进模型,比如增加外部温度这一变量。这样做后,我们观察到在几天的数据收集后,准确率再次上升,因为额外的特征为模型提供了更多信息,提升了其预测能力。在其他欠拟合的情况下,我们可能会选择更计算密集的预测模型,增加更多数据,工程化地优化特征,或者减少模型中的数学约束(例如正则化的 lambda 超参数)。
过拟合
在收集了大约几年的数据后,你自信地对你的农民朋友吹嘘,称你已经开发了一个预测准确率为 96% 的强大模型。你的朋友说,太好了,我能用这个吗? 作为一个利他主义者和慈善家,你立刻同意并把代码发给他。一天后,同一个朋友从他位于中国广东省的家里打电话回来,生气地说你的模型没能工作,并且毁掉了他的作物收成。发生了什么事?这其实只是一个将我们的模型过拟合到夏威夷热带气候的例子,导致模型无法很好地推广到其他样本之外。我们的模型没有看到气压和温度的足够变化,缺少了与晴天和雨天相对应的标签,无法充分预测另一个大陆的天气。实际上,由于我们的模型只看到了夏威夷的温度和气压,它记住了数据中的一些微不足道的模式(例如,两天连着的雨天是从未出现过的),并且将这些模式作为预测规则,而不是抓住更有信息量的趋势。当然,这里的一个简单解决办法是收集更多中国的天气数据,并根据当地的天气动态来微调你的预测模型。在其他类似的过拟合情况下,你可以尝试选择一个更简单的模型,通过去除离群值和错误来去噪数据,并使数据围绕均值进行中心化。
错误数据
在向你亲爱的中国朋友(以下简称“Chan”)解释刚刚发生的误算后,你指示他设置传感器并开始收集本地的气压和温度数据,以构建一个标注的晴天和雨天数据集,就像你在夏威夷做的那样。Chan 勤奋地将传感器安装在他的屋顶和田地中。不幸的是,Chan 的屋顶由高热导性的强化金属合金制成,这种材料会不规则地使屋顶的气压和温度传感器读数波动,导致读数不一致且不可靠。将这些损坏的数据输入到我们的预测模型中,自然会产生次优的结果,因为学到的线条被噪声和不具有代表性的数据干扰了。一个明显的解决方法是更换传感器,或者干脆丢弃有问题的传感器读数。
无关特征和标签
最终,通过使用来自夏威夷、中国和世界其他地方的足够数据,我们注意到一个明显的、全球普适的模式,这个模式可以用来预测天气。所以,每个人都很开心,直到有一天,你的预测模型告诉你今天将是一个明媚的晴天,结果却有龙卷风来敲门。发生了什么?我们哪里出错了?事实证明,当涉及到龙卷风时,我们的这个具有两个特征的二分类模型并没有包含足够的关于问题(即龙卷风动态)的信息,无法逼近一个可靠预测这一特定灾难结果的函数。到目前为止,我们的模型甚至没有尝试预测龙卷风,我们只收集了晴天和雨天的数据。
这里的一位气候学家可能会说,那就开始收集关于海拔、湿度、风速和风向的数据,并在你的数据中添加一些标注的龙卷风实例,的确,这会帮助我们抵御未来的龙卷风。但这也仅限于此,直到某天地震袭击了大陆架并引发了海啸。这个例子说明了无论你选择什么样的模型,都需要持续跟踪相关特征,并且每个预测类别(例如是否是晴天、雨天、龙卷风天气等)都要有足够的数据,才能实现良好的预测精度。拥有一个好的预测模型意味着你已经发现了一种能够使用到目前为止收集的数据来推导出一组似乎被遵守的预测规则的机制。
总结
在本章中,我们对生物神经网络进行了功能性概述,简要介绍了神经学习和分布式表征等概念。我们还回顾了一些经典的数据科学难题,这些难题对于神经网络与其他机器学习技术同样适用。在接下来的章节中,我们将深入探讨受到生物神经网络启发的学习机制,并探索人工神经网络(ANN)的基本架构。我们以友好的方式描述 ANN,因为尽管它们旨在像生物神经网络一样高效工作,但目前还未完全达到这一目标。在下一章中,您将了解设计 ANN 时的主要实现考虑因素,并逐步发现这一过程所涉及的复杂性。
进一步阅读
- 
符号主义学习与联结主义学习: www.cogsci.rpi.edu/~rsun/sun.encyc01.pdf
- 
人工智能的历史: sitn.hms.harvard.edu/flash/2017/history-artificial-intelligence/
第二章:深入探讨神经网络
在本章中,我们将更深入地了解神经网络。我们将从构建一个感知机开始。接下来,我们将学习激活函数。我们还将训练我们的第一个感知机。
在本章中,我们将覆盖以下主题:
- 
从生物神经元到人工神经元——感知机 
- 
构建感知机 
- 
通过错误学习 
- 
训练感知机 
- 
反向传播 
- 
扩展感知机 
- 
单层网络 
从生物神经元到人工神经元——感知机
现在我们已经简要了解了一些关于数据处理本质的见解,是时候看看我们自己生物神经元的人工“亲戚”是如何工作的了。我们从弗兰克·罗森布拉特(Frank Rosenblatt)在 1950 年代的创作开始。他将这一发明称为感知机 (citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.335.3398&rep=rep1&type=pdf)。从本质上讲,你可以将感知机看作是人工神经网络(ANN)中的一个单一神经元。理解一个单一感知机如何向前传播信息,将成为理解我们在后续章节中将遇到的更先进网络的一个绝佳垫脚石:

构建感知机
目前,我们将使用六个特定的数学表示来定义一个感知机,这些表示展示了它的学习机制。它们分别是输入、权重、偏置项、求和以及激活函数。输出将在下面进一步展开说明。
输入
还记得生物神经元是如何从它的树突接收电信号的吗?那么,感知机的行为方式类似,但它更倾向于接收数字而不是电流。实际上,它接收特征输入,如前图所示。这个特定的感知机只有三个输入通道,分别是x[1]、x[2]和x[3]。这些特征输入(x[1]、x[2]和x[3])可以是你选择的任何独立变量,用来表示你的观察结果。简单来说,如果我们想预测某天是否会晴天或下雨,我们可以记录每天的独立变量,如温度和气压,以及当天的输出类别(当天是晴天还是下雨)。然后,我们将这些独立变量,一天一天地输入到我们的感知机模型中。
权重
所以,我们知道数据是如何流入我们简单的神经元的,但我们如何将这些数据转化为可操作的知识呢?我们如何构建一个模型,将这些输入特征表示出来,并帮助我们预测某天的天气呢?
给我们提供了两个特征,可以作为输入用于我们的模型,在二分类任务中判断雨天或晴天。
好的,第一步是将每个输入特征与其对应的权重配对。你可以把这个权重看作是该特定输入特征相对于我们试图预测的输出类别的相对重要性。换句话说,我们的输入特征“温度”的权重应该反映出这个输入特征与输出类别的相关程度。这些权重一开始是随机初始化的,随着我们的模型看到越来越多的数据,它们会被学习到。我们这么做的希望是,在足够多的迭代后,这些权重会被引导到正确的方向,并学习到与温度和气压值对应的理想配置,这些配置对应于雨天和晴天。事实上,从领域知识来看,我们知道温度与天气高度相关,因此我们期望模型理想情况下会为这个特征学习到更大的权重,随着数据的传播,这个特征的权重会逐渐增大。这在某种程度上可以与生物神经元中的髓鞘相比较。如果一个特定的神经元频繁地激活,它的髓鞘会变厚,从而使神经元的轴突得到绝缘,下次可以更快地传递信号。
总和
所以,现在我们的输入特征已经流入了感知机,每个输入特征都与一个随机初始化的权重配对。下一步相对简单。首先,我们将所有三个特征及其权重表示为两个不同的 3 x 1 矩阵。我们希望用这两个矩阵来表示输入特征及其权重的综合效应。正如你从高中数学中回忆到的那样,你实际上不能将两个 3 x 1 矩阵直接相乘。所以,我们需要执行一个小的数学技巧,将这两个矩阵简化为一个值。我们只需将特征矩阵转置,如下所示:

我们可以使用这个新的转置特征矩阵(维度为 3 x 1),并将其与权重矩阵(维度为 1 x 3)相乘。当我们执行矩阵乘法时,得到的结果称为这两个矩阵的点积。在我们的例子中,我们计算的是转置特征矩阵与权重矩阵的点积。通过这样做,我们可以将这两个矩阵简化为一个单一的标量值,这个值代表了所有输入特征及其相应权重的综合影响。接下来,我们将看看如何使用这个综合表示,并与某个阈值进行对比,以评估该表示的质量。换句话说,我们将使用一个函数来评估这个标量表示是否编码了一个有用的模式。理想的有用模式应该是帮助我们的模型区分数据中的不同类别,从而输出正确预测的模式。
引入非线性
所以现在,我们知道了数据是如何进入感知机单元的,如何将相关的权重与每个输入特征配对。我们还知道如何将输入特征及其相应的权重表示为n x 1 矩阵,其中n是输入特征的数量。最后,我们看到如何转置我们的特征矩阵,以便计算它与包含权重的矩阵的点积。这个操作最终得到一个单一的标量值。那么,接下来呢?现在是时候稍微停下来,思考一下我们到底在追求什么,这有助于我们理解为什么我们想要使用类似激活函数这样的概念。
好吧,正如你所看到的,现实世界中的数据通常是非线性的。我们所说的意思是,当我们尝试将某个观察值作为不同输入的函数来建模时,这个函数本身无法线性表示,或者说,不能用直线来表示。
如果数据中的所有模式仅由直线构成,那么我们可能根本不会讨论神经网络。像支持向量机(SVMs)或甚至线性回归等技术已经非常擅长这个任务:

例如,使用温度来建模晴天和雨天会产生一条非线性曲线。实际上,这意味着我们无法通过一条直线来划分我们的决策边界。换句话说,在某些日子里,尽管气温较高,可能会下雨;而在其他日子里,尽管气温较低,可能会保持晴朗。
这是因为温度与天气之间的关系并不是线性的。任何给定日子的天气结果很可能是一个复杂的函数,涉及风速、气压等交互变量。因此,在任何给定的一天,13 度的温度可能意味着德国柏林是晴天,但在英国伦敦却是雨天:

当然,某些情况下,现象可能可以用线性方式表示。例如,在物理学中,物体的质量与体积之间的关系可以线性定义,如以下截图所示:

这是一个非线性函数的例子:

| 线性函数 | 非线性函数 | 
|---|---|
| Y = mx + b | Y = mx² + b | 
这里,m是直线的斜率,x是直线上的任何点(输入或x值),而b是直线与y轴的交点。
不幸的是,现实世界中的数据通常不保证线性,因为我们用多个特征来建模观察数据,每个特征可能在确定输出类别时有不同且不成比例的贡献。事实上,我们的世界是高度非线性的,因此,为了捕捉感知机模型中的这种非线性,我们需要引入能够表示这种现象的非线性函数。通过这样做,我们增加了神经元建模实际存在的更复杂模式的能力,并且能够绘制出如果只使用线性函数则无法实现的决策边界。这些用于建模数据中非线性关系的函数,被称为激活函数。
激活函数
基本上,到目前为止,我们所做的就是将不同的输入特征及其权重表示为低维标量表示。我们可以使用这种简化的表示,并通过一个简单的非线性函数来判断我们的表示是否超过某个阈值。类似于我们之前初始化的权重,这个阈值可以被视为感知机模型的一个可学习参数。
换句话说,我们希望我们的感知机找出理想的权重组合和阈值,使其能够可靠地将输入匹配到正确的输出类别。因此,我们将简化后的特征表示与阈值进行比较,如果超过该阈值,我们就激活感知机单元,否则什么也不做。这个比较简化特征值和阈值的函数,就被称为激活函数:

这些非线性函数有不同的形式,将在后续章节中进行更详细的探讨。现在,我们展示两种不同的激活函数;重阶跃和逻辑 sigmoid 激活函数。我们之前展示的感知机单元最初是通过这样的重阶跃函数实现的,从而产生二进制输出 1(激活)或 0(非激活)。使用感知机单元中的阶跃函数时,我们观察到,当值位于曲线之上时,会导致激活(1),而当值位于曲线下方或在曲线上时,则不会触发激活单元(0)。这个过程也可以用代数方式来总结。
下图显示了重阶跃函数:

输出阈值公式如下:

本质上,阶跃函数并不算真正的非线性函数,因为它可以被重写为两个有限的线性组合。因此,这种分段常数函数在建模现实世界数据时并不够灵活,因为现实数据通常比二元数据更具概率性。另一方面,逻辑 sigmoid 函数确实是一个非线性函数,并且可以更灵活地建模数据。这个函数以压缩其输入到一个 0 到 1 之间的输出值而闻名,因此它成为表示概率的流行函数,也是现代神经网络中常用的激活函数:

每种激活函数都有其一组优缺点,我们将在后续章节中进一步探讨。现在,你可以直观地将不同激活函数的选择看作是基于你数据的特定类型的考虑。换句话说,我们理想的做法是进行实验并选择一个最能捕捉数据中潜在趋势的函数。
因此,我们将采用这种激活函数来阈值化神经元的输入。输入会相应地进行转换,并与该激活阈值进行比较,从而导致神经元激活,或者保持不激活。在以下插图中,我们可以可视化由激活函数产生的决策边界。

理解偏置项的作用
现在,我们大致了解了数据如何进入感知器;它与权重配对并通过点积缩减,然后与激活阈值进行比较。此时,你们很多人可能会问,如果我们希望我们的阈值适应数据中的不同模式怎么办? 换句话说,如果激活函数的边界并不理想,无法单独识别我们希望模型学习的特定模式怎么办?我们需要能够调整激活曲线的形式,以确保每个神经元能够局部捕捉到一定的模式灵活性。
那么,我们究竟如何塑造我们的激活函数呢?一种方法是通过在模型中引入偏置项来实现。下图中,箭头从第一个输入节点(标记为数字“1”)离开,便是这一过程的示意:

具有代表性的是,我们可以将这个偏置项视为一个虚拟输入*。这个虚拟输入被认为始终存在,使得我们的激活单元可以随意触发,而无需任何输入特征明确存在(如前面绿色圆圈所示)。这个术语背后的动机是能够操控激活函数的形状,从而影响我们模型的学习。我们希望我们的形状能够灵活地适应数据中的不同模式。偏置项的权重与其他所有权重一样,以相同的方式进行更新。不同之处在于,它不受输入神经元的干扰,输入神经元始终保持一个常量值(如前所示)。
那么,我们如何通过这个偏置项实际影响我们的激活阈值呢?让我们考虑一个简化的例子。假设我们有一些由阶跃激活函数生成的输出,它为每个输出产生‘0’或‘1’,如下所示:

然后我们可以将这个公式重写,包含偏置项,如下所示:

换句话说,我们使用了另一个数学技巧,将阈值重新定义为偏置项的负值(Threshold = -(bias))。这个偏置项在我们训练开始时是随机初始化的,并随着模型看到更多示例并从中学习而逐步更新。因此,重要的是要理解,尽管我们随机初始化模型参数,例如权重和偏置项,但我们的目标实际上是给模型足够的输入示例及其对应的输出类别。在此过程中,我们希望模型从错误中学习,寻找与正确输出类别对应的理想权重和偏置的参数组合。请注意,当我们初始化不同的权重时,我们实际上在做的是修改激活函数的陡峭度。
以下图表显示了不同权重如何影响 sigmoid 激活函数的陡峭度:

本质上,我们希望通过调整激活函数的陡峭度,能够理想地捕捉到数据中的某些潜在模式。类似地,当我们初始化不同的偏置项时,我们实际在做的是以最佳方式(向左或向右)平移激活函数,从而触发与特定输入输出特征配置对应的激活。
以下图表显示了不同偏置项如何影响 sigmoid 激活函数的位置:

输出
在我们简单的感知器模型中,我们将实际的输出类别表示为 y,将预测的输出类别表示为  。输出类别只是指我们在数据中尝试预测的不同类别。具体来说,我们使用输入特征(x[n]),例如某一天的温度(x[1])和气压(x[2]),来预测那天是晴天还是雨天(
。输出类别只是指我们在数据中尝试预测的不同类别。具体来说,我们使用输入特征(x[n]),例如某一天的温度(x[1])和气压(x[2]),来预测那天是晴天还是雨天( )。然后我们可以将模型的预测与当天的实际输出类别进行比较,判断那天是否确实是雨天或晴天。我们可以将这种简单的比较表示为(
)。然后我们可以将模型的预测与当天的实际输出类别进行比较,判断那天是否确实是雨天或晴天。我们可以将这种简单的比较表示为( - y),这样我们就能观察到感知器平均上偏差了多少。但稍后我们会更详细讨论这个问题。现在,我们可以以数学方式表示我们迄今为止学到的整个预测模型:
 - y),这样我们就能观察到感知器平均上偏差了多少。但稍后我们会更详细讨论这个问题。现在,我们可以以数学方式表示我们迄今为止学到的整个预测模型:

以下图显示了前述公式的一个例子:

如果我们将之前显示的预测线( )绘制出来,我们将能够可视化出决策边界,它将我们的整个特征空间分成两个子空间。实质上,绘制预测线仅仅是让我们了解模型学到了什么,或者模型如何选择将包含所有数据点的超平面划分为我们关心的不同输出类别。实际上,通过绘制这条线,我们能够可视化地看到模型的表现,只需将晴天和雨天的观察数据放置到这个特征空间中,然后检查我们的决策边界是否理想地将输出类别分开,具体如下:
)绘制出来,我们将能够可视化出决策边界,它将我们的整个特征空间分成两个子空间。实质上,绘制预测线仅仅是让我们了解模型学到了什么,或者模型如何选择将包含所有数据点的超平面划分为我们关心的不同输出类别。实际上,通过绘制这条线,我们能够可视化地看到模型的表现,只需将晴天和雨天的观察数据放置到这个特征空间中,然后检查我们的决策边界是否理想地将输出类别分开,具体如下:

通过错误学习
我们对输入数据所做的基本操作就是计算点积,添加偏置项,通过非线性方程进行处理,然后将预测与实际输出值进行比较,朝着实际输出的方向迈进一步。这就是人工神经元的基本结构。你很快就会看到,如何通过重复配置这种结构,产生一些更为复杂的神经网络。
我们通过一种被称为误差反向传播(简称反向传播)的方法,准确地调整参数值,使其收敛到理想的值。为了实现误差反向传播,我们需要一种度量标准来评估我们在达成目标方面的进展。我们将这种度量标准定义为损失,并通过损失函数来计算。该函数试图将模型所认为的输出和实际结果之间的残差差异纳入考虑。从数学角度来看,这表现为(y -  )。在这里,理解损失值实际上可以作为我们模型参数的函数非常重要。因此,通过调整这些参数,我们可以减少损失并使预测值更接近实际输出值。我们将在回顾感知机的完整训练过程时,详细理解这一点。
)。在这里,理解损失值实际上可以作为我们模型参数的函数非常重要。因此,通过调整这些参数,我们可以减少损失并使预测值更接近实际输出值。我们将在回顾感知机的完整训练过程时,详细理解这一点。
均方误差损失函数
一个常用的损失函数是均方误差(MSE)函数,代数表示如下公式。正如你所注意到的,这个函数本质上只是将实际模型输出(y)与预测的模型输出( )进行比较。这个函数特别有助于我们评估预测能力,因为它以二次方式建模损失。也就是说,如果我们的模型表现不佳,且预测值与实际输出之间的差距越来越大,损失值将以平方的方式增加,从而更严厉地惩罚较大的误差。
)进行比较。这个函数特别有助于我们评估预测能力,因为它以二次方式建模损失。也就是说,如果我们的模型表现不佳,且预测值与实际输出之间的差距越来越大,损失值将以平方的方式增加,从而更严厉地惩罚较大的误差。

我们将重新审视这一概念,以了解如何通过不同类型的损失函数减少模型预测与实际输出之间的差异。现在,知道我们模型的损失可以通过梯度下降过程最小化就足够了。正如我们将很快看到的那样,梯度下降本质上是基于微积分的,并通过基于反向传播的算法实现。通过调整网络的参数,数学上减少预测值与实际输出之间的差异,实际上就是使网络能够学习的过程。在训练模型的过程中,我们会通过向模型展示新的输入和相关输出的例子来进行这一调整。
训练感知机
到目前为止,我们已经清楚地掌握了数据是如何在感知机中传播的。我们还简要地看到了模型的误差如何向后传播。我们使用损失函数在每次训练迭代中计算损失值。这个损失值告诉我们,模型的预测与实际真实值之间的差距有多大。那么接下来呢?
损失量化
由于损失值能够反映我们预测输出与实际输出之间的差异,因此可以推测,如果损失值较高,那么我们的模型预测与实际输出之间的差异也很大。相反,较低的损失值意味着我们的模型正在缩小预测值与实际输出之间的差距。理想情况下,我们希望损失值收敛到零,这意味着模型预测与实际输出之间几乎没有差异。我们通过另一个数学技巧使损失收敛到零,这个技巧基于微积分。那么,怎么做到呢?
模型权重对损失的影响
好吧,记得我们说过可以将损失值看作是模型参数的函数吗?考虑这个问题。我们的损失值告诉我们模型与实际预测之间的距离。这同样的损失值也可以重新定义为模型权重(θ)的函数。回想一下,这些权重实际上是在每次训练迭代中导致模型预测的因素。从直观上讲,我们希望能够根据损失来调整模型权重,从而尽可能减少预测误差。
更数学化地说,我们希望最小化损失函数,从而迭代地更新模型的权重,理想情况下收敛到最佳的权重。这些权重是最优的,因为它们能够最好地表示那些能预测输出类别的特征。这个过程被称为损失优化,可以通过以下数学方式表示:

梯度下降
注意,我们将理想模型的权重(θ^()) 表示为在整个训练集上的损失函数的最小值。换句话说,对于我们向模型展示的所有特征输入和标签输出,我们希望它能在特征空间中找到一个地方,使得实际值 (y) 和预测值 ( ) 之间的总体差异最小。我们所指的特征空间是模型可能初始化的所有不同权重组合。为了简化表示,我们将损失函数表示为 J(θ)。现在,我们可以通过迭代的方法求解损失函数 J(θ) 的最小值,并沿着超平面下降,收敛到全局最小值。这个过程就是我们所说的梯度下降*:
) 之间的总体差异最小。我们所指的特征空间是模型可能初始化的所有不同权重组合。为了简化表示,我们将损失函数表示为 J(θ)。现在,我们可以通过迭代的方法求解损失函数 J(θ) 的最小值,并沿着超平面下降,收敛到全局最小值。这个过程就是我们所说的梯度下降*:

反向传播
对于那些更加注重数学的读者,你一定在想我们是如何迭代地下降梯度的。好吧,正如你所知道的,我们从初始化模型的随机权重开始,输入一些数据,计算点积,然后将其通过激活函数和偏置传递,得到预测输出。我们使用这个预测输出和实际输出来估计模型表示中的误差,使用损失函数。现在进入微积分部分。我们现在可以做的是对我们的损失函数 J(θ) 对模型的权重(θ)进行求导。这个过程基本上让我们比较模型权重的变化如何影响模型损失的变化。这个微分的结果给出了当前模型权重(θ)下 J(θ) 函数的梯度以及最大上升的方向。所谓的最大上升方向,指的是预测值和输出值之间的差异似乎更大的方向。因此,我们只需朝相反的方向迈出一步,下降我们损失函数 J(θ) 相对于模型权重(θ)的梯度。我们用伪代码以算法的形式呈现这一概念,如下所示:

下图是梯度下降算法的可视化:

正如我们所见,梯度下降算法允许我们沿着损失超平面向下走,直到我们的模型收敛到某些最优参数。在这一点上,模型预测值和实际值之间的差异将非常微小,我们可以认为模型已经训练完成!

因此,我们计算网络权重的变化,以对应损失函数生成的值的变化(即网络权重的梯度)。然后,我们根据计算出的梯度的相反方向,按比例更新网络权重,从而调整误差。
计算梯度
现在我们已经熟悉了反向传播算法和梯度下降的概念,我们可以解决一些更技术性的问题。比如,我们到底是如何计算这个梯度的? 正如你所知道的,我们的模型并没有直观地了解损失的地形,也无法挑选出一条合适的下降路径。事实上,我们的模型并不知道什么是上,什么是下。它所知道的,且永远只会知道的,就是数字。然而,事实证明,数字能告诉我们很多东西!
让我们重新考虑一下我们简单的感知器模型,看看如何通过迭代地计算损失函数 J(θ) 的梯度来反向传播其误差:

如果我们想要看到第二层权重的变化如何影响损失的变化怎么办?遵循微积分规则,我们可以简单地对损失函数 J(θ) 进行求导,得到损失函数相对于第二层权重(θ[2])的变化。在数学上,我们也可以用不同的方式表示这一点。通过链式法则,我们可以表示损失相对于第二层权重的变化其实是两个不同梯度的乘积。一个梯度表示损失相对于模型预测的变化,另一个表示模型预测相对于第二层权重的变化。这个可以表示为:

仿佛这还不够复杂,我们甚至可以进一步推进这种递归。假设我们想要研究第二层权重(θ[2])变化的影响,而不是仅仅建模其影响,我们想要回溯到第一层权重,看看损失函数如何随第一层权重的变化而变化。我们只需像之前那样使用链式法则重新定义这个方程。再次强调,我们关心的是模型损失相对于第一层权重(θ[1])的变化。我们将这个变化定义为三个不同梯度的乘积:损失相对于输出的变化、输出相对于隐藏层值的变化,最后是隐藏层值相对于第一层权重的变化。我们可以总结为如下:

因此,这就是我们如何使用损失函数,通过计算损失函数相对于模型中每个权重的梯度来进行误差反向传播。通过这样做,我们能够将模型的调整方向引导到正确的地方,也就是之前提到的最大下降方向。我们对整个数据集进行这一操作,这一过程被称为一个迭代(epoch)。那我们的步长呢?这个步长是由我们设置的学习率决定的。
学习率
学习率虽然看起来直观,但它决定了模型学习的速度。用数学的语言来说,学习率决定了我们在每次迭代时采取的步长大小,随着我们沿着损失函数的地形下降,逐步逼近理想的权重。为你的问题设置正确的学习率可能是一个挑战,特别是当损失函数的地形复杂且充满了意外时,正如这里的插图所示:

这是一个相当重要的概念。如果我们设置的学习率太小,那么自然,在每次训练迭代中,我们的模型学习的内容会比它实际能够学习的少。更糟糕的是,低学习率可能会导致我们的模型陷入局部最小值,误以为它已经达到了全局最小值。相反,如果学习率过高,可能会使模型无法捕捉到有预测价值的模式。
如果我们的步伐太大,我们可能会不断越过我们特征空间中的任何全局最小值,因此,永远无法收敛到理想的模型权重。
解决这个问题的一种方法是设置一个自适应学习率,能够根据训练过程中遇到的特定损失景观进行响应。在后续章节中,我们将探索各种自适应学习率的实现(如动量法、Adadelta、Adagrad、RMSProp 等):

扩展感知机
到目前为止,我们已经看到一个神经元如何通过训练学习表示一个模式。现在,假设我们想要并行地利用另一个神经元的学习机制。在我们的模型中,两个感知机单元每个可能会学习表示数据中的不同模式。因此,如果我们想通过添加另一个神经元来稍微扩展前面的感知机,我们可能会得到一个具有两层全连接神经元的结构,如下图所示:

注意这里,特征权重以及每个神经元用来表示偏置的额外虚拟输入都已经消失。为了简化表示,我们将标量点积和偏置项合并为一个符号。
我们选择用字母z来表示这个数学函数。然后,z的值被输入到激活函数中,正如我们之前所做的那样,y = g(z)。如前面的图所示,我们的输入特征连接到两个不同的神经元,每个神经元可以调整其权重和偏置,从而学习从数据中提取特定且独特的表示。这些表示随后用于预测我们的输出类别,并在我们训练模型时进行更新。
单层网络
好的,现在我们已经看到了如何并行利用我们感知单元的两种版本,使每个单元能够学习我们喂入数据中可能存在的不同潜在模式。我们自然希望将这些神经元连接到输出神经元,这些输出神经元会触发,表示特定输出类别的存在。在我们的晴雨天分类示例中,我们有两个输出类别(晴天或雨天),因此,负责解决此问题的预测网络将有两个输出神经元。这些神经元将得到来自前一层神经元的学习支持,理想情况下,它们将代表对于预测晴天或雨天的有用特征。从数学角度来说,实际上发生的只是我们转化后的输入特征的前向传播,随后是我们预测中的误差的反向传播。我们可以将每个节点视为持有一个特定数字,类似地,每个箭头可以看作是从一个节点中提取一个数字,执行加权计算,然后将其传递到下一个节点层。

现在,我们有了一个带有一个隐藏层的神经网络。我们称其为隐藏层,因为该层的状态并不是直接强加的,区别于输入层和输出层。它们的表示不是由网络设计者硬编码的,而是通过数据在网络中传播时推断出来的。
如我们所见,输入层保存了我们的输入值。连接输入层和隐藏层的一组箭头只是计算了输入特征(x)和它们各自权重(θ[1])的偏置调整点积(z)。然后,(z) 值会存储在隐藏层神经元中,直到我们对这些值应用我们的非线性函数, g(x)。之后,离开隐藏层的箭头会计算 g(z) 和与隐藏层对应的权重(θ[2])的点积,然后将结果传递到两个输出神经元, 和
 和  。请注意,每一层都有相应的权重矩阵,这些矩阵通过对损失函数相对于上一个训练迭代中的权重矩阵求导,进行迭代更新。因此,我们通过对损失函数相对于模型权重的梯度下降训练神经网络,最终收敛到全局最小值。
。请注意,每一层都有相应的权重矩阵,这些矩阵通过对损失函数相对于上一个训练迭代中的权重矩阵求导,进行迭代更新。因此,我们通过对损失函数相对于模型权重的梯度下降训练神经网络,最终收敛到全局最小值。
在 TensorFlow playground 中进行实验
让我们用一个虚拟的例子来看一下不同的神经元如何捕捉到我们数据中的不同模式。假设我们在数据中有两个输出类别,如下图所示。我们神经网络的任务是学习将这两个输出类别分开的决策边界。绘制这个二维数据集,我们得到一个类似以下图表的图像,在其中我们看到几个决策边界,将不同的可能输出分类:

我们将使用一个出色的开源工具来可视化我们模型的学习过程,这个工具叫做 TensorFlow Playground。这个工具简单地模拟了一个神经网络,并使用一些合成数据,让我们实际看到我们的神经元正在捕捉哪些模式。它允许你调整我们迄今为止概述的所有概念,包括不同类型和形式的输入特征、激活函数、学习率等。我们强烈建议你尝试不同的合成数据集,玩弄输入特征,并逐步添加神经元以及隐藏层,以观察这些变化如何影响学习。也可以尝试不同的激活函数,看看你的模型如何从数据中捕捉各种复杂的模式。的确,眼见为实!(或者更科学地说,nullius in verba)。如我们在下面的图表中所见,隐藏层中的两个神经元实际上在捕捉特征空间中的不同曲率,从而学习到数据中的特定模式。你可以通过观察连接各层的线条粗细来可视化我们模型的权重。你还可以通过观察每个神经元的输出(显示在神经元内部的阴影蓝白区域)来查看该神经元在数据中捕捉到的底层模式。正如你在实验 Playground 时看到的那样,这一表示方式是逐步更新并收敛到理想值的,具体取决于数据的形式和类型、使用的激活函数和学习率:
 
 
一个具有一个隐藏层、两个神经元和 sigmoid 激活函数的模型,经过 1,000 轮训练
层次化地捕捉模式
我们之前看到过,具有两个神经元的特定模型配置,每个神经元都配备了 sigmoid 激活函数,能够捕捉到我们特征空间中的两种不同曲率,然后将其结合起来绘制出我们的决策边界,以上图所示的输出为代表。然而,这只是其中一种可能的配置,导致了一个可能的决策边界。
以下图表显示了一个具有两个隐藏层并使用 sigmoid 激活函数的模型,经过 1,000 轮训练:

下图展示了一个包含一个隐藏层的模型,该隐藏层由两个神经元组成,使用了整流线性单元激活函数,在相同的数据集上训练了 1,000 个周期:

下图展示了一个包含一个隐藏层的模型,该隐藏层由三个神经元组成,使用了整流线性单元激活函数,仍然是在相同的数据集上:

请注意,通过使用不同的激活函数,并操作隐藏层的数量和其神经元的数量,我们可以实现非常不同的决策边界。我们需要评估哪种配置最能预测,并且适合我们的使用案例。通常,这通过实验来完成,尽管对所建模数据的领域知识也可能起到很大的作用。
前进的步骤
恭喜!在短短几页的内容中,我们已经走了很长一段路。现在你知道神经网络是如何学习的,并且对允许它从数据中学习的高级数学结构有了了解。我们看到单个神经元(即感知器)是如何配置的。我们看到这个神经单元是如何在数据向前传播过程中转化其输入特征的。我们还理解了通过激活函数表示非线性概念,以及如何在一个层中组织多个神经元,从而使该层中的每个神经元能够表示我们数据中的不同模式。这些学习到的模式在每次训练迭代时都会被更新,每个神经元的权重都会调整,直到我们找到理想的配置。
实际上,现代神经网络采用了各种类型的神经元,以不同的方式配置,用于不同的预测任务。虽然神经网络的基本学习架构始终保持不变,但神经元的具体配置,例如它们的数量、互联性、所使用的激活函数等,都是定义不同类型神经网络架构的因素。为了便于理解,我们为您提供了由阿西莫夫研究所慷慨提供的全面插图。
在下图中,您可以看到一些突出的神经元类型,或细胞,以及它们的配置,这些配置构成了您将在本书中看到的一些最常用的最先进的神经网络:

总结
现在我们已经对神经学习系统有了全面的理解,我们可以开始动手实践。我们将很快实现我们的第一个神经网络,测试它在经典分类任务中的表现,并在实践中面对我们在这里讨论的许多概念。在此过程中,我们将详细介绍损失优化的确切性质以及神经网络的评估指标。
第三章:信号处理 - 使用神经网络进行数据分析
在掌握了大量关于神经网络的知识后,我们现在准备使用它们进行第一个操作。我们将从处理信号开始,并看看神经网络如何处理数据。您将会被增加神经元的层次和复杂性如何使问题看起来变得简单所迷住。然后,我们将看看如何处理语言。我们将使用数据集进行几次预测。
在本章中,我们将涵盖以下主题:
- 
处理信号 
- 
将图像视作数字 
- 
向神经网络输入数据 
- 
张量的示例 
- 
建立模型 
- 
编译模型 
- 
在 Keras 中实现权重正则化 
- 
权重正则化实验 
- 
在 Keras 中实现 dropout 正则化 
- 
语言处理 
- 
互联网电影评论数据集 
- 
绘制单个训练实例的图表 
- 
独热编码 
- 
向量化特征 
- 
向量化标签 
- 
构建网络 
- 
回调 
- 
访问模型预测 
- 
逐特征归一化 
- 
使用 scikit-learn API 进行交叉验证 
处理信号
宇宙中可能只存在四种基本力,但它们都是信号。所谓信号,是指我们对现实世界现象的各种特征表示。例如,我们的视觉世界充满了指示运动、颜色和形状的信号。这些是非常动态的信号,令人难以置信的是生物能够如此准确地处理这些刺激,即使我们这样说也是如此。当然,在更宏观的视角下,意识到自然已经花费了数亿年来完善这个配方,可能会让我们感到一点谦卑。但就目前而言,我们可以赞叹于人类视觉皮层的奇迹,这里配备有 1.4 亿个密集连接的神经元。事实上,存在一整套层次(V1 - V5),我们在进行日益复杂的图像处理任务时,信息就会通过这些层次传播。眼睛本身使用棒状和锥状细胞来检测不同的光强度和颜色模式,非常出色地将电磁辐射拼接在一起,并通过光转导将其转化为电信号。
当我们看一张图像时,我们的视觉皮层实际上是在解读眼睛将电磁信号转化为电信号并传递给它的特定配置。当我们听音乐时,我们的耳鼓,或称耳膜,仅仅是将一连串的振动信号转化并放大,以便我们的听觉皮层可以处理这些信号。实际上,大脑中的神经机制似乎非常高效,能够抽象和表征在不同现实世界信号中出现的模式。事实上,神经科学家们甚至发现一些哺乳动物的大脑具备重新连接的能力,这使得不同的皮层能够处理它们最初并未设计用来处理的数据类型。最值得注意的是,科学家们发现,通过重新连接雪貂的听觉皮层,这些生物能够处理来自大脑听觉区域的视觉信号,从而使它们能够使用先前用于听觉任务的非常不同的神经元来“看见”。许多科学家引用这些研究,提出大脑可能在使用一种主算法,能够处理任何形式的数据,并将其转化为高效的周围世界表征。
尽管这非常引人入胜,但它自然引发了更多关于神经学习的问题,而这些问题远远超出了我们在本书中能够解答的范围。可以说,不论是何种算法,或者一组算法,让我们的大脑实现如此高效的世界表征,都无疑是神经学家、深度学习工程师以及其他科学界人士所关注的重点。
表征学习
正如我们在 TensorFlow Playground 上进行感知机实验时看到的那样,一组人工神经元似乎能够学习相当简单的模式。这与我们人类能够执行并希望预测的复杂表征差距甚远。然而,我们可以看到,即使在它们刚刚起步的简单性中,这些网络似乎能够适应我们提供的数据类型,有时甚至超过其他统计预测模型的表现。那么,究竟发生了什么,让它与过去教机器为我们做事的方法如此不同呢?
教会计算机识别皮肤癌的样子非常有用,方法就是展示我们可能拥有的大量医学相关特征。事实上,这正是我们迄今为止对机器的方法。我们会手动设计特征,使机器能够轻松处理它们并生成相关的预测。但为何仅仅停留在这里呢?为什么不直接向计算机展示皮肤癌的实际样子呢?为什么不展示数百万张图片,让它自己弄清楚什么是相关的呢?事实上,这正是我们在谈论深度学习时所尝试做的。与传统的机器学习(ML)算法不同,在传统算法中,我们将数据以明确处理过的表示形式提供给机器学习,而在神经网络中,我们采取不同的方法。我们实际希望实现的目标是让网络自己学习这些表示。
如下图所示,网络通过学习简单的表示,并利用它们在连续的层中定义越来越复杂的表示,直到最终的层能够准确地表示输出类别:

事实证明,这种方法对教会计算机识别复杂的运动模式和面部表情非常有用,就像我们人类一样。比如你希望它在你不在时替你接收包裹,或者检测任何潜在的盗贼试图闯入你的房子。类似地,如果我们希望计算机为我们安排约会,找到市场上可能有利可图的股票,并根据我们感兴趣的内容更新信息,该怎么办呢?这样做需要处理复杂的图像、视频、音频、文本和时间序列数据,这些数据都以复杂的维度表示,无法仅凭几个神经元建模。那么,我们如何与神经学习系统合作,就像我们在上一章看到的那样?我们如何让神经网络学习眼睛、面部和其他现实世界物体中的复杂层次模式呢?显然的答案是让它们变得更大。但正如我们将看到的,这带来了自身的复杂性。长话短说,你在网络中放入的可学习参数越多,它记住一些随机模式的可能性就越大,因此它的泛化能力就越差。理想情况下,你希望神经元的配置能够完美地适应当前的学习任务,但在没有进行实验之前,几乎不可能事先确定这种配置。
避免随机记忆
另一个方法是,不仅操控神经元的总数,还要调整这些神经元之间的连接程度。我们可以通过技术手段来实现这一点,比如dropout 正则化和加权参数,稍后我们将详细了解。目前为止,我们已经看到了通过每个神经元在数据通过网络传播过程中可以执行的各种计算。我们还看到了大脑如何利用数亿个密集连接的神经元完成任务。然而,自然地,我们不能仅仅通过任意增加更多神经元来扩展我们的网络。简而言之,模拟接近大脑的神经结构,可能需要成千上万的千万亿次运算(petaflops,一种计算速度单位,等于每秒进行一千万亿(10¹⁵)次浮点运算)。也许在不久的将来,借助大规模并行计算范式,以及软件和硬件技术的其他进展,这将成为可能。不过,眼下,我们必须想出巧妙的方式来训练我们的网络,使其能够找到最有效的表示,而不会浪费宝贵的计算资源。
用数字表示信号
在本章中,我们将看到如何将神经元的序列层叠起来,逐步表示出越来越复杂的模式。我们还将看到像正则化和批量学习等概念,在最大化训练效果中是多么重要。我们将学会处理各种现实世界数据,包括图像、文本和时序相关信息。
图像作为数字
对于这样的任务,我们需要深度网络并拥有多个隐藏层,如果希望为我们的输出类别学习任何具有代表性的特征的话。我们还需要一个良好的数据集来练习我们的理解,并让自己熟悉我们将在设计智能系统时使用的工具。因此,我们迎来了第一个实际操作的神经网络任务,同时也将开始接触计算机视觉、图像处理和层次化表示学习的概念。我们当前的任务是教计算机读取数字,不是像它们已经做的那样读取 0 和 1,而是更像我们如何阅读由我们自己所写的数字。我们说的是手写数字,为此任务,我们将使用经典的 MNIST 数据集,深度学习数据集中的真正hello world。对于我们的第一个例子,选择它背后有着很好的理论和实践依据。
从理论的角度来看,我们需要理解如何使用层神经元来逐步学习更复杂的模式,正如我们的大脑所做的那样。由于我们的大脑大约有 2000 到 2500 年的训练数据,它在识别复杂符号(如手写数字)方面变得非常熟练。事实上,我们通常认为这是一项完全不费力的任务,因为我们从学前教育开始就学习如何区分这些符号。但实际上,这是一项非常艰巨的任务。想一想,不同的人写这些数字时可能会有如此巨大的变化,而我们的脑袋却能够分类这些数字,就像这并不是什么大事一样:

虽然穷举地编码明确的规则会让任何程序员发疯,但当我们看着前面的图像时,我们的大脑直觉地注意到数据中的一些模式。例如,它注意到2和3的顶部都有一个半圆圈,1、4和7都有一条向下的直线。它还感知到4实际上由一条向下的直线、一条半向下的线和另一条水平线组成。由于这个原因,我们能够轻松地将一个复杂的模式分解成更小的模式。这在手写数字上特别容易做到,正如我们刚才看到的那样。因此,我们的任务是看看如何构建一个深度神经网络,并希望每个神经元能够从我们的数据中捕捉简单的模式,例如线段,然后使用我们在前一层学到的简单模式,逐步构建更复杂的模式,并最终学习到与我们的输出类别相对应的准确表示组合。
从实际应用的角度来看,MNIST 数据集已经被深度学习领域的许多先驱研究了近二十年。从这个数据集中,我们获得了大量的知识,这使得它成为探索诸如层次表示、正则化和过拟合等概念的理想数据集。一旦我们理解了如何训练和测试神经网络,我们就可以将其用于更具挑战性的任务。
输入神经网络
本质上,所有进入并通过网络传播的数据都由一种数学结构——张量表示。这适用于音频数据、图像、视频以及我们能想到的任何数据,以供我们贪婪的数据网络使用。在数学中(en.wikipedia.org/wiki/Mathematics),张量被定义为一种抽象和任意的几何(en.wikipedia.org/wiki/Geometry)实体,它以多线性(en.wikipedia.org/wiki/Linear_map)方式映射向量的聚合,得到一个结果张量。事实上,向量和标量被视为张量的简单形式。在 Python 中,张量定义有三个特定的属性,如下:
- 
秩:具体来说,这表示轴的数量。一个矩阵的秩为 2,因为它表示一个二维张量。在 Python 库中,通常用 ndim表示这一点。
- 
形状:张量的形状可以通过调用 NumPy n维数组(在 Python 中张量的表示方式)上的 shape 属性来检查。这将返回一个整数元组,表示张量在每个轴上的维度数量。 
- 
内容:这指的是存储在张量中的数据类型,可以通过对感兴趣的张量调用 type()方法来检查。这将返回诸如 float32、uint8、float64 等数据类型,字符串值除外,字符串会先转换成向量表示,再以张量形式表示。
以下是一个张量图。不要担心复杂的图表——我们稍后会解释它的含义:

张量示例
我们之前看到的插图是一个三维张量的示例,但张量可以以多种形式出现。在接下来的部分,我们将概述一些不同秩的张量,从零秩张量开始:
- 
标量:标量表示单一的数值。它也可以被描述为一个维度为 0 的张量。一个例子是通过网络处理单一的灰度像素。 
- 
向量:一堆标量或一组数字被称为向量,或者是一个秩为 1 的张量。一个一维张量被认为有一个轴。一个例子是处理单一的平展图像。 
- 
矩阵:向量数组是一个矩阵,或者称为 2D 张量。矩阵有两个轴(通常称为行和列)。你可以将矩阵形象地解释为一个矩形的数字网格。一个例子是处理单一的灰度图像。 
- 
三维张量:通过将多个矩阵打包到一个新数组中,你得到一个 3D 张量,可以将其形象地解释为一个数字立方体。一个例子是处理一组灰度图像数据集。 
- 
四维张量:通过将 3D 张量打包到一个数组中,可以创建一个 4D 张量,依此类推。一个例子是处理彩色图像的数据集。 
- 
五维张量:这些是通过将 4D 张量打包到一个数组中创建的。一个例子是处理视频数据集。 
数据的维度
所以,考虑一个形状为(400, 600, 3)的张量。这是一个常见的输入形状,表示一个 400 x 600 像素的彩色图像的三维张量。由于 MNIST 数据集使用的是二进制灰度像素值,我们在表示图像时只处理 28 x 28 像素的矩阵。在这里,每张图像是一个二维张量,整个数据集可以用一个三维张量表示。在彩色图像中,每个像素值实际上有三个数字,分别表示该像素的红、绿、蓝光强度。因此,在彩色图像中,用于表示图像的二维矩阵现在扩展为三维张量。这样的张量用(x, y, 3)的元组表示,其中x和y代表图像的像素维度。因此,彩色图像的数据集可以用一个四维张量表示,正如我们在后续示例中看到的那样。现在,了解我们可以使用 NumPy 的n维数组在 Python 中表示、重塑、操作和存储张量是很有用的。
导入一些库
那么,让我们开始吧!我们将通过利用前几章中学习的所有概念,进行一些简单的实验,或许在这个过程中也会遇到一些新的概念。我们将使用 Keras,以及 TensorFlow API,这也让我们可以探索即时执行模式。我们的第一个任务是实现一个简单版本的多层感知器。这个版本被称为前馈神经网络,它是一种基本的架构,我们可以用它来进一步探索一些简单的图像分类示例。遵循深度学习的传统,我们将通过使用 MNIST 数据集进行手写数字分类任务来开始我们的第一次分类任务。这个数据集包含 70,000 张 0 到 9 之间的灰度数字图像。这个数据集的规模非常适合,因为机器通常需要每个类别约 5,000 张图像,才能在视觉识别任务中接近人类水平的表现。以下代码导入了我们将使用的库:
import numpy as np
import keras
from keras.datasets import mnist
from keras.utils import np_utils
Keras 的顺序 API
如你所知,每个 Python 库通常都有一个核心数据抽象,定义了该库能够操作的数据结构,以执行计算。NumPy 有它的数组,而 pandas 有它的 DataFrame。Keras 的核心数据结构是模型,实际上它是一种组织相互连接的神经元层的方式。我们将从最简单的模型类型开始:顺序模型(keras.io/getting-started/sequential-model-guide/)。它作为一个线性堆叠的层通过顺序 API 提供。更复杂的架构也允许我们查看功能 API,它用于构建自定义层。稍后我们会讲解这些。以下代码导入了顺序模型,以及一些我们将用来构建第一个网络的层:
from keras.models import Sequential
from keras.layers import Flatten, Dense, Dropout
from keras.layers.core import Activation
from keras import backend as K
加载数据
现在,让我们加载数据并进行拆分。幸运的是,MNIST 是 Keras 中已经实现的核心数据集之一,允许通过简洁的一行代码导入,并且还可以让我们将数据拆分为训练集和测试集。当然,现实世界中的数据没有那么容易移植和拆分。为此目的,有很多有用的工具存在于Keras.utils中,我们稍后会简要介绍,也鼓励你自己探索。此外,其他ML库(如 scikit-learn)也提供了一些方便的工具(如train_test_split、MinMaxScaler和normalizer等方法),这些工具顾名思义,能够帮助你根据需要拆分、缩放和归一化数据,以优化神经网络的训练。让我们导入并加载数据集,如下所示:
from keras.datasets import mnist
(x_train, y_train),(x_test, y_test)= fashion_mnist.load_data()
检查维度
接下来,我们需要查看我们的数据是什么样子的。我们将通过检查其类型、形状,然后使用matplotlib.pyplot绘制单独的观察结果来实现这一点,如下所示:
type(x_train[0]),x_train.shape,y_train.shape
你将得到以下结果:
(numpy.ndarray, (60000, 28, 28), (60000,))
绘制点:
import matplotlib.-pyplot as plt
%matplotlib inline
plt.show(x_train[0], cmap= plt.cm.binary)
<matplotlib.image.AxesImage at 0x24b7f0fa3c8>
这将绘制出类似于下图的图形:

如我们所见,我们的训练集有 60,000 张图片,每张图片由一个 28 x 28 的矩阵表示。当我们表示整个数据集时,实际上是表示一个三维张量(60,000 x 28 x 28)。现在,让我们重新缩放像素值,这些值通常在 0 到 225 之间。将这些值缩放到 0 到 1 之间,可以让我们的网络更容易进行计算和学习预测特征。我们建议你进行带有和不带有归一化的实验,这样你就可以评估预测能力的差异:
x_train=keras.utils.normalize(x_train, axis=1)
x_test=keras.utils.normalize(x_test, axis=1)
plt.imshow(x_train[0], cmap=plt.cm.binary)
上述代码会生成以下输出:
<matplotlib.image.AxesImage at 0x24b00003e48>
以下是获得的图形:

构建模型
现在我们可以继续构建我们的预测模型。但在进入有趣的代码之前,我们必须了解一些重要概念的理论。
引入 Keras 层
神经网络模型在 Keras 中的核心构建模块是其层。层基本上是数据处理过滤器,它们扭曲它们接收到的数据,将其转换为更有用的表示。正如我们将看到的,神经网络的主要架构通常在于层的设计方式和它们之间神经元的相互连接。Keras 的发明者 Francois Chollet 将这种架构描述为对我们的数据进行渐进蒸馏。让我们看看这是如何工作的:
#Simple Feedforward Neural Network
model = Sequential()
#feeds in the image composed of 28  28 a pixel matrix as one sequence   
 of 784
model.add(Flatten(input_shape=(28,28)))
model.add(Dense(24, activation='relu'))
model.add(Dense(8, activation='relu'))
model.add(Dense(10, activation='softmax'))
我们通过初始化一个空的模型实例来定义我们的模型,这个实例没有任何层。然后,我们添加第一层,这一层总是期望一个输入维度,对应于我们希望其接收的数据大小。在我们的例子中,我们希望模型接收 28 x 28 像素的图像数据,正如我们之前定义的那样。我们添加的额外逗号表示网络一次将看到多少个样本,正如我们很快会看到的那样。我们还在输入矩阵上调用了Flatten()方法。这样做的作用是将每个 28 x 28 的图像矩阵转换为一个由 784 个像素值组成的单一向量,每个像素值对应于一个输入神经元。
我们继续添加层,直到到达输出层,该层具有与输出类别数量对应的输出神经元——在这种情况下,是介于 0 和 9 之间的 10 个数字。请注意,只有输入层需要指定输入数据的维度,因为逐步的隐藏层能够执行自动形状推断(并且仅是第一个层需要,后续层可以自动推断形状)。
初始化权重
我们还可以选择为每一层的神经元初始化特定的权重。这不是一个前提条件,因为如果没有特别指定,它们会被自动初始化为小的随机数。权重初始化的实践实际上是神经网络中的一个独立子领域。需要特别注意的是,网络的谨慎初始化可以显著加速学习过程。
你可以使用kernel_initializer和bias_initializer参数分别设置每一层的权重和偏置。记住,这些权重将代表我们网络所获得的知识,这就是为什么理想的初始化可以显著提升学习效率:
#feeds in the image composed of 2828 as one sequence of 784
model.add(Flatten(input_shape=(28,28)))
model.add(Dense(64, activation='relu',   
          kernel_initializer='glorot_uniform',   
          bias_initializer='zeros'))
model.add(Dense(18, activation='relu'))
model.add(Dense(10, activation='softmax'))
对不同参数值的全面审查超出了本章的范围。我们以后可能会遇到一些需要调整这些参数的用例(请参阅优化章节)。kernel_initializer参数的一些值包括:
- 
glorot_uniform:权重是从-limit和limit之间的均匀分布样本中提取的。这里,limit定义为sqrt(6 / (fan_in + fan_out))。术语fan_in表示权重张量中的输入单元数量,而fan_out表示权重张量中的输出单元数量。
- 
random_uniform: 权重被随机初始化为-0.05 到 0.05 之间的小的均匀值。
- 
random_normal: 权重按高斯分布初始化[1],均值为 0,标准差为 0.05。
- 
zero: 层的权重初始化为零。
Keras 激活函数
目前,我们的网络由一个扁平化的输入层组成,接着是两层全连接的密集层,这些都是神经元的完全连接层。前两层使用修正线性单元(ReLU)激活函数,其图形绘制方式与我们在第二章《深入神经网络》一章中看到的 Sigmoid 函数有所不同。在以下的图示中,你可以看到 Keras 提供的一些不同激活函数的绘制方式。记住,在它们之间进行选择需要直观理解可能的决策边界,这些边界可能有助于或妨碍你的特征空间划分。某些情况下,使用合适的激活函数并与理想初始化的偏置一起使用可能至关重要,但在其他情况下则可能无关紧要。总之,建议进行实验,尽可能不留任何未尝试的方案:

我们模型中的第四层(也是最后一层)是一个 10 类 Softmax 层。在我们的例子中,这意味着它将返回一个包含十个概率值的数组,所有这些值的总和为 1。每个概率值表示当前数字图像属于我们输出类别之一的概率。因此,对于任何给定的输入,Softmax 激活函数层会计算并返回该输入相对于每个输出类别的类别概率。
以视觉方式总结模型
回到我们的模型,让我们总结一下我们即将训练的输出。在 Keras 中,你可以通过在模型上使用summary()方法来做到这一点,这实际上是一个更长的utility函数的快捷方式(因此更难记住),其代码如下:
keras.utils.print_summary(model, line_length=None, positions=None,     
                          print_fn=None)
使用这个,你实际上可以可视化神经网络各个层的形状,以及每一层的参数:
model.summary()
上述代码生成了以下输出:
_________________________________________________________________
Layer (type) Output Shape Param # 
=================================================================
flatten_2 (Flatten) (None, 784) 0 
_________________________________________________________________
dense_4 (Dense) (None, 1024) 803840 
_________________________________________________________________
dense_5 (Dense) (None, 28) 28700 
_________________________________________________________________
dense_6 (Dense) (None, 10) 290 
=================================================================
Total params: 832,830
Trainable params: 832,830
Non-trainable params: 0
_________________________________________________________________
如你所见,与我们在第二章《深入神经网络》一章中看到的感知机不同,这个极其简单的模型已经有了 51,600 个可训练的参数,相比其前身,几乎可以以指数级的速度扩展其学习能力。
编译模型
接下来,我们将编译我们的 Keras 模型。编译基本上指的是神经网络的学习方式。它让你亲自控制实现学习过程,这通过调用model对象的compile方法来完成。该方法至少需要三个参数:
model.compile(optimizer='resprop', #'sgd'
              loss='sparse_categorical_crossentropy',
              metrics=['accuracy'])
这里,我们描述以下函数:
- 
A lossfunction:这只是用来衡量我们在训练数据上的表现,与真实输出标签进行比较。因此,loss函数可以作为我们模型错误的指示。如我们之前所见,这个度量实际上是一个函数,用来确定我们的模型预测与实际输出类标签之间的差距。我们在第二章,深入探讨神经网络中看到了均方误差(MSE)loss函数,存在许多不同的变体。这些loss函数在 Keras 中被实现,具体取决于我们的ML任务的性质。例如,如果你希望执行二分类(两个输出神经元代表两个输出类别),你最好选择二元交叉熵。对于两个以上的类别,你可以尝试分类交叉熵或稀疏分类交叉熵。前者用于你的输出标签是独热编码的情况,而后者则用于你的输出类是数值型类别变量的情况。对于回归问题,我们通常建议使用 MSEloss函数。处理序列数据时,正如我们稍后将讨论的那样,连接时序分类(CTC)被认为是更合适的loss函数。其他类型的loss可能在衡量预测与实际输出标签之间的距离方式上有所不同(例如,cosine_proximity使用余弦距离度量),或者选择不同的概率分布来建模预测值(例如,泊松损失函数,如果你处理的是计数数据,可能更合适)。
- 
An optimizer:直观地看,优化器可以理解为告诉网络如何达到全局最小损失。这包括你希望优化的目标,以及它将在朝着目标的方向上采取的步长。技术上讲,优化器通常被描述为网络用于自我更新的机制,网络通过使用它所接收的数据和loss函数来进行自我更新。优化算法用于更新权重和偏差,这些是模型的内部参数,用于在误差减少过程中进行调整。实际上,优化函数有两种不同的类型:具有恒定学习率的函数(如随机梯度下降(SGD))和具有自适应学习率的函数(如 Adagrad、Adadelta、RMSprop 和 Adam)。后者因实现基于启发式和预设学习率方法而闻名。因此,使用自适应学习率可以减少调整模型超参数的工作量。
- 
metrics:这仅表示我们在训练和测试期间监控的评估基准。最常用的是准确度,但如果您愿意,您也可以通过 Keras 设计并实现自定义度量。损失和准确度评分之间的主要功能差异在于,准确度度量完全不参与训练过程,而损失则直接在训练过程中由优化器用来反向传播误差。
拟合模型
fit 参数启动训练过程,因此它可以被认为是训练模型的同义词。它接受您的训练特征、对应的训练标签、模型查看数据的次数,以及每次训练迭代中模型看到的学习示例数量,作为训练度量标准:
model.fit(x_train, y_train, epochs=5, batch_size = 2) #other arguments   
                            validation split=0.33, batch_size=10
您还可以添加额外的参数来打乱数据、创建验证集划分或为输出类别分配自定义权重。在每个训练周期前打乱训练数据很有用,特别是可以确保模型不会学习到数据中的任何随机非预测性序列,从而仅仅过拟合训练集。要打乱数据,您必须将 shuffle 参数的布尔值设置为 True。最后,自定义权重对于数据集中类别分布不均的情况特别有用。设置较高的权重相当于告诉模型,嘿,你,更多关注这些示例。要设置自定义权重,您必须提供 class_weight 参数,传递一个字典,将类别索引映射到与输出类别对应的自定义权重,按照提供的索引顺序。
以下是编译模型时会面临的关键架构决策概览。这些决策与您指示模型执行的训练过程相关:
- 
epochs:该参数必须定义为整数值,对应模型将遍历整个数据集的次数。从技术上讲,模型并不会根据epochs给出的迭代次数进行训练,而仅仅是直到达到epochs索引的周期为止。您希望将此参数设置为 恰到好处,具体取决于您希望模型表现的复杂性。如果设置过低,将导致用于推理的简化表示,而设置过高则会导致模型在训练数据上过拟合。
- 
batch_size:batch_size定义了每次训练迭代中,通过网络传播的样本数量。从直观上讲,这可以被看作是网络在学习时一次性看到的示例数量。在数学上,这只是网络在更新模型权重之前将看到的训练实例的数量。到目前为止,我们一直是在每个训练样本后更新模型权重(即batch_size为 1),但这种做法很快会成为计算和内存管理的负担。当数据集过大,甚至无法加载到内存中时,尤其如此。设置batch_size可以防止这种情况。神经网络在小批量上训练得也更快。事实上,批量大小甚至会影响我们在反向传播过程中梯度估计的准确性,正如下图所示。同一网络使用三种不同的批量大小进行训练。随机表示随机梯度,或批量大小为 1。如你所见,相比于较大完整批量梯度(蓝色),随机和小批量梯度(绿色)的方向波动要大得多:

- 迭代次数(无需显式定义)仅表示通过的次数,每次通过包含由 batch_size定义的训练示例数量。明确来说,一次通过是指数据通过我们的各层进行正向过滤,同时进行反向传播误差。假设我们将批量大小设置为 32。一次迭代包含模型查看 32 个训练示例,然后相应地更新其权重。在一个包含 64 个示例的数据集中,如果批量大小为 32,模型需要进行两次迭代才能遍历完所有数据。
现在我们已经调用了 fit 方法来初始化学习过程,我们将观察输出,输出显示每个 epoch 的估计训练时间、损失(错误)和训练数据的准确性:
Epoch 1/5
60000/60000 [==========] - 12s 192us/step - loss: 0.3596 - acc: 0.9177
Epoch 2/5
60000/60000 [==========] - 10s 172us/step - loss: 0.1822 - acc: 0.9664
Epoch 3/5
60000/60000 [==========] - 10s 173us/step - loss: 0.1505 - acc: 0.9759
Epoch 4/5
60000/60000 [==========] - 11s 177us/step - loss: 0.1369 - acc:  
                           0.97841s - loss: 
Epoch 5/5
60000/60000 [==========] - 11s 175us/step - loss: 0.1245 - acc: 0.9822
在数据集完整训练五次后,我们在训练过程中达到了 0.96(96.01%)的准确率。现在,我们必须通过在模型之前未见过的隔离测试集上进行测试,来验证我们的模型是否真的学到了我们想要它学习的内容:
model.evaluation(x_test, y_test)
10000/10000 [==============================] - 1s 98us/step
[0.1425468367099762, 0.9759]
评估模型性能
每当我们评估一个网络时,我们实际上关心的是它在测试集上分类图像的准确性。这对于任何机器学习模型都是适用的,因为在训练集上的准确率并不能可靠地反映我们模型的泛化能力。
在我们的案例中,测试集的准确率是 95.78%,略低于我们训练集的 96%。这是典型的过拟合案例,我们的模型似乎捕捉到了数据中的无关噪声,用来预测训练图像。由于这种固有的噪声在我们随机选择的测试集上不同,因此我们的网络无法依赖之前所捕捉到的无用表示,因此在测试时表现不佳。正如我们将在本书中看到的那样,测试神经网络时,确保它已经学习到正确且高效的数据表示是非常重要的。换句话说,我们需要确保我们的网络没有在训练数据上过拟合。
顺便提一下,你可以通过打印出给定测试对象的最高概率值的标签,并使用 Matplotlib 绘制该测试对象,来始终可视化你的预测结果。在这里,我们打印出了测试对象110的最大概率标签。我们的模型认为它是一个8。通过绘制该对象,我们可以看到我们的模型在这个案例中是正确的:
predictions= load_model.predict([x_test])
#predict use the inference graph generated in the model to predict class labels on our test set
#print maximum value for prediction of x_test subject no. 110)
import numpy as np
print(np.argmax(predictions[110]))
-------------------------------------------
8
------------------------------------------
plt.imshow(x_test[110]))
<matplotlib.image.AxesImage at 0x174dd374240>
上述代码生成了以下输出:

一旦满意,你可以保存并加载模型以备后用,如下所示:
model.save('mnist_nn.model')
load_model=kera.models.load_model('mnist_nn.model')
正则化
那么,你可以做些什么来防止模型从训练数据中学习到误导性或无关的模式呢?对于神经网络来说,最好的解决方案几乎总是获取更多的训练数据。一个训练在更多数据上的模型,确实会让你的模型在外部数据集上具有更好的预测能力。当然,获取更多数据并不总是那么简单,甚至有时是不可能的。在这种情况下,你还有其他几种技术可以使用,以达到类似的效果。其一是约束你的模型在可存储信息的数量方面。正如我们在第一章《神经网络概述》中看到的敌后例子,找到最有效的信息表示,或者具有最低熵的表示是非常有用的。类似地,如果我们只能让模型记住少量的模式,实际上是在强迫它找到最有效的表示,这些表示能更好地推广到我们模型未来可能遇到的其他数据上。通过减少过拟合来提高模型的泛化能力的过程被称为正则化,我们将在实际使用之前更详细地讲解它。
调整网络大小
当我们谈论一个网络的规模时,我们指的是网络中可训练参数的数量。这些参数由网络中的层数以及每层的神经元数量决定。本质上,网络的规模是其复杂性的度量。我们曾提到,网络规模过大会适得其反,导致过拟合。直观地理解这一点,我们应该倾向于选择更简单的表示方式,而不是复杂的,只要它们能够实现相同的目标——可以说,这是一种简约法则。设计此类学习系统的工程师确实是深思熟虑的。这里的直觉是,你可以根据网络的深度和每层的神经元数量,采用多种数据表示方式,但我们将优先选择更简单的配置,只有在需要时才会逐步扩大网络规模,以防它利用过多的学习能力去记忆随机性。然而,让模型拥有过少的参数可能导致欠拟合,使其忽视我们试图在数据中捕捉的潜在趋势。通过实验,你可以找到一个适合的网络规模,具体取决于你的应用场景。我们迫使网络在表示数据时保持高效,使其能够更好地从训练数据中进行泛化。下面,我们展示了一些实验,过程中调整了网络的规模。这让我们可以比较在每个周期中验证集上的损失变化。如我们所见,较大的模型更快地偏离最小损失值,并几乎立刻开始在训练数据上发生过拟合:

网络规模实验
现在我们将通过改变网络的规模并评估我们的表现,进行一些简短的实验。我们将在 Keras 上训练六个简单的神经网络,每个网络的规模都逐渐增大,以观察这些独立的网络如何学习分类手写数字。我们还将展示一些实验结果。所有这些模型都使用固定的批次大小(batch_size=100)、adam优化器和sparse_categorical_crossentropy作为loss函数进行训练,目的是为了本次实验。
以下拟合图展示了增加我们神经网络的复杂度(从规模上来说)如何影响我们在训练集和测试集上的表现。请注意,我们的目标始终是寻找一个模型,使训练和测试准确度/损失之间的差异最小化,因为这表明过拟合的程度最小。直观来说,这只是向我们展示了如果分配更多神经元,我们的网络学习会有多大的好处。通过观察测试集上准确度的提升,我们可以看到,添加更多神经元确实有助于我们的网络更好地分类它从未遇到过的图像。直到最佳点,即训练和测试值最接近的地方,这一点可以被注意到。然而,最终,复杂度的增加将导致边际效益递减。在我们的案例中,模型似乎在丢弃率约为 0.5 时最少发生过拟合,之后训练和测试集的准确度开始出现分歧:
 
   
为了通过增加网络的规模来复制这些结果,我们可以调整网络的宽度(每层的神经元数量)和深度(网络的层数)。在 Keras 中,增加网络的深度是通过使用 model.add() 向初始化的模型中添加层来完成的。add 方法的参数是层的类型(例如,Dense())。Dense 函数需要指定该层中要初始化的神经元数量,以及为该层使用的激活函数。以下是一个例子:
model.add(Dense(512,  activation=’softmax’))
正则化权重
另一种确保网络不会拾取无关特征的方法是通过正则化我们模型的权重。这使得我们能够通过限制层权重只取小值,来对网络的复杂度施加约束。这样做的结果就是使得层权重的分布更加规则。我们是怎么做到这一点的?通过简单地将成本添加到我们网络的loss函数中。这个成本实际上代表了对权重大于正常值的神经元的惩罚。传统上,我们通过三种方式来实现这种成本,分别是 L1、L2 和弹性网正则化:
- 
L1 正则化:我们添加一个与加权系数的绝对值成正比的成本。 
- 
L2 正则化:我们添加一个与加权系数的平方成正比的成本。这也被称为权重衰减,因为如果没有其他更新计划,权重将会指数衰减为零。 
- 
弹性网正则化:这种正则化方法通过同时使用 L1 和 L2 正则化的组合,帮助我们捕获模型的复杂性。 
使用丢弃层
最后,将 dropout 神经元添加到层中是一种广泛应用的技术,用于正则化神经网络并防止过拟合。在这里,我们实际上是随机地从模型中丢弃一些神经元。为什么这么做?其实,这带来了双重效用。首先,这些神经元对网络中更深层神经元激活的贡献会在前向传播过程中被随机忽略。其次,在反向传播过程中,任何权重调整都不会应用到这些神经元。尽管这一做法看起来有些奇怪,但背后其实有合理的直觉。从直觉上讲,神经元的权重在每次反向传播时都会进行调整,以专门化处理训练数据中的特定特征。但这种专门化会带来依赖关系。最终的结果往往是,周围的神经元开始依赖于某个附近神经元的专门化,而不是自己进行一些表征性工作。这种依赖模式通常被称为复杂共适应(complex co-adaptation),这是人工智能(AI)研究人员创造的一个术语。其中之一就是 Geoffrey Hinton,他是反向传播论文的原始合著者,并被广泛称为深度学习的教父。Hinton 幽默地将这种复杂共适应的行为描述为神经元之间的阴谋,并表示他受到银行防欺诈系统的启发。这个银行不断轮换员工,因此每当 Hinton 访问银行时,他总是会遇到不同的人在柜台后面。
直观理解 dropout
如果你熟悉 Leonardo DiCaprio 的电影 Catch me if you can,你一定记得他是如何通过约会和请银行工作人员吃点心来迷住他们,结果却通过兑现伪造的航空公司支票来欺诈银行。事实上,由于员工们与 DiCaprio 角色的频繁交往,他们开始更加关注一些无关的特征,比如 DiCaprio 的魅力。实际上,他们应该注意的是,DiCaprio 每月兑现工资支票的次数超过了三次。无需多说,商界通常不会如此宽松。丢弃一些神经元就像是轮换它们,确保没有任何神经元懒惰,并让一个滑头的 Leonardo 欺诈你的网络。
当我们向一层应用 dropout 时,我们只是丢弃了它本应输出的一部分结果。假设一层对给定输入的输出是向量 [3, 5, 7, 8, 1]。如果我们给这个层添加一个 dropout 比例(0.4),则该输出将变为 [0, 5, 7, 0, 1]。我们所做的只是将向量中 40% 的标量初始化为零。
Dropout 仅在训练过程中发生。在测试过程中,使用过 dropout 的层会将其输出按先前使用的 dropout 比例缩小。这实际上是为了调整测试时比训练时更多神经元激活的情况,因为 dropout 机制的存在。
在 Keras 中实现权重正则化
到目前为止,我们已经探讨了三种特定方法的理论,这些方法可以提高我们模型在未见数据上的泛化能力。首先,我们可以改变网络大小,确保其没有额外的学习能力。我们还可以通过初始化加权参数来惩罚低效的表示。最后,我们可以添加 dropout 层,防止网络变得懒散。如前所述,看得见才相信。
现在,让我们通过 MNIST 数据集和一些 Keras 代码来实现我们的理解。如前所述,要改变网络的大小,您只需要更改每层的神经元数量。这可以在 Keras 中通过添加层的过程来完成,如下所示:
import keras.regularizers
model=Sequential()
model.add(Flatten(input_shape=(28, 28)))
model.add(Dense(1024, kernel_regularizer=  
                      regularizers.12(0.0001),activation ='relu'))
model.add(Dense(28, kernel_regularizer=regularizers.12(0.0001), 
          activation='relu'))
model.add(Dense(10, activation='softmax'))
权重正则化实验
简单来说,正则化器让我们在优化过程中对层参数施加惩罚。这些惩罚被纳入网络优化的 loss 函数中。在 Keras 中,我们通过将 kernel_regularizer 实例传递给层来正则化层的权重:
import keras.regularizers
model=Sequential()
model.add(Flatten(input_shape=(28,28)))
model.add(Dense(1024, kernel_regularizer=regularizers.12(0.0001), 
          activation='relu'))
model.add(Dense(10, activation='softmax'))
如前所述,我们对每一层都添加了 L2 正则化,alpha 值为(0.0001)。正则化器的 alpha 值表示在将其添加到网络总损失之前,应用于层的权重矩阵中每个系数的变换。实质上,alpha 值用于将每个系数与它相乘(在我们的例子中是 0.0001)。Keras 中的不同正则化器可以在 keras.regularizers 中找到。下图展示了正则化如何影响两个相同大小模型每个 epoch 的验证损失。我们可以看到,正则化后的模型更不容易过拟合,因为验证损失在时间的变化中没有显著增加。而没有正则化的模型则完全不是这样,经过大约七个 epoch 后,模型开始过拟合,因此在验证集上的表现变差:

在 Keras 中实现 dropout 正则化
在 Keras 中,添加一个 dropout 层也是非常简单的。你只需要再次使用 model.add() 参数,然后指定一个 dropout 层(而不是我们一直使用的全连接层)来进行添加。Keras 中的 Dropout 参数是一个浮动值,表示将被丢弃的神经元预测的比例。一个非常低的 dropout 率可能无法提供我们所需的鲁棒性,而一个高 dropout 率则意味着我们的网络容易遗忘,无法记住任何有用的表示。我们再次努力寻找一个恰到好处的 dropout 值;通常,dropout 率设定在 0.2 到 0.4 之间:
#Simple feed forward neural network
model=Sequential()
#feeds in the image composed of 28  28 a pixel matrix as one sequence of 784
model.add(Flatten(input_shape=(28,28)))
model.add(Dense(1024, activation='relu'))
model.add(Dropout(0.3)
model.add(Dense(28, activation='relu'))
model.add(Dense(10, activation='softmax'))
Dropout 正则化实验
以下是我们使用相同大小的网络进行的两个实验,采用不同的 dropout 率,以观察性能上的差异。我们从 0.1 的 dropout 率开始,逐渐增加到 0.6,以查看这对我们识别手写数字的性能有何影响。正如下图所示,增加 dropout 率似乎减少了过拟合,因为模型在训练集上的表面准确度逐渐下降。我们可以看到,在 dropout 率接近 0.5 时,我们的训练准确度和测试准确度趋于收敛,之后它们出现了分歧行为。这简单地告诉我们,网络似乎在添加 dropout 率为 0.5 的层时最不容易过拟合:


复杂性与时间
现在,你已经看到了我们减少过拟合的一些最突出技巧,都是通过正则化实现的。本质上,正则化就是控制我们网络复杂度的一种方式。控制复杂度不仅仅是限制网络记忆随机性的手段,它还带来了更多直接的好处。从本质上讲,更复杂的网络在计算上代价更高。它们需要更长的训练时间,因此消耗更多资源。虽然在处理当前任务时这种差异几乎可以忽略不计,但它仍然是显著的。下图是一个时间复杂度图。这是一种将训练时间与网络复杂度之间的关系可视化的有用方式。我们可以看到,网络复杂度的增加似乎对每次训练迭代所需的平均时间的增加产生了近乎指数的影响:

MNIST 总结
到目前为止,在我们的学习旅程中,你已经了解了支配神经网络功能的基本学习机制和过程。你了解到,神经网络需要输入数据的张量表示才能进行预测性处理。你还学习了我们世界中不同类型的数据,如图像、视频、文本等,如何表示为* n *维的张量。此外,你学会了如何在 Keras 中实现一个顺序模型,该模型基本上让你构建一层层相互连接的神经元。你使用这个模型结构,构建了一个简单的前馈神经网络,用于分类手写数字的 MNIST 数据集。在此过程中,你了解了在模型开发的每个阶段需要考虑的关键架构决策。
在模型构建过程中,主要的决策是定义数据的正确输入大小,选择每一层的相关激活函数,以及根据数据中输出类别的数量来定义最后一层的输出神经元数量。在编译过程中,你需要选择优化技术、loss 函数和监控训练进展的度量标准。然后,你通过使用 .fit() 参数启动了新模型的训练会话,并传递了最后两个架构决策,作为启动训练过程之前必须做出的决定。这些决策涉及数据一次性处理的批次大小,以及训练模型的总轮数。
最后,你学会了如何测试预测结果,并了解了正则化这一关键概念。我们通过实验不同的正则化技术来修改模型的大小、层权重,并添加丢弃层,从而帮助我们提高模型对未见数据的泛化能力。最后,我们发现,除非任务的性质明确要求,否则增加模型复杂度是不利的:
- 
练习 x:初始化不同的加权参数,观察这如何影响模型的表现 
- 
练习 y:初始化每一层的不同权重,观察这如何影响模型的表现 
语言处理
到目前为止,我们已经看到如何在 Keras 上训练一个简单的前馈神经网络来进行图像分类任务。我们还看到如何将图像数据数学地表示为一个高维几何形状,也就是一个张量。我们了解到,较高阶的张量实际上是由较低阶的张量组成的。像素聚集在一起,代表一个图像,而图像又聚集在一起,代表一个完整的数据集。从本质上讲,每当我们想要利用神经网络的学习机制时,我们都有一种方法来将训练数据表示为一个张量。那么语言呢?我们如何像通过语言表达一样,将人类的思想及其复杂性表示出来?你猜对了——我们将再次使用数字。我们将简单地将由句子组成的文本(而句子又由单词组成)翻译成数学的通用语言。这是通过一种被称为向量化的过程完成的,在我们的任务中,我们将通过使用互联网电影数据库(IMDB)数据集来亲身体验这一过程。
情感分析
随着我们的计算能力逐年提升,我们开始将计算技术应用于以前仅由语言学家和定性学者频繁涉足的领域。事实证明,最初被认为太耗时的任务,随着处理器性能的提升,变成了计算机优化的理想对象。这导致了计算机辅助文本分析的爆炸式增长,不仅在学术界,而且在工业界也得到了广泛应用。像计算机辅助情感分析这样的任务,在各种应用场景中尤其有益。如果你是一家企业,试图跟踪在线客户评论,或者是一个雇主,想要进行社交媒体平台上的身份管理,这项技术都可以派上用场。事实上,甚至连政治竞选活动也越来越多地咨询那些监测公共情感并对各种政治话题进行舆情挖掘的服务。这帮助政治人物准备他们的竞选要点,理解公众的普遍情绪。尽管这种技术的使用可能颇具争议,但它可以极大地帮助组织了解其产品、服务和营销策略中的缺陷,同时以更符合受众需求的方式进行调整。
互联网电影评论数据集
最简单的情感分析任务是判断一段文本是否代表正面或负面的观点。这通常被称为 极性 或 二元情感分类任务,其中 0 代表负面情感,1 代表正面情感。当然,我们也可以有更复杂的情感模型(也许使用我们在 第一章 中看到的五大人格指标,神经网络概述),但目前我们将专注于这个简单但概念上充实的二元示例。这个示例指的是从互联网电影数据库 IMDB 分类电影评论。
IMDB 数据集包含 50,000 条二进制评论,正负情感评论数量均等。每条评论由一个整数列表组成,每个整数代表该评论中的一个词汇。同样,Keras 的守护者们贴心地为练习提供了这个数据集,因此可以在 Keras 的 keras.datasets 中找到。我们鼓励你享受通过 Keras 导入数据的过程,因为我们在以后的练习中不会再这样做(在现实世界中你也无法做到):
import keras
from keras.datasets import imdb
(x_train,y_train), (x_test,y_test)=imdb.load_data(num_words=12000)
加载数据集
就像我们之前所做的,我们通过定义训练实例和标签,以及测试实例和标签来加载数据集。我们可以使用 imdb 上的 load_data 参数将预处理后的数据加载到 50/50 的训练-测试拆分中。我们还可以指定我们想要保留在数据集中的最常见词汇数量。这帮助我们控制任务的固有复杂性,同时处理合理大小的评论向量。可以安全地假设,评论中出现的稀有词汇与给定电影的特定主题相关,因此它们对该评论的 情感 影响较小。因此,我们将词汇量限制为 12,000 个。
检查形状和类型
你可以通过检查 x_train 的 .shape 参数来查看每个数据拆分的评论数量,它本质上是一个 n 维的 NumPy 数组:
x_train.shape, x_test.shape, type(x_train)
((25000,), (25000,), numpy.ndarray)
绘制单个训练实例
正如我们所见,有 25,000 个训练和测试样本。我们还可以绘制一个单独的训练样本,看看如何表示单个评论。在这里,我们可以看到每条评论仅包含一个整数列表,每个整数对应词汇表中的一个单词:
x_train[1]
[1,
 194,
 1153,
 194,
 8255,
 78,
 228,
 5,
 6,
 1463,
 4369,
 5012,
 134,
 26,
 4,
 715,
 8,
 118,
 1634,
 14,
 394,
 20,
 13,
 119,
 954,
解码评论
如果你感到好奇(我们也很感兴趣),我们当然可以映射出这些数字所对应的确切单词,以便我们能够读懂评论的实际内容。为了做到这一点,我们必须备份我们的标签。虽然这一步不是必需的,但如果我们希望稍后直观验证网络的预测结果,这将非常有用:
#backup labels, so we can verify our networks prediction after vectorization
xtrain = x_train
xtest = x_test
然后,我们需要恢复与整数对应的单词,这些整数表示了评论,我们之前已经看到过。用于编码这些评论的单词字典包含在 IMDB 数据集中。我们将简单地将其恢复为word_index变量,并反转其存储顺序。这基本上允许我们将每个整数索引映射到其对应的单词:
word_index =imdb.get_word_index()
reverse_word_index = dict([(value, key) for (key, value) in word_index.items()])
以下函数接受两个参数。第一个参数(n)表示一个整数,指代数据集中第 n 条评论。第二个参数定义该 n 条评论是否来自训练数据或测试数据。然后,它简单地返回我们指定的评论的字符串版本。
这允许我们读取评论者实际写的内容。如我们所见,在我们的函数中,我们需要调整索引的位置,偏移了三个位置。这仅仅是 IMDB 数据集的设计者选择实现其编码方案的方式,因此对于其他任务来说,这并不具有实际意义。这个偏移量之所以存在,是因为位置 0、1 和 2 分别被填充、表示序列的开始和表示未知值的索引占据:
def decode_review(n, split= 'train'):
if split=='train':
    decoded_review=' '.join([reverse_word_index.get(i-3,'?')for i in 
                   ctrain[n]])
elif split=='test':
    decoded_review=' '.join([reverse_word_index.get(i-3,'?')for i in   
                   xtest[n]])
return decoded_review
使用这个函数,我们可以解码来自训练集中的第五条评论,如下代码所示。结果表明,这是一条负面评论,正如其训练标签所示,并通过其内容推断得出。请注意,问号仅仅是未知值的指示符。未知值可能会自然出现在评论中(例如由于使用了表情符号),或者由于我们施加的限制(即,如果一个词不在前 12,000 个最常见的单词中,如前所述):
print('Training label:',y_train[5])
decode_review(5, split='train'),
Training label: 0.0
数据准备
那么,我们在等什么呢?我们有一系列数字表示每条电影评论及其对应的标签,表示(1)为正面评论或(0)为负面评论。这听起来像是一个经典的结构化数据集,那么为什么不开始将其输入网络呢?实际上,事情并没有那么简单。我们之前提到过,神经网络有一个非常特定的“饮食”。它们几乎是张量食者,所以直接喂给它们一个整数列表并不会有什么效果。相反,我们必须将数据集表示为一个n维的张量,才能尝试将其传递给网络进行训练。目前,你会注意到,每条电影评论都是由一个单独的整数列表表示的。自然地,这些列表的大小不同,因为有些评论比其他评论短。另一方面,我们的网络要求输入特征的大小相同。因此,我们必须找到一种方法来填充评论,使它们的表示向量长度相同。
独热编码
由于我们知道整个语料库中最多有 12,000 个独特的词汇,我们可以假设最长的评论只能有 12,000 个单词。因此,我们可以将每个评论表示为一个长度为 12,000 的向量,包含二进制值。这个怎么操作呢?假设我们有一个包含两个单词的评论:bad 和 movie。我们数据集中包含这些词汇的列表可能看起来像是[6, 49]。相反,我们可以将这个相同的评论表示为一个 12,000 维的向量,除了索引 6 和 49,其余位置为 0,6 和 49 的索引位置则是 1。你所做的基本上是创建 12,000 个虚拟特征来表示每个评论。这些虚拟特征代表了给定评论中 12,000 个词汇的存在或不存在。这个方法也被称为独热编码(one-hot encoding)。它通常用于在各种深度学习场景中对特征和类别标签进行编码。
向量化特征
以下函数将接收我们的 25,000 个整数列表的训练数据,每个列表都是一个评论。它将返回每个它从训练集收到的整数列表的独热编码向量。然后,我们简单地使用这个函数将整数列表转换成一个 2D 张量的独热编码评论向量,从而重新定义我们的训练和测试特征:
import numpy as np
def vectorize_features(features):
#Define the number of total words in our corpus 
#make an empty 2D tensor of shape (25000, 12000)
dimension=12000
review_vectors=np.zeros((len(features), dimension))
#interate over each review 
#set the indices of our empty tensor to 1s
for location, feature in enumerate(features):
    review_vectors[location, feature]=1
return review_vectors
x_train = vectorize_features(x_train)
x_test = vectorize_features(x_test)
你可以通过检查训练特征和标签的类型和形状来查看我们的转换结果。你还可以检查一个单独向量的样子,如下代码所示。我们可以看到,每个评论现在都是一个长度为12000的向量:
type(x_train),x_train.shape, y_train.shape
(numpy.ndarray, (25000, 12000), (25000,))
x_train[0].shape, x_train[0]
((12000,), array([0., 1., 1., ..., 0., 0., 0.]), 12000)
向量化标签
我们还可以向量化我们的训练标签,这有助于我们的网络更好地处理数据。你可以把向量化看作是以一种高效的方式将信息表示给计算机。就像人类不擅长使用罗马数字进行计算一样,计算机在处理未向量化的数据时也常常力不从心。在以下代码中,我们将标签转换为包含 32 位浮动点值 0.0 或 1.0 的 NumPy 数组:
y_train= np.asarray(y_train).astype('float32')
y_test = np.asarray(y_test).astype('float32')
最终,我们得到了张量,准备好被神经网络使用。这个 2D 张量本质上是 25,000 个堆叠的向量,每个向量都有自己的标签。剩下的就是构建我们的网络。
构建网络
在使用密集层构建网络时,必须考虑的第一个架构约束是其深度和宽度。然后,你需要定义一个具有适当形状的输入层,并依次选择每一层要使用的激活函数。
就像我们为 MNIST 示例所做的那样,我们简单地导入了顺序模型和密集层结构。然后,我们通过初始化一个空的顺序模型,并逐步添加隐藏层,直到达到输出层。请注意,我们的输入层总是需要特定的输入形状,对于我们来说,这对应于我们将要馈送的 12,000 维度的独热编码向量。在我们当前的模型中,输出层仅有一个神经元,如果给定评论中的情感是积极的,则理想情况下会激活该神经元;否则,不会。我们将选择修正线性单元(ReLU)激活函数作为隐藏层的激活函数,并选择 sigmoid 激活函数作为最终层的激活函数。请记住,sigmoid 激活函数简单地将概率值压缩到 0 到 1 之间,非常适合我们的二元分类任务。ReLU 激活函数帮助我们将负值归零,因此可以被认为是许多深度学习任务中的一个良好默认选择。总之,我们选择了一个具有三个密集连接的隐藏层模型,分别包含 18、12 和 4 个神经元,以及一个具有 1 个神经元的输出层:
from keras.models import sequential 
from keras.layers import Dense
model=Sequential()
model.add(Dense(6, activation='relu', input_shape=(12000)))
model.add(Dense(6, activation='relu'))
model.add(Dense(1, activation='sigmoid'))
编译模型
现在我们可以编译我们新构建的模型,这是深度学习的传统做法。请记住,在编译过程中,两个关键架构决策是选择loss函数以及优化器。loss函数帮助我们在每次迭代中衡量模型与实际标签的差距,而优化器则确定了我们如何收敛到理想的预测权重。在第十章,思考当前和未来的发展,我们将审视先进的优化器及其在各种数据处理任务中的相关性。现在,我们将展示如何手动调整优化器的学习率。
为了演示目的,我们选择了非常小的学习率 0.001,使用均方根传播(RMS)优化器。请记住,学习率的大小仅仅决定了我们的网络在每次训练迭代中朝着正确输出方向迈出的步长大小。正如我们之前提到的,大步长可能会导致我们的网络在损失超空间中“跨越”全局最小值,而小学习率则可能导致模型花费很长时间才能收敛到最小损失值:
from keras import optimizers
model.compile(optimizer=optimizers.RMSprop(1r=0.001),
    loss='binary_crossentropy',
    metrics=['accuracy']) 
拟合模型
在我们之前的 MNIST 示例中,我们简要地介绍了最少的架构决策来让代码运行起来。这让我们可以快速覆盖深度学习的工作流,但效率相对较低。你可能还记得,我们只是简单地在模型上使用了fit参数,并传递了训练特征和标签,同时提供了两个整数,分别表示训练模型的 epoch 次数和每次训练迭代的批次大小。前者仅仅定义了数据通过模型的次数,而后者则定义了每次更新模型权重之前,模型会看到多少个学习样本。这两者是必须定义并根据具体情况调整的最重要的架构考量。不过,fit参数实际上还可以接受一些其他有用的参数。
验证数据
你可能会想,为什么我们要盲目地训练模型,迭代任意次数,然后再在保留数据上进行测试。难道不应该在每个 epoch 之后,就用一些看不见的数据来评估我们的模型表现,这样岂不是更高效吗?这样,我们就能准确评估模型开始过拟合的时机,从而结束训练过程,节省一些昂贵的计算时间。我们可以在每个 epoch 后展示测试集给模型,但不更新其权重,纯粹是为了看看它在该 epoch 后在测试数据上的表现如何。由于我们在每次测试运行时都不更新模型的权重,我们就不会让模型在测试数据上过拟合。这使我们能够在训练过程中实时了解模型的泛化能力,而不是训练完成后再去评估。要在验证集上测试你的模型,你只需要像传递训练数据那样,把验证数据的特征和标签作为参数传递给fit参数即可。
在我们的案例中,我们只是将测试特征和标签作为验证数据使用。在一个高风险的深度学习场景中,你可能会选择将测试集和验证集分开使用,其中一个用于训练过程中的验证,另一个则保留在后期评估中,确保在部署模型到生产环境之前进行最后的测试。以下是相应的代码示例:
network_metadata=model.fit(x_train, y_train,
                           validation_data=(x_test, y_test),
                           epochs=20,
                           batch_size=100)
现在,当你执行前面的单元格时,你将看到训练会话开始。此外,在每个训练周期结束时,你将看到我们的模型暂停片刻,计算并显示验证集上的准确度和损失。然后,模型在不更新权重的情况下,继续进入下一个周期进行新的训练轮次。前述模型将在 20 个周期中运行,每个周期会批量处理 25,000 个训练样本,每批 100 个,在每批次后更新模型权重。请注意,在我们的案例中,模型的权重每个周期更新 250 次,或者在 20 个周期的训练过程中,总共更新 5,000 次。所以,现在我们可以更好地评估我们的模型何时开始记忆训练集中的随机特征,但我们如何在此时中断训练会话呢?嗯,你可能已经注意到,与其直接执行model.fit(),我们将其定义为network_metadata。实际上,fit()参数会返回一个历史对象,其中包含我们模型的相关训练统计数据,我们希望恢复该对象。这个历史对象是通过 Keras 中名为回调的机制记录的。
回调函数
callback本质上是 Keras 库的一个函数,可以在训练过程中与我们的模型进行交互,检查其内部状态并保存相关的训练统计数据,以便后续审查。在keras.callbacks中存在很多回调函数,我们将介绍一些至关重要的回调。对于那些更倾向于技术性的用户,Keras 甚至允许你构建自定义回调。要使用回调,你只需要将它传递给fit参数,使用关键字参数callbacks。需要注意的是,历史回调会自动应用于每个 Keras 模型,因此只要你将拟合过程定义为变量,就不需要指定它。这使得你能够恢复相关的历史对象。
重要的是,如果你之前在 Jupyter Notebook 中启动了训练会话,那么在模型上调用fit()参数将会继续训练同一个模型。相反,你需要重新初始化一个空白模型,然后再进行另一次训练。你可以通过重新运行之前定义并编译顺序模型的单元格来实现这一点。然后,你可以通过使用callbacks关键字参数将回调传递给fit()参数,从而实现回调,示例如下:
early_stopping= keras.callbacks.EarlyStopping(monitor='loss')
network_metadata=model.fit(x_train, y_train, validation_data=(x_test,  
                           y_test), epochs=20, batch_size=100,  
                           callbacks=[early_stopping]) 
早停和历史回调
在前面的单元格中,我们使用了一个名为早停的回调。这个回调允许我们监控一个特定的训练指标。我们可以选择的指标包括训练集或验证集上的准确度或损失,这些信息都存储在一个与模型历史相关的字典中:
history_dict = network_metadata.history
history_dict.keys()
dict_keys(['val_loss','val_acc','loss','acc'])
选择一个监控的指标
理想的选择始终是验证损失或验证准确率,因为这些指标最能代表我们模型在外部数据集上的可预测性。这仅仅是因为我们只在训练过程中更新模型权重,而不是在验证过程中。选择训练准确率或损失作为指标(如以下代码所示)并不是最佳选择,因为你是在通过模型自身对基准的定义来评估模型。换句话说,你的模型可能一直在减少损失并提高准确率,但它这样做是通过死记硬背——而不是因为它正在学习我们希望它能掌握的普适预测规则。正如我们在以下代码中看到的,通过监控训练损失,我们的模型继续减少训练集上的损失,尽管验证集上的损失在第一次训练后不久就开始增加:
import matplotlib.pyplot as plt
acc=history_dict['acc']
loss_values=history_dict['loss']
val_loss_values=history_dict['loss']
val_loss_values=history_dict['val_loss']
epochs = range(1, len(acc) + 1)
plt.plot(epochs, loss_values,'r',label='Training loss')
plt.plot(epochs, val_loss_valuesm, 'rD', label-'Validation loss')
plt.title('Training and validation loss')plt.xlabel('Epochs')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()
plt.show()
上述代码生成了以下输出:

我们使用 Matplotlib 绘制了上述图表。同样,你也可以清除之前的损失图,并绘制出新的训练准确率图,如以下代码所示。如果我们将验证准确率作为指标来跟踪早停回调,那么我们的训练会在第一个训练周期后结束,因为此时我们的模型似乎对未见过的数据具有最好的泛化能力:
plt.clf()
acc_values=history_dict['acc']
val_acc_values=history_dict['val_acct']
plt.plot(epochs, history_dict.get('acc'),'g',label='Training acc')
plt.plot(epochs, history_dict.get('val_acc'),'gD',label='Validation acc')
plt.title('Training and validation accuracy')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()
plt.show()
上述代码生成了以下输出:

访问模型预测
在 MNIST 示例中,我们使用了Softmax激活函数作为最后一层。你可能记得,这一层生成了一个包含 10 个概率值的数组,总和为 1,表示给定输入的概率。每一个概率值代表输入图像属于某个输出类别的可能性(例如,它 90%确定看到的是数字 1,10%确定看到的是数字 7)。对于一个具有 10 个类别的分类任务,这种方法是合理的。在我们的情感分析问题中,我们选择了 Sigmoid 激活函数,因为我们处理的是二分类任务。在这里使用 Sigmoid 函数强制我们的网络对任何给定数据实例输出 0 到 1 之间的预测值。因此,越接近 1 的值意味着我们的网络认为该信息更有可能是积极评论,而越接近 0 的值则表示网络认为该信息是负面评论。要查看我们模型的预测,只需定义一个名为predictions的变量,使用predict()方法对训练好的模型进行预测,并传入我们的测试集。现在,我们可以查看网络在该测试集中某个示例上的预测结果,具体如下:
predictions=model.predict([x_test])
predictions[5]
在这种情况下,我们的网络似乎非常确信我们测试集中的5号评论是一个正面评论。我们不仅可以通过检查y_test[5]中存储的标签来验证这是否真是如此,还可以利用我们之前构建的解码器函数解码评论本身。让我们通过解码5号评论并检查其标签来验证网络的预测:
y_test[5], decode_review(5, split='test')
结果证明我们的网络是对的。这是一个复杂的语言模式示例,它需要对语言语法、现实世界的实体、关系逻辑以及人类胡乱叨叨的倾向有更高层次的理解。然而,凭借仅仅 12 个神经元,我们的网络似乎已经理解了这段信息中所编码的潜在情感。尽管出现了像disgusting这样的词汇,这些词在负面评论中非常常见,但它依然做出了高达 99.99%的高可信度预测。
探测预测结果
让我们再检查一个评论。为了更好地探测我们的预测结果,我们将编写一些函数来帮助我们更清晰地可视化结果。如果你想将模型的预测限制为最有信心的实例,这样的评估函数也可以派上用场:
def gauge_predictions(n):
if (predictions[n]<=0.4) and (y_test[n]==0):
    print('Network correctly predicts that review %d is negative' %(n))
elif (predictions[n] <=0.7) and (y_test[n]==1);
elif (predictions[n]>-0.7) and (y_test[n]==0):
else:
    print('Network is not so sure. Review mp. %d has a probability score of %(n),   
           predictions[n])
def verify_predictions(n):
    return gauge_predictions(n), predictions[n], decode_review(n, split='test')
我们将编写两个函数来帮助我们更好地可视化网络的错误,同时将我们的预测准确性限制在上限和下限之间。我们将使用第一个函数将网络的概率得分高于 0.7 的实例定义为好的预测,得分低于 0.4 的定义为差的预测,针对正面评论。对于负面评论,我们简单地反转这一规则(负面评论的好预测得分低于 0.4,差的得分高于 0.7)。我们还在 40%到 70%之间留下一个中间地带,将其标记为不确定预测,以便更好地理解其准确和不准确预测的原因。第二个函数设计得较为简单,接受一个整数值作为输入,表示你想探测和验证的第n条评论,并返回网络的评估结果、实际概率得分,以及该评论的内容。让我们使用这些新编写的函数来探测另一个评论:
verify-predictions(22)
network falsely predicts that review 22 is negative
如我们所见,网络似乎相当确定我们测试集中 22 号评论是负面的。它生成了 0.169 的概率得分。你也可以理解为我们的网络以 16.9% 的信心认为这条评论是正面的,因此它必须是负面的(因为我们只用了这两类来训练我们的网络)。结果证明,网络在这条评论上判断错误。阅读评论后,你会发现评论者实际上是对这部被认为被低估的电影表示赞赏。注意,开头的语气相当模糊,使用了诸如 silly 和 fall flat 这样的词汇。然而,句子中的语境情感转折器使得我们的生物神经网络能够确定这条评论其实表达了积极的情感。可惜,我们的人工神经网络似乎没有捕捉到这一特定模式。让我们继续使用另一个例子来进行探索分析:
verify_predictions(19999)
Network is not so sure. Review no. 19999 has a probability score of [0.5916141]
在这里,我们可以看到,尽管我们的网络实际上猜对了评论的情感,且概率得分为 0.59,接近 1(正面)而非 0(负面),但是它对于评论的情感并不太确定。对我们来说,这条评论显然是正面的——甚至有些过于推销。直观上,我们不明白为什么我们的网络对情感没有信心。稍后在本书中,我们将学习如何通过网络层可视化词嵌入。目前,让我们继续通过最后一个例子来探究:
verify_predictions(4)
Network correctly predicts that review 4 is positive
这一次,我们的网络再次做对了。实际上,我们的网络有 99.9% 的信心认为这是一条正面评论。阅读评论时,你会发现它实际上做得相当不错,因为评论中包含了 boring、average 这样的词汇,还有像 mouth shut 这样的暗示性语言,这些都可能出现在其他负面评论中,从而可能误导我们的网络。正如我们所见,我们通过提供一个短小的函数来结束这次探讨,你可以通过随机检查给定数量评论的网络预测来进行实验。然后,我们打印出网络对于测试集中两条随机挑选的评论的预测结果:
from random import randint
def random_predict(n_reviews):
for i in range(n_reviews):
print(verify_predictions(randint(0, 24000)))
random_predict(2)
Network correctly predicts that review 20092 is positive
IMDB 总结
现在你应该对如何通过简单的前馈神经网络处理自然语言文本和对话有了更清晰的了解。在我们旅程的这一小节中,你学习了如何使用前馈神经网络执行二分类情感分析任务。在这个过程中,你了解了如何填充和向量化自然语言数据,为神经网络处理做好准备。你还了解了二分类任务中涉及的关键架构变化,比如在网络最后一层使用输出神经元和 sigmoid 激活函数。你还看到了如何利用数据中的验证集来评估模型在每次训练周期后在未见数据上的表现。此外,你学会了如何通过使用 Keras 回调函数间接与模型进行交互。回调函数可以用于多种用例,从在某个检查点保存模型到在达到某个预期指标时终止训练会话。我们可以使用历史回调来可视化训练统计信息,还可以使用早停回调来指定终止当前训练会话的时刻。最后,你看到了如何检查每个评论的网络预测,以更好地理解模型会犯哪些错误:
- 练习:通过正则化提高性能,就像我们在 MNIST 示例中所做的那样。
预测连续变量
到目前为止,我们已经使用神经网络完成了两个分类任务。在第一个任务中,我们对手写数字进行了分类。在第二个任务中,我们对电影评论的情感进行了分类。但如果我们想预测一个连续值而不是分类值呢?如果我们想预测某个事件发生的可能性,或者某个物品未来的价格呢?对于这样的任务,像预测给定市场的价格等示例可能会浮现在脑海中。因此,我们将通过使用波士顿房价数据集来编写另一个简单的前馈网络,作为本章的总结。
该数据集类似于大多数数据科学家和机器学习从业者会遇到的现实世界数据集。数据集提供了 13 个特征,分别指代位于波士顿的某个特定地理区域。通过这些特征,任务是预测房屋的中位数价格。这些特征包括从居民和工业活动、空气中的有毒化学物质水平、财产税、教育资源可达性到与位置相关的其他社会经济指标。数据收集于 1970 年代中期,似乎带有一些当时的偏见。你会注意到某些特征显得非常细致,甚至可能不适合使用。例如,第 12 个特征在机器学习项目中使用可能会非常具有争议。在使用某种数据源或数据类型时,你必须始终考虑其更高层次的含义。作为机器学习从业者,你有责任确保你的模型不会引入或加强任何社会偏见,或在任何方面加剧人们的不平等和不适感。记住,我们的目标是利用技术减轻人类的负担,而不是增加负担。
波士顿房价数据集
如前一节所提到的,该数据集包含 13 个训练特征,表示的是一个观察到的地理区域。
加载数据
我们感兴趣的因变量是每个位置的房屋价格,它作为一个连续变量表示房价,以千美元为单位。
因此,我们的每个观察值可以表示为一个 13 维的向量,配有一个相应的标量标签。在下面的代码中,我们绘制了训练集中的第二个观察值,并标出其对应的标签:
import keras
from keras.datasets import boston_housing.load_data()
(x_train, y_train),(x_test,y_test)=boston_housing.load_data()
x_train[1], y_train[1]
探索数据
该数据集与我们之前处理的数据集相比要小得多。我们只看到 404 个训练观察值和102个测试观察值:
print(type(x_train),'training data:',x_train.shape,'test data:',x_test.shape)
<class 'numpy.ndarray'>training data:(403, 13) test data: (102, 13)
我们还将生成一个字典,包含各特征的描述,以便我们理解它们实际编码的内容:
column_names=['CRIM','ZN','INDUS','CHAS','NOX','RM','AGE','DIS','RAD','TAX','PTRATIO','B','LST  
  AT']
key= ['Per capita crime rate.',
    'The proportion of residential land zoned for lots over 25,000   
     square feet.',
    'The proportion of non-retail business acres per town.',
    'Charles River dummy variable (=1 if tract bounds river; 0 
     otherwise).',
    'Nitric oxides concentration (parts per 10 million).',
    'The average number of rooms per dwelling.',
    'The porportion of owner-occupied units built before 1940.',
    'Weighted distances to five Boston employment centers.',
    'Index of accessibility to radial highways.',
    'Full-value property tax rate per $10,000.',
    'Pupil-Teacher ratio by town.',
    '1000*(Bk-0.63)**2 where Bk is the proportion of Black people by 
     town.',
    'Percentage lower status of the population.'}
现在让我们创建一个 pandas DataFrame,并查看训练集中前五个观察值。我们将简单地将训练数据和之前定义的列名一起传递给 pandas DataFrame构造函数。然后,我们将使用.head()参数在新创建的.DataFrame对象上,获取一个整洁的展示,具体如下:
import pandas as pd
df= pd.DataFrame(x_train, columns=column_names)
df.head()
特征归一化
我们可以看到,在我们的观察中,每个特征似乎都处于不同的尺度上。一些值在数百之间,而另一些则介于 1 到 12 之间,甚至是二进制的。尽管神经网络仍然可以处理未经过尺度变换的特征,但它几乎总是更倾向于处理处于相同尺度上的特征。实际上,网络可以从不同尺度的特征中学习,但没有任何保证能够在损失函数的局部最小值中找到理想解,且可能需要更长的时间。为了让我们的网络能够更好地学习此数据集,我们必须通过特征归一化的过程来统一我们的数据。我们可以通过从每个特征的均值中减去特征特定的均值,并将其除以特征特定的标准差来实现这一点。请注意,在实际部署的模型中(例如股市模型),这种尺度化方法不可行,因为均值和标准差的值可能会不断变化,取决于新的、不断输入的数据。在这种情况下,其他归一化和标准化技术(例如对数归一化)更适合使用:
mean=x_train.mean(axis=0)
std=x_train.std(axis=0)
x_train=(x_train-mean)/std
x_test=(x_test-mean)/std
print(x_train[0]) #First Training sample, normalized
构建模型
这个回归模型的主要架构差异,与我们之前构建的分类模型相比,涉及的是我们如何构建网络最后一层的方式。回想一下,在经典的标量回归问题中,比如当前问题,我们的目标是预测一个连续变量。为了实现这一点,我们避免在最后一层使用激活函数,并且只使用一个输出神经元。
我们放弃激活函数的原因是因为我们不希望限制这一层的输出值可能采取的范围。由于我们正在实现一个纯线性层,我们的网络能够学习预测一个标量连续值,正如我们希望的那样:
from keras.layers import Dense, Dropout
from keras.models import Sequential
model= Sequential()
model.add(Dense(26, activation='relu',input_shape=(13,)))
model.add(Dense(26, activation='relu'))
model.add(Dense(12, activation='relu'))
model.add(Dense(1))
编译模型
在这里编译过程中主要的架构差异在于我们选择实现的loss函数和度量标准。我们将使用均方误差(MSE)loss函数来惩罚更高的预测误差,同时使用平均绝对误差(MAE)度量来监控模型的训练进展:
from keras import optimizers
model.compile(optimizer= opimizers.RMSprop(lr=0.001),
              loss-'mse',
              metrics=['mae'])
model.summary()
__________________________________________________________
Layer (type)                 Output Shape              Param #   
==========================================================
dense_1 (Dense)              (None, 6)                 72006     
__________________________________________________________
dense_2 (Dense)              (None, 6)                 42        
__________________________________________________________
dense_3 (Dense)              (None, 1)                 7         
==========================================================
Total params: 72,055
Trainable params: 72,055
Non-trainable params: 0
__________________________________________________________
如我们之前所见,MSE 函数测量的是我们网络预测误差的平方平均值。简而言之,我们是在测量估计房价标签与实际房价标签之间的平方差的平均值。平方项通过惩罚与均值差距较大的误差来强调预测误差的分布。这种方法在回归任务中尤其有用,因为即使是小的误差值,也会对预测准确性产生重要影响。
在我们的例子中,房价标签的范围在 5 到 50 之间,以千美元为单位。因此,绝对误差为 1 实际上意味着预测误差为 1,000 美元。因此,使用基于绝对误差的loss函数可能不会为网络提供最佳的反馈机制。
另一方面,选择 MAE 作为度量标准非常适合衡量我们的训练进度。事实上,直观地可视化平方误差对我们人类来说并不容易。更好的做法是直接查看模型预测中的绝对误差,因为它在视觉上更具信息性。我们选择的度量标准对模型的训练机制没有实际影响——它只是为我们提供了一个反馈统计数据,用于可视化模型在训练过程中的表现好坏。MAE 度量本质上是两个连续变量之间差异的度量。
绘制训练和测试误差
在下图中,我们可以看到平均误差大约是 2.5(或$2,500 美元)。当预测价格为$50,000 的房屋时,这可能是一个小的偏差,但如果房屋本身的价格为$5,000,这就开始变得重要了:

最后,让我们使用测试集中的数据来预测一些房价。我们将使用散点图来绘制测试集的预测值与实际标签。在下图中,我们可以看到最佳拟合线以及数据点。尽管某些点的预测出现偏差,我们的模型似乎仍然能够捕捉到数据中的一般趋势:

此外,我们还可以绘制一个直方图,显示预测误差的分布。图表显示,模型在大多数情况下表现良好,但在预测某些值时遇到了一些困难,同时对少数观察值出现过高或过低的预测,如下图所示:

使用 k 折交叉验证验证你的方法
我们之前提到过,我们的数据集显著小于我们之前处理的数据集。这在训练和测试过程中引发了几个问题。首先,像我们这样将数据分割成训练集和测试集,最终只剩下 100 个验证样本。即便如此少的样本,也不足以让我们有信心地部署模型。此外,我们的测试得分可能会根据测试集中的数据段而发生很大变化。因此,为了减少我们对任何特定数据段的依赖,我们采用了机器学习中常见的一种方法——k 折交叉验证。本质上,我们将数据分成n个较小的分区,并使用相同数量的神经网络在这些较小的数据分区上进行训练。因此,进行五折交叉验证时,我们会将 506 个训练样本分成五个分区,每个分区 101 个样本(最后一个分区有 102 个)。然后,我们使用五个不同的神经网络,每个神经网络在五个数据分区中的四个分区上进行训练,并在剩余的分区上进行测试。最后,我们将五个模型的预测结果平均,生成一个单一的估计值:

使用 scikit-learn API 进行交叉验证
交叉验证相较于反复随机子采样的优势在于,所有观察值都用于训练和验证,每个观察值仅用于一次验证。
以下代码展示了如何在 Keras 中实现五折交叉验证,我们使用整个数据集(训练和测试数据一起),并打印出每次交叉验证运行中网络的平均预测值。正如我们所看到的,这通过在四个随机拆分上训练模型并在剩余的拆分上进行测试来实现。我们使用 Keras 提供的 scikit-learn API 包装器,利用 Keras 回归器,以及 sklearn 的标准缩放器、k 折交叉验证创建器和评分评估器:
import numpy as np
import pandas as pd
from keras.models import Sequential
from keras.layers import Dense
from keras.wrappers.scikit_learn import KerasRegressor
from sklearn.model_selection import cross_val_score
from sklearn.model_selection import KFold
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from keras.datasets import boston_housing
(x_train,y_train),(x_test,y_test) = boston_housing.load_data()
x_train.shape, x_test.shape
---------------------------------------------------------------
((404, 13), (102, 13)) ----------------------------------------------------------------
import numpy as np
x_train = np.concatenate((x_train,x_test), axis=0)
y_train = np.concatenate((y_train,y_test), axis=0)
x_train.shape, y_train.shape
-----------------------------------------------------------------
((506, 13), (506,))
-----------------------------------------------------------------
你会注意到我们构建了一个名为baseline_model()的函数来搭建我们的网络。这是在许多场景中构建网络的一种有用方式,但在这里,它帮助我们将模型对象传递给KerasRegressor函数,这是我们从 Keras 提供的 scikit-learn API 包装器中使用的。正如许多人所知道的,scikit-learn 一直是机器学习的首选 Python 库,提供了各种预处理、缩放、归一化和算法实现。Keras 的创建者实现了一个 scikit-learn 包装器,以便在这些库之间实现一定程度的互操作性:
def baseline_model():
    model = Sequential()
    model.add(Dense(13, input_dim=13, kernel_initializer='normal', 
              activation='relu'))
 model.add(Dense(1, kernel_initializer='normal'))
 model.compile(loss='mean_squared_error', optimizer='adam')
 return model
我们将利用这种跨功能性来执行我们的 k 折交叉验证,正如我们之前所做的那样。首先,我们将初始化一个随机数生成器,并设置一个常数随机种子。这只会为我们提供一致的模型权重初始化,帮助我们确保未来的模型可以一致地进行比较:
#set seed for reproducability 
seed = 7
numpy.random.seed(seed)
# Add a data Scaler and the keras regressor containing our model function to a list of estimators
estimators = []
estimators.append(('standardize', StandardScaler()))
estimators.append(('mlp', KerasRegressor(build_fn=baseline_model,   
                    epochs=100, batch_size=5, verbose=0)))
#add our estimator list to a Sklearn pipeline
pipeline = Pipeline(estimators)
#initialize instance of k-fold validation from sklearn api
kfold = KFold(n_splits=5, random_state=seed)
#pass pipeline instance, training data and labels, and k-fold crossvalidator instance to evaluate score
results = cross_val_score(pipeline, x_train, y_train, cv=kfold)
#The results variable contains the mean squared errors for each of our     
 5 cross validation runs.
print("Average MSE of all 5 runs: %.2f, with standard dev: (%.2f)" %   
      (-1*(results.mean()), results.std()))
------------------------------------------------------------------
Model Type: <function larger_model at 0x000001454959CB70>
MSE per fold:
[-11.07775911 -12.70752338 -17.85225084 -14.55760158 -17.3656806 ]
Average MSE of all 5 runs: 14.71, with standard dev: (2.61) 
我们将创建一个估算器列表并传递给 sklearn 的转换流水线,这对于按顺序缩放和处理数据非常有用。为了这次缩放我们的值,我们只需使用来自 sklearn 的StandardScaler()预处理函数,并将其添加到我们的列表中。我们还将 Keras 包装器对象添加到同一个列表中。这个 Keras 包装器对象实际上是一个回归估算器,叫做KerasRegressor,它接受我们创建的模型函数以及期望的批次大小和训练周期数作为参数。Verbose仅表示你希望在训练过程中看到多少反馈。通过将其设置为0,我们要求模型静默训练。
请注意,这些与我们之前传递给模型的.fit()函数的参数相同,正如我们之前为了启动训练会话所做的那样。
运行上述代码,我们可以估算网络在执行的五次交叉验证中的平均表现。results变量存储了每次交叉验证运行的网络 MSE 得分。然后,我们打印出所有五次运行的 MSE 均值和标准差(平均方差)。请注意,我们将均值乘以-1。这是一个实现问题,因为 scikit-learn 的统一评分 API 总是最大化给定的分数。然而,在我们的案例中,我们是尝试最小化 MSE。因此,需要最小化的分数会被取反,以便统一的评分 API 能够正确工作。返回的得分是实际 MSE 的负值。
总结
在本章中,我们学习了如何使用神经网络执行回归任务。这涉及到对我们先前分类模型的一些简单架构更改,涉及到模型构建(一个没有激活函数的输出层)和loss函数的选择(MSE)。我们还跟踪了 MAE 作为度量,因为平方误差不太直观,难以可视化。最后,我们使用散点图将模型的预测与实际预测标签进行对比,以更好地可视化网络的表现。我们还使用了直方图来理解模型预测误差的分布。
最后,我们介绍了 k 折交叉验证的方法,它在处理非常少量数据时优于显式的数据训练和测试拆分。我们做的不是将数据拆分为训练集和测试集,而是将其拆分为k个较小的部分。然后,我们使用与数据子集相同数量的模型生成一个单一的预测估计。每个模型在k-1 个数据分区上进行训练,并在剩余的一个数据分区上进行测试,之后对它们的预测得分进行平均。这样做避免了我们依赖数据的任何特定拆分进行测试,因此我们获得了一个更具普遍性的预测估计。
在下一章中,我们将学习卷积神经网络(CNNs)。我们将实现 CNN 并使用它们进行物体检测。我们还将解决一些图像识别问题。
练习
- 
实现三个不同的函数,每个函数返回一个大小(深度和宽度)不同的网络。使用这些函数并执行 k 折交叉验证。评估哪种大小最合适。 
- 
尝试 MAE 和 MSE loss函数,并在训练过程中记录差异。
- 
尝试不同的 loss函数,并在训练过程中记录差异。
- 
尝试不同的正则化技术,并在训练过程中记录差异。 
第二部分:高级神经网络架构
本节旨在帮助读者了解在神经网络中用于处理感官输入的不同类型的卷积层和池化层,这些输入可以是来自你笔记本电脑的图像,也可以是数据库或实时物联网应用。读者将学习如何使用预训练模型,如 LeNet,以及部分卷积网络进行图像和视频重建,并在 Keras 上进行实现,同时深入了解如何通过 REST API 部署模型,并将其嵌入到树莓派计算设备中,应用于自定义场景,例如摄影、监控和库存管理。
读者将详细了解强化学习网络的底层架构,并学习如何在 Keras 中实现核心和扩展层,以达到预期效果。
接着,读者将深入探讨不同类型递归网络的理论基础,理解什么是图灵完备算法,研究时间反向传播的具体影响,包括梯度消失的问题,并全面了解如何在这些模型中捕捉时间信息。
然后,读者将深入探讨一种特定类型的递归神经网络(RNN),即长短时记忆网络(LSTM),并了解另一种受我们生物学启发的神经网络架构。
本节包含以下章节:
- 
第四章,卷积神经网络 
- 
第五章,递归神经网络 
- 
第六章,长短时记忆网络 
- 
第七章,使用深度 Q 网络的强化学习 
第四章:卷积神经网络
在上一章中,我们看到如何利用前馈神经网络的预测能力执行多个信号处理任务。这一基础架构使我们能够引入构成人工神经网络(ANNs)学习机制的许多基本特性。
在这一章中,我们将更深入地探索另一种类型的人工神经网络,即卷积神经网络(CNN),它因在图像识别、目标检测和语义分割等视觉任务中的高效表现而闻名。事实上,这些特定架构的灵感也回溯到我们自己的生物学。很快,我们将回顾人类的实验和发现,这些实验和发现促成了这些复杂系统的灵感,这些系统在视觉任务上表现优异。这个概念的最新版本可以追溯到 ImageNet 分类挑战赛,在这项挑战中,AlexNet 能够在超大数据集上的图像分类任务中超越当时最先进的计算机视觉系统。然而,正如我们很快将看到的,CNN 的思想是跨学科科学研究的产物,背后有着数百万年的试验积累。
在这一章中,我们将涵盖以下主题:
- 
为什么是 CNN? 
- 
视觉的诞生 
- 
理解生物学中的视觉 
- 
现代卷积神经网络的诞生 
- 
设计卷积神经网络(CNN) 
- 
稠密层与卷积层 
- 
卷积操作 
- 
保持图像的空间结构 
- 
使用滤波器进行特征提取 
为什么是 CNN?
卷积神经网络与普通神经网络非常相似。正如我们在上一章中看到的,神经网络由具有可学习权重和偏差的神经元组成。每个神经元仍然使用点积计算其输入的加权和,添加一个偏置项,然后通过非线性方程传递。网络将展示一个可微分的评分函数,这个函数将从一端的原始图像到另一端的分类分数。
它们也会有像 softmax 或 SVM 这样的损失函数在最后一层。此外,我们所学的开发神经网络的所有技术都将适用。
但你可能会问,卷积神经网络(ConvNets)有什么不同?所以需要注意的主要一点是,卷积网络架构明确假设接收到的输入都是图像,这一假设实际上帮助我们编码架构自身的其他属性。这样做可以使网络在实现上更高效,大大减少所需参数的数量。我们称一个网络为卷积网络,是因为它有卷积层,除此之外还有其他类型的层。很快,我们将探索这些特殊的层以及其他一些数学运算,如何帮助计算机更好地理解我们周围的世界。
因此,这种特定的神经网络架构在各种视觉处理任务中表现出色,涵盖了物体检测、面部识别、视频分类、语义分割、图像描述、人类姿态估计等多个领域。这些网络使得一系列计算机视觉任务能够高效执行,其中一些任务对人类进步至关重要(如医学诊断),而另一些则接近娱乐领域(如将特定艺术风格叠加到图像上)。在我们深入探讨其构思和当代应用之前,了解我们试图复制的更广泛领域是非常有用的,可以通过快速了解视觉这一既复杂又与我们人类息息相关的事物是如何诞生的。
视觉的诞生
接下来是一个史诗般的故事,一个发生在大约 5.4 亿年前的史诗般的故事。
在这个时期,在后来被称为地球的淡蓝色宇宙点上,生命是相当宁静且无忧无虑的。那时,几乎所有的祖先都是水生生物,他们会在海洋的宁静中漂浮,只有当食物漂浮在他们面前时,才会吃上一口。是的,这与今天的掠食性、压力重重且充满刺激的世界是截然不同的。
突然,一件相当奇特的事情发生了。在随后的相对较短时间内,地球上动物物种的数量和种类发生了爆炸性增长。在接下来的大约 2000 万年里,你会发现地球上的生物发生了剧烈变化。它们从偶尔会遇到的单细胞生物,组成松散的群体,变成了复杂的多细胞生物,遍布地球的每个小溪和角落。
生物学家曾长期困惑,争论是什么导致了这一大爆炸式的进化加速。我们真正发现的是生物视觉系统的诞生。通过研究当时生物的化石记录,动物学家能够提供确凿的证据,将这种物种爆发与首次出现的光感受细胞联系起来。这些细胞使得生物能够感知并对光作出反应,触发了一场进化的军备竞赛,最终导致了复杂的哺乳动物视觉皮层的形成,而你现在可能正是依赖这个视觉皮层来解读这段文字的。的确,视觉的恩赐使得生命变得更加动态和主动,因为生物现在能够感知并应对周围的环境。
如今,视觉是几乎所有生物中主要的感官系统,无论其是否具有智能。事实上,我们人类几乎将一半的神经元容量用于视觉处理,这使得视觉成为我们用来定位自己、识别他人和物体、并进行日常活动的最大感官系统。事实证明,视觉是认知系统的一个非常重要的组成部分,无论是生物系统还是其他系统。因此,研究自然界创造的视觉系统的发展与实现是完全合理的。毕竟,重复造轮子没有意义。
理解生物学视觉
我们对生物视觉系统的下一项见解来源于 20 世纪 50 年代末,哈佛大学的科学家们进行的一系列实验。诺贝尔奖得主大卫·胡贝尔和托尔斯坦·维塞尔通过映射猫的视觉神经元,从视网膜到视觉皮层,向世界展示了哺乳动物视觉皮层的内部工作原理。这些科学家利用电生理学方法,了解我们的感官器官如何摄取、处理和解释电磁辐射,从而生成我们周围所见的现实。这使他们能够更好地理解在单个神经元水平上,刺激和相关反应的流动:

以下截图描述了细胞如何响应光:

通过他们在神经科学领域的实验,我们能够与您分享他们研究中的几个关键元素,这些元素直接影响了科学界对视觉信号处理的理解,并引发了一系列学术贡献,最终促成了今天的成就。这些元素启示了我们大脑在视觉信息处理中的机制,激发了卷积神经网络(CNN)的设计,而这成为现代视觉智能系统的基石。
概念化空间不变性
其中的第一个概念来自于空间不变性。研究人员注意到,猫对特定图案的神经反应是一致的,无论这些图案在屏幕上的具体位置如何。从直觉上看,研究人员发现相同的一组神经元会对给定的图案(即一条线段)做出反应,即使该图案出现在屏幕的顶部或底部。这表明这些神经元的激活具有空间不变性,意味着它们的激活与给定图案的空间位置无关。
定义神经元的接受域
其次,他们还注意到神经元负责响应给定输入的特定区域。他们将这种神经元的特性称为感受野。换句话说,某些神经元只对给定输入的特定区域做出响应,而其他神经元则对同一输入的不同区域作出反应。神经元的感受野简单来说就是指神经元可能响应的输入范围。
实现神经元层次结构
最终,研究人员成功证明了视觉皮层中存在神经元的层次结构。他们展示了低级别的细胞负责检测简单的视觉模式,如线段。这些神经元的输出被后续神经元层用来构建越来越复杂的模式,最终形成我们看到并与之互动的物体和人物。实际上,现代神经科学证实,视觉皮层的结构是按层次组织的,通过使用前一层的输出,执行越来越复杂的推理,正如下图所示:

上述图示意味着,识别一个朋友涉及检测组成他们面部的线段(V1),利用这些线段构建形状和边缘(V2),使用这些形状和边缘形成复杂的形状,如眼睛和鼻子(V3),然后运用先前的知识推断出这组眼睛和鼻子最像你哪位朋友(IT-后部)。基于这种推理,甚至可能出现更高层次的激活,涉及到关于该朋友的个性、吸引力等概念(IT-前部)。
现代卷积神经网络的诞生
直到 1980 年代,Heubel 和 Wiesel 的发现才在计算机科学领域得到了重新利用。神经认知层次网(Fukushima, 1980: www.rctn.org/bruno/public/papers/Fukushima1980.pdf)通过将简单细胞和复杂细胞的概念交替堆叠在不同的层中。这个现代神经网络的前身利用前述的交替层,顺序地包含可修改的参数(或简单细胞),并通过池化层(或复杂细胞)使得网络对简单细胞的小变化具有不变性。尽管这一架构具有直观性,但它仍然不足以捕捉视觉信号中复杂的细节。
其中一个重要的突破发生在 1998 年,当时著名的 AI 研究人员颜麟恩和约书亚·本吉奥成功训练了一个卷积神经网络(CNN),利用基于梯度的权重更新,来执行文档识别任务。这个网络在识别邮政编码的数字方面表现得非常出色。类似的网络很快被美国邮政服务等组织采用,用于自动化处理繁琐的邮件分类工作(不是电子邮件哦)。尽管这些成果足够引起商业领域的兴趣,并且在狭窄的领域内产生了显著影响,但这些网络仍然无法处理更具挑战性和复杂的数据,比如面孔、汽车和其他真实世界的物体。然而,这些研究人员和许多其他人的集体努力,促成了现代较大且更深的卷积神经网络的出现,这些网络首次出现在 ImageNet 分类挑战赛中。如今,这些网络已经在计算机视觉领域占据主导地位,并广泛应用于当今最先进的人工视觉智能系统中。这些网络现在被用于诸如医学影像诊断、探测外太空天体、以及让计算机玩老式 Atari 游戏等任务,举几个例子。
设计卷积神经网络(CNN)
现在,基于对生物视觉的直觉,我们理解了神经元必须层次化组织,以便检测简单的模式,并利用这些模式逐步构建更复杂的模式,以对应真实世界中的物体。我们还知道,必须实现一个空间不变性机制,允许神经元处理在给定图像的不同空间位置上出现的相似输入。最后,我们意识到,为每个神经元实现一个感受野,对于实现神经元与现实世界中空间位置的拓扑映射非常有用,这样附近的神经元就可以表示视觉场中的相邻区域。
稠密层与卷积层
你会记得在上一章中,我们使用了一个前馈神经网络,它由全连接的稠密神经元层组成,用于执行手写数字识别任务。在构建我们的网络时,我们不得不将每个图像的 28 x 28 的输入像素展平为一个 784 个像素的向量。这样做导致我们失去了网络可以利用的任何空间相关信息,来帮助分类它所看到的数字。我们仅仅将每个图像展平成一个 784 维的向量,并期待它能够识别出数字。虽然这种方法足以在 MNIST 数据集中进行手写数字分类,并取得了相当不错的准确度,但在面对更复杂的数据时,这种方法很快就变得不实用了,尤其是那些涉及多种不同空间方向局部模式的数据。
我们理想的目标是保留空间信息,并重用神经元来检测不同空间区域中出现的相似模式。这将使我们的卷积网络更加高效。通过重用神经元来识别数据中具体模式,而不论它们的位置,CNN 在视觉任务中具有优势。另一方面,如果密集连接的网络遇到相同的模式出现在图像的另一个位置,它将被迫再次学习该模式。考虑到视觉数据中存在的自然空间层次结构,使用卷积层是一种理想的方式,能够检测微小的局部模式,并逐步构建出更复杂的模式。
以下截图展示了视觉数据的层次性:

我们提到的另一个密集层的问题是,它在捕捉数据中的局部模式方面存在弱点。密集层因能够捕捉涉及图像中所有像素的全局模式而广为人知。然而,正如 Hubel 和 Wiesel 的研究所示,我们希望将神经元的感受野限制为数据中存在的局部模式,并利用这些模式逐步形成更复杂的模式。这将使我们的网络能够处理非常不同类型的视觉数据,每种数据都有不同类型的局部模式。为了解决这些问题等,卷积神经网络的核心组件——卷积操作被开发出来。
卷积操作
词语convolvere来自拉丁语,意思是卷积或一起滚动。从数学角度来看,你可以将卷积定义为基于微积分的积分,表示当一个函数在另一个函数上滑动时,它们重叠的程度。换句话说,对两个函数(f和g)进行卷积操作将产生第三个函数,表示一个函数的形状如何被另一个函数所改变。术语卷积既指结果函数,也指计算过程,其根源在于信号处理的数学分支,如我们在此图中所见:

那么,我们如何利用这个操作来获得优势呢?
保留图像的空间结构
首先,我们将通过简单地将图像作为n维张量输入到神经网络中,来利用图像固有的空间结构。这意味着网络将接收每个像素的原始矩阵位置,而不是像之前那样将其缩减为单一向量中的一个位置。以 MNIST 示例为例,卷积神经网络将接收一个 28 x 28 x 1 的张量作为输入,表示每一张图像。这里,28 x 28 表示一个二维网格,像素在其上排列形成手写数字,而1表示图像的颜色通道(即每个像素的像素值数量)。如我们所知,彩色数据集的图像尺寸可能是 28 x 28 x 3,其中3表示构成单个像素颜色的红、绿、蓝三种值。由于我们在 MNIST 数据集中每个像素只处理单一的灰度值,颜色通道表示为 1。因此,理解图像作为三维张量进入网络并保留图像数据的空间结构是非常重要的:

接受域
现在,我们可以在网络架构中利用图像固有的附加空间信息。换句话说,我们现在已经准备好执行卷积操作。这意味着我们将使用一个较小的空间片段,并将其滑动到输入图像上,作为滤波器来检测局部模式。直观的理解是将输入数据空间的某些区域与隐藏层中对应的神经元连接起来。这样做可以让神经元每次卷积时仅观察图像的局部区域,从而限制其接受域。限制神经元的接受域有两个主要的好处。首先,我们认为图像中相邻的像素更有可能相互关联,因此在网络中限制神经元的接受域使得这些神经元更能够区分图像中像素之间的局部差异。此外,这种做法还允许我们大幅减少网络中可学习的参数(或权重)数量。通过这种方式,我们使用一个滤波器,本质上是一个权重矩阵,并在输入空间上从图像的左上角开始迭代应用它。每次卷积时,我们会将滤波器集中在输入空间中每个像素的上方,并向右移动步幅。完成图像顶部像素行的卷积后,我们会从图像的左侧再次执行相同的操作,这次是针对下面的行。通过这种方式,我们将滤波器滑过整个输入图像,在每次卷积时通过计算输入区域和所谓的滤波器的点积来提取局部特征。
这里的图示描绘了卷积操作的初始步骤。随着操作的进行,这个三维的蓝色矩形将会在整个图像的片段(用红色标示)上滑动,从而使得该操作得名为卷积。矩形本身被称为滤波器,或卷积核:

使用滤波器进行特征提取
每个滤波器本质上可以看作是一个神经元的排列,类似于我们在第三章《信号处理 - 使用神经网络进行数据分析》中遇到的那种。在这里,滤波器的神经元用随机权重初始化,并在训练过程中通过反向传播算法逐步更新。滤波器本身负责检测某种特定类型的模式,从线段到曲线以及更复杂的形状。当滤波器在输入图像的某一位置滑动时,滤波器的权重与该位置的像素值进行元素级相乘,从而生成一个单一的输出向量。然后,我们通过求和操作将该向量简化为一个标量值,正如我们之前在前馈神经网络中所做的那样。这些标量值在滤波器移动到每一个新位置时生成,遍历整个输入图像。它们被存储在一个被称为激活图(也称为特征图或响应图)的东西中,针对给定的滤波器。激活图本身的尺寸通常小于输入图像,并且它体现了输入图像的新表示,同时突出了其中的某些模式。
滤波器本身的权重可以被看作是卷积层的可学习参数,并在网络训练过程中不断更新,以捕捉与当前任务相关的模式:

卷积神经网络中的误差反向传播
当我们的网络训练以找到理想的滤波器权重时,我们希望某个滤波器的激活图能够捕捉到数据中可能存在的最有信息量的视觉模式。本质上,这些激活图是通过矩阵乘法生成的。这些激活图作为输入被送入后续层,因此信息会向前传播,直到模型的最终层,最终执行分类。在此时,我们的loss函数评估网络预测与实际输出之间的差异,并将预测误差反向传播以调整每一层的网络权重。
这基本上就是如何训练卷积神经网络(ConvNet)的高层次过程。卷积操作包括通过转置的滤波器矩阵(持有卷积滤波器的权重)与对应输入像素空间的点积运算,来从训练样本中提取可泛化的特征。然后,这些特征图(或激活图)首先被送入池化层以降低维度,随后再送入全连接层以确定哪种滤波器组合最能代表给定的输出类别。在反向传播过程中,随着模型权重的更新,在下一次前向传播时会生成新的激活图,这些激活图理想地编码了我们数据中更具代表性的特征。在这里,我们简要展示了 ConvNet 架构的视觉总结:

它遵循以下步骤:
- 
通过卷积学习输入图像中的特征 
- 
通过激活函数引入非线性(现实世界数据是非线性的) 
- 
通过池化减少维度并保持空间不变性 
使用多个滤波器
由于滤波器是特定模式的(即每个滤波器擅长捕捉某种类型的模式),我们需要不止一个滤波器来捕捉图像中可能存在的所有不同类型的模式。这意味着我们可能会在网络中的某一卷积层使用多个滤波器,从而允许我们为给定输入空间的区域提取多个不同的局部特征。这些局部特征存储在特定滤波器的激活图中,并可以传递到后续层以构建更复杂的模式。渐进的卷积层将使用不同的滤波器对前一层的输入激活图进行卷积,再次提取并将输入图转化为一个三维张量输出,代表每个使用的滤波器的激活图。新的激活图将再次是三维张量,并可以类似地传递到后续层。回想一下,当图像进入我们的网络时,它作为一个三维张量进入,具有(宽度、高度和深度)对应输入图像的维度(其中深度由像素通道表示)。而在我们的输出张量中,深度轴表示前一卷积层中使用的滤波器数量,每个滤波器产生自己的激活图。从本质上讲,这就是数据如何在卷积神经网络(CNN)中向前传播的过程,它以图像的形式进入,最终以三维激活图的形式输出,并在数据通过更深层时被各个滤波器逐步转化。转换的具体性质将在稍后更清晰地展示,当我们构建卷积神经网络时。我们将再多讨论一点理论,然后我们就准备好继续了。
卷积的步幅
以下图展示了你现在已经熟悉的卷积操作,在二维中(为了简化)。它展示了一个 4x4 的滤波器(红框)滑过一个更大的图像(14x14),每次移动两个像素:

滤波器在每次迭代时移动的像素数称为步幅(stride)。在每次步幅操作中,使用对应输入区域的像素矩阵和滤波器权重计算一个点积。这个点积作为标量值存储在该滤波器的激活图中(如下所示的 6x6 方形图)。因此,激活图表示了该层输入的简化表示,本质上是一个矩阵,由我们在对输入数据的各个片段进行卷积时得到的点积之和组成。从更高的层次来看,激活图表示了神经元对特定模式的激活,该模式由相应的滤波器检测到。较长的步幅会导致对应输入区域的采样更少,而较短的步幅则允许更多的像素被采样,从而产生更高分辨率的激活图。
什么是特征?
虽然使用不同类型的滤波器收集特征的整体机制可能很清楚,但你可能会想知道一个特征到底是什么样子,我们如何从滤波器中提取它们。
让我们考虑一个简单的例子来澄清我们的理解。假设你想从一堆灰度字母图像中检测字母 X。那么,卷积神经网络(CNN)是如何进行这项工作的呢?首先,让我们考虑一个 X 的图像。如下面所示,我们可以认为具有正值的像素形成了 X 的线条,而负值的像素则表示图像中的空白区域,如下图所示:

但接下来是位置变化的问题:如果 X 稍微旋转或以其他方式扭曲了怎么办?在现实世界中,字母 X 有许多不同的大小、形状等等。我们如何使用较小的滤波器来分解 X 的图像,从而捕捉到它的基本模式呢?这里有一种方法:

正如你可能已经注意到的那样,我们实际上可以将图像分解成更小的片段,其中每个片段(由绿色、橙色和紫色框表示)代表图像中的重复模式。在我们的例子中,我们能够使用两个对角线方向的滤波器和一个交叉滤波器,并将它们逐个滑过图像,以捕捉形成 X 的线段。本质上,你可以将每个滤波器视为某种模式检测器。当这些不同的滤波器对输入图像进行卷积时,我们得到的激活图开始像长的横线和交叉线一样,这些图像组合后帮助网络识别字母 X。
使用滤波器可视化特征提取
让我们考虑另一个例子,以巩固我们对滤波器如何检测模式的理解。考虑这张来自 MNIST 数据集的数字 7 的图像。我们使用这张 28 x 28 像素的图像来展示滤波器如何实际捕捉到不同的模式:

直观地,我们注意到这个数字 7 由两条水平线和一条倾斜的垂直线组成。我们基本上需要用可以捕捉到这些不同模式的值来初始化我们的滤波器。接下来,我们观察一些 3 x 3 的滤波器矩阵,这是卷积神经网络通常会为当前任务学习到的:

尽管不太直观,但这些滤波器实际上是复杂的边缘检测器。为了理解它们如何工作,我们可以将滤波器权重中的每个 0 视为灰色,而每个 1 则为白色,-1 则为黑色。当这些滤波器在输入图像上进行卷积时,会对滤波器值和下面的像素进行逐元素相乘。这个操作的结果是另一个矩阵,称为激活图,表示给定输入图像中由各自滤波器捕捉到的特定特征。现在,让我们看看在对数字 7 的输入图像执行卷积操作时,使用这四个滤波器分别捕捉到什么样的模式。以下插图展示了卷积层中每个滤波器的激活图,展示了它们在看到数字 7 的图像后的效果:

我们观察到,每个滤波器能够通过简单地计算输入图像中每个空间位置的像素值与滤波器权重的点积和,捕捉到图像中的特定模式。所捕捉到的模式可以通过前述的激活图中的白色区域表示。我们看到前两个滤波器分别巧妙地捕捉到了数字 7 图像中的上下水平线。我们还注意到后两个滤波器分别捕捉到了构成数字 7 主体的内外垂直线。虽然这些仍然是相当简单的模式检测示例,但卷积神经网络中的渐进层通常能够捕捉到更加丰富的结构,通过区分颜色和形状,这些本质上是数字模式,对我们的网络来说。
查看复杂的滤波器
以下图像展示了在 ConvNet 第二层中,按网格展示的每个网格对应的九个激活图,分别与特定输入相关联。在左侧,你可以将小网格看作是个别神经元的激活,针对给定输入。右侧的彩色网格则与这些神经元显示的输入相关。我们在这里可视化的是那些能够最大化这些神经元激活的输入。我们注意到,已经可以看到一些非常明显的圆形检测神经元(网格 2, 2),这些神经元会对例如灯罩顶部和动物眼睛等输入激活:

同样,我们注意到一些类似正方形的模式检测器(网格 4, 4),似乎会对包含门窗框架的图像激活。当我们逐步可视化 CNN 更深层次的激活图时,我们会看到更加复杂的几何图案被捕捉到,代表了狗的面部(网格 1, 1)、鸟的腿(网格 2, 4)等等:

总结卷积操作
我们在这里所做的就是将一组权重(即一个滤波器)应用于局部输入空间进行特征提取。我们是通过迭代的方式进行的,在固定的步长(称为步幅)中将滤波器移动到输入空间。此外,使用不同的滤波器使我们能够从给定输入中捕获不同的模式。最后,由于滤波器会对整个图像进行卷积操作,我们可以对给定的滤波器实现空间共享参数。这使得我们能够使用相同的滤波器在图像的不同位置检测到相似的模式,这与之前讨论的空间不变性概念相关。然而,卷积层输出的这些激活图本质上是抽象的高维表示。在进行分类之前,我们需要实现一种机制,将这些表示减少到更易处理的维度。这引出了池化层的概念。
理解池化层
使用卷积层时的最后一个考虑因素涉及堆叠简单单元以检测局部模式和堆叠复杂单元以下采样表示的概念,正如我们在猫脑实验和 neocognitron 中看到的那样。我们看到的卷积滤波器表现得像简单单元,通过集中关注输入的特定位置并训练神经元在接受到来自输入图像局部区域的某些刺激时激活。而复杂单元则需要对刺激的位置不那么具体。这时,池化层就发挥了作用。池化技术旨在将卷积神经网络层的输出减少到更易管理的表示。池化层定期添加到卷积层之间,按空间下采样卷积层的输出。这样做的效果就是逐渐减少卷积层输出的大小,从而得到更高效的表示,具体如图所示:

如您所见,深度体积被保留,因为我们使用 2 大小的滤波器和步幅为 2 对大小为 224 x 224 x 64 的输入体积进行了池化。因此,输出体积为 112 x 112 x 64。
池化操作类型
这同样减少了我们网络中所需的可学习参数数量,以执行相同的任务,并防止网络过拟合。最后,对于由给定层生成的每个激活图(即输入张量的每个深度切片),都会执行池化,并使用其自身的滤波器在空间上调整输入大小。常见的做法是在每个深度切片上执行池化层,使用 2 x 2 的滤波器和步幅为 2。执行这种下采样有多种方式。最常见的方法是通过最大池化操作来实现,这意味着我们保留池化滤波器下输入区域中像素值最高的像素。下图演示了在输入张量的给定切片上执行最大池化操作:

池化层按空间独立地对输入体积的每个深度切片进行下采样。最常见的下采样操作是最大池化,这里显示的是步幅为 2 的最大池化。也就是说,每个最大值是从四个数字中取的(即 2 x 2 的小方格)。
你也可以通过取 2 x 2 方格的平均值来进行降采样,如图所示。这个操作被称为平均池化。正如我们在后续章节中将看到的,Keras 提供了许多池化层,每个池化层执行不同类型的降采样操作,作用于上一层的输出。选择哪种池化层将主要取决于你的具体使用案例。在图像分类任务中,最常用的是二维或三维的最大池化层。池化层的维度指的是它接受的输入类型。例如,处理灰度图像时会使用二维池化层,而在处理彩色图像时,则会使用三维池化层。你可以参考维护良好的文档,自己深入学习。
在 Keras 中实现 CNN
在对卷积神经网络(CNN)的关键组件有了较为深入的理解后,我们现在可以开始实际实现一个 CNN。这样,我们可以熟悉构建卷积网络时需要考虑的关键架构因素,并概览实现细节,了解这些细节是如何使得网络表现如此优秀的。很快,我们将会在 Keras 中实现卷积层,并探索如池化层等降采样技术,看看我们如何通过卷积、池化和全连接层的组合来处理各种图像分类任务。
在这个例子中,我们将采用一个简单的用例。假设我们希望我们的 CNN 能够检测人类的情感,例如微笑或皱眉。这是一个简单的二分类任务。我们该如何进行呢?首先,我们需要一个标注了微笑和皱眉的人的数据集。虽然有很多方式可以实现这一目标,但我们选择了Happy House 数据集。该数据集包含约 750 张人类的图像,每张图片中的人物要么微笑,要么皱眉,存储在 h5py 文件中。要跟着学习,你只需要下载存放在 Kaggle 网站上的数据集,你可以通过这个链接免费访问:www.kaggle.com/iarunava/happy-house-dataset
检查我们的数据
让我们首先加载并检查一下数据集,了解一下我们正在处理的内容。我们编写了一个简单的函数,读取我们的 h5py 文件,提取训练数据和测试数据,并将其放入标准的 NumPy 数组中,代码如下:
import numpy as np
import h5py
import matplotlib.pyplot as plt
# Function to load data
def load_dataset():
# use h5py module and specify file path and mode (read) all_train_data=h5py.File('C:/Users/npurk/Desktop/Chapter_3_CNN/train_happy.h5', "r")
all_test_data=h5py.File('C:/Users/npurk/Desktop/Chapter_3_CNN/test_happy.h5', "r")
# Collect all train and test data from file as numpy arrays
x_train = np.array(all_train_data["train_set_x"][:]) 
y_train = np.array(all_train_data["train_set_y"][:]) 
x_test = np.array(all_test_data["test_set_x"][:])
y_test = np.array(all_test_data["test_set_y"][:]) 
# Reshape data
y_train = y_train.reshape((1, y_train.shape[0]))
y_test = y_test.reshape((1, y_test.shape[0]))    
return x_train, y_train, x_test, y_test
# Load the data 
X_train, Y_train, X_test, Y_test = load_dataset()
验证数据形状
接下来,我们将打印出训练数据和测试数据的形状。在以下代码块中,我们可以看到我们处理的是 64 x 64 像素的彩色图像。我们的训练集中有 600 张这样的图像,测试集则有 150 张。我们还可以查看其中一张图像的实际样子,正如我们在之前的例子中使用 Matplotlib 所做的那样:
print(X_train.shape)
print(X_test.shape)
print(Y_train.shape)
print(Y_test.shape)
(600, 64, 64, 3)
(150, 64, 64, 3)
(1, 600)
(1, 150)
# Plot out a single image plt.imshow(X_train[0]) # Print label for image (smiling = 1, frowning = 0) print ("y = " + str(np.squeeze(Y_train[:, 0])))
y = 0
——瞧!女士们,先生们,我们看到了一个皱眉的脸:

规范化我们的数据
现在,我们将通过将像素值重新缩放到 0 到 1 之间来准备我们的图像。我们还转置了标签矩阵,因为我们希望它们的方向是(600,1),而不是(1,600),这与我们之前在训练标签中提到的标签一致。最后,我们打印出训练集和测试集的特征和标签的形状:
# Normalize pixels using max channel value, 255 (Rescale data)
X_train = X_train/255.
X_test = X_test/255.
# Transpose labels
Y_train = Y_train.T
Y_test = Y_test.T
# Print stats
print ("Number of training examples : " + str(X_train.shape[0]))
print ("Number of test examples : " + str(X_test.shape[0]))
print ("X_train shape: " + str(X_train.shape))
print ("Y_train shape: " + str(Y_train.shape))
print ("X_test shape: " + str(X_test.shape))
print ("Y_test shape: " + str(Y_test.shape))
-----------------------------------------------------------------------
Output:
Number of training examples : 600
Number of test examples : 150
X_train shape: (600, 64, 64, 3)
Y_train shape: (600, 1)
X_test shape: (150, 64, 64, 3)
Y_test shape: (150, 1)
然后,我们将 NumPy 数组转换为浮点数值,这是我们网络偏好的格式:
#convert to float 32 ndarrays
from keras.utils import to_categorical
X_train = X_train.astype('float32') X_test = X_test.astype('float32') Y_train = Y_train.astype('float32') Y_test = Y_test.astype('float32')
导入一些必要的库
最后,我们导入将在情感分类任务中使用的新层。在前一段代码的底部部分,我们导入了一个二维卷积层。卷积层的维度是特定于你想执行的任务的属性。因为我们处理的是图像,所以二维卷积层是最佳选择。如果我们处理的是时间序列传感器数据(例如生物医学数据——如 EEG 或股市等财务数据),那么一维卷积层将是更合适的选择。类似地,如果输入数据是视频,那么我们会使用三维卷积层:
import keras
from keras.models import Sequential
from keras.layers import Flatten
from keras.layers import Dense
from keras.layers import Activation, Dropout
from keras.optimizers import Adam
from keras.layers import Conv2D
from keras.layers import MaxPooling2D
from keras.layers.normalization import BatchNormalization 
类似地,我们还导入了一个二维最大池化层,以及一个按批次进行归一化的层。批量归一化使我们能够处理网络传播过程中层输出的变化值。
“内部协变量偏移”问题确实是一个广为人知的现象。
CNNs 以及其他 ANN 架构,指的是输入统计量的变化
在经过几次训练迭代后,分布会发生变化,导致我们模型的收敛速度减慢,无法达到理想的权重。这一问题可以通过简单地在小批量中对数据进行归一化,使用均值和方差作为参考来避免。虽然我们鼓励你进一步研究“内部协变量偏移”问题以及批量归一化背后的数学原理,但现在了解它有助于我们更快地训练网络,并允许更高的学习率,同时使网络权重更容易初始化,这就足够了。
卷积层
在 Keras 中,卷积层涉及两个主要的架构考虑因素。第一个与在给定层中使用的滤波器数量有关,第二个与滤波器本身的大小有关。那么,让我们看看如何通过初始化一个空的顺序模型并向其中添加第一个卷积层来实现这一点:
model=sequential()
#First Convolutional layer 
model.add(Conv2D(16,(5,5), padding = 'same', activation = 'relu', input_shape = (64,64,3)))
model.add(BatchNormalization())
定义滤波器的数量和大小
正如我们之前所见,我们通过赋予层 16 个过滤器,每个过滤器的高度和宽度为 5 x 5,来定义这一层。实际上,我们过滤器的正确维度应该是 5 x 5 x 3。然而,所有过滤器的深度覆盖了给定输入张量的整个深度,因此无需明确指定。由于这是第一层,它接收的是我们训练图像的张量表示,因此我们过滤器的深度为 3,表示每个像素的红色、绿色和蓝色值。
填充输入张量
从直观的角度思考卷积操作,显而易见,当我们将过滤器滑过输入张量时,最终发生的情况是,过滤器通过边缘和边界的频率低于它通过输入张量其他部分的频率。这仅仅是因为每个不位于输入边缘的像素,随着过滤器在图像中滑动,可能会被过滤器多次重新采样。这使得输出表示在输入的边界和边缘部分具有不均匀的采样,称为边界效应。
我们可以通过简单地用零填充输入张量来避免这个问题,如下所示:

通过这种方式,通常位于输入张量边缘的像素将被后续处理,允许在输入的所有像素上执行均匀采样。我们在第一个卷积层中指定希望保持输入的长度和宽度,以确保输出张量在其长度和宽度上具有相同的空间维度。这是通过将该层的填充参数定义为same来实现的。卷积层输出的深度,如前所述,由我们选择使用的过滤器数量表示。在我们的案例中,这将是 16,表示 16 个激活图是在每个过滤器卷积输入空间时产生的。最后,我们定义输入形状为任何单一输入图像的维度。对于我们来说,这对应于数据集中我们拥有的 64 x 64 x 3 彩色像素,具体如下所示:
model = Sequential()
#First Convolutional layer 
model.add(Conv2D(16,(5,5), padding = 'same', activation = 'relu', input_shape = (64,64,3)))
model.add(BatchNormalization())
最大池化层
我们第一个卷积层的激活图经过归一化,并输入到下面的最大池化层。与卷积操作类似,池化操作是逐个输入区域应用的。对于最大池化操作,我们仅在像素网格中取最大的值,该值代表与每个特征最相关的像素,并将这些最大值组合形成输入图像的低维表示。通过这种方式,我们保留了更重要的值,并丢弃了给定激活图网格中的其余值。
这种下采样操作自然会导致一定程度的信息丢失,但大大减少了网络所需的存储空间,从而显著提高了效率:
#First Pooling layer 
model.add(MaxPooling2D(pool_size = (2,2)))
model.add(Dropout(0.1))
利用全连接层进行分类
然后,我们简单地添加一些卷积层、批量归一化层和 dropout 层,逐步构建我们的网络,直到达到最后的几层。就像在 MNIST 示例中一样,我们将利用密集连接层来实现我们网络中的分类机制。在此之前,我们必须将来自上一层的输入(16 x 16 x 32)展平成一个维度为(8,192)的 1D 向量。我们这么做是因为基于密集层的分类器倾向于接收 1D 向量,而不像我们上一层的输出那样是多维的。接下来,我们添加了两层密集连接层,第一层有 128 个神经元(这是一个任意选择),第二层只有一个神经元,因为我们处理的是一个二分类问题。如果一切按计划进行,这一个神经元将由前几层的神经元支撑,并学会在看到某个特定的输出类别时激活(例如,笑脸),而在遇到其他类别的图像时(例如,愁眉苦脸的脸)则不会激活。请注意,我们再次在最后一层使用 sigmoid 激活函数来计算每个输入图像的类别概率:
#Second Convolutional layer 
model.add(Conv2D(32, (5,5), padding = 'same', activation = 'relu'))
model.add(BatchNormalization())
#Second Pooling layer 
model.add(MaxPooling2D(pool_size = (2,2)))
#Dropout layer
model.add(Dropout(0.1))
#Flattening layer
model.add(Flatten())
#First densely connected layer
model.add(Dense(128, activation = 'relu'))
#Final output layer
model.add(Dense(1, activation = 'sigmoid'))
总结我们的模型
让我们可视化我们的模型,以便更好地理解我们刚刚构建的内容。你会注意到,激活图的数量(由后续层输出的深度表示)在整个网络中逐渐增加。另一方面,激活图的长度和宽度则趋向于减小,从(64 x 64)到(16 x 16),直到达到 dropout 层。这两种模式在大多数现代卷积神经网络(CNN)中是常见的,甚至可以说是标准的。
输入和输出维度之间的变化可能与我们之前讨论的边界效应的处理方式有关,或者与在卷积层中为过滤器实现的步幅有关。较小的步幅会导致更高的维度,而较大的步幅则会导致更低的维度。这与计算点积的位置数量有关,同时将结果存储在激活图中。较大的过滤器步幅(或步长)会更早到达图像的末尾,在同一输入空间中计算较少的点积值。卷积层的步幅可以通过定义步幅参数来设置,参数可以是一个整数或整数的元组/列表,表示给定层的步幅长度:
model.summary()
以下是总结:

如我们之前所述,您会注意到卷积层和最大池化层都会生成一个三维张量,其维度对应于输出的高度、宽度和深度。层输出的深度本质上是每个初始化的滤波器的激活图。我们实现了第一个卷积层,使用了 16 个滤波器,第二层使用了 32 个滤波器,因此每一层将生成相应数量的激活图,正如前面所见。
编译模型
目前,我们已经涵盖了设计卷积神经网络(ConvNet)过程中所有关键的架构决策,现在可以编译我们一直在构建的网络了。我们选择了adam优化器和binary_crossentropy损失函数,就像在上一章的二元情感分析任务中做的那样。类似地,我们还导入了EarlyStopping回调函数,用来监控验证集上的损失,以便了解模型在每个训练周期(epoch)中在未见数据上的表现:

检查模型的准确度
正如我们之前所见,在训练的最后一个周期,我们达到了 88%的测试准确度。让我们看看这到底意味着什么,通过解释分类器的精确度和召回率得分:

正如我们之前注意到的,测试集中正确预测的正类观察值与正类观察值总数的比率(即精确度)相当高,达到了 0.98。召回率略低,表示正确预测的结果与应返回的结果数之间的比率。最后,F-值是精确度和召回率的调和平均数。
为了补充我们的理解,我们绘制了分类器在测试集上的混淆矩阵,如下所示。这本质上是一个错误矩阵,它让我们可视化模型的表现。x轴表示分类器的预测类别,而y轴表示测试样本的实际类别。正如我们所看到的,分类器错误地检测出约 17 张图片,认为这些人是在微笑,而实际上他们是在皱眉(也称为假阳性)。
另一方面,我们的分类器在将微笑的面孔错误分类为皱眉的面孔时只犯了一个错误(也称为假阴性)。考虑假阳性和假阴性有助于我们评估分类器在实际场景中的实用性,并进行部署此类系统的成本效益分析。在我们的分类任务中,这种分析可能不太必要;然而,在其他场景(例如皮肤癌检测)中,这种分析和评估就显得尤为重要。

每当你对模型的准确度感到满意时,你可以保存一个模型,如下所示。请注意,这不仅是最佳实践,因为它为你提供了关于之前尝试、采取的步骤和取得的结果的良好文档记录,而且如果你想进一步探讨模型,通过查看它的中间层,看看它实际学到了什么,这也非常有用,正如我们马上要做的那样:

检测微笑的问题
我们必须指出的是,外部效度的问题(即模型的可推广性)在像微笑检测器这样的数据集上仍然存在。鉴于数据收集的方式比较有限,期望我们的卷积神经网络(CNN)能在其他数据上很好地推广是没有道理的。首先,网络是用低分辨率的输入图像训练的。此外,它每次只见到的是同一个地方的一位笑脸或皱眉的人。给这个网络输入一张比如说国际足联管理委员会的照片,无论照片中笑容多么明显,它都不会检测出微笑。我们需要重新调整方法。一种方法是通过对输入图像应用与训练数据相同的转换方式,例如按面部分割和调整大小。更好的方法是收集更多样化的数据,并通过旋转和扭曲训练样本来增强训练集,正如我们在后面的章节中将看到的那样。这里的关键是,在数据集中包括不同姿势、不同方向、不同光照条件下微笑的人,以便真正捕捉到微笑的所有有用视觉表现。如果收集更多数据成本太高,生成合成图像(使用 Keras 图像生成器)也是一个可行的选择。数据的质量可以大幅提高网络的性能。现在,我们将探索一些技术,了解 CNN 的内部工作原理。
黑箱内部
训练一个微笑检测器,仅仅使用一个狭窄的相似图像数据集,这是一回事。你不仅可以直接验证预测结果,而且每次错误预测也不会让你付出巨大的代价。现在,如果你使用类似的系统来监控精神病患者的行为反应(作为一个假设的例子),你可能会希望通过确保模型真正理解“微笑”的含义,而不是在训练集中识别一些无关的模式来确保高准确度。在医疗或能源等高风险行业中,任何误解都可能带来灾难性后果,从失去生命到资源浪费不等。因此,我们希望能够确保我们部署的模型确实捕捉到了数据中真正具有预测性的趋势,而不是记住了某些随机的特征,这些特征在训练集之外无法进行有效预测。这种情况在神经网络使用的历史中屡屡发生。在接下来的部分,我们从神经网络的民间故事中挑选了一些例子,以说明这些困境。
神经网络失败
曾几何时,美国陆军想到了利用神经网络来自动检测伪装的敌方坦克。研究人员被委托设计并训练一个神经网络,用于从敌方位置的航拍照片中检测伪装的坦克图像。研究人员仅仅是微调了模型的权重,使其能够为每个训练样本反映正确的输出标签,然后在他们隔离的测试样本上测试该模型。幸运的是(或者说看起来是这样),他们的网络能够充分分类所有的测试图像,这让研究人员确信他们的任务已经完成。然而,很快,他们收到愤怒的五角大楼官员的反馈,声称他们交付的网络在分类伪装坦克方面的表现不过是随机的。困惑的研究人员检查了他们的训练数据,并将其与五角大楼用于测试网络的数据进行了对比。他们发现,训练网络时使用的伪装坦克照片都是在阴天拍摄的,而负样本(没有伪装坦克的照片)则都是在晴天拍摄的。结果是,他们的网络只学会了区分天气(通过像素的亮度),而没有真正完成原本的分类任务:

很常见的是,神经网络在经历大量训练迭代后,能够在训练集上达到超越人类的准确度。例如,一位研究人员在尝试训练一个网络来分类不同类型的陆地和海洋哺乳动物时,观察到了这种现象。在取得良好表现后,研究人员尝试进一步深入研究,以解码人类可能忽视的任何分类规则。结果发现,他们的复杂网络所学到的一个重要部分是图像中蓝色像素的存在或缺失,这在陆地哺乳动物的图片中自然不会经常出现。
在我们短小的神经网络失败故事中,最后一个案例是自动驾驶汽车自行开车驶下桥的事件。困惑的自动化工程师试图探查已训练的网络,想弄清楚出了什么问题。令他们惊讶的是,他们发现了一件非常奇怪的事。网络并没有检测到街道上的道路来进行导航,而是出于某种原因,依赖于分隔道路与人行道的连续绿色草地来确定其方向。当遇到桥梁时,这片绿色草地消失了,导致网络表现出似乎不可预测的行为。
可视化卷积神经网络的学习
这些故事激发了我们确保模型不会过拟合随机噪声,而是能够捕捉到具有代表性的预测特征的需求。我们知道,不小心的数据处理、任务本身的固有特性或建模中的内在随机性都可能引入预测不准确性。神经网络中广泛传播的叙事通常会用到黑箱等术语来描述其学习机制。虽然理解个别神经元所学内容对各种神经网络来说可能并不直观,但这对卷积神经网络(CNN)来说并非如此。令人有趣的是,卷积神经网络允许我们直观地可视化其学习到的特征。正如我们之前看到的,我们可以可视化给定输入图像的神经激活。但我们可以做得更多。事实上,近年来已经开发了多种方法来探查 CNN,更好地理解它所学到的东西。虽然我们没有时间涵盖所有这些方法,但我们将能够介绍一些最具实用性的。
可视化中间层的神经激活
首先,我们可以通过查看它们的激活图来可视化 CNN 中逐层如何转换它们接收到的输入。回忆一下,这些激活图只是网络在处理数据时传播通过其架构的输入的简化表示。可视化中间层(卷积层或池化层)能让我们了解网络在每个阶段的神经元激活情况,因为输入会被各种学习到的滤波器逐步拆解。由于每个二维激活图存储了由给定滤波器提取的特征,因此我们必须将这些图像作为二维图像来进行可视化,其中每张图像对应于一个学习到的特征。这个方法通常被称为可视化中间激活。
为了能够提取我们网络学习到的特征,我们需要对模型做一些小的架构调整。这就引出了 Keras 的功能性 API。回忆一下,之前我们使用 Keras 的顺序 API 定义了一个顺序模型,基本上让我们可以顺序堆叠神经元层来执行分类任务。这些模型接收图像或单词表示的输入张量,并输出为每个输入分配的类别概率。现在,我们将使用功能性 API,它允许构建多输出模型、有向无环图,甚至是共享层的模型。我们将使用这个 API 来深入探索我们的卷积网络。
对输入图像的预测
首先,我们准备一张图像(尽管你可以使用几张图像)作为输入,让它通过我们的多输出模型,以便我们可以看到该图像在通过新模型时产生的中间层激活。为了这个目的,我们从测试集中随机选取一张图像,并将其准备为一个四维张量(批量大小为 1,因为我们只输入一张图像):

接下来,我们初始化一个多输出模型,对输入图像进行预测。这样做的目的是捕获每一层网络的中间激活,以便我们能够直观地绘制出不同滤波器生成的激活图。这有助于我们理解我们的模型实际学习到了哪些特征。
介绍 Keras 的功能性 API
我们到底如何做到这一点呢?我们从导入 Model 类开始,该类来自功能性 API。这让我们可以定义一个新的模型。我们新模型的关键区别在于,它能够返回多个输出,涉及到中间层的输出。我们通过使用一个经过训练的 CNN(比如我们的笑脸检测器)中的层输出,将其输入到这个新的多输出模型中,从而实现这一点。实际上,我们的多输出模型会接受一个输入图像,并返回我们之前训练的笑脸检测器模型中每个八个层的滤波器激活值。
你还可以通过在model.layers上使用列表切片符号,限制可视化的层数,如下所示:

上述代码的最后一行定义了activations变量,通过让我们的多输出模型对输入图像进行推理操作。此操作返回了与 CNN 每一层对应的多个输出,这些输出现在存储为一组 NumPy 数组:

如你所见,activations变量存储了一个包含8个 NumPy n维数组的列表。每一个8个数组都表示我们微笑检测器 CNN 中特定层的张量输出。每个层的输出代表了多个过滤器的激活。因此,我们在每层上观察到多个激活图。这些激活图本质上是二维张量,编码了输入图像的不同特征。
验证每层的通道数
我们看到每层都有一个深度,表示激活图的数量。这些也被称为通道,每个通道包含一个激活图,其高度和宽度为(n x n)。例如,我们的第一层有 16 个不同的激活图,大小为 64 x 64。类似地,第四层有 16 个大小为 32 x 32 的激活图。第八层有 32 个激活图,每个大小为 16 x 16。这些激活图是由各自层中的特定过滤器生成的,并被传递到后续层,以编码更高级的特征。这与我们微笑检测器模型的架构相符,我们总是可以验证,如下所示:

可视化激活图
现在是有趣的部分!我们将绘制给定层中不同过滤器的激活图。我们从第一层开始。我们可以绘制每个 16 个激活图,如下所示:
虽然我们不会展示所有 16 个激活图,但以下是我们发现的一些有趣的激活图:

正如我们可以清楚看到的,每个滤波器从输入图像中捕捉到了不同的特征,涉及到面部的水平和垂直边缘,以及图像的背景。当你可视化更深层的激活图时,你会注意到,激活变得越来越抽象,且不再容易被人眼解读。这些激活被认为是在编码与面部位置以及眼睛和耳朵相关的高级概念。你还会注意到,随着你深入探查网络,越来越多的激活图保持空白。这意味着在更深的层中激活的滤波器较少,因为输入图像没有包含与滤波器编码的模式相对应的特征。这是很常见的现象,因为我们可以预期随着网络层次的加深,激活模式会越来越与图像的类别相关。
理解显著性
我们之前看到,ConvNet 的中间层似乎能够编码一些非常明显的面部边缘检测器。然而,更难区分的是,我们的网络是否真正理解微笑是什么。你会注意到,在我们的微笑面孔数据集中,所有图片都在相同的背景下拍摄,且大致从相同的角度拍摄。此外,你还会注意到,数据集中的人们通常在抬起头并清晰地微笑时才会笑,而在皱眉时大多是低头的。这为我们的网络提供了很多过拟合无关模式的机会。那么,我们怎么知道网络理解微笑更多是与一个人嘴唇的动作有关,而不是与面部倾斜的角度有关呢?正如我们在神经网络失败案例中看到的那样,网络经常会捕捉到无关的模式。在我们实验的这一部分,我们将可视化给定网络输入的显著性图。
显著性图的概念最早由牛津大学视觉几何小组在一篇论文中提出,其背后的思想是通过计算所需输出类别相对于输入图像变化的梯度。换句话说,我们试图确定图像中像素值的微小变化如何影响网络对给定图像的理解:

直观地说,假设我们已经用不同动物的图像训练了一个卷积神经网络:长颈鹿、豹子、狗、猫等等。然后,为了测试它学到了什么,我们给它展示一张豹子的图片,并问它,你认为这张图片中的豹子在哪里? 从技术上讲,我们是在对输入图像中的像素进行排序,基于每个像素对网络输出的类别概率分数的影响。接着,我们可以简单地可视化对图像分类产生最大影响的像素,这些像素就是那些正向改变会导致增加网络对该图像属于某一类别的概率得分或置信度的像素。
使用 ResNet50 可视化显著性图
为了保持趣味性,我们将结束我们的微笑检测实验,并实际使用一个预训练的、非常深的 CNN 来展示我们的豹子示例。我们还使用 Keras vis,它是一个非常好的高级工具包,用于可视化和调试基于 Keras 构建的 CNN。你可以使用pip包管理器安装这个工具包:

在这里,我们导入了带有预训练权重的 ResNet50 CNN 架构,用于 ImageNet 数据集。我们鼓励你也探索 Keras 中存储的其他模型,可以通过keras.applications访问。我们还将网络最后一层的 Softmax 激活函数替换为线性激活函数,使用utils.apply_modifications,该函数重建了网络图,以帮助我们更好地可视化显著性图。
ResNet50 首次出现在 ILSVRC 竞赛中,并在 2015 年获得了第一名。它在避免与非常深的神经网络相关的精度下降问题方面表现得非常好。该模型是基于 ImageNet 数据集中的大约千个输出类别进行训练的。它被认为是一种高性能的、最先进的 CNN 架构,由其创建者免费提供。虽然它使用了一些有趣的机制,被称为残差模块,但我们将在后面的章节中才进一步讨论其架构。现在,让我们来看一下如何使用该模型的预训练权重来可视化一些豹子图片的显著性图。
从本地目录加载图片
如果你想跟着一起做,只需在 Google 上搜索一些漂亮的豹子图片,并将它们存储在本地目录中。你可以使用 Keras vis模块中的图像加载器来调整图片的大小,以符合 ResNet50 模型所接受的目标尺寸(即 224 x 224 像素的图片):

由于我们希望让实验对网络来说相当具有挑战性,因此特意选择了伪装的豹子图片,以观察该网络在检测大自然中最复杂的伪装尝试时表现如何——这些伪装试图将这些掠食性动物从猎物(比如我们自己)的视线中隐藏起来:

使用 Keras 的可视化模块
即使是我们整个视觉皮层中实施的生物神经网络,乍一看似乎在每张图像中找到豹子有些困难。让我们看看其人工对应物在这项任务中表现如何。在以下代码段中,我们从keras-vis模块导入显著性可视化器对象,以及一个工具,让我们能够按名称搜索层。请注意,这个模块不随标准的 Keras 安装一起提供。但是,可以通过 Python 的pip包管理器轻松安装它。您甚至可以通过 Jupyter 环境执行安装:
! pip install keras-vis
搜索通过层
接下来,我们执行一个实用搜索来定义模型中的最后一个密集连接层。我们需要这个层,因为它输出每个输出类别的类概率分数,我们需要能够可视化输入图像上的显著性。层的名称可以在模型的摘要中找到(model.summary())。我们将向visualize_salency()函数传递四个特定的参数:

这将返回我们的输出相对于输入的梯度,直观地告诉我们哪些像素对我们模型的预测有最大影响。梯度变量存储了六个 224 x 224 的图像(对应于 ResNet50 架构的输入尺寸),每个图像代表六个输入豹子图像。正如我们注意到的,这些图像是由visualize_salency函数生成的,该函数接受四个参数作为输入:
- 
用于执行预测的种子输入图像( seed_input)
- 
一个 Keras CNN 模型( model)
- 
模型输出层的标识符( layer_idx)
- 
我们想要可视化的输出类的索引( filter_indices)
此处使用的索引参考(288)是指 ImageNet 数据集上标签leopard的索引。回想一下,我们之前导入了当前初始化模型的预训练层权重。这些权重是通过在 ImageNet 数据集上训练 ResNet50 模型获得的。如果您对不同的输出类别感兴趣,您可以在这里找到它们以及它们各自的索引:gist.github.com/yrevar/942d3a0ac09ec9e5eb3a。
可视化前三张图像的显著性图,我们实际上可以看到网络正在关注我们在图像中找到豹子的位置。太棒了!这确实是我们想要看到的,因为它表明我们的网络确实(大致)理解豹子在我们的图像中的位置,尽管我们尽最大努力展示了伪装豹子的嘈杂图像:

练习
- 探查网络中的所有层。您注意到了什么?
梯度加权类激活映射
另一个巧妙的基于梯度的方法是梯度加权类别激活图(Grad-CAM)。如果你的输入图像包含属于多个输出类别的实体,并且你想要可视化网络与特定输出类别最相关的输入图像区域,这个方法特别有用。该技术利用流入 CNN 最终卷积层的类别特定梯度信息,生成图像中重要区域的粗略定位图。换句话说,我们将输入图像喂给网络,并通过按输出类别相对于通道的梯度加权来获取卷积层的输出激活图(即激活图)。这使我们能够更好地利用与网络最关注的空间信息,这些信息在网络的最后一个卷积层中表现出来。我们可以将这些梯度加权激活图叠加到输入图像上,从而了解网络将输入图像的哪些部分与特定输出类别(即豹子)高度关联。
使用 Keras-vis 可视化类别激活
为此,我们使用visualize_cam函数,它本质上生成一个 Grad-CAM 图,最大化指定输入的层激活,以便生成一个特定输出类别的激活图。
visualize_cam函数接受与之前相同的四个参数,并增加了一个额外的参数。我们传递给它与 Keras 模型相对应的参数,一个种子输入图像,一个滤波器索引对应我们的输出类别(豹子的 ImageNet 索引),以及两个模型层。其中一个层保持为完全连接的密集输出层,另一个层指的是 ResNet50 模型中的最终卷积层。该方法本质上利用这两个参考点生成梯度加权类别激活图,如下所示:

如我们所见,网络正确识别了两张图片中的豹子。此外,我们注意到,网络依赖于豹子身上的黑色斑点图案来识别其种类。可以推测,网络使用这个图案来识别豹子,因为它是这个物种的显著特征。我们可以通过热力图看到网络的注意力,它主要集中在豹子身体上清晰的斑点区域,而不一定是豹子的脸部,就像我们自己在遇到豹子时可能会做的那样。或许数百万年的生物进化已将我们大脑枕状回区域的层权重调整为特别能识别面孔,因为面孔识别对我们的生存至关重要:
- Grad-CAM 论文:arxiv.org/pdf/1610.02391.pdf
使用预训练模型进行预测
顺便提一下,您实际上可以使用预训练的 ImageNet 权重在给定图像上运行推理,就像我们在这里初始化的 ResNet50 架构一样。您可以通过首先将所需图像预处理为适当的四维张量格式,来在其上运行推理。对于您可能拥有的任何图像数据集,只要它们调整到适当的格式,当然也可以这么做:

上述代码通过沿着 0 轴扩展图像的维度,将我们的豹子图像重新塑造成一个 4D 张量,然后将该张量输入到初始化的 ResNet50 模型中,以获得类别概率预测。接着,我们对预测类别进行解码,生成可读的输出。为了好玩,我们还定义了labels变量,包含了网络为该图像预测的所有可能标签,按概率从高到低排序。让我们看看网络还为输入图像归类了哪些标签:

可视化每个输出类别的最大激活
在最后的方法中,我们简单地可视化了与特定输出类别相关的总体激活,而没有显式地将输入图像传递给模型。这个方法既直观又美观。为了进行最后的实验,我们导入了另一个预训练的模型,VGG16 网络。这个网络是一个深度架构,基于 2014 年 ImageNet 分类挑战赛的冠军模型。与我们的上一个例子类似,我们将最后一层的 Softmax 激活替换为线性激活:

然后,我们从keras-vis的可视化模块中导入激活可视化对象。通过将visualize_activation函数传递给我们的模型、输出层以及与豹子类别对应的索引,我们绘制了豹子类别的总体激活。如我们所见,网络实际上捕捉到了图像中不同方向和位置的豹子的整体形状。有些看起来被放大了,其他的则不太明显,但猫耳朵和斑点黑色图案在整个图像中都很容易辨认——是不是很酷?让我们看一下下面的截图:

收敛模型
接下来,您可以让模型在该输出类别上收敛,以可视化模型在多次收敛迭代后认为的豹子(或其他输出类别)的特征。您可以通过max_iter参数定义模型收敛的次数,如下所示:

使用多个过滤器索引进行幻觉生成
你还可以通过传递不同的filter_indices参数来尝试,选择与 ImageNet 数据集中的不同输出类别对应的索引。你还可以传递一个由两个整数构成的列表,分别对应两个不同的输出类别。这基本上让你的神经网络想象两种不同输出类别的视觉组合,同时可视化与这两个输出类别相关的激活情况。有时这些组合会变得非常有趣,因此,尽情释放你的想象力吧!值得注意的是,谷歌的 DeepDream 也运用了类似的概念,展示了如何通过叠加过度激活的激活图层在输入图像上生成艺术图案。这些图案的复杂性有时令人惊叹、充满敬畏:

本书作者的照片,拍摄地点为巴黎迪士尼乐园的鬼屋前。该图像已通过开源的 DeepDream 生成器进行处理,我们鼓励你也来尝试,不仅仅是为了欣赏其美丽,它还可以在节假日期间为艺术天赋的亲戚们生成一些非常实用的礼物。
卷积神经网络(CNN)的问题
许多人可能会声称,CNN 所采用的层次嵌套模式识别技术在某种程度上与我们自身视觉皮层的运作非常相似。这在某种程度上是有道理的。然而,视觉皮层实现的是一种更加复杂的架构,并且能够在大约 10 瓦的能量下高效运行。我们的视觉皮层也不会轻易被含有面部特征的图像所欺骗(尽管这种现象足够常见,已经在现代神经科学中得到了正式的命名)。错觉是一个与人类大脑解读信号相关的术语,它指的是大脑在没有实际存在的情况下,生成更高层次的概念。科学家们已经表明,这一现象与位于视觉皮层枕状回区域的神经元的早期激活有关,这些神经元负责进行多个视觉识别和分类任务。在错觉的情况下,这些神经元可以说是过早激活,导致我们检测到面部特征或听到声音,即便实际上并不存在。这一现象可以通过火星表面的著名图片来说明,在这张图片中,大多数人都能清楚地辨认出面部轮廓和特征,尽管这张图片实际上只是堆积的红色尘土:

图片来源:NASA/JPL-Caltech
神经网络错觉
这个问题显然并非我们生物大脑独有的。事实上,尽管卷积神经网络(CNN)在许多视觉任务中表现优秀,神经网络幻觉(pareidolia)问题一直是计算机视觉研究人员不断努力解决的问题。正如我们所提到的,CNN 通过学习一系列过滤器来分类图像,这些过滤器能提取有用的特征,并能以概率的方式对输入图像进行分解。然而,这些过滤器学到的特征并不代表给定图像中的所有信息。这些特征之间的相对方向同样至关重要!两只眼睛、嘴唇和鼻子的存在并不能固有地构成一个面部特征。实际上,正是这些元素在图像中的空间排列决定了我们所说的面部:

摘要
在这一章中,首先,我们使用了卷积层,这些卷积层能够将给定的视觉输入空间分解为逐层嵌套的卷积过滤器的概率激活,并随后连接到执行分类的稠密神经元。这些卷积层中的过滤器学习与有用表示相对应的权重,这些表示可以以概率的方式进行查询,从而将数据集中存在的输入特征集映射到各自的输出类别。此外,我们还看到如何深入理解我们的卷积网络所学到的内容。我们看到了四种具体的方法:基于中间激活的、基于显著性的、梯度加权类激活的,以及激活最大化可视化。每种方法都能提供不同层次的网络所捕捉到的模式的独特直觉。我们可视化了这些模式,不仅对于给定的图像,也对于整个输出类别,以直观地理解在推理时我们的网络关注的元素。
最后,尽管我们回顾了许多基于神经科学的灵感,这些灵感促成了 CNN 架构的发展,但现代的 CNN 在任何方面都无法与哺乳动物视觉皮层中实施的复杂机制相竞争。事实上,视觉皮层层次结构的许多设计甚至与我们目前所设计的完全不同。例如,视觉皮层的各层本身就被结构化为后续的皮层柱,包含着神经元,这些神经元的感受野据说是彼此不重叠的,其目的至今仍不为现代神经科学所知。即使我们的视网膜也通过使用棒状细胞(对低强度光敏感)、锥状细胞(对高强度光敏感)和 ipRGC 细胞(对时间相关刺激敏感)在将视觉信号以电信号形式发送至丘脑基底的外侧膝状核之前,进行了一系列的感官预处理。外侧膝状核被称为视觉信号的中继中心。从这里,信号开始其旅程,往返于视觉皮层六层密集相互连接的层次结构中(而不是卷积层),伴随我们的一生。本质上,人类的视觉是高度顺序化和动态的,远远不同于人工实现的视觉。总结来说,尽管我们距离像生物学那样赋予机器视觉智能还有很长的路要走,但 CNN 代表了计算机视觉领域的现代成就的巅峰,使其成为无数机器视觉任务中非常适应的架构。
在此,我们结束了关于 CNN 探索的章节。我们将在后续章节中回顾更复杂的架构,并实验数据增强技术和更复杂的计算机视觉任务。在下一章节中,我们将探索另一种神经网络架构——RNN,它特别适用于捕捉和建模序列信息,如时间变化数据,这在许多领域中都很常见,从工业工程到自然语言对话生成。
第五章:循环神经网络
在上一章中,我们惊叹于视觉皮层的功能,并借鉴了它处理视觉信号的方式来构建卷积神经网络(CNNs)的架构,后者构成了许多先进计算机视觉系统的基础。然而,我们并非仅通过视觉来理解周围的世界。声音,尤其是,也在其中扮演着非常重要的角色。更具体地说,我们人类喜欢通过符号化的简化序列和抽象的表现来沟通和表达复杂的思想与观念。我们内置的硬件使我们能够解读语音或其标记,构成了人类思维和集体理解的基础,而更复杂的表现形式(例如人类语言)可以在其之上构建。从本质上讲,这些符号序列是我们通过自己视角对周围世界的简化表示,我们用它们来导航环境并有效地表达自己。显而易见,我们希望机器能理解这种处理顺序信息的方式,因为它可以帮助我们解决现实世界中许多涉及顺序任务的问题。那么,具体来说,是什么问题呢?
以下是本章将涵盖的主题:
- 
建模序列 
- 
总结不同类型的序列处理任务 
- 
每个时间步预测输出 
- 
反向传播通过时间 
- 
梯度爆炸与消失 
- 
GRU 
- 
在 Keras 中构建字符级语言模型 
- 
字符建模的统计 
- 
随机控制的目的 
- 
测试不同的 RNN 模型 
- 
构建简单的 RNN 
- 
构建 GRU 
- 
顺序处理现实 
- 
Keras 中的双向层 
- 
可视化输出值 
建模序列
或许你希望在访问外国时,能够正确地翻译你在餐馆中的点餐。也许你希望你的汽车能够自动执行一系列动作,从而能自己停车。或者你可能想要理解人类基因组中腺嘌呤、鸟嘌呤、胸腺嘧啶和胞嘧啶分子在不同序列中的变化是如何导致人体内生物过程的差异的。这些例子之间有什么共同点呢?嗯,这些都是序列建模任务。在这些任务中,训练示例(无论是单词的向量、由车载控制生成的一系列车动作,还是A、G、T和C分子的配置)本质上都是一组具有时间依赖性的数据点,长度可能各不相同。
例如,句子是由单词组成的,这些单词的空间配置不仅暗示了已说出的内容,还暗示了未说出的内容。试着填入以下空白:
不要以貌取书。
你是怎么知道下一个词是cover的?你只是看了看单词及其相对位置,并进行了一些贝叶斯推断,利用你之前看到的句子以及它们与当前示例的明显相似性。本质上,你使用了你对英语语言的内部模型来预测最可能的下一个单词。这里的语言模型仅指在给定序列中,特定单词组合一起出现的概率。这些模型是现代语音识别和机器翻译系统的基础组件,依赖于建模单词序列的可能性。
使用 RNN 进行序列建模
自然语言理解领域是循环神经网络(RNNs)通常表现优异的一个领域。你可以想象一些任务,比如识别命名实体或分类给定文本中的主要情感。然而,正如我们提到的,RNNs 适用于广泛的任务,这些任务涉及建模时间依赖的序列数据。生成音乐也是一个序列建模任务,因为我们通过建模在给定节奏下演奏的音符序列来区分音乐和杂音。
RNN 架构甚至适用于一些视觉智能任务,比如视频活动识别。识别一个人在给定视频中是做饭、跑步还是抢银行,本质上是在建模人类运动的序列,并将其与特定类别进行匹配。事实上,RNNs 已经被应用于一些非常有趣的用例,包括生成莎士比亚风格的文本、创建现实(但错误的)代数论文,甚至为 Linux 操作系统生成格式正确的源代码。
那么,是什么让这些网络在执行这些看似不同的任务时如此多才多艺呢?在回答这个问题之前,让我们回顾一下迄今为止使用神经网络时遇到的一些困难:

由 RNN 生成的假代数几何图形,感谢 Andrej Karpathy
这意味着:

有什么关键点吗?
到目前为止,我们构建的所有网络存在一个问题,即它们只接受固定大小的输入和输出,用于给定的训练样本。我们一直需要指定输入的形状,定义进入网络的张量的维度,而网络则返回固定大小的输出,例如一个类别概率分数。此外,我们网络中的每一层都有自己的权重和激活函数,它们在某种程度上是独立的,未能识别连续输入值之间的关系。这对于我们在前几章中熟悉的前馈网络和卷积神经网络(CNN)都适用。对于我们构建的每个网络,我们使用了非序列化的训练向量,这些向量会通过固定数量的层进行传播并产生单一的输出。
尽管我们确实看到了一些多输出模型用来可视化卷积神经网络(CNN)的中间层,但我们从未真正修改我们的架构以适应处理一系列向量。这基本上使得我们无法共享任何可能影响预测可能性的时间依赖信息。舍弃时间依赖信息至今对我们所处理的任务没有造成太大问题。在图像分类的情况下,神经网络在最后一次迭代中看到了一只猫的图像,这对其分类当前图像并没有什么帮助,因为这两个实例的类别概率在时间上并没有关联。然而,这种方法已经在情感分析的用例中给我们带来了一些麻烦。回顾第三章,《信号处理 - 使用神经网络的数据分析》,我们通过将每条影评视为一个无向词袋(即,词语不按顺序排列)来进行分类。这种方法涉及将每条影评转化为一个固定长度的向量,长度由我们词汇表的大小定义(即语料库中的唯一词汇数,我们选择的是 12,000 个词)。虽然这种方法有用,但它显然不是最有效或最具可扩展性的表示信息的方式,因为任何给定长度的句子必须通过一个 12,000 维的向量来表示。我们训练的简单前馈网络(精度略高于 88%)错误地分类了其中一条影评的情感,以下是这条影评的重现:

我们的网络似乎因为(不必要地)复杂的句子,包含了几个长期依赖关系和上下文效价转换器而变得困惑。回顾起来,我们注意到有不清晰的双重否定,指代了如导演、演员和电影本身等不同实体;然而我们能够看出这篇评论的总体情感显然是积极的。为什么?因为我们能够跟踪与评论总体情感相关的概念,逐字阅读时,我们的大脑能够评估我们所阅读的每个新词如何影响已读语句的整体意义。通过这种方式,我们在阅读过程中会根据新的信息(如形容词或否定词)调整评论的情感得分,这些信息可能在特定的时间步骤上影响得分。
就像在卷积神经网络(CNN)中一样,我们希望网络能够使用在输入的某个片段上学到的表示,并能在后续的其他片段和示例中使用。换句话说,我们需要能够共享网络在前几个时间步骤中学到的权重,以便在按顺序阅读输入评论时将信息片段连接起来。这正是 RNN 所允许我们做的。这些层利用了连续事件中编码的额外信息,方法是遍历输入值的序列。根据架构实现的不同,RNN 可以将相关信息保存在其记忆(也称为状态)中,并使用这些信息在随后的时间步骤中进行预测。
这种机制与我们之前看到的网络显著不同,后者每次训练迭代是独立的,并且在预测之间没有保持任何状态。循环神经网络有多种不同的实现方式,从门控循环单元(GRUs)、有状态和无状态的长短期记忆(LSTM)网络、双向单元等,种类繁多。正如我们很快会发现的那样,这些架构中的每一种都有助于解决某类问题,并在彼此的不足之上构建:

基本的 RNN 架构
现在,让我们来看看 RNN 架构如何通过时间展开,区别于我们之前看到的其他网络。让我们考虑一个新的时间序列问题:语音识别。计算机可以执行这个任务,通过识别人类语音段落中单词的流动。它可以用于转录语音本身、翻译语音,或将其用作输入指令,类似于我们彼此间的指令方式。这类应用构成了像 Siri 或 Alexa 这样的系统的基础,甚至可能是未来更复杂、更具认知能力的虚拟助手的基础。那么,RNN 是如何解码通过电脑麦克风录制下来的分解震动序列,转化为与输入语音相对应的字符串变量的呢?
让我们考虑一个简化的理论示例。假设我们的训练数据将一系列人类发声映射到一组可读的单词。换句话说,你向网络展示一个音频片段,它会输出其中说的内容的文字记录。我们将一个 RNN 任务分配给它,处理一段语音,将其视为一系列向量(表示声音字节)。然后,网络可以尝试预测这些声音字节在每个时间步骤可能代表的英语单词:

考虑表示今天是个好日子这句话的声音字节向量集合。一个递归层将在几个时间步骤中展开这个序列。在第一个时间步骤,它将接受表示序列中第一个单词(即今天)的向量作为输入,与层权重进行点积运算,并将结果通过一个非线性激活函数(通常是 RNN 使用的 tanh)输出一个预测值。这个预测值对应着网络认为它所听到的单词。在第二个时间步骤,递归层接收下一个声音字节(即单词是),以及来自第一个时间步骤的激活值。然后,这两个值都会通过激活函数进行压缩,产生当前时间步骤的预测。这基本上使得该层能够利用先前时间步骤的信息来指导当前时间步骤的预测。这个过程会随着递归层接收每个音节的发声,并结合之前发声的激活值反复进行。该层可能会为字典中的每个单词计算一个 Softmax 概率分数,选择具有最高值的单词作为当前层的输出。这个单词就是网络认为它在这个时间点所听到的内容。
临时共享权重
为什么将激活连接到时间序列上有用?正如我们之前提到的,每个词都会影响下一个词的概率分布。如果我们的句子以单词Yesterday开头,那么它更可能接着是was,而不是is,这反映了过去时的使用。这种句法信息可以通过递归层传递,以通过利用网络在先前时间步输出的内容,来指导网络在每个时间步的预测。当我们的网络在给定的语音片段上进行训练时,它会调整其层权重,以最小化预测值与每个输出的真实值之间的差异,通过(希望)学习这些语法和句法规则,以及其他内容。重要的是,递归层的权重是时间共享的,使得先前时间步的激活对后续时间步的预测产生影响。这样,我们不再将每个预测视为孤立的,而是将其视为网络在先前时间步的激活和当前时间步的输入的函数。
语音识别模型的实际工作流程可能比我们之前描述的要复杂一些,其中涉及数据标准化技术,如傅里叶变换,它可以将音频信号分解成其组成的频率。实际上,我们总是试图对输入数据进行标准化,以更好地向神经网络表示数据,因为这有助于加速其收敛,从而编码有用的预测规则。从这个例子中得到的关键点是,递归层可以利用早期的时间信息来指导当前时间步的预测。随着本章的推进,我们将看到如何将这些架构应用于不同长度的序列输入和输出数据建模。
RNN 中序列建模的变体
语音识别示例包含了建模一个同步的多对多序列,其中我们预测了许多语音集与这些语音对应的多个单词。我们可以使用类似的架构来进行视频字幕任务,在该任务中,我们希望顺序地为视频的每一帧标注其中的主要物体。这又是一个同步的多对多序列,因为我们在每个时间步都输出一个与视频输入帧对应的预测。
编码多对多表示
在机器翻译的情况下,我们还可以使用半同步的多对多序列。这种用例是半同步的,因为我们不会在每个时间步骤上立即输出预测。相反,我们使用 RNN 的编码器部分来捕获整个短语,以便在我们继续并实际翻译之前先进行翻译。这使我们能够找到输入数据在目标语言中的更好表示,而不是逐个翻译每个单词。后一种方法并不稳健,通常会导致不准确的翻译。在以下示例中,RNN 将法语短语C'est pas mal!翻译成英文的对应词It's nice!,这比字面意思的It's not bad!要准确得多。因此,RNN 可以帮助我们解码法语中用于赞美一个人的独特规则,这可能有助于避免不少误解:

多对一
类似地,你也可以使用多对一架构来处理一些任务,例如将多个词序列(构成一个句子)映射到一个对应的情感分数。这就像我们在上一次练习中使用 IMDb 数据集时所做的那样。上次,我们的方法是将每个评论表示为无向词袋。使用 RNN 时,我们可以通过将评论建模为一个按正确顺序排列的有向单词序列来处理这个问题,从而利用单词排列中的空间信息来帮助我们获得情感分数。以下是一个简化的多对一 RNN 架构示例,用于情感分类:

一对多
最后,不同类型的序列任务可能需要不同的架构。另一个常用的架构是“一对多”RNN 模型,我们通常在音乐生成或图像标题生成的场景中使用它。对于音乐生成,我们基本上将一个输入音符馈送给网络,预测序列中的下一个音符,然后将其预测作为下一个时间步骤的输入:

一对多用于图像标题生成
另一个常见的一对多架构示例是图像描述任务中使用的架构。当我们将一张图片展示给网络,并要求它用简短的文字描述图片内容时,就会用到这种架构。为了实现这一点,我们实际上是一次性向网络输入一张图像,并输出与图像内容相关的多个单词。通常,你可能会在已经在某些实体(物体、动物、人物等)上训练过的卷积神经网络(CNN)上叠加一个递归层。这样,你可以利用递归层一次性处理卷积网络的输出值,并依次扫描图像,输出与输入图像描述相关的有意义的单词。这是一个更复杂的设置,我们将在后续章节中详细说明。目前,值得了解的是,LSTM 网络(如下所示)是一种受到人类记忆结构中的语义和情节划分启发的 RNN 类型,并将在第六章中作为主要讨论主题,长短期记忆网络。在下图中,我们可以看到网络如何利用从 CNN 获得的输出,识别出有几只长颈鹿站在周围。
总结不同类型的序列处理任务
现在,我们已经熟悉了递归层的基本原理,并回顾了一些具体的使用案例(如语音识别、机器翻译和图像描述),在这些案例中,可以使用此类时间依赖模型的变体。下图提供了我们讨论的一些序列任务的视觉总结,以及适用于这些任务的 RNN 类型:

接下来,我们将深入探讨 RNN 的控制方程以及其学习机制。
RNN 是如何学习的?
正如我们之前所看到的,对于几乎所有神经网络,你可以将学习机制分解为两个独立的部分。前向传播方程控制数据如何在神经网络中向前传播,一直到网络的预测结果。误差反向传播由方程(如损失函数和优化器)定义,它们允许模型的预测误差在模型的各层之间向后传播,调整每一层的权重,直到达到正确的预测值。
对于 RNN 来说,基本上是一样的,但有一些架构上的变动,用以应对时间依赖的信息流。为了做到这一点,RNN 可以利用一个内部状态,或记忆,来编码有用的时间依赖表示。首先,我们来看看递归层中数据的前向传递。递归层基本上是将输入向量与状态向量结合,在每个时间步产生一个新的输出向量。很快,我们将看到如何通过迭代更新这些状态向量来保留给定序列中与时间相关的信息。
一个通用的 RNN 层
以下图示应该有助于你熟悉这一过程。在左侧,图中的灰色箭头展示了当前时间步的激活是如何被传递到未来时间步的。这对于所有 RNN 都适用,构成了它们架构的独特标志。在右侧,你会看到 RNN 单元的简化表示。这是你在无数计算机科学研究论文中看到的 RNN 最常见的划分方式:

序列化,还是不序列化?
RNN 层本质上是以时间依赖和顺序的方式处理输入值。它采用一种状态(或记忆),这使得我们能够以一种新颖的方式解决序列建模任务。然而,也有许多例子表明,以顺序的方式处理非顺序数据,能够让我们以更高效的方式解决标准应用场景。以 DeepMind 关于引导网络注意力集中于图像的研究为例。
DeepMind 的研究人员展示了如何通过强化学习训练的 RNN 来代替简单的计算密集型 CNN 进行图像分类,且能够在更复杂的任务中达到更高的准确度,如对杂乱图像的分类,以及其他动态视觉控制问题。他们研究中的主要架构启示之一是,RNN 能够通过自适应选择要处理的序列或区域,以高分辨率提取图像或视频中的信息,从而减少以高分辨率处理整个图像所带来的冗余计算复杂性。这非常巧妙,因为我们并不一定需要处理图像的所有部分来进行分类。我们通常需要的内容往往集中在图像的局部区域:deepmind.com/research/publications/recurrent-models-visual-attention/。
前向传播
那么,信息是如何在这个 RNN 架构中流动的呢?让我们通过一个示例来介绍 RNN 中的前向传播操作。假设我们要预测短语中的下一个单词。设定我们的短语为:to be or not to be。随着单词进入网络,我们可以将每个时间步执行的计算分为两类概念性操作。在以下图示中,你可以将每个箭头视为在给定的数值集上执行一次计算(或点积操作):

我们可以看到,在一个递归单元中,计算既是纵向的也是横向的,数据在其中传播。需要记住的是,层的所有参数(或权重矩阵)都是时间共享的,意味着在每个时间步使用相同的参数进行计算。在第一个时间步,我们的层将使用这些参数集来计算两个输出值。其中一个是当前时间步的层激活值,而另一个则表示当前时间步的预测值。让我们从第一个开始。
每个时间步计算激活值
以下方程表示在时间 t 时刻的递归层激活值。术语 g 表示所选的非线性激活函数,通常为 tanh 函数。在括号内,我们执行两个矩阵级别的乘法运算,然后将它们与偏置项 (ba) 相加:
at = g [ (W^(ax) x x^t ) + (Waa x a(t-1)) + ba ]
术语 (W^(ax)) 控制输入向量  在时间 t 时进入递归层的变换。这个权重矩阵是时间共享的,意味着我们在每个时间步使用相同的权重矩阵。接下来,我们看到术语 (Waa),它表示控制来自前一个时间步的激活值的时间共享权重矩阵。在第一个时间步,(Waa) 会随机初始化为非常小的值(或零),因为我们此时还没有激活权重可以计算。对于值 (a<0>) 也是如此,它被初始化为零向量。因此,在第一个时间步,我们的方程将看起来像这样:
 在时间 t 时进入递归层的变换。这个权重矩阵是时间共享的,意味着我们在每个时间步使用相同的权重矩阵。接下来,我们看到术语 (Waa),它表示控制来自前一个时间步的激活值的时间共享权重矩阵。在第一个时间步,(Waa) 会随机初始化为非常小的值(或零),因为我们此时还没有激活权重可以计算。对于值 (a<0>) 也是如此,它被初始化为零向量。因此,在第一个时间步,我们的方程将看起来像这样:
a1 = tanH [ (W^(ax) x x1 ) + (Waa x a(0)) + ba ]
简化激活方程
我们可以通过将两个权重矩阵(Wax 和 Waa)水平堆叠成一个单一矩阵(W[a]),进一步简化这个方程,这个矩阵定义了递归层的所有权重(或状态)。我们还将代表前一个时间步激活(a(t-1)) 和当前时间步输入(  t )的两个向量垂直堆叠,形成一个新矩阵,我们将其表示为 a(t-1), ![ t ] 。这让我们可以简化之前的激活表达式, 如下所示:
 t )的两个向量垂直堆叠,形成一个新矩阵,我们将其表示为 a(t-1), ![ t ] 。这让我们可以简化之前的激活表达式, 如下所示:
at = tanH  (W t ) + (Waa x a(t-1)) + ba ] 或 at = tanH (W
 t ) + (Waa x a(t-1)) + ba ] 或 at = tanH (W a(t-1),
a(t-1),  t ] + ba )
 t ] + ba )
从概念上讲,由于两个矩阵(W )的高度保持不变,我们能够像上面那样水平堆叠它们。同样,输入的长度(
)的高度保持不变,我们能够像上面那样水平堆叠它们。同样,输入的长度( t)和激活向量(a(t-1)) 也保持不变,因为数据在 RNN 中传播。现在,计算步骤可以表示为:权重矩阵(W[a])既与前一个时间步的激活相乘,也与当前时间步的输入相乘,然后加上偏置项,整个项通过非线性激活函数传递。我们可以通过新的权重矩阵,按时间展开此过程,如下图所示:
 t)和激活向量(a(t-1)) 也保持不变,因为数据在 RNN 中传播。现在,计算步骤可以表示为:权重矩阵(W[a])既与前一个时间步的激活相乘,也与当前时间步的输入相乘,然后加上偏置项,整个项通过非线性激活函数传递。我们可以通过新的权重矩阵,按时间展开此过程,如下图所示:

本质上,使用时间共享的权重参数(如 Wa 和 Wya)使我们能够利用序列前面的信息来为后续时间步的预测提供依据。现在,你已经知道了如何在每个时间步迭代计算激活值,数据在递归层中流动。
预测每个时间步的输出
接下来,我们将看到一个方程,它利用我们刚刚计算出的激活值来产生预测(在给定时间步(t)下)。这个过程可以表示如下:
 = g [ (Way x at) + by ]
 = g [ (Way x at) + by ]
这告诉我们,层在某一时间步的预测是通过计算一个临时共享输出矩阵与我们刚刚计算出的激活输出(at)的点积来确定的。
由于共享权重参数,前一个时间步的信息得以保留,并通过递归层传递,用于当前的预测。例如,第三个时间步的预测利用了前一个时间步的信息,正如这里绿色箭头所示:

为了形式化这些计算,我们数学地展示了第三个时间步的预测输出与前几个时间步的激活之间的关系,如下所示:
![]() = sigmoid [ (Way x a3)* + by* ] = sigmoid [ (Way x a3)* + by* ]
其中a(3)的定义如下:
- a3 = sigmoid (W![]() a(2), a(2),![]() **3 ] + ba ) **3 ] + ba )
其中a**(2)的定义如下:
- a2 = sigmoid (W![]() a(1), a(1),![]() 2 ] + ba ) 2 ] + ba )
其中a(1)的定义如下:
- a1 = sigmoid (W![]() a(0), a(0),![]() **1 ] + ba ) **1 ] + ba )
最后,a(0) 通常初始化为零向量。这里要理解的主要概念是,RNN 层在将激活值传递到下一层之前,递归地通过多个时间步处理一个序列。现在,你已经完全掌握了 RNN 中信息前向传播的所有方程式,并且对其有了高层次的理解。尽管这种方法在建模许多时间序列方面非常强大,但它也存在一定的局限性。
单向信息流问题
一个主要的限制是我们只能通过前一时间步的激活值来告知当前时间步的预测,而无法利用未来时间步的数据。那么,为什么我们要这么做呢?请考虑命名实体识别的问题,在这个问题中,我们可能会使用同步的多对多 RNN 来预测句子中的每个词是否为命名实体(例如一个人名、地名、产品名等)。我们可能会遇到一些问题,如下所示:
- 
尽管面临各种障碍,斯巴达人依然坚定地向前行进。 
- 
这些人所面临的斯巴达式生活方式对于许多人来说是难以想象的。 
正如我们所看到的,仅凭前两个词,我们自己是无法判断“Spartan”这个词是作为名词(因此是一个命名实体)使用,还是作为形容词使用。只有在继续阅读完整句子之后,我们才可以为这个词加上正确的标签。同样,我们的网络也无法准确预测第一句中的“Spartan”是一个命名实体,除非它能够利用未来时间步的激活值。由于 RNN 可以从带注释的数据集中学习序列语法规则,它将能够学习到命名实体通常后面跟随动词(例如 marched)而非名词(例如 lifestyle)的规律,因此能够准确预测第一句中的“Spartan”是命名实体。这一切在一种特殊类型的 RNN——双向 RNN 的帮助下成为可能,我们将在本章后面讨论它。值得注意的是,带有词性标签(指示一个词是名词、形容词等)的注释数据集,将大大提高你网络学习有效序列表示的能力,正如我们希望它在这里所做的那样。我们可以将两句的第一部分,带有词性标签的注释,可视化如下:
- 斯巴达勇士行进...à:

- 斯巴达的生活方式...à:

在当前词语之前的词序列,提供的信息比它前面出现的词语更多。我们很快将看到双向 RNN 如何利用来自未来时间步以及过去时间步的信息,在当前时间进行预测。
长期依赖问题
我们在使用简单的递归层时常遇到的另一个问题是,它们在建模长期序列依赖关系时的弱点。为了澄清我们所说的这一点,考虑以下示例,我们将它们逐词输入到一个 RNN 中,以预测接下来的词语:
- 
那只猴子已经享受了一段时间的香蕉美味,并且渴望吃更多。 
- 
猴子们已经享受了一段时间的香蕉美味,并且渴望吃更多。 
要预测每个序列中第 11^(th)时间步的词,网络必须记住句子的主语(猴子),在时间步 2 看到的,是单数还是复数。然而,当模型训练并且误差通过时间反向传播时,距离当前时间步较近的时间步的权重受到的影响要比较早时间步的权重大得多。从数学角度来看,这就是梯度消失问题,它与我们损失函数的链式法则部分导数的极小值有关。我们递归层中的权重通常会根据每个时间步的这些部分导数进行更新,但它们并没有足够地朝着正确的方向微调,这使得我们的网络无法进一步学习。这样,模型无法更新层的权重,以反映早期时间步的长期语法依赖关系,就像我们的例子中所反映的那样。这是一个尤其棘手的问题,因为它显著影响了递归层中误差的反向传播。很快,我们将看到如何通过更复杂的架构(如 GRU 和 LSTM 网络)部分解决这个问题。首先,让我们理解 RNN 中反向传播的过程,它孕育了这个问题。
你可能会想知道,RNN 是如何精确地通过反向传播调整层的暂时共享权重的,尤其是在处理一系列输入时。这个过程甚至有一个有趣的名字。与我们遇到的其他神经网络不同,RNN 被称为通过时间进行反向传播。
通过时间反向传播
本质上,我们正在通过多个时间步反向传播我们的误差,反映了一个序列的长度。如我们所知,能够反向传播误差的第一步是必须有一个损失函数。我们可以使用交叉熵损失的任何变体,具体取决于我们是否在每个序列上执行二分类任务(即每个单词是实体还是非实体 à 二元交叉熵)或分类任务(即从我们的词汇表中选择下一个词 à 分类交叉熵)。这里的损失函数计算的是预测输出  和实际值 (y) 在时间步 t 上的交叉熵损失:
 和实际值 (y) 在时间步 t 上的交叉熵损失:
 (
(  
  log
 log  -  (1-
使用这种表示网络整体损失的方法,我们可以对每个时间步的层权重求导,以计算模型的误差。我们可以通过回顾递归层的示意图来可视化这个过程。箭头标出了通过时间的错误反向传播。
可视化时间反向传播
在这里,我们根据每个时间步的层权重反向传播模型中的误差,并在模型训练过程中调整权重矩阵Way和Wa。我们本质上仍然是在计算损失函数相对于网络所有参数的梯度,并在每个时间步中相应地反向调整两个权重矩阵。

现在,我们知道 RNN 是如何在一系列向量上进行操作,并利用时间相关的依赖来在每一步做出预测的。
梯度爆炸与梯度消失
然而,在深度神经网络中反向传播模型的错误也有其自身的复杂性。对于递归神经网络(RNN)来说,情况也不例外,它们面临着各自版本的梯度消失和梯度爆炸问题。正如我们之前讨论的,给定时间步的神经元激活依赖于以下公式:
at = tanH  (W t ) + (Waa x a(t-1)) + ba ]
 t ) + (Waa x a(t-1)) + ba ]
我们看到Wax和Waa是 RNN 层在时间上共享的两个独立的权重矩阵。这些矩阵分别与当前时间步的输入矩阵和前一个时间步的激活进行乘法计算。点积结果然后与偏置项加和,并通过 tanh 激活函数来计算当前时间步(t)的神经元激活。接着,我们使用这个激活矩阵来计算当前时间步的预测输出( ),然后将激活值传递到下一个时间步:
),然后将激活值传递到下一个时间步:
 = softmax [ (Way x at) + by ]
 = softmax [ (Way x at) + by ]
因此,权重矩阵(Wax、Waa 和 Way)代表了给定层的可训练参数。在时间反向传播过程中,我们首先计算梯度的乘积,表示每个时间步层权重相对于预测输出和实际输出变化的变化。然后,我们使用这些乘积来更新相应的层权重,方向与变化相反。然而,当跨多个时间步进行反向传播时,这些乘积可能变得极其微小(因此不会显著地改变层权重),或者变得极其巨大(因此超出了理想权重)。这主要适用于激活矩阵(Waa)。它代表了我们 RNN 层的记忆,因为它编码了来自前一个时间步的时间依赖信息。让我们通过一个概念性的例子来澄清这个概念,看看在处理长序列时,更新早期时间步的激活矩阵是如何变得越来越困难的。假设你想计算在时间步三时的损失梯度,相对于层权重。
从梯度的角度思考
在给定时间步的激活矩阵是前一个时间步激活矩阵的函数。因此,我们必须递归地将时间步三的损失定义为来自先前时间步的层权重子梯度的乘积:

这里,(L) 代表损失,(W) 代表时间步的权重矩阵,x 值是给定时间步的输入。数学上,这相当于以下表达式:

这些函数的导数存储在雅可比矩阵中,表示权重和损失向量的逐点导数。数学上,这些函数的导数的绝对值被限制在 1 以内。然而,小的导数值(接近 0),经过多次时间步的矩阵乘法后,会呈指数下降,几乎消失,这反过来又禁止了模型的收敛。对于激活矩阵中的大值(大于 1),也是如此,梯度将变得越来越大,直到它们被赋值为 NaN(不是一个数字),从而突然终止训练过程。我们该如何解决这些问题呢?
你可以在以下链接中找到关于梯度消失的更多信息:www.wildml.com/2015/10/recurrent-neural-networks-tutorial-part-3-backpropagation-through-time-and-vanishing-gradients/。
通过裁剪防止梯度爆炸
在梯度爆炸的情况下,问题则更加明显。您的模型会直接停止训练,返回 NaN 值的错误,这对应于爆炸的梯度值。解决这个问题的简单方法是通过定义一个任意的上限或阈值来裁剪梯度,以防梯度变得过大。Keras 让我们轻松实现这一点,您可以通过手动初始化优化器并传递clipvalue或clipnorm参数来定义这个阈值,如下所示:

然后,您可以将optimizers变量传递给模型进行编译。关于梯度裁剪的这一思想,以及与训练 RNN 相关的其他问题,已在论文《训练递归神经网络的难度》中进行了广泛讨论,您可以在proceedings.mlr.press/v28/pascanu13.pdf阅读该论文。
使用记忆防止梯度消失
在梯度消失的情况下,我们的网络停止学习新内容,因为在每次更新时权重几乎没有变化。这个问题对于 RNN 尤其棘手,因为它们尝试在许多时间步长中建模长序列,因此模型在反向传播错误并调整较早时间步的层权重时会遇到很大困难。我们看到这个问题如何影响语言建模任务,例如学习语法规则和基于实体的依赖关系(以猴子示例为例)。幸运的是,已经有一些解决方案被提出以应对这个问题。一些方法试图通过精心初始化激活矩阵Waa,使用 ReLU 激活函数以无监督的方式对层权重进行预训练来解决此问题。然而,更常见的做法是通过设计更复杂的架构来解决此问题,这些架构能够根据当前序列中的事件统计相关性存储长期信息。这本质上是更复杂的 RNN 变种(如门控循环单元(GRUs)和长短期记忆(LSTM)网络)的基本直觉。接下来,我们将看看 GRU 如何解决长期依赖问题。
GRUs
GRU 可以被视为 LSTM 的“弟弟”,我们将在第六章中讨论长短期记忆网络。本质上,二者都利用类似的概念来建模长期依赖关系,例如在生成后续序列时记住句子的主语是复数。很快,我们将看到如何通过记忆单元和流门来解决消失梯度问题,同时更好地建模序列数据中的长期依赖关系。GRU 与 LSTM 之间的根本区别在于它们所代表的计算复杂度。简单来说,LSTM 是更复杂的架构,虽然计算开销大、训练时间长,但能够非常好地将训练数据分解成有意义且具有普适性的表示。而 GRU 则相对计算负担较轻,但在表示能力上相较于 LSTM 有所限制。然而,并非所有任务都需要像 Siri、Cortana、Alexa 等使用的 10 层 LSTM。正如我们很快将看到的,字符级语言建模最初可以通过相对简单的架构实现,利用像 GRU 这样的轻量级模型,逐渐取得越来越有趣的结果。下图展示了我们迄今为止讨论的 SimpleRNN 与 GRU 之间的基本架构差异。
记忆单元
同样,我们有两个输入值进入单元,分别是当前时间步的序列输入和前一时间步的层激活值。GRU 的一个主要区别是增加了记忆单元(c),它使我们能够在给定的时间步存储一些相关信息,以便为后续的预测提供依据。实际上,这改变了我们如何计算给定时间步的激活值(ct*,在这里与*at相同)的方式:

回到猴子示例,单词级的 GRU 模型有潜力更好地表示第二个句子中存在多个实体这一事实,因此能够记住使用were而不是was来完成序列:

那么,这个记忆单元到底是如何工作的呢?其实,(c^t)的值存储了给定时间步(时间步 2)时的激活值(at*),并且如果该值对当前序列相关,则会被传递到后续的时间步。一旦该激活值的相关性丧失(也就是说,序列中检测到新的依赖关系),记忆单元就可以用新的(*ct)值进行更新,反映出可能更为相关的时间依赖信息:

更深入地了解 GRU 单元
表示记忆单元
在处理我们的示例句子时,基于单词级的 RNN 模型可能会在时间步 2(对于单词monkey和monkeys)保存激活值,并保存到时间步 11,在该时间步,它用于预测输出单词was和were。在每个时间步,都会生成一个候选值(c (̴t)*),该值尝试替换记忆单元的值(*ct)。然而,只要(c^t)在统计上仍然与序列相关,它就会被保留,直到稍后为了更相关的表示而被丢弃。让我们看看这是如何在数学上实现的,从候选值(c (̴t)*)开始。为了实现这个参数,我们将初始化一个新的权重矩阵(*Wc*)。然后,我们将计算(*Wc*)与之前的激活(*c(t-1))以及当前时间的输入( t)的点积,并将得到的向量通过非线性激活函数(如 tanh)。这个操作与我们之前看到的标准前向传播操作非常相似。
 t)的点积,并将得到的向量通过非线性激活函数(如 tanh)。这个操作与我们之前看到的标准前向传播操作非常相似。
更新记忆值
从数学角度来看,我们可以将此计算表示为:
c ^(̴t) = tanh ( Wc c^(t-1), ![ t ] + bc)
更重要的是,GRU 还实现了一个由希腊字母伽马(Γu)表示的门控,它基本上通过另一个非线性函数计算输入与之前激活的点积:
Γu = sigmoid ( Wu c^(t-1), ![ t ] + bu)
这个门控的目的是决定是否应当用候选值(c (̴t)*)更新当前值(*ct)。门控的值(Γu)可以被看作是一个二进制值。实际上,我们知道 sigmoid 激活函数以将值压缩在 0 和 1 之间而闻名。事实上,进入 sigmoid 激活函数的绝大多数输入值最终会变成 0 或 1,因此可以实用地将伽马变量视为一个二进制值,它决定是否在每个时间步将(c^t)替换为(c ^(̴t))。

更新方程的数学
让我们看看这在实践中如何运作。我们将再次使用之前的示例,这里扩展了该示例,以理论上演示何时一个世界级的 GRU 模型可能有效:
猴子曾一度喜欢吃香蕉,并渴望再吃一些。香蕉本身是岛屿这边能找到的最好的...
当 GRU 层遍历这个序列时,它可能会将第二时间步的激活值存储为(c^t),检测到一个单一实体的存在(即猴子)。它会将这一表示向前传递,直到遇到序列中的新概念(香蕉),此时更新门(Γu)将允许新的候选激活值(c ̴t)替换记忆单元中的旧值(c),反映出新的复数实体,即香蕉。从数学上讲,我们可以通过定义 GRU 中激活值(ct)的计算方式来总结这一过程:
ct = ( Γu x c ̴t ) + [ ( 1- Γu ) x ct-1 ]
如我们所见,在给定的时间步,激活值由两项之和定义。第一项反映了门控值和候选值的乘积。第二项表示门控值的反向,乘以前一时间步的激活值。直观地,第一项控制是否将更新项包含在方程中,通过取 1 或 0。第二项则控制是否对上一时间步的激活(ct-1)进行中和。让我们来看一下这两项如何协同工作,以决定在给定的时间步是否进行更新。
实现不更新场景
当值(Γu)为零时,第一个项完全归零,从而去除(c ̴t)的影响,而第二个项则直接采用上一时间步的激活值:
If Γu = 0:
ct = ( 0 x c ̴t ) + ((1 - 0) x ct-1 )
   = 0 + ct-1
Therefore, ct = ct-1
在这种情况下,不进行更新,之前的激活值(ct)被保留并传递到下一个时间步。
实现更新场景
另一方面,如果门控值为1,方程式允许 c ̴t 成为新的ct值,因为第二项归零 (( 1-1) x ct-1)。这使得我们能够有效地对记忆单元进行更新,从而保持有用的时间相关表示。更新场景可以用数学公式表示如下:
If Γu = 1:
ct = ( 1 x c ̴t ) + ((1 - 1) x ct-1 )
   = c ̴t+ (0 x ct-1)
Therefore, ct = c ̴t
保持时间步之间的相关性
用于执行记忆更新的两个项的性质帮助我们在多个时间步之间保持相关信息。因此,这种实现通过使用记忆单元来建模长期依赖性,可能为解决梯度消失问题提供了一个方案。然而,你可能会想,GRU 是如何评估激活的相关性的呢?更新门简单地允许用新的候选值 (c ^(̴t)) 替代激活向量 (c^t),但我们如何知道先前的激活 (c^(t-1)) 对当前时间步的相关性呢?好吧,之前我们展示了一个简化的方程来描述 GRU 单元。它实现的最后一部分就是相关性门(Γr),它帮助我们做正如其所示的事情。因此,我们通过这个相关性门(Γr)来计算候选值 (c (̴t)*),以便在计算当前时间步的激活时,将先前时间步的激活(*c(t-1)) 的相关性纳入其中。这帮助我们评估先前时间步的激活对于当前输入序列的相关性,其实现方式非常熟悉,如下图所示:

形式化相关性门
以下方程展示了 GRU 方程的完整范围,包括现在包含在我们之前计算候选记忆值(c ̴t)中的相关性门项:
- 
之前:c ̴t = tanh ( Wc ct-1, ![ t ] + bc) 
- 
现在:c ̴t = tanh ( Wc Γr , ct-1, ![ t ] + bc) 
- 
其中:Γr = sigmoid ( Wr ct-1, ![ t ] + br) 
不出所料,(Γr)是通过初始化另一个权重矩阵 (Wr) 来计算的,并将其与过去的激活 (c^(t-1)) 和当前输入( t)进行点积,然后通过 sigmoid 激活函数求和。计算当前激活 (c^t) 的方程保持不变,唯一的不同是其中的 (c ^(̴t)) 项,它现在在计算中引入了相关性门(Γr):
 t)进行点积,然后通过 sigmoid 激活函数求和。计算当前激活 (c^t) 的方程保持不变,唯一的不同是其中的 (c ^(̴t)) 项,它现在在计算中引入了相关性门(Γr):
ct = ( Γu x c ̴t ) + [ ( 1- Γu ) x ct-1 ]
给定时间步的预测输出的计算方式与 SimpleRNN 层相同。唯一的区别是 (a^t) 被 (c^t) 替代,后者表示 GRU 层在时间步 (t) 的神经元激活:
 = softmax [ (Wcy x ct) + by ]
 = softmax [ (Wcy x ct) + by ]
从实际角度来看,at*和*ct这两个术语在 GRU 的情况下可以认为是同义的,但稍后我们会看到一些架构不再适用这种情况,例如 LSTM。暂时来说,我们已经涵盖了控制 GRU 单元中数据前向传播的基本方程。你已经看到我们如何计算每个时间步骤的激活值和输出值,并使用不同的门(例如更新门和相关性门)来控制信息流,进而评估和存储长期依赖关系。我们看到的这一实现是解决梯度消失问题的常见方式。然而,这只是潜在更多实现中的一种。自 2014 年 Kyeunghyun Cho 等人提出以来,研究人员发现这种特定的公式化实现是一种成功的方式,用于评估相关性并为各种不同问题建模顺序依赖关系。
在 Keras 中构建字符级语言模型
现在,我们已经很好地掌握了不同类型 RNN 的基本学习机制,包括简单的和复杂的。我们也了解了一些不同的序列处理用例,以及允许我们对这些序列建模的不同 RNN 架构。接下来,我们将结合所有这些知识并加以实践。接下来,我们将通过一个实际任务来测试这些不同的模型,并看看它们各自的表现如何。
我们将探索一个简单的用例,构建一个字符级语言模型,类似于几乎每个人都熟悉的自动更正模型,它被实现于几乎所有设备的文字处理应用中。一个关键的不同之处在于,我们将训练我们的 RNN 从莎士比亚的《哈姆雷特》派生语言模型。因此,我们的网络将把莎士比亚的《哈姆雷特》中的一系列字符作为输入,并反复计算序列中下一个字符的概率分布。让我们进行一些导入并加载必要的包:
from __future__ import print_function
import sys
import numpy as np
import re
import random
import pickle
from nltk.corpus import gutenberg
from keras.models import Sequential
from keras.layers import Dense, Bidirectional, Dropout
from keras.layers import SimpleRNN, GRU, BatchNormalization
from keras.callbacks import LambdaCallback
from keras.callbacks import ModelCheckpoint
from keras.utils.data_utils import get_file
from keras.utils.data_utils import get_file
加载莎士比亚的《哈姆雷特》
我们将在 Python 中使用自然语言工具包(NLTK)来导入并预处理这部戏剧,该剧本可以在gutenberg语料库中找到:
from nltk.corpus import gutenberg
hamlet = gutenberg.words('shakespeare-hamlet.txt')
text =''
for word in hamlet:            # For each word
text+=str(word).lower()        # Convert to lower case and add to string variable
text+= ' '                     # Add space   
print('Corpus length, Hamlet only:', len(text))
-----------------------------------------------------------------------
Output:
Corpus length, Hamlet only: 166765
字符串变量(text)包含了构成《哈姆雷特》这部戏剧的整个字符序列。我们现在将其拆分成更短的序列,以便在连续的时间步中将其输入到我们的递归网络。为了构建输入序列,我们将定义一个任意长度的字符序列,网络在每个时间步看到这些字符。我们将通过迭代滑动并收集字符序列(作为训练特征),以及给定序列的下一个字符(作为训练标签),从文本字符串中采样这些字符。当然,采样更长的序列可以让网络计算出更准确的概率分布,从而反映出后续字符的上下文信息。然而,这也使得计算上更加密集,既需要在训练模型时进行更多的计算,也需要在测试时生成预测。
我们的每个输入序列(x)将对应 40 个字符和一个输出字符(y1),该字符对应序列中的下一个字符。我们可以使用范围函数按段对整个字符串(text)进行分段,从而每次处理 11 个字符,并将它们保存到一个列表中,如此所示。我们可以看到,我们已经将整个剧本拆分成了大约 55,575 个字符序列。
构建字符字典
现在,我们将继续创建一个词汇表或字符字典,用于将每个字符映射到一个特定的整数。这是我们能够将这些整数表示为向量的必要步骤,这样我们就可以在每个时间步将它们顺序输入到网络中。我们将创建两种版本的字典:一种是字符映射到索引,另一种是索引映射到字符。
这只是出于实用性考虑,因为我们需要这两个列表作为参考:
characters = sorted(list(set(text)))
print('Total characters:', len(characters))
char_indices = dict((l, i) for i, l in enumerate(characters))
indices_char = dict((i, l) for i, l in enumerate(characters))
-----------------------------------------------------------------------
Output:
Total characters= 65
你可以通过检查映射字典的长度来查看你的词汇量有多大。在我们的例子中,似乎有66个独特的字符组成了《哈姆雷特》这部戏剧的序列。
准备字符训练序列
在构建好我们的字符字典之后,我们将把构成《哈姆雷特》文本的字符拆分成一组序列,可以将这些序列输入到我们的网络中,并为每个序列提供一个对应的输出字符:
'''
Break text into :
Features  -    Character-level sequences of fixed length        
Labels    -    The next character in sequence     
'''
training_sequences = []          # Empty list to collect each sequence
next_chars = []                  # Empty list to collect next character in sequence
seq_len, stride = 35, 1    # Define lenth of each input sequence & stride to move before sampling next sequence
for i in range(0, len(text) - seq_len, stride):     # Loop over text with window of 35 characters, moving 1 stride at a time
training_sequences.append(text[i: i + seq_len]) # Append sequences to traning_sequences
next_chars.append(text[i + seq_len])            # Append following character in sequence to next_chars
我们创建了两个列表,并遍历了我们的文本字符串,每次附加一个 40 个字符的序列。一个列表保存训练序列,另一个列表保存紧随其后的下一个字符,即序列的下一个字符。我们实现了一个任意的序列长度 40,但你可以自由尝试不同的值。请记住,设置太小的序列长度将无法让你的网络看到足够远的信息来做出预测,而设置过大的序列长度可能会让你的网络很难收敛,因为它无法找到最有效的表示方式。就像《金发姑娘与三只小熊》的故事一样,你的目标是找到一个恰到好处的序列长度,这可以通过实验和/或领域知识来决定。
打印示例序列
同样地,我们也可以任意选择通过每次一个字符的窗口来遍历我们的文本文件。这意味着我们可以多次采样每个字符,就像我们的卷积滤波器通过固定步长逐步采样整个图像一样:
# Print out sequences and labels to verify
print('Number of sequences:', len(training_sequences))
print('First sequences:', training_sequences[:1])
print('Next characters in sequence:', next_chars[:1])
print('Second sequences:', training_sequences[1:2])
print('Next characters in sequence:', next_chars[1:2])
-----------------------------------------------------------------------
Output 
Number of sequences: 166730
First sequences: ['[ the tragedie of hamlet by william']
Next characters in sequence: [' ']
Second sequences: [' the tragedie of hamlet by william ']
Next characters in sequence: ['s']
这里的不同之处在于,我们将这个操作顺序地嵌入到训练数据本身,而不是让一个层在训练过程中执行步进操作。这对于文本数据来说是一种更简单(且更合乎逻辑)的做法,因为文本数据很容易操作,可以从整个《哈姆雷特》的文本中按照所需的步长生成字符序列。正如我们所看到的,我们的每个列表现在存储的是按步长三步采样的字符串序列,来自原始的文本字符串。我们打印出了训练数据的第一个和第二个序列及其标签,这展示了其排列的顺序性。
向量化训练数据
下一步是你已经非常熟悉的步骤。我们将通过将训练序列列表转换为表示 one-hot 编码训练特征的三维张量,并附上相应的标签(即序列中接下来的词语),来简单地向量化我们的数据。特征矩阵的维度可以表示为(时间步 x 序列长度 x 字符数)。在我们的案例中,这意味着 55,575 个序列,每个序列长度为 40。因此,我们的张量将由 55,575 个矩阵组成,每个矩阵有 40 个 66 维的向量,彼此堆叠在一起。在这里,每个向量代表一个字符,位于一个 40 个字符的序列中。它有 66 个维度,因为我们已将每个字符作为一个零向量进行了 one-hot 编码,1 位于我们字典中该字符的索引位置:
#Create a Matrix of zeros
# With dimensions : (training sequences, length of each sequence, total unique characters)
x = np.zeros((len(training_sequences), seq_len, len(characters)), dtype=np.bool)
y = np.zeros((len(training_sequences), len(characters)), dtype=np.bool)
for index, sequence in enumerate(training_sequences):     #Iterate over training sequences
for sub_index, chars in enumerate(sequence):          #Iterate over characters per sequence
x[index, sub_index, char_indices[chars]] = 1      #Update character position in feature matrix to 1
y[index, char_indices[next_chars[index]]] = 1         #Update character position in label matrix to 1
print('Data vectorization completed.')
print('Feature vectors shape', x.shape)
print('Label vectors shape', y.shape)
-----------------------------------------------------------------------
Data vectorization completed. 
Feature vectors shape (166730, 35, 43) 
Label vectors shape (166730, 43)
字符建模的统计数据
我们通常将单词和数字区分为不同的领域。实际上,它们并没有那么远。任何东西都可以通过数学的普遍语言进行解构。这是我们现实中的一个幸运属性,不仅仅是为了建模统计分布在字符序列上的愉悦。然而,既然我们已经讨论到这个话题,我们将继续定义语言模型的概念。从本质上讲,语言模型遵循贝叶斯逻辑,将后验事件的概率(或未来可能出现的标记)与先前事件的发生(已经出现的标记)联系起来。基于这样的假设,我们能够构建一个特征空间,表示一定时间内单词的统计分布。我们接下来将构建的 RNN 将为每个模型构建一个独特的概率分布特征空间。然后,我们可以将一个字符序列输入到模型中,并递归地使用该分布方案生成下一个字符。
建模字符级概率
在自然语言处理(NLP)中,字符串的单位称为标记。根据你希望如何预处理字符串数据,你可以选择单词标记或字符标记。在本例中,我们将使用字符标记,因为我们的训练数据已设置为让网络一次预测一个字符。因此,给定一个字符序列,我们的网络会为每个字符在词汇表中的概率分配一个 Softmax 分数。在我们的例子中,最初《哈姆雷特》中总共有 66 个字符,包括大写字母和小写字母,这对于当前任务来说有些冗余。因此,为了提高效率并减少 Softmax 分数的数量,我们会通过将《哈姆雷特》文本转换为小写来缩减训练词汇量,从而得到 44 个字符。这意味着在每次网络预测时,它会生成一个 44 维的 Softmax 输出。我们可以选择具有最大分数的字符(也就是进行贪婪采样),并将其添加到输入序列中,然后让网络预测接下来应该是什么。RNN 能够学习英语单词的一般结构,以及标点符号和语法规则,甚至能够创造新颖的序列,从酷炫的名字到可能具有生命拯救潜力的分子化合物,这取决于你输入给它的序列。事实上,RNN 已被证明能够捕捉分子表示法的句法,并且可以微调以生成特定的分子目标。这在药物发现等任务中为研究人员提供了极大的帮助,也是一个充满活力的科学研究领域。有关进一步的阅读,请查看以下链接:
www.ncbi.nlm.nih.gov/pubmed/29095571
采样阈值
为了能够生成类似莎士比亚风格的句子,我们需要设计一种方式来采样我们的概率分布。这些概率分布由我们模型的权重表示,并且在训练过程中会在每个时间步不断变化。采样这些分布就像是在每个训练周期结束时窥视网络对莎士比亚文本的理解。我们基本上是使用模型学习到的概率分布来生成一系列字符。此外,根据我们选择的采样策略,我们有可能在生成的文本中引入一些受控的随机性,以迫使模型生成一些新颖的序列。这可能会导致一些有趣的表达方式,实际上非常有娱乐性。
控制随机性的目的
采样背后的主要概念是如何选择控制随机性(或称随机性)来从可能字符的概率分布中选择下一个字符。不同的应用可能会要求不同的方法。
贪心采样
如果你正在尝试训练一个用于自动文本完成和修正的 RNN,使用贪心采样策略可能会更有效。这意味着,在每次采样时,你会根据 Softmax 输出分配给某个字符的最高概率来选择下一个字符。这样可以确保你的网络输出的预测很可能是你最常用的单词。另一方面,当你训练一个 RNN 来生成酷炫的名字、模仿某个人的书写风格,甚至生成未发现的分子化合物时,你可能会希望采用更分层的采样方法。在这种情况下,你并不希望选择最可能出现的字符,因为这会显得很无聊。我们可以通过以概率的方式选择下一个字符,而不是固定的方式,从而引入一些受控的随机性(或随机因素)。
随机采样
一种方法可能是,在选择下一个字符时,不仅仅依赖于 Softmax 输出值,而是对这些输出值的概率分布进行重新加权。这让我们能够做一些事情,比如为我们词汇表中的任何字符分配一个按比例的概率分数,使其成为下一个被选择的字符。举个例子,假设某个字符的下一个字符概率被分配为 0.25。那么我们将有四分之一的概率选择它作为下一个字符。通过这种方式,我们能够系统地引入一些随机性,这会产生富有创意且逼真的人工词汇和序列。在生成模型的领域中,通过引入随机性进行探索往往能带来有用的信息,正如我们将在后续章节中看到的那样。现在,我们将通过引入采样阈值来实现控制随机性的引入,从而重新分配我们模型的 Softmax 预测概率,arxiv.org/pdf/1308.0850.pdf:
def sample(softmax_predictions, sample_threshold=1.0):   
softmax_preds = np.asarray(softmax_predictions).astype('float64')    
# Make array of predictions, convert to float
log_preds = np.log(softmax_preds) / sample_threshold                 
# Log normalize and divide by threshold
exp_preds = np.exp(log_preds)                                        
# Compute exponents of log normalized terms
norm_preds = exp_preds / np.sum(exp_preds)                           
# Normalize predictions
prob = np.random.multinomial(1, norm_preds, 1)                       
# Draw sample from multinomial distribution
return np.argmax(prob)                                               #Return max value
这个阈值表示我们将使用的概率分布的熵,用于从我们的模型中采样给定的生成结果。较高的阈值会对应较高熵的分布,导致看起来不真实且缺乏结构的序列。另一方面,较低的阈值则会简单地编码英语语言的表示和形态,生成熟悉的单词和术语。
测试不同的 RNN 模型
现在,我们已经将训练数据预处理并准备好以张量格式呈现,可以尝试一种与前几章略有不同的方法。通常,我们会构建一个模型,然后开始训练它。相反,我们将构建几个模型,每个模型反映不同的 RNN 架构,并依次训练它们,看看每个模型在生成字符级别序列任务中的表现如何。从本质上讲,这些模型将利用不同的学习机制,并根据它们看到的字符序列来推导出其相应的语言模型。然后,我们可以从每个网络学习到的语言模型中进行采样。事实上,我们甚至可以在训练周期之间对我们的网络进行采样,看看我们的网络在每个周期生成莎士比亚短语的表现如何。在我们继续构建网络之前,必须先了解一些基本策略,以指导我们的语言建模和采样任务。然后,我们将构建一些 Keras 回调函数,允许我们在模型训练过程中与其进行交互并进行采样。
使用自定义回调函数生成文本
接下来,我们将构建一个自定义的 Keras 回调函数,允许我们使用刚才构建的采样函数,在每个训练周期结束时迭代地探测我们的模型。如你所记得,回调函数是一类可以在训练过程中对我们的模型执行操作(如保存和测试)的函数。这些函数对于可视化模型在训练过程中的表现非常有用。本质上,这个函数将从《哈姆雷特》文本中随机选择一段字符,然后根据给定的输入生成 400 个后续字符。它会对每个选择的五个采样阈值执行此操作,并在每个周期结束时打印出生成的结果:
def on_epoch_end(epoch, _):
global model, model_name
print('----- Generating text after Epoch: %d' % epoch)
start_index = random.randint(0, len(text) - seq_len - 1)    
# Random index position to start sample input sequence
end_index = start_index + seq_len                           
# End of sequence, corresponding to training sequence length
sampling_range = [0.3, 0.5, 0.7, 1.0, 1.2]                  
# Sampling entropy threshold
for threshold in sampling_range:print('----- *Sampling Threshold* :', threshold)
generated = ''                                          
# Empty string to collect sequence
sentence = text[start_index: end_index]                 
# Random input sequence taken from Hamlet
generated += sentence                                  
 # Add input sentence to generated
print('Input sequence to generate from : "' + sentence + '"')     
sys.stdout.write(generated)                            
# Print out buffer instead of waiting till the end
for i in range(400):                                   
# Generate 400 next characters in the sequence
x_pred = np.zeros((1, seq_len, len(characters)))   
# Matrix of zeros for input sentence
for n, char in enumerate(sentence):                
# For character in sentence
x_pred[0, n, char_indices[char]] = 1\.          
# Change index position for character to 1.
preds = model.predict(x_pred, verbose=0)[0]        
# Make prediction on input vector
next_index = sample(preds, threshold)              
# Get index position of next character using sample function
next_char = indices_char[next_index]               
# Get next character using index
generated += next_char                             
# Add generated character to sequence
sentence = sentence[1:] + next_char
sys.stdout.write(next_char)
sys.stdout.flush()
-----------------------------------------------------------------------
Output: 
print_callback = LambdaCallback(on_epoch_end=on_epoch_end)
测试多个模型
我们列表上的最后一个任务是构建一个辅助函数,该函数将训练、采样并保存一系列 RNN 模型。这个函数还会保存我们之前用于绘制每个时期的损失和准确度值的历史对象,如果你以后想要探索不同模型及其相对表现时,这会非常有用:
def test_models(list, epochs=10):
    global model, model_name
    for network in list:   
        print('Initiating compilation...')
        # Initialize model
        model = network()
        # Get model name
        model_name = re.split(' ', str(network))[1]  
        #Filepath to save model with name, epoch and loss 
        filepath = "C:/Users/npurk/Desktop/Ch5RNN/all_models/versions/%s_epoch-{epoch:02d}-loss-{loss:.4f}.h5"%model_name
        #Checkpoint callback object 
        checkpoint = ModelCheckpoint(filepath, monitor='loss', verbose=0, save_best_only=True, mode='min')
        # Compile model
        model.compile(loss='categorical_crossentropy', optimizer='adam')
        print('Compiled:', str(model_name))
        # Initiate training
        network = model.fit(x, y,
              batch_size=100,
              epochs=epochs,
              callbacks=[print_callback, checkpoint])
        # Print model configuration
        model.summary()
        #Save model history object for later analysis
        with open('C:/Users/npurk/Desktop/Ch5RNN/all_models/history/%s.pkl'%model_name, 'wb') as file_pi:
            pickle.dump(network.history, file_pi)
test_models(all_models, epochs=5)
现在,我们终于可以继续构建几种类型的 RNN 并用辅助函数训练它们,看看不同类型的 RNN 在生成类似莎士比亚的文本时表现如何。
构建 SimpleRNN
Keras 中的 SimpleRNN 模型是一个基本的 RNN 层,类似于我们之前讨论的那些。虽然它有许多参数,但大多数已经设置了非常优秀的默认值,适用于许多不同的使用场景。由于我们已经将 RNN 层初始化为模型的第一层,因此必须为其提供输入形状,表示每个序列的长度(我们之前选择为 40 个字符)和我们数据集中的独特字符数量(为 44)。尽管这个模型在计算上非常紧凑,但它严重受到了我们之前提到的梯度消失问题的影响。因此,它在建模长期依赖关系时存在一定问题:
from keras.models import Sequential
from keras.layers import Dense, Bidirectional, Dropout
from keras.layers import SimpleRNN, GRU, BatchNormalization
from keras.optimizers import RMSprop
'''Fun part: Construct a bunch of functions returning different kinds of RNNs, from simple to more complex'''
def SimpleRNN_stacked_model():
    model = Sequential()
    model.add(SimpleRNN(128, input_shape=(seq_len, len(characters)), return_sequences=True))
    model.add(SimpleRNN(128))
    model.add(Dense(len(characters), activation='softmax'))
    return model
请注意,这个两层模型的最终密集层的神经元数量与我们数据集中 44 个独特字符的数量相对应。我们为其配备了 Softmax 激活函数,它将在每个时间步输出一个 44 维的概率分数,表示每个字符后续出现的可能性。我们为这个实验构建的所有模型都将具有这个共同的最终密集层。最后,所有 RNN 都有保持状态的能力。这只是指将层的权重传递到后续序列的计算中。这个特性可以在所有 RNN 中显式设置,使用stateful参数,该参数接受布尔值,并可以在初始化层时提供。
堆叠 RNN 层
为什么只要一个,而不试试两个呢?Keras 中的所有递归层可以根据你想要实现的目标返回两种不同类型的张量。你可以选择接收一个维度为 (batch_size,time_steps,output_features) 的三维张量输出,或者仅返回一个维度为 (time_steps,output_features) 的二维张量。如果我们希望模型返回每个时间步的完整输出序列,我们就查询三维张量。如果我们想要将一个 RNN 层堆叠到另一个层上,并要求第一层返回所有的激活值给第二层,这时返回整个激活值就显得特别重要。返回所有激活值本质上意味着返回每个具体时间步的激活值。这些值可以随后输入到另一个递归层,以从相同的输入序列中编码出更高层次的抽象表示。以下图示展示了将布尔参数设置为 True 或 False 的数学效果:

将其设置为 true 将简单地返回每个时间步的预测张量,而不是仅返回最后一个时间步的预测。堆叠递归层非常有用。通过将 RNN 层一个接一个地堆叠在一起,我们可以潜在地增加网络的时间依赖表示能力,使其能够记住数据中可能存在的更抽象模式。
另一方面,如果我们只希望它返回每个输入序列在最后一个时间步的输出,我们可以要求它返回一个二维张量。当我们希望进行实际的预测,预测在我们词汇表中下一个最可能的字符时,这是必要的。我们可以通过 return_sequences 参数来控制这个实现,传递该参数时我们添加一个递归层。或者,我们也可以将其设置为 false,使得模型仅返回最后一个时间步的激活值,并可以将这些值向前传播用于分类:
def SimpleRNN_stacked_model():
    model = Sequential()
    model.add(SimpleRNN(128, input_shape=(seq_len, len(characters)), return_sequences=True))
    model.add(SimpleRNN(128))
    model.add(Dense(len(characters), activation='softmax'))
    return model
请注意,return_sequences 参数只能用于倒数第二个隐藏层,而不能用于连接到输出层之前的隐藏层,因为输出层只负责分类下一个即将到来的序列。
构建 GRU
GRU 在缓解梯度消失问题方面表现优异,是建模长期依赖关系(如语法、标点符号和词形变化)的良好选择:
def GRU_stacked_model():
    model = Sequential()
    model.add(GRU(128, input_shape=(seq_len, len(characters)), return_sequences=True))
    model.add(GRU(128))
    model.add(Dense(len(characters), activation='softmax'))
    return model
就像 SimpleRNN 一样,我们在第一层定义输入的维度,并将一个三维张量输出到第二个 GRU 层,这将有助于保留我们训练数据中更复杂的时间依赖表示。我们还将两个 GRU 层堆叠在一起,以查看我们模型增强后的表示能力带来了什么:

希望这一架构能够生成逼真且新颖的文本序列,即使是莎士比亚的专家也无法分辨与真实的文本有何不同。让我们通过以下图示来可视化我们所构建的模型:
请注意,我们还在之前构建的训练函数中加入了model.summary()这一行代码,以便在模型训练完成后,直观地展示模型的结构。
构建双向 GRU
接下来,我们要测试的模型是另一个 GRU 单元,但这一次有所不同。我们将它嵌套在一个双向层内,这使得我们能够同时以正常顺序和反向顺序喂入数据。在这种方式下,我们的模型能够看到未来的内容,利用未来的序列数据来为当前时间步做出预测。双向处理序列的方式大大增强了从数据中提取的表示。事实上,处理序列的顺序可能会对学习到的表示类型产生显著影响。
顺序处理现实
改变处理顺序的概念是一个相当有趣的命题。我们人类显然似乎偏好某种特定的学习顺序。以下图片中复制的第二句话对我们来说根本没有意义,尽管我们确切知道句子中的每一个单词是什么意思。同样,许多人难以倒背字母表,尽管我们对每个字母都非常熟悉,并用它们构造出更加复杂的概念,如单词、想法,甚至是 Keras 代码:

我们的顺序偏好很可能与我们现实的性质有关,现实本身就是顺序的,并且是前进的。归根结底,我们大脑中大约 10¹¹个神经元的配置是由时间和自然力量精心设计的,以最佳方式编码和表示我们每一秒钟所接触到的时间相关的感官信号。可以推测,我们的大脑神经架构有效地实现了一个机制,倾向于按特定顺序处理信号。然而,这并不是说我们不能与已学的顺序分道扬镳,因为许多学前儿童会挑战背诵字母表的倒序,并且做得相当成功。其他顺序性任务,例如听自然语言或节奏感强的音乐,可能更难以倒序处理。但别光听我说,试试将你最喜欢的歌曲倒放,看看你是否还能像以前一样喜欢它。
重新排列顺序数据的好处
在某种程度上,双向网络似乎能够潜在地克服我们在处理信息时的偏见。正如你所看到的,它们能够学习那些我们原本没有想到要包括的有用表示,从而帮助并增强我们的预测。是否处理一个特定信号、以及如何处理,完全取决于该信号在当前任务中的重要性。在我们之前的自然语言例子中,这一点对于确定词性(POS)标签尤为重要,比如单词Spartan:

Keras 中的双向层
因此,Keras 中的双向层同时按正常顺序和反向顺序处理数据序列,这使得我们能够利用序列后续的词汇信息来帮助当前时间点的预测。
本质上,双向层会复制任何输入给它的层,并使用其中一个副本按正常顺序处理信息,而另一个副本则按相反顺序处理数据。是不是很酷?我们可以通过一个简单的例子直观地理解双向层究竟是如何工作的。假设你在用双向 GRU 模型处理两个单词的序列Whats up:

为此,你需要将 GRU 嵌套在一个双向层中,这样 Keras 就能够生成双向模型的两个版本。在前面的图像中,我们将两个双向层叠加在一起,然后将它们连接到一个密集输出层,正如我们之前所做的那样:
def Bi_directional_GRU():
    model = Sequential()
    model.add(Bidirectional(GRU(128, return_sequences=True), input_shape=(seq_len, len(characters))))
    model.add(Bidirectional(GRU(128)))
    model.add(Dense(len(characters), activation='softmax'))
    return model
正常顺序处理序列的模型以红色表示。同样,蓝色模型按相反顺序处理相同的序列。这两个模型在每个时间步共同协作,生成针对当前时间步的预测输出。我们可以看到这两个模型是如何接收输入值并一起工作,生成预测输出( ,这对应我们输入的两个时间步):
,这对应我们输入的两个时间步):

控制前向传播信息的方程可以稍作修改,以适应数据从正向和反向序列层进入 RNN 的情况,且每个时间步都会如此。误差的反向传播仍然以相同的方式进行,并针对每个 GRU 层的方向(红色和蓝色)进行处理。在以下的公式中,我们可以看到如何使用来自正向和反向序列层的激活值来计算给定时间步(t)的预测输出( ):
):

这里的激活和权重矩阵仅由模型在双向层内嵌套定义。正如我们之前看到的,它们将在第一次时间步初始化,并通过时间反向传播误差进行更新。因此,这些是实现双向网络的过程,双向网络是一种无环网络,预测信息由前向和后向流动的信息共同提供,这与序列的顺序相对应。实现双向层的一个关键缺点是,我们的网络需要在能够进行预测之前看到整个数据序列。在语音识别等用例中,这会成为问题,因为我们必须确保目标在进行预测之前已经停止说话,以便将每个声音字节分类为一个单词。解决这个问题的一种方法是对输入序列进行迭代预测,并在新的信息流入时,迭代更新之前的预测。
实现递归丢弃
在前面的章节中,我们看到我们可以随机丢弃一些神经元的预测,以便更好地分布我们网络中的表示,避免过拟合问题。虽然我们当前的任务在过拟合方面没有太大的负面影响,但我们还是简要介绍了在 RNN 中缓解过拟合的具体情况。这将帮助我们的模型更好地生成新的序列,而不是从训练数据中复制粘贴片段。
然而,单纯地添加一个普通丢弃层并不能解决问题。它引入了过多的随机性。这通常会阻止我们的模型收敛到理想的损失值,并编码出有用的表示。另一方面,似乎有效的做法是,在每个时间步应用相同的丢弃方案(或掩码)。这与经典的丢弃操作不同,后者会在每个时间步随机丢弃神经元。我们可以使用这种递归丢弃技术来捕获正则化的表示,因为在时间上保持一个恒定的丢弃掩码。这是帮助防止递归层过拟合的最重要技术之一,通常被称为递归丢弃策略。这样做本质上允许我们的模型有代表性地编码顺序数据,而不会通过随机丢弃过程丢失宝贵的信息:
def larger_GRU():
    model = Sequential()
    model.add(GRU(128, input_shape=(seq_len, len(characters)),
                       dropout=0.2,
                       recurrent_dropout=0.2,
                       return_sequences=True))
    model.add(GRU(128, dropout=0.2,
                  recurrent_dropout=0.2,
                  return_sequences=True))
    model.add(GRU(128, dropout=0.2,
                  recurrent_dropout=0.2))
    model.add(Dense(128, activation='relu'))
    model.add(Dense(len(characters), activation='softmax'))
    return model
# All defined models
all_models = [SimpleRNN_model,
              SimpleRNN_stacked_model,
              GRU_stacked_model,
              Bi_directional_GRU, 
              Bi_directional_GRU,
              larger_GRU]
Keras 的设计者们已经友好地实现了两个与 dropout 相关的参数,可以在构建递归层时传入。recurrent_dropout参数接受一个浮动值,表示同一 dropout 掩码将应用于神经元的比例。您还可以指定进入递归层的输入值的比例,以随机丢弃部分数据,从而控制数据中的随机噪声。这可以通过传递一个浮动值给 dropout 参数(与recurrent_dropout不同)来实现,同时定义 RNN 层。
作为参考,您可以阅读以下论文:
- 
递归神经网络中 dropout 的理论基础应用: arxiv.org/pdf/1512.05287.pdf
输出值的可视化
为了娱乐,我们将展示一些来自我们自己训练实验中的更有趣的结果,作为本章的结尾。第一个截图显示了我们 SimpleRNN 模型在第一个训练周期结束时生成的输出(请注意,输出显示了第一个周期作为周期 0)。这只是一个实现问题,表示在n个周期的范围内的第一个索引位置。如我们所见,即使在第一个周期之后,SimpleRNN 似乎已经掌握了单词的形态学,并且在较低的采样阈值下生成了真实的英文单词。
这正是我们所预期的。同样,较高熵值的样本(例如阈值为 1.2)会产生更多的随机结果,并生成(从主观角度来看)听起来有趣的单词(如eresdoin,harereus,和nimhte):

可视化较重 GRU 模型的输出
在以下截图中,我们展示了我们较重的 GRU 模型的输出,该模型在经过两个训练周期后才开始生成类似莎士比亚的字符串。它甚至时不时地提到《哈姆雷特》的名字。请注意,网络的损失并不是我们示例中最好的评估指标。这里展示的模型的损失为 1.3,这仍然远远低于我们通常要求的水平。当然,您可以继续训练您的模型,以生成更加易于理解的莎士比亚式片段。然而,在这个用例中,使用损失指标来比较任何模型的表现就像是在比较苹果和橘子。直观上,损失接近零仅意味着模型已经记住了莎士比亚的《哈姆雷特》,而不会像我们希望的那样生成新颖的序列。最终,您才是生成任务(如本任务)的最佳评判者:

总结
在本章中,我们了解了循环神经网络及其在处理序列时间相关数据方面的适用性。您学到的概念现在可以应用于您可能遇到的任何时间序列数据集。虽然这对股票市场数据和自然时间序列等用例确实有效,但指望仅通过输入网络实时价格变动来获得奇妙的结果是不合理的。这仅仅因为影响股票市场价格的因素(如投资者看法、信息网络和可用资源)远未达到足够的水平,以允许适当的统计建模。关键在于以最可学习的方式表达所有相关信息,以使您的网络能够成功编码其中的有价值表征。
尽管我们广泛探讨了几种类型的 RNN 背后的学习机制,我们还在 Keras 中实现了一个生成建模用例,并学习构建自定义回调函数,使我们能够在每个 epoch 结束时生成数据序列。由于空间限制,我们不得不在这一章节中遗漏了一些概念。然而,请放心,这些将在接下来的章节中详细阐述。
在接下来的章节中,我们将更深入地了解一种非常流行的循环神经网络架构,称为LSTM 网络,并将其实现到其他令人兴奋的用例中。这些网络像 RNN 一样多才多艺,使我们能够生成非常详细的语言统计模型,适用于语音和实体识别、翻译以及机器问答等场景。对于自然语言理解,LSTMs(以及其他 RNNs)通常通过利用词嵌入等概念来实现,词嵌入是能够编码其语义含义的密集词向量。LSTMs 在生成诸如音乐片段等新颖序列方面表现也更为出色,但愿您也能亲自聆听。我们还将简要探讨注意力模型背后的直觉,并在后续章节中更详细地重新审视这一概念。
最后,在结束本章之前,我们将注意到 RNNs 与我们在前几章中提到的一种 CNN 类型之间的相似性。当建模时间序列数据时,RNNs 是一个流行的选择,但一维卷积层(Conv1D)也能胜任。这里的缺点来自于 CNN 处理输入值时的独立性,而不是顺序性。正如我们将看到的那样,我们甚至可以通过结合卷积和循环层来克服这一点。这使得前者能够在将减少的表示传递到 RNN 层进行顺序处理之前对输入序列进行某种预处理。但稍后会详细讨论这一点。
进一步阅读
- 
GRUs: arxiv.org/abs/1412.3555
- 
神经机器翻译: arxiv.org/abs/1409.1259
练习
- 
在《哈姆雷特》文本上训练每个模型,并使用它们的历史对象比较它们的相对损失。哪个模型收敛得更快?它们学到了什么? 
- 
检查在不同熵分布下生成的样本,并在每个训练轮次中观察每个 RNN 如何随着时间推移改进其语言模型。 
第六章:长短期记忆网络
“当我年轻时,我常常思考自己的人生该做什么。对我来说,最激动人心的事情似乎是能够解开宇宙的谜团。这意味着要成为一名物理学家。然而,我很快意识到,也许有更宏伟的目标。假如我能尝试去构建一台机器,让它成为比我任何时候都更优秀的物理学家呢?也许,这就是我能将自己微不足道的创造力,扩展到永恒的方法。”
– Jeurgen Schmidthuber,长短期记忆网络的共同发明人
在 1987 年的毕业论文中,Schmidthuber 提出了一种元学习机制的理论,该机制能够检查自身的学习算法,并随后对其进行修改,以有效地优化所使用的学习机制。这个想法意味着将学习空间开放给系统本身,以便它能够在看到新数据时不断改进自身的学习:可以说是一个“学习如何学习”的系统。Schmidthuber 甚至将这台机器命名为 Gödel 机器,命名灵感来自于递归自我改进算法背后的数学概念创始人 Gödel。不幸的是,我们至今尚未构建出 Schmidthuber 描述的那种自学习的通用问题解决器。然而,这可能并不像你想象的那么令人失望。有人可能会认为,鉴于当前人类事务的状态,自然界本身还未成功构建出这样的系统。
另一方面,Schmidthuber 和他的同事确实成功开发了一些相当新颖的东西。我们当然指的是长短期记忆(LSTM)网络。有趣的是,LSTM 在很多方面是门控递归单元(GRU)的“哥哥”。LSTM 网络不仅比 GRU(Cho 等人,2014)早(Hochreiter 和 Schmidthuber,1997)提出,而且它的计算复杂度也更高。虽然计算负担较重,但与我们之前看到的其他递归神经网络(RNN)相比,它在长期依赖建模方面带来了大量的表示能力。
LSTM 网络为我们早前回顾过的梯度爆炸和梯度消失问题提供了一种更为复杂的解决方案。你可以将 GRU 看作是 LSTM 的简化版本。
以下是本章将涉及的主题:
- 
LSTM 网络 
- 
剖析 LSTM 
- 
LSTM 记忆块 
- 
可视化信息流动 
- 
计算竞争者记忆 
- 
LSTM 的变种及其性能 
- 
理解窥视孔连接 
- 
时机与计数的重要性 
- 
将我们的知识付诸实践 
- 
关于建模股市数据 
- 
数据去噪 
- 
实现指数平滑 
- 
一步预测问题 
- 
创建观察序列 
- 
构建 LSTM 
- 
结束语 
处理复杂序列
在上一章中,我们讨论了人类如何倾向于按顺序处理事件。我们将日常任务分解成一系列较小的行动,而不会过多考虑。当你早上起床时,可能会选择先去洗手间,再做早餐。在洗手间,你可能会先洗澡,然后刷牙。有些人可能会选择同时完成这两个任务。通常,这些选择归结为个人偏好和时间限制。从另一个角度来看,我们做事的方式往往与大脑如何选择表示这些相对任务的重要性有关,这种选择是由它对近过去和远过去保存的信息所支配的。例如,当你早上醒来时,如果你住在一个有共享水供应的公寓楼里,你可能会倾向于先洗澡。
另一方面,如果你知道你的邻居正在度假,你可能会在某些日子推迟完成某些任务。事实证明,我们的大脑非常擅长选择、减少、分类并提供最有利的信息,以便对周围世界做出预测。
分解记忆
我们人类的大脑中有层次化的神经元,聚集在特定的区域,负责维护我们可能感知到的各种重要事件的详细且独特的表示。例如,考虑到颞叶,它包含了负责我们陈述性记忆或长期记忆的结构。这一部分通常被认为构成了我们对事件的意识回忆的范围。它提醒我们在世界的心理模型中,所有正在发生的一般事件,形成了对这些事件的语义记忆(有关其语义事实)以及事件发生的回忆(在情节记忆中)。一个语义事实可能是水的分子化合物由一个氢原子和两个氧原子组成。相反,一个情节事实可能是某个水池的水被污染了,因此不能饮用。记忆中的这些区分帮助我们有效地应对信息丰富的环境,使我们能够做出决策,优化我们可能有的任何目标。而且,有人甚至可能认为,做出这样的区分来划分信息,对于处理复杂的时间依赖数据序列至关重要。
最终,我们需要保持预测模型在长时间内的相关性,无论是用于创建互动聊天机器人,还是预测股价的走势。保持相关性不仅仅意味着了解最近发生了什么,还需要知道历史是如何展开的。毕竟,正如老话所说,历史往往会重演。因此,保持对所谓历史的记忆表示是很有用的。正如我们即将看到的,LSTM 正是为了实现这一目标而设计的。
LSTM 网络
看啊,LSTM 架构。这一模型以其复杂的信息路径和门控机制而著称,能够从它所接收到的输入中学习时间依赖的有意义表示。下图中的每一条线代表一个向量从一个节点传递到另一个节点,方向由箭头指示。当这些线条分开时,它们所携带的值会被复制到每一条路径上。来自前一时间步的记忆从单元的左上方进入,而来自前一时间步的激活值则从左下角进入。
这些框表示学习到的权重矩阵与某些通过激活函数传递的输入的点积。圆圈表示逐点操作,如逐元素向量乘法(*)或加法(+):

在上一章,我们看到了 RNN 如何通过时间上的反馈连接来存储近期输入的表示,通过激活值。这些激活值本质上可以被视为单元的短期记忆,因为它们主要受紧接着的前一时间步激活值的影响。遗憾的是,梯度消失问题使得我们无法利用发生在非常早期时间步(长期记忆)的信息来指导后续的预测。我们看到,构成隐藏状态的权重倾向于衰减或爆炸,因为误差在更多时间步中进行反向传播。我们该如何解决这个问题?我们如何才能有效地让信息流经时间步,像是让它流动,来影响序列中后期的预测?答案,当然,来自 Hochreiter 和 Schmidthuber,他们提出了在 RNN 中同时使用长期记忆 (c^((t-1))) 和短期记忆 (a^((t-1))) 的方法。
这种方法使得它们能够有效克服在长序列中进行相关预测的问题,通过实现一种能够有效保存远程事件相关记忆的 RNN 设计。实际上,这是通过采用一组信息门来完成的,这些信息门在保存和传递细胞状态方面表现出色,细胞状态编码了来自遥远过去的相关表示。这一重大突破已被证明适用于多种应用场景,包括语音处理、语言建模、非马尔可夫控制和音乐生成。
这里提供了进一步阅读的来源:
- 原始 LSTM 论文 Hochreiter 和 Schmidthuber:www.bioinf.jku.at/publications/older/2604.pdf
解构 LSTM
如前所述,LSTM 架构依赖于一系列门,这些门可以独立地影响来自前一个时间步的激活值(a((t-1))*)以及记忆值(*c(**^(t-1)))。这些值在信息流经 LSTM 单元时被转化,最终在每次迭代中输出当前时间步的激活(at*)和记忆(*ct)向量。虽然它们的早期版本分别进入单元,但它们允许以两种大致的方式相互作用。在下面的图示中,门(用大写希腊字母 gama,或 Γ 表示)代表了对它们各自初始化的权重矩阵与先前激活和当前输入的点积应用的 sigmoid 激活函数:

比较最接近的已知亲戚
让我们尝试通过运用我们之前对 GRU 架构的知识来理解 LSTM 是如何工作的,我们在上一章中已经看到过它。正如我们很快会发现的那样,LSTM 只是 GRU 的一个更复杂版本,尽管它遵循了与 GRU 操作相同的基本原理。
GRU 记忆
记得 GRU 架构是通过更新门利用两个向量来计算其单元状态(或记忆)的。这两个向量分别是来自先前时间步的激活(c****t-1),以及一个候选向量(c ̴****t)。候选向量在每个时间步表现为当前单元状态的候选者,而激活则代表了 GRU 从前一个时间步的隐藏状态。这两个向量对当前单元状态的影响程度由更新门决定。这个门控制信息流,允许记忆单元用新的表示来更新自身,从而为后续的预测提供相关的信息。通过使用更新门,我们能够计算出给定时间步的新单元状态(c^t),如下所示:

正如我们所观察到的,GRU 使用更新门(Γu)及其逆门(1- Γu)来决定是用新值(c ̴****t**)更新记忆单元,还是保留前一个时间步的旧值(**c****(t-1))。更重要的是,GRU 利用一个更新门及其逆值来控制记忆值(c^t)。LSTM 架构则提出了一种更复杂的机制,并且在核心部分使用与 GRU 架构类似的方程来维持相关状态。但它到底是如何做到的呢?
LSTM 记忆单元
在下面的图示中,您会注意到 LSTM 单元顶部的直线,它表示该单元的记忆或细胞状态(c^t)。更技术性地讲,细胞状态由常数误差旋转环(CEC)定义,它本质上是一个递归自连接的线性单元。这个实现是 LSTM 层的核心组件,使得在反向传播过程中能够强制执行恒定的误差流动。本质上,它允许缓解其他 RNN 所遭遇的梯度消失问题。
CEC 防止误差信号在反向传播过程中迅速衰减,从而使得早期的表示能够得到良好保持,并传递到未来的时间步。可以将其视为信息高速公路,使得这种架构能够学习在超过 1,000 步的时间间隔内传递相关信息。研究表明,这在各种时间序列预测任务中是有效的,能够有效解决以前架构面临的问题,并处理噪声输入数据。尽管通过梯度裁剪(如我们在上一章所见)可以解决梯度爆炸问题,但梯度消失问题同样可以通过 CEC 实现来解决。
现在我们已经对细胞状态如何通过 CEC 的激活来表示有了一个高层次的理解。这个激活(即 c^t)是通过多个信息门的输入来计算的。LSTM 架构中不同门的使用使其能够控制通过各个单元的误差流动,从而帮助维持相关的细胞状态(简写为c):

将激活值和记忆单独处理
注意观察短期记忆(a**(t-1)*)和长期记忆(*c**(t-1))是如何分别流入该架构的。来自前一时刻的记忆通过图示的左上角流入,而来自前一时刻的激活值则从左下角流入。这是我们从已经熟悉的 GRU 架构中能够注意到的第一个关键区别。这样做使得 LSTM 能够同时利用短期激活值和网络的长期记忆(细胞状态),同时计算当前记忆(c**t*)和激活值(*a**t)。这种二元结构有助于维持时间上的持续误差流动,同时让相关的表示被传递到未来的预测中。在自然语言处理(NLP)中,这样的预测可能是识别不同性别的存在,或者某个词序列中存在复数实体的事实。然而,如果我们希望从一个给定的词序列中记住多个信息呢?如果我们想在较长的词序列中记住一个主题的多个事实呢?考虑机器问答的情况,以下是两个句子:
- 
拿破仑被流放到圣赫勒拿岛已经有几个月了。他的精神已经衰弱,身体虚弱,但正是从他房间四周苍白绿色墙纸上滋生的潮湿霉菌中的砒霜,慢慢导致了他的死亡。 
- 
拿破仑在哪里?拿破仑是如何去世的? 
LSTM 记忆块
为了能够回答这些问题,我们的网络必须有多个记忆单元,每个单元可以存储与我们研究对象——法国皇帝拿破仑·波拿巴——相关的准依赖信息。实际上,一个 LSTM 单元可以有多个记忆单元,每个单元存储输入序列中的不同表示。一个可能存储主题的性别,另一个可能存储有多个主题的事实,依此类推。为了清晰地展示,我们在本章中只描绘了每个图示中的一个记忆单元。我们这么做是因为理解一个单元的工作原理足以推断出一个包含多个记忆单元的记忆块的工作方式。LSTM 中包含所有记忆单元的部分被称为记忆块。架构的自适应信息门控由记忆块中的所有单元共享,并用于控制短期激活值(a(t-1)*)、当前输入(*Xt)和 LSTM 的长期状态(c**^t)之间的信息流动。
忘记门的重要性
正如我们所注意到的,定义 LSTM 记忆单元状态(c**^t)的方程与 GRU 的状态方程在本质上是相似的。然而,一个关键的区别是,LSTM 利用了一个新的门(Γf),即遗忘门,以及更新门来决定是否忘记在前一个时间步存储的值(c**^(t-1)),或者将其包含在新单元记忆的计算中。以下公式描述了负责保持我们 LSTM 单元状态的 CEC(记忆单元控制单元)。它正是让 LSTM 能够有效记住长期依赖关系的公式。如前所述,CEC 是每个 LSTM 记忆单元特有的神经元,定义了在任何给定时间的单元状态。我们将从 LSTM 单元如何计算它的记忆单元中存储的值(C^t)开始:

这使得我们可以将来自候选值(c ̴**t)和前一个时间步的记忆值(c^(t-1))的信息,结合到当前的记忆值中。正如我们很快会看到的,这个遗忘门其实就是一个对矩阵级别的点积应用 sigmoid 激活函数,并加上一个偏置项,帮助我们控制从前一个时间步传递过来的信息流。
概念化差异
值得注意的是,遗忘门在保持单元状态方面与 GRU 架构所采用的机制存在一个重要的概念性区别,它们的目标是实现相似的效果。可以这样理解:遗忘门允许我们控制前一个单元状态(或记忆)在多大程度上影响当前的单元状态。而在 GRU 架构中,我们只是简单地暴露前一个时间步的全部记忆,或者只是新的候选值,很少在两者之间做出妥协。
GRU 单元状态的计算如下:

这种在暴露整个记忆和新的候选值之间的二元权衡实际上是可以避免的,正如 LSTM 架构所展示的那样。通过使用两个独立的门,每个门都有自己可学习的权重矩阵,来控制我们 LSTM 的单元状态,从而实现这一点。LSTM 单元状态的计算如下:

走进 LSTM
所以,让我们仔细看一下描述 LSTM 架构的整个方程组。我们将首先研究的门是遗忘门和更新门。与 GRU 不同,LSTM 使用这两个门来确定每个时间步的记忆值(c^t):

首先,让我们看看这些门是如何计算的。以下公式表明,这些门实际上只是将前一时刻的激活值与当前输入的点积,通过对应的权重矩阵(Wf和Wu分别用于遗忘门和输出门),再应用 sigmoid 函数的结果:
- 
遗忘门 (ΓF) = sigmoid ( Wf at-1, ![ t ] + bF) 
- 
更新门 (ΓU) = sigmoid ( Wu at-1, ![ t ] + bu) 

可视化信息流
这两个向量 (a**^(t-1) 和  t) 分别从 LSTM 单元的左下角进入,并在到达时被复制到每个门(ΓF 和ΓU)。然后,它们分别与各自门的权重矩阵相乘,再对它们的点积应用 sigmoid,并加上偏置项。正如我们所知,sigmoid 函数以其将输入压缩到零和一之间而闻名,因此每个门的值都在这个范围内。重要的是,每个权重矩阵是特定于给定门的(Wf用于遗忘门,Wu用于更新门)。权重矩阵(Wf和Wu)代表 LSTM 单元中的一部分可学习参数,并在反向传播过程中迭代更新,就像我们一直在做的那样。
 t) 分别从 LSTM 单元的左下角进入,并在到达时被复制到每个门(ΓF 和ΓU)。然后,它们分别与各自门的权重矩阵相乘,再对它们的点积应用 sigmoid,并加上偏置项。正如我们所知,sigmoid 函数以其将输入压缩到零和一之间而闻名,因此每个门的值都在这个范围内。重要的是,每个权重矩阵是特定于给定门的(Wf用于遗忘门,Wu用于更新门)。权重矩阵(Wf和Wu)代表 LSTM 单元中的一部分可学习参数,并在反向传播过程中迭代更新,就像我们一直在做的那样。
计算单元状态
现在我们知道了两个门(更新门和遗忘门)分别代表什么,它们是如何计算的,我们可以继续理解它们如何在给定时间步影响我们 LSTM 的记忆(或状态)。请再次注意流向和流出门的不同信息路径。输入从单元格的左侧进入,经过转换并传播,直到它们到达 LSTM 单元的右侧,如下图所示:

正如我们所看到的,遗忘门(ΓF)的作用,字面上就是忘记来自前一个时间步的记忆值。同样,更新门(Γu)决定是否允许将潜在的候选值(c ^(̴t)) 纳入当前时间步。这两个门共同负责在给定时间步保留我们 LSTM 记忆的状态(c**^t)。在数学上,这可以转化为以下公式:
- 当前记忆值 (c^t) = ( Γu * c ^(̴t) ) + (ΓF * c^(t-1) )*
正如我们提到的,每个门本质上表示一个介于零和一之间的值,因为我们通过非线性 sigmoid 函数将值压缩。我们知道,由于 sigmoid 的工作范围,大多数值往往非常接近零或接近一,因此我们可以将这些门看作是二进制值。这是有用的,因为我们可以将这些门想象成打开(1)让信息流通,或者关闭(0)。介于零和一之间的任何值都能让部分信息流入,但并不是全部。
所以,现在我们理解了这些门值是如何计算的,以及它们如何控制候选者值(c ̴**t)或前一个记忆状态(c**^(t-1))在当前状态计算中应具有的影响程度。LSTM 记忆的状态(c**^t)由之前展示的 LSTM 图中顶部的直线定义。实际上,这条直线(即常数误差环)非常擅长保持相关信息并将其传递到未来的时间步,以协助预测。
计算候选者记忆
我们现在知道了如何计算时间点(t)的记忆,但那么候选者(c ^(̴t))本身呢?毕竟,它在维护相关的记忆状态方面起着部分作用,特点是每个时间步出现的可能有用的表示。
这与我们在 GRU 单元中看到的想法相同,在那里我们允许在每个时间步使用候选者值更新记忆值。早些时候,在 GRU 中,我们使用了一个相关性门来帮助我们为 GRU 计算它。然而,在 LSTM 的情况下,这是不必要的,我们得到了一个更加简单且可以说更优雅的公式,如下所示:
- 候选者记忆值 (c ^(̴t)) = tanh ( Wc a^(t-1), ![ t ] + bc)
这里,Wc 是一个权重矩阵,在训练开始时初始化,并随着网络训练而迭代更新。这个矩阵与前一时刻的激活值(a(*t*-1)*)和当前输入(*xt)的点积,再加上偏置项(bc),通过 tanh 激活函数得出候选者值(c ̴t)。然后,这个候选者向量与我们在当前时间看到的内存状态(c**^t)的更新门值进行逐元素相乘。在下图中,我们说明了候选者记忆向量的计算,并展示了如何将信息传递到下一个时间步,影响最终的记忆单元状态(c**t):

请记得,tanh 激活函数有效地将输出压缩到 -1 和 1 之间,因此候选者向量(c ^(̴t))的值总是出现在这个范围内。现在我们理解了如何计算 LSTM 的单元状态(或记忆)在给定时间步的值。我们还了解了在更新门调整之前,候选者值是如何计算的,然后传递到当前记忆的计算中,(c**^t)。
计算每个时间步的激活值
正如我们之前在 LSTM 架构中指出的,它分别接收来自前一时间步的记忆和激活值。这与我们在 GRU 单元中做出的假设不同,在 GRU 中我们有 a^t = ct。这种双重数据处理方式使得我们能够在很长的序列中保留相关的表示,甚至可能达到 1,000 个时间步!然而,激活值始终与每个时间步的记忆(ct*)功能相关。因此,我们可以通过首先对记忆(*ct)应用 tanh 函数,然后将结果与输出门值(Γo)进行逐元素计算,来计算某个时间步的激活值。请注意,在这一步我们并不初始化权重矩阵,而只是对(c^t)向量中的每个元素应用 tanh 函数。数学表达式如下:
- 当前激活值 (a^t ) = Γo * tanh(c^t)

在这里,输出门不过是另一个 sigmoid 函数,应用于一个可学习的权重矩阵的点积,其中包含来自前一时间步的激活值和当前时刻的输入,具体如下:
- 输出门 (Γo) = sigmoid ( Wo a^(t-1), ![ t ] + bo)
存在于每个单独门(分别为遗忘门、更新门、候选门和输出门)的权重矩阵(Wf, Wu, Wc, 和 Wo)可以被视为 LSTM 单元的可学习参数,并在训练过程中不断更新。在这里提供的图示中,我们可以观察到每个权重矩阵是如何塑造进入各自门的输入,随后将结果传递到架构的其他部分:

LSTM 的变种与性能
你已经看到了 LSTM 的一个变种,即 GRU。我们已经广泛讨论了这两种架构的不同。还有其他一些变种同样值得注意。其中之一是 LSTM 的变种,它包含了被称为窥视连接(peephole connections)的东西。这些连接允许信息从细胞状态流回到信息门(遗忘门、更新门和输出门)。这使得我们的 LSTM 门在计算当前时间的门值时,可以“窥视”来自前一时间步的记忆值。
了解窥视连接(peephole connections)
窥视孔连接的核心思想是捕捉时间延迟信息。换句话说,我们希望在建模过程中包括序列子模式之间时间间隔传递的信息。这不仅对于某些语言处理任务(如语音识别)相关,而且对于从机器运动控制到计算机生成音乐中保持复杂节奏的其他众多任务也非常重要。以前处理语音识别等任务的方法使用了隐马尔可夫模型(HMMs)。这些本质上是统计模型,基于隐藏状态转移序列估计一组观察值的概率。在语音处理的例子中,观察值被定义为对应语音的数字信号片段,而马尔可夫隐藏状态则是我们希望识别为单词的音素序列。如你所见,这个模型中并未考虑音素之间的延迟,无法判断某一数字信号是否对应某个特定的单词。这些信息在 HMM 中通常会被丢弃,但在我们判断是听到句子I want to open my storage unit before...还是I want to open my storage unit, B-4时,延迟信息可能至关重要。在这些例子中,音素之间的延迟很可能区分出B-4和before。虽然 HMM 超出了本章讨论的范围,但它帮助我们理解了 LSTM 如何通过利用时间序列之间的延迟,克服了以往模型的局限。
你可以在以下链接查看窥视孔论文:ftp://ftp.idsia.ch/pub/juergen/TimeCount-IJCNN2000.pdf:

请注意,窥视孔修改可以应用于任意一个门。你可以选择对所有门实施该修改,或者仅对其中的一部分实施。
以下方程展示了在添加窥视孔连接以包含前一单元状态时,计算各门值时执行的计算:
- 
遗忘门 (ΓF) = sigmoid ( Wf c^(t-1) , a^(t-1), ![ t ] + bF) 
- 
更新门 (ΓU) = sigmoid ( Wu c^(t-1) , a^(t-1), ![ t ] + bu) 
- 
输出门 (Γo) = sigmoid ( Wo c^(t-1) , a^(t-1), ![ t ] + bo) 
因此,窥视孔修改在数学上简化为在计算给定门值时执行额外的矩阵级别乘法。换句话说,门的值现在可以通过与给定门的权重矩阵计算点积来容纳前一单元状态。然后,得到的点积与前两个点积及偏置项一起求和,再通过 sigmoid 函数进行处理。
时序和计数的重要性
让我们通过另一个概念性例子,进一步巩固使用时间间隔相关信息来指导顺序预测的理念,在这个例子中,这类信息被认为对于准确预测至关重要。举个例子,考虑一个人类鼓手,必须执行一系列精确的运动指令,这些指令对应着精确的节奏流。鼓手根据时间来安排他们的动作,并按顺序依赖地计数他们的进度。在这里,代表生成序列模式的信息,至少部分地,是通过这些事件之间的时间延迟来传达的。自然,我们会有兴趣人工复制这种复杂的序列建模任务,这种任务在这些互动中发生。从理论上讲,我们甚至可以利用这种方法从计算机生成的诗歌中提取新的押韵模式,或者创造可以在未来的奥运会中与人类竞争的机器人运动员(无论我们集体决定出于什么理由认为这是个好主意)。如果你希望进一步研究如何通过窥视连接来增强对复杂时间延迟序列的预测,我们鼓励你阅读原始的 LSTM 窥视点修改论文,链接如下:
www.jmlr.org/papers/volume3/gers02a/gers02a.pdf
探索其他架构变体
除了本书中涉及的 RNN 变体外,还有许多其他 RNN 变体(参见深度门控 RNNs,姚等人,2015;或时钟 RNNs,Koutnik 等人,2014)。这些变体每个都适合在特定任务中使用——普遍的共识是 LSTM 在大多数时间序列预测任务中表现出色,并且可以相当修改以适应大多数常见和更复杂的使用案例。事实上,作为进一步阅读,我们推荐一篇优秀的文章(LSTM:一次搜索空间奥德赛,2017:arxiv.org/abs/1503.04069),该文章比较了不同 LSTM 变体在多种任务中的表现,如语音识别和语言建模。由于该研究使用了大约 15 年的 GPU 时间来进行实验,因此它成为了一项独特的探索性资源,供研究人员更好地理解不同 LSTM 架构的考虑因素及其在建模顺序数据时的效果。
运用我们的知识
现在我们已经充分理解了 LSTM 的工作原理,以及它在特定任务中尤为擅长的方面,是时候实施一个真实世界的例子了。当然,时间序列数据可以出现在各种场景中,从工业机器的传感器数据到表示来自遥远星辰的光谱数据。然而,今天我们将模拟一个更常见但臭名昭著的用例。我们将使用 LSTM 来预测股价的波动。为此,我们将使用标准普尔(S&P)500 数据集,并随机选择一只股票准备进行序列建模。该数据集可以在 Kaggle 上找到,包含了所有当前 S&P 500 大市值公司在美国股市交易的历史股价(开盘价、最高价、最低价和收盘价)。
关于建模股市数据
在继续之前,我们必须提醒自己,市场趋势中蕴含着固有的随机性。也许你更倾向于相信有效市场假说,而不是非理性市场理论。无论你个人对股票波动背后的内在逻辑持何种信念,现实是,市场中有大量的随机性,常常连最具预测性的模型也无法捕捉。投资者行为难以预见,因为投资者往往出于不同的动机进行操作。即使是一般的趋势也可能具有欺骗性,正如最近比特币资产泡沫在 2017 年底的崩溃所证明的那样;还有许多其他例子(2008 年全球危机、津巴布韦的战后通货膨胀、1970 年代的石油危机、一战后德国的经济困境、荷兰黄金时代的郁金香狂热,等等,甚至可以追溯到古代)。
事实上,许多经济学家曾引用股市波动中似乎固有的随机性。普林斯顿大学经济学家伯顿·马尔基尔在近半个世纪前的著作《华尔街的随机漫步》中强调了这一点。然而,仅仅因为我们无法获得完美的预测结果,并不意味着我们不能尝试将我们的猜测引导到比喻上的“正确方向”。换句话说,这种序列建模的尝试在预测市场短期内的整体趋势时,仍然可能是有用的。那么,我们现在就导入数据,看看我们在这里处理的是什么内容,不再赘述。请随时跟随您的市场数据,或者使用我们所用的相同数据集,您可以在以下网址找到: www.kaggle.com/camnugent/sandp500。
导入数据
数据存储在逗号分隔值(CSV)文件中,可以通过 pandas 的 CSV 读取器导入。我们还将导入标准的 NumPy 和 Matplotlib 库,并使用来自 sklearn 的MinMaxScaler库,以便在合适的时候重塑、绘制和归一化我们的数据,如以下代码所示:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.preprocessing import MinMaxScaler
df = pd.read_csv('D:/Advanced_Computing/Active_Experiments/LSTM/
                  stock_market/all_stocks_5yr.csv')
df.head()
我们得到的输出如下:

排序并可视化趋势
首先,我们将从数据集中 505 只不同的股票中随机选择一只。你可以选择任何一只来重复进行这个实验。我们还将按日期对 DataFrame 进行排序,因为我们处理的是时间序列预测问题,在这种问题中,序列的顺序对于预测任务的价值至关重要。接着,我们可以通过按顺序绘制高价和低价(某一天)的走势图来可视化我们的数据。这有助于我们直观地查看美国航空集团(股票代码:AAL)在五年期(2013-2017 年)内的股票价格走势,如下所示:
plt.figure(figsize = (18,9))
plt.plot(range(aal.shape[0]),(aal['low']), color='r')
plt.plot(range(aal.shape[0]),(aal['high']), color = 'b')
plt.xticks(range(0,aal.shape[0],60),aal['date'].loc[::60],rotation=60)
plt.xlabel('Date',fontsize=18)
plt.ylabel('Price',fontsize=18)
plt.show()

从 DataFrame 到张量
我们观察到,尽管高价和低价略有不同,但它们的走势显然遵循相同的模式。因此,使用这两个变量进行预测建模是多余的,因为它们高度相关。当然,我们可以从这两个中选择一个,但也可以在任何给定的市场日,将两个价格指标取个平均值。我们将把包含某一天的高价和低价的列转换为 NumPy 数组。具体做法是通过调用各自列的values,这将返回每列的 NumPy 表示。然后,我们可以使用这些新定义的列来计算一个第三个 NumPy 数组,存储所有给定观察值的中间价(计算方式为 (high + low) /2),如下面所示:
high_prices = aal.loc[:,'high'].values
low_prices = aal.loc[:,'low'].values
mid_prices = (high_prices+low_prices)/2.0
mid_prices.shape
----------------------------------------------------
Output:
(1259,) ----------------------------------------------------mid_prices
----------------------------------------------------
Output:
array([14.875, 14.635, 14.305, ..., 51.07 , 50.145, 51.435])
我们注意到,总共有1259个观察值,每个观察值对应于我们 AAL 股票在某一天的中间价。我们将使用这个数组来定义我们的训练数据和测试数据,然后将它们按序列批次准备好,供 LSTM 模型使用。
拆分数据
现在,我们将把整个实例范围(即mid_prices变量)拆分为训练集和测试集。稍后,我们将分别使用这些数据集生成训练和测试序列:
train_data = mid_prices[:1000]
test_data = mid_prices[1000:1251]
train_data = train_data.reshape(-1,1)         #scaler.fit_transform
test_data = test_data.reshape(-1,1)           #scaler.fit_transform
print('%d training and %d total testing instances'%(len(train_data),    
      len(test_data)))
-----------------------------------------------------------------
Output:
1000 training and 251 total testing instances 
绘制训练和测试集的划分
在下面的截图中,我们简单地展示了两个子图,用于可视化未归一化的 AAL 股票数据的训练和测试部分。你可能会注意到,图表的比例并不一致,因为训练数据包含 1,000 个观察值,而测试数据仅包含大约四分之一的观察值。同样,测试数据的价格区间出现在 40 到 57 美元之间,而训练数据则出现在 0 到 50+美元之间,并覆盖了一个更长时间范围的观察期。请记住,测试数据只是我们预处理后的 AAL 中间股票价格数据中,紧跟前 1,000 个观察值后的时间序列:
#Subplot with training data
plt.subplot(1,2,1)
plt.plot(range(train_data.shape[0]),train_data,color='r',label='Training split')
plt.title('Train Data')
plt.xlabel('time')
plt.ylabel('Price')
plt.legend()
#Subplot with test data
plt.subplot(1,2,2)
plt.plot(range(test_data.shape[0]),test_data,color='b',label='Test Split')
plt.title('Test Data')
plt.xlabel('time')
plt.ylabel('Price')
plt.legend()
#adjust layout and plot all
plt.tight_layout()
plt.show()
上述代码块生成了以下输出:

窗口归一化
在我们将数据划分为更小的序列进行训练之前,我们必须将所有数据点缩放到零和一之间,正如我们迄今为止所做的那样。回想一下,这种表示方式使得我们的网络更容易从展示的数据中捕捉到相关的表示,并且这是深度学习社区内外针对各种机器学习(ML)任务的常见标准化方法。
然而,与之前的方法不同,我们必须调整我们的标准化策略来适应这个特定的时间序列问题。为此,我们采用了窗口标准化方法。为什么?因为这使我们能够在较小的批次中对数据进行标准化,而不是一次性对整个数据集进行标准化。早些时候,当我们可视化整个股票数据的时间序列时,我们注意到了一些问题。事实证明,不同年份的数据在不同时间段内具有不同的值范围。因此,整体标准化过程会导致时间序列早期出现的值接近零。这会阻止我们的模型按预期区分相关的趋势,并且在训练网络时,严重削弱了能够捕捉到的表示。你当然可以选择更宽的特征范围——但是,这也会对学习过程产生不利影响,因为人工神经网络(ANNs)在处理零到一之间的值时效果最好。
所以让我们实现这个窗口标准化方案,如下代码块所示:
#Window size to normalize data in chunks 
normalization_window = 250
#Feature range for normalization
scaler = MinMaxScaler(feature_range=(0, 1))
# Loop over the training data in windows of 250 instances at a time
for i in range(0,1000,normalization_window):
    # Fit the scaler object on the data in the current window
    scaler.fit(train_data[i:i+normalization_window,:])
    # Transform the data in the current window into values between the chosen feature range (0 and 1)
    train_data[i:i+normalization_window,:] = scaler.transform(train_data[i:i+normalization_window,:])
# normalize the the test data
test_data=scaler.fit_transform(test_data)
我们刚才采用的窗口标准化方法有一个问题值得提及。批量标准化数据可能在每个批次的结尾引入连续性的中断,因为每个批次都是独立标准化的。因此,建议选择一个合理的窗口大小,以避免在训练数据中引入过多的中断。就我们的情况而言,我们将选择 250 天的窗口大小,因为这不仅能完美地划分我们的训练集和测试集,而且在标准化整个数据集时(即 1000 / 250 = 4)只会引入四个潜在的连续性中断。我们认为这对于当前的演示用例是可控的。
数据去噪
接下来,我们将去噪我们的股票价格数据,以去除当前存在的那些不太相关的市场波动。我们可以通过以指数递减的方式加权数据点来做到这一点(也就是指数平滑)。这使得我们能够让近期的事件对当前数据点的影响大于远古的事件,从而每个数据点都可以作为当前值和时间序列中前面值的加权递归函数来表示(或平滑)。这可以用数学公式表示如下:

前面的公式表示了给定数据点(x[t])的平滑转换,作为加权项 gamma 的函数。结果(S[t])是给定数据点的平滑值,而 gamma 项表示一个介于零和一之间的平滑因子。衰减项使我们能够将可能对特定时间间隔内数据变化(即季节性)存在的假设编码到我们的预测模型中。因此,我们将平滑绘制的股价曲线与时间的关系。这是时间序列建模中常用的信号预处理技术,有助于去除数据中的高频噪声。
实施指数平滑
因此,我们通过循环遍历每个中间价格值来转换我们的训练数据,更新平滑系数,然后将其应用于当前价格值。请注意,我们使用之前展示的公式来更新平滑系数,这使我们能够根据当前和前一个观测值的加权函数对时间序列中的每个观测值进行加权:
Smoothing = 0.0     #Initialize smoothing value as zero
gamma = 0.1         #Define decay
for i in range(1000):
    Smoothing = gamma*train_data[i] + (1-gamma)*Smoothing   # Update   
                                                       smoothing value
    train_data[i] = Smoothing # Replace datapoint with smoothened value
可视化曲线
使用下面的图示,我们可以可视化平滑前后数据点曲率的差异。如您所见,紫色图表显示了一个更加平滑的曲线,同时保持了股价随时间变化的总体走势。
如果我们使用未平滑的数据点,我们很可能会很难使用任何类型的机器学习技术来训练预测模型:

表示是关键,且始终存在准确性和效率之间的最佳平衡。一方面,使用简化的表示可能使机器更快地从数据中学习。然而,简化到更易管理的表示的过程可能会导致有价值的信息丢失,这些信息可能不再被我们的统计模型捕捉到。另一方面,处理全部信息会引发计算复杂性的洪流,这种复杂性既没有必要的资源来建模,也不常需要考虑来解决眼前的问题。
执行一步预测
接下来,我们将解释一些基准模型。这将帮助我们更好地评估 LSTM 网络的有效性。我们进行的平滑处理将帮助我们实现这些基准模型,这些模型将用于基准测试我们 LSTM 模型的性能。我们将尝试使用一些相对简单的算法。为此,我们将使用两种技术,分别是简单移动平均和指数移动平均算法。这两种方法本质上都是进行一步预测,将训练数据中下一个时间序列的值预测为前一序列值的平均值。
为了评估每种方法的有效性,我们可以使用均方误差(MSE)函数来评估预测值与实际值之间的差异。回顾一下,这个函数实际上是在每个时间步长上,对预测值和实际结果之间的误差进行平方运算。我们还将通过将预测的时间序列进程与实际的股票价格时间序列进程叠加,直观地验证我们的预测。
简单移动平均预测
对于简单移动平均法,我们在预测时间序列中的下一个值时,会对给定窗口内的过去观测值进行等权重处理。在这里,我们计算给定时间间隔内股票价格的算术平均值。这个简单的算法可以用以下数学公式表示:

采用短期平均(即在几个月内)将使模型能够快速响应价格变化,而长期平均(即在几年内)通常对价格变化的反应较慢。在 Python 中,这一操作可以转化为以下代码:
window_size = 26            # Define window size
N = train_data.size         # and length of observations
std_avg_predictions = []    # Empty list to catch std
mse_errors = []             # and mse
for i in range(window_size,N):
    # Append the standard mean per window
    std_avg_predictions.append(np.mean(train_data[i-window_size:i]))                                                                                                         
    # Compute mean squared error per batch 
    mse_errors.append((std_avg_predictions[-1]-train_data[i])**2) 
print('MSE error for standard averaging: %.5f'  
      (0.5*np.mean(mse_errors)))
MSE error for standard averaging: 0.00444
我们通过再次遍历我们的训练数据,使用预定义的窗口大小,并收集每个数据点的批次均值以及均方误差(MSE),从而收集了简单平均预测。正如 MSE 值所示,我们的简单平均预测模型表现得还不错。接下来,我们可以将这些预测值绘制出来,并将其与我们股票价格的真实时间序列进程叠加在一起,从而直观地展示这种方法的表现:
plt.figure(figsize = (19,6))
plt.plot(range(train_data.shape[0]),train_data,color='darkblue',label='Actual')
plt.plot(range(window_size,N),std_avg_predictions,color='orange',label='Predicted')
plt.xticks(range(0,aal.shape[0]-len(test_data),50),aal['date'].loc[::50],rotation=45)
plt.xlabel('Date')
plt.ylabel('Mid Price')
plt.legend(fontsize=18)
plt.show()
我们得到以下图表:

在简单平均预测图中,我们注意到我们的预测确实捕捉到了股票价格的总体趋势,但在时间序列的各个独立点上并没有提供准确且可靠的预测。有些预测可能看起来非常准确,但大多数都偏离了实际值,而且它们相对于真实值变化的速度也太慢,无法做出任何有利可图的预测。如果你想更直观地了解预测的准确度,可以单独打印出预测数组的各个值,并与训练数据中的实际值进行比较。接下来,我们将继续进行第二个基准测试。
指数移动平均预测
指数移动平均比简单的移动平均稍微复杂一些;然而,我们已经熟悉我们将使用的公式。从本质上讲,我们将使用与平滑数据时相同的方程式。不过,这次我们将使用指数平均法来预测时间序列中的下一个数据点,而不是重新调整当前的数据点:
ema_avg_predictions = []
mse_errors = []
EMA = 0.0
ema_avg_predictions.append(EMA)
gamma = 0.5
window_size = 100
N = len(train_data)
for i in range(1,N):
    EMA = EMA*gamma + (1.0-gamma)*train_data[i-1]
    ema_avg_predictions.append(EMA)
    mse_errors.append((ema_avg_predictions[-1]-train_data[i])**2)
print('MSE error for EMA averaging: %.5f'%(0.5*np.mean(mse_errors)))
MSE error for EMA averaging: 0.00018
正如我们所见,简单移动平均(en.wikipedia.org/wiki/Moving_average#Simple_moving_average)赋予过去的观察值相同的权重。相反,我们使用指数函数来控制先前数据点对未来数据点的影响程度。换句话说,我们能够随着时间推移,给早期数据点分配逐渐减少的权重。这种技术允许建模者通过修改衰减率(gamma),将先验假设(例如季节性需求)编码到预测算法中。当与简单平均法计算得到的 MSE 相比,基于一步前的指数平均法得到的 MSE 要低得多。让我们绘制一张图表,直观地检查我们的结果:
plt.figure(figsize = (19,6))
plt.plot(range(train_data.shape[0]),train_data,color='darkblue',label='True')
plt.plot(range(0,N),ema_avg_predictions,color='orange', label='Prediction')
plt.xticks(range(0,aal.shape[0]-len(test_data),50),aal['date'].loc[::50],rotation=45)
plt.xlabel('Date')
plt.ylabel('Mid Price')
plt.legend(fontsize=18)
plt.show()
我们得到如下图表:

一步前预测的问题
非常棒!看起来,给定一组前几天的数据,我们几乎可以完美预测第二天的股价。我们甚至不需要训练复杂的神经网络!那么,为什么还要继续呢?事实证明,提前一天预测股价并不能让我们变成百万富翁。移动平均线本质上是滞后指标,它们仅在股价开始跟随某一特定趋势后,才能反映市场的重大变化。由于我们的预测与事件实际发生之间时间跨度较短,当这种模型反映出明显的趋势时,市场的最佳进入点往往已经过去。
另一方面,使用这种方法尝试预测多个时间步的未来股价也不会成功。我们实际上可以用数学来说明这一概念。假设我们有一个数据点,我们希望使用指数移动平均法预测两步以后的股价。换句话说,我们将不会使用(X[t + 1])的真实值,而是使用我们的预测值来计算接下来一天的股价。回顾一下,一步前预测的方程式定义如下:

假设数据点X[t]的值为 0.6,X[t-1]的EMA为 0.2,我们选择的衰减率(gamma)为 0.3。那么,我们对X[t-1]的预测可以如下计算:
- 
= 0.3 x 0.2 + (1 – 0.3) x 0.6 
- 
= 0.06 + (0.7 x 0.6) 
- 
= 0.06 + 0.42 = 0.48 
所以,0.48 既是我们对X[t-1]的预测值,也是当前时间步的EMA。如果我们使用相同的公式来计算下一时间步(X[t-2])的股价预测,就会遇到一些问题。以下方程式展示了这一难题,其中EMA[t] = X[t + 1] = 0.48:

因此,无论我们选择什么样的 gamma,由于EMA[t]和X[t + 1]持有相同的值,X[t + 2]的预测将与X[t + 1]的预测相同。这对于任何超过一个时间步长的X[t]预测都适用。实际上,指数加权移动平均线(EMA)常常被日内交易者用作理性检查,他们用它来评估和验证市场的重要波动,尤其是在可能快速变化的市场中。所以,现在我们已经使用一步前移平均预测建立了一个简单的基准,我们可以开始构建更复杂的模型,来预测未来更远的价格。
很快,我们将构建一组神经网络并评估它们的性能,看看 LSTM 在预测股价走势任务中的表现如何。我们将再次使用一个简单的前馈神经网络作为基准,并逐步构建更复杂的 LSTM 以比较它们的性能。然而,在我们继续之前,必须准备好数据。我们需要确保我们的网络能够接收一系列训练数据,才能对下一个序列值(我们股票的缩放中价)进行预测。
创建观测序列
我们使用以下函数来创建训练和测试序列,供我们训练和测试网络。该函数接受一组时间序列股价,并将其组织成一组n个连续值的片段,形成一个给定的序列。关键的不同之处在于,每个训练序列的标签将对应于四个时间步后的股价!这与我们之前使用移动平均法有所不同,因为移动平均法只能预测股价一个时间步的变化。因此,我们生成数据序列,使得我们的模型能够预测未来四个时间步后的股价。
我们定义了一个look_back值,它表示我们在给定的观测中保留的股价数量。在我们的案例中,我们实际上允许网络回顾过去7个价格值,然后再让它预测四个时间步后我们的股价会发生什么变化:
def create_dataset(dataset, look_back=7, foresight=3):   
    X, Y = [], []
    for i in range(len(dataset)-look_back-foresight): 
        obs = dataset[i:(i+look_back), 0] # Sequence of 7 stock prices  
                                     as features forming an observation    
       # Append sequence
        X.append(obs)
       # Append stock price value occurring 4 time-steps into future
        Y.append(dataset[i + (look_back+foresight), 0]) 
    return np.array(X), np.array(Y)
我们使用create_dataset函数来生成序列及其相应标签的数据集。此函数在我们的时间序列数据(即train_data变量)上调用,并接受两个额外的参数。第一个参数(look_back)表示每个观察序列中希望有多少个数据点。在我们的例子中,我们将创建包含七个数据点的序列,表示时间序列中某一点之前的七个中间价格值。同样,第二个参数(foresight)表示从观察序列的最后一个数据点到我们想要预测的目标数据点之间的步数。因此,我们的标签将反映每个训练和测试序列未来四个时间步的滞后。我们通过使用步长为一的方式,从原始训练数据中重复创建训练序列及其标签的方法。最终,我们将得到一个包含 990 个观察序列的训练数据集,每个序列的标签对应于四个时间步后达到的股票价格。虽然我们的look_back和foresight值在某种程度上是任意的,但我们鼓励你尝试不同的值,以评估较大的look_back和foresight值如何分别影响模型的预测能力。在实际操作中,你会发现这两个值在两端会有递减的回报。
调整数据形状
接下来,我们简单地调整训练集和测试集的序列形状以适应我们的网络。我们准备一个三维张量,维度为(时间步长,1,特征),这将对测试不同的神经网络模型非常有用:
x_train = np.reshape(x_train, (x_train.shape[0], 1,  x_train.shape[1]))
x_test = np.reshape(x_test, (x_test.shape[0], 1,  x_test.shape[1]))
x_train.shape
(990, 1, 7)
进行一些导入
现在我们准备好最终构建和测试一些神经网络架构,并看看它们在预测股市趋势任务中的表现。我们将从导入相关的 Keras 层开始,以及一些回调函数,回调函数可以让我们在模型训练过程中进行交互,以便保存模型或在我们认为合适的时候停止训练:
from keras.models import Sequential
from keras.layers import LSTM, GRU, Dense
from keras.layers import Dropout, Flatten
from keras.callbacks import ModelCheckpoint, EarlyStopping
基准神经网络
正如我们之前提到的,开始时使用较简单的模型进行检查总是好的,然后再逐步过渡到更复杂的模型。数据建模人员往往容易被所谓的强大模型吸引,但很多时候,这些复杂的模型可能并不一定是完成任务所必需的。在这些情况下,使用较简单(且通常计算开销较小)的模型来建立一个合适的基准,进而评估使用更复杂模型的增值效果,会更好。抱着这种精神,我们将构建两个基准模型。每个基准模型将展示特定类型网络在任务中的表现。我们将使用简单的前馈网络来建立所有神经网络的初步基准。然后,我们将使用基本的 GRU 网络来建立递归网络的基准。
构建前馈网络
虽然前馈网络是您非常熟悉的网络,但这种体系结构进行了一些修改,使其适合当前的任务。例如,最后一层是一个只有一个神经元的回归器层。它还使用线性激活函数。至于损失函数,我们选择了平均绝对误差(MAE)。我们还选择了adam优化器来执行此任务。所有未来的网络将实现相同的最后一层、损失和优化器。我们还将在一个函数中嵌套构建和编译模型,以便我们可以轻松测试多个网络,就像我们迄今为止所做的那样。以下代码块显示了如何实现这一点:
def feed_forward():
    model = Sequential()
    model.add(Flatten())
    model.add(Dense(128, activation='relu'))
    model.add(Dense(1, activation='linear'))
    model.compile(loss='mae', optimizer='adam')
    return model
递归基准
接下来,我们将构建一个简单的 GRU 网络来建立一个递归基准。我们指定正确的输入形状,并添加了一个小的递归 dropout 比例。请记住,这将对后续时间步骤应用相同的 dropout 方案,比简单的 dropout 方案更好地保留时间信息。我们还包括了一个小比例的神经元随机 dropout。我们建议您分别进行实验,保存我们当前正在进行的实验,以了解在不同 dropout 策略下 RNN 性能的差异:
def simple_gru():
    model = Sequential()
    model.add(GRU(32,  input_shape=(1, 7), dropout=0.1, recurrent_dropout=0.1))
    model.add(Dense(1, activation='linear'))
    model.compile(loss='mae', optimizer='adam', metrics = 
                  ['mean_absolute_error'])
    return model
构建 LSTM 模型
现在我们已经有了一些基准模型,让我们继续构建这一章的重点:一个 LSTM 模型。我们首先从一个普通的单层 LSTM 开始,没有使用任何 dropout 策略,配置 32 个神经元,如下所示:
def simple_lstm():
    model = Sequential()
    model.add(LSTM(32, input_shape=(1, 7)))
    model.add(Dense(1, activation='linear'))
    model.compile(loss='mae', optimizer='adam')
    return model
我们将 LSTM 层连接到我们的密集回归层,并继续使用相同的损失和优化器函数。
堆叠的 LSTM
接下来,我们简单地将两个 LSTM 层堆叠在一起,就像我们在上一章中对 GRU 所做的那样。我们将看到这是否有助于网络记住我们股票数据中更复杂的时间相关信号。我们对两个 LSTM 层都应用了 dropout 和递归 dropout 方案,如下所示:
def lstm_stacked():
    model = Sequential()
    model.add(LSTM(16, input_shape=(1, 7), dropout=0.1, recurrent_dropout=0.2, return_sequences=True))
    model.add(LSTM(16, dropout=0.1, recurrent_dropout=0.2))
    model.add(Dense(1, activation='linear'))
    model.compile(loss='mae', optimizer='adam')
    return model
现在我们准备运行实验并评估结果。我们可以通过 MSE 指标进行评估,以及通过将模型的预测结果与实际预测结果叠加进行视觉解释。我们已经构建了一些函数,帮助我们在每次训练会话结束时可视化我们的结果。
使用辅助函数
在我们开始训练我们的网络之前,我们可以构建一些辅助函数,这些函数可以在模型训练完成后帮助我们了解模型的性能。前者plot_losses函数简单地绘制训练损失和验证损失,使用我们模型的history对象。请记住,这是一个默认的回调函数,提供了在会话中计算的训练和验证损失的字典:
def plot_losses(network):
    plt.plot(network.history['loss'], label='loss')
    plt.plot(network.history['val_loss'], label='val loss')
    plt.legend()
    plt.show()
接下来,我们将使用plot_predictions函数绘制模型在我们隔离的测试集上的预测,并将其叠加到测试集的实际标签上。这与我们之前在一步预测中的做法精神相似。唯一的区别是,现在我们将可视化由我们的网络预测的三步预测趋势,如下所示:
def plot_predictions(model, y_test=y_test):
    preds = model.predict(x_test)
    plt.figure(figsize = (12,6))
    plt.plot(scaler.inverse_transform(preds.reshape(-1,1)), 
             label='generated', color='orange')
    plt.plot(scaler.inverse_transform(y_test.reshape(-1,1)),   
             label='Actual')
    plt.legend()
    plt.show()
训练模型
最后,我们构建了一个训练函数,帮助我们为每个网络启动训练会话,在每个周期保存模型权重,并在训练会话结束时可视化模型表现。
这个函数可以接受一个模型列表,并对每个模型执行上述步骤。因此,在运行以下代码单元后,请准备好进行一次简短/长时间的散步(取决于你的硬件配置)。
def train_network(list, x_train, y_train, epochs=5):
    for net in list:               
        network_name = str(net).split(' ')[1]
        filepath = network_name + "_epoch-{epoch:02d}-loss-
                   {loss:.4f}-.hdf5"
        print('Training:', network_name)
        checkpoint = ModelCheckpoint(filepath, monitor='loss', 
                     verbose=0, save_best_only=True, mode='min')
        callbacks_list = [checkpoint] 
        model = net()                  
        network = model.fit(x_train, y_train,
                            validation_data=(x_test, y_test),
                            epochs=epochs,
                            batch_size=64,
                            callbacks=callbacks_list)
        model.summary()
        plot_predictions(model, y_test)
    return network, model
all_networks = [feed_forward, simple_gru, simple_lstm, lstm_stacked]
train_network(all_networks, x_train, y_train, epochs=50)
可视化结果
最后,我们将展示模型的预测与实际价格的对比,如下图所示。请注意,虽然简单的 LSTM 表现最佳(MAE 为 0.0809),但它与简单的前馈神经网络的表现非常接近,后者在设计上具有比 LSTM 网络更少的可训练参数。
你可能会想,怎么做呢?嗯,虽然 LSTM 在编码复杂的时间依赖信号方面非常擅长,但这些信号必须首先出现在我们的数据中:

通过查看过去七个中间价格来预测未来,能够传递的信息是有限的。在我们的案例中,似乎 LSTM 能够为预测任务构建的表示与前馈网络所构建的表示相匹配。LSTM 在此上下文中可能能够建模很多复杂信号,但这些信号似乎在我们的数据集中并不存在。例如,在预测标签x[t + 3]时,我们并未设计性地包含关于市场在时间t+1或t+2的任何信息。此外,可能还存在其他变量,而非过去的中间股票价格,更能与股市的未来走势相关。例如,社交媒体情绪(如 Twitter,参考:arxiv.org/pdf/1010.3003.pdf)已被证明能与股票价格的变动相关,最多提前七天!事实证明,获胜的情感是冷静,而非快乐或神经质,这与股市在最多提前一周的变动最为一致。因此,包含代表其他类型和信息来源的特征,可能有助于提高我们的 LSTM 模型的表现,相比于基准模型。
结束评论
请注意,这并不一定意味着通过引入社交媒体数据,可以更好地预测所有行业中所有股票的走势。然而,这确实说明了我们的观点,即基于启发式的特征生成还有一些空间,可能允许利用额外的信号来实现更好的预测结果。为了对我们的实验做一些总结评论,我们还注意到,简单的 GRU 和堆叠的 LSTM 都具有更平滑的预测曲线,并且不太容易受到噪声输入序列的影响。它们在保持股票整体趋势方面表现得非常好。这些模型的外部精度(通过预测值与实际值之间的 MAE 来评估)告诉我们,它们的表现略逊色于前馈网络和简单的 LSTM。然而,根据具体的使用案例,我们可能更倾向于使用具有更平滑曲线的模型进行决策,而不是噪声较大的预测模型。
摘要
在本章中,我们深入探讨了 LSTM 网络的内部工作原理。我们探索了与这些网络相关的概念和数学实现,理解了信息是如何在 LSTM 单元中处理的,并使用短期和长期记忆来存储事件。我们还了解了为什么这个网络得名,因为它擅长在非常远的时间步长中保持相关的单元状态。虽然我们讨论了该架构的一些变体,如窥视孔连接,但在大多数常见的 LSTM 候选场景中很少见到它。尽管我们使用了一个简单的时间序列数据集进行演示,但我们强烈建议你实现这个架构来解决你可能已经熟悉的其他问题(例如 IMDB 情感分类数据集),并将结果与我们早期的工作进行比较。
LSTM 在自然语言处理(NLP)任务中确实表现突出。你可以尝试使用维基百科电影数据集生成电影剧本,或者尝试使用 music21 库和一些 MIDI 文件来生成音乐,并用训练歌曲进行训练。
进一步的编码可以在这里找到:
LSTM 背后的理论概念仍然相当引人注目——尤其是考虑到它们在各种顺序和非顺序任务中的出色表现。那么,是否可以将 LSTM 冠以 RNN 领域的终极冠军称号呢?嗯,答案并不完全是。下一个接近 RNN 领域的重要思想来源于注意力模型的领域,在这个领域中,我们字面上地试图引导神经网络在处理一组信息时的注意力。这种方法在图像描述任务中非常有用,因为我们需要将输入图像的关键部分与输出中必须包含的、按序排列的单词相关联。我们将在接下来的章节中详细探讨注意力模型的相关话题。对于感兴趣的读者,你可以通过阅读 Fang 等人于 2016 年发表的优秀论文Image captioning with semantic attention来进一步了解机器图像描述任务。
然而,在下一章中,我们将把注意力集中在神经网络和深度学习的另一个部分:强化学习。这是机器学习中一个极为有趣的领域,它研究人工智能体如何在一个设计好的环境中行动,以便能够累积性地最大化某个奖励。这个方法可以应用于各种各样的使用案例,例如教机器进行手术、生成笑话或玩视频游戏。让机器能够利用与人类相当(甚至超越)水平的身体或心理灵活性,能够帮助我们构建非常复杂和智能的系统。这些系统维护与其操作环境相关的内部状态,并能够通过研究其行为对环境的影响来更新内部状态,同时优化特定目标。因此,每一种行动组合都会触发不同的奖励信号,学习系统可以利用这些信号进行自我提升。
正如我们很快会看到的,设计允许通过奖励信号来强化的系统,可以导致非常复杂的行为,从而使机器能够执行高度智能的行动,甚至在人类通常占优势的领域也能表现突出。AlphaGo 与李世石的对决故事浮现在脑海中。2016 年,AlphaGo 以五比一战胜了李世石,而这一事件与 1997 年 IBM 的 Deep Blue 战胜加里·卡斯帕罗夫(Gary Kasparov)大不相同。许多观看 AlphaGo 与李世石对局的人都看到了机器操作方式的特殊性。有些人甚至称之为直觉。
在下一章,我们将看到这样的系统,基于环境和可能行动的一些相当简单的统计属性,如何产生出美丽而复杂的结果,有时甚至超出我们自己的预期。
练习
- 
检查模型收敛所需的时间。不同模型之间有很大的差异吗? 
- 
检查模型之间的训练和验证损失。你注意到了什么? 
- 
尝试缩小和放大架构,注意这如何影响学习过程。 
- 
尝试不同的优化器和损失度量,并注意这如何影响学习过程。 
- 
在 IMBD 数据集上实现 LSTM 进行情感分类。 
- 
在 Wikimovies 数据集上实现 LSTM,构建字符/词级语言模型并生成人工电影剧情。 
第七章:深度 Q 网络强化学习
在上一章中,我们看到了递归循环、信息门控和记忆单元如何被用来通过神经网络建模复杂的时间相关信号。更具体地说,我们看到长短期记忆(LSTM)架构如何利用这些机制来保留预测误差,并将其在越来越长的时间步长上反向传播。这使得我们的系统能够利用短期(即来自与即时环境相关的信息)和长期表示(即来自于很久以前观察到的环境信息)来提供预测。
LSTM 的美妙之处在于,它能够在非常长的时间跨度内(最长可达一千个时间步)学习并保留有用的表示。通过在架构中保持恒定的误差流,我们可以实现一种机制,使得我们的网络能够学习复杂的因果模式,这些模式嵌入在我们日常面对的现实中。实际上,教育计算机理解因果关系的问题,至今仍是人工智能(AI)领域中的一个巨大挑战。现实世界的环境充斥着稀疏且具有时间延迟的奖励,且随着奖励对应的动作集的复杂度增加,问题变得更加复杂。在这种情况下,建模最佳行为涉及到发现关于给定环境的足够信息,以及可能的动作集合和相应的奖励,以便做出相关预测。正如我们所知,编码这种复杂的因果关系对于人类来说也是一项困难的任务。我们常常在没有考虑到一些相当有益的因果关系时,屈服于我们的非理性欲望。为什么?简单来说,实际的因果关系可能与我们对情况的内部评估不相符。我们可能是在不同的奖励信号驱动下行动,这些信号跨越时间影响着我们的整体决策。
我们对某些奖励信号的反应程度差异很大。这是基于特定个体的情况,并由基因组成和环境因素的复杂组合决定的。在某些方面,这已经深深植根于我们的天性中。我们之中有些人天生更容易被短期奖励(如美味的小吃或娱乐电影)所吸引,而不是长期奖励(如保持健康的身体或高效利用时间)。这也不是特别糟糕,对吧?其实不一定。不同的环境需要不同的短期与长期考虑平衡才能成功。鉴于人类可能遇到的环境多种多样(无论是个体层面还是物种层面),我们在不同个体之间观察到如此多样的奖励信号解读方式也就不足为奇。从宏观角度来看,进化的目标只是最大化我们在各种环境中生存的机会,适应现实世界带来的挑战。然而,正如我们很快会看到的,这对某些个体(甚至是一些贪婪的机器)在微观层面的事件中可能会带来后果。
关于奖励与满足感
有趣的是,一组斯坦福大学的研究人员(由心理学家沃尔特·米歇尔领导,进行的棉花糖实验,发生在 1970 年代)展示了个体延迟短期满足感的能力与长期成功之间的相关性。简而言之,这些研究人员邀请了一些孩子,观察他们在面对一组选择时的行为。这些孩子有两个选择,决定他们在互动中能够获得多少棉花糖。他们可以选择立即拿到一个棉花糖,或者选择等待 15 分钟后拿到两个棉花糖。这个实验深入揭示了如何解读奖励信号对在特定环境中的表现是有益还是有害的,结果显示,选择等待两个棉花糖的孩子,平均一生中表现得更加成功。事实证明,延迟满足感可能是最大化长期更有益的行动的关键部分。许多人甚至指出,像宗教这样的概念可能就是延迟短期满足感的集体体现(即:不偷窃),以换取长期的好处(例如最终升天)。
一种新的学习检视方式
因此,似乎我们发展出了对应采取何种行动及其可能影响未来结果的内在感知。我们有机制使我们能够通过环境互动来调整这些感知,观察不同的行为会带来何种奖励,这一过程可能持续很长时间。这一点对于人类以及地球上大多数生物来说都是真实的,包括不仅是动物,甚至是植物。即使是植物,也在全天优化某种能量评分,它们通过转动叶子和枝条来捕捉光线,这是它们生存所必需的。那么,这种机制是什么,能让这些生物体建模出最佳结果?这些生物系统如何追踪环境并执行及时而精确的操作以达到有利的结果呢?或许,强化理论这一行为心理学分支能够为这个话题提供一些启示。
由哈佛心理学家 B.F. 斯金纳提出,这一观点将强化定义为代理(人类、动物,现在也包括计算机程序)与其环境之间观察到的互动的结果。从这种互动中编码的信息可能会增强或削弱代理在未来相似情境中以相同方式行动的可能性。简而言之,如果你走在炙热的煤炭上,你感受到的疼痛将作为负向强化,减少你未来选择再次踏上炙热煤炭的可能性。相反,如果你抢劫银行并成功逃脱,所体验到的刺激和兴奋可能会强化这一行为,使其在未来成为更可能的选择。事实上,斯金纳展示了如何通过简单的强化机制训练普通的鸽子,帮助它区分英语单词的差异,甚至让它玩乒乓球。他展示了,经过一段时间的奖励信号的暴露,足以激励鸽子识别它所看到的单词之间的微妙差异或它被要求执行的动作。由于识别这些差异意味着鸽子是否能够获得饱腹感,斯金纳通过逐步奖励鸽子达到期望的结果,从而影响其行为。从这些实验中,他创造了操作性条件反射一词,这一概念与将任务分解成小步骤并逐步奖励有利行为有关。
今天,约半个世纪后,我们在机器学习(ML)领域将这些概念应用于强化模拟代理在特定环境中执行有利行为。这一概念被称为强化学习,它可以产生复杂的系统,这些系统在执行任务时可能会与我们自己的智力相媲美(甚至超越)。
使用强化学习训练机器
到目前为止,我们一直在处理简单的回归和分类任务。我们根据连续值对观察结果进行回归(例如预测股市)并将特征分类到不同的标签中(例如进行情感分析)。这两项任务是监督学习的基石活动。在训练过程中,我们为每个网络遇到的观察结果显示一个特定的目标标签。稍后在本书中,我们将介绍一些无监督学习技术,使用生成对抗网络(GANs)和自动编码器。不过,今天我们将神经网络应用于与这两种学习任务截然不同的领域。这种学习任务可以称为强化学习。
强化学习与前述的机器学习变种明显不同。在这里,我们并不会明确标注环境中所有可能的动作序列与可能的结果(如监督分类中那样),也不会基于相似性度量来划分数据(如无监督聚类中那样)以寻找最佳动作。相反,我们让机器监控它所采取的行动的反馈,并在一段时间内逐步建模最大可能奖励作为行动的函数。本质上,我们处理的是目标导向的算法,这些算法通过给定的时间步学习实现复杂目标。目标可以是击败逐渐向屏幕下方移动的太空侵略者,或者让一个形似狗的机器人从现实世界中的 A 点移动到 B 点。
信用分配问题
就像父母通过奖励和奖励来强化我们的行为一样,我们也可以在给定环境的状态(或配置)下,强化机器的期望行为。这更像是一种试错学习方法,近期的事件表明,这种方法可以产生极其强大的学习系统,打开了非常有趣的应用场景的大门。然而,这种明显不同但强大的学习范式也带来了一些自身的复杂性。例如,考虑信用分配问题。也就是说,我们之前的哪些行动为生成奖励负责,负责的程度如何?在奖励稀疏且时延的环境中,许多动作可能发生在某些行动之间,这些行动后来产生了所讨论的奖励。正确地将应得的信用分配给各个行动会变得非常困难。如果没有适当的信用分配,我们的代理在评估不同策略时会毫无头绪,无法达成其目标。
探索-利用困境
假设我们的智能体甚至设法找到了一个稳定的策略来获取奖励。那么接下来怎么办?它是否应该一直坚持这个策略,永远生成相同的奖励?还是它应该一直尝试新的方法?也许,通过不利用已知的策略,智能体可以在未来获得更大的奖励?这就是所谓的探索与利用困境,指的是智能体在探索新策略或利用已知策略之间的权衡。
极端情况下,我们可以通过理解依赖已知策略来获得即时奖励的长期不利性,更好地理解探索与利用的困境。例如,通过对老鼠的实验表明,如果给这些动物一个触发多巴胺释放机制的方式,它们会饿死自己(多巴胺是一种负责调节我们奖励系统的神经递质)。显然,尽管结局可能让老鼠非常兴奋,但从长远来看,饿死自己并不是正确的选择。然而,由于老鼠利用了一种简单的策略,它不断触发奖励信号,因此它并没有去探索长期奖励的前景(例如活下去)。那么,如何弥补环境中可能存在更好机会的事实,而放弃当前的机会呢?如果我们希望智能体能够恰当地解决复杂环境,我们必须让它理解延迟满足的概念。很快,我们将看到深度强化学习如何尝试解决这些问题,从而催生出更复杂、更强大的系统,有些人甚至可能称其为异常敏锐。
通向人工通用智能的路径
以 AlphaGo 系统为例,该系统由总部位于英国的初创公司 DeepMind 开发,利用了一种深度强化学习的独特形式来为其预测提供依据。谷歌以 5 亿美元的价格收购 DeepMind 是有充分理由的,因为许多人认为 DeepMind 已经迈出了朝向人工通用智能(AGI)的第一步——如果你愿意的话,这可以看作是 AI 的圣杯。这一概念指的是人工智能系统在多种任务上表现良好的能力,而不是我们目前的网络在狭窄应用范围内的表现。一个通过观察自己在环境中行动来学习的系统,从精神上看(并且可能更快),与我们人类的学习方式相似。
我们在前几章构建的网络在狭窄的分类或回归任务中表现良好,但如果要执行其他任务,则必须进行显著的重新设计和重新训练。然而,DeepMind 展示了如何训练一个单一的网络,在多个不同(尽管狭窄)的任务上表现出色,这些任务包括玩几款经典的 Atari 2600 游戏。虽然这些游戏有些过时,但它们最初设计时就是为了给人类带来挑战,这使得这一成就成为 AI 领域的一个相当了不起的突破。在他们的研究中(deepmind.com/research/dqn/),DeepMind 展示了他们的深度 Q 网络(DQN)如何仅通过观察屏幕上的像素,而不需要任何关于游戏本身的先验信息,就能让人工代理玩不同的游戏。他们的工作激发了一波新研究人员,他们开始使用基于强化学习的算法训练深度学习网络,推动了深度强化学习的诞生。此后,研究人员和企业家们纷纷尝试利用这些技术应用于一系列场景,包括但不限于让机器像动物和人类一样移动、生成药物分子化合物,甚至创建能够在股市交易的机器人。
不用多说,这种系统在模拟现实世界事件方面更具灵活性,并可以应用于多种任务,从而减少训练多个独立狭窄系统所需的资源。未来,它们甚至可能揭示复杂且高维的因果关系,通过利用来自多个领域的训练示例来编码协同表示,这些表示反过来帮助我们解决更复杂的问题。我们的发现往往受到来自不同科学领域的信息启发,这有助于加深我们对这些情况及其复杂动态的理解。那么,为什么不让机器也来做这些呢?只要给定合适的奖励信号,机器在特定环境下的可能行动甚至可能超越我们自己的直觉!也许有一天你能在这方面提供帮助。现在,让我们先来看看如何模拟一个虚拟代理,并让它与环境互动,解决简单的问题。
模拟环境
首先,我们需要一个模拟环境。环境被定义为学习代理的互动空间。对于人类而言,环境可以是你一天中去的任何地方。对于人工智能代理而言,这通常是我们精心设计的模拟环境。为什么是模拟的呢?我们可以要求代理像我们一样在实时中学习,但事实证明这是相当不切实际的。首先,我们必须为每个代理设计一个身体,然后精确地设计其行为以及它们要与之互动的环境。此外,代理在模拟中训练得更快,无需局限于人类的时间框架。当机器在现实中完成一个任务时,其模拟版本可能已经完成了同一个任务好几次,为其从错误中学习提供了更好的机会。
接下来,我们将介绍一些基本术语,这些术语用于描述游戏,而游戏代表了一个代理需要在其中执行特定任务以获得奖励并解决环境的环境。
理解状态、行动和奖励
环境本身可以分解为一系列不同的状态,这些状态代表了代理可能遇到的不同情况。代理可以通过尝试不同的行动组合来在这些状态间导航(例如,在二维街机游戏中,向左或向右走、跳跃等)。代理所做的行动会有效地改变环境的状态,使得可用的工具、备用路线、敌人或游戏设计者可能隐藏的任何其他元素变得可用,从而使游戏更加有趣。这些对象和事件代表了学习环境在代理进行导航时可能经历的不同状态。每当代理在其先前的状态下与环境互动,或环境中发生随机事件时,环境就会生成一个新的状态。这就是游戏如何进行,直到达到终端状态,即游戏无法继续(因为胜利或死亡)。
本质上,我们希望代理采取及时且有利的行动来解决其环境。这些行动必须改变环境的状态,使代理更接近达成预定目标(如从 A 点到 B 点或最大化得分)。为了做到这一点,我们需要设计奖励信号,这些信号作为代理与环境中不同状态互动的结果发生。我们可以将奖励的概念视为反馈,允许我们的代理评估其行动所取得的成功程度,从而优化给定目标:

对于那些熟悉经典街机风格视频游戏的人来说,可以想象一场《马里奥》的游戏。马里奥本身是代理角色,由你来控制。环境指的是马里奥可以在其中移动的地图。金币和蘑菇的存在代表着游戏中的不同状态。一旦马里奥与这些状态中的任何一个互动,就会触发奖励,形式为积分,新的状态也会随之产生,进而改变马里奥的环境。马里奥的目标可以是从 A 点移动到 B 点(如果你急于完成游戏),或者是最大化他的得分(如果你更感兴趣的是解锁成就)。
一辆自动驾驶出租车
接下来,我们将通过观察人工智能代理如何解决环境问题,来澄清我们迄今为止所获得的理论理解。我们将看到,即使通过随机从代理的动作空间中采样动作(代理可能执行的动作),也能实现这一目标。这将帮助我们理解解决即使是最简单环境所涉及的复杂性,以及为什么我们可能很快需要调用深度强化学习来帮助我们实现目标。我们即将解决的目标是在一个简化的模拟环境中创建一辆自动驾驶出租车。尽管我们将处理的环境比现实世界简单得多,但这个模拟将作为深入强化学习系统设计架构的一个优秀跳板。
为了实现这个目标,我们将使用 OpenAI 的gym,一个恰如其名的模块,用于模拟人工环境以训练机器。你可以使用pip包管理器来安装 OpenAI gym 依赖。以下命令将在 Jupyter Notebooks 中运行并启动该模块的安装:
 ! pip install gym 
gym模块提供了大量预安装的环境(或测试问题),涵盖了从简单到复杂的各种模拟。我们将在以下示例中使用的测试问题来自'TaxiCab-v2'环境。我们将从所谓的出租车模拟开始实验,这个模拟简单地模拟了一张道路网格,出租车需要在其中穿行,以便接送顾客:
import numpy as np
import gym
from gym import envs
# This will print allavailable environemnts
# print(envs.registry.all())
理解任务
出租车模拟最早由(Dietterich 2000)提出,用以展示在分层方式应用强化学习时所遇到的问题。然而,我们将使用此模拟来巩固我们对智能体、环境、奖励和目标的理解,然后再继续模拟和解决更复杂的问题。目前,我们面临的问题相对简单:接送乘客并将其送到指定地点。共有四个目的地,这些地点用字母表示。我们的智能体只需前往这些接载地点,接上乘客,然后前往指定的下车地点,乘客可以下车。成功下车后,智能体会获得+20 点奖励(模拟我们虚拟出租车司机获得的报酬)。每经过一步,出租车司机在到达目的地前会被扣除-1 点奖励(直观上,这代表了出租车司机为补充油料而产生的费用)。最后,对于未按计划接载或下车的情况,还有一个-10 点的惩罚。你可以想象,惩罚接载的原因就像出租车公司试图优化其车队部署,以覆盖城市的各个区域,要求我们的虚拟出租车司机只接载指定乘客。而未按计划下车,则反映了顾客的不满和困惑。让我们来看一下出租车环境实际是什么样子的。
渲染环境
为了可视化我们刚加载的环境,我们必须首先通过在环境对象上调用reset()来初始化它。然后,我们可以渲染起始帧,显示出租车(黄色)的位置以及四个不同的接客地点(用不同颜色的字母表示):
# running env.reset() returns the initial state of the environment
print('Initial state of environment:' , env.reset())
env.render()
我们得到以下输出:

请注意,前面的截图中,开放的道路用冒号(:)表示,而出租车无法穿越的墙壁则用竖线(|)表示。虽然这些障碍物和道路的位置保持不变,但表示接载点的字母以及我们黄色出租车的位置在每次初始化环境时都会发生变化。我们还可以注意到,重置环境会生成一个整数。这表示环境的一个特定状态(即出租车和接载点的位置),这是在初始化时获得的。
你可以将Taxi-v2字符串替换为注册表中的其他环境(如CartPole-v0或MountainCar-v0),并渲染几帧来大致了解我们所处理的环境。还有一些其他命令可以帮助你更好地理解你所面对的环境。虽然出租车环境足够简单,可以使用彩色符号进行模拟,但更复杂的环境可能会在执行时在一个单独的窗口中渲染。
参考观察空间
接下来,我们将尝试更好地理解我们的环境和动作空间。出租车环境中的所有状态通过一个介于 0 到 499 之间的整数来表示。我们可以通过打印出环境中所有可能状态的总数来验证这一点。让我们来看一下我们环境可能的不同状态的数量:
env.observation_space.n
500
引用动作空间
在出租车模拟中,我们的司机智能体在每个时间步骤上有六个不同的动作可以执行。我们可以通过查看环境的动作空间来检查可能的动作总数,具体如下所示:
env.action_space.n
6
我们的司机在任何给定时刻都可以执行六个动作之一。这些动作分别对应向上、向下、向左或向右移动;接乘客;或把乘客放下。
与环境互动
要让我们的智能体执行某个动作,我们可以在环境对象上使用step()方法。step(i)方法接受一个整数,这个整数对应智能体允许执行的六个可能动作中的一个。在这个例子中,这些动作标记如下:
- 
(0) 表示向下移动 
- 
(1) 表示向上移动 
- 
(2) 表示右转 
- 
(3) 表示左转 
- 
(4) 表示接乘客 
- 
(5) 表示放下乘客 
以下是代码的表示方式:
#render current position
env.render()
#move down
env.step(0)
#render new position
env.render()
我们得到以下输出:

如我们所见,我们让智能体向下执行一个步骤。现在,我们理解了如何让智能体执行所有必要的步骤以达成目标。事实上,调用环境对象的step(i)会返回四个特定的变量,这些变量描述了从智能体的角度来看,动作(i)对环境的影响。这些变量如下:
- 
observation:这是环境的观测状态。它可以是来自游戏截图的像素数据,或者是以其他方式表示环境状态的形式,以便学习智能体理解。
- 
reward:这是我们为智能体提供的奖励,用来表示智能体在某个时间步骤执行某个动作后的补偿。我们利用奖励为学习智能体设定目标,方法是要求其最大化在特定环境中获得的奖励。注意,奖励(浮动)值的尺度可能会根据实验设置有所不同。
- 
done:这个布尔(布尔值)变量表示一个试验是否已结束。在出租车模拟中,当乘客被接上并在指定位置放下时,认为这一轮试验已完成。在一款雅达利游戏中,试验可以定义为智能体的生命,直到被外星人击中而结束。
- 
info:这个字典项用于存储调试智能体动作时所需要的信息,通常在学习过程中不使用。然而,它存储了有价值的信息,比如影响前一个状态变化的概率,在给定步骤中:
# env.step(i) will return four variables
# They are defiend as (in order) the state, reward, done, info
env.step(1)
(204, -1, False, {'prob': 1.0})
随机解决环境
在掌握了 OpenAI gym 环境的逻辑和如何让人工智能体在其中互动之后,我们可以继续实现一个随机算法,让智能体(最终)解决出租车环境。首先,我们定义一个固定状态来开始我们的仿真。如果你想重复同一个实验(即用相同的状态初始化环境),这将非常有帮助,同时可以检查智能体在每次任务中解决环境时采取了多少随机步骤。我们还定义了一个counter变量,用于简单地跟踪智能体在任务进展过程中所采取的时间步骤数。奖励变量初始化为None,并将在智能体采取第一步时更新。然后,我们简单地启动一个while循环,反复从我们的动作空间中随机选择可能的动作,并更新每个选定动作的state、reward和done变量。为了从环境的动作空间中随机选择动作,我们使用.sample()方法操作env.action_space对象。最后,我们增加counter,并渲染环境以便可视化:
# Overriding current state, used for reproducibility
state = env.env.s = 114
# counter tracks number of moves made
counter = 0
#No reward to begin with
reward = None
dropoffs = 0
#loop through random actions until successful dropoff (20 points)
while reward != 20:
    state, reward, done, info = env.step(env.action_space.sample())
    counter += 1
    print(counter)
    env.render()
print(counter, dropoffs)
我们得到以下输出:

直到第 2,145 次尝试,我们的智能体才找到了正确的乘客(如出租车变绿所示)。即便你可能没有感到时间的流逝,这也是相当长的。随机算法有助于在调用更复杂的模型时作为基准,确保结果的合理性。但显然,我们可以做得比智能体运行在随机算法上所用的 6,011 步(解决这个简单环境)要好。怎么做呢?我们奖励它做对的事情。为此,我们必须首先在数学上定义奖励的概念。
立刻奖励与未来奖励之间的权衡
初看之下,这可能显得相当简单。我们已经看到,出租车司机通过正确送客获得+20 分,错误送客扣-10 分,每个时间步骤完成一轮任务时扣-1 分。逻辑上,你可以将一个智能体在一次任务中的总奖励计算为该智能体在每个时间步骤所获得的所有个别奖励的累积。我们可以通过数学表示来表示这一点,并表示一次任务中的总奖励如下:

这里,n仅仅表示任务的时间步骤。这似乎是直观的。现在,我们可以要求智能体最大化给定任务中的总奖励。但问题来了。就像我们自己所处的现实一样,智能体所面临的环境可能主要由随机事件主导。因此,无法保证在相似的未来状态下执行相同的动作会返回相同的奖励。事实上,随着我们进入未来,由于固有的随机性,奖励可能会越来越偏离每个状态下采取的相应动作所带来的奖励。
对未来奖励的折扣
那么,我们如何弥补这种差异呢?一种方法是通过对未来奖励进行折扣,从而增强当前奖励相对于未来时间步奖励的重要性。我们可以通过在每个时间步生成奖励时加入折扣因子来实现这一点,同时在计算给定回合的总奖励时应用它。折扣因子的目的是减弱未来奖励并增强当前奖励。从短期来看,通过使用相应的状态-行动对,我们更有把握获得奖励。然而,由于环境中随机事件的累积效应,从长期来看就无法做到这一点。因此,为了激励智能体集中于相对确定的事件,我们可以修改之前的总奖励公式,加入这个折扣因子,如下所示:

在我们新的总奖励公式中,γ 表示一个介于 0 和 1 之间的折扣因子,t 表示当前的时间步。如你所见,γ 项的指数递减使得未来的奖励相对于当前奖励被减弱。直观地说,这意味着在智能体考虑下一步行动时,较远的未来奖励会比更接近的奖励被考虑得少。那么,减弱的程度如何呢?这仍然由我们决定。一个接近零的折扣因子会产生短视的策略,享乐主义地偏向即时奖励而非未来奖励。另一方面,如果折扣因子过于接近 1,折扣因子的作用将被完全抵消。实际上,根据环境中的随机性程度,折扣因子的平衡值通常在 0.75 到 0.9 之间。作为经验法则,较为确定性环境中需要较高的 γ 值,而较为随机的环境中需要较低的 γ 值。我们甚至可以像下面这样简化之前给出的总奖励公式:

因此,我们将回合中的总奖励形式化为每个时间步的累计折扣奖励。通过使用折扣未来奖励的概念,我们可以为智能体生成策略,从而指导其行动。执行有利策略的智能体将会选择那些在给定回合中最大化折扣未来奖励的行动。现在,我们已经大致了解了如何为智能体设计奖励信号,是时候继续研究整个学习过程的概述了。
马尔可夫决策过程
在强化学习中,我们试图解决将即时行动与其返回的延迟奖励相关联的问题。这些奖励仅仅是稀疏的、时间延迟的标签,用于控制智能体的行为。到目前为止,我们已经讨论了智能体如何根据环境的不同状态进行行动。我们还看到了交互如何为智能体生成不同的奖励,并解锁环境的新状态。从这里开始,智能体可以继续与环境进行交互,直到一回合结束。现在是时候数学上形式化这些智能体与环境之间的关系,以便进行目标优化了。为此,我们将借用由俄罗斯数学家安德烈·马尔可夫提出的框架,现在称为马尔可夫决策过程(MDP)。
这个数学框架使我们能够建模智能体在部分随机且部分可控的环境中的决策过程。这个过程依赖于马尔可夫假设,假设未来状态的概率(st+1)仅依赖于当前状态(st)。这一假设意味着,所有导致当前状态的状态和动作对未来状态的概率没有影响。MDP 由以下五个变量定义:

虽然前两个变量相当直观,第三个变量(R)指的是给定一个状态-动作对下的奖励概率分布。这里,状态-动作对仅指给定环境状态下应采取的相应动作。接下来是转移概率(P),它表示在某一时间步下,给定选择的状态-动作对后得到新状态的概率。
最后,折扣因子指的是我们希望将未来奖励折扣到更即时奖励的程度,以下图说明:

左:强化学习问题。右:马尔可夫决策过程
因此,我们可以使用 MDP 描述智能体与环境之间的交互。一个 MDP 由一系列状态和动作组成,并包含规则,规定了从一个状态到另一个状态的转移。我们现在可以数学地定义一次回合为一个有限的状态、动作和奖励序列,如下所示:

在这里,(s[t]) 和 (a[t]) 表示时间 t 时的状态和相应的动作。我们还可以将与该状态-动作对对应的奖励表示为 (r[t+1])。因此,我们通过从环境中采样一个初始状态 (s[o]) 来开始一个回合。接下来,直到目标完成,我们要求智能体为其所处环境的相应状态选择一个动作。一旦智能体执行了一个动作,环境就会为智能体采取的动作以及接下来的状态 (s[t+1]) 采样一个奖励。然后,智能体在接收到奖励和下一个状态后,会重复这个过程,直到它能够解决环境中的问题。最后,在回合结束时,会到达一个终止状态 (s[n])(也就是说,当目标完成时,或者在游戏中用完所有生命时)。决定智能体在每个状态下采取动作的规则统称为策略,并用希腊字母 (π) 表示。
理解策略函数
如我们所见,智能体解决一个环境的效率取决于它在每个时间步骤中使用什么策略来匹配状态-动作对。因此,一个被称为策略函数(π)的函数可以指定智能体在每个时间步骤上遇到的状态-动作对的组合。随着模拟的进行,策略负责生成轨迹,轨迹由游戏状态、智能体作为响应所采取的动作、环境生成的奖励以及智能体接收到的游戏下一个状态组成。直观地讲,你可以将策略看作是一个启发式方法,它生成响应环境状态的动作。策略函数本身可以是好的,也可以是坏的。如果你的策略是先开枪再问问题,最终你可能会误伤人质。因此,我们现在要做的就是评估不同的策略(即它们产生的轨迹,包括状态、动作、奖励和下一个状态的序列),并挑选出能最大化给定游戏的累计折扣奖励的最优策略 (π *)。这可以通过以下数学公式来表示:

因此,如果我们试图从 A 点移动到 B 点,我们的最优策略将包括采取一个使我们在每个时间步尽可能靠近 B 点的行动。不幸的是,由于在这样的环境中存在随机性,我们不能如此确定地声称我们的策略绝对最大化了折扣奖励的总和。根据定义,我们无法考虑在从 A 点到 B 点的过程中发生的某些随机事件,例如地震(假设你不是地震学专家)。因此,我们也无法完美地考虑因环境中的随机性而产生的动作所带来的奖励。相反,我们可以将最优策略(π*)定义为一种让我们的代理最大化预期的折扣奖励之和的策略。这可以通过对先前方程式进行轻微修改来表示,表示如下:

在这里,我们使用 MDP 框架,并从我们的状态概率分布 p(s[o])中采样初始状态(s[o])。代理的动作(a[t])根据给定状态从策略中采样。然后,采样一个奖励,对应于在给定状态下执行的动作的效用。最后,环境从当前状态-动作对的转移概率分布中采样下一个状态(s[t+1])。因此,在每个时间步,我们的目标是更新最优策略,以最大化预期的折扣奖励之和。你们中的一些人可能会在此时想,如何在给定状态的情况下评估一个动作的效用呢?嗯,这就是值函数和 Q 值函数发挥作用的地方。为了评估不同的策略函数,我们需要能够评估不同状态的值,以及给定策略下与这些状态对应的动作质量。为此,我们需要定义两个额外的函数,分别是值函数和 Q 值函数。
评估状态的值
首先,我们需要估计在遵循特定策略(π)下,状态(s)的值(V)。这告诉你在遵循从状态(s)开始的策略(π)下,游戏结束时的预期累计奖励。为什么这个有用呢?想象一下,我们的学习代理环境中充满了不断追赶代理的敌人。它可能已经形成了一个策略,指示它在整个游戏过程中永远不停跑。在这种情况下,代理应该具有足够的灵活性来评估游戏状态的值(例如,当它跑到悬崖边缘时,以避免跑下去并死亡)。我们可以通过定义给定状态下的值函数,V π (s),作为代理在遵循该策略时,从当前状态开始的预期累计(折扣)奖励来实现这一点:

因此,我们能够使用价值函数来评估在遵循特定策略时,某个状态的优劣。然而,这仅仅告诉我们给定策略下一个状态本身的价值。我们还希望我们的智能体能够判断某个动作在特定状态下的价值。这正是允许智能体根据环境中的任何情况(无论是敌人还是悬崖边缘)动态做出反应的关键。我们可以通过使用 Q 值函数来实现这一概念,将给定状态-动作对在特定策略下的“优良”程度量化出来。
评估动作质量
如果你走到墙前,能执行的动作不多。你可能会通过选择掉头动作来回应这个状态,接着会问自己为什么一开始走到墙前。同样地,我们希望我们的智能体能根据它所处状态的不同,利用一个关于动作优劣的感知,同时遵循策略。我们可以通过使用 Q 值函数来实现这一目标。这个函数简单地表示在特定状态下采取特定动作时,遵循一个策略所期望的累积奖励。换句话说,它表示给定策略下,状态-动作对的质量。数学上,我们可以表示 Q π ( a , s) 关系如下:

Q π ( s , a) 函数允许我们表示遵循一个策略(π)所获得的期望累积奖励。直观地说,这个函数帮助我们量化在游戏结束时的总得分,给定每个状态(即你观察到的游戏画面)下所采取的动作(即不同的摇杆控制操作),并遵循一个策略(例如:在跳跃时向前移动)。通过这个函数,我们可以定义在游戏的终止状态下,给定所遵循的策略时,最好的期望累积奖励。这可以表示为 Q 值函数能够达到的最大期望值,称为最优 Q 值函数。我们可以通过以下数学公式来定义它:

现在,我们有一个函数,该函数量化了在给定策略下,状态-动作对的期望最优值。我们可以使用这个函数来预测在给定游戏状态下应该采取的最优动作。然而,我们如何评估预测的真实标签呢?我们并没有给游戏屏幕标注对应的目标动作,因此无法评估我们的网络偏差有多大。这时,贝尔曼方程派上用场了,它帮助我们评估给定状态-动作对的值,作为当前产生的奖励和随后的游戏状态值的函数。然后,我们可以利用这个函数来比较网络的预测结果,并通过反向传播误差来更新模型权重。
使用贝尔曼方程
贝尔曼方程是由美国数学家理查德·贝尔曼提出的,它是驱动深度 Q 学习的主要方程之一。它本质上让我们能够解决之前所定义的马尔可夫决策过程。直观地说,贝尔曼方程做了一个简单的假设。它声明,对于一个给定的状态下执行的动作,最大未来奖励是当前奖励加上下一个状态的最大未来奖励。为了类比棉花糖实验,两个棉花糖的最大可能奖励是通过在第一时刻自我克制(奖励为 0 个棉花糖)然后在第二时刻收集(奖励为两个棉花糖)来实现的。
换句话说,给定任何状态-动作对,在给定状态(s)下执行动作(a)的质量(Q)等于将要收到的奖励(r),以及智能体最终到达的下一个状态(s')的价值。因此,只要我们能够估计下一个时间步的最优状态-动作值,Q(s',a'),就能计算当前状态的最优动作。正如我们在棉花糖实验中所看到的那样,智能体需要能够预见到未来某个时刻可能获得的最大奖励(两个棉花糖),以便在当前时刻拒绝只获得一个棉花糖。使用贝尔曼方程,我们希望智能体采取最大化当前奖励(r)以及下一个状态-动作对的最优 Q值的动作,Q(s',a')*,并且考虑折扣因子 gamma(y)。用更简单的话来说,我们希望它能够计算当前状态下动作的最大预期未来奖励。这可以转化为以下公式:

现在,我们知道如何在数学上估计给定状态下执行一个动作的预期质量。我们也知道如何估计遵循特定策略时,状态-动作对的最大预期奖励。从这里,我们可以重新定义给定状态(s)下的最优策略(π*),即给定状态下的动作最大预期 Q 值。这可以表示如下:

最后,我们已经具备了所有拼图的部分,实际上可以尝试找到一个最优策略(π)来引导我们的智能体。这个策略将允许我们的智能体通过为环境生成的每个状态选择理想的动作,从而最大化预期的折扣奖励(考虑环境的随机性)。那么,我们究竟该如何进行操作呢?一个简单的非深度学习解决方案是使用价值迭代算法来计算未来时间步的动作质量( Qt+1 ( s , a )),作为期望当前奖励(r)和下一个游戏状态的最大折扣奖励(γ max a Qt ( s' , a' )*)的函数。从数学上讲,我们可以将其表示如下:

在这里,我们基本上会不断迭代更新贝尔曼方程,直到 Qt 收敛到 Q,随着t*的增加,直到无限远。我们实际上可以通过直接实现贝尔曼方程来测试它在解决出租车模拟问题中的估计效果。
迭代更新贝尔曼方程
你可能记得,采用随机方法解决出租车模拟时,我们的智能体大约需要 6,000 个时间步。有时,凭借纯粹的运气,你可能会在 2,000 个时间步以内解决它。然而,我们可以通过实现一个贝尔曼方程的版本进一步提升我们的成功概率。这种方法将基本上允许我们的智能体通过使用 Q 表记住每个状态下的动作及其相应的奖励。我们可以在 Python 中使用 NumPy 数组实现这个 Q 表,数组的维度对应于我们出租车模拟中的观察空间(可能的不同状态的数量)和动作空间(智能体可以执行的不同动作的数量)。回想一下,出租车模拟的环境空间是 500,动作空间是六个,因此我们的 Q 表是一个 500 行六列的矩阵。我们还可以初始化一个奖励变量(R)和一个折扣因子(gamma)的值:
#Q-table, functions as agent's memory of state action pairs
Q = np.zeros([env.observation_space.n, env.action_space.n])
#track reward
R = 0
#discount factor
gamma = 0.85
# Track successful dropoffs
dropoffs_done = 0
#Run for 1000 episodes
for episode in range(1,1001):
    done = False
    #Initialize reward
    R, reward = 0,0
    #Initialize state
    state = env.reset()
    counter=0
    while done != True:
            counter+=1
            #Pick action with highest Q value
            action = np.argmax(Q[state]) 
            #Stores future state for compairason
            new_state, reward, done, info = env.step(action)
            #Update state action pair using reward and max Q-value for the new state
            Q[state,action] += gamma * (reward + np.max(Q[new_state]) - Q[state,action]) 
            #Update reward
            R += reward  
            #Update state
            state = new_state
            #Check how many times agent completes task
            if reward == 20:
                dropoffs_done +=1
    #Print reward every 50 episodes        
    if episode % 50 == 0:
        print('Episode {}   Total Reward: {}   Dropoffs done: {}  Time-Steps taken {}'
              .format(episode,R, dropoffs_done, counter))
Episode 50 Total Reward: -30 Dropoffs done: 19 Time-Steps taken 51
Episode 100 Total Reward: 14 Dropoffs done: 66 Time-Steps taken 7
Episode 150 Total Reward: -5 Dropoffs done: 116 Time-Steps taken 26
Episode 200 Total Reward: 14 Dropoffs done: 166 Time-Steps taken 7
Episode 250 Total Reward: 12 Dropoffs done: 216 Time-Steps taken 9
Episode 300 Total Reward: 5 Dropoffs done: 266 Time-Steps taken 16
然后,我们只需循环执行一千个回合。在每个回合中,我们初始化环境的状态,设置一个计数器来跟踪已执行的下车操作,以及奖励变量(总奖励:R和每回合奖励:r)。在我们的第一个循环中,我们再次嵌套另一个循环,指示智能体选择具有最高 Q 值的动作,执行该动作,并存储环境的未来状态及获得的奖励。这个循环会一直执行,直到回合结束,由布尔变量 done 表示。
接下来,我们在 Q 表中更新状态-动作对,并更新全局奖励变量(它表示我们的智能体整体表现如何)。算法中的 alpha 项(α)表示学习率,它有助于控制在更新 Q 表时前一个 Q 值与新生成的 Q 值之间的变化量。因此,我们的算法通过在每个时间步近似最优 Q 值来迭代更新状态-动作对的质量(Q[state, action])。随着这个过程不断重复,我们的智能体最终会收敛到最优的状态-动作对,正如 Q*所示。
最后,我们更新状态变量,用新的状态变量重新定义当前状态。然后,循环可以重新开始,迭代地更新 Q 值,并理想情况下收敛到存储在 Q 表中的最优状态-动作对。我们每 50 个回合输出一次环境采样的整体奖励,作为代理动作的结果。我们可以看到,代理最终会收敛到任务的最优奖励(即考虑到每个时间步的旅行成本,以及正确的下车奖励的最优奖励),这个任务的最优奖励大约在 9 到 13 分之间。你还会注意到,到第 50^(回合)时,我们的代理在 51 个时间步内成功完成了 19 次下车!这个方法的表现明显优于我们之前实现的随机方法。
为什么使用神经网络?
正如我们刚才所看到的,基本的价值迭代方法可以用来更新贝尔曼方程,并通过迭代地寻找理想的状态-动作对,从而最优地导航给定的环境。这个方法实际上在每一个时间步都会存储新的信息,不断地让我们的算法变得更加智能。然而,这个方法也有一个问题,那就是它根本不可扩展!出租车环境足够简单,只有 500 个状态和 6 个动作,可以通过迭代更新 Q 值来解决,从而估计每个状态-动作对的价值。然而,更复杂的模拟环境,比如视频游戏,可能会有数百万个状态和数百个动作,这就是为什么计算每个状态-动作对的质量变得在计算上不可行且在逻辑上低效的原因。在这种情况下,我们唯一能做的就是尝试使用加权参数的网络来逼近函数Q(a,s)。
因此,我们进入了神经网络的领域,正如我们现在已经很清楚的,神经网络是非常优秀的函数逼近器。我们即将体验的深度强化学习的特殊形式叫做深度 Q 学习(Deep Q-learning),它的名字来源于它的任务:学习给定状态-动作对的最优 Q 值。更正式地说,我们将使用神经网络通过模拟代理的状态、动作和奖励序列来逼近最优函数Q(s,a)*。通过这样做,我们可以迭代更新我们的模型权重(theta),朝着最匹配给定环境的最优状态-动作对的方向前进:

在 Q 学习中执行前向传递
现在,你理解了使用神经网络逼近最优函数Q(s,a)的直觉,找到给定状态下的最佳动作。不言而喻,状态序列的最优动作序列将生成一个最优的奖励序列。因此,我们的神经网络试图估计一个函数,可以将可能的动作映射到状态,从而为整个剧集生成最优的奖励。正如你也会回忆起的,我们需要估计的最优质量函数Q(s,a)必须满足贝尔曼方程。贝尔曼方程简单地将最大可能的未来奖励建模为当前时刻的奖励加上紧接着的下一个时间步骤的最大可能奖励:

因此,我们需要确保在预测给定时间的最优 Q 值时,贝尔曼方程中规定的条件得以保持。为此,我们可以将该模型的整体损失函数定义为最小化贝尔曼方程和实际预测中的误差。换句话说,在每次前向传播时,我们计算当前状态-动作质量值Q (s, a ; θ)与由贝尔曼方程在该时间(Y[t])所表示的理想值之间的差距。由于由贝尔曼方程表示的理想预测正在被迭代更新,我们实际上是在使用一个移动目标变量(Y[t])来计算我们模型的损失。这可以通过数学公式表示如下:

因此,我们的网络将通过最小化一系列损失函数Lt来训练,在每个时间步骤中进行更新。这里,术语y[t]是我们在时间(t)进行预测时的目标标签,并在每个时间步骤中持续更新。另请注意,术语ρ(s, a)仅表示我们模型在序列 s 和执行的动作上的内部概率分布,也称为其行为分布。正如你所看到的,在优化给定时间(t)的损失函数时,前一时刻(t-1)的模型权重保持不变。虽然这里展示的实现使用相同的网络进行两个独立的前向传播,但后来的 Q 学习变体(Mihn 等,2015 年)使用两个独立的网络:一个用于预测满足贝尔曼方程的移动目标变量(称为目标网络),另一个用于计算给定时间的模型预测。现在,让我们看看反向传播是如何在深度 Q 学习中更新我们的模型权重的。
在 Q 学习中执行反向传播
现在,我们有了一个定义好的损失度量,它计算给定时间点最优 Q 函数(由 Bellman 方程推导得出)与当前 Q 函数之间的误差。然后,我们可以将 Q 值中的预测误差反向传播,通过模型层进行反向传播,就像我们的网络在环境中进行探索一样。正如我们现在已经非常清楚的那样,这通过对损失函数关于模型权重的梯度进行求解,并根据每个学习批次在梯度的反方向上更新这些权重来实现。因此,我们可以在最优 Q 值函数的方向上迭代地更新模型权重。我们可以像这样表述反向传播过程,并说明模型权重(theta)的变化:

最终,随着模型看到足够的状态-动作对,它将充分反向传播误差并学习最优的表示,从而帮助其在给定环境中导航。换句话说,一个经过训练的模型将具有理想的层权重配置,对应于最优的 Q 值函数,映射代理在环境给定状态下的动作。
长话短说,这些方程描述了估算最优策略(π*)以解决给定环境的过程。我们使用神经网络学习在给定环境中状态-动作对的最佳 Q 值,这反过来可以用来计算生成最优奖励的轨迹,为我们的代理(即最优策略)。这就是我们如何使用强化学习来训练在稀疏时间延迟奖励的准随机模拟中运行的预期性和反应性代理。现在,我们已经掌握了所有实施我们自己的深度强化学习代理所需的理解。
用深度学习替代迭代更新
在我们开始实现之前,让我们澄清一下迄今为止我们在深度 Q 学习方面所学到的内容。正如我们在迭代更新方法中所看到的,我们可以使用转移(从初始状态、执行的动作、生成的奖励、以及采样的新状态,< s, a, r, s' >)来更新 Q 表,在每个时间步骤保存这些元组的值。然而,正如我们提到的,这种方法在计算上不可扩展。相反,我们将替代这种在 Q 表上进行的迭代更新,并尝试使用神经网络近似最优的 Q 值函数 (Q(s,a)*),如下所示:
- 
使用当前状态(s)作为输入执行前馈传递,然后预测该状态下所有动作的 Q 值。 
- 
使用新的状态(s')执行前馈传递,计算网络在下一个状态下的最大总体输出,即 max a' Q(s', a')。 
- 
在步骤 2 中计算出最大整体输出后,我们将目标 Q 值设置为所选动作的r + γmax a' Q(s', a')。我们还将所有其他动作的目标 Q 值设置为步骤 1 返回的相同值,针对每个未选择的动作,仅计算所选动作的预测误差。这有效地消除了(设为零)未被我们的智能体在每个时间步选择的预测动作带来的误差影响。 
- 
反向传播误差以更新模型权重 
我们在这里所做的只是建立一个能够对动态目标进行预测的网络。这非常有用,因为随着模型在玩游戏的过程中不断获得更多的环境物理信息,它的目标输出(在给定状态下执行的动作)也不断变化,这不同于监督学习,在监督学习中我们有固定的输出,称之为标签。因此,我们实际上是在尝试学习一个可以学习不断变化的输入(游戏状态)与输出(相应动作)之间映射关系的函数 Q(s,a)。
通过这个过程,我们的模型在执行动作时形成更好的直觉,并随着它看到更多的环境信息,获得关于状态-动作对正确 Q 值的更清晰认识。从理论上讲,Q 学习通过将奖励与在先前游戏状态中所采取的动作相关联来解决信用分配问题。误差会被反向传播,直到我们的模型能够识别出决定性的状态-动作对,这些对负责生成给定的奖励。然而,我们很快会看到,为了让深度 Q 学习系统如预期那样工作,使用了许多计算和数学技巧。在我们深入研究这些考虑因素之前,了解深度 Q 网络中前向和反向传播的过程可能会有所帮助。
Keras 中的深度 Q 学习
现在我们已经理解了如何训练智能体选择最佳的状态-动作对,接下来让我们尝试解决一个比之前的出租车模拟更复杂的环境。为什么不实现一个学习智能体来解决一个最初为人类设计的问题呢?好吧,感谢开源运动的奇迹,这正是我们将要做的。接下来,我们将实施 Mnih 等人(2013 年和 2015 年)的方法,参考原始的 DeepMind 论文,该论文实现了基于 Q 学习的智能体。研究人员使用相同的方法和神经架构来玩七种不同的 Atari 游戏。值得注意的是,研究人员在七个测试的不同游戏中,有六个取得了显著的成绩。在这六个游戏中的三款,智能体的表现超过了人类专家。这就是为什么今天,我们试图部分复现这些结果,并训练一个神经网络来玩一些经典游戏,如《太空入侵者》和《吃豆人》。
这是通过使用卷积神经网络(CNN)完成的,该网络将视频游戏截图作为输入,并估计给定游戏状态下动作的最优 Q 值。为了跟上进度,你只需要做的是安装建立在 Keras 基础上的强化学习包,名为keras-rl。你还需要为 OpenAI gym模块安装 Atari 依赖,这是我们之前使用过的。Atari 依赖本质上是一个为 Atari 主机设计的模拟器,将生成我们的训练环境。虽然该依赖最初是为 Ubuntu 操作系统设计的,但它已经被移植并与 Windows 和 Mac 用户兼容。你可以使用pip包管理器安装这两个模块,以便进行以下实验。
- 你可以使用以下命令安装 Keras 强化学习包:
 ! pip install keras-rl
- 你可以使用以下命令为 Windows 安装 Atari 依赖:
! pip install --no-index -f https://github.com/Kojoley/atari- 
  py/releases atari_py
- Mnih 等人(2015)arxiv.org/pdf/1312.5602v1.pdf
导入一些库
在机器智能领域,实现人类水平的控制一直是一个长期的梦想,尤其是在游戏等任务中。涉及到的复杂性包括自动化智能体的操作,该操作仅依赖于高维感知输入(如音频、图像等),这在强化学习中一直是一个非常具有挑战性的任务。之前的方法主要依赖于手工设计的特征,结合了过于依赖工程特征质量的线性策略表示,以便取得良好的表现。与以往的尝试不同,这种技术不需要我们的智能体拥有任何关于游戏的人工设计知识。它将完全依赖它所接收到的像素输入,并编码表示,以预测在其遍历的每个环境状态下每个可能动作的最优 Q 值。挺酷的,是吧?让我们将以下库导入到工作区,以便继续进行此任务:
from PIL import Image
import numpy as np
import gym
from keras.models import Sequential
from keras.layers import Dense, Activation, Flatten, Convolution2D, Permute
from keras.optimizers import Adam
import keras.backend as K
from rl.agents.dqn import DQNAgent
from rl.policy import LinearAnnealedPolicy, BoltzmannQPolicy, EpsGreedyQPolicy
from rl.memory import SequentialMemory
from rl.core import Processor
from rl.callbacks import FileLogger, ModelIntervalCheckpoint
预处理技术
正如我们之前提到的,我们将使用卷积神经网络(CNN)对展示给我们智能体的每个状态进行编码,提取代表性的视觉特征。我们的 CNN 将继续回归这些高级表示与最优 Q 值,这些 Q 值对应于每个给定状态下应该采取的最优行动。因此,我们必须向我们的网络展示一系列输入, corresponding to the sequence of screenshots you would see, when playing an Atari game.
如果我们正在玩《太空侵略者》(Atari 2600),这些截图大致会是这样的:

原始的 Atari 2600 游戏画面帧,设计时旨在让人眼前一亮,符合 70 年代的美学,尺寸为 210 x 160 像素,色彩方案为 128 色。尽管按顺序处理这些原始帧可能在计算上要求较高,但请注意,我们可以从这些帧中对训练图像进行降采样,从而得到更易于处理的表示。实际上,这正是 Minh 等人所采用的方法,将输入维度降至更易管理的大小。这是通过将原始 RGB 图像降采样为 110 x 84 像素的灰度图像实现的,然后裁剪掉图像的边缘部分,因这些部分没有太多变化。这使得我们最终得到了 84 x 84 像素的图像大小。这一维度的减少有助于我们的 CNN 更好地编码代表性的视觉特征,遵循我们在第四章《卷积神经网络》中讲解的理论。
定义输入参数
最终,我们的卷积神经网络将按批次接收这些裁剪后的图像,每次四张。使用这四帧图像,神经网络将被要求估算给定输入帧的最优 Q 值。因此,我们定义了我们的输入形状,指的是经过预处理的 84 x 84 游戏画面帧的大小。我们还定义了一个窗口长度为4,它仅仅指的是我们的网络每次看到的图像数量。对于每一张图像,网络将对最优 Q 值进行标量预测,该 Q 值最大化了我们智能体可以获得的预期未来奖励:

创建 Atari 游戏状态处理器
由于我们的网络只能通过输入图像观察游戏状态,我们必须首先构建一个 Python 类,允许我们的深度 Q 学习智能体(DQN)处理由 Atari 模拟器生成的状态和奖励。这个类将接受一个处理器对象,处理器对象指的是智能体与其环境之间的耦合机制,正如在keras-rl库中实现的那样。
我们正在创建AtariProcessor类,因为我们希望使用相同的网络在不同的环境中进行操作,每个环境具有不同类型的状态、动作和奖励。这背后的直觉是什么呢?嗯,想想太空入侵者游戏和吃豆人游戏之间的游戏画面和可能的操作差异。虽然太空入侵者游戏中的防御者只能左右移动并开火,但吃豆人可以上下左右移动,以应对环境的不同状态。自定义的处理器类帮助我们简化不同游戏之间的训练过程,而不必对学习智能体或观测环境做过多修改。我们将实现的处理器类将允许我们简化不同游戏状态和奖励的处理,这些状态和奖励是通过智能体对环境的作用生成的:
class AtariProcessor(Processor):
    def process_observation(self, observation):
        # Assert dimension (height, width, channel) 
        assert observation.ndim == 3 
        # Retrieve image from array
        img = Image.fromarray(observation) 
        # Resize and convert to grayscale
        img = img.resize(INPUT_SHAPE).convert('L') 
        # Convert back to array
        processed_observation = np.array(img) 
        # Assert input shape
        assert processed_observation.shape == INPUT_SHAPE 
        # Save processed observation in experience memory (8bit)
        return processed_observation.astype('uint8')
   def process_state_batch(self, batch):
      #Convert the batches of images to float32 datatype
       processed_batch = batch.astype('float32') / 255.
       return processed_batch
   def process_reward(self, reward):
       return np.clip(reward, -1., 1.) # Clip reward
处理单独的状态
我们的处理器类包含三个简单的函数。第一个函数(process_observation)接收一个表示模拟游戏状态的数组,并将其转换为图像。然后,这些图像被调整大小、转换回数组,并作为可管理的数据类型返回给经验记忆(这是一个我们稍后将详细说明的概念)。
批量处理状态
接下来,我们有一个(process_state_batch)函数,它批量处理图像并将其作为float32数组返回。虽然这个步骤也可以在第一个函数中实现,但我们单独处理的原因是为了提高计算效率。按照简单的数学规律,存储一个float32数组比存储一个 8 位数组需要更多四倍的内存。由于我们希望将观察结果存储在经验记忆中,因此我们宁愿将它们存储为可管理的表示形式。当处理给定环境中的数百万个状态时,这一点变得尤为重要。
处理奖励
最后,我们类中的最后一个函数使用(process_reward)函数对从环境中生成的奖励进行裁剪。为什么这么做呢?让我们先考虑一下背景信息。当我们让智能体在真实且未经修改的游戏中进行训练时,这种奖励结构的变化仅在训练过程中进行。我们不会让智能体使用游戏画面中的实际得分,而是将正奖励和负奖励分别固定为+1 和-1。奖励为 0 的部分不受此裁剪操作的影响。这样做在实践中非常有用,因为它可以限制我们在反向传播网络误差时的导数规模。此外,由于智能体不需要为全新的游戏类型学习新的评分方案,因此在不同学习环境中实现相同的智能体变得更加容易。
奖励裁剪的局限性
如 DeepMind 论文(Minh 等,2015)所指出,奖励裁剪的一个明显缺点是,这个操作使得我们的智能体无法区分不同大小的奖励。这一点对于更复杂的模拟环境来说肯定会有影响。例如,考虑一辆真实的自动驾驶汽车。控制的人工智能智能体可能需要评估在给定环境状态下,采取困境性行动时,奖励/惩罚的大小。也许该智能体面临的选择是为了避免更严重的事故,选择撞到行人。这种局限性,然而,对我们智能体在 Atari 2600 游戏中应对简单学习环境的能力似乎并不会产生严重影响。
初始化环境
接下来,我们只需使用之前添加到可用gym环境中的 Atari 依赖(无需单独导入),初始化太空入侵者环境:
env = gym.make('SpaceInvaders-v0')
np.random.seed(123)
env.seed(123)
nb_actions = env.action_space.n
我们还生成了一个随机种子来始终如一地初始化环境的状态,以便进行可重复的实验。最后,我们定义了一个变量,表示代理在任何给定时刻可以执行的动作数量。
构建网络
从直观上思考我们的这个问题,我们正在设计一个神经网络,该网络接收来自环境的游戏状态序列。在序列的每个状态下,我们希望我们的网络预测出具有最高 Q 值的动作。因此,我们网络的输出将指代每个可能游戏状态下的每个动作的 Q 值。因此,我们首先定义了几个卷积层,随着层数的增加,滤波器数量逐渐增多,步幅长度逐渐减少。所有这些卷积层都使用修正线性单元 (ReLU) 激活函数。接下来,我们添加了一个展平层,将卷积层的输出维度缩减为向量表示。
然后,这些表示会被输入到两个密集连接的层中,这些层执行游戏状态与动作的 Q 值回归:
input_shape = (WINDOW_LENGTH,) + INPUT_SHAPE
# Build Conv2D model
model = Sequential()
model.add(Permute((2, 3, 1), input_shape=input_shape))
model.add(Convolution2D(32, (8, 8), strides=(4, 4), activation='relu'))
model.add(Convolution2D(64, (4, 4), strides=(2, 2), activation='relu'))
model.add(Convolution2D(64, (3, 3), strides=(1, 1), activation='relu'))
model.add(Flatten())
model.add(Dense(512, activation='relu'))
# Last layer: no. of neurons corresponds to action space
# Linear activation
model.add(Dense(nb_actions, activation='linear'))  
print(model.summary())
_____________________________________________________________
Layer (type) Output Shape Param # 
=============================================================
permute_2 (Permute) (None, 84, 84, 4) 0 
_____________________________________________________________
conv2d_4 (Conv2D) (None, 20, 20, 32) 8224 
_____________________________________________________________
conv2d_5 (Conv2D) (None, 9, 9, 64) 32832 
_____________________________________________________________
conv2d_6 (Conv2D) (None, 7, 7, 64) 36928 
_____________________________________________________________
flatten_2 (Flatten) (None, 3136) 0 
_____________________________________________________________
dense_3 (Dense) (None, 512) 1606144 
_____________________________________________________________
dense_4 (Dense) (None, 6) 3078 
=============================================================
Total params: 1,687,206
Trainable params: 1,687,206
Non-trainable params: 0
______________________________________________________________
None
最后,你会注意到我们的输出层是一个密集连接的层,包含与我们代理的动作空间相对应的神经元数量(即它可能执行的动作数)。这个层也采用了线性激活函数,就像我们之前看到的回归示例一样。这是因为我们的网络本质上在执行一种多变量回归,它利用其特征表示来预测代理在给定输入状态下每个动作的最高 Q 值。
缺乏池化层
你可能还注意到与之前的 CNN 示例相比的另一个区别是缺少池化层。之前,我们使用池化层对每个卷积层生成的激活图进行下采样。正如你在第四章《卷积神经网络》中回忆的那样,这些池化层帮助我们实现了对不同类型输入的空间不变性。然而,在为我们特定的使用案例实现 CNN 时,我们可能不希望丢弃特定于表示空间位置的信息,因为这实际上可能是识别代理正确动作的一个重要部分。

如你在这两张几乎相同的图片中所看到的,太空侵略者发射的弹丸的位置显著地改变了我们代理的游戏状态。当代理在第一张图中足够远以避开这个弹丸时,它可能在第二张图中因犯一个错误(向右移动)而迎来末日。由于我们希望它能够显著区分这两种状态,因此我们避免使用池化层。
实时学习中的问题
正如我们之前提到的,我们的神经网络将一次处理四帧图像,并将这些输入回归到每个个体状态(即从 Atari 模拟器中采样的图像)对应的最高 Q 值动作。然而,如果我们不打乱网络接收每个四张图像批次的顺序,那么在学习过程中,网络会遇到一些相当棘手的问题。
我们不希望网络从连续批次的样本中学习的原因是,这些序列是局部相关的。这是一个问题,因为网络的参数在任何给定时刻都会决定由模拟器生成的下一批训练样本。根据马尔可夫假设,未来的游戏状态概率依赖于当前的游戏状态。因此,如果当前的最大化动作要求我们的智能体向右移动,那么接下来批次中的训练样本将会主要是智能体向右移动,导致糟糕且不必要的反馈循环。而且,连续的训练样本通常过于相似,网络难以从中有效学习。这些问题可能会导致网络的损失在训练过程中收敛到局部(而不是全局)最小值。那么,我们到底该如何应对这个问题呢?
存储经验到重放记忆中
解决方案在于为我们的网络构建重放记忆的概念。本质上,重放记忆可以充当一种固定长度的经验 队列。它可以用来存储正在进行的游戏的连续状态,连同所采取的动作、生成的奖励和返回给智能体的状态。这些经验队列会不断更新,以保持最近的n个游戏状态。然后,我们的网络将使用从重放记忆中保存的随机批次经验元组(state,action,reward,和next state)来执行梯度下降。
在rl.memory,即keras-rl模块中,提供了不同类型的重放记忆实现。我们使用SequentialMemory对象来实现我们的目的。这个对象需要两个参数,如下所示:
memory = SequentialMemory(limit=1000000, window_length=WINDOW_LENGTH)
limit参数表示要保存在记忆中的条目数。一旦超出此限制,新的条目将替换旧的条目。window_length参数仅指每个批次中的训练样本数量。
由于经验元组批次的随机顺序,网络不容易陷入局部最小值,并最终会收敛找到最优权重,代表给定环境的最优策略。此外,使用非顺序批次进行权重更新意味着我们可以实现更高的数据效率,因为同一张图像可以被打乱到不同的批次中,从而贡献多次权重更新。最后,这些经验元组甚至可以从人类游戏数据中收集,而不是之前由网络执行的动作。
其他方法(Schaul 等人,2016 年:arxiv.org/abs/1511.05952)通过添加一个额外的数据结构,记录每个转换(状态 -> 动作 -> 奖励 -> 下一状态)的优先级,实现了优先级版本的经验重放记忆,以便更频繁地重放重要的转换。其背后的直觉是让网络更频繁地从其最佳和最差的表现中学习,而不是从那些无法产生多少学习的实例中学习。尽管这些是一些巧妙的方法,有助于我们的模型收敛到相关的表示,但我们也希望它时不时地给我们带来惊喜,并探索它尚未考虑过的机会。这让我们回到了之前讨论过的,探索-开发困境。
平衡探索与开发
我们如何确保我们的代理在旧策略和新策略之间保持良好的平衡呢?这个问题在我们的 Q 网络随机初始化权重时变得更加复杂。由于预测的 Q 值是这些随机权重的结果,模型在初期训练时会生成次优的预测,进而导致 Q 值学习较差。自然,我们不希望我们的网络过于依赖它最初为给定状态-动作对生成的策略。就像多巴胺成瘾的老鼠一样,如果代理不探索新策略并扩展其视野,而只是利用已知的策略,它不可能在长期内表现良好。为了解决这个问题,我们必须实现一种机制,鼓励代理尝试新的动作,忽略已学得的 Q 值。这样做基本上允许我们的学习代理尝试新的策略,这些策略可能在长期内更有利。
Epsilon-贪婪探索策略
这可以通过算法修改我们学习代理解决环境问题时使用的策略来实现。一个常见的方法是使用 epsilon-贪婪探索策略。在这里,我们定义一个概率(ε)。然后,我们的代理可能会忽略学习到的 Q 值,并以(1 - ε)的概率尝试一个随机动作。因此,如果 epsilon 值设置为 0.5,那么我们的网络平均将忽略其学习的 Q 表所建议的动作,做一些随机的事情。这是一个相当具有探索性的代理。相反,epsilon 值为 0.001 时,网络将更一致地依赖学习到的 Q 值,平均每一百个时间步中才随机选择一次动作。
很少使用固定的 ε 值,因为探索与利用的程度可以基于许多内部(例如,代理的学习率)和外部因素(例如,给定环境中的随机性与确定性的程度)有所不同。在 DeepMind 论文中,研究人员实现了一个随时间衰减的 ε 项,从 1(即完全不依赖初始随机预测)到 0.1(在 10 次中有 9 次依赖于预测的 Q 值):
policy = LinearAnnealedPolicy(EpsGreedyQPolicy(), 
                              attr='eps',
                              value_max=1.,
                              value_min=.1,
                              value_test=.05,
                              nb_steps=1000000)
因此,衰减的 epsilon 确保我们的代理不会在初期训练阶段依赖于随机预测,而是在 Q 函数逐渐收敛到更一致的预测后,逐渐更积极地利用自身的预测。
初始化深度 Q 学习代理
现在,我们已经通过编程定义了初始化深度 Q 学习代理所需的所有个体组件。为此,我们使用从 rl.agents.dqn 导入的 DQNAgent 对象,并定义了相应的参数,如下所示:
#Initialize the atari_processor() class
processor = AtariProcessor()
# Initialize the DQN agent 
dqn = DQNAgent(model=model,             #Compiled neural network model
               nb_actions=nb_actions,   #Action space
               policy=policy,   #Policy chosen (Try Boltzman Q policy)
               memory=memory,   #Replay memory (Try Episode Parameter  
                                                memory)
               processor=processor,     #Atari processor class
#Warmup steps to ignore initially (due to random initial weights)
               nb_steps_warmup=50000,   
               gamma=.99,                #Discount factor
               train_interval=4,         #Training intervals
               delta_clip=1.,            #Reward clipping
              )
前述参数是根据原始 DeepMind 论文初始化的。现在,我们准备好最终编译我们的模型并启动训练过程了。为了编译模型,我们只需要在我们的 dqn 模型对象上调用 compile 方法:
dqn.compile(optimizer=Adam(lr=.00025), metrics=['mae'])
这里的 compile 方法需要传入一个优化器和我们希望跟踪的度量标准作为参数。在我们的例子中,我们选择了学习率为 0.00025 的 Adam 优化器,并跟踪 平均绝对误差 (MAE) 度量,具体如图所示。
训练模型
现在,我们可以启动我们的深度 Q 学习网络的训练过程。我们通过调用已编译的 DQN 网络对象上的 fit 方法来实现这一点。fit 参数需要传入正在训练的环境(在我们这个例子中是 SpaceInvaders-v0)以及在此次训练过程中总的游戏步数(类似于 epoch,表示从环境中采样的游戏状态总数)作为参数。如果你希望可视化训练过程中代理的表现,可以选择将可选参数 visualize 设置为 True。虽然这非常有趣——甚至有点让人着迷——但它会显著影响训练速度,因此不建议将其设置为默认选项:
dqn.fit(env, nb_steps=1750000)   #visualize=True
Training for 1750000 steps ...
Interval 1 (0 steps performed)
 2697/10000 [=======>....................] - ETA: 26s - reward: 0.0126
测试模型
我们使用以下代码测试模型:
dqn.test(env, nb_episodes=10, visualize=True)
Testing for 10 episodes ...
Episode 1: reward: 3.000, steps: 654
Episode 2: reward: 11.000, steps: 807
Episode 3: reward: 8.000, steps: 812
Episode 4: reward: 3.000, steps: 475
Episode 5: reward: 4.000, steps: 625
Episode 6: reward: 9.000, steps: 688
Episode 7: reward: 5.000, steps: 652
Episode 8: reward: 12.000, steps: 826
Episode 9: reward: 2.000, steps: 632
Episode 10: reward: 3.000, steps: 643
<keras.callbacks.History at 0x24280aadc50>
总结 Q 学习算法
恭喜!你现在已经深入理解了深度 Q 学习的概念,并将这些概念应用于使模拟代理逐步学习解决其环境。以下伪代码作为我们刚刚实现的整个深度 Q 学习过程的回顾:
initialize replay memory
initialize Q-Value function with random weights
sample initial state from environment
Keep repeating:
     choose an action to perform:
            with probability ε select a random action
            otherwise select action with argmax a Q(s, a')
     execute chosen action
     collect reward and next state
     save experience <s, a, r, s'> in replay memory
     sample random transitions <s, a, r, s'> from replay memory
     compute target variable for each mini-batch transition:
             if s' is terminal state then target = r
             otherwise t = r + γ max a'Q(s', a')
     train the network with loss (target - Q(s,a)`²)
     s = s'
until done
双重 Q 学习
我们刚刚构建的标准 Q-learning 模型的另一个扩展是 Double Q-learning 的思想,该方法由 Hado van Hasselt(2010 年,及 2015 年)提出。其背后的直觉非常简单。回顾一下,到目前为止,我们通过 Bellman 方程估计每个状态-动作对的目标值,并检查在给定状态下我们的预测偏差,如下所示:

然而,从这种方式估算最大期望未来奖励会出现一个问题。如你可能在之前注意到的那样,目标方程中的最大运算符(y[t])使用相同的 Q 值来评估给定动作,这些 Q 值也被用于预测采样状态下的给定动作。这引入了 Q 值过度估计的倾向,最终可能失控。为了解决这种可能性,Van Hasselt 等人(2016)实施了一个模型,将动作选择与其评估解耦。这是通过使用两个独立的神经网络来实现的,每个网络都被参数化以估算整个方程的一个子集。第一个网络负责预测给定状态下应该采取的动作,而第二个网络则用来生成目标,供第一个网络的预测在计算损失时迭代评估。尽管每次迭代时损失的公式没有变化,但给定状态的目标标签现在可以通过增强的 Double DQN 方程来表示,如下所示:

如我们所见,目标网络有自己的一组参数需要优化,(θ-)。这种将动作选择与评估解耦的做法已被证明可以弥补天真 DQN 所学习到的过度乐观的表示。因此,我们能够更快地收敛我们的损失函数,同时实现更稳定的学习。
实际上,目标网络的权重也可以被固定,并且定期/缓慢地更新,以避免因目标与预测之间的反馈回路不良而使模型不稳定。这项技术被另一本 DeepMind 论文(Hunt、Pritzel、Heess 等人,2016)广泛推广,该论文指出这种方法能够稳定训练过程。
Hunt、Pritzel、Heess 等人的 DeepMind 论文,Continuous Control with Deep Reinforcement **Learning, 2016,可以通过以下链接访问:arxiv.org/pdf/1509.02971.pdf。
你可以通过使用之前训练我们《太空侵略者》智能体时的相同代码,并对定义 DQN 智能体的部分做些微小修改,来通过keras-rl模块实现 Double DQN:
double_dqn = DQNAgent(model=model,
               nb_actions=nb_actions,
               policy=policy,
               memory=memory,
               processor=processor,
               nb_steps_warmup=50000,
               gamma=.99, 
               target_model_update=1e-2,
               train_interval=4,
               delta_clip=1.,
               enable_double_dqn=True,
              )
我们只需要将enable_double_dqn的布尔值设置为True,然后就可以开始了!如果需要,您还可以尝试调整预热步数(即在模型开始学习之前的步骤数)以及目标模型更新的频率。我们可以进一步参考以下论文:
- 深度强化学习与双 Q 学习:arxiv.org/pdf/1509.06461.pdf
对战网络架构
我们将实现的最后一种 Q 学习架构是对战网络架构(arxiv.org/abs/1511.06581)。顾名思义,在这种架构中,我们通过使用两个独立的估计器来使神经网络与自己对战,一个估计器用于评估状态值,另一个用于评估状态-动作对的值。你可能还记得在本章前面部分,我们使用一个单一的卷积和密集连接层流来估计状态-动作对的质量。然而,我们实际上可以将 Q 值函数分解为两个独立项的和。这样做的原因是让我们的模型能够分别学习某些状态可能有价值,某些则没有价值,而不需要专门学习在每个状态下执行每个动作的影响:

在前面的图表顶部,我们可以看到标准的 DQN 架构。底部则展示了对战 DQN 架构如何分成两个独立的流,其中状态值和状态-动作值被分别估计,而不需要额外的监督。因此,对战 DQN 使用独立的估计器(即密集连接层)来分别估计处于某一状态时的值V(s)以及在给定状态下执行某个动作相较于其他动作的优势A(s,a)。这两个项随后被结合起来,预测给定状态-动作对的 Q 值,从而确保我们的智能体在长期内选择最优动作。与标准的 Q 函数Q(s,a)只能让我们估计给定状态下选择动作的价值不同,使用这种方法我们可以分别衡量状态的价值和动作的相对优势。在执行某个动作不会显著改变环境的情况下,这种做法可能会有所帮助。
值函数和优势函数可以通过以下方程给出:

DeepMind 的研究人员(Wang 等人,2016 年)在一个早期的赛车游戏(Atari Enduro)中测试了这样的架构,游戏中要求代理在道路上行驶,途中可能会遇到障碍物。研究人员注意到,状态值流会学习关注道路和屏幕上的分数,而动作优势流则只有在游戏屏幕上出现特定障碍物时才会学会关注。自然,只有当障碍物出现在其路径上时,代理才需要执行一个动作(向左或向右移动)。否则,向左或向右移动对代理来说没有任何意义。另一方面,代理始终需要关注道路和分数,这是由网络的状态值流来完成的。因此,在他们的实验中,研究人员展示了这种架构如何在代理面对许多具有相似后果的动作时,提供更好的策略评估。
我们可以使用keras-rl模块实现对战 DQN,解决之前提到的 Space Invaders 问题。我们需要做的就是重新定义我们的代理,如下所示:
dueling_dqn = DQNAgent(model=model,
               nb_actions=nb_actions,
               policy=policy,
               memory=memory,
               processor=processor,
               nb_steps_warmup=50000,
               gamma=.99, 
               target_model_update=10000,
               train_interval=4,
               delta_clip=1.,
               enable_dueling_network=True,
               dueling_type='avg'
              )
在这里,我们只需将布尔参数enable_dueling_network设置为True并指定一个对战类型。
想要了解更多关于网络架构和使用潜在好处的信息,我们鼓励你参考完整的研究论文,《深度强化学习中的对战网络架构》,你可以在arxiv.org/pdf/1511.06581.pdf查看。
练习
- 
在 Atari 环境中实现标准的 Q 学习并采用不同的策略(Boltzman),检查性能指标的差异 
- 
在相同问题上实现双重 DQN 并比较性能差异 
- 
实现一个对战 DQN 并比较性能差异 
Q 学习的局限性
真是令人惊叹,像这样的相对简单的算法,经过足够的训练时间后,竟能产生复杂的策略,代理能够凭此做出决策。特别是,研究人员(现在你也可以)能够展示出如何通过与环境的充分互动,学习到专家策略。例如,在经典的打砖块游戏中(该游戏作为 Atari 依赖的环境之一),你需要移动屏幕底部的挡板,反弹一个球并击破屏幕上方的砖块。经过足够的训练时间,DQN 代理甚至能想出复杂的策略,比如将球卡在屏幕的顶部,获得最大得分:

这种直观的行为自然会让你产生疑问——我们能将这种方法应用到多远?我们能通过这种方法掌握哪些类型的环境,它的局限性又是什么?
的确,Q 学习算法的优势在于它们能够解决具有高维观测空间的问题,比如来自游戏屏幕的图像。我们通过卷积架构实现了这一点,使我们能够将状态-动作对与最优奖励关联起来。然而,到目前为止,我们关注的动作空间大多是离散的和低维的。例如,向右或向左转是一个离散的动作,而不是像以角度转向左那样的连续动作。后者是一个连续动作空间的例子,因为智能体向左转的动作依赖于一个由特定角度表示的变量,该角度可以取连续值。我们最初也没有那么多可执行的动作(Atari 2600 游戏中的动作范围为 4 到 18)。其他潜在的深度强化学习问题,如机器人运动控制或优化车队部署,可能需要建模非常高维和连续的动作空间,在这种情况下,标准的 DQN 表现较差。这是因为 DQN 依赖于找到最大化 Q 值函数的动作,而这在连续动作空间的情况下需要在每一步进行迭代优化。幸运的是,针对这个问题,已经有其他方法存在。
使用策略梯度改进 Q 学习
到目前为止,我们的方法是通过迭代更新状态-动作对的 Q 值估计,从而推断出最优策略。然而,当面对连续动作空间时,这变成了一项繁重的学习任务。例如,在机器人运动控制的情况下,我们的动作空间由机器人关节位置和角度等连续变量定义。在这种情况下,估计 Q 值函数变得不切实际,因为我们可以假设这个函数本身非常复杂。因此, вместо学习每个关节位置和角度的最优 Q 值,我们可以尝试一种不同的方法。如果我们能够直接学习一个策略,而不是通过迭代更新状态-动作对的 Q 值来推断策略,那会怎么样呢?回想一下,策略仅仅是一个状态轨迹,之后是执行的动作、产生的奖励以及返回给智能体的状态。因此,我们可以定义一组参数化的策略(通过神经网络的权重(θ)参数化),其中每个策略的价值可以由此处给出的函数定义:

在这里,策略的价值由函数J(θ)表示,其中 theta 表示我们的模型权重。在左侧,我们可以用之前熟悉的术语来定义给定策略的价值,表示预期的累计未来奖励之和。在这个新设置下,我们的目标是找到能够使策略价值函数J(θ)最大化的模型权重,从而为我们的智能体带来最佳的预期未来奖励。
之前,为了找到函数的全局最小值,我们通过对该函数的一级导数进行迭代优化,采取与梯度负方向成比例的步骤来更新模型权重。这就是我们所说的梯度下降。然而,由于我们想要找到我们策略价值函数的最大值,J(θ),我们将执行梯度上升,它会迭代地更新与梯度正方向成比例的模型权重。因此,我们可以通过评估由给定策略生成的轨迹来使深度神经网络收敛到最优策略,而不是单独评估状态-动作对的质量。接着,我们甚至可以让来自有利策略的动作在给定游戏状态下有更高的被选中概率,而来自不利策略的动作可以被较少地采样。这就是策略梯度方法背后的主要直觉。自然,跟随这一方法会有一整套新的技巧,我们鼓励你去阅读。例如,深度强化学习中的连续控制,可以在arxiv.org/pdf/1509.02971.pdf找到。
一个值得关注的有趣的策略梯度实现是演员-评论家模型,它可以在连续动作空间中实现,以解决更复杂的高维动作空间问题,例如我们之前讨论过的问题。有关演员-评论家模型的更多信息可以在arxiv.org/pdf/1509.02971.pdf中找到。
相同的演员-评论家概念已经在不同的环境中应用于各种任务,如自然语言生成和对话建模,甚至是在像《星际争霸 II》这样的复杂实时策略游戏中,感兴趣的读者可以进一步探索:
- 
自然语言生成与对话建模: arxiv.org/pdf/1607.07086.pdf
- 
星际争霸 II:强化学习的新挑战: arxiv.org/pdf/1708.04782.pdf?fbclid=IwAR30QJE6Kw16pHA949pEf_VCTbrX582BDNnWG2OdmgqTIQpn4yPbtdV-xFs
总结
在这一章中,我们涵盖了很多内容。我们不仅探索了机器学习的一个全新分支——强化学习,还实现了一些被证明能够培养复杂自主智能体的最先进算法。我们了解了如何使用马尔可夫决策过程来建模环境,并通过贝尔曼方程评估最优奖励。我们还看到,如何通过使用深度神经网络近似质量函数来解决信用分配问题。在这个过程中,我们探索了许多技巧,如奖励折扣、裁剪和经验回放等(仅举几例),它们有助于表示高维输入,比如游戏画面图像,以便在模拟环境中导航并优化目标。
最后,我们探索了深度 Q 学习领域的一些进展,概述了双重 DQN 和对抗性 DQN 等架构。最后,我们回顾了一些在使智能体成功导航高维动作空间时面临的挑战,并了解了如策略梯度等不同方法如何帮助解决这些问题。
第三部分:混合模型架构
本节让读者了解当前深度学习的潜力与局限,以及如何从学术界和工业界获得启发,整理实施端到端深度学习工作流程所需的所有资源。
本节包含以下章节:
- 
第八章,自编码器 
- 
第九章,生成网络 
第八章:自编码器
在前一章中,我们熟悉了机器学习(ML)中的一个新领域:强化学习的领域。我们看到如何通过神经网络增强强化学习算法,以及如何学习近似函数,将游戏状态映射到智能体可能采取的动作。这些动作随后与一个动态的目标变量进行比较,而该目标变量是通过我们所称的贝尔曼方程定义的。严格来说,这是一种自监督学习的机器学习技术,因为用来比较我们预测结果的是贝尔曼方程,而不是一组标记的目标变量,这在监督学习方法中才会出现(例如,带有每个状态下应采取的最优动作标签的游戏画面)。后者虽然可能实现,但对于给定的使用场景来说,计算成本要高得多。现在,我们将继续前进,发现另一种自监督机器学习技术,探索神经自编码器的世界。
本章中,我们将探讨让神经网络学习从给定数据集中编码最具代表性特征的实用性和优势。本质上,这使我们能够保留并在以后重建定义观察类别的关键元素。观察本身可以是图像、自然语言数据,甚至是可能受益于降维的时间序列观察,通过去除那些表示给定观察中较不具信息性方面的信息。你可能会问,谁得益?
本章将涵盖以下主题:
- 
为什么选择自编码器? 
- 
自动编码信息 
- 
理解自编码器的局限性 
- 
解析自编码器 
- 
训练自编码器 
- 
概览自编码器原型 
- 
网络规模和表示能力 
- 
理解自编码器中的正则化 
- 
使用稀疏自编码器进行正则化 
- 
探索数据 
- 
构建验证模型 
- 
设计深度自编码器 
- 
使用功能性 API 设计自编码器 
- 
深度卷积自编码器 
- 
编译和训练模型 
- 
测试并可视化结果 
- 
去噪自编码器 
- 
训练去噪网络 
为什么选择自编码器?
过去(大约 2012 年),自编码器因其在初始化深度卷积神经网络(CNNs)层权重方面的应用(通过一种被称为贪婪逐层预训练的操作)而短暂地享有一些声誉,但随着更好的随机权重初始化方案的出现,以及允许训练更深层神经网络的更具优势的方法(例如 2014 年的批量归一化,及 2015 年的残差学习)逐渐成为主流,研究人员对这种预训练技术的兴趣逐渐减退。
如今,自动编码器的一个重要应用来自于它们能够发现高维数据的低维表示,同时尽量保留其中的核心属性。这使我们能够执行例如恢复损坏图像(或图像去噪)等任务。自动编码器的另一个活跃研究领域是它们能够执行主成分分析,例如数据的变换,从而可以可视化数据中主要方差因素的有用信息。事实上,带有线性激活函数的单层自动编码器与在数据集上执行的标准主成分分析(PCA)操作非常相似。这样的自动编码器只学习一个维度减少的子空间,这个子空间正是通过 PCA 得到的。因此,自动编码器可以与 t-SNE 算法(en.wikipedia.org/wiki/T-distributed_stochastic_neighbor_embedding)结合使用,后者因其在二维平面上可视化信息的能力而著名,首先对高维数据集进行降采样,然后可视化观察到的主要方差因素。
此外,自动编码器在此类应用中的优势(即执行降维)源于它们可能具有非线性的编码器和解码器函数,而 PCA 算法仅限于线性映射。这使得自动编码器能够学习比 PCA 分析相同数据得到的结果更强大的非线性特征空间表示。事实上,当你处理非常稀疏和高维的数据时,自动编码器可以证明是数据科学工具箱中一个非常强大的工具。
除了自动编码器的这些实际应用外,还有一些更具创意和艺术性的应用。例如,从编码器生成的低维表示中采样,已被用来生成艺术图像,这些图像在纽约某拍卖行以约 50 万美元的价格拍卖(见www.bloomberg.com/news/articles/2018-10-25/ai-generated-portrait-is-sold-for-432-500-in-an-auction-first)。我们将在下一章回顾此类图像生成技术的基础知识,当时我们将介绍变分自动编码器架构和生成对抗网络(GANs)。但首先,让我们尝试更好地理解自动编码器神经网络的本质。
自动编码信息
那么,自编码器的理念有什么不同之处呢?你肯定已经接触过无数的编码算法,像是 MP3 压缩用于存储音频文件,或者 JPEG 压缩用于存储图像文件。自编码神经网络之所以有趣,是因为它们采用了一种与之前提到的准对等物相比非常不同的信息表示方式。这正是你在阅读完神经网络内部工作机制的七个章节后,理应期待的一种方法。
与 MP3 或 JPEG 算法不同,后者通常对声音和像素有普遍的假设,而神经自编码器则被迫自动从任何训练过程中显示的输入中学习代表性特征。它接着使用在训练过程中捕获的学习表示来重建给定的输入。重要的是要理解,自编码器的吸引力并不在于简单地复制其输入。当训练自编码器时,我们通常并不关心它所生成的解码输出,而更关心的是网络如何转化给定输入的维度。理想情况下,我们希望通过给网络提供激励和约束,以尽可能准确地重建原始输入,从而寻找代表性的编码方案。通过这样做,我们可以将编码器函数应用于类似的数据集,作为一种特征检测算法,从而为给定的输入提供语义丰富的表示。
这些表示方法可以用来执行某种分类,具体取决于所处理的使用案例。因此,采用的正是编码的架构机制,这也定义了自编码器与其他标准编码算法相比的新颖方法。
理解自编码器的局限性
如前所述,神经网络,例如自编码器,被用来自动从数据中学习代表性特征,而不需要明确依赖于人工设计的假设。尽管这种方法可以让我们发现适用于不同类型数据的理想编码方案,但它也确实存在一些局限性。首先,自编码器被认为是数据特定的,这意味着它们的作用仅限于与训练数据非常相似的数据。例如,一个仅训练生成猫图像的自编码器在没有明确训练的情况下,几乎无法生成狗的图像。显然,这似乎限制了此类算法的可扩展性。值得注意的是,直到现在,自编码器在编码图像时的表现并没有明显优于 JPEG 算法。另一个问题是,自编码器往往会产生有损输出。这意味着压缩和解压操作会降低网络输出的质量,生成的表示相比输入会不够精确。这个问题似乎在大多数编码应用场景中都有出现(包括基于启发式的编码方案,如 MP3 和 JPEG)。
因此,自编码器揭示了一些非常有前景的实践方法,用于处理未标记的真实世界数据。然而,今天在数字领域中可用的绝大多数数据实际上是无结构且未标记的。另一个值得注意的常见误解是将自编码器归类为无监督学习,但实际上,它不过是自监督学习的另一种变体,正如我们很快将要发现的那样。那么,这些网络究竟是如何工作的呢?
分析自编码器
从高层次看,自编码器可以被认为是一种特定类型的前馈网络,它学习模仿输入并重构出相似的输出。正如我们之前提到的,它由两部分组成:编码器函数和解码器函数。我们可以将整个自编码器视为一层层互联的神经元,首先通过编码输入数据,然后使用生成的编码重构输出:

一个不完全自编码器的示例
上图展示了一个特定类型的自动编码器网络。从概念上讲,自动编码器的输入层连接到一个神经元层,将数据引导到一个潜在空间,这就是 编码器函数。该函数可以泛化定义为 h = f(x),其中 x 代表网络输入,h 代表由编码器函数生成的潜在空间。潜在空间可能体现了输入到我们网络的压缩表示,随后被解码器函数(即后续的神经元层)用来解开这个简化的表示,将其映射到一个更高维的特征空间。因此,解码器函数(形式化为 r = g(h)) 接着将由编码器生成的潜在空间 (h) 转换为网络的 重构 输出 (r)。
训练一个自动编码器
编码器和解码器函数之间的交互由另一个函数控制,该函数操作输入和输出之间的距离,我们通常称之为神经网络中的 loss 函数。因此,为了训练一个自动编码器,我们只需对编码器和解码器函数分别关于 loss 函数(通常使用均方误差)进行求导,并使用梯度来反向传播模型的误差,更新整个网络的层权重。
因此,自动编码器的学习机制可以表示为最小化一个 loss 函数,其公式如下:
min L(x, g ( f ( x ) ) )
在前面的公式中,L 代表一个 loss 函数(如均方误差 MSE),它对解码器函数的输出(g(f( x )))进行惩罚,惩罚的内容是输出与网络输入 (x) 的偏差。通过这种方式反复最小化重构损失,我们的模型最终将收敛到编码适应输入数据的理想表示,这些表示可以用于解码类似数据,同时损失的信息量最小。因此,自动编码器几乎总是通过小批量梯度下降进行训练,这与其他前馈神经网络的训练方法相同。
虽然自编码器也可以使用一种称为再循环(Hinton 和 McClelland,1988)的技术进行训练,但我们在本章中不会深入讨论这一子话题,因为这种方法在大多数涉及自编码器的机器学习应用中很少使用。仅需提及,再循环通过将给定输入的网络激活与生成的重建的网络激活进行比较,而不是通过反向传播基于梯度的误差(通过对loss函数相对于网络权重求导来获得)来工作。尽管在概念上有所不同,从理论角度来看,这可能是一个有趣的阅读内容,因为再循环被认为是反向传播算法的生物学上可行的替代方案,暗示着我们自己可能如何随着新信息的出现,更新我们对世界的心理模型。
概览自编码器原型
我们之前描述的其实是一个欠完备自编码器的例子,基本上它对潜在空间维度进行了约束。之所以称其为欠完备,是因为编码维度(即潜在空间的维度)小于输入维度,这迫使自编码器学习数据样本中最显著的特征。
相反,过完备自编码器则具有相对于输入维度更大的编码维度。这种自编码器相比输入大小拥有更多的编码能力,正如下图所示:

网络规模与表示能力
在之前的图示中,我们可以看到四种基本的自编码架构。浅层自编码器(浅层神经网络的扩展)通过仅有一个隐藏层的神经元来定义,而深层自编码器则可以有多个层来执行编码和解码操作。回顾之前章节的内容,较深的神经网络相比浅层神经网络可能具备更强的表示能力。这一原则同样适用于自编码器。除此之外,还注意到,深层自编码器可能会在网络学习表示输入时,显著减少所需的计算资源。它还可以大大减少网络学习输入的丰富压缩版本所需的训练样本数量。虽然读到最后几行可能会促使你们中的一些人开始训练数百层的自编码器,但你可能想要稍安勿躁。赋予编码器和解码器函数过多的能力也会带来自身的缺点。
例如,一个具有过多容量的自编码器可能学会完美地重建毕加索画作的输入图像,而从未学会与毕加索画风相关的任何代表性特征。在这种情况下,你得到的只是一个昂贵的模仿算法,它可以与微软画图的复制功能类比。另一方面,按照所建模数据的复杂性和分布设计自编码器,可能使得自编码器能够捕捉到毕加索创作方法中具有代表性的风格特征,进而成为艺术家和历史学者学习的素材。实际上,选择正确的网络深度和规模可能依赖于对学习过程的理论理解、实验以及与使用场景相关的领域知识的精妙结合。听起来有点耗时吗?幸运的是,可能有一种折衷方案,通过使用正则化自编码器来实现。
理解自编码器中的正则化
在一个极端情况下,你可能总是通过坚持浅层网络并设置非常小的潜在空间维度来限制网络的学习能力。这种方法甚至可能为基准测试复杂方法提供一个优秀的基线。然而,也有其他方法可以让我们在不被过度容量问题惩罚的情况下,受益于更深层网络的表示能力,直到某种程度。这些方法包括修改自编码器使用的loss函数,以激励学习网络中潜在空间的某些表示标准。
例如,我们可以要求loss函数考虑潜在空间的稀疏性,偏向于更丰富的表示,而不是其他表示。正如我们所看到的,我们甚至可以考虑潜在空间的导数大小或对缺失输入的鲁棒性等属性,以确保我们的模型确实捕捉到它所展示的输入中的代表性特征。
使用稀疏自编码器进行正则化
如前所述,确保我们的模型从输入中编码代表性特征的一种方法是对表示潜在空间(h)的隐藏层添加稀疏性约束。我们用希腊字母欧米伽(Ω)表示这个约束,这样我们就可以重新定义稀疏自编码器的loss函数,方式如下:
- 
正常自编码器损失:L ( x , g ( f ( x ) ) ) 
- 
稀疏自编码器损失:L ( x , g ( f ( x ) ) ) + Ω(h) 
这个稀疏性约束项,Ω(h),可以简单地看作是可以添加到前馈神经网络中的正则化项,就像我们在前几章中看到的那样。
关于自编码器中不同形式稀疏约束方法的全面综述可以在以下研究论文中找到,我们推荐感兴趣的读者参考:通过学习深度稀疏自编码器进行面部表情识别:www.sciencedirect.com/science/article/pii/S0925231217314649。
这为我们的议程腾出了一些空间,使我们可以简要介绍一些自编码器在实践中使用的其他正则化方法,然后再继续编写我们自己的模型。
使用去噪自编码器的正则化
与稀疏自编码器不同,去噪自编码器通过不同的方式确保我们的模型能够在其赋予的能力范围内捕捉有用的表示。在这种情况下,我们不是向loss函数添加约束,而是可以实际修改loss函数中的重建误差项。换句话说,我们只是告诉网络,通过使用该输入的噪声版本来重建其输入。
在这种情况下,噪声可能指的是图像中的缺失像素、句子中的缺失单词,或者碎片化的音频流。因此,我们可以将去噪自编码器的loss函数重新公式化如下:
- 
正常 AE 损失:L ( x , g ( f ( x ) ) ) 
- 
去噪 AE 损失:L ( x , g ( f ( ~x) ) ) 
在这里,术语(~x)仅指被某种噪声形式破坏过的输入x的版本。我们的去噪自编码器必须对提供的有噪声输入进行去噪,而不是仅仅尝试复制原始输入。向训练数据中添加噪声可能迫使自编码器捕捉最能代表正确重建损坏版本训练实例的特征。
一些有趣的特性和使用案例(例如语音增强)已在以下论文中探讨,对于感兴趣的读者来说值得注意:基于深度去噪自编码器的语音增强:pdfs.semanticscholar.org/3674/37d5ee2ffbfee1076cf21c3852b2ec50d734.pdf。
这将引出我们在本章中将讨论的最后一种正则化策略——收缩自编码器,然后再进入实际的内容。
使用收缩自编码器的正则化
虽然我们不会深入探讨这种自编码器网络亚种的数学,但收缩自编码器(CAE)因其在概念上与去噪自编码器的相似性以及如何局部扭曲输入空间而值得注意。在 CAE 的情况下,我们再次向loss函数添加一个约束(Ω),但方式有所不同:
- 
正常 AE 损失:L ( x , g ( f ( x ) ) ) 
- 
CAE 损失:L ( x , g ( f ( x) ) ) + Ω(h,x) 
在这里,术语Ω(h, x)以不同的方式表示,可以按照以下方式公式化:

在这里,CAE 利用对loss函数的约束来鼓励编码器的导数尽可能小。对于那些更加数学倾向的人来说,约束项Ω(h, x)实际上被称为Frobenius 范数的平方(即元素的平方和),用于填充编码器函数的偏导数的雅可比矩阵。
对于那些希望扩展其知识以了解 CAEs 内部工作原理和特征提取应用的人,以下论文提供了一个优秀的概述:Contractive Auto-Encoders: Explicit Invariance During Feature Extraction:www.iro.umontreal.ca/~lisa/pointeurs/ICML2011_explicit_invariance.pdf。
从实际角度来看,我们在这里需要理解的是,通过将ω项定义为这样,CAEs 可以学习近似一个函数,该函数可以将输入映射到输出,即使输入略有变化。由于这种惩罚仅在训练过程中应用,网络学会从输入中捕获代表性特征,并且在测试期间能够表现良好,即使所展示的输入与其训练时略有不同。
现在我们已经讨论了基本的学习机制以及定义各种自编码器网络的一些架构变化,我们可以继续进行本章的实现部分。在这里,我们将在 Keras 中设计一个基本的自编码器,并逐步更新架构,以涵盖一些实际考虑因素和用例。
在 Keras 中实现浅层 AE
现在,我们将在 Keras 中实现一个浅层自编码器。我们将使用标准的时尚 MNIST 数据集作为这个模型的用例:通过像素化的 28 x 28 图像来生成不同的时尚服装。由于我们知道网络输出的质量直接取决于可用的输入数据的质量,我们必须警告我们的观众,不要期望通过这种方式生成下一个畅销服装。该数据集提供了程序概念和实现步骤的澄清,您在设计任何类型的 Keras AE 网络时必须熟悉这些步骤。
导入一些库
在本练习中,我们将使用 Keras 的功能性 API,通过 keras.models 进行访问,允许我们构建无环图和多输出模型,就像我们在第四章《卷积神经网络》中所做的那样,深入研究卷积网络的中间层。尽管你也可以使用顺序 API 来复制自动编码器(毕竟自动编码器是顺序模型),但它们通常通过功能性 API 实现,这也让我们有机会更加熟悉 Keras 的两种 API。
import numpy as np
import matplotlib.pyplot as plt
from keras.layers import Input, Dense
from keras.models import Model
from keras.datasets import fashion_mnist
探索数据
接下来,我们只需加载 Keras 中包含的 fashion_mnist 数据集。请注意,尽管我们已经加载了每个图像的标签,但对于我们接下来要执行的任务,这并不是必需的。我们只需要输入图像,而我们的浅层自动编码器将重新生成这些图像:
(x_train, y_train), (x_test, y_test) = fashion_mnist.load_data()
x_train.shape,  x_test.shape, type(x_train)
((60000, 28, 28), (10000, 28, 28), numpy.ndarray)
plt.imshow(x_train[1], cmap='binary')
以下是输出:

我们可以继续检查输入图像的维度和类型,然后从训练数据中绘制一个示例图像,满足我们自己的视觉需求。该示例似乎是一件印有一些难以辨认内容的休闲 T 恤。很好——现在,我们可以继续定义我们的自动编码器模型了!
数据预处理
正如我们之前做过无数次的那样,我们现在将像素数据归一化到 0 和 1 之间,这有助于提高我们网络对归一化数据的学习能力:
# Normalize pixel values
x_train = x_train.astype('float32') / 255.
x_test = x_test.astype('float32') / 255.
# Flatten images to 2D arrays
x_train = x_train.reshape((len(x_train), np.prod(x_train.shape[1:])))
x_test = x_test.reshape((len(x_test), np.prod(x_test.shape[1:])))
# Print out the shape
print(x_train.shape)
print(x_test.shape)
-----------------------------------------------------------------------
(60000, 784)
(10000, 784)
我们还将把 28 x 28 像素的图像平铺成一个 784 像素的向量,就像我们在之前训练前馈网络时所做的那样。最后,我们将打印出训练集和测试集的形状,以确保它们的格式符合要求。
构建模型
现在我们准备好在 Keras 中设计我们的第一个自动编码器网络,我们将使用功能性 API 来实现。功能性 API 的基本原理相当简单,正如我们在之前的示例中所看到的那样。在我们的应用场景中,我们将定义潜在空间的编码维度。在这里,我们选择了 32。这意味着每个 784 像素的图像将经过一个压缩维度,该维度仅存储 32 个像素,输出将从中重建。
这意味着压缩因子为 24.5(784/32),虽然这个选择有些随意,但可以作为类似任务的经验法则:
# Size of encoded representation
# 32 floats denotes a compression factor of 24.5 assuming input is 784 float
# we have 32*32 or 1024 floats
encoding_dim = 32
#Input placeholder
input_img = Input(shape=(784,))
#Encoded representation of input image
encoded = Dense(encoding_dim, activation='relu',  activity_regularizer=regularizers.l1(10e-5))(input_img)                               
# Decode is lossy reconstruction of input              
decoded = Dense(784, activation='sigmoid')(encoded)
# This autoencoder will map input to reconstructed output
autoencoder = Model(input_img, decoded)
然后,我们使用 keras.layers 中的输入占位符来定义输入层,并指定我们期望的平铺图像维度。正如我们从之前的 MNIST 实验中知道的那样(通过一些简单的数学计算),将 28 x 28 像素的图像平铺后得到一个 784 像素的数组,之后可以通过前馈神经网络进行处理。
接下来,我们定义编码后的潜在空间的维度。这是通过定义一个与输入层相连的全连接层来完成的,并且该层的神经元数与我们的编码维度(先前定义为 32)相对应,使用 ReLU 激活函数。这些层之间的连接通过在定义后续层的参数后,括号中包含定义前一层的变量来表示。
最后,我们定义解码器函数作为一个与输入层维度相同的全连接层(784 个像素),使用 sigmoid 激活函数。该层自然地与表示潜在空间的编码维度相连接,并重新生成依赖于编码层神经激活的输出。现在我们可以通过使用功能性 API 中的模型类来初始化自编码器,并将输入占位符和解码器层作为参数提供给它。
实现稀疏性约束
正如我们在本章前面提到的,在设计自编码器时有许多方法可以进行正则化。例如,稀疏自编码器通过对潜在空间实施稀疏性约束,强制自编码器偏向丰富的表示。回想一下,当神经网络中的神经元的输出值接近 1 时,它们可能会激活,而当输出接近 0 时,它们则不会激活。添加稀疏性约束可以简单地理解为限制潜在空间中的神经元大部分时间保持不活跃。因此,任何给定时刻可能只有少数神经元被激活,迫使这些被激活的神经元尽可能高效地传播信息,从潜在空间到输出空间。幸运的是,在 Keras 中实现这一过程非常简单。这可以通过定义activity_regularizer参数来实现,同时在定义代表潜在空间的全连接层时进行设置。在下面的代码中,我们使用keras.regularizers中的 L1 正则化器,稀疏参数非常接近零(在我们这里是 0.067)。现在你也知道如何在 Keras 中设计稀疏自编码器了!虽然我们将继续使用非稀疏版本,但为了这个练习的目的,你可以比较这两种浅层自编码器的性能,亲自感受在设计此类模型时向潜在空间添加稀疏性约束的好处。
编译和可视化模型
我们可以通过简单地编译模型并调用模型对象上的summary()来可视化我们刚刚做的事情,像这样。我们将选择 Adadelta 优化器,它在反向传播过程中限制了过去梯度的累计数目,仅在一个固定的窗口内进行,而不是通过选择像 Adagrad 优化器那样单调减少学习率。如果你错过了本书早些时候提到的内容,我们鼓励你去研究可用优化器的广泛库(ruder.io/optimizing-gradient-descent/),并进行实验以找到适合你用例的优化器。最后,我们将定义二元交叉熵作为loss函数,它在我们的情况下考虑了输出生成的像素级损失:
autoencoder.compile(optimizer='adadelta', loss='binary_crossentropy')
autoencoder.summary()
以下是输出:

构建验证模型
现在我们几乎拥有了启动浅层自编码器训练会话所需的所有内容。然而,我们缺少一个至关重要的组成部分。严格来说,这部分并不是训练自编码器所必需的,但我们必须实现它,以便能够直观地验证我们的自编码器是否真的从训练数据中学习到重要特征。为了做到这一点,我们实际上将定义两个额外的网络。别担心——这两个网络本质上是我们刚刚定义的自编码器网络中编码器和解码器功能的镜像。因此,我们所做的就是创建一个独立的编码器和解码器网络,它们将匹配自编码器中编码器和解码器功能的超参数。这两个独立的网络将在自编码器训练完成后用于预测。基本上,编码器网络将用于预测输入图像的压缩表示,而解码器网络则会继续预测存储在潜在空间中的信息的解码版本。
定义一个独立的编码器网络
在下面的代码中,我们可以看到编码器功能是我们自编码器上半部分的精确副本;它本质上将展平的像素值输入向量映射到一个压缩的潜在空间:
''' The seperate encoder network '''
# Define a model which maps input images to the latent space
encoder_network = Model(input_img, encoded)
# Visualize network
encoder_network.summary()
以下是总结:

定义一个独立的解码器网络
同样,在下面的代码中,我们可以看到解码器网络是我们自编码器神经网络下半部分的完美副本,它将存储在潜在空间中的压缩表示映射到重建输入图像的输出层:
''' The seperate decoder network ''' 
# Placeholder to recieve the encoded (32-dimensional) representation as input
encoded_input = Input(shape=(encoding_dim,))
# Decoder layer, retrieved from the aucoencoder model
decoder_layer = autoencoder.layers[-1]
# Define the decoder model, mapping the latent space to the output layer
decoder_network = Model(encoded_input, decoder_layer(encoded_input))
# Visualize network
decoder_network.summary()
这是总结:

请注意,要定义解码器网络,我们必须首先构建一个形状与我们的编码维度(即 32)相匹配的输入层。然后,我们只需通过引用之前自动编码器模型最后一层的索引来复制解码器层。现在,我们已经准备好启动自动编码器网络的训练了!
训练自动编码器
接下来,我们像之前做过无数次的那样,简单地训练我们的自动编码器网络。我们选择将该模型训练 50 个 epoch,每次批次处理 256 张图像,然后再进行网络节点的权重更新。我们在训练过程中还会打乱数据。正如我们所知道的那样,这样做能确保批次之间减少一些方差,从而提高模型的泛化能力:

最后,我们还使用我们的测试集定义了验证数据,只是为了能够在每个 epoch 结束时比较模型在未见示例上的表现。请记住,在正常的机器学习工作流程中,常见的做法是将数据分为验证集和开发集,这样你可以在一个数据集上调整模型,并在另一个数据集上进行测试。虽然这不是我们演示用例的前提,但为了获得可泛化的结果,实施这种双重验证策略总是有益的。
可视化结果
现在到了收获成果的时候了。让我们看看我们的自动编码器能够通过使用我们隔离的测试集重建出什么样的图像。换句话说,我们将为网络提供与训练集相似(但不完全相同)的图像,看看模型在未见数据上的表现如何。为此,我们将使用编码器网络对测试集进行预测。编码器将预测如何将输入图像映射到压缩表示。然后,我们将简单地使用解码器网络预测如何解码由编码器网络生成的压缩表示。以下代码展示了这些步骤:
# Time to encode some images
encoded_imgs = encoder_network.predict(x_test)
# Then decode them 
decoded_imgs = decoder_network.predict(encoded_imgs)
接下来,我们重构一些图像,并将它们与触发重构的输入进行比较,看看我们的自动编码器是否捕捉到了衣物应有的外观。为此,我们将简单地使用 Matplotlib 绘制九个图像,并将它们的重构图像展示在下方,如下所示:
# use Matplotlib (don't ask)
import matplotlib.pyplot as plt
plt.figure(figsize=(22, 6))
num_imgs = 9
for i in range(n):                        
    # display original
    ax = plt.subplot(2, num_imgs, i + 1)
    true_img = x_test[i].reshape(28, 28)
    plt.imshow(true_img)
    # display reconstruction
    ax = plt.subplot(2, num_imgs, i + 1 + num_imgs)
    reconstructed_img = decoded_imgs[i].reshape(28,28)
    plt.imshow(reconstructed_img)
plt.show()
以下是生成的输出:

如你所见,虽然我们的浅层自动编码器无法重建品牌标签(例如第二张图片中的Lee标签),但它确实能捕捉到人类服装的基本概念,尽管它的学习能力相当有限。但这就足够了吗?对于任何实际应用场景来说,答案是“不够”,比如计算机辅助服装设计。缺少的细节太多,部分是由于我们网络的学习能力有限,部分是因为压缩输出存在损失。自然而然地,这让人想知道,深度模型能做到什么呢?正如那句老话所说,nullius in verba(用更现代的语言来说,就是让我们自己看看!)。
设计一个深度自动编码器
接下来,我们将研究自动编码器的重建效果可以有多好,看看它们是否能生成比我们刚刚看到的模糊表示更好的图片。为此,我们将设计一个深度前馈自动编码器。如你所知,这意味着我们将在自动编码器的输入层和输出层之间添加额外的隐藏层。为了保持趣味性,我们还将使用一个不同的数据集。你可以根据自己的兴趣,在 fashion_mnist 数据集上重新实现这个方法,进一步探索自动编码器能达成的时尚感。
对于下一个练习,我们将使用位于 Kaggle 的 10 种猴子物种数据集。我们将尝试重建我们这些顽皮、捣蛋的“远房亲戚”——来自丛林的猴子们的图片,并看看我们的自动编码器在一个更复杂的重建任务中表现如何。这也给了我们一个机会,远离 Keras 中现成预处理数据集的舒适区,因为我们将学会处理不同大小和更高分辨率的图片,而不是单调的 MNIST 示例:www.kaggle.com/slothkong/10-monkey-species。
导入必要的库
我们将首先导入必要的库,这已经是传统了。你会注意到一些常见的库,比如 NumPy、pandas、Matplotlib,以及一些 Keras 的模型和层对象:
import cv2
import datetime as dt
import matplotlib.pylab as plt
import numpy as np
import pandas as pd
from keras import models, layers, optimizers
from keras.layers import Input, Dense
from keras.models import Model
from pathlib import Path
from vis.utils import utils
请注意,我们从 Keras 的 vis 库中导入了一个实用模块。虽然该模块包含许多其他方便的图片处理功能,但我们将使用它来将我们的训练图片调整为统一的尺寸,因为该数据集中的图片并不都是统一的。
理解数据
我们选择这个数据集用于我们的应用案例有一个特定的原因。与 28 x 28 像素的衣物图片不同,这些图片呈现了丰富和复杂的特征,比如体型的变化,以及当然,还有颜色!我们可以绘制出数据集的组成,看看类别分布的情况,这完全是为了满足我们的好奇心:

你会注意到,这 10 种不同的猴子物种各自有显著不同的特征,包括不同的体型、毛发颜色和面部构成,这使得对于自编码器来说这是一个更加具有挑战性的任务。以下是来自八种不同猴子物种的示例图像,以便更好地展示这些物种之间的差异。如你所见,它们每一种看起来都独一无二:

由于我们知道自编码器是数据特定的,因此训练自编码器重构一个具有高方差的图像类别可能会导致可疑的结果,这也是合乎逻辑的。然而,我们希望这能为你提供一个有用的案例,以便你更好地理解使用这些模型时会遇到的潜力和限制。那么,让我们开始吧!
导入数据
我们将从 Kaggle 仓库开始导入不同猴子物种的图像。像之前一样,我们将数据下载到文件系统中,然后使用 Python 内置的操作系统接口(即os模块)访问训练数据文件夹:
import os
all_monkeys = []
for image in os.listdir(train_dir):
    try:
        monkey = utils.load_img(('C:/Users/npurk/Desktop/VAE/training/' + image), target_size=(64,64))
        all_monkeys.append(monkey)
    except Exception as e:
        pass
    print('Recovered data format:', type(all_monkeys))    
print('Number of monkey images:', len(all_monkeys))
-----------------------------------------------------------------------
Recovered data format: <class 'list'> 
Number of monkey images: 1094
你会注意到我们将图像变量嵌套在一个try/except循环中。这只是一个实现上的考虑,因为我们发现数据集中的一些图像已经损坏。因此,如果我们无法通过utils模块中的load_img()函数加载图像,我们将完全忽略该图像文件。这种(有些任意的)选择策略使得我们从训练文件夹中恢复了 1,094 张图像,总共有 1,097 张。
数据预处理
接下来,我们将把像素值列表转换成 NumPy 数组。我们可以打印出数组的形状来确认我们确实有 1,094 张 64 x 64 像素的彩色图像。确认之后,我们只需通过将每个像素值除以可能的最大像素值(即 255)来将像素值归一化到 0 – 1 之间:
# Make into array
all_monkeys = np.asarray(all_monkeys)
print('Shape of array:', all_monkeys.shape)
# Normalize pixel values
all_monkeys = all_monkeys.astype('float32') / 255.
# Flatten array
all_monkeys = all_monkeys.reshape((len(all_monkeys), np.prod(all_monkeys.shape[1:])))
print('Shape after flattened:', all_monkeys.shape)
Shape of array: (1094, 64, 64, 3)
Shape after flattened: (1094, 12288)
最后,我们将四维数组展平成二维数组,因为我们的深度自编码器由前馈神经网络组成,神经网络通过其层传播二维向量。这类似于我们在第三章中所做的,信号处理 – 使用神经网络进行数据分析,我们实际上是将每张三维图像(64 x 64 x 3)转换成一个二维向量,维度为(1,12,288)。
数据分区
现在我们的数据已经经过预处理,并作为一个标准化像素值的 2D 张量存在,我们最终可以将其划分为训练集和测试集。这样做非常重要,因为我们希望最终在网络从未见过的图像上使用我们的模型,并能够利用它对猴子应该是什么样子的理解重建这些图像。请注意,虽然我们在这个用例中并不使用数据集中提供的标签,但网络本身会接收到每个图像的标签。在这种情况下,标签将仅仅是图像本身,因为我们处理的是图像重建任务,而非分类。因此,在自编码器的情况下,输入变量与目标变量是相同的。正如以下截图所示,我们使用 sklearn 的模型选择模块中的 train_test_split 函数来生成训练和测试数据(80/20 的划分比例)。你会注意到,由于我们任务的性质,x 和 y 变量都由相同的数据结构定义:

现在我们剩下 875 个训练样本和 219 个测试样本,用于训练和测试我们的深度自编码器。请注意,Kaggle 数据集自带一个明确的测试集目录,因为该数据集的最初目的是尝试使用机器学习模型对不同猴子物种进行分类。然而,在我们的用例中,我们暂时并没有严格保证类的平衡,只是对深度自编码器在高方差数据集上进行训练时重建图像的表现感兴趣。我们确实鼓励进一步的实验,比较在特定猴子物种上训练的深度自编码器的表现。逻辑上,这些模型会在重建输入图像时表现更好,因为它们在训练观察中的相似性较高。
使用功能性 API 设计自编码器
就像我们在前一个示例中做的那样,我们将使用功能性 API 来构建我们的深度自编码器。我们将导入输入层和全连接层,以及我们稍后将用于初始化网络的模型对象。我们还将定义图像的输入维度(64 x 64 x 3 = 12,288),以及编码维度为 256,这样我们的压缩比为 48。简单来说,这意味着每张图像会被压缩 48 倍,然后网络将尝试从潜在空间中重建它:
from keras.layers import Input, Dense
from keras.models import Model
##Input dimension
input_dim=12288
##Encoding dimension for the latent space
encoding_dim=256
压缩因子是一个非常重要的参数,值得考虑,因为将输入映射到非常低的维度空间会导致过多的信息丢失,从而导致重建效果较差。可能根本没有足够的空间存储图像的关键要素。另一方面,我们已经知道,向模型提供过多的学习能力可能会导致过拟合,这也是手动选择压缩因子可能相当棘手的原因。当有疑问时,尝试不同的压缩因子和正则化方法(只要你有时间)总是值得一试。
构建模型
为了构建我们的深度自编码器,我们将从定义输入层开始,该层接受与猴子图像的二维向量相对应的维度。接着,我们简单地开始定义网络的编码器部分,使用密集层,每一层的神经元数量逐层减少,直到达到潜在空间。请注意,我们简单地选择了每一层神经元数量相对于所选编码维度减少的比例为 2。因此,第一层有(256 x 4) 1024 个神经元,第二层有(256 x 2) 512 个神经元,第三层,即潜在空间层,有 256 个神经元。虽然你不必严格遵守这一约定,但通常在接近潜在空间时减少每一层的神经元数量,而在潜在空间之后的层中增加神经元数量,这是在使用欠完备自编码器时的常见做法:
# Input layer placeholder
input_layer = Input(shape=(input_dim,))
# Encoding layers funnel the images into lower dimensional representations
encoded = Dense(encoding_dim * 4, activation='relu')(input_layer)
encoded = Dense(encoding_dim * 2, activation='relu')(encoded)
# Latent space
encoded = Dense(encoding_dim, activation='relu')(encoded)
# "decoded" is the lossy reconstruction of the input
decoded = Dense(encoding_dim * 2, activation='relu')(encoded)
decoded = Dense(encoding_dim * 4, activation='relu')(decoded)
decoded = Dense(input_dim, activation='sigmoid')(decoded)
# this model maps an input to its reconstruction
autoencoder = Model(input_layer, decoded)
autoencoder.summary()
_______________________________________________________________
Layer (type)                 Output Shape              Param #   =================================================================
input_1 (InputLayer)         (None, 12288)             0         _______________________________________________________________
dense_1 (Dense)              (None, 1024)              12583936  _______________________________________________________________
dense_2 (Dense)              (None, 512)               524800    _______________________________________________________________
dense_3 (Dense)              (None, 256)               131328    _______________________________________________________________
dense_4 (Dense)              (None, 512)               131584    _______________________________________________________________
dense_5 (Dense)              (None, 1024)              525312    _______________________________________________________________
dense_6 (Dense)              (None, 12288)             12595200  =================================================================
Total params: 26,492,160
Trainable params: 26,492,160
Non-trainable params: 0
_________________________________________________________________
最后,我们通过将输入层和解码器层作为参数传递给模型对象来初始化自编码器。然后,我们可以直观地总结我们刚刚构建的模型。
训练模型
最后,我们可以开始训练会话了!这次,我们将使用adam优化器来编译模型,并用均方误差来操作loss函数。然后,我们只需通过调用.fit()方法并提供适当的参数来开始训练:
autoencoder.compile(optimizer='adam', loss='mse')
autoencoder.fit(x_train, x_train, epochs=100, batch_size=20, verbose=1)
Epoch 1/100
875/875 [==============================] - 15s 17ms/step - loss: 0.0061
Epoch 2/100
875/875 [==============================] - 13s 15ms/step - loss: 0.0030
Epoch 3/100
875/875 [==============================] - 13s 15ms/step - loss: 0.0025
Epoch 4/100
875/875 [==============================] - 14s 16ms/step - loss: 0.0024
Epoch 5/100
875/875 [==============================] - 13s 15ms/step - loss: 0.0024
该模型在第 100 个周期结束时的损失为(0.0046)。请注意,由于之前为浅层模型选择了不同的loss函数,因此每个模型的损失指标不能直接进行比较。实际上,loss函数的定义方式决定了模型试图最小化的目标。如果你希望基准测试并比较两种不同神经网络架构的性能(例如前馈网络和卷积神经网络),建议首先使用相同的优化器和loss函数,然后再尝试其他的选择。
可视化结果
现在,让我们通过在孤立的测试集上测试深度自编码器的表现,来看看它能够进行的重建。为此,我们将简单地使用单独的编码器网络来预测如何将这些图像压缩到潜在空间,然后解码器网络将从编码器预测的潜在空间中接手,进行解码并重建原始图像:
decoded_imgs = autoencoder.predict(x_test)
# use Matplotlib (don't ask)
import matplotlib.pyplot as plt
n = 6  # how many digits we will display
plt.figure(figsize=(22, 6))
for i in range(n):
    # display original
    ax = plt.subplot(2, n, i + 1)
    plt.imshow(x_test[i].reshape(64, 64, 3))    #x_test
    plt.gray()
    ax.get_xaxis().set_visible(False)
    ax.get_yaxis().set_visible(False)
    # display reconstruction
    ax = plt.subplot(2, n, i + 1 + n)
    plt.imshow(decoded_imgs[i].reshape(64, 64, 3))
    plt.gray()
    ax.get_xaxis().set_visible(False)
    ax.get_yaxis().set_visible(False)
plt.show()
我们将得到以下输出:

尽管这些图像本身可以说在美学上令人愉悦,但似乎代表猴子的本质特征大部分依然没有被我们的模型捕捉到。大多数重建图像看起来像星空,而不是猴子的特征。我们确实注意到,网络已经开始在一个非常基础的层面上学习到一般的人形形态,但这并不足为奇。那么,如何改进呢?归根结底,我们希望至少能够用一些看起来逼真的猴子重建图像来结束这一章。为此,我们将使用一种特定类型的网络,它擅长处理图像数据。我们所说的就是卷积神经网络(CNN)架构,我们将重新设计它,以便在本练习的下一部分中构建一个深度卷积自编码器。
深度卷积自编码器
幸运的是,我们所要做的就是定义一个卷积网络,并将我们的训练数组调整为适当的维度,以测试它在当前任务中的表现。因此,我们将导入一些卷积、MaxPooling 和 UpSampling 层,开始构建网络。我们定义输入层,并为其提供 64 x 64 彩色图像的形状。然后,我们简单地交替使用卷积层和池化层,直到达到潜在空间,该空间由第二个 MaxPooling2D 层表示。另一方面,从潜在空间出去的层必须交替使用卷积层和 UpSampling 层。UpSampling 层顾名思义,通过重复前一层的数据的行和列,简单地增加表示维度:
from keras.layers import Conv2D, MaxPooling2D, UpSampling2D
# Input Placeholder
input_img = Input(shape=(64, 64, 3))  # adapt this if using `channels_first` image data format
# Encoder part
l1 = Conv2D(32, (3, 3), activation='relu', padding='same')(input_img)
l2 = MaxPooling2D((2, 2), padding='same')(l1)
l3 = Conv2D(16, (3, 3), activation='relu', padding='same')(l2)
# Latent Space, with dimension (None, 32, 32, 16)
encoded = MaxPooling2D((1,1), padding='same')(l3) 
# Decoder Part
l8 = Conv2D(16, (3, 3), activation='relu', padding='same')(encoded)
l9 = UpSampling2D((2, 2))(l8)
decoded = Conv2D(3, (3, 3), activation='sigmoid', padding='same')(l9)
autoencoder = Model(input_img, decoded)
autoencoder.summary()
_______________________________________________________________
Layer (type)                 Output Shape              Param #   =================================================================
input_2 (InputLayer)         (None, 64, 64, 3)         0         _______________________________________________________________
conv2d_5 (Conv2D)            (None, 64, 64, 32)        896       _______________________________________________________________
max_pooling2d_3 (MaxPooling2 (None, 32, 32, 32)        0         _______________________________________________________________
conv2d_6 (Conv2D)            (None, 32, 32, 16)        4624      _______________________________________________________________
max_pooling2d_4 (MaxPooling2 (None, 32, 32, 16)        0         _______________________________________________________________
conv2d_7 (Conv2D)            (None, 32, 32, 16)        2320      _______________________________________________________________
up_sampling2d_2 (UpSampling2 (None, 64, 64, 16)        0         _______________________________________________________________
conv2d_8 (Conv2D)            (None, 64, 64, 3)         435       =================================================================
Total params: 8,275
Trainable params: 8,275
Non-trainable params: _________________________________________________________________
正如我们所见,这个卷积自编码器有八层。信息首先进入输入层,然后卷积层生成 32 个特征图。这些特征图通过最大池化层进行下采样,生成 32 个特征图,每个特征图的大小为 32 x 32 像素。接着,这些特征图传递到潜在层,该层存储输入图像的 16 种不同表示,每种表示的尺寸为 32 x 32 像素。这些表示随后传递到后续层,输入在卷积和上采样操作下不断处理,直到到达解码层。就像输入层一样,我们的解码层与 64 x 64 彩色图像的尺寸匹配。你可以通过使用 Keras 后台模块中的int_shape()函数来检查特定卷积层的尺寸(而不是可视化整个模型),如下所示:
# Check shape of a layer
import keras
keras.backend.int_shape(encoded)
(None, 32, 32, 16)
编译和训练模型
接下来,我们简单地使用相同的优化器和loss函数来编译我们的网络,这些都是我们为深度前馈网络选择的,并通过调用模型对象的.fit()方法启动训练会话。需要注意的是,我们只训练这个模型 50 个周期,并且在每次批量更新时处理 128 张图像。证明这种方法在计算上更高效,使得我们能在比训练前馈模型所需的时间短得多的时间内完成训练。让我们看看在这个特定用例中,训练时间和准确性之间的折衷是否对我们有利:
autoencoder.compile(optimizer='adam', loss='mse')
autoencoder.fit(x_train, x_train, epochs=50, batch_size=20,
                shuffle=True, verbose=1)
Epoch 1/50
875/875 [==============================] - 7s 8ms/step - loss: 0.0462
Epoch 2/50
875/875 [==============================] - 6s 7ms/step - loss: 0.0173
Epoch 3/50
875/875 [==============================] - 7s 9ms/step - loss: 0.0133
Epoch 4/50
875/875 [==============================] - 8s 9ms/step - loss: 0.0116
到第 50^(次)训练周期结束时,模型的损失达到了(0.0044)。这比早期的前馈模型要低,后者在训练时使用了更大的批量大小,并且训练了较少的训练周期。接下来,让我们通过视觉判断模型在重建从未见过的图像时的表现。
测试和可视化结果
现在是时候看看卷积神经网络(CNN)是否真的能够胜任我们当前的图像重建任务了。我们简单地定义了一个辅助函数,允许我们绘制出从测试集中生成的若干样本,并将它们与原始测试输入进行比较。然后,在接下来的代码单元中,我们定义了一个变量,用来存储我们模型对测试集进行推理后的结果,方法是使用模型对象的.predict()方法。这将生成一个 NumPy ndarray,包含所有解码后的测试集输入图像。最后,我们调用compare_outputs()函数,使用测试集和对应的解码预测作为参数来可视化结果:
def compare_outputs(x_test, decoded_imgs=None, n=10):
    plt.figure(figsize=(22, 5))
    for i in range(n):
        ax = plt.subplot(2, n, i+1)
        plt.imshow(x_test[i].reshape(64,64,3))
        ax.get_xaxis().set_visible(False)
        ax.get_yaxis().set_visible(False)
        if decoded_imgs is not None:
            ax = plt.subplot(2, n, i+ 1 +n)
            plt.imshow(decoded_imgs[i].reshape(64,64,3))
            ax.get_xaxis().set_visible(False)
            ax.get_yaxis().set_visible(False)
    plt.show()
decoded_imgs = autoencoder.predict(x_test)
print('Upper row: Input image provided \nBottom row: Decoded output 
       generated')
compare_outputs(x_test, decoded_imgs)
Upper row: Input image provided 
Bottom row: Decoded output generated
以下是输出结果:

如我们所见,深度卷积自编码器在重建测试集中的图像方面表现非常出色。它不仅学会了身体形态和正确的色彩方案,甚至能够重建一些细节,比如相机闪光灯下的红眼现象(如猴子 4 及其人工复制品所示)。太棒了!所以,我们成功地重建了一些猿类图像。随着兴奋感的渐渐消退(如果一开始就有的话),我们将希望将自编码器应用于更多有用的现实任务——比如图像去噪任务,在这类任务中,我们委托网络从损坏的输入中完全重建图像。
去噪自编码器
再次,我们将继续使用猴子物种数据集,并修改训练图像以引入噪声因子。这个噪声因子本质上是通过改变原始图像的像素值,去除构成原始图像的一些信息,从而使任务变得比简单重建原始输入更具挑战性。需要注意的是,这意味着我们的输入变量将是噪声图像,而在训练期间,网络看到的目标变量将是未损坏的噪声输入图像版本。为了生成训练和测试图像的噪声版本,我们只需对图像像素应用一个高斯噪声矩阵,然后将其值截断在 0 到 1 之间:
noise_factor = 0.35
# Define noisy versions
x_train_noisy = x_train + noise_factor * np.random.normal(loc=0.0, scale=1.0, size=x_train.shape) 
x_test_noisy = x_test + noise_factor * np.random.normal(loc=0.0, scale=1.0, size=x_test.shape) 
# CLip values between 0 and 1
x_train_noisy = np.clip(x_train_noisy, 0., 1.)
x_test_noisy = np.clip(x_test_noisy, 0., 1.)
我们可以通过绘制来自数据中的一个随机示例,看到我们任意选择的噪声因子0.35如何实际影响图像,如下面的代码所示。在这个分辨率下,噪声图像几乎无法被人眼理解,看起来仅仅是一些随机像素聚集在一起:
# Effect of adding noise factor
f = plt.figure()
f.add_subplot(1,2, 1)
plt.imshow(x_test[1])
f.add_subplot(1,2, 2)
plt.imshow(x_test_noisy[1])
plt.show(block=True)
这是您将得到的输出:

训练去噪网络
我们将使用相同的卷积自编码器架构来处理这个任务。然而,我们将重新初始化模型并从头开始训练,这次使用的是带有噪声输入变量的数据:
autoencoder.compile(optimizer='adam', loss='mse')
autoencoder.fit(x_train_noisy, x_train, epochs=50, batch_size=20,
                shuffle=True, verbose=1)
Epoch 1/50875/875 [==============================] - 7s 8ms/step - loss: 0.0449
Epoch 2/50
875/875 [==============================] - 6s 7ms/step - loss: 0.0212
Epoch 3/50
875/875 [==============================] - 6s 7ms/step - loss: 0.0185
Epoch 4/50
875/875 [==============================] - 6s 7ms/step - loss: 0.0169
如我们所见,在去噪自编码器的情况下,损失收敛速度比之前的实验更为缓慢。这是自然而然的,因为现在输入信息已经丢失了很多,导致网络更难学习到一个适当的潜在空间来生成未损坏的输出。因此,网络在压缩和重建操作中被迫变得稍微有点创造性。该网络的训练在 50 个周期后结束,损失为 0.0126。现在我们可以对测试集进行一些预测,并可视化一些重建结果。
可视化结果
最后,我们可以测试模型在面对更具挑战性的任务(如图像去噪)时的表现。我们将使用相同的辅助函数,将我们的网络输出与测试集中的一个样本进行比较,如下所示:
def compare_outputs(x_test, decoded_imgs=None, n=10):
    plt.figure(figsize=(22, 5))
    for i in range(n):
        ax = plt.subplot(2, n, i+1)
        plt.imshow(x_test_noisy[i].reshape(64,64,3))
        plt.gray()
        ax.get_xaxis().set_visible(False)
        ax.get_yaxis().set_visible(False)
        if decoded_imgs is not None:
            ax = plt.subplot(2, n, i+ 1 +n)
            plt.imshow(decoded_imgs[i].reshape(64,64,3))
            ax.get_xaxis().set_visible(False)
            ax.get_yaxis().set_visible(False)
    plt.show()
decoded_imgs = autoencoder.predict(x_test_noisy)
print('Upper row: Input image provided \nBottom row: Decoded output generated')
compare_outputs(x_test, decoded_imgs)
Upper row: Input image provided 
Bottom row: Decoded output generated
以下是输出结果:

如我们所见,尽管添加了噪声因素,网络在重建图像方面表现相当不错!这些图像对人眼来说很难区分,因此,网络能够重建其中元素的整体结构和组成确实值得注意,尤其是考虑到分配给网络的有限学习能力和训练时间。
我们鼓励你通过更改层数、过滤器数量和潜在空间的编码维度来尝试更复杂的架构。事实上,现在可能是进行一些练习的最佳时机,相关练习会在本章末尾提供。
总结
本章中,我们从高层次探讨了自编码器的基本理论,并概念化了允许这些模型进行学习的基础数学。我们看到了几种不同的自编码器架构,包括浅层、深层、不完全和过度完整的模型。这让我们能够概述与每种模型的表示能力相关的考虑因素,以及它们在容量过大时容易过拟合的倾向。我们还探讨了一些正则化技术,帮助我们弥补过拟合问题,例如稀疏自编码器和收缩自编码器。最后,我们训练了几种不同类型的自编码器网络,包括浅层、深层和卷积网络,进行图像重建和去噪的任务。我们看到,尽管学习能力和训练时间非常有限,卷积自编码器在图像重建方面超越了所有其他模型。此外,它能够从受损的输入中生成去噪图像,保持输入数据的整体格式。
虽然我们没有探索其他使用案例,例如使用降维来可视化主要的方差因子,但自编码器在不同领域中得到了广泛应用,从推荐系统中的协同过滤,到甚至预测未来的病人,见Deep Patient:www.nature.com/articles/srep26094。有一种特定类型的自编码器是我们故意没有在本章中讨论的:变分 自编码器(VAE)。这种自编码器在模型学习的潜在空间上施加了特殊的约束。它实际上迫使模型学习一个表示输入数据的概率分布,并从中采样生成输出。这与我们至今探索的方法大不相同,后者最多只能让我们的网络学习一个某种程度上任意的函数。我们选择不在本章中包括这一有趣的子话题的原因是,VAE 在技术术语中属于生成模型的一个实例,而生成模型正是我们下一章的主题!
练习
- 
使用时尚 MNIST 数据集创建深度自动编码器(AE),并监控损失何时趋于平稳。然后,与浅层自动编码器进行比较。 
- 
在另一个您选择的数据集上实现自动编码器(AE),并尝试不同的编码维度、优化器和 loss函数,看看模型表现如何。
- 
比较不同模型(CNN、FF)损失何时收敛以及损失值下降的稳定性或不稳定性。您注意到了什么? 
第九章:生成对抗网络
在上一章中,我们沉浸在自编码神经网络的世界里。我们看到了这些模型如何用于估计能够根据目标输出重建给定输入的参数化函数。虽然乍一看这似乎很简单,但我们现在知道,这种自监督编码方式具有多种理论和实际意义。
实际上,从机器学习(ML)的角度来看,能够将高维空间中的一组连接点近似到低维空间(即流形学习)具有多个优点,从更高的数据存储效率到更高效的内存消耗。实际上,这使我们能够为不同类型的数据发现理想的编码方案,或在此基础上执行降维,以应用于主成分分析(PCA)或信息检索等用例。例如,使用相似查询搜索特定信息的任务可以通过从低维空间中学习有用的表示来大大增强。此外,学习到的表示还可以作为特征检测器,用于分类新的、传入的数据。这种应用可能使我们能够构建强大的数据库,在面对查询时能够进行高级推理和推断。衍生的实现包括律师用来根据当前案件的相似性高效检索先例的法律数据库,或允许医生根据每个患者的噪声数据高效诊断患者的医疗系统。这些潜变量模型使研究人员和企业能够解决各种用例,从序列到序列的机器翻译,到将复杂意图归因于客户评论。实质上,使用生成模型,我们试图回答这个问题:在给定数据实例属于某个类别(y)的情况下,这些特征(x)出现的可能性有多大? 这与我们在监督学习任务中会问的这个问题是完全不同的:在给定特征(x)的情况下,这个实例属于类别(y)的可能性有多大? 为了更好地理解这种角色的反转,我们将进一步探讨前一章中介绍的潜变量建模的理念。
在本章中,我们将看到如何进一步发展潜变量的概念。我们不再只是简单地学习一个参数化的函数,它将输入映射到输出,而是可以使用神经网络来学习一个表示潜在空间中概率分布的函数。然后,我们可以从这个概率分布中采样,生成新的、合成的输入数据实例。这就是生成模型背后的核心理论基础,正如我们即将发现的那样。
在本章中,我们将涵盖以下主题:
- 
复制与生成内容 
- 
理解潜在空间的概念 
- 
深入了解生成网络 
- 
使用随机性来增强输出 
- 
从潜在空间进行采样 
- 
理解生成对抗网络的类型 
- 
理解变分自编码器(VAE) 
- 
在 Keras 中设计变分自编码器(VAE) 
- 
在 VAE 中构建编码模块 
- 
构建解码器模块 
- 
可视化潜在空间 
- 
潜在空间采样与输出生成 
- 
探索生成对抗网络(GANs) 
- 
深入了解生成对抗网络(GANs) 
- 
在 Keras 中设计生成对抗网络(GAN) 
- 
设计生成器模块 
- 
设计判别器模块 
- 
整合生成对抗网络(GAN) 
- 
训练函数 
- 
定义判别器标签 
- 
按批次训练生成器 
- 
执行训练会话 
复制与生成内容
尽管我们在上一章中的自动编码应用仅限于图像重建和去噪,这些应用与我们将在本章讨论的案例有很大区别。到目前为止,我们让自动编码器通过学习任意映射函数来重建某些给定的输入。在本章中,我们希望理解如何训练一个模型来创造某些内容的新实例,而不仅仅是复制它的输入。换句话说,如果我们要求神经网络像人类一样真正具备创造力并生成内容,这是否可行?在人工智能(AI)领域,标准答案是肯定的,但过程相当复杂。在寻找更详细的答案时,我们来到了本章的主题:生成网络。
尽管存在众多生成网络,从深度玻尔兹曼机到深度信念网络的变种,但大多数已经失去了流行,原因在于它们的适用性有限且出现了更具计算效率的方法。然而,少数几种依然处于焦点之中,因为它们具备生成合成内容的神奇能力,比如从未存在过的面孔、从未写过的电影评论和新闻文章,或是从未拍摄过的视频!为了更好地理解这些魔术背后的机制,我们将花几行文字来介绍潜在空间的概念,从而更好地理解这些模型如何转化其学习到的表示,创造出看似全新的东西。
理解潜在空间的概念
回顾上一章,潜在空间不过是输入数据在低维空间中的压缩表示。它本质上包含了对于识别原始输入至关重要的特征。为了更好地理解这个概念,尝试在脑海中可视化潜在空间可能编码的那些信息是很有帮助的。一个有用的类比是考虑我们如何用想象力创造内容。假设你被要求创造一个虚构的动物,你会依赖哪些信息来创造这个生物?你将从之前见过的动物身上采样特征,比如它们的颜色,或者它们是双足的,四足的,是哺乳动物还是爬行动物,生活在陆地还是海洋等。事实证明,我们自己也在世界中航行时,逐渐发展出潜在的世界模型。当我们尝试想象某个类别的新实例时,实际上是在采样一些潜在变量模型,这些模型是在我们存在的过程中学习得到的。
想一想。在我们的一生中,我们遇到了无数种不同颜色、大小和形态的动物。我们不断地将这些丰富的表示减少到更可管理的维度。例如,我们都知道狮子长什么样,因为我们已经在脑海中编码了代表狮子的属性(或潜在变量),比如它们的四条腿、尾巴、毛茸茸的皮毛、颜色等。这些学习到的属性证明了我们如何在低维空间中存储信息,从而创造出周围世界的功能性模型。我们假设,这些信息是存储在低维空间中的,因为大多数人,例如,无法在纸上完美重现狮子的形象。有些人甚至无法接近,这对于本书的作者来说就是如此。然而,我们所有人只要提到狮子这个词,都会立即并集体地同意狮子的总体形态。
识别概念向量
这个小小的思维实验展示了潜在变量模型在创造世界的功能性表示方面的巨大力量。如果我们的脑袋没有不断将从感官输入接收到的信息进行下采样,以创造可管理且现实的世界模型,它很可能会消耗远超过那可怜的 12 瓦特的能量。因此,使用潜在变量模型本质上允许我们查询输入的简化表示(或属性),这些表示可能会与其他表示重新组合,从而生成看似新颖的输出(例如:独角兽 = 来自马的身体和脸 + 来自犀牛/独角鲸的角)。
同样,神经网络也可以从学习到的潜在空间中转换样本,以生成新的内容。实现这一目标的一种方法是识别嵌入在学习到的潜在空间中的概念向量。这里的想法非常简单。假设我们要从代表面孔的潜在空间中采样一个面孔(f)。然后,另一个点(f + c)可以被认为是相同面孔的嵌入式表示,并且包含某些修改(即在原始面孔上添加微笑、眼镜或面部毛发)。这些概念向量本质上编码了输入数据的各个差异维度,随后可以用于改变输入图像的某些有趣特性。换句话说,我们可以在潜在空间中寻找与输入数据中存在的概念相关的向量。在识别出这些向量后,我们可以修改它们,以改变输入数据的特征。例如,微笑向量可以被学习并用来修改某人在图像中的微笑程度。同样,性别向量可以用来修改某人的外观,使其看起来更女性化或男性化,反之亦然。现在,我们对潜在空间中可能查询到的信息以及如何修改这些信息以生成新内容有了更好的理解,我们可以继续我们的探索之旅。
深入探讨生成网络
所以,让我们尝试理解生成网络的核心机制,以及这种方法与我们已知的其他方法有何不同。到目前为止,我们实现的大多数网络都是为了执行某些输入的确定性变换,从而得到某种输出。直到我们探讨强化学习的话题(第七章,使用深度 Q 网络进行强化学习),我们才了解了将一定程度的随机性(即随机因素)引入建模过程的好处。这是一个核心概念,我们将在进一步熟悉生成网络如何运作的过程中进行探讨。正如我们之前提到的,生成网络的核心思想是使用深度神经网络来学习在简化的潜在空间中,变量的概率分布。然后,可以以准随机的方式对潜在空间进行采样和转换,从而生成一些输出(y)。
正如你所注意到的,这与我们在上一章中采用的方法有很大的不同。对于自编码器,我们只是估计了一个任意函数,通过编码器将输入(x)映射到压缩的潜在空间,再通过解码器重构输出(y)。而在生成对抗网络中,我们则学习输入数据(x)的潜在变量模型。然后,我们可以将潜在空间中的样本转化为生成的输出。不错吧?然而,在我们进一步探讨如何将这个概念操作化之前,让我们简要回顾一下随机性在生成创造性内容方面的作用。
受控随机性与创造力
回想一下,我们通过使用epsilon 贪心选择策略在深度强化学习算法中引入了随机性,这基本上使我们的网络不再过度依赖相同的动作,而是能够探索新动作来解决给定的环境。引入这种随机性,从某种意义上说,为这个过程带来了创造性,因为我们的网络能够系统地创建新的状态-动作对,而不依赖于之前学到的知识。然而需要注意的是,将引入随机性的后果称为创造力,可能是我们某种程度上的拟人化。实际上,赋予人类(我们的基准)创造力的真实过程仍然极为难以捉摸,并且科学界对其理解甚少。另一方面,随机性与创造力之间的联系早已得到认可,特别是在人工智能领域。早在 1956 年,人工智能研究者就对超越机器看似决定性的局限性产生了兴趣。当时,基于规则的系统的突出地位使得人们认为,诸如创造力这样的概念只能在高级生物有机体中观察到。尽管有这种广泛的信念,但塑造人工智能历史的最重要文件之一(可以说影响了未来一个世纪),即达特茅斯夏季研究项目提案(1956 年),特别提到了受控随机性在人工智能系统中的作用,以及它与生成创造性内容的联系。虽然我们鼓励你阅读整个文件,但我们提取了其中与当前话题相关的部分:
“一个相当有吸引力但显然不完整的猜想是,创造性思维与缺乏想象力的有能力的思维之间的区别在于引入了一些随机性。这些随机性必须通过直觉来指导,以提高效率。换句话说,教育性猜测或直觉包括在其他有序思维中引入受控的随机性。”
约翰·麦卡锡、马文·L·明斯基、内森尼尔·罗切斯特和克劳德·E·香农
使用随机性来增强输出
多年来,我们开发了可以实现这种注入受控随机性的机制,这些方法在某种程度上是由输入的直觉引导的。当我们谈论生成模型时,本质上是希望实现一种机制,允许我们对输入进行受控且准随机的变换,从而生成新的东西,但仍然在可行的范围内与原始输入相似。
让我们花一点时间考虑一下如何实现这一点。我们希望训练一个神经网络,利用一些输入变量(x)生成一些输出变量(y),这些输出来自模型生成的潜在空间。解决这一问题的一种简单方法是向我们的生成器网络输入添加一个随机性元素,这里由变量(z)定义。z的值可以从某个概率分布中抽样(例如,高斯分布),并与输入一起传递给神经网络。因此,这个网络实际上是在估计函数f(x, z),而不仅仅是f(x)。自然地,对于一个无法测量z值的独立观察者来说,这个函数看起来是随机的,但在现实中并非如此。
从潜在空间进行采样
进一步说明,假设我们必须从潜在空间的变量的概率分布中抽取一些样本(y),其中均值为(μ),方差为(σ2):
- 采样操作:y ̴ N(μ , σ2)
由于我们使用采样过程从该分布中抽取样本,每次查询该过程时,每个单独的样本可能会发生变化。我们无法准确地根据分布参数(μ 和 σ2)对生成的样本(y)进行求导,因为我们处理的是采样操作,而不是函数。那么,我们究竟如何进行反向传播模型的误差呢?一种解决方法可能是重新定义采样过程,例如对随机变量(z)进行变换,以得到我们的生成输出(y),如下所示:
- 采样方程:y = μ + σz
这是一个关键步骤,因为现在我们可以使用反向传播算法来计算生成输出(y)相对于采样操作本身((μ + σz))的梯度。有什么变化?本质上,我们现在将采样操作视为一个确定性操作,它包含了概率分布中的均值(μ)和标准差(σ),以及一个与我们要估计的其他变量分布无关的随机变量(z)。我们使用这种方法来估计当我们分布的均值(μ)或标准差(σ)发生变化时,如何影响生成输出(y),前提是采样操作以相同的z值被重现。
学习概率分布
既然我们现在可以通过采样操作进行反向传播,我们就可以将这一步骤作为更大网络的一部分。将其插入到更大的网络中后,我们可以重新定义早期采样操作(μ和σ)的参数,作为可以通过这个更大神经网络的部分来估算的函数!更数学化地说,我们可以将概率分布的均值和标准差重新定义为可以通过神经网络参数(例如,μ = f(x ;θ) 和 σ = g(x; θ),其中θ表示神经网络的可学习参数)来逼近的函数。然后,我们可以使用这些定义的函数来生成输出(y):
- 采样函数:y = μ + σz
在这个函数中,μ = f(x ;θ) 和 σ = g(x; θ)。
现在我们知道如何对输出进行采样(y),我们可以通过对定义的损失函数J(y)进行微分,来最终训练我们的更大网络。回想一下,我们使用链式法则重新定义这个过程,关于中间层,这些中间层在此表示参数化的函数(μ和σ)。因此,微分这个损失函数可以得到它的导数,利用这些导数迭代更新网络的参数,而这些参数本身代表了一个概率分布。
太棒了!现在我们有了关于这些模型如何生成输出的全面理论理解。这个整个过程允许我们首先估计,然后从由编码器函数生成的密集编码变量的概率分布中采样。在本章后面,我们将进一步探讨不同生成网络如何通过对比其输出进行学习,并使用反向传播算法进行权重更新。
理解生成网络的类型
所以,我们实际上在做的就是通过转换从表示编码潜在空间的概率分布中采样得到的样本来生成输出。在上一章中,我们展示了如何使用编码函数从一些输入数据生成这样的潜在空间。在本章中,我们将展示如何学习一个连续的潜在空间(l),然后从中采样以生成新的输出。为了实现这一点,我们本质上学习一个可微分的生成器函数,g (l ; θ(g)), 该函数将来自连续潜在空间(l)的样本转换为输出。在这里,神经网络实际上在逼近这个函数本身。
生成网络家族包括变分自编码器(VAEs)和生成对抗网络(GANs)。如前所述,存在许多类型的生成模型,但在本章中,我们将重点讨论这两种变体,因为它们在各种认知任务(如计算机视觉和自然语言生成)中具有广泛的应用性。值得注意的是,VAEs 通过将生成网络与近似推断网络结合,来区分自己,而近似推断网络其实就是我们在上一章看到的编码架构。另一方面,GANs 将生成网络与独立的判别网络结合,后者接收来自实际训练数据和生成输出的样本,并负责区分原始图像与计算机生成的图像。一旦生成器被认为“骗过”了判别器,你的 GAN 就被认为训练完成了。本质上,这两种不同类型的生成模型采用不同的学习潜在空间的方法,这使得它们在不同类型的使用场景中具有独特的适用性。例如,VAEs 在学习良好结构化的空间方面表现出色,在这些空间中,由于输入数据的特定组成,可能会编码出显著的变化(正如我们稍后会看到,使用 MNIST 数据集时)。然而,VAEs 也存在模糊重建的问题,其原因尚未得到充分理解。相比之下,GANs 在生成逼真内容方面表现得更好,尽管它们是从一个无结构且不连续的潜在空间进行采样的,正如我们将在本章后面看到的那样。
理解变分自编码器(VAEs)
现在我们对生成网络的概念有了一个大致的理解,我们可以专注于一种特定类型的生成模型。VAE 就是其中之一,它由 Kingma 和 Welling(2013)以及 Rezende、Mohamed 和 Wierstra(2014)提出。这个模型实际上与我们在上一章中看到的自动编码器非常相似,但它们有一个小小的变化——或者更准确地说,有几个变化。首先,学习的潜在空间不再是离散的,而是通过设计变成了连续的!那么,这有什么大不了的呢?正如我们之前所解释的那样,我们将从这个潜在空间中进行采样来生成输出。然而,从离散的潜在空间中进行采样是有问题的。因为它是离散的,意味着潜在空间中会有不连续的区域,这样如果这些区域被随机采样,输出将看起来完全不真实。另一方面,学习一个连续的潜在空间使得模型能够以概率的方式学习从一个类别到另一个类别的过渡。此外,由于学习的潜在空间是连续的,因此可以识别和操控我们之前提到的概念向量,这些向量以一种有意义的方式编码了输入数据中存在的各种方差轴。在这一点上,许多人可能会好奇 VAE 如何准确地学习建模一个连续的潜在空间。嗯,别再好奇了。
之前,我们已经看到如何重新定义从潜在空间中采样的过程,以便能够将其插入到一个更大的网络中来估计概率分布。我们通过使用参数化函数(也就是神经网络的部分)来估计潜在空间中变量的均值(μ)和标准差(σ),从而将潜在空间分解。在 VAE 中,它的编码器函数正是做了这件事。这就迫使模型学习在连续潜在空间上变量的统计分布。这个过程使我们可以假设输入图像是以概率的方式生成的,因为潜在空间编码了一个概率分布。因此,我们可以使用学习到的均值和标准差参数,从该分布中进行随机采样,并将其解码到数据的原始维度。这里的插图帮助我们更好地理解 VAE 的工作流程:

这个过程使我们能够首先学习,然后从连续潜在空间中采样,生成合理的输出。还觉得有些模糊吗?嗯,也许一个演示示例可以帮助澄清这个概念。让我们从在 Keras 中构建一个 VAE 开始,边构建模型边讨论理论和实现方面的内容。
在 Keras 中设计 VAE
对于这个练习,我们将回到一个所有人都能轻松获取的知名数据集:MNIST 数据集。手写数字的视觉特征使得这个数据集特别适合用来实验变分自编码器(VAE),从而帮助我们更好地理解这些模型是如何工作的。我们首先导入必要的库:
import numpy as np
import matplotlib.pyplot as plt
from keras.layers import Input, Dense, Lambda, Layer
from keras.models import Model
from keras import backend as K
from keras import metrics
from keras.datasets import mnist
加载和预处理数据
接下来,我们加载数据集,就像在第三章《信号处理–神经网络数据分析》中做的那样。我们还自定义了一些变量,稍后在设计网络时可以复用。在这里,我们简单地定义了用于定义原始图像尺寸的图像大小(每个图像 784 像素)。我们选择了一个 2 的编码维度来表示潜在空间,并且选择了一个 256 的中间维度。这里定义的这些变量稍后将被传递到 VAE 的全连接层,用来定义每层的神经元数量:
(x_train, y_train), (x_test, y_test) = mnist.load_data()
image_size = x_train.shape[1]
original_dim=image_size * image_size
latent_dim= 2
intermediate_dim= 256
epochs=50
epsilon_std=1.0
#preprocessing training arrays
x_train=np.reshape(x_train, [-1, original_dim])
x_test=np.reshape(x_test, [-1, original_dim])
x_train=x_train.astype('float 32')/255
x_test=x_test.astype('float 32')/255
然后,我们简单地通过将图像先展平为二维向量(每个图像的维度为 784)来预处理图像。最后,我们将这些二维向量中的像素值归一化到 0 和 1 之间。
构建 VAE 中的编码模块
接下来,我们将开始构建 VAE 的编码模块。这一部分几乎与我们在上一章中构建的浅层编码器相同,只不过它拆分成了两个独立的层:一个估计均值,另一个估计潜在空间上的方差:
#Encoder module
input_layer= Input(shape=(original_dim,))
intermediate_layer= Dense(intermediate_dim, activation='relu', name='Intermediate layer')(input_layer)
z_mean=Dense(latent_dim, name='z-mean')(intermediate_layer)
z_log_var=Dense(latent_dim, name='z_log_var')(intermediate_layer)
在定义一个层时,你可以选择性地添加 name 参数,以便直观地可视化我们的模型。如果我们愿意,实际上可以通过初始化并总结它来可视化我们到目前为止构建的网络,如下所示:

请注意,中间层的输出如何连接到均值估计层(z_mean)和方差估计层(z_log_var),这两个层都表示由网络编码的潜在空间。总的来说,这些分离的层估计了潜在空间上变量的概率分布,正如本章前面所述。
所以,现在我们有了一个由 VAE 中间层学习的概率分布。接下来,我们需要一个机制从这个概率分布中随机采样,以生成我们的输出。这就引出了采样方程。
对潜在空间进行采样
这个过程背后的思想非常简单。我们通过在方程中使用从潜在空间中学到的均值(z_mean)和方差(z_log_variance)来定义一个样本(z),这个方程可以表述如下:
z = z_mean + exp(z_log_variance) * epsilon
在这里,epsilon 只是一个由非常小的值组成的随机张量,确保每次查询的样本中都会渗透一定程度的随机性。由于它是一个由非常小的值组成的张量,确保每个解码后的图像在可信度上会与输入图像相似。
这里介绍的采样函数简单地利用编码器网络学习到的值(即均值和方差),定义一个匹配潜在维度的小值张量,然后通过之前定义的采样方程返回一个来自概率分布的样本:

由于 Keras 要求所有操作都必须嵌套在层中,我们使用一个自定义 Lambda 层来嵌套这个采样函数,并定义输出形状。这个层,在这里定义为(z),将负责从学习到的潜在空间生成样本。
构建解码器模块
现在我们已经实现了从潜在空间采样的机制,可以继续构建解码器模块,将该样本映射到输出空间,从而生成输入数据的新实例。回想一下,就像编码器通过逐渐缩小层的维度直到得到编码表示,解码器层则逐步扩大从潜在空间采样的表示,将它们映射回原始图像维度:
#Decoder module
decoder_h= Dense(intermediate_dim, activation='relu')
decoder_mean= Dense(original_dim, activation='sigmoid')
h_decoded=decoder_h(z)
x_decoded_mean=decoder_mean(h_decoded)
定义一个自定义变分层
现在我们已经构建了网络的编码器和解码器模块,在开始训练 VAE 之前,我们还需要关注一个实现问题。这个问题非常重要,因为它涉及到我们的网络如何计算损失并更新自己,以生成更逼真的输出。乍一看,这可能有点奇怪。我们将生成结果与什么进行比较呢?并不是说我们有一个目标表示来与模型的生成结果进行比较,那么我们如何计算模型的误差呢?其实答案很简单。我们将使用两个独立的loss函数,每个函数跟踪模型在生成图像的不同方面的表现。第一个损失函数被称为重建损失,它简单地确保我们的模型的解码输出与提供的输入匹配。第二个loss函数是正则化损失。这个函数实际上帮助模型避免仅仅复制训练数据,从而避免过拟合,进而从输入中学习理想的潜在空间。不幸的是,这些loss函数在 Keras 中并没有直接实现,因此需要一些额外的技术处理才能运作。
我们通过构建一个自定义变分层类来操作这两个loss函数,这将成为我们网络的最终层,执行两个不同损失度量的计算,并使用它们的均值来计算关于网络参数的损失梯度:

如您所见,自定义层包含三个函数。第一个是初始化函数。第二个函数负责计算两个损失。它使用二元交叉熵度量来计算重建损失,并使用Kullback–Leibler(KL)散度公式来计算正则化损失。KL 散度项本质上允许我们计算生成输出相对于采样潜在空间(z)的相对熵。它让我们能够迭代地评估输出概率分布与潜在空间分布之间的差异。然后,vae_loss函数返回一个综合损失值,这个值就是这两项计算得出的度量的均值。
最后,call函数用于实现自定义层,方法是使用内建的add_loss层方法。这个部分实际上是将我们网络的最后一层定义为损失层,从而使用我们任意定义的loss函数生成损失值,进而可以进行反向传播。
编译并检查模型
接下来,我们使用我们刚刚实现的自定义变分层类来定义网络的最后一层(y),如下面所示:

现在我们终于可以编译并训练我们的模型了!首先,我们将整个模型组合在一起,使用功能性 API 中的Model对象,并将其输入层传入自编码器模块,以及我们刚刚定义的最后一个自定义损失层。接着,我们使用通常的compile语法来初始化网络,并为其配备rmsprop优化器。然而需要注意的是,由于我们使用了自定义损失函数,compile语句实际上不接受任何损失度量,通常应该存在的地方并没有出现。在这时,我们可以通过调用.summary()方法来可视化整个模型,如下所示:

如您所见,这个架构将输入图像传入并将其压缩为两个不同的编码表示:z_mean和z_log_var(即在潜在空间中学习得到的均值和方差)。然后,使用添加的 Lambda 层对这个概率分布进行采样,从而生成潜在空间中的一个点。接着,这个点通过全连接层(dense_5和dense_6)进行解码,最后通过我们自定义的损失层计算损失值。现在您已经看到了整个流程。
启动训练会话
现在是时候真正训练我们的网络了。这里没有什么特别之处,除了我们不需要指定目标变量(即y_train)之外。只是因为目标通常用于计算损失指标,而现在这些损失是由我们最后的自定义层计算的。你可能还会注意到,训练过程中显示的损失值相当大,相比之前的实现。这些损失值的大小不必惊慌,因为这只是由于这种架构计算损失的方式所导致的:

该模型训练了 50 个周期,在最后我们得到了151.71的验证损失和149.39的训练损失。在我们生成一些新颖的手写数字之前,让我们尝试可视化一下我们的模型所学到的潜在空间。
可视化潜在空间
由于我们有一个二维潜在空间,我们可以简单地将表示绘制为一个二维流形,在这个流形中,每个数字类别的编码实例可以根据它们与其他实例的接近度进行可视化。这使我们能够检查我们之前提到的连续潜在空间,并观察网络如何将不同特征与 10 个数字类别(0 到 9)相互关联。为此,我们重新访问 VAE 的编码模块,该模块现在可以用来从给定的数据中生成压缩的潜在空间。因此,我们使用编码器模块对测试集进行预测,从而将这些图像编码到潜在空间中。最后,我们可以使用 Matplotlib 中的散点图来绘制潜在表示。请注意,每个单独的点代表测试集中的一个编码实例。颜色表示不同的数字类别:
# 2D visualization of latent space
x_test_encoded = encoder_network.predict(x_test, batch_size=256)
plt.figure(figsize=(8, 8))
plt.scatter(x_test_encoded[:, 0], x_test_encoded[:, 1], c=y_test, cmap='Paired')
plt.colorbar()
plt.show()
以下是输出结果:

请注意,不同数字类别之间几乎没有不连续性或间隙。因此,我们现在可以从这种编码表示中采样,生成有意义的数字。如果学到的潜在空间是离散的,像我们在上一章构建的自编码器那样,这样的操作将不会产生有意义的结果。这些模型的潜在空间看起来与 VAE 所学到的潜在空间截然不同:

潜在空间采样与输出生成
最后,我们可以利用我们的变分自编码器(VAE)生成一些新的手写数字。为此,我们只需要重新访问 VAE 的解码器部分(自然不包括损失层)。我们将使用它从潜在空间解码样本,并生成一些从未由任何人实际书写过的手写数字:

接下来,我们将显示一个 15 x 15 的数字网格,每个数字的大小为 28。为了实现这一点,我们初始化一个全为零的矩阵,矩阵的尺寸与要生成的整个输出相匹配。然后,我们使用 SciPy 的ppf函数,将一些线性排列的坐标转化为潜在变量(z)的网格值。之后,我们遍历这些网格以获取一个采样的(z)值。我们现在可以将这个样本输入到生成器网络中,生成器将解码潜在表示,随后将输出重塑为正确的格式,最终得到如下截图:

请注意,这个网格展示了如何从连续空间进行采样,从而使我们能够直观地呈现输入数据中潜在的变化因素。我们注意到,随着沿着x或y轴的移动,数字会变换成其他数字。例如,考虑从图像的中心开始移动。向右移动会将数字8变成9,而向左移动则会将其变为6。同样,沿右上对角线向上移动,数字8会先变为5,然后最终变为1。这些不同的轴可以被看作是代表给定数字上某些特征的存在。这些特征随着我们沿给定轴的方向进一步推进而变得更加突出,最终将数字塑造成特定数字类别的一个实例。
VAE 的总结性评论
正如我们在 MNIST 实验中所见,VAE 擅长学习一个结构良好的连续潜在空间,从中我们可以采样并解码输出。这些模型非常适合编辑图像,或者生成视觉效果类似于图像转化为其他图像的迷幻过渡效果。一些公司甚至开始尝试使用基于 VAE 的模型,让顾客通过手机摄像头完全虚拟地试戴珠宝、太阳镜或其他服饰!这是因为 VAE 在学习和编辑概念向量方面独具优势,正如我们之前讨论的那样。例如,如果你想生成一个介于 1 和 0 之间的新样本,我们只需计算它们在潜在空间中的均值向量差异,并将差异的一半加到原始样本上,然后再解码。这将生成一个 6,就像我们在之前的截图中看到的那样。同样的概念也适用于训练面部图像的 VAE(例如使用 CelebFaces 数据集),我们可以在两位不同名人的面部之间采样,从而创建他们的“合成兄弟”。类似地,如果我们想要生成特定的面部特征,比如胡子,只需要找到有胡子和没有胡子的面部样本。接着,我们可以使用编码函数获取它们各自的编码向量,并保存这两个向量之间的差异。现在,我们保存的胡子向量就可以应用到任何图像上,只需将它加到新图像的编码空间中,再进行解码即可。
其他有趣的 VAE 应用案例包括在实时视频中交换面孔,或者为了娱乐添加额外的元素。这些网络非常独特,因为它们能够逼真地修改图像并生成那些原本不存在的图像。自然地,这也让人想知道这些技术是否可以用于一些不那么有趣的目的;滥用这些模型来歪曲人们或情况的真实性,可能会导致严重后果。然而,既然我们可以训练神经网络来欺骗我们人类,我们也可以训练它们来帮助我们辨别这种伪造。这引出了本章的下一个主题:GANs。
探索 GANs
与其他类似模型相比,GANs 的思想更容易理解。从本质上讲,我们使用多个神经网络来进行一场相当复杂的博弈。就像电影《猫鼠游戏》中的情节一样。对于那些不熟悉该电影情节的人,我们提前为错过的暗示表示歉意。
我们可以将 GAN 看作是一个由两个参与者组成的系统。在一方,我们有一个类似 Di Caprio 的网络,试图重新创作一些莫奈和达利的作品,并将它们发送给毫无戒心的艺术经销商。另一方面,我们有一个警觉的、像汤姆·汉克斯风格的网络,拦截这些货物并识别其中的伪作。随着时间的推移,两者都变得越来越擅长自己的工作,导致骗子那一方能够创造出逼真的伪作,而警察那一方则具备了敏锐的眼光来识别它们。这种常用的类比变体,确实很好地介绍了这些架构背后的理念。
一个 GAN 本质上有两个部分:生成器和判别器。这两个部分可以看作是独立的神经网络,它们在模型训练时通过相互检查输出进行协同工作。生成器网络的任务是通过从潜在空间中抽取随机向量来生成虚假的数据点。然后,判别器接收这些生成的数据点,以及实际的数据点,并识别哪些数据点是真的,哪些是假的(因此得名判别器)。随着网络的训练,生成器和判别器分别在生成合成数据和识别合成数据的能力上不断提升:

GAN 的实用性和实际应用
该架构最早由 Goodfellow 等人于 2014 年提出,随后在多个领域的研究人员中得到了广泛应用。它们之所以迅速走红,是因为它们能够生成几乎无法与真实图像区分的合成图像。虽然我们已经讨论了由此方法衍生的一些较为有趣和日常的应用,但也有一些更复杂的应用。例如,虽然 GAN 主要用于计算机视觉任务,如纹理编辑和图像修改,但它们在多个学术领域中的应用也日益增加,出现在越来越多的研究方法中。如今,你可能会发现 GAN 被应用于医学图像合成,甚至在粒子物理学和天体物理学等领域中得到应用。生成合成数据的相同方法可以用来再生来自遥远星系的去噪图像,或模拟高能粒子碰撞产生的真实辐射模式。GAN 的真正实用性在于它们能够学习数据中潜在的统计分布,使其能够生成原始输入的合成实例。这种方法尤其对研究人员有用,因为收集真实数据可能既昂贵又物理上不可能实现。此外,GAN 的应用不仅限于计算机视觉领域。其他应用包括使用这些网络的变种从自然语言数据生成精细的图像,比如描述某种风景的句子:

arxiv.org/pdf/1612.03242v1.pdf
这些应用案例展示了 GAN 如何使我们能够处理新任务,既有创造性的也有实用性的影响。然而,这些架构并非都是轻松愉快的。它们以训练困难著称,深入研究这些领域的人们形容它们更像是一门艺术,而非科学。
关于这个主题的更多信息,请参考以下内容:
- 
Goodfellow 等人的原创论文: papers.nips.cc/paper/5423-generative-adversarial-nets
- 
天体物理学中的 GAN: academic.oup.com/mnrasl/article/467/1/L110/2931732
- 
粒子物理学中的 GAN: link.springer.com/article/10.1007/s41781-017-0004-6
- 
细粒度文本到图像生成: openaccess.thecvf.com/content_cvpr_2018/html/Xu_AttnGAN_Fine-Grained_Text_CVPR_2018_paper.html
深入了解 GAN
那么,让我们尝试更好地理解 GAN 的不同部分如何协作生成合成数据。考虑一下带参数的函数(G)(你知道的,我们通常使用神经网络来近似的那种)。这个将是我们的生成器,它从某个潜在的概率分布中采样输入向量(z),并将它们转换为合成图像。我们的判别网络(D)随后将会接收到由生成器生成的一些合成图像,这些图像与真实图像混合,并尝试将真实图像与伪造图像区分开来。因此,我们的判别网络只是一个二分类器,配备了类似于 sigmoid 激活函数的东西。理想情况下,我们希望判别器在看到真实图像时输出较高的值,而在看到生成的伪造图像时输出较低的值。相反,我们希望我们的生成器网络通过使判别器对生成的伪造图像也输出较高的值来愚弄判别器。这些概念引出了训练 GAN 的数学公式,其本质上是两个神经网络(D和G)之间的对抗,每个网络都试图超越另一个:

在给定的公式中,第一个项实际上表示与来自真实分布的数据点(x)相关的熵,该数据点被呈现给判别器。判别器的目标是尽量将这个项最大化到 1,因为它希望能够正确识别真实图像。此外,公式中的第二个项表示与随机抽样的点相关的熵,这些点被生成器转换为合成图像 G(z),并呈现给判别器 D(G(z))。判别器不希望看到这个,因此它试图将数据点是假的对数概率(即第二项)最大化到 0。因此,我们可以说,判别器试图最大化整个 V 函数。另一方面,生成器的目标则是做相反的事情。生成器的目标是尽量最小化第一个项并最大化第二个项,以便判别器无法区分真实与虚假。这就开始了警察与小偷之间的漫长博弈。
优化 GAN 存在的问题
有趣的是,由于两个网络轮流优化自己的度量,GAN 具有动态的损失景观。这与我们在本书中看到的其他所有例子不同,在那些例子中,损失超平面保持不变,随着我们通过反向传播调整模型误差,逐步收敛到更理想的参数。而在这里,由于两个网络都在优化其参数,每一步沿超平面的下降都会略微改变景观,直到两种优化约束之间达到平衡。正如生活中的许多事情一样,这种平衡并不容易实现,它需要大量的关注和努力。在 GAN 的情况下,关注层权重初始化、使用 LeakyRelu 和 tanh 替代 修正线性单元 (ReLU) 和 sigmoid 激活函数、实现批量归一化和 dropout 层等方面,都是提高 GAN 达到平衡能力的众多考虑因素之一。然而,没有比通过实际编写代码并实现这些令人着迷的架构实例更好的方式来熟悉这些问题。
更多相关信息,请参考以下内容:
- 
改进的 GAN 训练技术: arxiv.org/pdf/1606.03498.pdf
- 
照片级真实感图像生成: openaccess.thecvf.com/content_cvpr_2017/html/Ledig_Photo-Realistic_Single_Image_CVPR_2017_paper.html
在 Keras 中设计 GAN
假设你是一个研究团队的一员,团队为一家大型汽车制造商工作。你的老板希望你想出一种生成汽车合成设计的方法,以系统地激发设计团队的灵感。你听说过很多关于 GAN 的宣传,决定研究它们是否能用于这个任务。为此,你首先想做一个概念验证,因此你迅速获取了一些低分辨率的汽车图片,并在 Keras 中设计了一个基础的 GAN,看看网络是否至少能够重建汽车的一般形态。一旦你能够确认这一点,你就可以说服经理为办公室投资几台Titan x GUPs,获取更高分辨率的数据,并开发更复杂的架构。那么,让我们首先获取一些汽车图片,通过实现这个概念验证来开始吧。对于这个演示用例,我们使用了经典的 CIFAR-10 数据集,并将自己限制在商用汽车类别。我们从导入一些库开始,如下所示:

准备数据
我们继续通过 Keras 加载数据,只选择汽车图像(索引=1)。然后,我们检查训练和测试数组的形状。我们看到有 5,000 张训练图像和 1,000 张测试图像:

可视化一些实例
现在我们将使用 Matplotlib 查看数据集中的真实图像。记住这些图像,因为稍后我们将生成一些假图像进行比较:
# Plot many
plt.figure(figsize=(5, 4))
for i in range(20):
    plt.subplot(4, 5, i+1)
    plt.imshow(x_train[i].reshape(32,32,3), cmap='gray')
    plt.xticks([])
    plt.yticks([])
plt.tight_layout()
plt.show()
以下是输出结果:

数据预处理
接下来,我们只是简单地对像素值进行归一化。然而,与之前的尝试不同,这次我们将像素值归一化到-1 到 1 之间(而不是 0 到 1 之间)。这是因为我们将为生成器网络使用tanh激活函数。这个特定的激活函数输出的值在-1 到 1 之间;因此,以类似的方式归一化数据会使学习过程更加平滑:

我们鼓励你尝试不同的归一化策略,探索它们如何影响网络训练中的学习过程。现在我们已经准备好所有组件,可以开始构建 GAN 架构了。
设计生成器模块
现在是最有趣的部分。我们将实现一个深度卷积生成对抗网络(DCGAN)。我们从 DCGAN 的第一部分开始:生成器网络。生成器网络本质上将通过从某个正态概率分布中采样来重建真实的汽车图像,代表一个潜在空间。
我们将再次使用功能 API 来定义我们的模型,将其嵌套在一个带有三个不同参数的函数中。第一个参数latent_dim指的是从正态分布中随机采样的输入数据的维度。leaky_alpha参数指的是提供给网络中使用的LeakyRelu激活函数的 alpha 参数。最后,init_stddev参数指的是初始化网络随机权重时使用的标准差,用于定义构建层时的kernel_initializer参数:
# Input Placeholder
def gen(latent_dim, leaky_alpha, init_stddev ):
    input_img = Input(shape=(latent_dim,))  # adapt this if using `channels_first` image data format
# Encoder part
x = Dense(32*32*3)(input_img)
x = Reshape((4, 4, 192))(x)
x = BatchNormalization(momentum=0.8)(x)
x = LeakyReLU(alpha=leaky_alpha)(x)
x = Conv2DTranspose(256, kernel_size=5, strides=2, padding='same',
                       kernel_initializer=RandomNormal(stddev=init_stddev))(x)
x = BatchNormalization(momentum=0.8)(x)
x = LeakyReLU(alpha=leaky_alpha)(x)
x = Conv2DTranspose(128, kernel_size=5, strides=2, padding='same',
kernel_initializer=RandomNormal(stddev=init_stddev))(x)
x = BatchNormalization(momentum=0.8)(x)
x = LeakyReLU(alpha=leaky_alpha)(x)
x = Conv2DTranspose(3, kernel_size=5, strides=2, padding='same',
kernel_initializer=RandomNormal(stddev=init_stddev), activation='tanh')(x)
generator = Model(input_img, x)
generator.summary()
return generator
注意在设计此模型时考虑了许多因素。例如,LeakyReLU激活函数在倒数第二层被选中,因为与 ReLU 相比,它能放宽输出的稀疏性约束。这是因为LeakyReLU允许一些小的负梯度值,而 ReLU 会将所有负值压缩为零。梯度稀疏性通常被认为是训练神经网络时的理想特性,但对于 GAN 来说并不适用。这也是为什么最大池化操作在 DCGAN 中不太流行的原因,因为这种下采样操作通常会产生稀疏的表示。相反,我们将使用带有 Conv2D 转置层的步幅卷积进行下采样需求。我们还实现了批量归一化层(其均值和方差的移动参数设置为 0.8),因为我们发现这对改善生成图像的质量有显著的作用。你还会注意到,卷积核的大小被设置为能被步幅整除,这有助于改善生成的图像,同时减少生成图像区域之间的差异,因为卷积核可以均匀地采样所有区域。最后,网络的最后一层配备了tanh激活函数,因为在 GAN 架构中,这一激活函数 consistently 显示出更好的效果。下一张截图展示了我们 GAN 的整个生成器模块,它将生成 32 x 32 x 3 的合成汽车图像,随后用于尝试欺骗判别器模块:

设计判别器模块
接下来,我们继续设计判别器模块,该模块负责区分由刚设计的生成器模块提供的真实图像和假图像。该架构的概念与生成器非常相似,但也有一些关键的不同之处。判别器网络接收尺寸为 32 x 32 x 3 的图像,然后将其转化为多种表示,随着信息通过更深层传播,直到达到带有一个神经元和 sigmoid 激活函数的密集分类层。它有一个神经元,因为我们处理的是区分假图像与真实图像的二分类任务。sigmoid函数确保输出一个介于 0 和 1 之间的概率值,表示网络认为给定图像可能有多假或多真。还需要注意的是,在密集分类器层之前引入了 dropout 层,这有助于增强模型的鲁棒性和泛化能力:
def disc(leaky_alpha, init_stddev):
disc_input = Input(shape=(32,32,3)) 
x = Conv2D(64, kernel_size=5, strides=2, padding='same', kernel_initializer=RandomNormal(stddev=init_stddev))(disc_input)
x = LeakyReLU(alpha=leaky_alpha)(x)
x = Conv2D(128, kernel_size=5, strides=2, padding='same',
kernel_initializer=RandomNormal(stddev=init_stddev))(x)
x = BatchNormalization(momentum=0.8)(x)
x = LeakyReLU(alpha=leaky_alpha)(x)
x = Conv2D(256,kernel_size=5, strides=2, padding='same',
kernel_initializer=RandomNormal(stddev=init_stddev))(x)
x = BatchNormalization(momentum=0.8)(x)
x = LeakyReLU(alpha=leaky_alpha)(x)
x = Flatten()(x)
x = Dropout(0.2)(x)
x = Dense(1, activation='sigmoid')(x)
discriminator = Model(disc_input, x)
discriminator.summary()
return discriminator
再次强调,我们鼓励你尽可能多地尝试不同的模型超参数,以便更好地理解如何通过调整这些超参数影响学习过程和我们的 GAN 模型生成的输出。
组装 GAN
接下来,我们使用此处显示的函数将两个模块组合在一起。作为参数,它接受生成器的潜在样本大小,生成器网络将通过该大小转换生成合成图像。它还接受生成器和判别器网络的学习率和衰减率。最后,最后两个参数表示用于LeakyReLU激活函数的 alpha 值,以及网络权重随机初始化的标准差值:
def make_DCGAN(sample_size, 
               g_learning_rate,
               g_beta_1,
               d_learning_rate,
               d_beta_1,
               leaky_alpha,
               init_std):
    # clear first
    K.clear_session()
    # generator
    generator = gen(sample_size, leaky_alpha, init_std)
    # discriminator
    discriminator = disc(leaky_alpha, init_std)
    discriminator_optimizer = Adam(lr=d_learning_rate, beta_1=d_beta_1) #keras.optimizers.RMSprop(lr=d_learning_rate, clipvalue=1.0, decay=1e-8) 
    discriminator.compile(optimizer=discriminator_optimizer, loss='binary_crossentropy')
    # GAN
    gan = Sequential([generator, discriminator])
    gan_optimizer = Adam(lr=g_learning_rate, beta_1=g_beta_1) #keras.optimizers.RMSprop(lr=g_learning_rate, clipvalue=1.0, decay=1e-8)
    gan.compile(optimizer=gan_optimizer, loss='binary_crossentropy')
    return generator, discriminator, gan
我们通过调用导入的后端对象K上的.clear_session()来确保没有之前的 Keras 会话在运行。然后,我们可以通过调用之前设计的生成器和判别器网络函数,并为它们提供适当的参数,来定义这两个网络。需要注意的是,判别器已被编译,而生成器没有被编译。
请注意,这些函数的设计方式鼓励通过使用参数来快速实验,调整不同的模型超参数。
最后,在使用二进制交叉熵损失函数编译判别器网络后,我们将这两个独立的网络合并。我们使用顺序 API 来实现这一点,顺序 API 使得将两个全连接的模型合并变得非常简单。然后,我们可以编译整个 GAN,再次使用相同的损失函数和优化器,但使用不同的学习率。在我们的实验中,我们选择了Adam优化器,GAN 的学习率为 0.0001,判别器网络的学习率为 0.001,这在当前任务中效果很好。
用于训练的辅助函数
接下来,我们将定义一些辅助函数,帮助我们在训练过程中进行操作。第一个函数只是从正态概率分布中生成潜在变量的样本。接下来,我们有make_trainable()函数,它帮助我们交替训练鉴别器和生成器网络。换句话说,它允许我们冻结一个模块(鉴别器或生成器)的层权重,同时训练另一个模块。此函数的 trainable 参数只是一个布尔变量(true 或 false)。最后,make_labels()函数只是返回用于训练鉴别器模块的标签。这些标签是二进制的,其中1代表真实,0代表伪造:
def make_latent_samples(n_samples, sample_size):
    #return np.random.uniform(-1, 1, size=(n_samples, sample_size))
    return np.random.normal(loc=0, scale=1, size=(n_samples, sample_size))
def make_trainable(model, trainable):
    for layer in model.layers:
        layer.trainable = trainable
def make_labels(size):
    return np.ones([size, 1]), np.zeros([size, 1])
显示输出的辅助函数
接下来的两个辅助函数使我们能够在训练结束时可视化损失值,并在每个周期结束时绘制出一张图像,从而直观评估网络的表现。由于损失值的变化是动态的,因此损失值的意义较小。就像在生成对抗网络中常见的情况一样,其输出的评估通常是由人类观察者通过视觉检查来完成的。因此,我们需要能够在训练过程中实时地检查模型的表现:
def show_results(losses):
    labels = ['Classifier', 'Discriminator', 'Generator']
    losses = np.array(losses)    
    fig, ax = plt.subplots()
    plt.plot(losses.T[0], label='Discriminator Net')
    plt.plot(losses.T[1], label='Generator Net')
    plt.title("Losses during training")
    plt.legend()
    plt.show()
def show_images(generated_images):     
n_images = len(generated_images)     
rows = 4     cols = n_images//rows          
plt.figure(figsize=(cols, rows))     
for i in range(n_images):         
img = deprocess(generated_images[i])         
plt.subplot(rows, cols, i+1)         
plt.imshow(img, cmap='gray')         
plt.xticks([])         
plt.yticks([])     
plt.tight_layout()     
plt.show()
第一个函数只是接受一个包含鉴别器和生成器网络在整个训练过程中损失值的列表,进行转置并按周期绘制。第二个函数让我们能够在每个周期结束时可视化生成的图像网格。
训练函数
接下来是训练函数。是的,它比较复杂。但正如你很快就会看到的,它是相当直观的,并基本上结合了我们到目前为止实现的所有内容:
def train(
    g_learning_rate,   # learning rate for the generator
    g_beta_1,          # the exponential decay rate for the 1st moment estimates in Adam optimizer
    d_learning_rate,   # learning rate for the discriminator
    d_beta_1,          # the exponential decay rate for the 1st moment estimates in Adam optimizer
    leaky_alpha,
    init_std,
    smooth=0.1,        # label smoothing
    sample_size=100,   # latent sample size (i.e. 100 random numbers)
    epochs=200,
    batch_size=128,    # train batch size
    eval_size=16):      # evaluate size
    # labels for the batch size and the test size
    y_train_real, y_train_fake = make_labels(batch_size)
    y_eval_real,  y_eval_fake  = make_labels(eval_size)
    # create a GAN, a generator and a discriminator
    generator, discriminator, gan = make_DCGAN(
        sample_size, 
        g_learning_rate, 
        g_beta_1,
        d_learning_rate,
        d_beta_1,
        leaky_alpha,
        init_std)
    losses = []
    for epoch_indx in range(epochs):
        for i in tqdm(range(len(X_train_real)//batch_size)):
            # real images
            X_batch_real = X_train_real[i*batch_size:(i+1)*batch_size]
            # latent samples and the generated images
            latent_samples = make_latent_samples(batch_size, sample_size)
            X_batch_fake = generator.predict_on_batch(latent_samples)
            # train the discriminator to detect real and fake images
            make_trainable(discriminator, True)
            discriminator.train_on_batch(X_batch_real, y_train_real * (1 - smooth))
            discriminator.train_on_batch(X_batch_fake, y_train_fake)
            # train the generator via GAN
            make_trainable(discriminator, False)
            gan.train_on_batch(latent_samples, y_train_real)
        # evaluate
        X_eval_real = X_test_real[np.random.choice(len(X_test_real), eval_size, replace=False)]
        latent_samples = make_latent_samples(eval_size, sample_size)
        X_eval_fake = generator.predict_on_batch(latent_samples)
        d_loss  = discriminator.test_on_batch(X_eval_real, y_eval_real)
        d_loss += discriminator.test_on_batch(X_eval_fake, y_eval_fake)
        g_loss  = gan.test_on_batch(latent_samples, y_eval_real) # we want the fake to be realistic!
        losses.append((d_loss, g_loss))
        print("At epoch:{:>3}/{},\nDiscriminator Loss:{:>7.4f} \nGenerator Loss:{:>7.4f}".format(
            epoch_indx+1, epochs, d_loss, g_loss))
        if (epoch_indx+1)%1==0:
            show_images(X_eval_fake)
    show_results(losses)
    return generator
训练函数中的参数
你已经熟悉了训练函数中的大多数参数。前四个参数仅仅是指分别用于生成器和鉴别器网络的学习率和衰减率。类似地,leaky_alpha参数是我们为LeakyReLU激活函数实现的负斜率系数,在两个网络中都使用了这个函数。接下来的 smooth 参数代表的是单边标签平滑的实现,如 Goodfellow 等人(2016)提出的那样。其背后的思想是将鉴别器模块中的真实(1)目标值替换为平滑的值,比如 0.9,因为这已被证明能够减少神经网络在对抗样本面前的失败风险:
def train(
    g_learning_rate,   # learning rate for the generator
    g_beta_1,          # the exponential decay rate for the 1st moment estimates in Adam optimizer
    d_learning_rate,   # learning rate for the discriminator
    d_beta_1,          # the exponential decay rate for the 1st moment estimates in Adam optimizer
    leaky_alpha,
    init_std,
    smooth=0.1,        # label smoothing
    sample_size=100,   # latent sample size (i.e. 100 random numbers)
    epochs=200,
    batch_size=128,    # train batch size
    eval_size=16):      # evaluate size
接下来,我们有四个简单易懂的参数。其中第一个是sample_size,指的是从潜在空间中提取的样本大小。接下来,我们有训练的周期数以及用于进行权重更新的batch_size。最后,我们有eval_size参数,它指的是在每个训练周期结束时用于评估的生成图像数量。
定义鉴别器标签
接下来,我们通过调用make_labels()函数,并使用合适的批次维度,来定义用于训练和评估图像的标签数组。这样会返回带有标签 1 和 0 的数组,用于每个训练和评估图像的实例:
# labels for the batch size and the test size
    y_train_real, y_train_fake = make_labels(batch_size)
    y_eval_real,  y_eval_fake  = make_labels(eval_size)
初始化 GAN
随后,我们通过调用之前定义的make_DCGAN()函数并传入适当的参数,初始化 GAN 网络:
# create a GAN, a generator and a discriminator
    generator, discriminator, gan = make_DCGAN(
        sample_size, 
        g_learning_rate, 
        g_beta_1,
        d_learning_rate,
        d_beta_1,
        leaky_alpha,
        init_std)
每批次训练判别器
之后,我们定义一个列表,用于在训练过程中收集每个网络的损失值。为了训练这个网络,我们实际上会使用.train_on_batch()方法,它允许我们有选择地操作训练过程,正如我们案例中所需要的那样。基本上,我们将实现一个双重for循环:
    losses = []
    for epoch_indx in range(epochs):
        for i in tqdm(range(len(X_train_real)//batch_size)):
            # real images
            X_batch_real = X_train_real[i*batch_size:(i+1)*batch_size]
            # latent samples and the generated images
            latent_samples = make_latent_samples(batch_size, sample_size)
            X_batch_fake = generator.predict_on_batch(latent_samples)
            # train the discriminator to detect real and fake images
            make_trainable(discriminator, True)
            discriminator.train_on_batch(X_batch_real, y_train_real * (1 - smooth))
            discriminator.train_on_batch(X_batch_fake, y_train_fake)
因此,在每个 epoch 中的每个批次里,我们将首先训练判别器,然后在给定的批次数据上训练生成器。我们首先使用第一批真实的训练图像,并从正态分布中采样一批潜在变量。然后,我们使用生成器模块对潜在样本进行预测,实质上生成一张汽车的合成图像。
随后,我们允许判别器在两个批次(即真实图像和生成图像)上进行训练,使用make_trainable()函数。这时,判别器有机会学习区分真实和虚假。
每批次训练生成器
接下来,我们冻结判别器的层,再次使用make_trainable()函数,这次只训练网络的其余部分。现在轮到生成器尝试击败判别器,通过生成一张真实的图像:
# train the generator via GAN
make_trainable(discriminator, False)
gan.train_on_batch(latent_samples, y_train_real)
每个 epoch 的评估结果
接下来,我们退出nested循环,在每个 epoch 的结束执行一些操作。我们随机采样一些真实图像以及潜在变量,然后生成一些假图像并进行绘制。请注意,我们使用了.test_on_batch()方法来获取判别器和 GAN 的损失值,并将其附加到损失列表中。在每个 epoch 的末尾,我们打印出判别器和生成器的损失,并绘制出 16 张样本的网格。现在,只剩下调用这个函数了:
# evaluate
        X_eval_real = X_test_real[np.random.choice(len(X_test_real), eval_size, replace=False)]
        latent_samples = make_latent_samples(eval_size, sample_size)
        X_eval_fake = generator.predict_on_batch(latent_samples)
        d_loss  = discriminator.test_on_batch(X_eval_real, y_eval_real)
        d_loss += discriminator.test_on_batch(X_eval_fake, y_eval_fake)
        g_loss  = gan.test_on_batch(latent_samples, y_eval_real) # we want the fake to be realistic!
        losses.append((d_loss, g_loss))
        print("At epoch:{:>3}/{},\nDiscriminator Loss:{:>7.4f} \nGenerator Loss:{:>7.4f}".format(
            epoch_indx+1, epochs, d_loss, g_loss))
        if (epoch_indx+1)%1==0:
            show_images(X_eval_fake)
    show_results(losses)
    return generator
更多信息,请参考以下内容:
- 改进的 GAN 训练技巧:arxiv.org/pdf/1606.03498.pdf
执行训练会话
我们最终使用相应的参数启动了训练会话。您会注意到,tqdm 模块显示一个百分比条,指示每个周期处理的批次数量。周期结束时,您将能够可视化一个 4 x 4 网格(如下所示),其中包含从 GAN 网络生成的样本。到此为止,您已经知道如何在 Keras 中实现 GAN。顺便提一下,如果您在具有 GPU 的本地机器上运行代码,设置tensorflow-gpu和 CUDA 会非常有益。我们运行了 200 个周期的代码,但如果有足够的资源和时间,运行几千个周期也并不罕见。理想情况下,两个网络对抗的时间越长,结果应该越好。然而,这并不总是如此,因此,这样的尝试可能也需要仔细监控损失值:

训练过程中测试损失的解释
正如您接下来看到的,测试集上的损失值变化非常不稳定。我们预期不同的优化器会呈现出更平滑或更剧烈的损失曲线,并且我们鼓励您使用不同的损失函数来测试这些假设(例如,RMSProp 是一个很好的起点)。虽然查看损失的曲线图不是特别直观,但跨越多个训练周期可视化生成的图像可以对这一过程进行有意义的评估:

跨周期可视化结果
接下来,我们展示了在训练过程中不同时间点生成的 16 x 16 网格样本的八个快照。尽管图像本身相当小,但它们无可否认地呈现出训练结束时接近汽车的形态:

就是这样。如您所见,GAN 在训练一段时间后变得非常擅长生成逼真的汽车图像,因为它在愚弄判别器方面越来越好。到最后几个周期时,人眼几乎无法分辨真假,至少在初看时是如此。此外,我们通过相对简单且直接的实现达到了这一点。考虑到生成器网络从未实际见过一张真实图像,这一成就显得更加令人惊讶。回想一下,它仅仅是从一个随机概率分布中进行采样,并仅通过判别器的反馈来改善自己的输出!正如我们所看到的,训练 DCGAN 的过程涉及了大量对细节的考虑,以及选择特定模型约束和超参数。对于感兴趣的读者,您可以在以下研究论文中找到更多关于如何优化和微调您的 GAN 的详细信息:
- 
关于 GAN 的原始论文: papers.nips.cc/paper/5423-generative-adversarial-nets
- 
使用 DCGAN 进行无监督表示学习: arxiv.org/abs/1511.06434
- 
照片级超分辨率 GAN: openaccess.thecvf.com/content_cvpr_2017/papers/Ledig_Photo-Realistic_Single_Image_CVPR_2017_paper.pdf
结论
在本章的这一部分,我们实现了一种特定类型的 GAN(即 DCGAN),用于特定的应用场景(图像生成)。然而,使用两个网络并行工作,相互制约的思路,可以应用于多种类型的网络,解决非常不同的用例。例如,如果你希望生成合成的时间序列数据,我们可以将我们在这里学到的相同概念应用于递归神经网络,设计一个生成对抗模型!在研究界,已经有几次尝试,并取得了相当成功的结果。例如,一组瑞典研究人员就使用递归神经网络,在生成对抗框架下生成古典音乐的合成片段!与 GAN 相关的其他重要思想包括使用注意力模型(遗憾的是本书未涉及该话题)来引导网络的感知,并将记忆访问引导至图像的更精细细节。例如,我们在本章中讨论的基础理论可以应用于许多不同的领域,使用不同类型的网络来解决越来越复杂的问题。核心思想保持不变:使用两个不同的函数近似器,每个都试图超越另一个。接下来,我们将展示一些链接,供感兴趣的读者进一步了解不同的基于 GAN 的架构及其各自的应用。我们还包括一个由 Google 和乔治亚理工大学开发的非常有趣的工具的链接,它可以让你可视化使用不同类型的数据分布和采样考虑来训练 GAN 的整个过程!
如需更多信息,请参阅以下内容:
- 
C-RNN_GAN 音乐生成: mogren.one/publications/2016/c-rnn-gan/mogren2016crnngan.pdf
- 
自注意力 GAN: arxiv.org/abs/1805.08318
- 
OpenAI 关于生成网络的博客: openai.com/blog/generative-models/
- 
GAN 实验室: poloclub.github.io/ganlab/?fbclid=IwAR0JrixZYr1Ah3c08YjC6q34X0e38J7_mPdHaSpUsrRSsi0v97Y1DNQR6eU
摘要
在本章中,我们学习了如何以系统化的方式通过随机性来增强神经网络,从而使它们输出我们人类认为是创造性的实例。通过变分自编码器(VAE),我们看到如何利用神经网络的参数化函数近似来学习一个连续潜在空间上的概率分布。接着,我们学习了如何从这样的分布中随机抽样,并生成原始数据的合成实例。在本章的第二部分,我们了解了如何以对抗的方式训练两个网络来完成类似的任务。
训练生成对抗网络(GAN)的方法论与变分自编码器(VAE)不同,是学习潜在空间的另一种策略。尽管 GAN 在生成合成图像的应用场景中有一些关键优势,但它们也有一些缺点。GAN 的训练 notoriously 难度较大,且通常生成来自无结构且不连续潜在空间的图像,而 VAE 则相对更为结构化,因此 GAN 在挖掘概念向量时更为困难。在选择这些生成网络时,还需要考虑许多其他因素。生成建模领域在不断扩展,尽管我们能够涵盖其中一些基本的概念性内容,但新的想法和技术几乎每天都在涌现,这使得研究这类模型成为一个激动人心的时刻。
第四部分:前方的道路
本节使读者了解如何利用最新的研究成果,并将其视为一个平台,以科学的方式推测深度学习和人工智能的未来发展。
我们从程序员和科学家的视角出发,凭借直觉预测该主题未来可能的发展,指出潜在的新兴研究方向和商业发展路径。生成模型和自编码器的影响将被应用于生成定制的人工图像,诸如 vid2vid、深度伪造(deep fakes)和深度语音(deep voice)等话题也将被探讨。
本节只包含一个章节:
- 第十章,思考当前与未来发展
第十章:思考当前与未来的发展
在本书的过程中,我们有幸共同探讨了一个引人入胜的概念,这个概念充斥并目前主导着人工智能(AI)领域:人工神经网络(ANNs)。在我们的旅程中,我们有机会详细了解神经模型的运作,包括前馈神经网络、卷积神经网络和递归神经网络,从而深入理解长短期记忆(LSTM)。我们继续深入探索自监督学习方法,包括具有深度 Q 网络的强化学习(RL),以及自编码器。我们最终通过回顾生成模型的直觉,完成了这次探险。
在本章中,我们将涵盖以下主题:
- 
使用迁移学习共享表示 
- 
在 Keras 上进行迁移学习 
- 
总结我们的实验 
- 
学习表示 
- 
当前神经网络的局限 
- 
鼓励稀疏表示学习 
- 
调整超参数 
- 
自动优化和进化算法 
- 
多网络预测与集成模型 
- 
AI 和神经网络的未来 
- 
前方的道路 
- 
经典计算的难题 
- 
量子计算的到来 
- 
量子神经网络 
- 
技术与社会 
- 
思考未来 
使用迁移学习共享表示
我们尚未讨论的一个强大范式是迁移学习的概念。在我们的探索过程中,我们看到了各种方法和技术,使神经网络能够从它们所见的数据中推导出强大而准确的表示。
然而,如果我们想将这些学习到的表示迁移到其他网络上呢?如果我们面临的任务预先没有大量的训练数据,这种迁移将非常有用。基本上,迁移学习旨在利用不同学习任务之间的共性,这些任务可能具有相似的统计特征。考虑以下情况:你是一个放射科医生,想使用卷积神经网络(CNN)来分类不同的肺部疾病,使用的是胸部 X 光片的图像。唯一的问题是,你只有大约一百张标记过的胸部 X 光图像。由于你不能随便为任何不知情的病人订购 X 光片来扩充数据集,因此你需要发挥创造力。也许你有不同的图像,表示相同的现象(例如 MRI 和 CT 扫描),或者你有来自不同身体部位的大量 X 光图像。那么,为什么不利用这些呢?
既然我们知道 CNN 的前层学习的是相同的低级特征(如边缘、线段和曲率),那为什么不直接重用这些从不同任务中学到的特征,并将模型微调以适应我们的新学习任务呢?与从零开始训练网络相比,迁移学习在许多情况下能够节省大量时间,是你深度学习工具库中的一个非常有用的工具。秉持这种精神,让我们探索最后一个实践示例:在 Keras 上实现一个简单的迁移学习工作流程。
Keras 上的迁移学习
在这一部分,我们将探讨 Keras 中一种非常简化的迁移学习方法。其背后的理念很简单:为什么要浪费宝贵的计算资源去学习几乎所有图像中常见的低级特征?
我们将使用著名的CIFAR10数据集来说明我们的实现,任务是对数据集中任何一个图像类别进行分类。然而,我们将通过使用预训练网络的层并将其添加到我们自己的网络中,来增强我们的学习体验。为了做到这一点,我们将导入一个非常深的卷积神经网络(CNN),它已经在昂贵的图形处理单元(GPU)上训练了数百个小时,我们只需对其进行微调以适应我们的用例。我们将使用的模型正是我们在第四章中使用过的VGG 网络,卷积神经网络,用来可视化神经网络如何识别猎豹。
然而这一次,我们将“解剖”它,挑选出一些中间层,将它们拼接到我们自己的模型中,从而将其学到的知识转移到一个新的任务中。我们将首先进行一些导入:
 "import numpy as np\n",
 "import matplotlib.pyplot as plt\n",
 "% matplotlib inline\n",
 "\n",
 "\n",
 "from keras import applications\n",
 "from keras import optimizers\n",
 "from keras.models import Sequential, Model \n",
 "from keras.layers import Dropout, Flatten, Dense, GlobalAveragePooling2D, Input\n",
 "from keras import backend as k \n",
 "from keras.datasets import cifar10\n",
 "from keras import utils"
加载预训练模型
我们定义图像的维度,并加载已经在ImageNet分类任务(ILSVRC)上训练的 VGG16 模型,去掉它的输入层。这样做是因为我们将训练的图像与其原始训练图像在尺寸上有所不同。在下面的代码块中,我们可以直观地总结我们加载的模型对象:
img_width, img_height = 32, 32
model = applications.VGG19(weights = "imagenet", include_top=False, input_shape = (img_width, img_height, 3))
model.summary()
输出结果如下:

这是一个相当大的模型。事实上,它大约有 2000 万个可训练的参数!
从模型中获取中间层
得益于 Keras 像乐高积木一样的模块化接口,我们可以做一些非常酷的事情,比如拆分上述模型,并将其层重用到另一个网络中。这将是我们的下一步,而且可以通过功能性 API 很容易地实现:
model2= Model(inputs=model.input, outputs=   
              model.get_layer('block3_pool').output) 
model2.summary()
得到的结果将如下所示:

请注意,我们所做的只是使用功能性 API 初始化一个模型对象,并将 VGG 网络的前 12 层传递给它。这是通过在 VGG 模型对象上使用.get_layer()方法并传递层名称来实现的。请回忆,单个层的名称可以通过在给定模型对象上使用.summary()方法来验证。
向模型中添加层
现在我们已经从 VGG 网络中提取了预训练的中间层。接下来,我们可以将更多的顺序层连接到这些预训练层上。这样做的思路是利用预训练层学到的表示,并在其基础上进行构建,从而通过来自不同学习任务的知识来增强分类任务:
 #Adding custom Layers
 num_classes = 10
 x = model2.output
 x = Flatten()(x)
 x = Dense(1024, activation="relu")(x)
 x = Dropout(0.5)(x)
 x = Dense(1024, activation="relu")(x)
 predictions = Dense(num_classes, activation="softmax")(x)
为了向我们的模型中添加更多层,我们将再次使用功能性 API 语法创建一个简单的前馈网络,它将从选定的 VGG 网络层中获取输出值,并将其展平为二维数组,然后将这些数据传递到包含 1,024 个神经元的全连接层。这一层接着连接到一个大规模丢弃层,在训练过程中会忽略上一层一半的神经连接。
接下来,我们在到达最终输出层之前,再添加一个包含 1,024 个神经元的全连接层。输出层配备了 10 个神经元,代表我们训练数据中的类别数,并且具有一个 softmax 激活函数,它将为网络看到的每个观测生成一个 10 维的概率得分。
现在我们已经定义了希望添加到网络中的层,我们可以再次使用功能性 API 语法将这两个独立的模型合并在一起:
# creating the final model
model_final = Model(input = model2.input, output = predictions)
model_final.summary()
你将得到如下输出:

最重要的是,我们必须冻结 VGG 模型的层权重,以便利用它在之前的训练过程中(在那些昂贵的 GPU 上)编码的表示。
在这里,我们只选择冻结前四层,并决定让其余部分的架构在这个新的学习任务上重新训练:
# Freeze the layers that dont need to train
for layer in model2.layers[:4]:
    layer.trainable = False
其他方法可能会选择保持整个模型架构冻结,只重新初始化模型最后一层的权重。我们建议你尝试冻结不同数量的层,并通过可视化损失收敛等方式探索这如何改变网络的学习体验。
直观地说,不同的学习任务可能需要不同的方式。这自然取决于多种因素,例如任务之间的相似性、训练数据之间的相似性等等。
如果目标学习任务的数据非常少,通常的经验法则是仅重新初始化最后一层的权重。相反,如果目标任务的数据很多,甚至可以在训练期间重新初始化整个网络的权重。在这种情况下,您只需使用预训练模型,并为不同的用例重新实施它。与深度学习一样,答案在于实验。
加载和预处理数据
接下来,我们预处理 CIFAR10 图像并向量化标签,就像我们在本书的整个过程中一直在做的那样。这里没有什么特别需要注意的地方:
(x_train, y_train),(x_test, y_test)=cifar10.load_data() 
x_train = x_train.astype('float32')
x_test = x_test.astype('float32')
x_train /= 255
x_test /= 255
y_train = utils.to_categorical(y_train, num_classes)
y_test = utils.to_categorical(y_test, num_classes)
我们首先在第一个代码块中加载图像。在第二个块中,我们将像素值归一化为介于0和1之间的浮点值。最后,在最后一个块中,我们对标签进行独热编码。现在,我们的网络已准备好进行编译和训练。
训练网络
接下来,我们编译我们的模型,使用分类交叉熵loss函数和Adam优化器。然后,我们可以启动训练会话,如下所示:
# compile the model
model_final.compile(loss = "categorical_crossentropy", optimizer =  
      optimizers.Adam(lr=0.0001), metrics=["accuracy"])
下面将获得以下输出:

该模型以 128 张图像的批次进行了 10 个 epoch 的训练。实现的验证准确率约为 85%,比从头开始训练的同一模型要好得多。您可以自己尝试这一点,在训练模型之前解冻我们冻结的层。现在您已在 Keras 中实现了迁移学习工作流程,并能够重用神经网络用于需要预训练或微调的用例。
练习
- 
通过从预训练的 VGG 网络中检索更多块来尝试不同的模型深度。使用更深的模型,准确率是否会显著提高?可以改变选择层的位置。 
- 
改变可训练层数量;这如何影响 loss的收敛性?
- 
尝试在 keras.applications中的 10 个预训练模型中选择一个不同的模型来构建一个分类器,使用迁移学习的概念。
- 
听安德鲁·吴(Andrew Ng)讲解迁移学习: www.youtube.com/watch?v=yofjFQddwHE。
结论我们的实验
这些讨论标志着我们对各种神经网络架构的探索与实验的结束。然而,仍然有许多内容需要讨论和发现。毕竟,虽然我们的共同旅程接近尾声,你的旅程才刚刚开始!还有无数更多的使用案例、架构变体和实现细节等着我们去探索,但这样做将偏离我们最初对这项工作的目标。我们的目标是深入理解神经网络到底做了什么、它们是如何运作的,以及在什么情况下它们可能被使用。此外,我们还希望培养对这些网络内部实际发生的事情的直觉,并理解这些架构为何如此有效。本章剩余部分将致力于巩固这一概念,使你能更好地理解表示学习的基本思想,并将这一概念应用于任何你未来可能希望使用神经网络解决的案例。最后,我们还将借此机会探讨人工神经网络领域的一些最新进展,以及各个商业和机构如何利用这一技术创造价值。最后,我们还将尝试展望未来,推测未来的发展如何影响科学、经济和社会格局,尤其是在大数据等现象的兴起以及量子计算等潜在技术突破的背景下。
学习表示
虽然我们在第一章《神经网络概述》中讨论了表示的话题以及这如何影响学习任务,但现在,在我们执行过的一些实践示例基础上,我们可以进一步加深讨论。
到目前为止,我们都清楚任何机器学习(ML)算法(包括神经网络等深度学习算法)的成功,直接依赖于我们选择如何表示它所展示的数据。这背后到底有什么问题?
为了展示表示的重要性及其对信息处理的影响,回想一下在本书早些时候我们看到的一个简洁的例子。我们使用罗马数字进行长除法等数学运算,揭示了使用不理想的表示方法进行此类任务的困难。事实上,我们选择如何表示信息直接影响我们处理信息的方式、我们能够进行的操作类型以及我们可能得到的理解。
DNA 与技术
再考虑一个例子:DNA 分子。脱氧核糖核酸(DNA)是一种由两条相互缠绕的链状结构组成的分子,称为双螺旋结构。该分子可以被分解为更简单的单体单位(或核苷酸),形成由四种以氮为基础的构建块组成的碱基对(分别是腺嘌呤(A)、鸟嘌呤(G)、胸腺嘧啶(T)和胞嘧啶(C))。
现在,很多人可能会想,“这和当前的主题有什么关系?”事实上,正如它所显示的,这种分子结构包含了地球上所有生命形式的蓝图。该分子决定了细胞如何分裂,如何变得更复杂,直到地球上的植物和动物的偏好和行为。
不用说,这种表示信息的四元制系统找到了编码和复制指令的方式,从而产生了我们周围所有看到的生命!到目前为止,人类所设计的任何表示格式都无法接近模拟我们所知的宏大生命领域。事实上,我们至今仍在努力模拟逼真的沉浸式游戏环境,以供娱乐之用。有趣的是,根据许多估计,DNA 分子本身可以用我们自己的二进制系统表示,约为 1.5GB 的数据。想一想,1.5GB 的数据,或者一张单独的蓝光光盘,足以存储生命本身的所有指令。但这就是我们目前能做的一切了。我们不能指示蓝光光盘不停地复制自己,进而形成我们每天看到的复杂性。撇开硬件问题不谈,我们不能以这种方式复制生命运作的一个根本原因是数据本身的表示方式!因此,我们如何表示数据,对我们可以执行的变换方式有着深远的影响,导致越来越复杂的信息处理系统。
当前神经网络的局限性
类似地,在机器学习(ML)中,有人假设数据的不同表示可以捕捉到其中不同的解释性变异因素。我们看到的神经网络在从输入值中诱导出高效表示并利用这些表示进行各种学习任务方面表现出色。然而,这些输入值本身必须经过大量的预处理考虑,将原始数据转化为更适合网络的格式。
当前,神经网络的不足之处在于它们对这样的预处理和特征工程的高度依赖,从给定数据中学习有用的表示。仅凭其自身,它们无法从原始输入值中提取和分类出具有区分性的元素。通常,在每一个神经网络背后,都有一个人类的身影。
我们仍然需要运用我们的创造力、领域知识和好奇心来克服这一缺陷。然而,最终,我们将努力设计出需要最少人工干预(例如特征工程)的系统,并真正理解世界上存在的原始数据。设计这样的系统是人工智能领域的首要目标之一,也是我们希望你能帮助推动的方向。
然而,暂时我们将介绍一些有用的概念,它们使我们能够从原始数据中设计出更好的表示,从而为我们的人工对手设计更好的学习体验。
为机器工程化表示
表示学习的话题正是解决这个问题的。直观地说,我们问自己:我们如何使机器更容易从数据中提取有用的信息? 这一概念与世界上存在某些通用假设密切相关,这些假设可以应用于更好地解读和综合可用的原始数据。这些通用假设与实验技术结合,使我们能够设计出好的表示并剔除不好的表示。它们作为实验原则,用于设计神经网络等学习算法的预处理工作流。
一个好的表示应该是什么样的?
直观来说,一个好的表示应该能够解开引起事件发生的主要变异因素。因此,一种方法可能是通过增强分析工作流,使机器更容易识别这些变异因素。
多年来,研究人员积累了一套适用于深度学习领域的启发式假设,使我们能够做到这一点。接下来,我们将复述一部分这样的启发式规则或正则化策略,这些策略已知能增强深度神经网络的学习体验。
若要全面了解表示学习中涉及的所有考虑因素,请参阅这篇由深度学习领域的几位先驱所写的优秀论文——表示学习:综述与新视角(Y Bengio, A Courville, Pascal Vincent, 2016):arxiv.org/pdf/1206.5538.pdf。
预处理和数据处理
正如你已经非常清楚的,神经网络是非常挑剔的食客。也就是说,在将数据输入神经网络之前,需要执行两个基本操作:向量化和归一化。
向量化
回想一下,向量化的意思是,所有输入和目标变量的数据必须是张量格式,包含浮点数值(或在特定情况下,比如波士顿房价回归例子,使用整数)。我们之前通过使用索引值填充零矩阵(如情感分类示例)或通过独热编码变量来实现这一点。
归一化
除了向量化,我们还需要考虑输入数据的归一化。在大多数机器学习工作流中,这已经成为一种标准实践,包含了将输入变量转换为一个小的、同质的值范围。我们在图像处理等任务中通过将像素值归一化到 0 和 1 之间来实现这一点。如果我们的输入变量具有不同的尺度(例如波士顿案例),我们必须实施独立的特征归一化策略。不进行这些步骤可能导致梯度更新无法收敛到全局最小值,从而使网络学习变得更加困难。一般来说,经验法则是尝试独立特征归一化,确保每个特征的均值为 0,标准差为 1。
数据的平滑性
神经网络在进行预测时,如果它们看到的是一个局部平滑的数据分布,通常会表现得最好。这是什么意思呢?简单来说,如果一个输入 x 产生一个输出 y,那么接近这个输入的一个点会产生一个与 y 成比例接近的输出。这就是平滑性的特性,它大大增强了神经网络等学习架构的能力,使它们能够从这样的数据中捕捉到更好的表示。然而,不幸的是,数据分布中具备这种特性并不是神经网络学习良好表示的唯一标准;例如,维度灾难仍然是一个需要解决的问题,可以通过特征选择或降维来应对。
向数据中添加一些平滑因子,例如,可以在学习过程中带来很大益处,就像我们在使用 LSTM 预测股市价格时所做的那样。
鼓励稀疏表示学习
假设你正在训练一个网络来分类猫狗图片。在训练过程中,网络的中间层将学习来自输入值的不同表示或特征(如猫耳朵、狗眼睛等),并以概率的方式将它们结合起来,以检测输出类别的存在(即,图片是猫还是狗)。
然而,在对单张图片进行推理时,我们是否需要检测猫耳朵的特征来确定这张图片是狗的?几乎在所有情况下,答案是否定的。大多数时候,我们可以假设网络在训练过程中学到的大多数特征对于每个单独的预测并不相关。因此,我们希望网络为每个输入学习稀疏表示,生成的张量表示大多数条目为零(可能表示相应特征的存在或缺失)。
在深度学习中,稀疏性是学习表示中非常理想的特性。这不仅让我们在表示某一现象时能有更少的神经元激活(从而提高网络的效率),还帮助网络更好地解开数据本身中的主要方差因素。
直观地看,稀疏性让网络能够在数据中识别学习到的特征,而不会被输入中发生的小变化干扰。实现方面,稀疏性通过将大多数学习到的特征的值设为零来强制执行,当表示任何单一输入时。稀疏表示可以通过如 one-hot 编码、激活函数所施加的非线性变换,或通过其他方式惩罚中间层相对于输入值的导数来学习。
调整超参数
一般来说,假设更深的模型架构能提供更强的表示能力,使我们能够为预测任务层次化组织抽象表示。
然而,正如我们所知,深层架构容易发生过拟合,因此训练起来可能具有挑战性,需要特别关注正则化等方面(如在第三章中探讨的正则化策略,信号处理 - 使用神经网络进行数据分析)。我们如何评估应初始化多少层、每层适当的神经元数量以及使用哪些相关的正则化策略?考虑到设计正确架构的复杂性,实验不同的模型超参数以找到合适的网络配置来解决手头的任务可能非常耗时。
虽然我们已经讨论了如何设计更健壮的架构的总体直觉,使用诸如 dropout 和批归一化等技术,但我们不禁想知道是否有办法自动化整个繁琐的过程。甚至可以考虑将深度学习应用到这个过程本身,前提是它不是一个有离散约束的优化问题(与我们迄今为止解决的连续优化问题不同,后者使用的是梯度下降)。
自动优化和进化算法
幸运的是,已经有很多工具可以实现这种自动化的参数优化。Talos (github.com/autonomio/talos) 就是其中一个工具,构建于 Keras 库之上,并且在 GitHub 上作为开源软件提供。它允许你预定义一组超参数(如不同的层数、每层神经元数量和激活函数),然后该工具会自动训练并比较这些 Keras 模型,以评估哪个模型表现更好。
其他解决方案,如Hyperas (github.com/maxpumperla/hyperas) 或 auto_ML (auto-ml.readthedocs.io/en/latest/) 提供了类似的功能,并能大幅减少开发时间,帮助你发现哪些超参数最适合你的任务。实际上,你可以使用这些工具并创建自己的遗传算法,帮助你从超参数池中进行选择,训练并评估网络,然后选择最佳的网络架构,随机突变选定网络的某些超参数,重复训练和评估。最终,这样的算法可以产生越来越复杂的架构来解决特定问题,就像自然界中的进化一样。虽然这种方法的详细概述超出了本书的范围,但我们在这里提供了一个简单的实现链接,允许通过进化网络参数来找到理想的配置。
参考文献
- 
进化算法与神经网络: www.weiss-gerhard.info/publications/C22.pdf
- 
进化神经网络的实现: blog.coast.ai/lets-evolve-a-neural-network-with-a-genetic-algorithm-code-included-8809bece164
多网络预测与集成模型
获取神经网络最佳效果的另一种方法是使用集成模型。这个想法非常简单:为什么只使用一个网络,而不使用多个?换句话说,为什么不设计不同的神经网络,每个网络对输入数据的特定表示敏感?然后,我们可以对它们的预测进行平均,从而得到比单一网络更具普适性和简洁性的预测。
我们甚至可以根据每个网络在任务中取得的测试准确率为其分配权重。然后,我们可以根据每个网络的预测(按相对准确率加权)计算加权平均,从而得出更全面的预测结果。
直观地说,我们只是用不同的视角来看待数据;每个网络由于其设计的不同,可能会关注不同的方差因素,而这些因素可能被其他网络忽略。这个方法相对直接且简单实现,只需要设计不同的网络,并且对每个网络能够捕捉到的表示方式有很好的直觉。之后,只需为每个网络的预测加上适当的权重,并对结果进行平均。
AI 与神经网络的未来
在本书的过程中,我们深入探讨了人工智能(AI)中的一个特定领域,这个领域嵌套在机器学习(ML)中,我们称之为深度学习。这种机器智能的警示性方法采用了联结主义方法,结合了分布式表示的预测能力,而这些表示是通过深度神经网络学习得到的。
尽管深度学习神经网络因 GPU 的出现、加速计算和大数据的普及而迅速崛起,许多关于这些架构的直觉和实现方法的改进也随之而来,自约十年前它们重新崭露头角(Hinton 等,2008 年)。然而,深度学习仍然无法充分应对许多复杂的任务。
全局向量方法
有时候,对给定输入值进行的数学变换序列不足以学习到一个有效的函数,将其映射到某些输出值。实际上,已经存在许多这样的例子,特别是在自然语言处理(NLP)领域。尽管我们将 NLP 的使用案例限制为简单的单词向量化,但这种方法对于一些需要理解人类语言中复杂依赖关系的用例来说可能存在局限性。
相反,一种流行的方法是将属性象征性地归属于单词,并将值归属给这些属性,从而使得可以与其他单词进行比较。这是全局向量(GloVe)技术背后的基本直觉,它是一种在数据送入神经网络之前,用作文本预处理的向量化技术。这样的做法或许暗示了未来深度学习使用的演变。这一特定的工作流展示了如何结合分布式和符号表示的原则,以发现、理解并解决复杂问题,比如机器问答中涉及的逻辑推理。
分布式表示
在未来,我们很可能会开始结合来自不同人工智能学科的原理,以及深度学习所带来的分布式表示能力,设计出真正和普遍智能的系统。这些系统能够以自主的方式学习任务,并具备解决复杂问题的增强能力。例如,它们可能会采用科学方法进行研究,从而实现人类知识发现的自动化。简而言之,深度学习将长期存在,并可能与其他人工智能的子领域相互补充,开发出非常强大的计算系统。
硬件难题
然而,在我们达到那个阶段的人工智能之前,肯定还有其他改进的空间。回想一下,深度学习之所以流行,不仅是因为我们学会了在更高层次上表示和处理数据的技术,还因为我们的硬件得到了巨大的提升。现在,我们可以以几千美元的价格获得过去需要几百万美元才能实现的计算能力。类似地,人类在设计出真正直观且逻辑上更优越的系统之前,可能还需要克服另一个硬件难关,这样的系统能够解决人类的重大问题。
许多人推测,这一巨大的飞跃将以量子计算的形式实现。虽然对这一主题的深入讨论超出了本书的范围(也超出了作者的能力范围),但我们还是忍不住插入一个简短的插曲,来说明将神经网络引入一个新兴的计算范式中的好处和复杂性,这一范式具有很大的前景。
前进的道路
虽然前面的图示展示了处理能力的进展,这可能让我们怀念过去我们取得的成就,但一旦意识到我们还有多远的路要走,这份怀旧之情会迅速消失。
正如我们在前面的图示中看到的,我们迄今为止实现的系统的计算能力远远不及人类大脑。我们设计的神经网络(至少在本书中)包含的神经元数量从一百万(相当于你在蟑螂中找到的神经元数量)到大约一千万(接近成年斑马鱼常见的神经元数量)不等。
试图训练一个神经网络,使其在神经元数量上与人类大脑相当,至少在目前的工程技术水平上,超出了人类的能力范畴,正如本书出版时的情况一样。它根本超出了我们当前的计算能力。此外,值得注意的是,这种比较自然忽略了每个学习系统(人工与生物)的神经元在形式和功能上存在的差异。
生物神经元的运作方式与它们的人工对应物大不相同,且受分子化学等量子系统的影响。现代神经科学仍未完全理解生物神经元中信息处理和存储的确切方式。那么,我们如何模拟我们尚未完全理解的东西呢?这个难题的一个答案可能是设计更强大的计算机,能够以更适合该领域的方式表示和转化信息。这就引出了量子计算现象。
经典计算的问题
简单来说,量子力学是一个研究非常小、孤立和寒冷的事物的领域。虽然这开始时可能并不吸引人,但请考虑我们当前面临的问题。根据摩尔定律,芯片上晶体管数量的指数增长似乎已经放缓。
这为什么重要?这些晶体管实际上就是我们能够进行计算的基础!从简单的数据存储到神经网络本身的复杂数学运算,所有经典计算机中的数据表示都依赖于这些半导体器件。我们利用它们来放大和切换电信号,从而创建逻辑门,能够追踪带电电子的存在(1)或其不存在(0)。这些开关可以被操控来生成表示信息单位的二进制数字,或称为比特。归根结底,这种二进制系统构成了所有数字编码的基础,利用晶体管的物理特性来存储和处理信息。它是机器的语言,能够表示和处理信息。
从最早的全数字化和可编程计算机(Z3,1938 年)到最新的超级计算机(IBM Summit,2018 年),这种基本的表示语言并未发生变化。实际上,机器的通用语言已经基于二进制系统约有一个世纪之久。
然而,正如我们之前讨论的,不同的表示方式让我们能够执行不同的操作。因此,也许是时候重新审视我们表示数据的基本方式了。鉴于晶体管的尺寸已经有了极限,我们正慢慢但稳步地接近经典计算的极限。因此,寻找解决方案的最佳地方或许就是那微小且奇异的量子力学世界。
量子计算的到来
在经典计算机使用二进制表示法将信息编码为比特的同时,其量子计算机的对应物则利用物理定律将信息编码为Q-Bits。有许多设计此类系统的方法。例如,你可以利用微波脉冲改变电子的自旋动量,以表示和存储信息。
量子叠加
事实证明,这可能使我们能够利用有趣的量子现象来表示那些没有已知经典对应物的操作。例如量子叠加,其中两个不同的量子状态可以叠加在一起,形成一个有效的第三种状态。因此,与经典对应物不同,Q-Bit 可以拥有三种状态:(0)、(1)和(1/0),其中第三种状态是通过量子叠加特性才能实现的状态。
自然地,这使我们能够表示更多的信息,为我们解决更高复杂度类别的问题打开了大门(例如模拟智能)。
区分 Q-Bits 与经典计算机的对应物
量子比特与经典比特之间还存在其他量子特性。例如,两个量子比特可以进入纠缠状态,在这种状态下,每个量子比特的电子自旋会持续指向相反的方向。
为什么这很重要?嗯,这两个量子比特(Q-Bit)即使相距数十亿英里,似乎仍然能保持彼此之间的联系。根据物理定律,我们知道,每个电子的自旋在被观察时,始终会指向相反的方向,无论这两个量子比特之间的距离有多远。
这个纠缠状态非常有趣,因为没有任何经典操作能够表示两个不同比特没有指定值,但始终保持彼此相反值的概念。这些概念有潜力彻底改变通信和密码学等领域,更不用说它们带来的指数级计算能力了。量子计算机能够利用的量子比特越多,它能够使用的非经典操作就越多,从而更有效地表示和处理数据。本质上,这些是量子计算背后的核心理念之一。
量子神经网络
你们很多人可能会想,虽然这些很有意思,但我们距离能够使用量子计算机,还差几十年,更不用说在上面设计神经网络了。虽然健康的怀疑态度总是不错的,但这并不能公正地评估当今研究人员、科学家和企业的努力,他们日以继夜地致力于将这样的系统付诸实践。例如,你可能会感到惊讶的是,事实上,今天全球任何有互联网连接的人,都可以免费访问量子计算机,只需要点击这个链接(由 IBM 提供):quantumexperience.ng.bluemix.net/qx/editor。
事实上,像弗朗切斯科·塔基诺(Francesco Tacchino)及其同事这样的研究人员,已经利用这项服务实现了量子神经网络的分类任务!他们成功实现了世界上第一个量子感知机,精神上类似于我们在第二章中看到的感知机,《深入神经网络》中介绍的感知机,但加入了量子力学的规律。他们使用了 IBM 的Q-5 特内里费超导量子处理器,该处理器可以操作多达五个量子比特,用于训练分类器以检测简单的模式,比如线段。
尽管这乍一看似乎微不足道,但他们工作的意义却相当重大。他们能够决定性地展示量子计算机如何允许它处理的维度数呈指数级增长。例如,虽然经典的感知机只能处理n维的输入值,但这些研究人员设计的量子感知机能够处理 2N 维的输入!这样的实现为未来的研究者实现更复杂的架构铺平了道路。
自然地,量子神经网络领域仍处于初期阶段,因为量子计算机本身还有许多改进空间。然而,目前的活跃研究集中在将神经网络引入量子世界的多个领域,涵盖从简单的连接层扩展到在导航丧失景观方面更有效的量子优化算法。
有人甚至猜测,量子现象如隧穿效应可能被用来,字面上地,通过隧穿丧失的景观,极其迅速地收敛到最佳网络权重!这真正代表了机器学习和人工智能新时代的曙光。一旦这些系统经过彻底的试验和验证,我们可能能够用全新的方式表示真正复杂的模式,其影响力超出了我们当前的想象。
进一步阅读
- 
量子神经网络的论文: arxiv.org/pdf/1811.02266.pdf
- 
谷歌的 QNN 论文: arxiv.org/pdf/1802.06002.pdf
- 
谷歌量子 AI 博客: ai.googleblog.com/2018/12/exploring-quantum-neural-networks.html
技术与社会
今天,我们站在一个非常有趣的时代交汇点上。这个时代被一些人称为将定义人类未来并改变我们感知和与世界互动方式的时代。自动化、认知技术、人工智能和量子计算仅仅是众多颠覆性技术中的一部分,正在不断迫使组织重新评估其价值链,并在影响世界的方式上不断自我完善。
或许人们将能够更高效地工作,更好地组织时间,将生活投入到那些能独特补充自己技能的活动中,从而为所参与的社会提供最优价值。或者,也许我们面前有一个更加反乌托邦的未来,科技被用来剥夺大众的权利,观察和控制人类行为,并限制我们的自由。虽然这些技术本身只是类比于人类以前发明的任何工具,但我们选择如何使用这些工具,将对所有相关方产生深远的影响。最终,选择权在我们手中。幸运的是,我们正处在这个新时代的黎明时刻,因此我们仍有机会以可持续和包容的方式引领进步的方向。
目前,全球各地的组织都在急于寻找利用这些技术的方式,希望在它们还没有来得及适应之前能够收获成果,这导致了从透明度到合法性和伦理等各方面的种种担忧。尽管我们仍处于人工智能的初期阶段,这些困境依然浮现出来。从本质上讲,我们在本书中探索的所有方法和技术都是狭义人工智能技术。它们是能够解决工作流程中某些具体部分的系统,无论是解决特定的计算机视觉任务,还是回答某些类型的自然语言问题。这与字面意义上的人工智能概念有很大不同:即一种自主的智能,能够以自给自足的方式进行学习,不依赖外界直接操控其内部学习算法。它是一种能够成长和进化的智能,类似于人类婴儿到成年的过程,尽管速度不同。
思考我们的未来
想象一个新生的人类婴儿。最初,它甚至无法呼吸,必须通过一些由主治医生施加的友好轻拍来激发呼吸。在最初的几个月里,这个生命似乎没有做任何显著的事情,无法独立移动,更不用说思考了。然而,渐渐地,这个婴儿开始发展起对周围世界的内在模型。它变得越来越擅长区分它所看到的光线和它听到的嘈杂声音。很快,它开始认识到诸如运动之类的事物,也许是一张友善的面孔,围绕着它,拿着美味的食物。稍后,它通过观察周围的世界,发展出一个初步的内在物理引擎。然后,它用这些表征来进行爬行、蹒跚学步,最终甚至学会走路,逐步更新其内部物理引擎,以表示更为复杂的世界模型。不久,它便能做翻跟头、创作精美的诗歌,甚至研究数学、历史、哲学,甚至是人工智能科学等课题。
请注意,没有人是在精确调整 CNN 来让婴儿看得更清楚,或者是增加 LSTM 架构的规模,以便让婴儿写出更好的诗歌。这个个体能够做到这些,而无需任何直接的外部干预,只是通过观察周围的事物、聆听他人、并通过实践来学习。尽管在婴儿成长为成人的过程中,内部有无数的复杂机制运作,这些大多数都超出了本书的范围,但这个例子展示了我们距离创造一个能够真正与我们自己的智慧相媲美的系统还有多远。
同样类型的婴儿最终能学会开车,稍微一点帮助就能解决诸如世界饥饿或星际旅行等复杂问题!这才是真正的智能生物。本书中我们探讨的人工智能的对应物,因其应用范围狭窄,还不足以与前者的智能形式相提并论。它们不过是拼图的一部分,一种处理信息的方式,通常是针对特定认知领域的。也许有一天,这些狭窄的技术将会在一个综合系统中统一,将多种技术拼接在一起,创造出比各个组件更强大的东西。事实上,这一过程目前正在发生,正如我们在本书中已经看到的那样。例如,我们看到了卷积架构如何与其他神经网络架构(如 LSTM)相结合,用于涉及时间维度的复杂视觉信息处理,就像在游戏中做出正确的决策。
但问题依然存在:这些架构真的能变得聪明吗?这也许是今天的哲学家的问题,但同样也是未来科学家的问题。随着这些系统的发展,并征服越来越多曾经只有人类才能完成的领域,我们最终将面临这些关于机器和我们自己的存在性问题。我们真的那么不同吗?我们是否只是非常复杂的计算机,通过生物学进行算术运算?还是说,智慧和意识远远不止于简单的计算?遗憾的是,我们并没有所有的答案,但这无疑为我们物种的未来旅程带来了激动人心的前景。
总结
在本章中,我们回顾了本书中学到的内容,并看到了如何改进现有的技术。接着,我们展望了深度学习的未来,并深入了解了量子计算。
我希望这段旅程对你有所启发。感谢阅读,祝一切顺利!

 
                    
                
 = sigmoid [ (Way x a3)* + by* ]
 = sigmoid [ (Way x a3)* + by* ] a(2),
a(2),  **3 ] + ba )
**3 ] + ba ) a(1),
a(1),  2 ] + ba )
2 ] + ba ) a(0),
a(0),  **1 ] + ba )
**1 ] + ba ) 
                
            
         浙公网安备 33010602011771号
浙公网安备 33010602011771号