原文:
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(开放式神经电子智能机器人操作系统)项目的研究工作的一部分开发的。它的名字来源于希腊语单词, https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/5cd7fa77-066f-42ed-aa01-4a3a6e07fdd5.png,字面意思是角。这个词暗指一段古希腊文学中的文字游戏,指的是阿马尔忒亚的角(也称为丰饶之角),这是丰盈和繁荣的永恒象征。
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,将预测的输出类别表示为 https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/f98b8c99-3018-46e8-8c39-03fdd04ca52b.png。输出类别只是指我们在数据中尝试预测的不同类别。具体来说,我们使用输入特征(x[n]),例如某一天的温度(x[1])和气压(x[2]),来预测那天是晴天还是雨天(https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/f98b8c99-3018-46e8-8c39-03fdd04ca52b.png)。然后我们可以将模型的预测与当天的实际输出类别进行比较,判断那天是否确实是雨天或晴天。我们可以将这种简单的比较表示为(https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/f98b8c99-3018-46e8-8c39-03fdd04ca52b.png - y),这样我们就能观察到感知器平均上偏差了多少。但稍后我们会更详细讨论这个问题。现在,我们可以以数学方式表示我们迄今为止学到的整个预测模型:
以下图显示了前述公式的一个例子:
如果我们将之前显示的预测线(https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/f98b8c99-3018-46e8-8c39-03fdd04ca52b.png)绘制出来,我们将能够可视化出决策边界,它将我们的整个特征空间分成两个子空间。实质上,绘制预测线仅仅是让我们了解模型学到了什么,或者模型如何选择将包含所有数据点的超平面划分为我们关心的不同输出类别。实际上,通过绘制这条线,我们能够可视化地看到模型的表现,只需将晴天和雨天的观察数据放置到这个特征空间中,然后检查我们的决策边界是否理想地将输出类别分开,具体如下:
通过错误学习
我们对输入数据所做的基本操作就是计算点积,添加偏置项,通过非线性方程进行处理,然后将预测与实际输出值进行比较,朝着实际输出的方向迈进一步。这就是人工神经元的基本结构。你很快就会看到,如何通过重复配置这种结构,产生一些更为复杂的神经网络。
我们通过一种被称为误差反向传播(简称反向传播)的方法,准确地调整参数值,使其收敛到理想的值。为了实现误差反向传播,我们需要一种度量标准来评估我们在达成目标方面的进展。我们将这种度量标准定义为损失,并通过损失函数来计算。该函数试图将模型所认为的输出和实际结果之间的残差差异纳入考虑。从数学角度来看,这表现为(y - https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/7a282e6b-02ad-450b-bfc8-37467f25c131.png)。在这里,理解损失值实际上可以作为我们模型参数的函数非常重要。因此,通过调整这些参数,我们可以减少损失并使预测值更接近实际输出值。我们将在回顾感知机的完整训练过程时,详细理解这一点。
均方误差损失函数
一个常用的损失函数是均方误差(MSE)函数,代数表示如下公式。正如你所注意到的,这个函数本质上只是将实际模型输出(y)与预测的模型输出(https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/1a12876d-d701-4c8b-9986-b147145150ab.png)进行比较。这个函数特别有助于我们评估预测能力,因为它以二次方式建模损失。也就是说,如果我们的模型表现不佳,且预测值与实际输出之间的差距越来越大,损失值将以平方的方式增加,从而更严厉地惩罚较大的误差。
我们将重新审视这一概念,以了解如何通过不同类型的损失函数减少模型预测与实际输出之间的差异。现在,知道我们模型的损失可以通过梯度下降过程最小化就足够了。正如我们将很快看到的那样,梯度下降本质上是基于微积分的,并通过基于反向传播的算法实现。通过调整网络的参数,数学上减少预测值与实际输出之间的差异,实际上就是使网络能够学习的过程。在训练模型的过程中,我们会通过向模型展示新的输入和相关输出的例子来进行这一调整。
训练感知机
到目前为止,我们已经清楚地掌握了数据是如何在感知机中传播的。我们还简要地看到了模型的误差如何向后传播。我们使用损失函数在每次训练迭代中计算损失值。这个损失值告诉我们,模型的预测与实际真实值之间的差距有多大。那么接下来呢?
损失量化
由于损失值能够反映我们预测输出与实际输出之间的差异,因此可以推测,如果损失值较高,那么我们的模型预测与实际输出之间的差异也很大。相反,较低的损失值意味着我们的模型正在缩小预测值与实际输出之间的差距。理想情况下,我们希望损失值收敛到零,这意味着模型预测与实际输出之间几乎没有差异。我们通过另一个数学技巧使损失收敛到零,这个技巧基于微积分。那么,怎么做到呢?
模型权重对损失的影响
好吧,记得我们说过可以将损失值看作是模型参数的函数吗?考虑这个问题。我们的损失值告诉我们模型与实际预测之间的距离。这同样的损失值也可以重新定义为模型权重(θ)的函数。回想一下,这些权重实际上是在每次训练迭代中导致模型预测的因素。从直观上讲,我们希望能够根据损失来调整模型权重,从而尽可能减少预测误差。
更数学化地说,我们希望最小化损失函数,从而迭代地更新模型的权重,理想情况下收敛到最佳的权重。这些权重是最优的,因为它们能够最好地表示那些能预测输出类别的特征。这个过程被称为损失优化,可以通过以下数学方式表示:
梯度下降
注意,我们将理想模型的权重(θ^()) 表示为在整个训练集上的损失函数的最小值。换句话说,对于我们向模型展示的所有特征输入和标签输出,我们希望它能在特征空间中找到一个地方,使得实际值 (y) 和预测值 (https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/0577a5df-72ab-422b-9fc6-41d0b83225a7.png) 之间的总体差异最小。我们所指的特征空间是模型可能初始化的所有不同权重组合。为了简化表示,我们将损失函数表示为 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])的点积,然后将结果传递到两个输出神经元,https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/25c7dba2-7da4-41b4-af22-9d3bdbd503a2.png 和 https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/1af86b7f-9ad1-45e2-8dbc-8ab4b4dcb6a7.png。请注意,每一层都有相应的权重矩阵,这些矩阵通过对损失函数相对于上一个训练迭代中的权重矩阵求导,进行迭代更新。因此,我们通过对损失函数相对于模型权重的梯度下降训练神经网络,最终收敛到全局最小值。
在 TensorFlow playground 中进行实验
让我们用一个虚拟的例子来看一下不同的神经元如何捕捉到我们数据中的不同模式。假设我们在数据中有两个输出类别,如下图所示。我们神经网络的任务是学习将这两个输出类别分开的决策边界。绘制这个二维数据集,我们得到一个类似以下图表的图像,在其中我们看到几个决策边界,将不同的可能输出分类:
我们将使用一个出色的开源工具来可视化我们模型的学习过程,这个工具叫做 TensorFlow Playground。这个工具简单地模拟了一个神经网络,并使用一些合成数据,让我们实际看到我们的神经元正在捕捉哪些模式。它允许你调整我们迄今为止概述的所有概念,包括不同类型和形式的输入特征、激活函数、学习率等。我们强烈建议你尝试不同的合成数据集,玩弄输入特征,并逐步添加神经元以及隐藏层,以观察这些变化如何影响学习。也可以尝试不同的激活函数,看看你的模型如何从数据中捕捉各种复杂的模式。的确,眼见为实!(或者更科学地说,nullius in verba)。如我们在下面的图表中所见,隐藏层中的两个神经元实际上在捕捉特征空间中的不同曲率,从而学习到数据中的特定模式。你可以通过观察连接各层的线条粗细来可视化我们模型的权重。你还可以通过观察每个神经元的输出(显示在神经元内部的阴影蓝白区域)来查看该神经元在数据中捕捉到的底层模式。正如你在实验 Playground 时看到的那样,这一表示方式是逐步更新并收敛到理想值的,具体取决于数据的形式和类型、使用的激活函数和学习率:
https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/5da2a3e3-fe20-4d99-9b70-366fc12aeb3c.png https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/6256fd9d-ef64-4cc7-9e5c-f83691beb5af.png
一个具有一个隐藏层、两个神经元和 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
loss
function:这只是用来衡量我们在训练数据上的表现,与真实输出标签进行比较。因此,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 时最少发生过拟合,之后训练和测试集的准确度开始出现分歧:
https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/b6d1ddb0-1ab8-46e3-936e-8690f653ea00.png https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/a153a0d8-4f37-47d0-bcfa-ef6e702f760d.png
为了通过增加网络的规模来复制这些结果,我们可以调整网络的宽度(每层的神经元数量)和深度(网络的层数)。在 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)) 控制输入向量 https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/ebdcf4e7-33d9-4f4e-b3f1-6e2a1b117f3d.png 在时间 t 时进入递归层的变换。这个权重矩阵是时间共享的,意味着我们在每个时间步使用相同的权重矩阵。接下来,我们看到术语 (Waa),它表示控制来自前一个时间步的激活值的时间共享权重矩阵。在第一个时间步,(Waa) 会随机初始化为非常小的值(或零),因为我们此时还没有激活权重可以计算。对于值 (a<0>) 也是如此,它被初始化为零向量。因此,在第一个时间步,我们的方程将看起来像这样:
a1 = tanH [ (W^(ax) x x1 ) + (Waa x a(0)) + ba ]
简化激活方程
我们可以通过将两个权重矩阵(Wax 和 Waa)水平堆叠成一个单一矩阵(W[a]),进一步简化这个方程,这个矩阵定义了递归层的所有权重(或状态)。我们还将代表前一个时间步激活(a(t-1)) 和当前时间步输入( https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/d3b3e016-18de-4d98-be08-c5d9a8826797.png t )的两个向量垂直堆叠,形成一个新矩阵,我们将其表示为 a(t-1), ![ t ] 。这让我们可以简化之前的激活表达式, 如下所示:
at = tanH (Whttps://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/b7d3a7c5-8985-4076-9db2-0ab3f5c850ca.png t ) + (Waa x a(t-1)) + ba ] 或 at = tanH (Whttps://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/5f597f64-f660-4896-be74-16289a777546.pnga(t-1), https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/71373feb-6c48-4dcb-9caa-3bd3750e0dd8.png t ] + ba )
从概念上讲,由于两个矩阵(Whttps://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/7ee6ef33-8386-4d3a-a8eb-31f499fa003f.png)的高度保持不变,我们能够像上面那样水平堆叠它们。同样,输入的长度(https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/22c27f03-9e5f-401d-80ba-870c948b2871.png t)和激活向量(a(t-1)) 也保持不变,因为数据在 RNN 中传播。现在,计算步骤可以表示为:权重矩阵(W[a])既与前一个时间步的激活相乘,也与当前时间步的输入相乘,然后加上偏置项,整个项通过非线性激活函数传递。我们可以通过新的权重矩阵,按时间展开此过程,如下图所示:
本质上,使用时间共享的权重参数(如 Wa 和 Wya)使我们能够利用序列前面的信息来为后续时间步的预测提供依据。现在,你已经知道了如何在每个时间步迭代计算激活值,数据在递归层中流动。
预测每个时间步的输出
接下来,我们将看到一个方程,它利用我们刚刚计算出的激活值来产生预测(在给定时间步(t)下)。这个过程可以表示如下:
https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/c2da3b60-fd3d-41a0-b36f-2deb2800f3f5.png = g [ (Way x at) + by ]
这告诉我们,层在某一时间步的预测是通过计算一个临时共享输出矩阵与我们刚刚计算出的激活输出(at)的点积来确定的。
由于共享权重参数,前一个时间步的信息得以保留,并通过递归层传递,用于当前的预测。例如,第三个时间步的预测利用了前一个时间步的信息,正如这里绿色箭头所示:
为了形式化这些计算,我们数学地展示了第三个时间步的预测输出与前几个时间步的激活之间的关系,如下所示:
- https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/c003db51-5aab-473c-9e57-5bea6611fa35.png = sigmoid [ (Way x a3)* + by* ]
其中*a(3)*的定义如下:
- *a3 = sigmoid (Whttps://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/49ef1f9f-c46d-4975-92fa-0cf0963c59d0.pnga(2), https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/da73e7b9-513b-4b84-a764-8c01ef19d543.png*3 ] + ba )
其中*a**(2)*的定义如下:
- a2 = sigmoid (W*https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/89282f8e-619a-418d-9988-d949c010a5aa.pnga(1), https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/01d42088-c76b-475a-83bd-c1d6d5c4cfcf.png*2 ] + ba )
其中*a(1)*的定义如下:
- *a1 = sigmoid (Whttps://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/3eca3c48-eacc-4485-8d76-da83a1a9fc8c.pnga(0), https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/23fc8ac4-b910-41d8-a11e-7dffeba43a8c.png*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 被称为通过时间进行反向传播。
通过时间反向传播
本质上,我们正在通过多个时间步反向传播我们的误差,反映了一个序列的长度。如我们所知,能够反向传播误差的第一步是必须有一个损失函数。我们可以使用交叉熵损失的任何变体,具体取决于我们是否在每个序列上执行二分类任务(即每个单词是实体还是非实体 à 二元交叉熵)或分类任务(即从我们的词汇表中选择下一个词 à 分类交叉熵)。这里的损失函数计算的是预测输出 https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/6ebd4a7c-8d96-4cd2-9c2d-2fb746fb420d.png 和实际值 (y) 在时间步 t 上的交叉熵损失:
*https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/46ec09e1-ff3f-49fc-a4e7-4d2cab07b46a.png( https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/aa137547-d065-4e5f-946c-fcb135fc277b.png https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/59cd5142-6f5b-4cb8-b930-f2cb8c7b63ae.png log https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/0f3b1dbe-0f85-4cfb-abdf-c011330710ed.png - (1-https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/eae7767c-e6d4-4719-94ee-5b37bb3d6eee.png
使用这种表示网络整体损失的方法,我们可以对每个时间步的层权重求导,以计算模型的误差。我们可以通过回顾递归层的示意图来可视化这个过程。箭头标出了通过时间的错误反向传播。
可视化时间反向传播
在这里,我们根据每个时间步的层权重反向传播模型中的误差,并在模型训练过程中调整权重矩阵Way和Wa。我们本质上仍然是在计算损失函数相对于网络所有参数的梯度,并在每个时间步中相应地反向调整两个权重矩阵。
现在,我们知道 RNN 是如何在一系列向量上进行操作,并利用时间相关的依赖来在每一步做出预测的。
梯度爆炸与梯度消失
然而,在深度神经网络中反向传播模型的错误也有其自身的复杂性。对于递归神经网络(RNN)来说,情况也不例外,它们面临着各自版本的梯度消失和梯度爆炸问题。正如我们之前讨论的,给定时间步的神经元激活依赖于以下公式:
at = tanH (Whttps://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/3175fbaa-c555-4dde-8aa9-6641b48527ea.png t ) + (Waa x a(t-1)) + ba ]
我们看到Wax和Waa是 RNN 层在时间上共享的两个独立的权重矩阵。这些矩阵分别与当前时间步的输入矩阵和前一个时间步的激活进行乘法计算。点积结果然后与偏置项加和,并通过 tanh 激活函数来计算当前时间步(t)的神经元激活。接着,我们使用这个激活矩阵来计算当前时间步的预测输出(https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/0d6d9ba5-a6ca-4868-b7fc-5db15a607871.png),然后将激活值传递到下一个时间步:
https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/fcfd4173-60dc-4147-ad63-3e8ba6eaf87c.png = 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))以及当前时间的输入(https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/005b1e37-203a-4ae1-b717-95d0ed56f958.png 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)) 和当前输入(https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/288f2225-c922-4231-9fb6-8a3474d237fa.png t)进行点积,然后通过 sigmoid 激活函数求和。计算当前激活 (c^t) 的方程保持不变,唯一的不同是其中的 (c ^(̴t)) 项,它现在在计算中引入了相关性门(Γr):
ct = ( Γu x c ̴t ) + [ ( 1- Γu ) x ct-1 ]
给定时间步的预测输出的计算方式与 SimpleRNN 层相同。唯一的区别是 (a^t) 被 (c^t) 替代,后者表示 GRU 层在时间步 (t) 的神经元激活:
https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/f0e96174-8b99-4ab8-8570-59e838271286.png = 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
正常顺序处理序列的模型以红色表示。同样,蓝色模型按相反顺序处理相同的序列。这两个模型在每个时间步共同协作,生成针对当前时间步的预测输出。我们可以看到这两个模型是如何接收输入值并一起工作,生成预测输出(https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/5a07aa4f-cdcc-4d84-96af-8024ce385aa7.png,这对应我们输入的两个时间步):
控制前向传播信息的方程可以稍作修改,以适应数据从正向和反向序列层进入 RNN 的情况,且每个时间步都会如此。误差的反向传播仍然以相同的方式进行,并针对每个 GRU 层的方向(红色和蓝色)进行处理。在以下的公式中,我们可以看到如何使用来自正向和反向序列层的激活值来计算给定时间步(t)的预测输出(https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/5c108a8b-2fe1-40e2-a65a-75cbbc5f1ba6.png):
这里的激活和权重矩阵仅由模型在双向层内嵌套定义。正如我们之前看到的,它们将在第一次时间步初始化,并通过时间反向传播误差进行更新。因此,这些是实现双向网络的过程,双向网络是一种无环网络,预测信息由前向和后向流动的信息共同提供,这与序列的顺序相对应。实现双向层的一个关键缺点是,我们的网络需要在能够进行预测之前看到整个数据序列。在语音识别等用例中,这会成为问题,因为我们必须确保目标在进行预测之前已经停止说话,以便将每个声音字节分类为一个单词。解决这个问题的一种方法是对输入序列进行迭代预测,并在新的信息流入时,迭代更新之前的预测。
实现递归丢弃
在前面的章节中,我们看到我们可以随机丢弃一些神经元的预测,以便更好地分布我们网络中的表示,避免过拟合问题。虽然我们当前的任务在过拟合方面没有太大的负面影响,但我们还是简要介绍了在 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 如何随着时间推移改进其语言模型。