深度学习基础知识-全-
深度学习基础知识(全)
原文:
annas-archive.org/md5/a53e57faaeec563f2d18a04cad100c9d译者:飞龙
前言
关于
本节简要介绍了作者和本书所涵盖的内容。
关于本书
深度学习正在迅速成为解决数据问题的首选方法。这部分得益于其丰富的数学算法种类以及它们能够发现我们通常无法察觉的模式。
从基础学深度学习 以 Python 深度学习的快速入门为开篇,介绍深度学习的定义、特征及应用。您将学习如何使用 Python 解释器执行脚本文件,还将学会如何在深度学习应用中利用 NumPy 和 Matplotlib。随着书中的学习进展,您将了解反向传播——一种高效计算权重参数梯度的方法,并学习多层感知机及其局限性,最终实现一个三层神经网络并计算多维数组。
本书结束时,您将具备应用深度学习相关技术的知识。
关于作者
斋藤幸樹,1984 年出生于日本长崎。他毕业于东京工业大学工程系,并完成了东京大学跨学科信息学研究生院的硕士课程。目前,他从事计算机视觉和机器学习的研究与开发工作。他是《从基础学深度学习系列(第 1-3 卷)》的作者,该系列由日本 O'Reilly 出版。
学习目标
-
使用最少的外部库和 Python 实现深度学习程序
-
学习各种深度学习和神经网络理论
-
学习如何设置权重的初始值
-
实现诸如批量归一化、Dropout 和 Adam 等技术
-
探索自动驾驶、图像生成和强化学习等应用
读者对象
从基础学深度学习 适合希望使用深度学习技术开发高效解决方案的数据科学家、数据分析师和开发人员。本书特别适合那些希望深入理解并全面了解这些技术的人。具备一定的 Python 基础知识是必须的,了解 NumPy 和 pandas 会更有帮助,但并非必需。
方法论
本书采用实践导向的深度学习方法。通过引导式练习,您将从程序员的角度编写代码并实现数学算法。
第一章:引言
随着科技的发展,曾经被称为科幻的东西如今开始看起来越来越切实际——有时甚至变得非常现实。人工智能已经战胜了将棋、国际象棋,甚至围棋冠军。智能手机能够理解人类语言,视频通话可以提供实时机器翻译。内置摄像头的“无人碰撞汽车”保护人类生命,自动驾驶汽车也越来越接近实际应用。当我们环顾四周时,会发现人工智能完美地完成了我们曾认为是人类专属的任务——有时甚至超越了我们。人工智能的发展正在改变我们的世界,使其焕然一新。
“深度学习”技术在这些显著的发展中发挥了重要作用。世界各地的研究人员称赞深度学习是一项创新技术,甚至将其称为十年一遇的突破。这个术语如今对大众来说,与研究人员和工程师一样熟悉。
本书聚焦于备受关注的深度学习,目的是让读者尽可能全面地理解深度学习技术。
通过实施深度学习程序的过程接近深度学习的本质,本书力图详细介绍所需技术以及功能性、实用性的示例,以便读者进行实验。
深度学习已经在全球范围内得到应用——在智能手机中、在自动驾驶汽车中、在支持 Web 服务的服务器中。深度学习今天正在许多人没有注意到的地方运行。未来,深度学习将更加清晰地走入公众视野。本书将帮助你理解并被其相关技术所吸引。
本书的概念
本书关于深度学习,涵盖了从基础开始逐步理解它所需的知识,包括它是什么、包含什么以及如何工作,以尽可能简单的方式让读者对相关技术有更深入的了解。
那么,我们应该做什么才能更好地理解深度学习呢?嗯,最好的方法之一就是通过做一些事情——例如,执行实际任务,从头开始创建一个程序,通过阅读源代码来促进批判性思维。这里的“从头开始”意味着尽可能少使用外部现成的东西(如库和工具)。这本书的目标是尽量少使用这些“黑箱”,这些内容是未知的,也就是说,你将从最基础的知识开始,然后进行构建、分析和实现,来理解并制作最先进的深度学习程序。如果将这本书与汽车手册相比,它不是一本教你如何开车的手册,而是一本专注于理解汽车原理的手册。它引导你打开汽车引擎盖,拆卸并检查每个零件的形状、功能和位置,然后重新组装并按其精确的尺寸和操作构建你的模型。这本书的目标是让你觉得自己能够制造一辆车,并通过制作过程熟悉周围的技术。
本书将使用一种名为 Python 的编程语言来进行深度学习。Python 是一种非常流行且易于初学者使用的编程语言,特别适合用来做原型开发。你可以立即尝试你的想法,并在检查结果的同时进行各种实验。本书在实现 Python 程序并进行实验的同时,也会描述深度学习的理论方面。
通过阅读和运行源代码,了解技术流向,通常可以理解那些仅通过数学表达式或理论描述无法理解的内容。这本书强调“工程”——通过编写代码来做一些事情,从而理解深度学习。你将看到许多数学表达式,也将从程序员的角度看到许多源代码。
这本书是为谁编写的?
本书提供了实施活动,帮助你更好地理解深度学习。以下是本课程的学习目标:
-
使用 Python 和最少的外部库从头开始实现深度学习程序。
-
讲解如何为 Python 初学者使用 Python。
-
提供可运行的 Python 源代码和一个可供实验的学习环境。
-
从简单的机器学习问题开始,实施一个能够高精度识别图像的系统。
-
清晰地解释深度学习和神经网络理论。
-
解释那些看起来复杂的技术,例如反向传播和卷积,使你能够从实现层面理解它们。
-
讲解深度学习中的有用和实用技术,例如如何确定学习系数和权重的初始值。
-
描述并实现如批量归一化(Batch Normalization)、Dropout 和 Adam 等趋势。
-
确定深度学习为何优秀,为什么更深的层次可以提高识别精度,为什么隐藏层很重要。
-
介绍深度学习的应用,例如自动驾驶、图像生成和强化学习。
本书不适合哪些人阅读?
说明这本书不适合哪些人阅读也是很重要的。以下列出了本书中我们不会做的事情。
本书…
-
不详细描述或介绍深度学习的最新研究。
-
不描述如何使用深度学习框架,如 Caffe、TensorFlow 和 PyTorch。
-
不提供关于深度学习,尤其是神经网络的详细理论描述。
-
不提供关于深度学习中提升识别精度的调优的详细描述。
-
不涉及使用 GPU 加速深度学习的实现。
-
专注于图像识别。它不涉及自然语言处理或语音识别。
因此,本书不涉及最新的研究或理论细节。然而,当你快读完本书时,你可以继续阅读有关神经网络的最新研究论文和理论书籍。
本书专注于图像识别。你可以主要学习使用深度学习进行图像识别所需的技术。它不涉及自然语言处理或语音识别。
如何阅读本书
当我们学习新知识时,通常仅仅通过听讲解我们往往无法理解或很快就会忘记。正如一位古代中国哲学家所说,“你忘记了你听到的,你学会了你看到的,你能理解你做过的。” 实践是学习新知识时最重要的。本书在讲解一个概念后,通常会提供实践例子(可以运行的源代码)。
本书提供了可以在你的计算机上运行的 Python 代码,帮助实践实施,强化和应用理论,同时通过反复试验进行实验。
本书将在“理论讲解”和“Python 实现”这对车轮的推动下前进。建议你拥有编程环境。你可以使用 Windows、Mac 或 Linux 计算机配合本书使用。第一章 Python 简介 介绍了如何安装和使用 Python。你可以从以下 GitHub 仓库下载本书中使用的程序。
github.com/koki0702/deep-learning-from-the-basics
这部分结束了介绍部分,应该给你一个关于本书内容的概览,并希望激发你继续阅读的兴趣。
在接下来的章节中,你将在过程中进行各种实验。有时我们会陷入困境并停下来思考为什么某些事情会发生。这些耗时的活动为我们深入理解技术提供了重要的知识。经过这么长时间获得的知识肯定也有助于使用现有库、阅读尖端论文和构建原创系统。最重要的是,创造一些东西是很有趣的。现在,我们已经准备好了。让我们开始深入学习的旅程吧!
致谢
首先,我要感谢那些进行深度学习技术研究的研究人员和工程师:机器学习和计算机科学领域的研究使我能够撰写本书。我还感谢那些在书籍和网络上发布有用信息的人。最重要的是,我从斯坦福大学开设的公开课 CS231n(用于视觉识别的卷积神经网络(cs231n.github.io/)中慷慨分享有用技术和信息的精神中学到了很多。
下列人员为我撰写本书作出了贡献:teamLab 公司的加藤哲郎、北信也、飞田友佳、中野耕太、中村昌辰、林明宏、以及山本亮,Top Studio Co.的武藤健志和増子萌,Flickfit 的野村健司,以及德州大学奥斯汀分校的 JSPS 海外研究员丹野英隆。这些人阅读了本书的手稿并提供了许多建议。在此向他们表示感谢。我明确声明,本书中的任何缺陷和错误由作者负责。
最后,我要感谢 O'Reilly 日本的宫川直樹,他在本书从构思到完成的一年半时间里给予了持续的支持。谢谢。
2016 年 9 月 1 日
齐藤幸
第二章:1. Python 简介
自 Python 编程语言发布以来,已经过去了 20 多年。在此期间,它不断发展并增加了用户基础。Python 目前是世界上最流行的编程语言。
在本书中,我们将使用这门强大的语言来实现一个深度学习系统。本章简要介绍 Python 并描述如何使用它。如果你已经熟悉 Python、NumPy 和 Matplotlib,可以跳过本章。
什么是 Python?
Python 是一种简单的编程语言,易于阅读和学习。它是开源软件,你可以免费使用它,随意编写具有类似英语语法的程序,而无需耗时的编译过程。这使得 Python 易于使用,因此成为初学者程序员的绝佳选择。事实上,许多大学和专业学校的计算机科学课程选择 Python 作为他们教授的第一门语言。
Python 使你能够编写既易于阅读又高效(快速)的代码。如果需要处理大量数据并且要求快速响应,Python 将满足你的需求。这也是为什么 Python 深受初学者和专业人士喜爱的原因。像 Google、Microsoft 和 Facebook 这样的前沿 IT 公司也经常使用 Python。
Python 常常用于科学领域,尤其是机器学习和数据科学。由于其高性能以及在数值计算和统计处理方面的优秀库(例如 NumPy 和 SciPy),Python 在数据科学领域占据了坚实的地位。它常被用作深度学习框架(如 Caffe、TensorFlow 和 PyTorch)的核心,这些框架提供 Python 接口。因此,学习 Python 对于希望使用深度学习框架的人来说也非常有用。
Python 是一种优化的编程语言,特别是在数据科学领域,因为它为初学者和专业人士提供了各种用户友好且高效的功能。正因为如此,它成为了实现本书目标的自然选择:从基础学习深度学习。
安装 Python
以下部分描述了在你的环境(PC)中安装 Python 时需要注意的一些事项。
Python 版本
Python 有两个主要版本:版本 2 和版本 3。目前,两个版本都在积极使用。因此,当你安装 Python 时,必须仔细选择要安装的版本。这两个版本并不完全兼容(准确来说,没有向后兼容性)。一些用 Python 3 编写的程序无法在 Python 2 中运行。本书使用的是 Python 3。如果你只安装了 Python 2,建议安装 Python 3。
我们使用的外部库
本书的目标是从基础实现 深度学习。因此,我们的方针是尽量少使用外部库,但我们会例外地使用以下两个库:NumPy 和 Matplotlib。我们将利用这两个库来高效地实现深度学习。
NumPy 是一个用于数值计算的库。它提供了许多便捷的方法来处理高级数学算法和数组(矩阵)。为了在本书中实现深度学习,我们将利用这些便捷的方法进行高效的实现。
Matplotlib 是一个用于绘制图形的库。你可以使用 Matplotlib 来可视化实验结果,并在执行深度学习时直观地检查数据。本书使用这些库来实现深度学习。
本书使用以下编程语言和库:
-
Python 3
-
NumPy
-
Matplotlib
现在,我们将介绍如何安装 Python,供需要安装的读者参考。如果你已经满足这些要求,可以跳过此部分。
Anaconda 发行版
尽管有多种安装 Python 的方法,本书推荐使用一个名为 Anaconda 的发行版。发行版包含所需的库,用户可以一次性安装这些库。Anaconda 发行版专注于数据分析,并包含了对数据分析有用的库,例如前面提到的 NumPy 和 Matplotlib。
正如我们之前提到的,本书使用的是 Python 3。因此,你需要安装适用于 Python 3 的 Anaconda 发行版。使用以下链接下载适合你操作系统的发行版并安装:
docs.anaconda.com/anaconda/install/
Python 解释器
安装 Python 后,首先检查版本。打开终端(Windows 系统为命令提示符),输入 python --version 命令。该命令会输出你安装的 Python 版本:
$ python --version
Python 3.4.1 :: Anaconda 2.1.0 (x86_64)
如果显示了 Python 3.4.1(具体数字会根据你安装的版本不同而有所变化),如前面的代码所示,说明 Python 3 已经成功安装。现在,输入 python 并启动 Python 解释器:
$ python
Python 3.4.1 |Anaconda 2.1.0 (x86_64)| (default, Sep 10 2014, 17:24:09) [GCC 4.2.1 (Apple Inc. build 5577)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>
Python 解释器也可以称作 3,当你问“1+2 等于多少?”时,输入以下内容:
>>> 1 + 2
3
因此,Python 解释器允许你进行交互式编程。在本书中,我们将使用交互式模式来处理 Python 编程的简单示例。
数学运算
你可以进行数学运算,例如加法和乘法,如下所示:
>>> 1 - 2
-1
>>> 4 * 5
20
>>> 7 / 5
1.4
>>> 3 ** 2
9
这里,* 表示乘法,/ 表示除法,** 表示指数运算。(3 ** 2 表示 3 的平方。)在 Python 2 中,当你将两个整数相除时,会返回一个整数。例如,7/5 的结果是 1。而在 Python 3 中,当你将两个整数相除时,会返回一个浮动小数。
数据类型
编程有数据类型。数据类型表示数据的特性,比如整数、浮点数或字符串。Python 提供了type()函数来检查数据的类型:
>>> type(10)
<class 'int'>
>>> type(2.718)
<class 'float'>
>>> type("hello")
<class 'str'>
前面的结果表明,10是int(整数类型),2.718是float(浮点类型),而hello是str(字符串类型)。type和class这两个词有时可以互换使用。输出结果<class 'int'>可以解释为10是int的class(类型)。
变量
你可以定义x和y。你还可以使用变量来计算或给变量赋另一个值:
>>> x = 10 # Initialize
>>> print(x)
10
>>> x = 100 # Assign
>>> print(x)
100
>>> y = 3.14
>>> x * y
314.0
>>> type(x * y)
<class 'float'>
Python 是一种动态类型的编程语言。x是int(整数)。Python 通过x被初始化为整数 10 来判断x的类型是int。前面的例子还表明,整数与小数相乘会返回小数(自动类型转换)。#符号用于注释后续字符,Python 会忽略它们。
列表
你可以使用列表(数组)将多个数字赋值给一个变量:
>>> a = [1, 2, 3, 4, 5] # Create a list
>>> print(a) # Print the content of the list
[1, 2, 3, 4, 5]
>>> len(a) # Get the length of the list
5
>>> a[0] # Access the first element
1
>>> a[4]
5
>>> a[4] = 99 # Assign a value
>>> print(a)
[1, 2, 3, 4, 99]
要访问一个元素,你可以写a[0],例如。[ ]中的数字称为索引,从 0 开始(索引 0 表示第一个元素)。Python 列表提供了一种便捷的表示法,称为切片。你可以使用切片来访问单个元素或列表的子列表:
>>> print(a)
[1, 2, 3, 4, 99]
>>> a[0:2] # Obtain from the zero index to the second index (the second one is not included!)
[1, 2]
>>> a[1:] # Obtain from the first index to the last
[2, 3, 4, 99]
>>> a[:3] # Obtain from the zero index to the third index (the third one is not included!)
[1, 2, 3]
>>> a[:-1] # Obtain from the first element to the second-last element
[1, 2, 3, 4]
>>> a[:-2] # Obtain from the first element to the third-last element
[1, 2, 3]
你可以通过写a[0:2]来切片一个列表。在这个例子中,a[0:2]获取从零索引到第二个索引前的元素。因此,在这种情况下,它只会显示零索引和一索引的元素。索引数字-1表示最后一个元素,而-2表示倒数第二个元素。
字典
在列表中,值是通过索引号(0,1,2,...)存储的,索引从 0 开始。字典以键/值对的形式存储数据。与它们含义相关的单词就像在语言词典中一样被存储在字典中:
>>> me = {'height':180} # Create a dictionary
>>> me['height'] # Access an element
180
>>> me['weight'] = 70 # Add a new element
>>> print(me)
{'height': 180, 'weight': 70}
布尔值
Python 有一个布尔类型。它的值是True或False。布尔类型的运算符有and、or和not(数据类型决定了可以使用的运算符,例如数字可以使用+、-、*、/等运算符):
>>> hungry = True # Hungry?
>>> sleepy = False # Sleepy?
>>> type(hungry)
<class 'bool'>
>>> not hungry
False
>>> hungry and sleepy
False
>>> hungry or sleepy
True
if语句
你可以使用if/else根据条件切换过程:
>>> hungry = True
>>> if hungry:
... print("I'm hungry")
...
I'm hungry
>>> hungry = False
>>> if hungry:
... print("I'm hungry") # Indent with spaces
... else:
... print("I'm not hungry")
... print("I'm sleepy")
...
I'm not hungry
I'm sleepy
在 Python 中,空格有着重要的意义。在这个if语句的例子中,if hungry后的下一行语句是以四个空格开头的。这是一个缩进,表示当条件(if hungry)满足时会执行的代码。虽然你可以使用制表符(Tab)来缩进,Python 推荐使用空格。
在 Python 中,使用空格表示缩进。通常每个缩进级别使用四个空格。
for语句
使用for语句进行循环:
>>> for i in [1, 2, 3]:
... print(i)
...
1
2
3
这个例子输出了一个列表的元素[1, 2, 3]。当你使用for … in …:语句时,你可以依次访问数据集中的每个元素,例如列表。
函数
你可以定义一组过程作为一个函数:
>>> def hello():
... print("Hello World!")
...
>>> hello()
Hello World!
一个函数可以接受一个参数:
>>> def hello(object):
... print("Hello " + object + "!")
...
>>> hello("cat")
Hello cat!
使用+来连接字符串。
要关闭 Python 解释器,对于 Linux 和 macOS X,输入Ctrl+D(按住Ctrl键的同时按下D键)。对于 Windows,输入Ctrl+Z并按Enter键。
Python 脚本文件
到目前为止展示的例子使用了一个 Python 解释器,提供了一种可以与 Python 交互的模式,适用于简单的实验。然而,如果你想进行大规模的处理,它就有点不方便,因为每次都得输入程序。在这种情况下,你可以将 Python 程序保存为一个文件并一次性执行。接下来的部分提供了 Python 脚本文件的例子。
保存到文件
打开你的文本编辑器并创建一个hungry.py文件。hungry.py文件只包含一行代码,如下所示:
print("I'm hungry!")
然后,打开终端(Windows 的命令提示符)并移动到创建hungry.py文件的位置。使用文件名hungry.py作为参数执行python命令。在这里,
假设hungry.py位于~/deep-learning-from-zero/ch01目录下(在本书提供的源代码中,hungry.py位于ch01目录下):
$ cd ~/deep-learning-from-zero/ch01 # Move to the directory
$ python hungry.py
I'm hungry!
因此,你可以使用python hungry.py命令来运行 Python 程序。
类
到目前为止,你已经学习了int和str等数据类型(你可以使用type()函数来检查对象的类型)。这些数据类型被称为内建数据类型,因为它们是 Python 自带的。在这里,你将定义一个新的类来创建你的数据类型。你还可以定义你自己的方法(类的函数)和属性。
在 Python 中,你可以使用class关键字来定义一个类。你必须使用以下格式:
class name:
def __init__ (self, argument, …): # Constructor
...
def method name 1 (self, argument, …): # Method 1
...
def method name 2 (self, argument, …): # Method 2
...
__init__方法是一个特殊的初始化方法。这个初始化方法也被显式地称为self,作为方法的第一个参数来表示你自己(你的实例)。(对那些熟悉其他语言的人来说,这种做法可能显得有些奇怪。)
创建一个简单的类,如下所示,并将以下程序保存为man.py:
class Man:
def __init__(self, name):
self.name = name
print("Initialized!")
def hello(self):
print("Hello " + self.name + "!")
def goodbye(self):
print("Good-bye " + self.name + "!")
m = Man("David")
m.hello()
m.goodbye()
从终端执行man.py:
$ python man.py
Initialized!
Hello David!
Good-bye David!
在这里,你定义了一个新的类Man。在前面的例子中,从Man类创建了一个实例(对象)m。
Man类的构造函数(初始化方法)接受name作为参数,并用它来初始化实例变量self.name。一个self。
NumPy
在实现深度学习时,数组和矩阵经常被计算。NumPy 的数组类(numpy.array)提供了许多方便的方法,这些方法在深度学习中非常常用。本节简要介绍了 NumPy,稍后我们会使用它。
导入 NumPy
NumPy 是一个外部库。外部这里的意思是,NumPy 没有包含在标准 Python 中。所以,你必须先加载(导入)NumPy 库:
>>> import numpy as np
在 Python 中,使用 import 语句导入库。这里,import numpy as np表示将numpy库加载为np。因此,你现在可以通过np来引用 NumPy 的方法。
创建 NumPy 数组
你可以使用np.array()方法创建一个 NumPy 数组。np.array()以 Python 列表作为参数来创建一个 NumPy 数组——也就是numpy.ndarray:
>>> x = np.array([1.0, 2.0, 3.0])
>>> print(x)
[ 1\. 2\. 3.]
>>> type(x)
<class 'numpy.ndarray'>
NumPy 中的数学运算
以下是一些涉及 NumPy 数组的数学运算示例:
>>> x = np.array([1.0, 2.0, 3.0])
>>> y = np.array([2.0, 4.0, 6.0])
>>> x + y # Add arrays
array([ 3., 6., 9.])
>>> x - y
array([ -1., -2., -3.])
>>> x * y # element-wise product
array([ 2., 8., 18.])
>>> x / y
array([ 0.5, 0.5, 0.5])
请注意,数组x和y的元素数量是相同的(它们都是一维数组,包含三个元素)。当x和y的元素数量相同,数学运算会对每个元素进行。如果元素数量不同,则会发生错误。因此,它们的元素数量必须相同。“对于每个元素”也称为逐元素,而“每个元素的乘积”称为逐元素乘积。
除了逐元素计算,NumPy 数组与单一数字(标量值)之间的数学运算也是可用的。在这种情况下,计算会在 NumPy 数组的每个元素与标量值之间进行。这一特性被称为广播(更多详细信息将在后续介绍):
>>> x = np.array([1.0, 2.0, 3.0])
>>> x / 2.0
array([ 0.5, 1\. , 1.5])
N 维 NumPy 数组
在 NumPy 中,你可以创建多维数组,也可以创建一维数组(线性数组)。例如,你可以按如下方式创建二维数组(矩阵):
>>> A = np.array([[1, 2], [3, 4]])
>>> print(A)
[[1 2]
[3 4]]
>>> A.shape
(2, 2)
>>> A.dtype
dtype('int64')
在这里,创建了一个 2x2 的矩阵A。你可以使用shape检查矩阵A的形状,使用dtype检查其元素的类型。以下是矩阵的数学运算:
>>> B = np.array([[3, 0],[0, 6]])
>>> A + B
array([[ 4, 2],
[ 3, 10]])
>>> A * B
array([[ 3, 0],
[ 0, 24]])
与数组一样,矩阵如果具有相同形状,则按元素逐一进行计算。矩阵与标量(单个数字)之间的数学运算也是可用的。这也是通过广播来实现的:
>>> print(A)
[[1 2]
[3 4]]
>>> A * 10
array([[ 10, 20],
[ 30, 40]])
一个 NumPy 数组(np.array)可以是 N 维数组。你可以创建任意维数的数组,例如一维、二维、三维等。在线性代数中,一维数组叫做向量,二维数组叫做矩阵。将向量和矩阵的概念推广就是张量。在本书中,我们将把二维数组称为矩阵,把三维或更多维的数组称为张量或多维数组。
广播
在 NumPy 中,你还可以对形状不同的数组进行数学运算。在前面的例子中,2x2 矩阵A被与标量值s相乘。图 1.1展示了这个操作的过程:标量值10被扩展为 2x2 元素进行运算。这个智能特性叫做广播:

图 1.1:广播示例——标量值 10 被视为一个 2x2 矩阵
这里是另一个广播示例的计算:
>>> A = np.array([[1, 2], [3, 4]])
>>> B = np.array([10, 20])
>>> A * B
array([[ 10, 40],
[ 30, 80]])
在这里(如图 1.2所示),一维数组B被转换为与二维数组A相同的形状,并且它们按元素进行逐一计算。
因此,NumPy 可以使用广播机制在形状不同的数组之间进行运算:

图 1.2:示例广播
访问元素
元素的索引从0开始(如常规)。你可以按如下方式访问每个元素:
>>> X = np.array([[51, 55], [14, 19], [0, 4]])
>>> print(X)
[[51 55]
[14 19]
[ 0 4]]
>>> X[0] # 0th row
array([51, 55])
>>> X[0][1] # Element at (0,1)
55
使用for语句来访问每个元素:
>>> for row in X:
... print(row)
...
[51 55]
[14 19]
[0 4]
除了前面描述的索引操作,NumPy 还可以使用数组来访问每个元素:
>>> X = X.flatten( ) # Convert X into a one-dimensional array
>>> print(X)
[51 55 14 19 0 4]
>>> X[np.array([0, 2, 4])] # Obtain the elements of the 0th, 2nd, and 4th indices
array([51, 14, 0])
使用这种符号可以获取仅满足特定条件的元素。例如,下面的语句从X中提取大于15的值:
>>> X > 15
array([ True, True, False, True, False, False], dtype=bool)
>>> X[X>15]
array([51, 55, 19])
使用 NumPy 数组时,使用不等号(例如X > 15,如前面的示例)会返回一个布尔数组。这里,布尔数组被用来提取数组中的每个元素,提取那些值为True的元素。
注意
有人说,像 Python 这样的动态语言在处理速度上比 C 和 C++ 这样的静态语言(编译语言)要慢。实际上,你应该在 C/C++ 中编写程序来处理繁重的计算工作。当 Python 需要性能时,某个过程的内容可以在 C/C++ 中实现。那时,Python 作为中介调用用 C/C++ 编写的程序。在 NumPy 中,主要的处理过程是由 C 和 C++ 实现的。因此,你可以在不降低性能的情况下使用便捷的 Python 语法。
Matplotlib
在深度学习实验中,绘制图形和可视化数据非常重要。使用 Matplotlib,你可以轻松地通过绘制图形和图表来进行可视化。本节将介绍如何绘制图形并显示图像。
绘制简单图形
你可以使用 Matplotlib 的pyplot模块来绘制图形。这里是绘制正弦函数的示例:
import numpy as np
import matplotlib.pyplot as plt
# Create data
x = np.arange(0, 6, 0.1) # Generate from 0 to 6 in increments of 0.1
y = np.sin(x)
# Draw a graph
plt.plot(x, y)
plt.show()
这里,NumPy 的arange方法用于生成数据[0, 0.1, 0.2, …, 5.8, 5.9]并命名为x。NumPy 的正弦函数np.sin()应用于x的每个元素,x和y的数据行提供给plt.plot方法绘制图形。最后,通过plt.show()显示图形。当执行上述代码时,显示的图像如图 1.3所示:

图 1.3:正弦函数图
pyplot 的特性
这里,我们将绘制余弦函数(cos),除了之前查看过的正弦函数(sin)。我们还将使用一些pyplot的其他特性来显示标题、x 轴的标签名称等:
import numpy as np
import matplotlib.pyplot as plt
# Create data
x = np.arange(0, 6, 0.1) # Generate from 0 to 6 in increments of 0.1
y1 = np.sin(x)
y2 = np.cos(x)
# Draw a graph
plt.plot(x, y1, label="sin")
plt.plot(x, y2, linestyle = "--", label="cos") # Draw with a dashed line
plt.xlabel("x") # Label of the x axis
plt.ylabel("y") # Label of the y axis
plt.title('sin & cos') # Title
plt.legend()
plt.show()
图 1.4显示了生成的图形。你可以看到图形的标题和坐标轴的标签名称:

图 1.4:正弦和余弦函数图
显示图像
imshow() 方法用于显示图像,它也包含在 pyplot 中。你可以使用 matplotlib.image 模块中的 imread() 加载图像,如下例所示:
import matplotlib.pyplot as plt
from matplotlib.image import imread
img = imread('lena.png') # Load an image (specify an appropriate path!)
plt.imshow(img)
plt.show()
当你执行这段代码时,显示的图像是图 1.5:

图 1.5:显示图像
在此,假设图像 lena.png 位于当前目录。根据你的环境,你需要更改文件的名称和路径。在本书提供的源代码中,lena.png 位于 dataset 目录下作为示例图像。例如,要从 ch01 目录中执行上述代码,需将图像路径从 lena.png 更改为 ../dataset/lena.png 以确保正常运行。
总结
本章介绍了实现深度学习和神经网络所需的一些 Python 编程基础。下一章,我们将进入深度学习的世界,看看一些实际的 Python 代码。
本章仅提供了 Python 的简要概述。如果你想了解更多,以下材料可能会有所帮助。对于 Python,推荐阅读 Bill Lubanovic: Introducing Python, Second Edition, O'Reilly Media, 2019。这是一本实用的入门书籍,详细讲解了从 Python 基础到应用的编程内容。对于 NumPy,Wes McKinney: Python for Data Analysis, O'Reilly Media, 2012 语言简单易懂,结构清晰。此外,Scipy Lecture Notes(scipy-lectures.org)网站深入描述了 NumPy 和 Matplotlib 在科学计算中的应用。如果你感兴趣,可以参考这些资料。
本章涵盖了以下内容:
-
Python 是一种简单易学的编程语言。
-
Python 是一个开源软件,你可以根据需要自由使用。
-
本书使用 Python 3 实现深度学习。
-
NumPy 和 Matplotlib 被用作外部库。
-
Python 提供了两种执行模式:解释器模式和脚本文件模式。
-
在 Python 中,你可以将函数和类实现并作为模块导入。
-
NumPy 提供了许多处理多维数组的便捷方法。
第三章:2. 感知机
本章描述了一种叫做感知机的算法。它由美国研究员弗兰克·罗森布拉特于 1957 年发明,正是从这种传统算法中衍生出了神经网络(即深度学习),因此它是学习这两个领域更高阶知识的必要第一步。本章将介绍感知机,并利用感知机解决简单问题。在此过程中,你将熟悉感知机的基本原理。
什么是感知机?
感知机接收多个信号作为输入,并输出一个信号。这里的“信号”像电流或河流一样“流动”。就像电流通过导体推动电子前进一样,感知机中的信号也会流动并传递信息。与电流不同的是,感知机中的信号是二进制的:“流动(1)或不流动(0)。”在本书中,0 表示“不传递信号”,1 表示“传递信号”。
(为了准确起见,注意本章描述的感知机更准确地应称为“人工神经元”或“简单感知机”。在这里,我们将其称为“感知机”,因为其基本过程通常是相同的。)
图 2.1 显示了一个接收两个信号作为输入的感知机示例:

图 2.1:带有两个输入的感知机
x1 和 x2 是输入信号,y 是输出信号,w1 和 w2 是权重(w 是“权重”一词的首字母)。前面的图中的圆圈叫做“神经元”或“节点”。当输入信号传递到神经元时,每个信号都与其对应的权重相乘(w1 x1 和 w2 x2)。神经元对接收到的信号进行求和,当和超过某一限制值时,它输出 1。这有时被称为“激活神经元”。这里,限制值称为阈值,并由θ符号表示。
这就是感知机的工作原理。公式(2.1)展示了我们在此描述的内容:
![]() |
(2.1) |
|---|
感知机为多个输入中的每一个设置了特定的权重,权重控制每个信号的重要性。权重越大,信号对该权重的影响就越大。
注意
权重相当于电气“电阻”。电阻是衡量电流通过难度的参数。电阻越小,电流越大。同时,当感知机的权重越大,流过的信号也越强。电阻和权重的作用方式相同,它们都控制信号通过的难易程度。
简单逻辑电路
与门
以下是一些使用感知机的简单问题。我们在这里讨论逻辑电路。让我们首先思考一个 AND 门。一个 AND 门由两个输入和一个输出组成。输入和输出信号的表格,如图 2.2所示,称为“真值表”。如图 2.2所示,当两个输入为 1 时,AND 门输出 1,其他情况输出 0:

图 2.2:AND 门的真值表
现在,我们将使用感知机来表示这个 AND 门。我们将确定 w1、w2 和 θ 的值,使它们满足图 2.2的真值表。我们可以设置什么值来创建一个符合图 2.2条件的感知机?
实际上,满足图 2.2的参数组合有无数种。例如,当(w1, w2, θ)=(0.5, 0.5, 0.7)时,感知机的工作方式如图 2.2所示。(0.5, 0.5, 0.8)和(1.0, 1.0, 1.0)也满足 AND 门的条件。如果设置这些参数,当 x1 和 x2 都为 1 时,带权信号的总和超过给定的阈值θ。
NAND 和 OR 门
现在,让我们来看一个 NAND 门。NAND 是 Not AND 的意思,NAND 门的输出是 AND 门的反面。如图 2.3提供的真值表所示,当 x1 和 x2 都为 1 时,它输出 0,其他情况输出 1。NAND 门有哪些参数组合呢?

图 2.3:NAND 门的真值表
一组组合(w1, w2, θ)=(-0.5, -0.5, -0.7)可以表示一个 NAND 门,实际上还有无数种其他组合。事实上,你可以通过反转构建 AND 门的参数值的所有符号来构建一个 NAND 门。
现在,让我们看看 OR 门,如图 2.4所示。这是一个逻辑电路,当至少一个输入信号为 1 时,它输出 1。你认为我们可以为 OR 门设置什么参数?

图 2.4:OR 门的真值表
注意
在这里,我们是决定感知机参数的人,而不是计算机。在查看“训练数据”时,也就是所谓的真值表,我们手动考虑(或找到)了参数值。在机器学习问题中,计算机会自动确定参数值。训练是决定合适参数的任务,我们考虑感知机的结构(模型),并将训练数据提供给计算机。
如前所述,我们可以使用感知器构建与门、非与门和或门逻辑电路。这里重要的是,感知器的结构对于与门、非与门和或门是相同的。三者之间的区别在于参数值(权重和阈值)。就像一个多才多艺的演员能够演绎各种角色一样,当适当调整参数值时,具有相同结构的感知器可以变成与门、非与门或或门。
实现感知器
简单实现
让我们使用 Python 实现前面的逻辑电路。在这里,我们将定义 AND 函数,接受x1和x2作为参数:
def AND(x1, x2):
w1, w2, theta = 0.5, 0.5, 0.7
tmp = x1*w1 + x2*w2
if tmp <= theta:
return 0
elif tmp > theta:
return 1
w1、w2 和 theta 参数在函数内部被初始化。当加权输入的总和超过阈值时,返回 1;否则,返回 0。让我们检查输出是否与图 2.2中所示的相同:
AND(0, 0) # 0 (output)
AND(1, 0) # 0 (output)
AND(0, 1) # 0 (output)
AND(1, 1) # 1 (output)
输出结果如我们预期的那样。这样,你就构建了一个与门。尽管你可以使用类似的过程构建非与门或或门,但我们将稍微改变实现方式。
引入权重和偏置
尽管前面的与门实现简单且易于理解,我们将在后续章节中将其改为不同的实现方式,将方程(2.1)中的θ替换为-b,并在方程(2.2)中表示感知器的行为:
![]() |
(2.2) |
|---|
尽管符号的表示法已经发生变化,方程(2.1)和(2.2)表示的完全相同。这里,b 被称为偏置,而w1 和 w2 被称为权重。如方程(2.2)所示,感知器将输入信号值与权重相乘后加和,再加上偏置。如果和超过 0,输出 1,否则输出 0。现在,让我们使用 NumPy 实现方程(2.2)。我们将使用 Python 解释器逐一检查结果:
>>> import numpy as np
>>> x = np.array([0, 1]) # Input
>>> w = np.array([0.5, 0.5]) # Weight
>>> b = -0.7 # Bias
>>> w*x
array([ 0\. , 0.5])
>>> np.sum(w*x)
0.5
>>> np.sum(w*x) + b
-0.19999999999999996 # About -0.2 (Operation error with floatingpoint numbers)
如此示例所示,当 NumPy 数组相乘时,如果两个数组的元素数量相同,它们的每个元素都会相乘。因此,在计算w*x时,每个元素都会相乘,([0, 1] * [0.5, 0.5] => [0, 0.5])。在np.sum(w*x)中,每个元素会被求和。当偏置被加到这个加权和时,方程(2.2)的计算就完成了。
使用权重和偏置的实现
你可以使用权重和偏置实现与门,如下所示:
def AND(x1, x2):
x = np.array([x1, x2])
w = np.array([0.5, 0.5])
b = -0.7
tmp = np.sum(w*x) + b
if tmp <= 0:
return 0
else:
return 1
这里,-θ称为偏置(bias),b。请注意,偏置与权重* w1 和 w2 的作用不同。具体来说,w1 和 w2 作为参数,控制输入信号的重要性,而偏置则作为调节神经元激活容易度的参数——即输出信号为 1 的可能性。例如,如果b是-0.1,当输入信号的加权和超过 0.1 时,神经元会触发。另一方面,如果b是-20.0,只有当输入信号的加权和超过 20.0 时,神经元才会触发。因此,偏置的值决定了神经元激活的难易程度。虽然w1 和 w2 被称为“权重”,而b* 被称为“偏置”,但在某些上下文中,所有这些参数(即b,w1 和 w2)有时统称为“权重”。
注:
“偏置”一词也有“填充”的意思。它表示如果没有输入(即输入为 0),则输出会增加。实际上,如果输入x1 和x2 均为 0,那么当计算公式(2.2)中的b + w1 x1 + w2 x2 时,输出的值就是偏置的值。
现在,让我们实现 NAND 和 OR 门:
def NAND(x1, x2):
x = np.array([x1, x2])
w = np.array([-0.5, -0.5]) # Only the weights and bias are different from AND!
b = 0.7
tmp = np.sum(w*x) + b
if tmp <= 0:
return 0
else:
return 1
def OR(x1, x2):
x = np.array([x1, x2])
w = np.array([0.5, 0.5]) # Only the weights and bias are different from AND!
b = -0.2
tmp = np.sum(w*x) + b
if tmp <= 0:
return 0
else:
return 1
如前一部分所述,AND、NAND 和 OR 门在感知器的结构上是相同的,区别仅在于权重参数的值。在实现 NAND 和 OR 门时,唯一区别就是权重和偏置的值与 AND 门不同。
感知器的局限性
如前所述,我们可以使用感知器来实现 AND、NAND 和 OR 逻辑门。在接下来的部分,你将考虑如何实现 XOR 门。
XOR 门
XOR 门是一种门电路,也叫做异或门。如图 2.5 所示,当x1 或x2 其中之一为 1 时,输出为 1(“exclusive”意味着“仅限于一个”)。为了利用感知器实现 XOR 门,权重应该取什么值呢?

图 2.5:XOR 门的真值表
事实上,利用我们目前所学的感知器,无法构建这个 XOR 门。那么,为什么我们可以构建 AND 门和 OR 门,却不能构建 XOR 门呢?
首先,让我们通过视觉方式检查 OR 门的行为。例如,当权重参数为(b, w1, w2) = (-0.5, 1.0, 1.0)时,OR 门符合图 2.5 中的真值表。在这种情况下,感知器由公式(2.3)表示:
![]() |
(2.3) |
|---|
由公式(2.3)表示的感知器生成了两个由直线-0.5 + x1 + x2 = 0 分隔的区域。直线分隔出的一个区域输出 1,另一个区域输出 0。图 2.6 直观地展示了这一点:

图 2.6:可视化感知器——感知器在灰色区域输出 0,这符合 OR 门的特性
OR 门在(x1, x2) = (0, 0)时输出 0,在(x1, x2) = (0, 1)、(1, 0)、(1, 1)时输出 1。这里,圆圈表示 0,三角形表示 1。要创建一个 OR 门,我们必须用一条直线将圆圈和三角形分开。这条直线实际上可以正确地分割四个点。
那么,XOR 门的情况如何呢?我们能像在 OR 门的情况下那样,用一条直线将圆圈和三角形分开吗?

图 2.7:圆圈和三角形表示 XOR 门的输出。
无论你多么努力地解决这个问题,你都无法用一条直线将圆圈和三角形分开。一条直线无法将它们分开。
线性与非线性
你不能用一条直线将圆圈和三角形分开。然而,如果你能够去掉“直线”这一限制,就能做到分开。例如,你可以创建将圆圈和三角形分开的区域,如图 2.8所示。
感知机的局限性在于它只能表示由直线分割的区域。它不能表示曲线,如图 2.8所示。在图 2.8中,由曲线分割的区域被称为非线性区域,而由直线分割的区域被称为线性区域。线性和非线性这两个词在机器学习中常常使用。你可以通过图 2.6和图 2.8来可视化它们:

图 2.8:曲线可以将圆圈和三角形分开
多层感知机
不幸的是,我们无法使用感知机表示 XOR 门。然而,这并不是什么坏消息。实际上,感知机的优点在于可以堆叠多个感知机层(本节的重点是多个层可以表示 XOR)。稍后我们会讨论堆叠层的问题。在这里,我们可以从另一个角度考虑 XOR 门的问题。
组合现有的门
我们可以采取一些方法来制作 XOR 门。其中一种方法是将我们迄今为止创建的 AND、NAND 和 OR 门组合起来并接线。在这里,AND、NAND 和 OR 门用图 2.9中的符号表示。图 2.9中 NAND 门顶端的圆圈表示输出已被反转。

图 2.9:AND、NAND 和 OR 门的符号
现在,让我们思考如何将 AND、NAND 和 OR 门接线以创建一个 XOR 门。请注意,你可以将 AND、NAND 或 OR 分别分配给图 2.10中的每个?符号,以完成 XOR 门:

图 2.10:将“?”符号替换为 AND、NAND 或 OR 门,完成 XOR 门!
更具体地说,上一节中描述的感知器的限制是单层感知器无法表示 XOR 门或划分非线性区域。在这里,我们将看到通过组合感知器(即堆叠层)可以构建 XOR 门。
图 2.11 中的接线可以构建一个 XOR 门。这里,x1 和 x2 表示输入信号,而 y 表示输出信号。x1 和 x2 是 NAND 和 OR 门的输入,而 NAND 和 OR 门的输出是 AND 门的输入:

图 2.11:AND、NAND 和 OR 门的组合构建了一个 XOR 门
让我们检查一下图 2.11中的接线是否真的能够形成一个 XOR 门。假设 NAND 的输出是 s1,OR 的输出是 s2,我们将完成真值表。图 2.12 显示了结果。当我们查看 x1、x2 和 y 时,可以看到它们代表了 XOR 的输出:

图 2.12:XOR 门的真值表
实现 XOR 门
现在,我们将使用 Python 实现 图 2.11 中接线表示的 XOR 门。通过使用我们之前定义的 AND、NAND 和 OR 函数,我们可以如下实现:
def XOR(x1, x2):
s1 = NAND(x1, x2)
s2 = OR(x1, x2)
y = AND(s1, s2)
return y
XOR 函数按预期输出结果:
XOR(0, 0) # 0 (output)
XOR(1, 0) # 1 (output)
XOR(0, 1) # 1 (output)
XOR(1, 1) # 0 (output)
现在,我们可以构建一个 XOR 门。完成后,我们将通过感知器(明确显示神经元)表示我们刚刚实现的 XOR。图 2.13 显示了这种表示。
XOR 是一个多层网络,如 图 2.13 所示。在这里,我们将称最左边一列为 层 0,接下来为 层 1,最右边为 层 2。
图 2.13 中的感知器形状与我们之前看到的 AND 和 OR 感知器有所不同(图 2.1)。AND 和 OR 感知器是单层的,而 XOR 感知器是两层的。具有多层的感知器有时被称为多层感知器:

图 2.13:感知器表示的 XOR
注释
尽管 图 2.13 中的感知器由三层组成,但我们将其称为“二层感知器”,因为只有两层(层 0 和层 1 之间、层 1 和层 2 之间)有权重。一些文献将 图 2.13 中的感知器称为“三层感知器”,因为它由三层组成。
如 图 2.13 所示,二层感知器在层 0 和层 1 之间以及层 1 和层 2 之间传递信号。以下将更详细地描述这种行为:
-
层 0 中的两个神经元接收输入信号并将信号传递给层 1 中的神经元。
-
层 1 中的神经元将信号传递给层 2 中的神经元,层 2 的神经元输出 y。
这个两层感知机的行为可以与通过管道的组装过程进行比较。第一层(或第一层的工作者)处理到达的“组件”,并在完成任务后将其传递给第二层(第二层的工作者)。第二层的工作者处理从第一层工作者接收到的“组件”,完成并输出(交付)它。
因此,XOR 门中的感知机会在工作者之间“传递组件”。这种两层结构使得感知机能够构建 XOR 门。这可以解释为“单层感知机无法实现的事情,可以通过添加一层来实现”。通过堆叠层(加深层数),感知机能够提供更灵活的表示。
从 NAND 到计算机
多层感知机可以创建比我们到目前为止所研究的电路更复杂的电路。例如,一个能够加法运算的加法器电路可以通过感知机来创建。一个将二进制数转换为十进制数的编码器,以及一个在满足特定条件时输出 1 的电路(奇偶校验电路)也可以通过感知机来表示。事实上,我们甚至可以用感知机来表示一个计算机。
计算机是一种处理信息的机器。当它接收到输入时,计算机会以某种方式处理这些输入并输出结果。以某种方式处理意味着计算机和感知机都有输入和输出,并根据固定的规则对其进行计算。
尽管看起来计算机内部执行的是非常复杂的过程,但实际上(令人惊讶的是),一组 NAND 门就可以复现计算机的功能。令人惊讶的事实是,NAND 门是构建计算机所需的全部元件,这意味着感知机也可以代表一个计算机,因为 NAND 门本身就可以用感知机来构造。简而言之,如果我们可以通过组合 NAND 门来创建一个计算机,那么我们也可以通过仅仅组合感知机来表示一个计算机(感知机的组合可以表示为一个多层感知机)。
注释
你可能会觉得很难相信一组 NAND 门可以创建一个计算机。如果你对这个话题感兴趣,推荐阅读《计算机系统的元素:从基本原理构建现代计算机》(MIT 出版社)。这本书旨在深入理解计算机。在“从 NAND 到俄罗斯方块”的口号下,它通过 NAND 门创建一个能够运行俄罗斯方块的计算机。如果你阅读这本书,你会意识到计算机是可以由简单的元素——也就是 NAND 门构建的。
因此,多层感知机能够实现像构建计算机一样复杂的表示。那么,哪种感知机结构可以表示一个计算机呢?构建一个计算机需要多少层呢?
答案是,理论上,计算机可以通过两层感知机来创建。已经证明,任何函数都可以通过两层感知机来表示(准确地说,当激活函数是非线性的 Sigmoid 函数时——具体细节见下一章)。然而,通过指定合适的权重来在两层感知机结构中创建计算机将是一项非常繁重的工作。实际上,从低级组件(如 NAND 门)开始创建计算机,逐步创建所需的组件(模块)是很自然的——从与门(AND)和或门(OR)开始,进而到半加器和全加器,算术逻辑单元(ALU),以及中央处理器(CPU)。因此,使用感知机表示计算机时,创建多层结构是很自然的方式。
虽然本书中我们不会创建计算机,但请记住,多层感知机能够实现非线性表示,并且原则上它们能够表示计算机所做的事情。
小结
本章我们讲解了感知机。感知机是一个非常简单的算法,你应该能够很快理解它的工作原理。感知机是神经网络的基础,下一章我们将学习神经网络。这些要点可以总结为以下列表:
-
感知机是一种具有输入和输出的算法。当它接收到某个输入时,会输出一个固定值。
-
感知机有“权重”和“偏置”参数。
-
你可以使用感知机表示诸如与门(AND)和或门(OR)这样的逻辑电路。
-
XOR 门不能通过单层感知机表示。
-
两层感知机可以用来表示 XOR 门。
-
单层感知机只能表示线性区域,而多层感知机可以表示非线性区域。
-
多层感知机理论上可以表示计算机。
第四章:3. 神经网络
我们在上一章学习了感知机,其中有好消息也有坏消息。好消息是,感知机很可能能够表示复杂的函数。例如,感知机(理论上)能够表示计算机执行的复杂过程,正如上一章所描述的那样。坏消息是,在确定合适的权重之前,必须先手动定义权重,以满足预期的输入和输出。在上一章中,我们使用了与 AND 和 OR 门相关的真值表来手动确定适当的权重。
神经网络的存在是为了解决坏消息。更具体地说,神经网络的一个重要特性是,它可以自动从数据中学习合适的权重参数。本章概述了神经网络,并着重介绍了它们的区别。下一章将描述它如何从数据中学习权重参数。
从感知机到神经网络
神经网络在许多方面与上一章中描述的感知机相似。神经网络是如何工作的,以及它如何与感知机有所不同,将在本节中进行描述。
神经网络示例
图 3.1 显示了一个神经网络示例。这里,左列称为输入层,右列称为输出层,中间列称为中间层。中间层也叫做隐藏层。“隐藏”意味着隐藏层中的神经元是不可见的(与输入层和输出层中的神经元不同)。在本书中,我们将这些层依次称为第 0 层、第 1 层和第 2 层(层编号从第 0 层开始,因为在后面使用 Python 实现层时这样做较为方便)。在图 3.1中,第 0 层是输入层,第 1 层是中间层,第 2 层是输出层:

图 3.1:神经网络示例
注意
尽管图 3.1中的网络包含三层,但我们称其为“二层网络”,因为它有两个带权重的层。有些书籍根据构成网络的层数称其为“三层网络”,但在本书中,网络的名称是根据具有权重的层数来命名的(即输入层、隐藏层和输出层的总层数减去 1)。
图 3.1 中的神经网络在形态上类似于上一章中的感知机。实际上,在神经元连接方式上,它与我们在上一章中看到的感知机没有什么不同。那么,信号是如何在神经网络中传递的呢?
回顾感知机
要回答这个问题,我们首先需要回顾感知机。考虑一个具有以下结构的网络:

图 3.2:回顾感知机
图 3.2 展示了一个感知器,它接收两个输入信号 (x1 和 x2),并输出 y。如前所述,图 3.2 中的感知器由方程式 (3.1) 表示:
![]() |
(3.1) |
|---|
这里,b 是一个称为“偏置”的参数,控制神经元触发的容易程度。同时,w1 和 w2 是表示单个信号“权重”的参数,用于控制它们的重要性。
你可能已经注意到,图 3.2 中的网络没有偏置 b。如果需要,我们可以在 图 3.3 中表示偏置。在 图 3.3 中添加了一个权重为 b,输入为 1 的信号。这个感知器接收三个信号 (x1, x2 和 1) 作为神经元的输入,并将每个信号与相应的权重相乘后传递到下一个神经元。下一个神经元将加权信号求和,如果总和超过 0,则输出 1。如果没有超过 0,则输出 0。以下图中的神经元用实心灰色表示,以便与其他神经元区分开来。这是因为偏置的输入信号始终为 1:

](https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/dl-bsc/img/fig03_3.jpg)
图 3.3:明确展示偏置
现在,我们想简化方程式 (3.1)。为此,我们使用一个单一的函数来表示这个条件,其中 1 是当和超过 0 时的输出,如果不超过则输出 0。在这里,我们引入一个新函数 h(x),并将方程式 (3.1) 重写为方程式 (3.2) 和 (3.3),如下所示:
![]() |
(3.2) |
|---|---|
![]() |
(3.3) |
方程式 (3.2) 表示 h(x) 函数将输入信号的和转换为输出 y。方程式 (3.3) 中表示的 h(x) 函数当输入超过 0 时返回 1,否则返回 0。因此,方程式 (3.2) 和 (3.3) 与方程式 (3.1) 的操作方式相同。
引入激活函数
这里出现的 h(x) 函数通常称为 激活函数。它将输入信号的和转换为输出信号。正如“激活”这个名字所示,激活函数决定了输入信号的和如何激活(即它如何触发)。
现在,我们可以再次重写方程式 (3.2)。方程式 (3.2) 执行两个过程:加权输入信号求和,然后通过激活函数转换该和。因此,可以将方程式 (3.2) 分解为以下两个方程式:
![]() |
(3.4) |
|---|---|
![]() |
(3.5) |
在方程式 (3.4) 中,加权输入信号和偏置的和变为 a。在方程式 (3.5) 中,a 被 h() 转换,y 被输出。
到目前为止,一个神经元已被表示为一个圆形。图 3.4 明确展示了方程式 (3.4) 和 (3.5):

](https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/dl-bsc/img/fig03_4.jpg)
图 3.4:明确展示激活函数执行的过程
图 3.4 明确展示了激活函数在神经元圆圈内执行的过程。我们可以清楚地看到,带权重的信号之和成为节点 a,并通过激活函数 h() 转换为节点 y。在本书中,“神经元”和“节点”是可以互换使用的术语。这里,圆圈 a 和 y 被称为“节点”,与之前使用的“神经元”意义相同。
我们将继续将神经元表示为一个圆圈,如 图 3.5 左侧所示。在本书中,我们还将展示激活过程(如 图 3.5 右侧所示),以便能够阐明神经网络的行为:

图 3.5:左侧的图像是一个普通的神经元图像,而右侧的图像则明确展示了神经元激活的过程(a 是输入信号的总和,h() 是激活函数,y 是输出)
现在,让我们专注于激活函数,它是从感知机到神经网络的桥梁。
注意
在本书中,"感知机"一词所表示的算法并没有严格定义。通常,“简单感知机”是一个单层网络,其中使用一个在阈值处改变输出值的阶跃函数作为激活函数。“多层感知机”通常指包含多个层并使用平滑激活函数(如 sigmoid 函数)的神经网络。
激活函数
由方程(3.3)表示的激活函数在阈值处改变输出值,称为“阶跃函数”或“阶梯函数”。因此,我们可以说,“感知机使用阶跃函数作为激活函数”。换句话说,感知机从众多候选函数中选择“阶跃函数”作为激活函数。如果感知机使用了阶跃函数作为激活函数,那么如果使用其他函数作为激活函数会发生什么呢?好吧,通过将激活函数从阶跃函数更改为其他函数,我们可以进入神经网络的世界。下一节将介绍神经网络的激活函数。
Sigmoid 函数
神经网络中常用的激活函数之一是 sigmoid 函数,其由方程(3.6)表示:
![]() |
(3.6) |
|---|
方程(3.6)中的exp(-*x*)表示e-x。实数e是自然常数,即 2.7182... 由方程(3.6)表示的 Sigmoid 函数看起来很复杂,但它其实只是一个“函数”。一个函数是一个转换器,当提供输入时,它会返回输出。例如,当提供像 1.0 和 2.0 这样的值到 Sigmoid 函数时,返回的值如h(1.0) = 0.731... 和h(2.0) = 0.880...。
在神经网络中,Sigmoid 函数通常用作激活函数来转换信号,并将转换后的信号传递给下一个神经元。事实上,上一章所描述的感知机和这里描述的神经网络的主要区别就是激活函数。其他方面,如神经元的多层连接结构和信号如何传递,基本上与感知机是一样的。现在,让我们通过与阶跃函数的比较,更深入地了解作为激活函数使用的 Sigmoid 函数。
实现阶跃函数
在这里,我们将使用 Python 显示阶跃函数的图。正如方程(3.3)所表示的,阶跃函数当输入超过 0 时输出 1,否则输出 0。以下是阶跃函数的简单实现:
def step_function(x):
if x > 0:
return 1
else:
return 0
这个实现简单易懂,但它只接受一个实数(浮点数)作为参数x。因此,step_function(3.0)是允许的。但是,这个函数不能接受 NumPy 数组作为参数。因此,step_function(np.array([1.0, 2.0]))是不允许的。在这里,我们希望修改为未来的实现,以便它可以接受 NumPy 数组。为此,我们可以编写类似以下的实现:
def step_function(x):
y = x > 0
return y.astype(np.int)
尽管前面的函数只有两行,但它可能有些难以理解,因为它使用了 NumPy 的一个有用“技巧”。在这里,使用以下 Python 解释器的示例来描述使用了什么样的技巧。在这个示例中,提供了 NumPy 的x数组。对于 NumPy 数组,进行比较运算符操作:
>>> import numpy as np
>>> x = np.array([-1.0, 1.0, 2.0])
>>> x
array([-1., 1., 2.])
>>> y = x > 0
>>> y
array([False, True, True], dtype=bool)
当对 NumPy 数组进行“大于”比较时,数组中的每个元素都会被比较,以生成一个布尔数组。在这里,当x数组中的每个元素超过 0 时,它会被转换为True,否则为False。然后,生成新的数组y。
y数组是布尔类型,所需的阶跃函数必须返回0或1的整数类型。因此,我们将数组y的元素类型从布尔型转换为整数型:
>>> y = y.astype(np.int)
>>> y
array([0, 1, 1])
如图所示,astype()方法用于转换 NumPy 数组的类型。astype()方法将所需的类型(在此示例中为np.int)作为参数。在 Python 中,True会被转换为1,False会被转换为0,通过将布尔类型转换为整数类型。前面的代码解释了在实现阶跃函数时 NumPy 所使用的“技巧”。
阶跃函数图
现在,让我们绘制之前定义的阶跃函数的图像。为此,我们需要使用 Matplotlib 库:
import numpy as np
import matplotlib.pylab as plt
def step_function(x):
return np.array(x > 0, dtype=np.int)
x = np.arange(-5.0, 5.0, 0.1)
y = step_function(x)
plt.plot(x, y)
plt.ylim(-0.1, 1.1) # Specify the range of the y-axis
plt.show()
np.arange(-5.0, 5.0, 0.1) 会生成一个包含从 -5.0 到 5.0,步长为 0.1 的 NumPy 数组([-5.0, -4.9, …, 4.9])。step_function() 接受一个 NumPy 数组作为参数。它会对数组中的每个元素执行阶跃函数,并返回一个数组作为结果。当这些 x 和 y 数组被绘制时,会显示出图 3.6所示的图形:

图 3.6:阶跃函数图
如图 3.6所示,阶跃函数的输出在 0 的阈值处从 0 变为 1(或从 1 变为 0)。阶跃函数有时被称为“阶梯函数”,因为它的输出就像楼梯的台阶,正如图 3.6所示。
实现 sigmoid 函数
现在,让我们来实现一个 sigmoid 函数。我们可以将公式(3.6)中的 sigmoid 函数用 Python 编写如下:
def sigmoid(x):
return 1 / (1 + np.exp(-x))
这里,np.exp(-x) 对应公式中的 exp(−x)。这个实现并不复杂。当将 NumPy 数组作为 x 参数传递时,仍然能够返回正确的结果。该 sigmoid 函数接收 NumPy 数组时,会按正确的方式计算,如下所示:
>>> x = np.array([-1.0, 1.0, 2.0])
>>> sigmoid(x)
array([0.26894142, 0.73105858, 0.88079708])
sigmoid 函数的实现支持 NumPy 数组,这得益于 NumPy 的广播功能(有关详情,请参阅第一章:Python 简介中的广播部分)。当对标量和 NumPy 数组进行操作时,借助广播机制,操作会在标量和 NumPy 数组的每个元素之间执行。
>>> t = np.array([1.0, 2.0, 3.0])
>>> 1.0 + t
array([2., 3., 4.])
>>> 1.0 / t
array([1\. , 0.5 , 0.33333333])
在上述示例中,算术运算(如 + 和 /)是在标量值(此处为 1.0)和 NumPy 数组之间进行的。结果是,标量值和 NumPy 数组的每个元素都参与了运算,结果作为 NumPy 数组输出。在这个 sigmoid 函数的实现中,因为 np.exp(-x) 会生成一个 NumPy 数组,1 / (1 + np.exp(-x)) 也会对 NumPy 数组的每个元素进行操作。
现在,让我们来绘制 sigmoid 函数的图像。绘制代码几乎与阶跃函数的代码相同。唯一的区别是输出 y 的函数被更改为 sigmoid 函数:
x = np.arange(-5.0, 5.0, 0.1)
y = sigmoid(x)
plt.plot(x, y)
plt.ylim(-0.1, 1.1) # Specify the range of the y-axis
plt.show()
上述代码执行时会生成图 3.7所示的图形:

图 3.7:sigmoid 函数图
比较 sigmoid 函数和阶跃函数
让我们比较一下 sigmoid 函数和阶跃函数。图 3.8 显示了 sigmoid 函数和阶跃函数。两者有什么不同?又有哪些相似之处?我们可以考虑一下图 3.8,并思考一下这个问题。
当你查看图 3.8时,你可能会注意到平滑度上的差异。Sigmoid 函数是一条平滑曲线,其输出会基于输入连续变化。另一方面,阶跃函数的输出在0时会突然变化。Sigmoid 函数的平滑性在训练神经网络时具有重要意义:

图 3.8:阶跃函数和 Sigmoid 函数(虚线表示阶跃函数)
与之前提到的平滑度相关,它们的不同之处在于阶跃函数仅返回 0 或 1,而 Sigmoid 函数返回如 0.731...和 0.880...等实数。也就是说,在感知机中,0 和 1 的二进制信号在神经元之间流动,而在神经网络中,连续实数信号在神经元之间流动。
当我们用“水”来描述这两个函数的行为时,阶跃函数可以比作“石榴水竹”(水流出管道后竹管撞击石头的声音),而 Sigmoid 函数则可以比作“水车”。阶跃函数执行两个动作:排水或储水(0 或 1),而 Sigmoid 函数则像“水车”一样,基于达到它的水量来控制水流。
现在,考虑一下阶跃函数和 Sigmoid 函数的相似之处。它们在“平滑度”上有所不同,但从更广泛的角度来看,当你查看图 3.8时,你可能会发现它们在形状上是相似的。实际上,它们都在输入较小时输出接近/为 0 的值,而随着输入增大,输出趋近/达到 1。阶跃函数和 Sigmoid 函数在输入信号包含重要信息时输出较大的值,而在没有重要信息时输出较小的值。它们还有一个相似之处,就是无论输入信号的值多么小或大,它们都会输出介于 0 和 1 之间的值。
非线性函数
阶跃函数和 Sigmoid 函数在另一个方面也相似。一个重要的相似点是它们都是非线性函数。Sigmoid 函数由一条曲线表示,而阶跃函数则由看起来像楼梯的直线表示。它们都被归类为非线性函数。
注意
“非线性函数”和“线性函数”这两个术语通常出现在激活函数中。一个函数是一个“转换器”,当提供一个值时返回一个结果。一个输出为输入值乘以常数的函数称为线性函数(其表示式为h(x) = cx,其中c为常数)。因此,线性函数的图像是一条直线。与此相对,顾名思义,非线性函数的图像不是一条简单的直线。
在神经网络中,必须使用非线性函数作为激活函数。换句话说,线性函数不能作为激活函数使用。为什么线性函数不能使用?原因是如果使用线性函数,增加神经网络中的层数将变得毫无意义。
线性函数的问题在于,无论增加多少层,总有一个“没有隐藏层的网络”能够完成相同的任务。为了具体理解这一点(并且稍微直观一些),我们来看一个简单的例子。这里,使用线性函数 h(x) = cx 作为激活函数,并像三层网络一样计算 y(x) = h(h(h(x)))。它包含了 y(x) = c×c×c×x 的乘法,而同样的操作可以通过一次乘法 y(x) = ax(其中 a = c³)来表示。因此,它可以通过一个没有隐藏层的网络来表示。正如这个例子所示,使用线性函数会抵消多层的优势。因此,为了利用多层的优势,必须使用非线性函数作为激活函数。
ReLU 函数
到目前为止,我们已经学习了作为激活函数的阶跃函数和 sigmoid 函数。虽然 sigmoid 函数在神经网络历史上已经使用了很长时间,但如今主要使用一种叫做整流线性单元(ReLU)的函数。
如果输入超过 0,ReLU 函数将直接输出输入值。如果输入小于或等于 0,它将输出 0(见 图 3.9):

图 3.9:ReLU 函数
方程 (3.7) 表示 ReLU 函数:
![]() |
(3.7) |
|---|
如图和方程所示,ReLU 函数非常简单。因此,我们也可以轻松实现它,如下所示:
def relu(x):
return np.maximum(0, x)
这里使用了 NumPy 的最大值函数。它输出输入值中较大的那个。
虽然在本章后续将使用 sigmoid 函数作为激活函数,但 ReLU 函数主要在本书后半部分使用。
计算多维数组
如果你学会了如何使用 NumPy 计算多维数组,你将能够高效地实现一个神经网络。首先,我们将学习如何使用 NumPy 计算多维数组。然后,我们将实现一个神经网络。
多维数组
简单来说,多维数组是“一个由数字组成的集合”,这些数字可以排成一行、一个矩形、三维的,或者(更一般地)N 维的,这种集合被称为多维数组。我们将使用 NumPy 来创建一个多维数组。首先,我们将创建一个一维数组,正如我们到目前为止所描述的那样:
>>> import numpy as np
>>> A = np.array([1, 2, 3, 4])
>>> print(A)
[1 2 3 4]
>>> np.ndim(A)
1
>>> A.shape
(4,)
>>> A.shape[0]
4
如图所示,你可以使用np.ndim()函数来获取数组的维度数量。你还可以使用实例变量shape来获取数组的形状。前面的例子显示,A是一个由四个元素组成的一维数组。请注意,A.shape的结果是一个元组。这是因为该结果的返回格式对于一维数组和多维数组都是相同的。例如,对于二维数组返回一个(4,3)的元组,对于三维数组返回(4,3,2)的元组。因此,对于一维数组也会返回一个元组。现在,让我们创建一个二维数组:
>>> B = np.array([[1,2], [3,4], [5,6]])
>>> print(B)
[[1 2]
[3 4]
[5 6]]
>>> np.ndim(B)
2
>>> B.shape
(3, 2)
在这里,创建了一个 3x2 的数组 B。3x2 数组意味着它在第一维上有三个元素,在下一维上有两个元素。第一维是维度 0,下一维是维度 1(在 Python 中索引从 0 开始)。二维数组称为矩阵。如图 3.10所示,数组中的水平序列称为行,垂直序列称为列:

图 3.10:水平序列称为“行”,垂直序列称为“列”
矩阵乘法
现在,考虑矩阵(二维数组)的乘积。对于 2x2 矩阵,矩阵乘法的计算如图 3.11所示(定义为此过程中的计算):

图 3.11:计算矩阵乘法
如本例所示,矩阵乘法通过将左矩阵的(水平)行与右矩阵的(垂直)列的元素相乘并加和来计算。计算结果被存储为新多维数组的元素。例如,A 的第一行与 B 的第一列的结果成为第一行的第一个元素,而 A 的第二行与 B 的第一列的结果成为第二行的第一个元素。在本书中,方程中的矩阵用粗体显示。例如,矩阵用A表示,以区别于只有一个元素的标量值(例如,a 或 b)。这个计算在 Python 中实现如下:
>>> A = np.array([[1,2], [3,4]])
>>> A.shape
(2, 2)
>>> B = np.array([[5,6], [7,8]])
>>> B.shape
(2, 2)
>>> np.dot(A, B)
array([[19, 22],
[43, 50]])
A 和 B 是 2x2 矩阵。使用 NumPy 的np.dot()函数计算矩阵 A 和 B 的乘积(这里的“dot”表示点积)。np.dot(点积)计算一维数组的内积以及二维数组的矩阵乘法。你需要注意的是,np.dot(A, B)和np.dot(B, A)可能返回不同的值。与常规运算(+,*等)不同,矩阵的乘积在操作数(A 和 B)的顺序不同的情况下会有所不同。
上述示例展示了 2x2 矩阵的乘积。你也可以计算不同形状矩阵的乘积。例如,2x3 矩阵和 3x2 矩阵的乘积可以通过以下 Python 代码实现:
>>> A = np.array([[1,2,3], [4,5,6]])
>>> A.shape
(2, 3)
>>> B = np.array([[1,2], [3,4], [5,6]])
>>> B.shape
(3, 2)
>>> np.dot(A, B)
array([[22, 28],
[49, 64]])
上述代码展示了如何实现 2x3 矩阵 A 和 3x2 矩阵 B 的乘积。在这里,你必须注意“矩阵的形状”。具体来说,矩阵 A 的维度 1 中的元素数量(列数)必须与矩阵 B 的维度 0 中的元素数量(行数)相同。实际上,在上述示例中,矩阵 A 是 2x3,矩阵 B 是 3x2,矩阵 A 维度 1 中的元素数量(3)与矩阵 B 维度 0 中的元素数量(3)相同。如果它们不同,则无法计算矩阵的乘积。那么,如果你尝试计算 2x3 矩阵 A 和 2x2 矩阵 C 的乘积,以下错误将发生:
>>> C = np.array([[1,2], [3,4]])
>>> C.shape
(2, 2)
>>> A.shape
(2, 3)
>>> np.dot(A, C)
回溯(最近的调用最后):
File "<stdin>", line 1, in <module>
ValueError: shapes (2,3) and (2,2) not aligned: 3 (dim 1) != 2 (dim 0)
这个错误提示矩阵 A 的维度 1 和矩阵 C 的维度 0 在元素数量上不相同(维度的索引从零开始)。换句话说,要计算多维数组的乘积,两个矩阵的相关维度的元素数量必须相同。因为这是一个重要的点,让我们在图 3.12中再检查一遍:

图 3.12:矩阵乘法时相关维度的元素数量必须相同
图 3.12展示了 3x2 矩阵 A 和 2x4 矩阵 B 的乘积结果,得到 3x4 矩阵 C。如我们所见,矩阵 A 和矩阵 B 的相关维度的元素数量必须相同。结果矩阵 C 的行数与矩阵 A 相同,列数与矩阵 B 相同。这一点也很重要。
即使 A 是二维矩阵而 B 是一维数组,依然适用相同的原则(即相关维度的元素数量必须相同),如图 3.13所示:

图 3.13:即使 A 是二维矩阵而 B 是一维数组,相关维度的元素数量也必须相同
图 3.13中的示例可以通过以下 Python 代码实现:
>>> A = np.array([[1,2], [3, 4], [5,6]])
>>> A.shape
(3, 2)
>>> B = np.array([7,8])
>>> B.shape
(2,)
>>> np.dot(A, B)
array([23, 53, 83])
神经网络中的矩阵乘法
现在,让我们使用 NumPy 矩阵实现神经网络,如图 3.14所示。假设神经网络仅有权重,偏置和激活函数已省略。

图 3.14:使用矩阵乘法计算神经网络
在这个实现中,我们必须注意X、W和Y的形状。非常重要的一点是,X和W对应维度中的元素数量必须相同:
>>> X = np.array([1, 2])
>>> X.shape
(2,)
>>> W = np.array([[1, 3, 5], [2, 4, 6]])
>>> print(W)
[[1 3 5]
[2 4 6]]
>>> W.shape
(2, 3)
>>> Y = np.dot(X, W)
>>> print(Y)
[ 5 11 17]
如图所示,你可以使用np.dot(多维矩阵的点积)一次性计算结果Y。这意味着,即使Y的元素数量是 100 或 1000,你也可以一次性计算出来。如果没有np.dot,你就必须逐个提取Y的元素(并使用for语句)进行计算,这样非常繁琐。因此,我们可以说,使用矩阵乘法来计算多维矩阵的乘积这一技巧非常重要。
实现一个三层神经网络
现在,让我们实现一个“实际的”神经网络。在这里,我们将实现从输入到输出的过程(一个前向传播过程),并且这个过程使用的是图 3.15中所示的三层神经网络。我们将使用 NumPy 的多维数组(如前一节所述)来实现。通过充分利用 NumPy 数组,你可以为神经网络的前向传播过程编写简洁的代码。
检查符号
在这里,我们将使用像
和
这样的符号来解释神经网络中执行的过程。它们可能看起来有点复杂。你可以快速浏览这一部分,因为这些符号仅在此处使用:

图 3.15:一个三层神经网络,包括输入层(层 0)中的两个神经元,第一个隐藏层(层 1)中的三个神经元,第二个隐藏层(层 2)中的两个神经元,以及输出层(层 3)中的两个神经元
注意
本节中重要的是,神经网络的计算可以作为矩阵计算来统一进行。神经网络中每一层的计算可以通过矩阵乘法统一进行(从更广泛的视角来看,这也是合理的)。因此,即使你忘记了与这些符号相关的详细规则,也不会影响后续的理解。
让我们从定义符号开始。请查看图 3.16。该图展示了从输入层 x2 到下一层神经元a
的权重。
如 图 3.16 所示,“(1)”位于权重或隐藏层神经元的右上角。这个数字表示第 1 层的权重或神经元。一个权重在右下角有两个数字,表示下一层和上一层神经元的索引。例如,
表示它是上一层第二个神经元(x2)到下一层第一个神经元(
) 的权重。权重右下角的索引数字必须按照“下一层的编号和上一层的编号”顺序排列:

图 3.16:权重符号
实现每层信号传递
现在,我们来看一下从输入层到“第 1 层的第一个神经元”的信号传递。图 3.17 以图示方式展示了这一过程:

图 3.17:从输入层到第 1 层的信号传递
如 图 3.17 所示,① 被添加为偏置神经元。请注意,偏置的右下角只有一个索引。这是因为在上一层中只有一个偏置神经元(① 神经元)。现在,让我们将
表达为一个方程式,回顾一下我们迄今为止所学的内容。
是加权信号和偏置的总和,并按照以下方式计算:
![]() |
(3.8) |
|---|
通过使用矩阵乘法,可以将第 1 层的“加权和”整体表示为:
![]() |
(3.9) |
|---|
这里,A(1)、X、B(1) 和 W(1) 如下所示:

现在,我们使用 NumPy 的多维数组来实现方程(3.9)。这里为输入信号、权重和偏置设置了任意值:
X = np.array([1.0, 0.5])
W1 = np.array([[0.1, 0.3, 0.5], [0.2, 0.4, 0.6]])
B1 = np.array([0.1, 0.2, 0.3])
print(W1.shape) # (2, 3)
print(X.shape) # (2,)
print(B1.shape) # (3,)
A1 = np.dot(X, W1) + B1
该计算与上一节中的计算相同。W1 是一个 2x3 的数组,X 是一个包含两个元素的单维数组。此外,在这种情况下,W1 和 X 的对应维度中的元素个数相同。
现在,考虑激活函数在第 1 层中执行的过程。图 3.18 以图示方式展示了这些过程。
如 图 3.18 所示,隐藏层中的加权和(加权信号和偏置的总和)表示为 a',通过激活函数转换的信号表示为 z'。这里,激活函数表示为 h(),使用的是 sigmoid 函数:

图 3.18:从输入层到第 1 层的信号传递
该过程在 Python 中的实现如下:
Z1 = sigmoid(A1)
print(A1) # [0.3, 0.7, 1.1]
print(Z1) # [0.57444252, 0.66818777, 0.75026011]
这个 sigmoid() 函数是我们之前定义的。它接受一个 NumPy 数组并返回一个具有相同元素个数的 NumPy 数组。
现在,我们继续讲解从第 1 层到第 2 层的实现(图 3.19):

图 3.19:从第一层到第二层的信号传递
这个实现与之前的实现相同,不同之处在于第一层的输出(Z1)是第二层的输入。如你所见,通过使用 NumPy 数组,可以轻松地实现信号从一层到另一层的传递:
W2 = np.array([[0.1, 0.4], [0.2, 0.5], [0.3, 0.6]])
B2 = np.array([0.1, 0.2])
print(Z1.shape) # (3,)
print(W2.shape) # (3, 2)
print(B2.shape) # (2,)
A2 = np.dot(Z1, W2) + B2
Z2 = sigmoid(A2)
最后,让我们实现从第二层到输出层的信号传递(图 3.20)。你可以几乎以与我们之前看到的其他实现相同的方式实现输出层。唯一不同的是最后的激活函数与我们之前看到的隐藏层的激活函数不同:
def identity_function(x):
return x
W3 = np.array([[0.1, 0.3], [0.2, 0.4]])
B3 = np.array([0.1, 0.2])
A3 = np.dot(Z2, W3) + B3
Y = identity_function(A3) # or Y = A3
在这里,我们将定义一个名为identity_function()的函数,并将其作为输出层的激活函数。单位函数将输入原样输出。尽管在这个例子中不需要定义identity_function(),但为了与之前的实现保持一致,使用了该实现。在图 3.20中,输出层的激活函数显示为σ(),以表明它与隐藏层的激活函数h()不同(σ被称为sigma):

图 3.20:从第二层到输出层的信号传递
你可以根据希望解决的问题类型选择输出层使用的激活函数。通常,对于回归问题使用单位函数,对于二分类问题使用 sigmoid 函数,对于多分类问题使用 softmax 函数。输出层的激活函数将在下一节中详细解释。
实现总结
这也完成了我们对三层神经网络的研究。以下总结了我们到目前为止的实现过程。按照神经网络实现的惯例,只有权重采用大写字母(例如,W1),而其他项(如偏置和中间结果)则采用小写字母:
def init_network():
network = {}
network['W1'] = np.array([[0.1, 0.3, 0.5], [0.2, 0.4, 0.6]])
network['b1'] = np.array([0.1, 0.2, 0.3])
network['W2'] = np.array([[0.1, 0.4], [0.2, 0.5], [0.3, 0.6]])
network['b2'] = np.array([0.1, 0.2])
network['W3'] = np.array([[0.1, 0.3], [0.2, 0.4]])
network['b3'] = np.array([0.1, 0.2])
return network
def forward(network, x):
W1, W2, W3 = network['W1'], network['W2'], network['W3']
b1, b2, b3 = network['b1'], network['b2'], network['b3']
a1 = np.dot(x, W1) + b1
z1 = sigmoid(a1)
a2 = np.dot(z1, W2) + b2
z2 = sigmoid(a2)
a3 = np.dot(z2, W3) + b3
y = identity_function(a3)
return y
network = init_network()
x = np.array([1.0, 0.5])
y = forward(network, x)
print(y) # [ 0.31682708 0.69627909]
这里定义了init_network()和forward()函数。init_network()函数初始化权重和偏置,并将其存储在字典类型的变量network中,该变量存储了各个层所需的参数、权重和偏置。forward()函数则实现了将输入信号转化为输出信号的过程。
这里的“forward”表示从输入到输出的传递过程。稍后,在我们训练神经网络时,我们将研究反向过程(从输出到输入)。
这完成了前向传播方向上三层神经网络的实现。通过使用 NumPy 的多维数组,我们能够高效地实现神经网络。
设计输出层
你可以将神经网络同时用于分类问题和回归问题。然而,你必须根据所处理的问题更改输出层的激活函数。通常,回归问题使用恒等函数,分类问题使用 softmax 函数。
注意
机器学习问题可以大致分为“分类问题”和“回归问题”。分类问题是指识别数据属于哪个类别——例如,将图像中的人分类为男性或女性——而回归问题则是从某些输入数据中预测一个(连续的)数值——例如,预测图像中人的体重。
恒等函数与 Softmax 函数
恒等函数的输出就是输入本身。一个不进行任何处理、直接输出输入内容的函数就是恒等函数。因此,当输出层使用恒等函数时,输入信号会原样返回。利用我们迄今使用的神经网络图,你可以像 图 3.21 所示一样,用恒等函数表示这个过程。恒等函数的转换过程可以通过一条箭头来表示,方式与我们之前见过的激活函数类似:

](https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/dl-bsc/img/fig03_21.jpg)
图 3.21:恒等函数
用于分类问题的 softmax 函数可以通过以下方程表示:
![]() |
(3.10) |
|---|
exp(x) 是一个指数函数,表示 ex(e 是自然常数,约为 2.7182…)。假设输出层的总数为 n,方程提供了第 k 个输出,yk。如方程(3.10)所示,softmax 函数的分子是输入信号 ak 的指数函数,分母是所有输入信号指数函数的总和。
图 3.22 通过图形方式展示了 softmax 函数。如你所见,softmax 函数的输出是通过箭头与所有输入信号连接的。正如方程(3.10)所示,输出的每个神经元都受到所有输入信号的影响:

](https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/dl-bsc/img/fig03_22.jpg)
图 3.22:Softmax 函数
现在,让我们实现 softmax 函数,并使用 Python 解释器逐个检查结果:
>>> a = np.array([0.3, 2.9, 4.0])
>>>
>>> exp_a = np.exp(a) # Exponential function
>>> print(exp_a)
[ 1.34985881 18.17414537 54.59815003]
>>>
>>> sum_exp_a = np.sum(exp_a) # Sum of exponential functions
>>> print(sum_exp_a)
74.1221542102
>>>
>>> y = exp_a / sum_exp_a
>>> print(y)
[ 0.01821127 0.24519181 0.73659691]
该实现通过 Python 表示了方程(3.10)的 softmax 函数。因此,不需要额外的描述。当我们以后使用 softmax 函数时,我们将其定义为一个 Python 函数,如下所示:
def softmax(a):
exp_a = np.exp(a)
sum_exp_a = np.sum(exp_a)
y = exp_a / sum_exp_a
return y
实现 Softmax 函数时的问题
上述实现的 softmax 函数正确地表示了方程 (3.10),但在计算机计算中存在缺陷。这个缺陷是溢出问题。实现 softmax 函数涉及计算指数函数,而指数函数的值可能非常大。例如,e10 的值大于 20,000,e100 是一个超过 40 位的巨大值。e1000 的结果返回 inf,表示无限大值。将这些大值相除会得到一个“不稳定”的结果。
注意
当计算机处理“数字”时,它会以有限的数据宽度存储,例如四个或八个字节。这意味着一个数字有一定的有效数字位数。一个数字能够表示的范围是有限的。因此,会存在无法表达非常大值的问题,这称为溢出,所以我们在使用计算机进行计算时必须小心。
softmax 函数的改进实现来源于以下方程:
![]() |
(3.11) |
|---|
首先,通过将分子和分母同时乘以一个任意常数 C 来变换方程 (3.11)(由于分子和分母都乘以相同的常数,因此进行的计算是相同的)。然后,将 C 移入指数函数 (exp),表示为 log C。最后,将 log C 替换为另一个符号 C'。
方程 (3.11) 表明,在计算 softmax 函数中的指数函数时,加上或减去某个常数不会改变结果。虽然在这里你可以使用任何数作为 C',但通常使用输入信号中的最大值来防止溢出。考虑以下示例:
>>> a = np.array([1010, 1000, 990])
>>> np.exp(a) / np.sum(np.exp(a)) # Calculating the softmax function
array([ nan, nan, nan]) # Not calculated correctly
>>>
>>> c = np.max(a) # 1010
>>> a - c
array([ 0, -10, -20])
>>>
>>> np.exp(a - c) / np.sum(np.exp(a - c))
array([ 9.99954600e-01, 4.53978686e-05, 2.06106005e-09])
如本例所示,当输入信号的最大值 (c,此处) 被减去时,你可以正确计算该函数。否则,将返回 nan(非数:不稳定)值。基于此描述,我们可以按如下方式实现 softmax 函数:
def softmax(a):
c = np.max(a)
exp_a = np.exp(a - c) # Prevent an overflow
sum_exp_a = np.sum(exp_a)
y = exp_a / sum_exp_a
return y
Softmax 函数的特点
你可以使用 softmax() 函数来计算神经网络的输出,如下所示:
>>> a = np.array([0.3, 2.9, 4.0])
>>> y = softmax(a)
>>> print(y)
[ 0.01821127 0.24519181 0.73659691]
>>> np.sum(y)
1.0
Softmax 函数输出一个介于 0 和 1.0 之间的实数。softmax 函数的输出总和为 1。总和为 1 是 softmax 函数的一个重要特性,因为它意味着我们可以将 softmax 函数的输出解释为“概率”。
举例来说,在前面的例子中,我们可以将 y[0] 的概率解释为 0.018(1.8%),y[1] 的概率为 0.245(24.5%),y[2] 的概率为 0.737(73.7%)。根据这些概率,我们可以说,“由于第二个元素的概率最高,答案是第二类。”我们甚至可以用概率性地回答:“答案是第二类,概率为 74%,第一类的概率为 25%,零类的概率为 1%。”因此,你可以使用 softmax 函数来概率性地(统计学上)处理问题。
我们应该注意,应用 softmax 函数不会改变元素的顺序。这是因为指数函数,(y = exp(x)),是单调递增的。实际上,在前面的例子中,a 中元素的顺序与 y 中元素的顺序相同。a 中的最大值是第二个元素,而 y 中的最大值也是第二个元素。
通常,神经网络的分类任务只会识别与最大输出对应的类别。使用 softmax 函数并不会改变最大输出对应的神经元位置。因此,你可以在神经网络分类中省略输出层的 softmax 函数。实际上,由于指数函数需要一些计算,输出层的 softmax 函数通常会被省略。
注意
解决机器学习问题的过程分为两个阶段:“训练”和“预测”。首先,在训练阶段你训练一个模型,然后使用训练好的模型在推理阶段对未知数据进行预测(分类)。如前所述,推理阶段通常省略输出层的 softmax 函数。我们之所以在输出层使用 softmax 函数,是因为它对神经网络的训练有重要作用(更多细节请参考下一章)。
输出层中的神经元数量
你必须根据要解决的问题来确定输出层中神经元的数量。对于分类问题,分类的类别数通常作为输出层神经元的数量。例如,要从输入图像中预测一个从 0 到 9 的数字(10 类分类),输出层会有 10 个神经元,如 图 3.23 所示:

图 3.23:输出层中的神经元对应于每个数字
如 图 3.23 所示,输出层中的神经元从上到下分别对应数字 0、1、...、9。这里,各种灰度的不同深浅表示输出层神经元的值。在这个例子中,y2 的颜色最深,因为 y2 神经元输出的值最大。这表明该神经网络预测输入属于与 y2 对应的类别;也就是“2”。
手写数字识别
现在我们已经介绍了神经网络的机制,让我们考虑一个实际的问题。我们将对一些手写数字图像进行分类。假设训练已经完成,我们将使用训练好的参数在神经网络中实现“推理”。在神经网络中,这种推理也称为前向传播。
注意
与解决机器学习问题的过程相同(包括“训练”和“推理”两个阶段),要使用神经网络解决问题,我们将使用训练数据训练权重参数,然后在预测时使用训练好的参数对输入数据进行分类。
MNIST 数据集
在这里,我们将使用称为 MNIST 的手写数字图像集。MNIST 是机器学习领域中最著名的数据集之一,并以从简单实验到研究的各种方式使用。当您阅读有关图像识别或机器学习的研究论文时,您经常会注意到 MNIST 数据集作为实验数据的使用。
MNIST 数据集包含从 0 到 9 的数字图像(Figure 3. 24)。它包含 60,000 张训练图像和 10,000 张测试图像,用于训练和推理。当我们使用 MNIST 数据集时,通常使用训练图像进行训练,并测量训练模型如何正确分类测试图像:
![图 3.24:MNIST 图像数据集示例
[img/fig03_24.jpg)
图 3.24:MNIST 图像数据集示例
MNIST 的图像数据是 28x28 的灰度图像(一个通道),每个像素值从 0 到 255。每个图像数据都有标签,如“7”,“2”和“1”。
本书提供了一个方便的 Python 脚本mnist.py,位于dataset目录中。它支持下载 MNIST 数据集并将图像数据转换为 NumPy 数组。要使用mnist.py脚本,当前目录必须是ch01、ch02、ch03、... 或 ch08目录。通过在mnist.py中使用load_mnist()函数,您可以轻松加载 MNIST 数据,如下所示:
import sys, os
sys.path.append(os.pardir) # Configure to import the files in the parent directory
from dataset.mnist import load_mnist
# Waits for a few minutes for the first call ...
(x_train, t_train), (x_test, t_test) = ∖
load_mnist(flatten=True, normalize=False)
# Output the shape of each data
print(x_train.shape) # (60000, 784)
print(t_train.shape) # (60000,)
print(x_test.shape) # (10000, 784)
print(t_test.shape) # (10000,)
首先,配置导入父目录中文件的详细信息。然后,从dataset/mnist.py导入load_mnist函数。最后,使用导入的load_mnist函数加载 MNIST 数据集。第一次调用load_mnist时,需要互联网连接下载 MNIST 数据。后续调用因为仅加载本地保存的文件(pickle 文件),所以完成时间很快。
注意
用于加载 MNIST 图像的文件位于本书提供的源代码的数据集目录中。假定此 MNIST 数据集仅从ch01、ch02、ch03、... 或 ch08目录中使用。因此,要使用数据集,需要sys.path.append(os.pardir)语句。这是因为必须导入父目录(数据集目录)中的文件。
load_mnist 函数返回加载的 MNIST 数据,格式为 (训练图像,训练标签),(测试图像,测试标签)。它可以接受三个参数:load_mnist(normalize=True, flatten=True, one_hot_label=False)。第一个参数 normalize 指定是否将输入图像归一化到 0.0 到 1.0 之间。如果设置为 False,输入图像的像素值将保持在 0 到 255 之间。第二个参数 flatten 指定是否将输入图像展平(将其转换为一维数组)。如果设置为 False,输入图像将以三维数组(1 × 28 × 28)存储。如果设置为 True,图像将以一维数组形式存储,包含 784 个元素。第三个参数 one_hot_label 指定是否使用独热编码存储标签。在独热编码的数组中,只有正确标签的元素为 1,其他元素为 0,例如 [0,0,1,0,0,0,0,0,0,0]。当 one_hot_label 为 False 时,标签将仅存储正确的标签值,如 7 或 2;当 one_hot_label 为 True 时,标签将存储为独热编码数组。
注意
Python 有一个方便的功能,叫做 pickle,它可以在程序执行过程中将对象保存为文件。通过加载保存的 pickle 文件,你可以立即恢复程序执行过程中使用的对象。load_mnist() 函数(用于加载 MNIST 数据集)也使用 pickle(用于第二次或之后的加载阶段)。通过使用 pickle 的功能,你可以快速准备 MNIST 数据。
现在,让我们显示 MNIST 图像以检查数据。我们将使用 ch03/mnist_show.py。
import sys, os
sys.path.append(os.pardir)
import numpy as np
from dataset.mnist import load_mnist
from PIL import Image
def img_show(img):
pil_img = Image.fromarray(np.uint8(img))
pil_img.show()
(x_train, t_train), (x_test, t_test) = /
load_mnist(flatten=True, normalize=False)
img = x_train[0]
label = t_train[0]
print(label) # 5
print(img.shape) # (784,)
img = img.reshape(28, 28) # Reshape the image based on the original size
print(img.shape) # (28, 28)
img_show(img)
这将产生以下输出:

图 3.25:显示 MNIST 图像
请注意,当 flatten=True 时,加载的图像会作为一维的 NumPy 数组存储。因此,为了显示图像,你必须将其重新调整为原始的 28x28 大小。你可以使用 reshape() 方法,通过指定所需的形状来调整 NumPy 数组的形状。你还需要将以 NumPy 数组存储的图像数据转换为 PIL 所需的数据对象。你可以使用 Image.fromarray() 进行此转换。
神经网络推理
现在,让我们实现一个神经网络,用于预测 MNIST 数据集上的数据。该网络由一个包含 784 个神经元的输入层和一个包含 10 个神经元的输出层组成。输入层的 784 来自于图像的大小(28 x 28 = 784),而输出层的 10 来自于 10 类分类(数字 0 到 9 的 10 个类别)。有两个隐藏层:第一个隐藏层有 50 个神经元,第二个隐藏层有 100 个神经元。你可以根据需要更改 50 和 100 的值。首先,我们定义三个函数:get_data()、init_network() 和 predict()(以下源代码位于 ch03/neuralnet_mnist.py):
def get_data():
(x_train, t_train), (x_test, t_test) = /
load_mnist(normalize=True, flatten=True, one_hot_label=False)
return x_test, t_test
def init_network():
with open("sample_weight.pkl", 'rb') as f:
network = pickle.load(f)
return network
def predict(network, x):
W1, W2, W3 = network['W1'], network['W2'], network['W3']
b1, b2, b3 = network['b1'], network['b2'], network['b3']
a1 = np.dot(x, W1) + b1
z1 = sigmoid(a1)
a2 = np.dot(z1, W2) + b2
z2 = sigmoid(a2)
a3 = np.dot(z2, W3) + b3
y = softmax(a3)
return y
init_network()函数加载了存储在 pickle 文件sample_weight.pkl中的训练权重参数。这个文件包含了权重和偏差参数,并以字典类型变量的形式存储。剩下的两个函数与之前描述的实现几乎相同,因此不需要再次描述。现在,我们将使用这三个函数来进行神经网络的预测。我们想要评估识别精度——即它能够多正确地分类:
x, t = get_data()
network = init_network()
accuracy_cnt = 0
for i in range(len(x)):
y = predict(network, x[i])
p = np.argmax(y) # Obtain the index of the most probable element
if p == t[i]:
accuracy_cnt += 1
print("Accuracy:" + str(float(accuracy_cnt) / len(x)))
在这里,我们将获取 MNIST 数据集并构建一个网络,然后使用for语句获取存储在x中的每个图像数据,并使用predict()函数进行分类。predict()函数返回一个 NumPy 数组,其中包含每个标签的概率。例如,返回一个像[0.1, 0.3, 0.2, …, 0.04]这样的数组,表示"0"的概率为 0.1,"1"的概率为 0.3,依此类推。通过找到这个概率列表中最大值的索引,我们可以得到最可能的元素作为预测结果。你可以使用np.argmax(x)来获取数组中最大元素的索引。它返回x参数指定数组中最大元素的索引。最后,神经网络预测的答案与正确的标签进行比较,正确预测的比率将显示为识别精度(准确率)。
当前面的代码执行时,Accuracy:0.9352会显示。这表示 93.52%的分类是正确的。我们在这里不讨论识别准确率,因为我们的目标是运行一个训练好的神经网络,但在本书的后面部分,我们将改进神经网络的结构和训练方法,以获得更高的识别准确率。实际上,准确率将超过 99%。
在这个例子中,load_mnist函数的参数normalize被设置为True。当normalize为True时,函数会将图像中每个像素的值除以 255,使得数据值处于 0.0 到 1.0 之间。将数据转换为某个特定范围内的值叫做归一化,而以特定方式转换神经网络的输入数据叫做预处理。在这里,输入图像数据在预处理阶段进行了归一化。
注意
在实际应用中,预处理常常用于神经网络(深度学习)中。通过实验已经证明了预处理的有效性,例如提高了区分度和加快了学习速度。在前面的例子中,简单的归一化是通过将每个像素的值除以 255 来进行的预处理。实际上,预处理通常是在考虑整个数据分布的基础上进行的。归一化是通过使用整个数据的平均值和标准差来进行的,使得所有数据都围绕 0 分布或符合某个特定的范围。此外,白化也会被执行,以便所有数据分布更加均匀。
批量处理
这个过程是使用 MNIST 数据集实现神经网络的过程。在这里,我们将重新审视前面的实现,并重点关注输入数据和权重参数的“形状”。
让我们使用 Python 解释器输出前面神经网络中每一层权重的形状:
>>> x, _ = get_data( )
>>> network = init_network( )
>>> W1, W2, W3 = network['W1'], network['W2'], network['W3']
>>>
>>> x.shape
(10000, 784)
>>> x[0].shape
(784,)
>>> W1.shape
(784, 50)
>>> W2.shape
(50, 100)
>>> W3.shape
(100, 10)
让我们检查多维数组的对应维度的元素数量是否与前面的结果一致(偏差被省略)。图 3.26以图形化方式展示了这一点。在这里,多维数组的对应维度的元素数量是一致的。请验证一个包含 10 个元素的单维数组 y 是否作为最终结果返回:

图 3.26:数组形状变化
图 3.26展示了一个一维数组(原本为 28x28 的二维数组)包含 784 个元素输入,并返回一个包含 10 个元素的一维数组的过程。这是单张图片输入时的处理流程。
现在,让我们思考一下当多张图片同时输入时的处理过程。例如,假设你想使用predict()函数一次性处理 100 张图片。为了实现这一点,你可以将x的形状更改为100×784,这样就可以将 100 张图片作为输入数据一起输入。图 3.27以图形化方式展示了这一点:

图 3.27:批处理中的数组形状变化
如图 3.27所示,输入数据的形状为 100x784,输出数据的形状为 100x10\。这表示 100 张图片的输入数据会一次性返回结果。例如,x[0]和y[0]存储第 0 张图片的图像和预测结果,x[1]和y[1]存储第一张图片的图像和预测结果,以此类推。
这种有组织的输入数据集,如这里所述,称为批次。批次就像一堆叠起来的图片,类似于一叠钞票。
注意
批处理在计算机计算中具有巨大优势。由于许多处理数值计算的库经过高度优化,因此可以高效地计算大型数组,批处理大大减少了每张图片的处理时间。当数据传输成为神经网络计算的瓶颈时,批处理可以减轻总线带宽的负载(即:操作与数据加载的比例可以增加)。虽然批处理需要计算一个大型数组,但一次性计算整个数组比一点一点地分割计算小数组要快。
现在,让我们在实现中使用批处理。这里,和之前代码的不同之处以粗体标出:
x, t = get_data( )
network = init_network( )
batch_size = 100 # Number of batches
accuracy_cnt = 0
for i in range(0, len(x), batch_size):
x_batch = x[i:i+batch_size]
y_batch = predict(network, x_batch)
p = np.argmax(y_batch, axis=1)
accuracy_cnt += np.sum(p == t[i:i+batch_size])
print("Accuracy:" + str(float(accuracy_cnt) / len(x)))
现在,我们将描述每个加粗的部分。首先,让我们看一下 range() 函数。您可以使用 range() 函数,例如 range(start, end),生成一个从 start 到 end-1 的整数列表。通过指定三个整数,如 range(start, end, step),您可以生成一个按 step 指定的值递增的整数列表,如下例所示:
>>> list( range(0, 10) )
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> list( range(0, 10, 3) )
[0, 3, 6, 9]
基于 range() 函数返回的列表,x[i:i+batch_size] 用于从输入数据中提取一个批次。x[i:i+batch_n] 从输入数据中获取第 i- 个到 i+batch_n- 个的数据。在这个例子中,数据从开始提取 100 项,比如 x[0:100],x[100:200],…
然后,argmax() 获取最大值的索引。请注意,这里指定了一个参数 axis=1。这意味着,在一个 100x10 的数组中,最大值的索引是在维度 1(轴几乎等同于维度)中的元素中找到的,如下所示:
>>> x = np.array([[0.1, 0.8, 0.1], [0.3, 0.1, 0.6],
... [0.2, 0.5, 0.3], [0.8, 0.1, 0.1]])
>>> y = np.argmax(x, axis=1)
>>> print(y)
[1 2 1 0]
最后,将每一批次的分类结果与实际答案进行比较。为此,使用比较运算符(==)来比较 NumPy 数组。返回一个布尔值数组 True/False,并计算 True 的数量,如下所示:
>>> y = np.array([1, 2, 1, 0])
>>> t = np.array([1, 2, 0, 0])
>>> print(y==t)
[True True False True]
>>> np.sum(y==t)
3
这就是使用批处理实现的内容。批处理使得处理快速且高效。当我们在下一章学习神经网络时,将会使用一批批的图像数据进行训练。到时候,我们也会像本章一样构建一个批处理实现。
总结
本章描述了神经网络中的前向传播。本章中解释的神经网络与上一章的感知器相同,都以层次化的方式传递神经元信号。然而,在激活函数方面有很大差异,这些激活函数会在信号传递到下一个神经元时改变信号。在激活函数中,神经网络使用的是 Sigmoid 函数,该函数平滑地改变信号,而感知器使用的是步进函数,该函数会突然改变信号。这一差异在神经网络训练中非常重要,并将在下一章中进行描述。本章涵盖了以下内容:
-
神经网络使用平滑变化的函数作为激活函数,如 Sigmoid 函数或 ReLU 函数。
-
通过使用 NumPy 的多维数组,您可以高效地实现神经网络。
-
机器学习问题大致可以分为分类问题和回归问题。
-
在为输出层使用激活函数时,回归问题通常使用恒等函数,分类问题则使用 softmax 函数。
-
对于分类问题,用来分类的类别数作为输出层神经元的数量。
-
一组输入数据被称为一个批次。按批次进行预测可以加速计算过程。
第五章:4. 神经网络训练
本章介绍神经网络的训练。当我们在这个语境中谈论“训练”时,我们指的是从训练数据中自动获取最佳权重参数。在本章中,我们将介绍一种称为损失函数的标准,它使得神经网络能够学习。训练的目的是发现能够使损失函数值最小的权重参数。本章还将介绍一种通过函数梯度发现最小损失函数值的方法,这种方法被称为梯度法。
从数据中学习
神经网络的核心特性是其从数据中学习的能力。从数据中学习意味着权重参数的值可以自动确定。如果你需要手动确定所有参数,这将是一项非常艰巨的任务。例如,对于一个样本感知机,如第二章所示的感知机,我们在查看真值表时手动确定了参数值。这里只有三个参数。然而,在实际的神经网络中,参数的数量可以从几千到几万不等。对于具有更多层次的深度学习,参数数量可能达到数亿。手动确定这些参数几乎是不可能的。本章描述了神经网络的训练,或者说如何从数据中确定参数值,并实现了一个使用 Python 从 MNIST 数据集学习手写数字的模型。
注意
对于线性可分问题,感知机可以通过数据自动学习。当训练完成一定次数时,它能够解决线性可分问题,这就是所谓的“感知机收敛定理”。另一方面,非线性分离问题是无法解决的(自动化)。
数据驱动
数据在机器学习中至关重要。机器学习在数据中寻找答案,发现数据中的模式,并基于此讲述一个故事。没有数据,它什么也做不了。因此,“数据”处于机器学习的核心。我们可以说,这种数据驱动的方法是对以“人”为中心方法的偏离。
通常,当我们解决一个问题时——尤其是当我们需要找出一个模式时——我们必须考虑各种因素来找到答案。“这个问题似乎有这样的模式。”“不,可能在别的地方有原因。”基于我们的经验和直觉,我们通过反复试验推进这一任务。机器学习尽量避免人为干预。它试图从收集到的数据中找到答案(模式)。此外,神经网络和深度学习有一个共同的重要特性,那就是它们能够比传统的机器学习更好地避免人为干预。
让我们来看一个具体的问题。假设我们想实现一个识别数字"5"的程序。假设我们的目标是实现一个程序,判断手写图像(如图 4.1所示)是"5"还是不是"5"。这个问题看起来相对简单。我们可以使用什么算法呢?

图 4.1:手写数字示例——"5"的书写方式因人而异
当你尝试设计一个能正确分类"5"的程序时,你会发现这比预期的要难得多。我们可以轻松识别"5",但很难明确识别图像为"5"的规则。如图 4.1所示,书写方式因人而异。这告诉我们,找到识别"5"的规则将是艰苦的工作,并且可能需要大量的时间。
现在,我们不再是从零开始"推导"出识别"5"的算法,而是希望有效利用数据来解决问题。我们可以使用的方法之一是从图像中提取特征,并使用机器学习技术来学习这些特征的模式。特征指的是一个转换器,它被设计用来准确地从输入数据(输入图像)中提取重要数据(关键数据)。图像的特征通常被描述为一个向量。在计算机视觉领域,著名的特征包括 SIFT、SURF 和 HOG。你可以使用这些特征将图像数据转换为向量,并使用机器学习中的分类器,如 SVM 和 KNN,来学习转换后的向量。
在这种机器学习方法中,"机器"从收集到的数据中发现一个模式。与我们从头开始发明算法相比,这可以更高效地解决问题,并减少对"人"的负担。然而,我们必须注意,当图像被转换成向量时,所使用的特征是由"人"设计的。因为没有使用适合问题的特征(或者没有设计特征),是无法获得良好结果的。例如,要识别狗的面部,可能需要选择与识别"5"不同的特征。毕竟,即使是使用特征和机器学习的方法,也可能需要根据问题选择合适的特征,这些特征仍然由"人"来选择。
到目前为止,我们已经讨论了两种机器学习方法。这两种方法如图 4.2中的上排所示。同时,使用神经网络(深度学习)的方法则显示在图 4.2的下排。它通过一个没有人工干预的模块来表示。
如图 4.2所示,神经网络学习的是图像"原样"。在第二种方法中,使用特征和机器学习的示例,称为人工设计的特征,而在神经网络中,"机器"从图像中学习重要的特征:

图 4.2:从人工规则到从数据中学习的“机器”的范式转变——没有人工干预的模块以灰色显示
注意
深度学习有时被称为“端到端机器学习。”“端到端”意味着“从一端到另一端”,也就是说,从原始数据(输入)中获取期望的结果(输出)。
神经网络的优势在于它可以在相同的流程中解决所有问题;例如,无论是试图识别“5”、一只狗还是一个人脸,神经网络都会耐心地学习提供的数据,努力发现在给定问题中的模式。神经网络可以“端到端”地学习数据,无论是解决什么问题。
训练数据和测试数据
在本章中,我们将介绍神经网络训练,从处理机器学习数据的一些最佳实践开始。
在机器学习问题中,我们通常根据目的使用训练数据和测试数据。首先,我们仅使用训练数据来寻找最优参数。然后,使用测试数据来评估训练好的模型的能力。为什么要将训练数据和测试数据分开?因为我们希望模型具备泛化能力。我们必须将训练数据和测试数据分开,因为我们要正确评估这种泛化能力。
泛化是指未知数据(不包含在训练数据中的数据)的能力,机器学习的最终目标是获得这种泛化能力。例如,手写数字识别可以用于自动读取明信片上的邮政编码。在这种情况下,手写数字识别必须能够识别“某个人”写的字符。这个“某个人”并不是“某个特定的人写的特定字符”,而是“一个任意的人写的任意字符”。即便模型能很好地区分你的训练数据,它可能只学会了数据中某个人的特定书写风格。
因此,如果你只使用一个数据集来学习参数并评估它们,那么无法提供正确的评估。这会导致一个可以很好地处理某个数据集,但无法处理另一个数据集的模型。当模型过于适应仅一个数据集时,过拟合就会发生。避免过拟合是机器学习中的一个重要挑战。
损失函数
当你被问到“你现在有多幸福?”时,你会怎么回答?我们通常会模糊地回答:“我挺开心的”或者“我不太开心。”如果有人回答:“我当前的幸福分数是 10.23”,你可能会感到惊讶,因为这个人只能用一个分数量化自己的幸福。如果真有这样的人,他可能只根据自己的“幸福分数”来引导生活。
这个“幸福评分”是一个寓言,用来说明在神经网络训练中发生的一些类似现象。在神经网络训练中,使用一个“评分”来表示当前的状态。基于这个评分,搜索最佳的权重参数。就像这个人根据“幸福评分”寻找“最佳生活”,神经网络则通过“一个评分”来寻找最优的参数。在神经网络训练中使用的评分叫做损失函数。虽然任何函数都可以作为损失函数,但通常使用的是平方误差之和或交叉熵误差。
注意
损失函数是一个指标,用来表示神经网络能力的“差劲”程度。它表示当前神经网络对标记数据的不适应程度,以及它与标记数据的偏差程度。你可能觉得“能力差劲”作为评分有些不自然,但你可以将损失函数乘以一个负值,解释为“能力好的评分”(即“能力好”的评分)。“最小化能力差劲”与“最大化能力好”是等价的。因此,能力的“差劲”指标与能力的“好”指标本质上是相同的。
平方误差之和
有几种函数可以用作损失函数。可能最著名的是平方误差之和。它通过以下方程表示:
![]() |
(4.1) |
|---|
在这里,yk 是神经网络的输出,tk 是标记数据,k是数据的维度数量。例如,在第三章:神经网络的手写数字识别部分中,yk 和tk 是由 10 个元素组成的数据项:
>>> y = [0.1, 0.05, 0.6, 0.0, 0.05, 0.1, 0.0, 0.1, 0.0, 0.0]
>>> t = [0, 0, 1, 0, 0, 0, 0, 0, 0, 0]
这些数组的元素对应于从第一个索引开始按顺序排列的数字“0”,“1”,“2”,...。在这里,神经网络的输出 y 是经过 softmax 函数处理的输出。softmax 函数的输出可以解释为一个概率。在这个例子中,“0”的概率是 0.1,“1”的概率是 0.05,“2”的概率是 0.6,依此类推。同时,t 是标记数据。在标记数据中,正确的标签是 1,其他标签是 0。这里,标签“2”是 1,表示正确答案是“2”。将正确标签设为 1,其他标签设为 0,称为独热编码表示。
如方程(4.1)所示,平方误差之和是神经网络输出与正确教师数据相应元素之间差值的平方和。现在,让我们在 Python 中实现平方误差之和。你可以按照以下方式实现:
def sum_squared_error(y, t):
return 0.5 * np.sum((y-t)**2)
在这里,y和t参数是 NumPy 数组。由于这只是实现方程式(4.1),我们在此不再详细解释。现在,我们将使用这个函数进行计算:
>>> # Assume that "2" is correct
>>> t = [0, 0, 1, 0, 0, 0, 0, 0, 0, 0]
>>>
>>> # Example 1: "2" is the most probable (0.6)
>>> y = [0.1, 0.05, 0.6, 0.0, 0.05, 0.1, 0.0, 0.1, 0.0, 0.0]
>>> sum_squared_error(np.array(y), np.array(t))
0.097500000000000031
>>>
>>> # Example 2: "7" is the most probable (0.6)
>>> y = [0.1, 0.05, 0.1, 0.0, 0.05, 0.1, 0.0, 0.6, 0.0, 0.0]
>>> sum_squared_error(np.array(y), np.array(t))
0.59750000000000003
这里有两个例子。在第一个例子中,正确答案是“2”,神经网络的输出在“2”时最大。与此同时,在第二个例子中,正确答案是“2”,但神经网络的输出在“7”时最大。实验结果显示,第一个例子的损失函数较小,这表明标记数据之间的差异较小。换句话说,平方误差的和表明第一个例子的输出与标记数据更为匹配。
交叉熵误差
除了平方误差和,交叉熵误差也常被用作损失函数。其表达式如下:
![]() |
(4.2) |
|---|
这里,log 表示自然对数,即以e (loge)为底的对数。yk 是神经网络的输出,tk 是正确标签。在 tk 中,只有正确标签的索引为 1;其他索引为 0(独热表示法)。因此,方程式(4.2)仅计算对应正确标签的输出的对数,1。例如,如果“2”是正确标签的索引,并且神经网络对应的输出是 0.6,则交叉熵误差为-log 0.6 = 0.51。如果“2”的输出是 0.1,则误差为-log 0.1 = 2.30。交叉熵误差依赖于正确标签的输出结果。图 4.3显示了这个自然对数的图形:

图 4.3:自然对数 y = log x 的图形
如图 4.3所示,y在* x 为 1 时为 0,且随着x接近 0,y*的值变得更小。因此,由于正确标签对应的输出更大,方程式(4.2)趋向于 0。当输出为 1 时,交叉熵误差为 0。当正确标签的输出较小时,方程式(4.2)的值较大。
现在,让我们实现一个交叉熵误差:
def cross_entropy_error(y, t):
delta = 1e-7
return -np.sum(t * np.log(y + delta))
在这里,y 和 t 参数是 NumPy 数组。当计算np.log时,会加上一个非常小的值 delta。如果计算np.log(0),则返回-inf,表示负无穷。在这种情况下,计算无法继续进行。为避免这种情况,会加上一个非常小的值,以避免负无穷的出现。现在,为了便于计算,我们使用cross_entropy_error(y, t):
>>> t = [0, 0, 1, 0, 0, 0, 0, 0, 0, 0]
>>> y = [0.1, 0.05, 0.6, 0.0, 0.05, 0.1, 0.0, 0.1, 0.0, 0.0]
>>> cross_entropy_error(np.array(y), np.array(t))
0.51082545709933802
>>>
>>> y = [0.1, 0.05, 0.1, 0.0, 0.05, 0.1, 0.0, 0.6, 0.0, 0.0]
>>> cross_entropy_error(np.array(y), np.array(t))
2.3025840929945458
在第一个例子中,正确标签的输出为 0.6,交叉熵误差为 0.51。在下一个例子中,正确标签的输出小到只有 0.1,交叉熵误差为 2.3。这些结果与我们到目前为止讨论的一致。
小批量学习
对于机器学习问题,训练数据用于训练。准确地说,它意味着找到训练数据的损失函数,并找到使该值尽可能小的参数。因此,必须使用所有训练数据来获得损失函数。如果有 100 条训练数据,则必须将它们 100 个损失函数的和作为指标。
在我们之前描述的损失函数示例中,使用的是单条数据的损失函数。对于交叉熵误差,方程(4.3)可以计算所有训练数据的损失函数之和:
![]() |
(4.3) |
|---|
假设数据元素的数量为 N。tnk 表示第 n 条数据的第 k 个值(ynk 是神经网络的输出,tnk 是标记数据)。虽然这个方程看起来有点复杂,但它只是方程(4.2)的扩展,表示对于 N 条数据的单条数据损失函数。最终,它除以N进行归一化。除以 N 计算每条数据的“平均损失函数”。这个平均值可以作为一致的指标,而不受训练数据量的影响。例如,即使训练数据的数量为 1,000 或 10,000,也可以计算每个数据元素的平均损失函数。
MNIST 数据集包含 60,000 条训练数据。计算所有这些数据的损失函数之和需要一些时间。大数据有时包含数百万或数千万条数据。在这种情况下,计算所有数据的损失函数并不实际。因此,提取部分数据来近似所有数据。此外,在神经网络训练中,选取一些训练数据,并对每一组数据进行训练,这种方式称为小批量(mini-batch)训练。例如,从 60,000 条训练数据中随机选择 100 条数据进行训练。这种训练方式叫做小批量训练。
现在,让我们编写一些代码,从训练数据中随机选择指定数量的数据进行小批量训练。在此之前,以下是加载 MNIST 数据集的代码:
import sys, os
sys.path.append(os.pardir)
import numpy as np
from dataset.mnist import load_mnist
(x_train, t_train), (x_test, t_test) = /
load_mnist(normalize=True, one_hot_label=True)
print(x_train.shape) # (60000, 784)
print(t_train.shape) # (60000, 10)
如第三章《神经网络》中所述,load_mnist函数加载了 MNIST 数据集。它位于本书提供的dataset/mnist.py文件中。该函数加载训练和测试数据。通过指定one_hot_label=True参数,你可以使用独热编码表示法,其中正确标签为 1,其他标签为 0。
当你加载之前的 MNIST 数据时,你会发现训练数据的数量是 60,000,输入数据包含 784 行图像数据(最初为 28x28)。标记数据是具有 10 行的数据。因此,x_train和t_train的形状分别为(60000,784)和(60000,10)。
现在,如何从训练数据中随机提取 10 条数据?我们可以通过使用 NumPy 的np.random.choice()函数编写以下代码:
train_size = x_train.shape[0]
batch_size = 10
batch_mask = np.random.choice(train_size, batch_size)
x_batch = x_train[batch_mask]
t_batch = t_train[batch_mask]
通过使用np.random.choice(),你可以从指定的数字中随机选择所需数量的数字。例如,np.random.choice(60000, 10)会从 0 到小于 60,000 的数字中随机选择 10 个数字。在实际代码中,如这里所示,你可以获取索引作为一个数组,用于选择小批量:
>>> np.random.choice(60000, 10)
array([ 8013, 14666, 58210, 23832, 52091, 10153, 8107, 19410, 27260,
21411])
现在,你可以指定随机选择的索引来提取小批量数据。我们将使用这些小批量来计算损失函数。
注意
为了衡量电视观众人数,并不是所有家庭都参与,而是选定的家庭。例如,通过测量从东京随机选取的 1,000 户家庭的收视情况,你可以大致估算整个东京的收视人数。这 1,000 户家庭的收视情况与整个收视情况并不完全相同,但可以作为一个近似值。就像这里描述的收视情况一样,小批量的损失函数是通过使用样本数据来近似整个数据来衡量的。简而言之,随机选择的小部分数据(小批量)被用作整个训练数据的近似值。
实现交叉熵误差(使用小批量)
我们如何使用小批量数据来实现交叉熵误差?通过改进我们之前实现的交叉熵误差(仅针对一条数据),我们可以轻松实现它。这里,我们将支持单条数据输入和批量数据输入:
def cross_entropy_error(y, t):
if y.ndim == 1:
t = t.reshape(1, t.size)
y = y.reshape(1, y.size)
batch_size = y.shape[0]
return -np.sum(t * np.log(y + 1e-7)) / batch_size
这里,y是神经网络的输出,t是标签数据。如果y是一维的(也就是说,要计算一条数据的交叉熵误差),则数据的形状会发生变化。每条数据的平均交叉熵误差是通过根据批量数据量进行归一化来计算的。
如果标签数据是作为标签提供的(不是以独热表示格式,而是以“2”和“7”等标签形式),我们可以如下实现交叉熵误差:
def cross_entropy_error(y, t):
if y.ndim == 1:
t = t.reshape(1, t.size)
y = y.reshape(1, y.size)
batch_size = y.shape[0]
return -np.sum(np.log(y[np.arange(batch_size), t] + 1e-7)) / batch_size
请注意,如果某个元素在独热表示中t为 0,则其交叉熵误差也为0,你可以忽略此计算。换句话说,如果你能够获取神经网络对正确标签的输出,就可以计算交叉熵误差。因此,对于作为独热表示的t,使用t * np.log(y),而对于作为标签的t,使用np.log(y[np.arange(batch_size), t])进行相同的处理(这里为了可视化,已省略“一个非常小的值1e-7”的描述)。
作为参考,我们可以简要介绍一下np.log( y[np.arange(batch_size), t] )。np.arange(batch_size)会生成一个从 0 到batch_size-1的数组。当batch_size为 5 时,np.arange(batch_size)生成一个 NumPy 数组,[0, 1, 2, 3, 4]。t包含标签,如[2, 7, 0, 9, 4],而y[np.arange(batch_size), t]则提取每个数据的正确标签对应的神经网络输出(在这个例子中,y[np.arange(batch_size), t]生成的 NumPy 数组为[y[0,2], y[1,7], y[2,0], y[3,9], y[4,4]])。
为什么我们要配置损失函数?
有些人可能会想,为什么我们要引入损失函数?例如,在数字识别的情况下,我们希望参数提高识别准确率。引入损失函数难道不是多余的工作吗?我们的目标是实现一个最大化识别准确率的神经网络。那么,难道我们不应该使用“识别准确率”作为评分标准吗?
你可以通过关注“导数”在神经网络训练中的作用,来找到这个问题的答案。下一节将详细解释这一点。神经网络训练的目标是寻找最优的参数(权重和偏置),使损失函数的值最小。为了寻找损失函数最小的值,需要计算某个参数的导数(准确来说是梯度),并根据导数的值逐步更新参数值。
例如,假设这里存在一个虚拟的神经网络。我们将关注神经网络中的一个权重参数。在这里,权重参数的损失函数的导数表示当权重参数的值稍微改变时,损失函数的变化情况。如果导数变为负值,则可以通过将权重参数沿正方向调整来减少损失函数。另一方面,如果导数是正值,则可以通过将权重参数沿负方向调整来减少损失函数。然而,当导数值为 0 时,不管如何移动权重参数,损失函数的值都不会改变。此时,权重参数的更新将停止。
我们不能使用识别准确率作为评分标准,因为在几乎所有位置,导数都会变为 0,导致参数无法更新。现在,让我们简洁地总结一下这一点。
注意
在训练神经网络时,我们不应使用识别准确率作为评分标准。原因是,如果使用识别准确率作为评分标准,大多数地方的参数导数将为零。
那么,为什么把识别精度作为评分会导致参数的导数在几乎所有位置都为 0 呢?为了说明这一点,我们考虑另一个例子。假设一个神经网络能识别训练数据中的 100 个项目中的 32 个。这意味着识别精度是 32%。如果我们将识别精度作为评分,稍微改变权重参数,它仍会保持在 32%,不会发生变化。稍微调整参数不会提高识别精度。即使识别精度提高,变化也不会是连续的,比如 32.0123…%,而是突发性的,比如 33% 和 34%。另一方面,如果使用损失函数作为评分,损失函数的当前值表示为一个值,比如 0.92543…稍微改变参数值也会使损失函数连续变化,比如 0.93432…
稍微调整参数只会稍微改变识别精度,且任何变化都是不连续和突发的。这对于激活函数的“阶跃函数”也是一样的。如果使用阶跃函数作为激活函数,神经网络也无法正确学习,原因相同。阶跃函数的导数在几乎所有位置(除 0 外)都为 0,如图 4.4所示。当使用阶跃函数时,参数的微小变化会被阶跃函数抹去,损失函数的值不会发生变化,即使你将其用作评分。
阶跃函数只在某些时刻发生变化,就像石上水流或稻草人。另一方面,Sigmoid 函数的导数(切线)会连续变化,纵轴的输出(值)也会不断变化,曲线的梯度也在不断变化,如图 4.4所示。简而言之,Sigmoid 函数的导数在任何位置都不为 0。这对于神经网络的“训练”至关重要。因为梯度从不为 0,神经网络可以正确学习:

图 4.4:阶跃函数与 Sigmoid 函数——阶跃函数的梯度在几乎所有位置为 0,而 Sigmoid 函数(切线)的梯度从不为 0
数值微分
梯度法使用梯度信息来确定前进的方向。本节将介绍梯度是什么以及它的特性,从“导数”开始。
导数
例如,假设你在 10 分钟内跑了 2 公里,刚开始全程马拉松。你可以计算出速度为 2 / 10 = 0.2 [公里/分钟]。你以每分钟 0.2 公里的速度跑步。
在这个例子中,我们计算了“行驶距离”随“时间”的变化量。严格来说,这个计算表示的是 10 分钟的“平均速度”,因为你在 10 分钟内跑了 2 公里。导数表示的是在“某一时刻”的变化量。因此,通过缩小 10 分钟的时间(比如过去 1 分钟的距离、过去 1 秒的距离、过去 0.1 秒的距离,等等),你可以得到某一时刻的变化量(瞬时速度)。
因此,导数表示在某一时刻的变化量。这个定义由以下方程表示:
![]() |
(4.4) |
|---|
方程(4.4)表示一个函数的导数。左侧的
表示* f(x)* 关于x的导数——即 f(x)对 x 的变化程度。方程(4.4)表示的导数表明了由于x的“微小变化”,函数f(x)的值如何发生变化。在这里,微小变化h被无限接近 0,这表示为
。
让我们编写一个程序,根据方程(4.4)来求取一个函数的导数。为了直接实现方程(4.4),你可以为计算目的给 h 赋一个小值:
# Bad implementation sample
def numerical_diff(f, x):
h = 10e-50
return (f(x+h) - f(x)) / h
该函数名为numerical_diff(f, x),即数值微分。它接受两个参数:函数 f 和函数 f 的自变量 x。这个实现看起来是正确的,但可以做出两个改进。
前面的实现使用了一个小值10e-50(即“0.00...1”包含 50 个零)作为 h,因为我们希望使用尽可能小的值作为 h(如果可能的话,我们希望将 h 无限接近 0)。但是这里出现了舍入误差的问题。舍入误差是指在最终的计算结果中,因忽略小范围内的数字(例如,忽略八位或更多小数位)而产生的误差。以下例子展示了 Python 中的舍入误差:
>>> np.float32(1e-50)
0.0
当你在 float32 类型(32 位浮点数)中表示1e-50时,值变成了 0.0。你无法正确表示它。使用过小的值会导致计算机计算时出现问题。现在,这是第一个改进。你可以将 10−4 作为小值 h 来使用。已知值约为 10−4 时会产生较好的结果。
第二个改进是在函数 f 的差异方面。前面的实现计算了 x + h 和 x 之间函数 f 的差异。你应该注意到,这种计算首先会引入误差。如图 4.5所示,"真实导数"对应于函数在 x 位置的梯度(称为切线),而这个实现中的导数对应于 (x + h) 和 x 之间的梯度。因此,真实导数(真实切线)与这个实现的值不完全相同。这个差异的原因是你无法将 h 无限接近于 0:

图 4.5: 真实导数(真实切线)和数值微分(通过近似获得的切线)在数值上是不同的
如图 4.5所示,数值微分包含误差。为了减小这个误差,你可以计算函数(f)在 (x + h) 和 (x - h) 之间的差异。这种差异称为中心差分,因为它是围绕 x 计算的(另一方面,(x + h) 和 x 之间的差异称为前向差分)。现在,让我们基于这两个改进实现数值微分(数值梯度):
def numerical_diff(f, x):
h = 1e-4 # 0.0001
return (f(x+h) - f(x-h)) / (2*h)
注意
如前面的代码所示,通过使用非常小的值差异来计算导数被称为数值微分。另一方面,通过扩展获得导数称为“解析解”或“解析求导”,例如,使用“analytic”一词。你可以通过解析方式得到 y = x² 的导数,如
。因此,你可以计算出 y 对 x 的导数为 2,结果是 4。解析导数是没有误差的“真实导数”。
数值微分的示例
让我们通过数值微分来求导一个简单的函数。第一个例子是由以下方程表示的二次函数:
![]() |
(4.5) |
|---|
在 Python 中实现方程(4.5)如下:
def function_1(x):
return 0.01*x**2 + 0.1*x
绘制这个函数的图像。以下是绘制图像的代码和结果图像(图 4.6):
import numpy as np
import matplotlib.pylab as plt
x = np.arange(0.0, 20.0, 0.1) # The array x containing 0 to 20 in increments of 0.1
y = function_1(x)
plt.xlabel("x")
plt.ylabel("f(x)")
plt.plot(x, y)
plt.show()
现在计算当 x=5 和 x=10 时的函数微分:
>>> numerical_diff(function_1, 5)
0.1999999999990898
>>> numerical_diff(function_1, 10)
0.2999999999986347
这里计算的微分是 f(x) 对 x 的变化量,它对应于函数的梯度。顺便提一下,f(x) = 0.01x² + 0.1x* 的解析解是
= 0.02x + 0.1。当 x=5 和 x=10 时,真实导数分别为 0.2 和 0.3。它们与数值微分的结果不完全相同,但误差非常小。实际上,误差小到可以认为它们几乎是相同的值:

图 4.6:图形 f (x) = 0.01x2 + 0.1x
我们将使用前面的数值微分结果来绘制梯度为数值微分值的直线图。结果如图 4.7所示。在这里,您可以看到导数对应于函数的切线(源代码位于ch04/gradient_1d.py):

图 4.7:当 x = 5 和 x = 10 时的切线 – 使用数值微分的值作为直线的梯度
偏导数
接下来,我们来看方程(4.6)表示的函数。这个简单的方程计算了参数的平方和。请注意,它有两个变量,不像前面的例子那样只有一个变量:
![]() |
(4.6) |
|---|
你可以在 Python 中这样实现:
def function_2(x):
return x[0]**2 + x[1]**2
# or return np.sum(x**2)
这里假设传递的是 NumPy 数组作为参数。该函数简单地对每个 NumPy 数组的元素进行平方并求和(np.sum(x**2)也能实现相同的处理)。现在,让我们绘制这个函数的图形。该三维图形如下所示:

图 4.8:图形 ![28]()
现在,我们要计算方程(4.6)的导数。这里,请注意方程(4.6)有两个变量。因此,您必须指定对于 x0 和 x1 中的哪一个变量计算微分。由多个变量组成的函数的导数称为偏导数。它们表示为
。
为了说明这一点,考虑以下两个偏导数问题及其解决方案:
x0 当 x0 = 3 且 x1 = 4 时:
>>> def function_tmp1(x0):
... return x0*x0 + 4.0**2.0
...
>>> numerical_diff(function_tmp1, 3.0)
6.00000000000378
x1 当 x0 = 3 且 x1 = 4 时:
>>> def function_tmp2(x1):
... return 3.0**2.0 + x1*x1
...
>>> numerical_diff(function_tmp2, 4.0)
7.999999999999119
为了解决这些问题,定义了一个只有一个变量的函数,并计算该函数的导数。例如,当x1=4时,定义了该函数,其中只有一个变量x0,并将其传递给该函数进行数值微分。根据结果,答案是6.00000000000378,答案是7.999999999999119。它们与解析微分的解大致相同。
这样,偏导数计算了某一位置的梯度,就像对一个变量的微分一样。然而,对于偏导数来说,某一个变量是目标变量,其他变量则固定在某个特定值。在前面的实现中,定义了一个新函数来保持其他变量在特定值上。这个新定义的函数被传递给先前的数值微分函数来计算偏导数。
梯度
在前面的例子中,分别计算了x0 和x1 的偏导数。现在,我们希望计算x0 和x1 的偏导数总和。例如,假设我们要计算当x0 = 3和x1 = 4时(x0, x1)的偏导数,如
。表示所有变量偏导数总和的向量,如
,被称为梯度。你可以通过以下方式实现梯度:
def numerical_gradient(f, x):
h = 1e-4 # 0.0001
grad = np.zeros_like(x) # Generate an array with the same shape as x
for idx in range(x.size):
tmp_val = x[idx]
# Calculate f(x+h)
x[idx] = tmp_val + h
fxh1 = f(x)
# Calculate f(x-h)
x[idx] = tmp_val - h
fxh2 = f(x)
grad[idx] = (fxh1 - fxh2) / (2*h)
x[idx] = tmp_val # Restore the original value
return grad
实现numerical_gradient(f, x)函数看起来有点复杂,但过程几乎和单变量数值微分中的步骤一样。请注意,np.zeros_like(x)生成一个与x形状相同且元素全为零的数组。
numerical_gradient(f, x)函数接受f(函数)和x(NumPy 数组)作为参数,并为 NumPy 数组x中的每个元素获取数值微分。现在,让我们使用这个函数来计算梯度。在这里,我们将获得点(3, 4),(0, 2)和(3, 0)处的梯度:
>>> numerical_gradient(function_2, np.array([3.0, 4.0]))
array([ 6., 8.])
>>> numerical_gradient(function_2, np.array([0.0, 2.0]))
array([ 0., 4.])
>>> numerical_gradient(function_2, np.array([3.0, 0.0]))
array([ 6., 0.])
注
实际结果是[6.0000000000037801, 7.9999999999991189],但返回的是[6., 8.]。这是因为返回的 NumPy 数组进行了格式化,以便更清晰地显示数值。
因此,我们可以计算(x0, x1)每个点的梯度。上面的例子显示,点(3, 4)处的梯度是(6, 8),点(0, 2)处的梯度是(0, 4),点(3, 0)处的梯度是(6, 0)。这些梯度意味着什么呢?为了理解这一点,让我们看看
的梯度。在这里,我们将使梯度变为负数,并绘制向量(源代码位于ch04/gradient_2d.py)。
的梯度显示为指向最低点的向量(箭头),如图 4.9所示。在图 4.9中,梯度似乎指向函数f(x0, x1)的“最低位置(最小值)”。就像指南针一样,箭头指向一个点。它们距离“最低位置”越远,箭头的大小就越大:

图 4.9:
的梯度
在图 4.9所示的示例中,梯度指向最低位置,但情况并非总是如此。事实上,梯度在每个位置都指向较低的方向。更准确地说,梯度的方向是在每个位置上使函数值减少最快的方向。这是一个重要的概念,请务必记住这一点。
梯度法
许多机器学习问题在训练过程中需要寻找最优参数。神经网络在训练过程中也需要找到最优参数(权重和偏差)。这里的最优参数是指损失函数取最小值时的参数值。然而,损失函数可能很复杂,参数空间也很庞大,我们无法直接猜测最小值的所在位置。梯度法利用梯度来寻找函数的最小值(或最小可能值)。
梯度表示在每个位置上最能减少函数值的方向。因此,梯度指向的位置是否真的是函数的最小值,换句话说,梯度指向的方向是否真的是正确的方向,是无法保证的。实际上,在复杂的函数中,梯度指向的方向在大多数情况下并不是最小值所在的方向。
注意
在局部最小值、最小值以及一个被称为函数鞍点的点处,梯度为 0。局部最小值是局部范围内的最小值,即在一个有限范围内的最小值。鞍点是在一个方向上为局部最大值,在另一个方向上为局部最小值的位置。梯度法寻找的是梯度为 0 的位置,但这个位置并不总是全局最小值(它可能是局部最小值或鞍点)。当一个函数具有复杂且扭曲的形状时,学习可能会进入一个(几乎)平坦的区域,并且可能会出现一个被称为“平台期”的停滞期,导致训练停滞不前。
即使梯度的方向并不总是指向全局最小值,沿着这个方向移动也可以最大程度地减少函数值。因此,若要寻找最小值的位置或寻找函数具有最小可能值的位置,应该根据梯度信息来确定移动的方向。
现在,让我们来看一下梯度法。在梯度法中,你会沿着梯度方向从当前位置移动一个固定的距离。通过这样做,你会在新位置上获得一个梯度,然后再次沿着梯度方向移动。这样,你会反复沿梯度方向移动。通过反复沿梯度方向移动逐渐减少函数值的过程被称为梯度法。这种方法常用于机器学习中的优化问题,尤其是在神经网络的训练中。
注意
如果梯度法用于寻找最小值或最大值,它有另一个名称。准确来说,寻找最小值的方法称为 梯度下降法,而寻找最大值的方法称为 梯度上升法。然而,反转损失函数的符号可以将最小值问题转换为最大值问题。因此,“下降”与“上升”之间的区别并不是特别重要。通常,梯度下降法在神经网络(深度学习)中被广泛使用。
现在,让我们用一个公式表示梯度法。公式 (4.7) 展示了一个梯度法:
![]() |
(4.7) |
|---|
在公式 (4.7) 中,η 调整更新的幅度。这在神经网络中称为 学习率。学习率决定了需要学习多少以及如何更新参数。
公式 (4.7) 展示了一个训练实例的更新方程,步骤会重复进行。每一步都按照公式 (4.7) 更新变量值,并且这个步骤会重复多次,以逐步减小函数的值。这个例子有两个变量,但即使增加了变量数量,也使用类似的方程——每个变量的偏导数——来进行更新。
你必须提前指定学习率的值,如 0.01 或 0.001。通常,如果这个值过大或过小,就无法达到“好的位置”。在神经网络训练中,我们通常通过调整学习率来检查训练是否成功。
现在,让我们在 Python 中实现一个梯度下降法。可以按如下方式进行:
def gradient_descent(f, init_x, lr=0.01, step_num=100):
x = init_x
for i in range(step_num):
grad = numerical_gradient(f, x)
x -= lr * grad
return x
f 参数是需要优化的函数,init_x 参数是初始值,lr 参数是学习率,step_num 参数是梯度法中重复的次数。通过 numerical_gradient(f, x) 获取函数的梯度,并将其乘以学习率进行更新,重复指定的 step_num 次数。
你可以使用此函数获取函数的局部最小值,甚至在运气好的时候得到全局最小值。现在,让我们尝试解决一个问题。
问题:使用梯度法获取
的最小值:
>>> def function_2(x):
... return x[0]**2 + x[1]**2
...
>>> init_x = np.array([-3.0, 4.0])
>>> gradient_descent(function_2, init_x=init_x, lr=0.1, step_num=100)
array([ -6.11110793e-10, 8.14814391e-10])
在这里,指定初始值为 (-3.0, 4.0),并通过梯度法开始寻找最小值。最终结果为 (-6.1e-10, 8.1e-10),几乎接近 (0, 0)。实际上,真实的最小值是 (0, 0)。通过使用梯度法,你成功地获得了几乎正确的结果。图 4.10 显示了使用梯度法更新的过程。原点是最低点,你可以看到结果逐渐接近它。绘制该图的源代码位于 ch04/gradient_method.py(ch04/gradient_method.py 并未显示虚线,这些虚线表示图中的等高线):

图 4.10:使用梯度方法更新
——虚线表示函数的等高线
如前所述,过大或过小的学习率都无法取得良好的结果。让我们在这里做一些关于这两种情况的实验:
# When the learning rate is too large: lr=10.0
>>> init_x = np.array([-3.0, 4.0])
>>> gradient_descent(function_2, init_x=init_x, lr=10.0, step_num=100)
array([ -2.58983747e+13, -1.29524862e+12])
# When the learning rate is too small: lr=1e-10
>>> init_x = np.array([-3.0, 4.0])
>>> gradient_descent(function_2, init_x=init_x, lr=1e-10, step_num=100)
array([-2.99999994, 3.99999992])
正如这个实验所示,如果学习率过大,结果会发散到一个很大的值。另一方面,如果学习率过小,则几乎不会发生更新。设置合适的学习率非常重要。
注意
类似学习率这样的参数被称为超参数。它与神经网络的参数(权重和偏置)在特性上有所不同。神经网络中的权重参数可以通过训练数据和训练算法自动获得,而超参数必须手动指定。通常,你需要将超参数设置为不同的值,找到一个能够良好训练的值。
神经网络的梯度
在神经网络训练中,你还必须计算梯度。这里的梯度是损失函数对于权重参数的梯度。例如,假设一个神经网络只有权重 W(2x3 数组),损失函数是 L。在这种情况下,我们可以将梯度表示为
。以下方程显示了这一点:
![]() |
(4.8) |
|---|
每个元素的
是每个元素的偏导数。例如,第一行第一列的元素
表示 w11 的微小变化如何影响损失函数 L。这里重要的是,
的形状与 W 的形状相同。实际上,在公式(4.8)中,W 和
的形状是相同的(2x3)。
现在,让我们实现一个程序,通过使用一个简单的神经网络来计算梯度。为此,我们将实现一个名为simpleNet的类(源代码位于ch04/gradient_simplenet.py):
import sys, os
sys.path.append(os.pardir)
import numpy as np
from common.functions import softmax, cross_entropy_error
from common.gradient import numerical_gradient
class simpleNet:
def __ init __ (self):
self.W = np.random.randn(2,3) # Initialize with a Gaussian distribution
def predict(self, x):
return np.dot(x, self.W)
def loss(self, x, t):
z = self.predict(x)
y = softmax(z)
loss = cross_entropy_error(y, t)
return loss
这里使用了common/functions.py中的softmax和cross_entropy_error方法。同时也使用了common/gradient.py中的numerical_gradient方法。simpleNet类只有一个实例变量,即形状为 2x3 的权重参数。它有两个方法:一个是用于预测的predict(x),另一个是用于计算损失函数值的loss(x, t)。这里,x参数是输入数据,t参数是正确标签。现在,让我们尝试使用simpleNet:
>>> net = simpleNet()
>>> print(net.W) # Weight parameters
[[ 0.47355232 0.9977393 0.84668094]
[ 0.85557411 0.03563661 0.69422093]]
>>>
>>> x = np.array([0.6, 0.9])
>>> p = net.predict(x)
>>> print(p)
[ 1.05414809 0.63071653 1.1328074]
>>> np.argmax(p) # Index for the maximum value
2
>>>
>>> t = np.array([0, 0, 1]) # Correct label
>>> net.loss(x, t)
0.92806853663411326
接下来,让我们使用numerical_gradient(f, x)来计算梯度。这里定义的f(W)函数接受一个虚拟参数W。因为f(x)函数在numerical_gradient(f, x)中执行,所以为了保持一致,f(W)被定义:
>>> def f(W):
... return net.loss(x, t)
...
>>> dW = numerical_gradient(f, net.W)
>>> print(dW)
[[ 0.21924763 0.14356247 -0.36281009]
[ 0.32887144 0.2153437 -0.54421514]]
numerical_gradient(f, x) 的 f 参数是一个函数,x 参数是函数 f 的输入参数。因此,这里定义了一个新函数 f,它以 net.W 作为参数并计算损失函数。新定义的函数被传递给 numerical_gradient(f, x)。
numerical_gradient(f, net.W) 返回 dW,它是一个二维的 2x3 数组。dW 显示例如:
对应的
大约为 0.2。这表明,当 w11 增加 h 时,损失函数的值增加了 0.2h。
大约为 -0.5,这表明当 w23 增加 h 时,损失函数的值减少了 0.5h。因此,为了减少损失函数,应该将 w23 更新为正方向,而 w11 更新为负方向。你还可以看到,更新 w23 对减少损失函数的贡献大于更新 w11。
在前面的实现中,新函数写成了 def f(x):…。在 Python 中,您可以使用 lambda 表达式来编写并实现一个简单的函数,如下所示:
>>> f = lambda w: net.loss(x, t)
>>> dW = numerical_gradient(f, net.W)
在获得神经网络的梯度后,您只需使用梯度方法更新权重参数。在下一节中,我们将为一个两层神经网络实现所有这些训练过程。
注意
我们在这里使用的 numerical_gradient() 函数与之前处理多维数组(如权重参数 W)的实现稍有不同。然而,这些更改非常简单,仅用于处理多维数组。有关详细信息,请参考源代码(common/gradient.py)。
实现训练算法
到目前为止,我们已经学习了神经网络训练的基础知识。诸如“损失函数”、“小批量”、“梯度”和“梯度下降法”等重要关键词已经相继出现。接下来,我们将回顾一下神经网络训练的流程。让我们一起回顾神经网络训练的步骤。
假设条件
神经网络具有可调的权重和偏差。调整它们以适应训练数据的过程称为“训练”。神经网络训练包括四个步骤。
第 1 步(小批量)
从训练数据中随机选择一些数据。选中的数据称为小批量。这里的目的是减少小批量的损失函数值。
第 2 步(计算梯度)
为了减少小批量的损失函数,计算每个权重参数的梯度。梯度表示最能减少损失函数值的方向。
第 3 步(更新参数)
在梯度方向上稍微更新权重参数。
第 4 步(重复)
重复 步骤 1、2 和 3。
上述四个步骤用于神经网络训练。此方法使用梯度下降法来更新参数。由于这里使用的数据是随机选取的小批量数据,因此称之为随机梯度下降。 “随机”意味着“随机选择数据。”因此,随机梯度下降表示“对随机选取的数据应用梯度下降法”。在许多深度学习框架中,随机梯度下降通常作为 SGD 函数实现,其名称源自其首字母。
现在,让我们实现一个实际学习手写数字的神经网络。在这里,一个两层神经网络(包含一个隐藏层)将使用 MNIST 数据集进行训练。
作为一个类的两层神经网络
首先,让我们实现一个两层神经网络作为类。这个类被命名为 TwoLayerNet,其实现如下(TwoLayerNet 的实现基于斯坦福大学 CS231n 课程《卷积神经网络与视觉识别》(cs231n.github.io/)提供的 Python 源代码。源代码位于 ch04/two_layer_net.py):
import sys, os
sys.path.append(os.pardir)
from common.functions import *
from common.gradient import numerical_gradient
class TwoLayerNet:
def __ init __ (self, input_size, hidden_size, output_size,
weight_init_std=0.01):
# Initialize weights
self.params = {}
self.params['W1'] = weight_init_std * /
np.random.randn(input_size, hidden_size)
self.params['b1'] = np.zeros(hidden_size)
self.params['W2'] = weight_init_std * /
np.random.randn(hidden_size, output_size)
self.params['b2'] = np.zeros(output_size)
def predict(self, x):
W1, W2 = self.params['W1'], self.params['W2']
b1, b2 = self.params['b1'], self.params['b2']
a1 = np.dot(x, W1) + b1
z1 = sigmoid(a1)
a2 = np.dot(z1, W2) + b2
y = softmax(a2)
return y
# x: input data, t: label data
def loss(self, x, t):
y = self.predict(x)
return cross_entropy_error(y, t)
def accuracy(self, x, t):
y = self.predict(x)
y = np.argmax(y, axis=1)
t = np.argmax(t, axis=1)
accuracy = np.sum(y == t) / float(x.shape[0])
return accuracy
# x: input data, t: teacher data
def numerical_gradient(self, x, t):
loss_W = lambda W: self.loss(x, t)
grads = {}
grads['W1'] = numerical_gradient(loss_W, self.params['W1'])
grads['b1'] = numerical_gradient(loss_W, self.params['b1'])
grads['W2'] = numerical_gradient(loss_W, self.params['W2'])
grads['b2'] = numerical_gradient(loss_W, self.params['b2'])
return grads
这个类的实现稍微有些长,但并没有出现什么新的内容。它与上一章中讲解的神经网络前向处理实现有很多相似之处。首先,让我们来看一下在这个类中使用的变量和方法。表 4.1 显示了重要的变量,而 表 4.2 显示了所有的方法:

表 4.1: 在 TwoLayerNet 类中使用的变量

表 4.2: 在 TwoLayerNet 类中使用的方法
TwoLayerNet 类有两个字典变量,params 和 grads,作为实例变量。params 变量包含权重参数。例如,第 1 层的权重参数存储在 params['W1'] 中,类型为 NumPy 数组。你可以使用 params['b1'] 访问第 1 层的偏置。以下是一个示例:
net = TwoLayerNet(input_size=784, hidden_size=100, output_size=10)
net.params['W1'].shape # (784, 100)
net.params['b1'].shape # (100,)
net.params['W2'].shape # (100, 10)
net.params['b2'].shape # (10,)
如图所示,params 变量包含了该网络所需的所有参数。params 变量中的权重参数用于预测(前向处理)。你可以按如下方式进行预测:
x = np.random.rand(100, 784) # Dummy input data (for 100 images)
y = net.predict(x)
grads 变量包含每个参数的梯度,以便与 params 变量一一对应。当你使用 numerical_gradient() 方法计算梯度时,梯度信息会存储在 grads 变量中,如下所示:
x = p.random.rand(100, 784) # Dummy input data (for 100 images)
t = np.random.rand(100, 10) # Dummy correct label (for 100 images)
grads = net.numerical_gradient(x, t) # Calculate gradients
grads['W1'].shape # (784, 100)
grads['b1'].shape # (100,)
grads['W2'].shape # (100, 10)
grads['b2'].shape # (10,)
现在,让我们看一下 TwoLayerNet 中方法的实现。__init__ (self, input_size, hidden_size, output_size) 方法是类的初始化方法(在生成 TwoLayerNet 时调用)。参数依次是输入层、隐藏层和输出层的神经元数量。从左到右排列。对于手写数字识别,提供了总共 784 张 28x28 大小的输入图像,并返回 10 个类别。因此,我们指定了 input_size=784 和 output_size=10 参数,并设置适当的 hidden_size 作为隐藏层的数量。
该初始化方法还初始化了权重参数。确定初始权重参数的值对于神经网络训练的成功至关重要。我们将在后面详细讨论权重参数的初始化。这里,权重通过使用基于高斯分布的随机数来初始化,偏置被初始化为 0。predict(self, x) 和 accuracy(self, x, t) 与上一章中我们看到的神经网络预测实现几乎相同。如果你有任何疑问,请参考上一章。loss(self, x, t) 方法计算损失函数的值。它基于 predict() 的结果和正确标签来获得交叉熵误差。
剩下的 numerical_gradient(self, x, t) 方法计算每个参数的梯度。它使用数值微分来计算每个参数的损失函数的梯度。gradient(self, x, t) 方法将在下一章中实现。
注意
numerical_gradient(self, x, t) 使用数值微分计算参数的梯度。在下一章中,我们将讨论如何使用反向传播快速计算梯度,反向传播返回的结果几乎与使用数值微分得到的结果相同,但处理速度更快。通过反向传播获取梯度的方法将在下一章实现为 gradient(self, x, t)。如果你想节省时间,可以使用 gradient(self, x, t) 替代 numerical_gradient(self, x, t),因为神经网络训练本身就需要一定的时间。
实现小批量训练
在这里,我们将使用小批量训练来实现神经网络训练。在小批量训练中,我们从训练数据中随机提取一些数据(称为小批量),并使用它来通过梯度方法更新参数。我们将使用 MNIST 数据集(源代码位于 ch04/train_neuralnet.py)来进行 TwoLayerNet 类的训练:
import numpy as np
from dataset.mnist import load_mnist
from two_layer_net import TwoLayerNet
(x_train, t_train), (x_test, t_test) = \
load_mnist(normalize=True, one_hot_label=True)
train_loss_list = []
# Hyper-parameters
iters_num = 10000
train_size = x_train.shape[0]
batch_size = 100
learning_rate = 0.1
network = TwoLayerNet(input_size=784, hidden_size=50, output_size=10)
for i in range(iters_num):
# Obtain a mini-batch
batch_mask = np.random.choice(train_size, batch_size)
x_batch = x_train[batch_mask]
t_batch = t_train[batch_mask]
# Calculate a gradient
grad = network.numerical_gradient(x_batch, t_batch)
# grad = network.gradient(x_batch, t_batch) # fast version!
# Update the parameters
for key in ('W1', 'b1', 'W2', 'b2'):
network.params[key] -= learning_rate * grad[key]
# Record learning progress
loss = network.loss(x_batch, t_batch)
train_loss_list.append(loss)
这里,mini-batch 的大小为 100。每次,从 60,000 条训练数据中随机抽取 100 条数据(图像数据和正确标签数据)。然后,针对该 mini-batch 计算梯度,并使用随机梯度下降(SGD)更新参数。这里,梯度方法的更新次数,即迭代次数为 10,000。每次更新时,都会计算训练数据的损失函数,并将其值添加到数组中。图 4.11显示了损失函数值变化的图形。
图 4.11显示,随着训练次数的增加,损失函数的值逐渐减小。这表明训练成功。神经网络的权重参数正在逐渐适应数据。神经网络确实在学习。通过反复接触数据,神经网络正在接近最优的权重参数:

图 4.11:损失函数的变化——左侧图像显示了 10,000 次迭代的变化,右侧图像显示了 1,000 次迭代的变化
使用测试数据进行评估
图 4.11的结果表明,反复训练数据会逐渐减小损失函数的值。然而,损失函数的值是“mini-batch 训练数据的损失函数值”。训练数据的损失函数值的减小表明神经网络正在良好地学习。然而,这个结果并不证明它可以像处理当前数据集一样处理不同的数据集。
在神经网络训练中,我们必须检查是否能正确识别训练数据以外的数据。我们必须检查是否没有发生“过拟合”。过拟合意味着只能正确识别训练数据中包含的图像,而无法识别那些未包含的图像。
神经网络训练的目标是获得泛化能力。为此,我们必须使用不包含在训练数据中的数据来评估神经网络的泛化能力。在接下来的实现中,我们将在训练过程中定期记录测试数据和训练数据的识别准确率。我们将为每个 epoch 记录测试数据和训练数据的识别准确率。
注意
一个 epoch 是一个单位。一个 epoch 表示所有训练数据用于训练时的迭代次数。例如,假设使用 100 个 mini-batch 来学习 10,000 条训练数据。经过 100 次随机梯度下降方法的重复,所有训练数据都会被看到。在这种情况下,100 次迭代 = 1 个 epoch。
现在,我们将稍微调整前面的实现,以获得正确的评估。这里,和之前实现的不同之处已用粗体标出:
import numpy as np
from dataset.mnist import load_mnist
from two_layer_net import TwoLayerNet
(x_train, t_train), (x_test, t_test) = \
load_mnist(normalize=True, one_hot_label=True)
train_loss_list = []
train_acc_list = []
test_acc_list = []
# Number of iterations per epoch
iter_per_epoch = max(train_size / batch_size, 1)
# Hyper-parameters
iters_num = 10000
batch_size = 100
learning_rate = 0.1
network = TwoLayerNet(input_size=784, hidden_size=50,
output_size=10)
for i in range(iters_num):
# Obtain a mini-batch
batch_mask = np.random.choice(train_size, batch_size)
x_batch = x_train[batch_mask]
t_batch = t_train[batch_mask]
# Calculate a gradient
grad = network.numerical_gradient(x_batch, t_batch)
# grad = network.gradient(x_batch, t_batch) # Quick version!
# Update the parameters
for key in ('W1', 'b1', 'W2', 'b2'):
network.params[key] -= learning_rate * grad[key]
loss = network.loss(x_batch, t_batch)
train_loss_list.append(loss)
# Calculate recognition accuracy for each epoch
if i % iter_per_epoch == 0:
train_acc = network.accuracy(x_train, t_train)
test_acc = network.accuracy(x_test, t_test)
train_acc_list.append(train_acc)
test_acc_list.append(test_acc)
print("train acc, test acc | " + str(train_acc) + " , " + str(test_acc))
在前面的例子中,我们计算了所有训练数据和测试数据的识别精度,并在每个训练轮次中记录了结果。每个训练轮次都会计算一次识别精度,因为在for语句中重复计算会消耗时间。而且,我们不需要频繁记录识别精度(我们只需要识别精度的近似变化)。因此,记录了每个训练轮次的训练数据识别精度变化。
现在,让我们通过图表展示前面代码的结果:

图 4.12:训练数据和测试数据的识别精度变化。横轴表示训练轮次
在图 4.12中,实线表示训练数据的识别精度,而虚线表示测试数据的识别精度。如您所见,随着训练次数的增加(训练的进展),训练数据和测试数据的识别精度都得到了提高。在这里,我们可以看到两条识别精度曲线几乎重合,这表明没有发生过拟合。
总结
本章介绍了神经网络训练。首先,我们介绍了一个叫做损失函数的得分,使神经网络能够学习。神经网络训练的目标是发现能够使损失函数值最小的权重参数。接着,我们学习了如何使用函数的梯度(即梯度法)来发现损失函数的最小值。本章涵盖了以下要点:
-
在机器学习中,我们使用训练数据和测试数据。
-
训练数据用于训练,而测试数据用于评估训练模型的泛化能力。
-
损失函数作为神经网络训练中的得分函数使用。权重参数通过更新,使得损失函数的值逐渐减小。
-
为了更新权重参数,我们使用它们的梯度,按照梯度方向反复更新它们的值。
-
当给定非常小的值时,基于差异来计算导数的过程称为数值微分。
-
您可以使用数值微分来获得权重参数的梯度。
-
数值微分需要时间来计算,但其实现较为简单。另一方面,反向传播将在下一章中描述,虽然它稍微复杂一些,但能够快速计算梯度。
第六章:5. 反向传播
上一章讲解了神经网络的训练。在那一章中,神经网络中权重参数的梯度(即损失函数对权重参数的梯度)是通过数值微分获得的。数值微分很简单,且易于实现,但它的缺点是计算速度较慢。本章介绍了反向传播,它是一种更高效地计算权重参数梯度的方法。
有两种方法可以正确理解反向传播。一种使用“方程式”,另一种使用计算图。前者是一种常见方法,许多关于机器学习的书籍通过专注于公式来扩展这一点。这种方式很好,因为它严格且简单,但可能隐藏了重要细节,或者最终只是一些毫无意义的方程式列表。
因此,本章将使用计算图,以便让你“直观”地理解反向传播。编写代码将进一步加深你的理解,并使你信服这一点。使用计算图来解释反向传播的想法基于 Andrej Karpathy 的博客(黑客的神经网络指南,(karpathy.github.io/neuralnets/)) 以及他和斯坦福大学的李飞飞教授提供的深度学习课程(CS231n: 用于视觉识别的卷积神经网络,cs231n.github.io/)。
计算图
计算图展示了计算过程。这个图作为数据结构图表示,由多个节点和边(即连接节点的直线)组成。在这一节中,我们将解决简单的问题,以便在逐步进入更复杂的反向传播之前,先熟悉计算图的使用。
使用计算图解决问题
本节中的问题足够简单,你可以用心算解决它们,但这里的目的是让你熟悉计算图。学习使用计算图将对我们后续要讨论的复杂计算非常有帮助,因此首先掌握如何使用计算图非常重要。
问题 1:太郎买了两个每个 100 日元的苹果。如果加上 10%的消费税,请计算他支付的金额。
计算图展示了带有节点和箭头的计算过程。节点用圆圈表示,运算在其中描述。箭头上方的中间结果显示从左到右流动的每个节点的计算结果。下图展示了解决问题 1 的计算图:

图 5.1:使用计算图解答问题 1
如上图所示,100 日元的苹果流向“x2”节点后变成了 200 日元,然后传递到下一个节点。接着,200 日元传递到“× 1.1”节点后变为 220 日元。这个计算图的结果显示答案是 220 日元。
在上面的图示中,每个圆圈内包含了“× 2”或“× 1.1”作为一个操作。你也可以只在圆圈中放入“x”来表示操作。在这种情况下,如下图所示,你可以将“2”和“1.1”放在圆圈外部,作为“苹果数量”和“消费税”变量。
这个问题的解答可以从下面的图中看到:

图 5.2:使用计算图解答问题 1:“苹果数量”和“消费税”变量被放置在圆圈外部
问题 2:太郎买了 2 个苹果和 3 个橙子。一个苹果 100 日元,橙子 150 日元。计算施加了 10%的消费税后,他支付了多少钱?
如问题 1 所示,我们将使用计算图来解答问题 2。以下图示显示了这个问题的计算图:

图 5.3:使用计算图解答问题 2
在这个问题中,增加了一个加法节点“+”,用于求和苹果和橙子的数量。创建计算图后,我们从左到右推进计算。计算结果从左到右移动,就像电流在电路中流动一样,计算在结果到达最右端时结束。上图显示答案是 715 日元。
使用计算图解决问题时,必须执行以下操作:
-
创建一个计算图。
-
在计算图上从左到右推进计算。
步骤 2 被称为向前传播或前向传播。在前向传播中,计算从开始到结束沿着计算图进行传播。如果存在前向传播,我们也可以考虑向后传播——从右到左。这被称为反向传播,它将在我们稍后计算导数时发挥重要作用。
局部计算
计算图的主要特点是,它可以通过传播“局部计算”来获得最终结果。“局部”意味着“与节点相关的一个小范围”。局部计算可以从与节点相关的信息中返回下一个结果(后续结果),无论整体上发生了什么。
我们可以通过一个具体的例子来分解局部计算。例如,假设我们在超市买了两个苹果和许多其他东西。为了可视化这一点,你可以创建一个像这样的计算图:

图 5.4:购买两个苹果和其他许多东西的例子
假设我们购买了许多物品,总金额是 4,000 日元(经过复杂计算得出),如前面的计算图所示。这里重要的是,每个节点中的计算都是局部计算。为了求出苹果和其他购买物品的总金额(4,000 + 200 -> 4,200),你只需要将这两个数字相加,而不必考虑 4,000 是如何得出的。换句话说,在每个节点中需要计算的仅仅是与该节点相关的计算——在这个例子中,就是将两个数字相加。我们不需要考虑整个图。
因此,你可以在计算图中专注于局部计算。无论整个计算多么复杂,每一步所做的都是“目标节点的局部计算”。通过传递简单局部计算的结果,你可以获得构成整个图的复杂计算结果。
注意
例如,汽车的组装过程是复杂的,但通常是基于“流水线”上的分工进行的。每个工人(或机器)执行简单的工作。工作成果传递给下一个工人,最终完成一辆车的组装。计算图也将复杂的计算分解为“简单的局部计算”,并将计算结果传递给下一个节点,就像汽车沿流水线传递一样。像汽车组装一样,复杂的计算可以分解为简单的计算。
为什么我们使用计算图?
我们通过使用计算图解决了两个问题,现在可以考虑其优势。其中之一是前面提到的“局部计算”。无论整个计算多么复杂,局部计算使你能够集中精力处理每个节点中的简单计算,从而简化整个问题。
另一个优势是,你可以在计算图中保留所有中间计算的结果(例如,计算出两个苹果的 200 日元和加上消费税前的 650 日元)。然而,使用计算图的最大原因是,你可以通过反向传播高效地计算“导数”。
为了描述计算图中的反向传播,再次考虑问题 1。在这个问题中,你计算了关于两个苹果和消费税的最终支付金额。现在假设你需要知道当苹果价格上涨时,最终支付金额将如何变化。这就相当于求“支付金额对苹果价格的导数”。它对应于当苹果价格为 x,支付金额为 L 时,得到
。这个导数的值表示当苹果价格“略微”上涨时,支付金额会增加多少。
如前所述,您可以在计算图中使用反向传播来获得一个值,例如“支付金额对苹果价格的导数”。首先,我们只看结果。如下面的图所示,您可以通过在计算图中使用反向传播来获得导数:

图 5.5:使用反向传播传播微分值
如前图所示,反向传播通过箭头(粗线)以与前向方向相反的方向进行图示。反向传播传递“局部微分”,并将值放置在箭头下方。在此示例中,导数值从右到左传递,如 1 -> 1.1 -> 2.2。结果显示“支付金额对苹果价格的导数”的值为 2.2。
这表示当苹果的价格上涨 1 日元时,最终支付的金额增加了 2.2 日元。这意味着当苹果价格小幅上涨时,最终支付的金额会增加小幅值的 2.2 倍。在这里,只获得了相对于苹果价格的导数,但您也可以通过类似步骤获得“支付金额相对于消费税的导数”和“支付金额相对于苹果数量的导数”。
在这些步骤中,您可以共享导数的中间结果(传递到一半的导数),以便更高效地计算多个导数。因此,计算图的优势在于前向传播和反向传播使您能够高效地获得每个变量的导数值。
链式法则
计算图中的前向传播将计算结果从左到右沿前向方向传播。这些计算看起来很自然,因为它们通常是这样进行的。另一方面,在反向传播中,“局部导数”沿反向方向从右到左传播。传播“局部导数”的原理基于链式法则。让我们来看一下链式法则,明确它如何与计算图中的反向传播相对应。
计算图中的反向传播
现在我们来看一个使用计算图进行反向传播的示例。假设存在一个计算关系 y = f(x),以下图展示了该计算的反向传播:

图 5.6:计算图中的反向传播——局部导数在反向方向上相乘
如前图所示,反向传播将信号 E 乘以节点的局部导数,
,并将其传播到下一个节点。这里的局部导数指的是在前向传播中获得计算的导数,y = f(x),并表示获得 y 相对于 x 的导数,
;例如,y = f(x) = x²,
。局部导数与从上游传播下来的值(此例中的 E)相乘,并传递到前一个节点。
这就是反向传播的过程。它可以高效地获得目标导数值。之所以能够实现这一点,可以通过下一节中定义的链式法则原理来解释。
什么是链式法则?
在解释链式法则之前,我们需要先讨论z = (x + y)²由两个方程组成,如方程(5.1)所示:
![]() |
(5.1) |
|---|
链式法则是与复合函数的导数相关的特性,定义如下。
当一个函数由复合函数表示时,复合函数的导数可以表示为构成复合函数的每个函数的导数的乘积。
这就是链式法则的原理。虽然看起来可能很难,但其实它相当简单。在方程(5.1)给出的例子中,
(z 对 x 的导数)是
(z对t的导数)和
(t对 x 的导数)的乘积。你可以用以下方程(5.2)表示这个:
![]() |
(5.2) |
|---|
你可以轻松记住方程(5.2),因为∂t 相互抵消,如下所示:
现在,让我们用链式法则来获得方程(5.2)的导数,
。首先,获得方程(5.1)的局部微分(偏微分):
![]() |
(5.3) |
|---|
如方程(5.3)所示,
是 2t,
是 1\。这个结果是通过微分公式获得的。最终结果,
,可以通过方程(5.3)中得到的导数乘积计算得到:
![]() |
(5.4) |
|---|
链式法则与计算图
现在,让我们用计算图来表达方程(5.4)中链式法则的计算。当我们用节点"**2"表示平方时,我们可以为其绘制如下图:

图 5.7:方程(5.4)的计算图 – 局部导数相乘并在反向传播中传递
如上图所示,计算图中的反向传播将信号从右向左传播。反向传播将提供给节点的信号乘以节点的局部导数(偏导数),然后传递给下一个节点。例如,在反向传播中,“**2”中的输入是
。它乘以本地导数
(在正向传播中,输入是 t,输出是 z,因此此节点的(局部)导数是
),然后乘以并传递给下一个节点。在上图中,反向传播中的第一个信号
在前面的方程中未出现。这是因为
= 1。
从上图我们应该注意的是左侧位置的反向传播结果。这对应于链式法则导致的 *"关于 x 的 z 的导数"。反向传播执行的操作基于链式法则的原理。
当您指定方程 (5.3) 的结果,如上图所示,结果如下。因此,
是 2(x + y):

图 5.8: 基于计算图中反向传播结果,是 2(x + y)
反向传播
前面的章节描述了计算图中的反向传播是基于链式法则的。我们现在将介绍通过以“+”和“x”等操作为例的反向传播工作方式。
加法节点中的反向传播
首先,让我们考虑一个加法节点中的反向传播。在这里,我们将看一下方程 z = x + y 的反向传播。我们可以按如下方式(解析地)获得 z = x + y 的导数:
![]() |
(5.5) |
|---|
正如方程 (5.5) 所示,
和
都是 1。因此,我们可以在计算图中表示它们,如下图所示。在反向传播中,从上游传来的导数 —— 例如本例中的
—— 被乘以 1 并传递到下游。简言之,在加法节点的反向传播中,乘以 1,因此它只传递输入值到下一个节点。
在这个例子中,来自上游的微分值被表示为
。这是因为我们假设了一个大型计算图,最终输出 L,如图 5.10所示。计算 z = x + y 存在于大型计算图的某处,并且
的值从上游传递下来。
和
的值向下传播:

图 5.9:加法节点中的反向传播——左侧是正向传播,右侧是反向传播。
如右图所示,加法节点中的反向传播将一个值从上游传递到下游,而不做任何变化。

图 5.10:这个加法节点存在于最终输出计算的某个位置。
在反向传播中,从最右侧的输出开始,局部的导数从节点到节点以反向方向传播
现在,让我们来看一个反向传播的例子。假设存在一个计算 "10 + 5 = 15",并且一个值为 1.3 从上游流向反向传播。以下图示表示了这一过程,基于计算图:

图 5.11:加法节点中反向传播的例子
因为加法节点中的反向传播只将输入信号输出到下一个节点,它将 1.3 传递给下一个节点。
乘法节点中的反向传播
让我们通过以方程 z = xy 为例来看看乘法节点中的反向传播。该方程的微分由以下方程(5.6)表示:
![]() |
(5.6) |
|---|
基于前面的方程(5.6),你可以写出如下的计算图。
对于乘法的反向传播,上游的值与正向传播输入信号的“反向值”相乘后传递给下游。反向值意味着,如果正向传播中的信号是 x,那么反向传播时要乘的值是 y;如果正向传播中的信号是 y,那么反向传播时要乘的值是 x,如下图所示。
让我们来看一个例子。假设存在一个计算 10 x 5 = 50,并且一个值为 1.3 从上游流向反向传播。图 5.13 以计算图的形式显示了这一过程。
在乘法的反向传播中,反向的输入信号相乘,因此得到 1.3 x 5 = 6.5 和 1.3 x 10 = 13。而在加法的反向传播中,上游的值仅被传递给下游。因此,正向传播中的输入信号不需要。在另一方面,对于乘法的反向传播,正向传播中的输入信号是必须的。因此,为了实现一个乘法节点,正向传播的输入信号需要保留:

图 5.12:乘法节点中的反向传播——左侧是正向传播,右侧是反向传播

](https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/dl-bsc/img/Figure_5.14.jpg)
图 5.13:乘法节点中的反向传播示例
苹果例子
让我们再从本章开始时考虑购买苹果的例子——两个苹果和消费税。这里要解决的问题是,三个变量(苹果的价格、苹果的数量和消费税)如何影响最终支付的金额。这对应于计算“苹果价格对支付金额的导数”,“苹果数量对支付金额的导数”和“消费税对支付金额的导数”。我们可以通过在计算图中使用反向传播来解决这个问题,如下图所示:

](https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/dl-bsc/img/Figure_5.14.jpg)
图 5.14:购买苹果的反向传播示例
如前所述,输入信号在乘法节点的反向传播中被反向并传递到下游。根据前面图示的结果,苹果价格的微分为 2.2,苹果数量为 110,消费税为 200。它们表示当消费税和苹果价格增加相同的量时,消费税对最终支付金额的影响为 200,而苹果价格的影响为 2.2。然而,这一结果是因为在这个例子中,消费税和苹果价格的单位不同(消费税的单位是 100%,而苹果价格的单位是 1 日元)。
最后,让我们通过“购买苹果和橙子”这个练习来解决反向传播问题。请计算各个变量的导数,并将数字填入下图中提供的方框内(你可以在下一节找到答案):

](https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/dl-bsc/img/Figure_5.15.jpg)
图 5.15:购买苹果和橙子的反向传播示例——通过将数字填入方框完成此计算
实现简单层
在本节中,我们将使用 Python 实现我们所描述的苹果例子,使用计算图中的乘法节点作为乘法层(MulLayer),而将加法节点作为加法层(AddLayer)。
注意
在下一节中,我们将实现一个类,其中包含构成神经网络的“层”。这里的“层”是神经网络中的一个功能单元——Sigmoid 层用于 sigmoid 函数,Affine 层用于矩阵乘法。因此,我们还将在“层”级别实现乘法和加法节点。
实现一个乘法层
我们将实现一个层,使其具有两个常用的方法(接口):forward()和backward(),分别对应正向传播和反向传播。现在,你可以将乘法层实现为一个名为MulLayer的类,如下所示(源代码位于ch05/layer_naive.py):
class MulLayer:
def __init__ (self):
self.x = None
self.y = None
def forward(self, x, y):
self.x = x
self.y =y
out = x * y
return out
def backward(self, dout):
dx = dout * self.y # Reverse x and y
dy = dout * self.x
return dx, dy
__init__()初始化实例变量x和y,用于保留正向传播中的输入值。forward()接收两个变量x和y,并将它们相乘并输出它们的乘积。另一方面,backward()将来自上游的导数dout与正向传播的“反向值”相乘,并将结果传递给下游。
现在,使用MulLayer实现“购买苹果”——两个苹果和消费税。在前面的部分,我们使用计算图中的正向传播和反向传播来进行这个计算,如下图所示:

](https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/dl-bsc/img/Figure_5.16.jpg)
图 5.16: 购买两个苹果
通过使用乘法层,我们可以如下实现该正向传播(源代码位于ch05/buy_apple.py):
apple = 100
apple_num = 2
tax = 1.1
# layer
mul_apple_layer = MulLayer()
mul_tax_layer = MulLayer()
# forward
apple_price = mul_apple_layer.forward(apple, apple_num)
price = mul_tax_layer.forward(apple_price, tax)
print(price) # 220
你可以使用backward()来获取每个变量的导数。
# backward
dprice = 1
dapple_price, dtax = mul_tax_layer.backward(dprice)
dapple, dapple_num = mul_apple_layer.backward(dapple_price)
print(dapple, dapple_num, dtax) # 2.2 110 200
在这里,调用backward()的顺序与调用forward()的顺序相反。请注意,backward()的参数是“正向传播中输出变量的导数”。例如,乘法层mul_apple_layer在正向传播中返回apple_price,而在反向传播中,它将apple_price (dapple_price)的导数值作为参数传递。该程序的执行结果与前面的图示结果相符。
实现一个加法层
现在,我们将实现一个加法层,它是一个加法节点,如下所示:
class AddLayer:
def __init__ (self):
pass
def forward(self, x, y):
out = x + y
return out
def backward(self, dout):
dx = dout * 1
dy = dout * 1
return dx, dy
加法层无需初始化,因此__init__()什么都不做(pass语句表示“什么都不做”)。加法层中的forward()接收两个参数x和y,并将它们相加输出。backward()将上游的导数dout传递给下游。
现在,让我们使用加法层和乘法层来实现购买两个苹果和三个橙子的操作,如下图所示。

](https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/dl-bsc/img/Figure_5.17.jpg)
图 5.17: 购买两个苹果和三个橙子
你可以如下方式在 Python 中实现这个计算图(源代码位于ch05/buy_apple_orange.py):
apple = 100
apple_num = 2
orange = 150
orange_num = 3
tax = 1.1
# layer
mul_apple_layer = MulLayer()
mul_orange_layer = MulLayer()
add_apple_orange_layer = AddLayer()
mul_tax_layer = MulLayer()
# forward
apple_price = mul_apple_layer.forward(apple, apple_num) #(1)
orange_price = mul_orange_layer.forward(orange, orange_num) #(2)
all_price = add_apple_orange_layer.forward(apple_price, orange_price) #(3)
price = mul_tax_layer.forward(all_price, tax) #(4)
# backward
dprice = 1
dall_price, dtax = mul_tax_layer.backward(dprice) #(4)
dapple_price, dorange_price = add_apple_orange_layer.backward(dall_price) #(3)
dorange, dorange_num = mul_orange_layer.backward(dorange_price) #(2)
dapple, dapple_num = mul_apple_layer.backward(dapple_price) #(1)
print(price) # 715
print(dapple_num, dapple, dorange, dorange_num, dtax) # 110 2.2 3.3 165 650
这个实现稍微长一些,但每一条语句都很简单。所需的层被创建,并按适当的顺序调用前向传播方法forward()。然后,反向传播方法backward()会以与前向传播相反的顺序调用,以获得所需的导数。
通过这种方式,在计算图中实现层(如加法层和乘法层)变得容易,你可以利用它们获得复杂的导数。接下来,我们将实现神经网络中使用的层。
实现激活函数层
现在,我们将计算图的思想应用于神经网络。在这里,我们将使用 ReLU 和 Sigmoid 层(激活函数)来实现构成神经网络的“层”。
ReLU 层
Rectified Linear Unit(ReLU)作为激活函数,其表达式为以下方程(5.7):
![]() |
(5.7) |
|---|
从前面的方程式(5.7)中,你可以通过方程式(5.8)得到 y 关于 x 的导数:
![]() |
(5.8) |
|---|
如方程式(5.8)所示,如果前向传播中的输入 x 大于 0,反向传播会将上游的值传递给下游而不做任何改变。同时,如果前向传播中的 x 为 0 或更小,信号将在反向传播中停在那里。你可以在计算图中表示这一点,如下图所示:

图 5.18:ReLU 层的计算图
接下来,我们来实现 ReLU 层。在实现神经网络中的一个层时,我们假设 forward() 和 backward() 以 NumPy 数组作为参数。ReLU 层的实现位于 common/layers.py:
class Relu:
def __init__ (self):
self.mask = None
def forward(self, x):
self.mask = (x <= 0)
out = x.copy()
out[self.mask] = 0
return out
def backward(self, dout):
dout[self.mask] = 0
dx = dout
return dx
Relu 类有一个实例变量 mask。mask 变量是一个由 True/False 值组成的 NumPy 数组。如果前向传播中输入 x 的某个元素为 0 或更小,那么 mask 中对应的元素为 True。否则(如果大于 0),该元素为 False。例如,mask 变量包含一个由 True 和 False 组成的 NumPy 数组,如下面的代码所示:
>>> x = np.array( [[1.0, -0.5], [-2.0, 3.0]] )
>>> print(x)
[[ 1\. -0.5]
[-2\. 3\. ]]
>>> mask = (x <= 0)
>>> print(mask)
[[False True]
[ True False]]
如前图所示,当前向传播中的输入值为 0 或更小时,反向传播的值为 0。因此,在反向传播中,前向传播中存储的 mask 变量被用来设置来自上游的 dout。如果 mask 的某个元素为 True,则 dout 中对应的元素被设置为 0\。
注意
ReLU 层在电路中充当“开关”的角色。在前向传播中,当电流流过它时,它会打开开关;当电流不流过时,它会关闭开关。在反向传播中,如果开关是打开的,电流会继续流动;如果开关是关闭的,电流则不再流动。
Sigmoid 层
接下来,让我们实现一个 sigmoid 函数。这由方程(5.9)表示:
![]() |
(5.9) |
|---|
下图显示了代表方程(5.9)的计算图:

图 5.19:Sigmoid 层的计算图(仅正向传播)
这里,exp 和 / 节点除了 X 和 + 节点之外还出现了。exp 节点计算 y = exp(x),而 / 节点计算
。
方程(5.9)的计算涉及局部计算的传播。接下来,让我们考虑前面计算图中展示的反向传播,逐步查看反向传播的流程,总结我们到目前为止描述的内容。
第一步:
/ 节点表示
。其导数通过以下方程解析表示:
 |
(5.10) |
|---|
基于方程(5.10),在反向传播中,节点将上游值乘以 −y2(正向传播中输出的平方的加法逆),并将值传递给下游。以下计算图显示了这一过程:

图 5.20:Sigmoid 层的计算图(带有平方的加法逆)
第二步:
+ 节点仅将上游值传递给下游。以下计算图显示了这一点:

图 5.21:Sigmoid 层的计算图(传递上游值)
第三步:
"exp" 节点表示 y = exp(x),其导数由以下方程表示:
![]() |
(5.11) |
|---|
在下面的计算图中,节点将上游值乘以正向传播中的输出(在本例中为 exp(-x)),并将值传递给下游:

图 5.22:Sigmoid 层的计算图
第四步:
在正向传播中,X 节点反转值以便进行乘法运算。因此,在这里乘以 −1:

图 5.23:Sigmoid 层的计算图(反转值)
因此,我们可以在前面图示中的计算图中展示 Sigmoid 层的反向传播。根据前面计算图的结果,反向传播的输出是
,并传播到下游节点。需要注意的是,
可以通过前向传播的输入x和输出y计算得出。因此,我们可以将前面图示中的计算图绘制为一个分组的“sigmoid”节点,如下所示:

图 5.24:Sigmoid 层的计算图(简化版)
图 5.23中的计算图和图 5.24中的简化计算图提供了相同的计算结果。简化版更高效,因为它可以省略反向传播中的中间计算。还需要注意的是,通过分组节点,你可以只关注输入和输出,而无需关心 Sigmoid 层的细节。
你也可以将
组织如下:
![]() |
(5.12) |
|---|
因此,你只能通过前向传播的输出计算上图所示的 Sigmoid 层中的反向传播:

图 5.25:Sigmoid 层的计算图——你可以使用前向传播的输出 y 来计算反向传播
现在,让我们在 Python 中实现 Sigmoid 层。根据前面的图示,你可以按如下方式实现它(此实现位于common/layers.py):
class Sigmoid:
def __init__ (self):
self.out = None
def forward(self, x):
out = 1 / (1 + np.exp(-x))
self.out = out
return out
def backward(self, dout):
dx = dout * (1.0 - self.out) * self.out
return dx
该实现将前向传播的输出保存在out实例变量中,然后在反向传播中使用out变量进行计算。
实现仿射层和 Softmax 层
仿射层
在神经网络的前向传播中,使用矩阵乘积(np.dot(),在 NumPy 中)来加权信号求和(详情请参阅第三章:神经网络中的计算多维数组部分)。例如,你是否还记得下面在 Python 中的实现?
>>> X = np.random.rand(2) # Input values
>>> W = np.random.rand(2,3) # Weights
>>> B = np.random.rand(3) # Biases
>>>
>>> X.shape # (2,)
>>> W.shape # (2, 3)
>>> B.shape # (3,)
>>>
>>> Y = np.dot(X, W) + B
假设X、W和B分别是形状为(2,)、(2, 3)和(3,)的多维数组。基于此,你可以计算神经元的加权和为Y = np.dot(X, W) + B。Y经过激活函数处理并传播到下一层,这就是神经网络中的前向传播过程。请注意,矩阵乘法时,相应维度中的元素数量必须相同。这意味着在X和W的乘积中,相应维度的元素数量必须一致,如下图所示。这里,矩阵的形状以括号表示为(2, 3)(这是与 NumPy 的输出形状一致):

图 5.26:矩阵乘法时,相应维度中的元素数量必须相同
注意
神经网络中的前向传播中的矩阵乘积在几何学中被称为“仿射变换”。因此,我们将实现执行仿射变换的过程,称之为“仿射层”。
现在,让我们来看一下计算过程——矩阵的乘积和偏置的加和——在计算图中的表示。当我们将计算矩阵乘积的节点表示为“dot”时,以下计算图可以表示计算np.dot(X, W) + B。在每个变量上方,都会标明该变量的形状(例如,X的形状是(2,),而X – W的形状是(3,),如下所示):

图 5.27:仿射层的计算图。请注意,变量是矩阵。每个变量上方显示该变量的形状
前述是一个相对简单的计算图。但请注意,X、W和B是多维数组。在我们之前看到的计算图中,“标量值”在节点之间流动,而在这个例子中,“多维数组”在节点之间传播。
让我们思考一下前述计算图的反向传播过程。为了获得多维数组的反向传播,你可以采用与之前用于标量值的计算图相同的步骤,通过逐个写出多维数组的每个元素来进行。这样,我们可以得到以下方程式(如何得到方程(5.13)在此省略):
![]() |
(5.13) |
|---|
在方程(5.13)中,WT 中的 T 表示转置。转置操作将 W 的(i, j)元素切换为(j, i)元素,如下所示的方程:
![]() |
(5.14) |
|---|
如方程(5.14)所示,当 W 的形状为(2, 3)时,WT 的形状变为(3, 2)。
根据方程 (5.13),让我们写出计算图中的反向传播。下图展示了结果:

图 5.28:仿射层的反向传播。请注意,变量是矩阵。每个变量下方显示了该变量的形状。
让我们仔细考虑每个变量的形状。请注意,X 和
形状相同,W 和
在形状上是相同的,这是因为以下方程:
![]() |
(5.15) |
|---|
我们注意矩阵的形状,因为矩阵乘法要求对应维度中的元素数量相同,检查它们是否相同可以得到方程 (5.13)。例如,考虑
和 W 的乘积,使得
的形状变为 (2,),当
的形状为 (3,) 且 W 的形状为 (2,3) 时。然后,方程 (5.13) 就成立。这可以在下图中看到:

图 5.29:矩阵乘积(通过配置矩阵乘积,使得对应维度中的元素数量相同,可以创建“点”节点的反向传播)
基于批量的仿射层。
仿射层将一块数据作为输入,X。在本节中,我们将讨论基于批量的仿射层,它共同传播 N 个数据点(一个数据组被称为“批量”)。让我们从查看基于批量的仿射层的计算图(图 5.30)开始。
与之前的解释唯一不同的是,输入 X 的形状现在是 (N, 2)。我们需要做的就是按照之前的方式计算计算图中的矩阵。对于反向传播,我们必须小心矩阵的形状。只有在此之后,我们才能以相同的方式得到
和
。
当添加偏置时,必须小心。在前向传播中,偏置被添加到每个数据的 X · W 中。例如,当 N = 2(两个数据点)时,偏置被添加到每个数据点(每个计算结果)。下图展示了这一过程的具体示例:

图 5.30:基于批量的仿射层计算图。
>>> X_dot_W = np.array([[0, 0, 0], [10, 10, 10]])
>>> B = np.array([1, 2, 3])
>>>
>>> X_dot_W
array([[ 0, 0, 0],
[ 10, 10, 10]])
>>> X_dot_W + B
array([[ 1, 2, 3],
[11, 12, 13]])
在前向传播中,偏置项被加到每个数据上(第一个、第二个,依此类推)。因此,在反向传播中,反向传播中每个数据的值必须与偏置项的元素合并。以下代码展示了这一点:
>>> dY = np.array([[1, 2, 3,], [4, 5, 6]])
>>> dY
array([[1, 2, 3],
[4, 5, 6]])
>>>
>>> dB = np.sum(dY, axis=0)
>>> dB
array([5, 7, 9])
在这个例子中,我们假设有两条数据(N = 2)。在偏置的反向传播中,对于每条数据,关于这两条数据的导数会被加总。为了实现这一点,np.sum()会对轴 0 的元素进行求和。
因此,仿射层的实现如下所示。common/layers.py中的仿射层实现与这里描述的实现稍有不同,因为它考虑了输入数据为张量(四维数组)的情况:
class Affine:
def __init__ (self, W, b):
self.W = W
self.b = b
self.x = None
self.dW = None
self.db = None
def forward(self, x):
self.x = x
out = np.dot(x, self.W) + self.b
return out
def backward(self, dout):
dx = np.dot(dout, self.W.T)
self.dW = np.dot(self.x.T, dout)
self.db = np.sum(dout, axis=0)
return dx
Softmax-with-Loss 层
最后,我们应该考虑一个 softmax 函数,它是输出层。softmax 函数归一化输入的值并输出它们(如前所述)。例如,下面的图显示了手写数字识别中 Softmax 层的输出。
如我们所见,Softmax 层归一化了输入的值(即将它们转换为输出值的总和为 1),并输出它们。Softmax 层有 10 个输入,因为手写数字识别将数据分类为 10 个类别。
注意
神经网络的处理包含两个阶段:推理和训练。通常,神经网络的推理阶段不使用 Softmax 层。例如,在下图所示的网络中,最后一个仿射层的输出作为推理结果。神经网络的未归一化输出结果(即下图中 Softmax 层之前的仿射层输出)有时称为“得分”。为了在神经网络推理中得到唯一的答案,只需要计算最大得分,因此不需要 Softmax 层。然而,在神经网络训练过程中是需要 Softmax 层的。

Figure 5.31: 图像通过仿射层和 ReLU 层转换,10 个输入值被 Softmax 层归一化
在这个例子中,“0”的得分是 5.3,经过 Softmax 层转换为 0.008(0.8%)。 “2”的得分是 10.1,经过 Softmax 层转换为 0.991(99.1%)。
现在,我们将实现“Softmax-with-Loss 层”,该层也包含交叉熵误差,这是一个损失函数。下图显示了 Softmax-with-Loss 层的计算图(softmax 函数和交叉熵误差):

Figure 5.32: Softmax-with-Loss 层的计算图
如你所见,Softmax-with-Loss 层略显复杂。这里只展示了结果。如果你对 Softmax-with-Loss 层的构建过程感兴趣,请参考附录中Softmax-with-Loss 层的计算图部分。
我们可以将前面图示的计算图简化如下:

图 5.33:“简化”版的 Softmax-with-Loss 层计算图
在前面的计算图中,Softmax 层表示 Softmax 函数,而交叉熵误差层表示交叉熵误差。在这里,我们假设数据已经被分类为三类,并且有三个输入(得分),这些数据来自前一层。如图所示,Softmax 层对输入(a1, a2, a3)进行归一化,并输出(y1, y2, y3)。交叉熵误差层接收 Softmax 的输出(y1, y2, y3)和标签(t1, t2, t3),并根据这些数据输出损失 L。
从 Softmax 层的反向传播返回(y1 t1, y2 t2, y3 t3),这是一个“干净”的结果。因为(y1, y2, y3)是 Softmax 层的输出,(t1, t2, t3)是标签,(y1 − t1, y2 − t2, y3 − t3)是 Softmax 层的输出与标签之间的差异。在神经网络反向传播时,这个差异作为误差传递给上一层。这个特性在训练神经网络时非常重要。
请注意,神经网络训练的目的是调整权重参数,使得神经网络的输出(Softmax 的输出)接近标签。为了做到这一点,我们需要有效地将神经网络输出与标签之间的误差传递给上一层。前面的结果(y1 − t1, y2 − t2, y3 − t3)正是 Softmax 层输出与标签之间的差异,并清楚地显示了神经网络输出与标签之间的当前误差。
当我们使用“交叉熵误差”作为“softmax 函数”的损失函数时,反向传播返回一个“漂亮”的结果,(y1 − t1, y2 − t2, y3 − t3)。这个“漂亮”的结果不是偶然的。调用交叉熵误差的函数就是为了做到这一点。在回归问题中,输出层使用“恒等函数”,损失函数使用“平方误差和”(参见第三章的设计输出层部分,神经网络)是出于同样的原因。当我们使用平方误差和作为“恒等函数”的损失函数时,反向传播也会提供一个“漂亮”的结果,(y1 − t1, y2 − t2, y3 − t3)。
让我们在这里考虑一个具体的例子。假设对于一组数据,标签是(0,1,0),而 Softmax 层的输出是(0.3,0.2,0.5)。此时,神经网络没有正确识别,因为它是正确标签的概率只有 0.2(20%)。在这种情况下,从 Softmax 层向后传播一个较大的误差(0.3,−0.8,0.5)。因为这个较大的误差会传播到前面的层,所以 Softmax 层之前的层从这个大误差中学到了更多信息。
作为另一个例子,假设对于一组数据,标签是(0,1,0),而 Softmax 层的输出是(0.01,0.99,0)(这个神经网络识别得相当准确)。在这种情况下,从 Softmax 层向后传播一个小的误差(0.01,−0.01,0)。这个小误差会传播到前面的层。Softmax 层之前的层只学习到小部分信息,因为误差很小。
现在,我们来实现 Softmax-with-Loss 层。你可以按照如下方式实现 Softmax-with-Loss 层:
class SoftmaxWithLoss:
def __init__ (self):
self.loss = None # Loss
self.y = None # Output of softmax
self.t = None # Label data (one-hot vector)
def forward(self, x, t):
self.t = t
self.y = softmax(x)
self.loss = cross_entropy_error(self.y, self.t)
return self.loss
def backward(self, dout=1):
batch_size = self.t.shape[0]
dx = (self.y - self.t) / batch_size
return dx
本实现使用了 softmax() 和 cross_entropy_error() 函数。这些函数在《第三章 神经网络》的子章节 实现 Softmax 函数时的问题 和《第四章 神经网络训练》的子章节 实现交叉熵误差(使用小批量) 中已经实现。因此,这里的实现非常简单。请注意,每个数据的误差会在反向传播时传播到前面的层,因为传播值会被批量大小(batch_size)除以。
实现反向传播
你可以通过将之前实现的层像拼装乐高积木一样组合起来,构建一个神经网络。在这里,我们将通过组合目前为止实现的层来构建一个神经网络。
神经网络训练的整体视图
因为我的描述有点长,在继续实现之前,我们先来回顾一下神经网络训练的整体过程。现在我们将查看神经网络训练的流程。
前提假设
神经网络具有可调的权重和偏置。调整它们以使其适应训练数据的过程称为“训练”。神经网络训练包括以下四个步骤:
步骤 1(小批量):
从训练数据中随机选择一些数据。
步骤 2(计算梯度):
获取每个权重参数的损失函数梯度。
步骤 3(更新参数):
在梯度的方向上稍微更新参数。
步骤 4(重复):
重复步骤 1、2 和 3。
反向传播发生在步骤 2,计算梯度。在上一章中,我们通过数值微分获得梯度。数值微分容易实现,但计算时间较长。如果使用反向传播,我们可以更快速有效地获得梯度。
实现一个支持反向传播的神经网络
在本节中,我们将实现一个名为TwoLayerNet的两层神经网络。首先,我们将查看表 5.1和表 5.2中该类的实例变量和方法。
这个类的实现稍微长一些,但有许多部分与第四章中实现训练算法部分的实现相似。与上一章的主要变化是,这里使用了层。如果使用层,你可以通过在层之间传播来获取识别结果(predict( ))和梯度(gradient( )):

表 5.1:TwoLayerNet 类中的实例变量

表 5.2:TwoLayerNet 类中的方法
现在,让我们实现TwoLayerNet:
import sys, os
sys.path.append(os.pardir)
import numpy as np
from common.layers import *
from common.gradient import numerical_gradient
from collections import OrderedDict
class TwoLayerNet:
def __init__ (self, input_size, hidden_size, output_size,
weight_init_std=0.01):
# Initialize weights
self.params = {}
self.params['W1'] = weight_init_std * \
np.random.randn(input_size, hidden_size)
self.params['b1'] = np.zeros(hidden_size)
self.params['W2'] = weight_init_std * \
np.random.randn(hidden_size, output_size)
self.params['b2'] = p.zeros(output_size)
# Create layers
self.layers = OrderedDict( )
self.layers['Affine1'] = \
Affine(self.params['W1'], self.params['b1'])
self.layers['Relu1'] = Relu( )
self.layers['Affine2'] = \
Affine(self.params['W2'], self.params['b2'])
self.lastLayer = SoftmaxWithLoss( )
def predict(self, x):
for layer in self.layers.values( ):
x = layer.forward(x)
return x
# x: input data, t: label data
def loss(self, x, t):
y = self.predict(x)
return self.lastLayer.forward(y, t)
def accuracy (self, x, t):
y = self.predict(x)
y = np.argmax(y, axis=1)
if t.ndim != 1 : t = np.argmax(t, axis=1)
accuracy = np.sum(y == t) / float(x.shape[0])
return accuracy
# x: input data, t: teacher data
def numerical_gradient(self, x, t):
loss_W = lambda W: self.loss(x, t)
grads = {}
grads['W1'] = numerical_gradient(loss_W, self.params['W1'])
grads['b1'] = numerical_gradient(loss_W, self.params['b1'])
grads['W2'] = numerical_gradient(loss_W, self.params['W2'])
grads['b2'] = numerical_gradient(loss_W, self.params['b2'])
return grads
def gradient(self, x, t):
# forward
self.loss(x, t)
# backward
dout = 1
dout = self.lastLayer.backward(dout)
layers = list(self.layers.values( ))
layers.reverse( )
for layer in layers:
dout = layer.backward(dout)
# Settings
grads = {}
grads['W1'] = self.layers['Affine1'].dW
grads['b1'] = self.layers['Affine1'].db
grads['W2'] = self.layers['Affine2'].dW
grads['b2'] = self.layers['Affine2'].db
return grads
注意这里的代码是加粗的。保持神经网络层为OrderedDict(即有序字典)是特别重要的,因为它意味着字典可以记住添加到其中的元素顺序。因此,在神经网络的前向传播中,你可以按添加顺序调用层的forward()方法完成处理。在反向传播中,你只需要按相反顺序调用这些层。Affine 和 ReLU 层在内部会正确处理前向传播和反向传播。所以,你只需要按正确的顺序将层组合起来并按顺序调用它们(或反向顺序调用)。
因此,通过将神经网络的组件实现为“层”,你可以轻松构建神经网络。使用“层”进行模块化实现的优势是巨大的。如果你想创建一个包含五层、十层或二十层的大型网络,你可以通过添加所需的层(就像组装乐高积木一样)来完成。通过这种方式,通过每一层实现的前向传播和反向传播,正确地获得了识别和学习所需的梯度。
梯度检查
到目前为止,我们已经看到了计算梯度的两种方法。它们中的一种使用数值微分,另一种通过解析解来求解方程。后一种方法通过使用反向传播,使得即使有很多参数也能高效地计算。因此,从现在开始,我们将使用反向传播而不是缓慢的数值微分来计算梯度。
数值微分需要时间来计算。如果存在正确的反向传播实现,我们就不需要数值微分的实现。那么,数值微分到底有什么用呢?事实上,数值微分是用来检查反向传播的实现是否正确的。
数值微分的优点在于它易于实现,与更加复杂的反向传播相比,犯错的概率较低。因此,数值微分的结果常常与反向传播的结果进行比较,以检查反向传播的实现是否正确。这个验证过程被称为ch05/gradient_check.py。
import sys, os
sys.path.append(os.pardir)
import numpy as np
from dataset.mnist import load_mnist
from two_layer_net import TwoLayerNet
# Load data
(x_train, t_train), (x_test, t_test) = \
load_mnist(normalize=True, one_hot_label=True)
network = TwoLayerNet(input_size=784, hidden_size=50, output_size=10)
x_batch = x_train[:3]
t_batch = t_train[:3]
grad_numerical = network.numerical_gradient(x_batch, t_batch)
grad_backprop = network.gradient(x_batch, t_batch)
# Calculate the average of the absolute errors of individual weights
for key in grad_numerical.keys( ):
diff = np.average( np.abs(grad_backprop[key] - grad_numerical[key]) )
print(key + ":" + str(diff))
这里,MNIST 数据集像往常一样被加载。接下来,使用部分训练数据检查数值微分的梯度与反向传播的梯度之间的误差。误差是通过对各个权重参数中元素差异的绝对值求平均得出的。当执行上述代码时,将返回以下结果:
b1:9.70418809871e-13
W2:8.41139039497e-13
b2:1.1945999745e-10
W1:2.2232446644e-13
结果表明,数值微分和反向传播计算的梯度之间的差异非常小。例如,层 1 偏置的误差为 9.7e-13 (0.00000000000097)。这表明反向传播计算的梯度也是正确的,并提高了其准确性的可靠性。
数值微分的计算结果与反向传播的计算结果之间的误差很少为 0。原因在于计算机执行的计算精度是有限的(例如,使用的是 32 位浮动点数)。由于数值精度有限,误差通常不会为 0。但是,如果实现正确,误差应该是一个接近 0 的小值。如果误差较大,则说明反向传播的实现有误。
使用反向传播进行训练
最后,我们将看到如何使用反向传播实现神经网络训练。唯一的区别是梯度是通过反向传播计算的。我们将仅查看代码并省略描述(源代码位于 ch05/train_neuralnet.py):
import sys, os
sys.path.append(os.pardir)
import numpy as np
from dataset.mnist import load_mnist
from two_layer_net import TwoLayerNet
# Load data
(x_train, t_train), (x_test, t_test) = \
load_mnist(normalize=True, one_hot_label=True)
network = TwoLayerNet(input_size=784, hidden_size=50, output_size=10)
iters_num = 10000
train_size = x_train.shape[0]
batch_size = 100
learning_rate = 0.1
train_loss_list = [ ]
train_acc_list = [ ]
test_acc_list = [ ]
iter_per_epoch = max(train_size / batch_size, 1)
for i in range(iters_num):
batch_mask = np.random.choice(train_size, batch_size)
x_batch = x_train[batch_mask]
t_batch = t_train[batch_mask]
# Use backpropagation to obtain a gradient
grad = network.gradient(x_batch, t_batch)
# Update
for key in ('W1', 'b1', 'W2', 'b2'):
network.params[key] -= learning_rate * grad[key]
loss = network.loss(x_batch, t_batch)
train_loss_list.append(loss)
if i % iter_per_epoch == 0:
train_acc = network.accuracy(x_train, t_train)
test_acc = network.accuracy(x_test, t_test)
train_acc_list.append(train_acc)
test_acc_list.append(test_acc)
print(train_acc, test_acc)
总结
本章中,我们学习了计算图,它以直观的方式展示了计算过程。我们查看了一个描述神经网络反向传播的计算图,并实现了具有多个层的神经网络处理,包括 ReLU 层、Softmax-with-Loss 层、仿射层和 Softmax 层。这些层具有前向和后向方法,可以通过前向和后向传播数据来高效地计算权重参数的梯度。通过将层作为模块,您可以在神经网络中自由组合它们,从而轻松构建所需的网络。本章涵盖了以下内容:
-
我们可以使用计算图来直观地展示计算过程。
-
计算图中的一个节点由局部计算组成,局部计算构成了整个计算。
-
在计算图中执行前向传播会进行常规计算。与此同时,在计算图中执行反向传播可以计算每个节点的微分。
-
通过将神经网络中的组件实现为层,您可以高效地计算梯度(反向传播)。
-
通过比较数值微分和反向传播的结果,您可以检查反向传播的实现是否正确(梯度检查)。
第七章:6. 训练技巧
本章描述了神经网络训练中的重要思想,包括用于寻找最优权重参数的优化技术、权重参数的初始值以及设置超参数的方法——这些都是神经网络训练中的重要话题。我们将讨论正则化方法,如权重衰减和丢弃法,以防止过拟合并加以实现。最后,我们将探讨批量归一化,这在近年来的研究中被广泛使用。通过使用本章介绍的方法,你将能够有效地推进神经网络训练,从而提高识别精度。
更新参数
神经网络训练的目的是找到使损失函数值最小化的参数。问题在于找到最优参数——这一过程被称为优化。不幸的是,优化非常困难,因为参数空间非常复杂,且最优解很难找到。你不能通过解方程来立即得到最小值。在深度网络中,情况更加困难,因为参数的数量非常庞大。
到目前为止,我们依赖参数的梯度(导数)来寻找最优参数。通过反复使用参数的梯度在梯度方向上更新参数,我们逐步接近最优参数。这是一种简单的方法,称为随机梯度下降法(SGD),但它比随机搜索参数空间是一种“更聪明”的方法。然而,SGD 是一种简单的方法,(对于某些问题)也有一些方法能够更好地工作。那么,我们首先来考虑 SGD 的缺点,并介绍其他优化技术。
冒险者的故事
在进入主题之前,我们可以通过一个寓言来描述我们在优化方面所面临的情况。
注意
有一个奇怪的冒险者。他每天穿越广阔的干旱地区,寻找一片深谷的底部。他的目标是到达最深的谷底,他称之为“深处”。这也是他旅行的原因。此外,他对自己设定了两个严格的“限制”。其中一个是不能使用地图,另一个是要蒙住双眼。因此,他不知道最深的谷底在哪个地方,也看不见任何东西。在这种严格的条件下,这个冒险者如何寻找“深处”?他如何高效地移动以寻找“深处”?
我们在寻找最优参数时所处的情境,就像这个冒险者的世界一样是一片黑暗。我们必须在广阔且复杂的地形中,蒙着眼睛、没有地图地寻找“深处”。
在这种困难的情况下,重要的是“地面的倾斜度”。冒险者看不到周围的环境,但他知道地面的倾斜度,因为他能从站立的位置(他的双脚可以感觉到)感知到它。因此,SGD 的策略是沿着地面倾斜度最陡的方向前进。勇敢的冒险者想:“通过不断重复,我可能某天能够到达‘深处’。”
SGD
既然我们已经理解了这个优化问题的困难,接下来让我们回顾一下 SGD。方程 6.1 表示 SGD 如下:
![]() |
(6.1) |
|---|
这里,要更新的权重参数是 W,W 的损失函数梯度是
。η是学习率,我们需要预定义它的值,如 0.01 或 0.001。方程中的<-表示右侧的值将用于更新左侧的值。如方程 6.1 所示,SGD 是一个简单的方法,它在梯度方向上移动一定的距离。现在,我们将以 Python 类的形式实现SGD:
class SGD:
def __init__ (self, lr=0.01):
self.lr = lr
def update(self, params, grads):
for key in params.keys():
params[key] -= self.lr * grads[key]
这里,初始化时的参数lr是学习率。学习率被保留为一个实例变量。我们还将定义update(params, grads)方法,这个方法会在 SGD 中被反复调用。params和grads是字典类型的变量(如同我们至今在神经网络实现中所做的那样)。例如,params['W1']和grads['W1'],每个元素存储着权重参数或梯度。通过使用SGD类,你可以像下面这样更新神经网络中的参数(以下代码是伪代码,不可直接运行):
network = TwoLayerNet(...)
optimizer = SGD()
for i in range(10000):
...
x_batch, t_batch = get_mini_batch(...) # Mini-batch
grads = network.gradient(x_batch, t_batch)
params = network.params
optimizer.update(params, grads)
...
这里出现的变量名optimizer表示“优化者”。在此,SGD 扮演着这一角色。optimizer变量负责更新参数。我们所需要做的就是将关于参数和梯度的信息传递给优化器。
因此,单独实现一个优化类有助于特性的模块化。例如,我们很快就会实现另一个优化技术,叫做update(params, grads)。然后,我们只需将optimizer = SGD()语句更改为optimizer = Momentum(),就能从 SGD 切换到 Momentum 优化技术。
注意
在许多深度学习框架中,已经实现了各种优化技术,并提供了机制,让我们可以轻松地在它们之间切换。例如,在一个叫 Lasagne 的深度学习框架中,优化技术作为函数实现,并存放在updates.py文件中(github.com/Lasagne/Lasagne/blob/master/lasagne/updates.py)。用户可以从中选择所需的优化技术。
SGD 的缺点
虽然 SGD(随机梯度下降)简单且易于实现,但在某些问题上可能效率较低。为了讨论 SGD 的缺点,我们考虑一个计算以下函数最小值的问题:
![]() |
(6.2) |
|---|
由方程 6.2 表示的函数形状像一个沿 x 轴方向拉伸的“碗”,如下面的图所示。实际上,方程 6.2 的等高线像是沿 x 轴方向延伸的椭圆。
现在,让我们来看一下由方程 6.2 表示的函数的梯度。图 6.2展示了这些梯度。这些梯度在 y 轴方向上很大,而在 x 轴方向上很小。换句话说,y 轴方向的倾斜度很陡,而 x 轴方向则比较平缓。注意,方程 6.2 的最小值位置是(x, y) = (0, 0),但在许多地方,图 6.2中的梯度并未指向(0, 0)方向。
让我们将 SGD 应用于如下图所示的函数。它从(x, y) = (-7.0, 2.0)(初始值)开始搜索。图 6.3显示了结果:

图 6.1:![![图 6.2:梯度]()
图 6.2:梯度!68
SGD 以锯齿状方式移动,如下图所示。SGD 的缺点是,如果一个函数的形状不是各向同性的,也就是说它是拉长的,它的搜索路径会变得低效。因此,我们需要比 SGD 更智能的方法,能够仅沿梯度方向移动。SGD 搜索路径低效的根本原因是梯度并没有指向正确的最小值:

图 6.3:SGD 优化更新路径 — 由于它以锯齿状方式逼近最小值(0, 0),效率低
为了改进 SGD 的缺点,我们将介绍三种替代方法:动量法、AdaGrad 和 Adam。我们将简要描述它们,并展示它们的方程和 Python 实现。
动量
动量与物理学有关;它意味着“运动量”。动量法通过以下方程表示:
![]() |
(6.3) |
|---|---|
![]() |
(6.4) |
就像 SGD 一样,W 是要更新的权重参数,
是 W 的损失函数梯度,η是学习率。这里出现的新变量 v 是物理学中的“速度”。方程 6.3 表示了一个物理法则,说明物体在梯度方向上受力并由此加速。在动量法中,更新函数就像球在地面上滚动一样,如下图所示:

图 6.4:动量图像 — 一个球在地面坡道上滚动
方程 6.3 中的项αv 会在物体不受力时逐渐减速(为α设置一个值,如 0.9)。这就是地面或空气阻力造成的摩擦力。以下代码展示了动量的实现(源代码位于common/optimizer.py):
class Momentum:
def __ init __ (self, lr=0.01, momentum=0.9):
self.lr = lr
self.momentum = momentum
self.v = None
def update(self, params, grads):
if self.v is None:
self.v = {}
for key, val in params.items():
self.v[key] = np.zeros_like(val)
for key in params.keys():
self.v[key] = self.momentum*self.v[key] - self.lr*grads[key]
params[key] += self.v[key]
实例变量v保留了物体的速度。在初始化时,v不保留任何值。当调用update()时,它保留了与字典变量结构相同的数据。剩下的实现很简单:它只需实现方程 6.3 和 6.4。
现在,让我们使用动量来解决方程 6.2 中的优化问题。下图展示了结果。
如下图所示,更新路径像一个球在碗中滚动。可以看到,与 SGD 相比,“之字形程度”有所减少。x 轴方向上的力非常小,但物体始终在相同的方向上受到力,并且在相同的方向上不断加速。另一方面,y 轴方向上的力很大,但物体在正负方向上交替受力,彼此抵消,因此 y 轴方向上的速度不稳定。这可以加速 x 轴方向上的运动,并减少与 SGD 相比的之字形运动:

图 6.5:通过动量优化的更新路径
AdaGrad
在神经网络训练中,学习rate--η在equation--中的值非常重要。如果它太小,训练会持续太长时间。如果它太大,训练会发散,无法达到正确的训练效果。
有一种有效的学习率技术叫做学习率衰减。它随着训练的进行使用更低的学习率。这种方法在神经网络训练中经常使用。神经网络首先学习“很多”,然后逐渐学习“更少”。
逐渐降低学习率等同于逐渐减小所有参数的学习率。AdaGrad(John Duchi, Elad Hazan, and Yoram Singer (2011): Adaptive Subgradient Methods for Online Learning and Stochastic Optimization. Journal of Machine Learning Research 12, Jul (2011), 2121 – 2159.)是这种方法的一个高级版本。AdaGrad 为每个参数创建了一个定制的值。
AdaGrad 会自适应地调整每个参数元素的学习率以进行训练(AdaGrad 中的"Ada"来自于"Adaptive")。现在,我们将通过方程展示 AdaGrad 的更新方法:
![]() |
(6.5) |
|---|---|
![]() |
(6.6) |
就像 SGD 一样,W 是需要更新的权重参数,
是 W 的损失函数梯度,η是学习率。这里引入了一个新变量 h。h 变量存储了迄今为止梯度值的平方和,如方程 6.5 所示(方程 6.5 中的⊙表示数组元素之间的乘法)。在更新参数时,AdaGrad 通过乘以
来调整学习的规模。对于已经显著移动的参数元素(即已经进行了大量更新的参数),学习率会变小。因此,您可以通过逐渐减少那些显著移动的参数的学习率来衰减每个参数元素的学习率。
注意
AdaGrad 将所有过去的梯度记录为平方和。因此,随着学习的进行,更新的程度变小。当学习无限进行时,更新的程度变为 0,导致没有更新。RMSProp(Tieleman, T., & Hinton, G. (2012): Lecture 6.5—RMSProp: Divide the gradient by a running average of its recent magnitude. COURSERA: Neural Networks for Machine Learning)方法解决了这个问题。它不会对所有过去的梯度进行等权重处理,而是逐渐遗忘过去的梯度,并进行加权处理,从而使新梯度的信息得以清晰反映。这样,过去梯度的规模会指数衰减,这就是所谓的“指数加权平均”。
现在,让我们来实现 AdaGrad。您可以按如下方式实现 AdaGrad(源代码位于common/optimizer.py):
class AdaGrad:
def __init__ (self, lr=0.01):
self.lr = lr
self.h = None
def update(self, params, grads):
if self.h is None:
self.h = {}
for key, val in params.items():
self.h[key] = np.zeros_like(val)
for key in params.keys():
self.h[key] += grads[key] * grads[key]
params[key] -= self.lr * grads[key] / (np.sqrt(self.h[key]) + 1e-7)
请注意,最后一行添加了一个小的值1e-7。这可以防止当self.h[key]包含0时发生除以0的情况。在许多深度学习框架中,您可以将这个小值配置为一个参数,但在这里,使用了一个固定值1e-7。
现在,让我们用 AdaGrad 来解决方程 6.2 中的优化问题。下图展示了结果:

图 6.6:AdaGrad 优化的更新路径
前面的图像显示了参数高效地向最小值移动。最初,参数移动较多,因为 y 轴方向的梯度很大。调整与大幅运动成比例进行,因此更新步长变小。这样,y 轴方向的更新程度被减弱,从而减少了锯齿形的波动。
Adam
在 Momentum 中,参数的更新基于物理法则,就像一个球在碗中滚动一样。AdaGrad 为每个参数元素自适应地调整更新步长。那么,当 Momentum 和 AdaGrad 这两种技术结合时,会发生什么呢?这就是 Adam 技术的基本思想(这里对 Adam 的解释是直观的,缺少一些更细致的技术细节。如需更精确的定义,请参阅原文)。
Adam 是一种新的技术,于 2015 年提出。其理论略显复杂。直观上来说,它类似于动量和 AdaGrad 的结合体。通过结合这两种技术的优势,我们可以期待有效地搜索参数空间。超参数的“偏差校正”也是 Adam 的一个特点。更多细节,请参阅原始论文(Diederik Kingma 和 Jimmy Ba. (2014): Adam: A Method for Stochastic Optimization. arXiv:1412.6980[cs] (2014 年 12 月))。在 Python 中实现为common/optimizer.py中的Adam类。
现在,让我们使用 Adam 来解决方程 6.2 的优化问题。下图显示了结果。

图 6.7: Adam 优化的更新路径
如图 6.7所示,Adam 的更新路径就像是把球滚进碗里一样移动。这种运动类似于动量,但球的左右运动较小。这种优势是由学习率的自适应调整引起的。
注意
Adam 有三个超参数。第一个是学习率(在论文中表示为α)。其他两个是主动量的系数β1 和次动量的系数β2。文章指出,标准值分别为β1 为 0.9,β2 为 0.999,在许多情况下非常有效。
我们应该使用哪种更新技术?
到目前为止,我们已经考虑了四种参数更新技术。在这里,我们将比较它们的结果(源代码位于ch06/optimizer_compare_naive.py)。
如图 6.8所示,不同的技术使用不同的路径来更新参数。这幅图似乎显示 AdaGrad 是最好的,但请注意,结果因解决的问题而异。当然,结果也会因超参数的值(如学习率)而异:

图 6.8: 优化技术的比较 – SGD、动量、AdaGrad 和 Adam
到目前为止,我们已经看过四种技术:SGD、动量、AdaGrad 和 Adam。但我们应该使用哪一种?遗憾的是,目前没有一种被广泛认可的技术能够解决所有问题。每种技术都有其独特的特点和优势,使其更适合某些问题而不适合其他问题。因此,了解在特定情况下哪种技术最有效非常重要。
SGD 仍然被广泛应用于许多研究中。动量和 AdaGrad 也值得尝试。最近,许多研究人员和工程师似乎更喜欢 Adam。本书主要使用 SGD 和 Adam。您可以根据需要尝试其他技术。
使用 MNIST 数据集比较更新技术
对于手写数字识别,我们将比较迄今为止描述的四种技术:SGD、动量法、AdaGrad 和 Adam。让我们探讨每种技术在训练过程中的工作原理。图 6.9 显示了结果(源代码位于 h06/optimizer_compare_mnist.py):

图 6.9:使用 MNIST 数据集比较四种更新技术——横轴表示学习的迭代次数,纵轴表示损失函数的值
本实验使用了一个五层神经网络,每层有 100 个神经元。ReLU 被用作激活函数。
图 6.9 的结果显示,其他技术的学习速度比 SGD 更快。似乎剩下的三种技术学习得差不多快。当我们仔细观察时,AdaGrad 学得略快一些。在本实验中,值得注意的是,结果会因为学习率的超参数和神经网络的结构(层数)而有所不同。然而,一般来说,其他三种技术的学习速度通常会比 SGD 快,有时还能获得更好的最终识别性能。
初始权重值
初始权重值在神经网络训练中尤为重要。设置什么样的初始权重值通常决定了神经网络训练的成败。本节将解释推荐的初始权重值,并通过实验验证它们是否能加速神经网络的学习。
如何将初始权重值设置为 0?
稍后,我们将介绍一种叫做权重衰减的技术,它可以减少过拟合并提高泛化性能。简而言之,权重衰减是一种减少权重参数值以防止过拟合的技术。
如果我们希望权重较小,从最小的初始值开始可能是一个不错的方法。在这里,我们使用 0.01 * np.random.randn(10, 100) 作为初始权重值。这个小值是从高斯分布中生成的,乘以 0.01——高斯分布的标准差为 0.01。
如果我们希望权重值较小,怎样将所有初始权重值设置为 0 呢?这是一个糟糕的主意,因为它会阻止我们正确地训练。
为什么初始权重值不应该是 0?换句话说,为什么权重不应该是统一值?因为在反向传播中,所有权重值会以相同的方式均匀更新。所以,假设在一个两层神经网络中,第一层和第二层的权重都是 0。那么,在前向传播时,输入层的所有神经元的值都会被传递到第二层,因为输入层的权重是 0。当相同的值输入到第二层的所有神经元时,在反向传播时,第二层的所有权重都会以相同的方式更新(请记住“在乘法节点中的反向传播”)。因此,权重以相同的值更新,并且变成对称值(重复值)。因此,拥有许多权重没有意义。为了防止权重统一或破坏其对称结构,需要使用随机初始值。
隐藏层激活值的分布
观察隐藏层中激活值的分布(这里指的是激活函数后的输出数据,尽管一些文献称流经各层的数据为“激活”)提供了很多信息。在这里,我们将进行一个简单的实验,看看初始权重值如何改变隐藏层中的激活值。我们将一些随机生成的数据输入到一个五层神经网络中(使用 sigmoid 函数作为激活函数),并通过直方图展示每一层激活值的分布。这个实验基于斯坦福大学的 CS231n 课程(CS231n: 卷积神经网络与视觉识别 (cs231n.github.io/))。
实验的源代码位于ch06/weight_init_activation_histogram.py。以下是部分代码:
import numpy as np
import matplotlib.pyplot as plt
def sigmoid(x):
return 1 / (1 + np.exp(-x))
x = np.random.randn(1000, 100) # 1000 data
node_num = 100 # Number of nodes (neurons) in each hidden layer
hidden_layer_size = 5 # Five hidden layers exist
activations = {} # The results of activations are stored here
for i in range(hidden_layer_size):
if i != 0:
x = activations[i-1]
w = np.random.randn(node_num, node_num) * 1
z = np.dot(x, w)
a = sigmoid(z) # Sigmoid function!
activations[i] = a
这里有五层,每一层有 100 个神经元。作为输入数据,随机生成 1,000 个数据点,服从高斯分布,并提供给这五层神经网络。使用 sigmoid 函数作为激活函数,每一层的激活结果存储在activations变量中。请注意权重的规模。这里使用标准差为 1 的高斯分布。这个实验的目的是通过改变这个尺度(标准差)来观察activations的分布如何变化。现在,让我们展示存储在activations中的每一层数据,并以直方图的形式呈现:
# Draw histograms
for i, a in activations.items( ):
plt.subplot(1, len(activations), i+1)
plt.title(str(i+1) + "-layer")
plt.hist(a.flatten(), 30, range=(0,1))
plt.show()
执行此代码将生成下图所示的直方图。
该图显示,每一层的激活值主要为 0 和 1。这里使用的 sigmoid 函数是一种 S 曲线函数。当 sigmoid 函数的输出接近 0(或 1)时,微分的值会接近 0。因此,当数据主要是 0 和 1 时,反向传播中的梯度值会变得越来越小,直到消失。这就是所谓的梯度消失问题。在深度学习中,层数较多时,梯度消失问题可能会变得更加严重。
接下来,让我们进行相同的实验,但这次权重的标准差为 0.01。为了设置初始权重值,您需要修改之前的代码,如下所示:
# w = np.random.randn(node_num, node_num) * 1
w = np.random.randn(node_num, node_num) * 0.01

图 6.10:当使用标准差为 1 的高斯分布作为初始权重值时,各层激活值的分布
观察结果。以下图像显示了当使用标准差为 0.01 的高斯分布作为初始权重值时,各层激活值的分布:

图 6.11:当使用标准差为 0.01 的高斯分布作为初始权重值时,各层激活值的分布
现在,激活值集中在 0.5 附近。与之前的例子不同,它们并不偏向于 0 和 1。梯度消失问题没有发生。然而,当激活值存在偏差时,会在表示能力上造成很大问题。如果多个神经元输出几乎相同的值,那么这些神经元就没有存在的意义。例如,当 100 个神经元输出几乎相同的值时,一个神经元就可以代表几乎相同的内容。因此,偏置的激活值会导致问题,因为表示能力受到限制。
注意
各层激活值的分布需要适当地分散。这是因为,当每一层的输入数据适度多样时,神经网络能够高效地学习。另一方面,当数据有偏时,训练可能会因为梯度消失和“有限的表示能力”而变得不顺利。
接下来,我们将使用 Xavier Glorot 等人在论文中推荐的初始权重值(Xavier Glorot 和 Yoshua Bengio(2010):理解训练深度前馈神经网络的难度。发表于《国际人工智能与统计会议论文集》(AISTATS2010)。人工智能与统计学会)。这就是所谓的“Xavier 初始化”。目前,Xavier 初始化器通常在普通的深度学习框架中使用。例如,在 Caffe 框架中,你可以为初始权重设置指定 xavier 参数来使用 Xavier 初始化器。
Xavier 的论文得出了适当的权重尺度,以便每一层的激活值能够相似地分布。论文中指出,当前一层有 n 个节点时,应使用标准差为
的分布作为初始值(Xavier 的论文建议设置同时考虑前一层输入节点和下一层输出节点的数量。然而,在像 Caffe 这样的框架实现中,值仅基于前一层的输入节点数量计算,以简化实现,如这里所述)。这一点可以从下图中看到:

图 6.12:Xavier 初始化器 – 当前一层有 n 个节点连接时,初始值使用标准差为
的分布
当使用 Xavier 初始化器时,由于前一层的节点数量较大,因此为目标节点设置的初始值权重的尺度较小。现在,让我们使用 Xavier 初始化器来完成一些实验。你只需修改初始权重值,如下所示(这里的实现进行了简化,因为所有层的节点数量均为 100):
node_num = 100 # Number of nodes in the previous layer
w = np.random.randn(node_num, node_num) / np.sqrt(node_num)

图 6.13:当使用 Xavier 初始化器作为初始权重值时,各层激活值的分布
上图展示了使用 Xavier 初始化器时的结果。它表明分布更广泛,尽管较高的层呈现出更扭曲的形状。我们可以预期,由于每层中流动的数据得到了正确分布,训练将会高效进行,并且 sigmoid 函数的表示不会受到限制。
注意
此外,上层的分布在形状上略有扭曲。使用tanh函数(双曲函数)代替sigmoid函数时,扭曲的形状有所改善。实际上,当使用tanh函数时,分布将呈现钟形曲线。tanh函数是一种 S 型曲线函数,类似于sigmoid函数。tanh函数关于原点(0, 0)对称,而sigmoid函数则关于(x, y) = (0, 0.5)对称。最好使用tanh函数,这样激活函数就能关于原点对称。
ReLU 的初始权重值
Xavier 初始化器基于激活函数是线性的假设。Xavier 初始化器是合适的,因为sigmoid和tanh函数是对称的,可以在其中心附近被视为线性函数。与此同时,对于 ReLU,建议使用初始值。这被称为 He 初始化器,由 Kaiming He 等人提出并推荐(Kaiming He, Xiangyu Zhang, Shaoqing Ren, 和 Jian Sun (2015):深入研究整流器:超越 ImageNet 分类中的人类水平表现。在 1026 – 1034)。He 初始化器使用标准差为
的高斯分布,当前一层的节点数为 n 时。如果我们考虑到 Xavier 初始化器是
,我们可以直观地假设系数必须加倍,以提供更广的分布,因为对于 ReLU,负区域为 0。
让我们来看一下当 ReLU 作为激活函数时激活分布的情况。我们将考虑在使用标准差为 0.01(即std=0.01)的高斯分布、Xavier 初始化器和专为 ReLU 设计的 He 初始化器后进行的三个实验的结果(图 6.14)。
结果表明,当std=0.01时,每一层的激活值都非常小(各层分布的平均值如下:第 1 层:0.0396,第 2 层:0.00290,第 3 层:0.000197,第 4 层:1.32e-5,第 5 层:9.46e-7)。当小数据流过神经网络时,反向传播中权重的梯度也很小。这是一个严重的问题,因为训练几乎无法推进。
接下来,我们来看一下使用 Xavier 初始化器的结果。这表明,随着层数加深,偏置逐渐增大,激活值也一样。训练时会出现梯度消失的问题。另一方面,对于 He 初始化器,每一层中高斯分布的扩展是相似的。即使层数更深,数据的扩展也相似。因此,我们可以预期在反向传播时也能流动适当的值。
总结来说,当使用 ReLU 作为激活函数时,应使用 He 初始化器;对于像sigmoid和tanh这样的 S 型曲线函数,应使用 Xavier 初始化器。至于目前写作时,这是最佳实践。
使用 MNIST 数据集比较权重初始化器
让我们用实际数据来看不同权重初始化器如何影响神经网络的学习。我们将在实验中使用std=0.01、Xavier 初始化器和 He 初始化器(源代码位于ch06/weight_init_compare.py)。下图展示了结果:

图 6.14:当使用 ReLU 作为激活函数时,权重初始化器对激活值分布的变化
本实验使用一个五层的神经网络(每层 100 个神经元)和 ReLU 作为激活函数。下图所示的结果揭示了对于std=0.01,没有进行学习。这是因为在前向传播时,较小的值(接近 0 的数据)流动,就像我们在激活值分布中看到的那样。因此,在反向传播时计算得到的梯度也很小,导致权重更新的次数很少。另一方面,Xavier 和 He 初始化器的训练过程顺利进行。下图也显示了对于 He 初始化器,训练进展较快:

图 6.15:使用 MNIST 数据集比较权重初始化器——横轴表示训练的迭代次数,纵轴表示损失函数的值
如我们所见,初始权重值在神经网络训练中非常重要。它们往往决定了训练的成功与否。尽管初始权重值的重要性有时被忽视,但起始(初始)值对一切都是重要的。
批量归一化
在前一节中,我们观察了每一层激活值的分布。我们了解到,适当的初始权重值为每层激活值的分布提供了一个适当的扩展,从而使训练过程顺利进行。那么,强制调整激活值的分布,使其在每一层中都有适当的扩展,效果如何呢?
该技术基于批量归一化的思想(Sergey Ioffe 和 Christian Szegedy(2015):批量归一化:通过减少内部协变量偏移加速深度网络训练。arXiv:1502.03167[cs](2015 年 2 月))。
批量归一化算法
批量归一化(也称为 batch norm)最早在 2015 年提出。尽管批量归一化是一项新技术,但它已被许多研究人员和工程师广泛应用。事实上,在围绕机器学习的竞赛中,批量归一化常常能够取得优异的成绩。
批量归一化因其以下优点而受到广泛关注:
-
它可以加速学习(可以增加学习率)。
-
它不那么依赖于初始权重值(你不需要对初始值过于小心)。
-
它减少了过拟合(减少了对 dropout 的需求)。
第一个优点特别吸引人,因为深度学习需要大量时间。通过批量归一化,初始权重值无需过于担心,而且由于它减少了过拟合,它消除了深度学习中的这种焦虑来源。
正如我们之前所述,批量归一化的目的是调整每一层激活值的分布,使其具有适当的分布范围。为此,规范化数据分布的层被插入到神经网络中,成为批量归一化层(也称为批量归一化层),如下面的图所示:

图 6.16:使用批量归一化的神经网络示例(批量归一化层以灰色显示)
如其名称所示,批量归一化对用于训练的每个小批次进行规范化。具体来说,它将数据规范化,使得平均值为 0,方差为 1。以下方程展示了这一点:
![]() |
(6.7) |
|---|
在这里,一组 m 个输入数据,b
,被视为一个小批次,并计算其平均值,
,和方差,
。输入数据被规范化,使其平均值为 0,方差为 1,以实现适当的分布。在方程 6.7 中,ε是一个小值(如 10e-7)。这可以防止除以 0 的情况。
方程 6.7 简单地将一个小批次的输入数据,
,转换为平均值为 0、方差为 1 的数据,
。通过将此过程插入激活函数之前(或之后)(请参见Sergey Ioffe 和 Christian Szegedy (2015):批量归一化:通过减少内部协变量偏移来加速深度网络训练. arXiv:1502.03167[cs] (2015 年 2 月)和Dmytro Mishkin 和 Jiri Matas (2015):你需要的只是一个好的初始化. arXiv:1511.06422[cs] (2015 年 11 月)讨论(和实验)是否应将批量归一化插入激活函数之前或之后),你可以减少数据的分布偏差。
此外,批量归一化层将数据规范化为具有特殊缩放和平移的形式。以下方程展示了这一转换:
![]() |
(6.8) |
|---|
这里,γ 和 β 是参数。它们从 γ = 1 和 β = 0 开始,并将通过训练调整为适当的值。
这是批量归一化的算法。该算法提供了神经网络中的前向传播。通过使用计算图,如第五章《反向传播》所述,我们可以将批量归一化表示如下。
我们在这里不会详细讨论如何推导批量归一化的反向传播,因为它有些复杂。当你使用计算图时,像下图所示的图,你可以相对容易地推导出批量归一化的反向传播。Frederik Kratzert 的博客 理解批量归一化层中的反向传播 (kratzert.github.io/2016/02/12/understanding-the-gradient-flow-through-the-batch-normalization-layer.html) 提供了详细的描述。如果你感兴趣,请参考:

图 6.17:批量归一化的计算图
注意
图 6.17 引用了参考文献,Frederik Kratzert 的博客 “理解批量归一化层中的反向传播” (kratzert.github.io/2016/02/12/understanding-the-gradient-flow-through-the-batch-normalization-layer.html)。
评估批量归一化
现在,让我们使用批量归一化层进行一些实验。首先,我们将使用 MNIST 数据集,看看在有无批量归一化层的情况下,学习进度如何变化(源代码可以在 ch06/batch_norm_test.py 找到)。图 6.18 展示了结果。
图 6.18 显示了批量归一化加速训练的效果。接下来,我们看看在使用不同初始值规模时,训练进度如何变化。图 6.19 包含了当初始权重值的标准差发生变化时,训练进度的图表。
这表明在几乎所有情况下,批量归一化都能加速训练。事实上,当不使用批量归一化时,如果没有合适的初始值规模,训练根本无法进行。
正如我们所见,使用批量归一化可以加速训练,并且为初始权重值提供了鲁棒性(“鲁棒性”意味着对初始值的依赖较小)。由于其如此优秀的特性,批量归一化将在许多情况下发挥积极作用。
正则化
过拟合 通常会给机器学习问题带来困难。在过拟合的情况下,模型过度拟合训练数据,无法正确处理不包含在训练数据中的其他数据。机器学习的目标是泛化性能。理想情况下,模型应该能够正确识别不包含在训练数据中的未知数据。虽然你可以通过这种方式创建一个复杂且具有代表性的模型,但减少过拟合同样很重要:

图 6.18:批量归一化的效果——批量归一化加速学习
过拟合
过拟合的主要原因有两个:
-
模型参数较多,且具有代表性。
-
训练数据不足。
在这里,我们通过提供这两个原因来产生过拟合。在 MNIST 数据集中,60,000 条训练数据中仅提供了 300 条,并且使用了一个七层的网络来增加网络的复杂性。每层有 100 个神经元。ReLU 作为激活函数:

图 6.19:实线表示使用批归一化的结果,虚线表示未使用批归一化的结果——每个图的标题表示初始权重值的标准差
以下是本实验的一部分代码(源文件位于ch06/overfit_weight_decay.py)。首先,代码加载数据:
(x_train, t_train), (x_test, t_test) = load_mnist(normalize=True)
# Reduce learning data to reproduce overfitting
x_train = x_train[:300]
t_train = t_train[:300]
以下代码进行训练。在这里,识别准确率对于每个训练周期的所有训练数据和测试数据都进行计算:
network = MultiLayerNet(input_size=784, hidden_size_list=[100, 100, 100, 100, 100, 100], output_size=10)
optimizer = SGD(lr=0.01) # Use SGD with the learning rate of 0.01 to update the parameters
max_epochs = 201
train_size = x_train.shape[0]
batch_size = 100
train_loss_list = []
train_acc_list = []
test_acc_list = []
iter_per_epoch = max(train_size / batch_size, 1
epoch_cnt = 0
for i in range(1000000000):
batch_mask = np.random.choice(train_size, batch_size)
x_batch = x_train[batch_mask]
t_batch = t_train[batch_mask]
grads = network.gradient(x_batch, t_batch)
optimizer.update(network.params, grads)
if i % iter_per_epoch == 0:
train_acc = network.accuracy(x_train, t_train)
test_acc = network.accuracy(x_test, t_test)
train_acc_list.append(train_acc)
test_acc_list.append(test_acc)
epoch_cnt += 1
if epoch_cnt >= max_epochs:
break
train_acc_list 和 test_acc_list 列表存储了每个周期的识别准确率。一个周期表示所有训练数据已被使用。我们基于这些列表(train_acc_list 和 test_acc_list)绘制图表。以下图表显示了结果。
使用训练数据测量的识别准确率在 100 个周期后几乎达到了 100%,但在测试数据上的识别准确率远低于 100%。这些大的差异是由于训练数据的过拟合造成的。该图显示了模型无法正确处理在训练中未使用的通用数据(测试数据):

图 6.20:训练数据(train)和测试数据(test)识别准确率的变化
权重衰减
权重衰减技术常用于减少过拟合。通过在训练过程中对大权重施加惩罚,它避免了过拟合。当一个权重参数取较大值时,往往会发生过拟合。
如前所述,神经网络训练的目的是减少损失函数的值。例如,您可以将权重的平方范数(L2 范数)添加到损失函数中。这样,您就可以防止权重过大。当权重为 W 时,权重衰减的 L2 范数为
。此项
被添加到损失函数中。在这里,λ是控制正则化强度的超参数。如果您将较大的值设置给λ,您可以对较大的权重施加更强的惩罚。
出现在
的开始处,是一个常数,用于调整,以便
的微分为λW。
权重衰减将
添加到所有权重的损失函数中。因此,在计算权重的梯度时,正则化项λW 的微分会被添加到反向传播的结果中。
L2 范数是每个元素的平方和。除了 L2 范数,还有 L1 范数和 L ∞范数。L1 范数是绝对值之和,即|w1| + |w2| + ... + |wn|。L ∞范数也称为最大范数,是所有元素绝对值中的最大值。你可以选择这些范数中的任何一个作为正则化项。每个范数都有其特点,但这里我们只实现 L2 范数,因为它是最常用的。
现在,让我们进行一个实验。我们将在前述实验中应用λ= 0.1 的权重衰减。下图显示了实验结果(支持权重衰减的网络位于common/multi_layer_net.py,实验代码位于ch06/overfit_weight_decay.py):

图 6.21:使用权重衰减时,训练数据(train)和测试数据(test)识别准确率的变化
上图显示了训练数据和测试数据的识别准确率有所不同,但与图 6.20(未使用权重衰减)所示的情况相比,差异较小。这表明过拟合已被减少。注意,训练数据的识别准确率尚未达到 100%(1.0)。
Dropout
前一节描述了权重衰减技术。它将权重的 L2 范数添加到损失函数中以减少过拟合。权重衰减实现简单,并且可以在一定程度上减少过拟合。然而,随着神经网络模型的复杂化,权重衰减往往不足够。这时,常常使用 Dropout 技术(N. Srivastava, G. Hinton, A. Krizhevsky, I. Sutskever, 和 R. Salakhutdinov(2014):Dropout:一种防止神经网络过拟合的简单方法。《机器学习研究杂志》,1929–1958 页,2014 年)。
Dropout 在训练过程中随机删除神经元。训练时,它会随机选择隐藏层中的神经元并将其删除。如下面的图所示,被删除的神经元不会传递信号。训练时,每次数据流动时,都会随机选择要删除的神经元。测试时,所有神经元的信号都会传播。每个神经元的输出会乘以训练时被删除神经元的比率:

图 6.22:Dropout 概念
注意
图 6.22 引用自文献,N. Srivastava, G. Hinton, A. Krizhevsky, I. Sutskever, 和 R. Salakhutdinov(2014):Dropout:一种防止神经网络过拟合的简单方法。《机器学习研究杂志》,1929–1958 页,2014 年。
左侧图片展示了普通神经网络,而右侧图片展示了应用了 dropout 的网络。Dropout 随机选择神经元并将其删除,以阻止后续信号的传递。
现在,让我们实现 dropout。这里实现的重点是简洁性。如果在训练过程中进行适当的计算,我们只需通过前向传播流动数据(无需乘以被删除神经元的比率)。这种实现方式在深度学习框架中得到了广泛应用。例如,Chainer 框架中实现的 dropout 可能会很有用:
class Dropout:
def __init__ (self, dropout_ratio=0.5):
self.dropout_ratio = dropout_ratio
self.mask = None
def forward(self, x, train_flg=True):
if train_flg:
self.mask = np.random.rand(*x.shape) > self.dropout_ratio
return x * self.mask
else:
return x * (1.0 - self.dropout_ratio)
def backward(self, dout):
return dout * self.mask
请注意,在每次前向传播中,要删除的神经元会在self.mask中被标记为False。self.mask会随机生成一个与x形状相同的数组,并在其值大于dropout_ratio时将对应元素设置为True。反向传播中的行为与 ReLU 相同。如果一个神经元在前向传播中传递了信号,它会在反向传播中不加改变地传递该信号。如果一个神经元在前向传播中没有传递信号,它将在反向传播中停止该信号的传递。
我们将使用 MNIST 数据集来验证 dropout 的效果。源代码可以在ch06/overfit_dropout.py中找到。它使用Trainer类来简化实现。
Trainer类在common/trainer.py中实现。它执行了本章迄今为止的网络训练。有关详细信息,请参见common/trainer.py和ch06/overfit_dropout.py。
为了实验 dropout,我们将使用一个七层的网络(每层包含 100 个神经元,并且 ReLU 作为激活函数),与之前的实验相同。一个实验将使用 dropout,而另一个则不使用。下图展示了结果。
如我们所见,使用 dropout 可以减少训练数据和测试数据之间的识别准确率差异。它还表明,训练数据的识别准确率并没有达到 100%。因此,即使在一个典型的网络中,你也可以使用 dropout 来减少过拟合:

图 6.23:左侧图片展示了没有使用 dropout 的实验,右侧图片展示了使用了 dropout(dropout_rate=0.15)的实验
注意
在机器学习中,常常使用集成学习,其中多个模型分别学习,然后通过预测对它们的多个输出进行平均。例如,当我们在神经网络中使用集成学习时,我们准备五个具有相同(或相似)结构的网络并训练每一个。然后,在测试时,我们将五个输出的平均值作为结果。实验表明,集成学习能将神经网络的识别准确率提高几个百分点。
集成学习与 dropout 很相似。在 dropout 中随机删除神经元的过程可以解释为每次提供一个不同的模型来学习数据。在预测时,神经元的输出会乘以删除率(例如 0.5)来对模型进行平均。因此,我们可以说 dropout 在一个网络中模拟了集成学习。
验证超参数
神经网络使用许多超参数,以及如权重和偏置等参数。这里的超参数包括每层神经元的数量、批次大小、更新参数的学习率以及权重衰减。将超参数设置为不合适的值会导致模型性能下降。这些超参数的值非常重要,但确定它们通常需要大量的反复试验。本节将介绍如何尽可能高效地搜索超参数值。
验证数据
在我们到目前为止使用的数据集中,训练数据和测试数据是分开的。训练数据用于训练网络,而测试数据用于评估泛化性能。因此,你可以判断网络是否过于拟合训练数据(即是否发生了过拟合),以及其泛化性能有多大。
我们将使用不同的超参数设置进行验证。请注意,绝不能使用测试数据来评估超参数的性能。这一点非常重要,但往往被忽视。
那么,为什么不能使用测试数据来评估超参数的性能呢?如果使用测试数据来调整超参数,超参数的值会过拟合测试数据。换句话说,它使用测试数据来检查超参数值是否“合适”,因此调整超参数值使其仅适应测试数据。在这种情况下,模型可能会提供较低的泛化性能,并且无法适应其他数据。
因此,我们需要使用验证数据(称为验证数据)来调整它们。这些验证数据用于评估我们超参数的质量。
训练数据用于学习参数(权重和偏置)。验证数据用于评估超参数的性能。测试数据则用于(理想情况下只用一次)训练结束后检查泛化性能。
一些数据集提供单独的训练数据、验证数据和测试数据。有些则只提供训练数据和测试数据,而有些只提供一种类型的数据。在这种情况下,你必须手动分离数据。对于 MNIST 数据集,获取验证数据的最简单方法是预先分离出 20% 的训练数据并将其作为验证数据。以下代码展示了这一点:
(x_train, t_train), (x_test, t_test) = load_mnist()
# Shuffle training data
x_train, t_train = shuffle_dataset(x_train, t_train)
# Separate validation data
validation_rate = 0.20
validation_num = int(x_train.shape[0] * validation_rate)
x_val = x_train[:validation_num]
t_val = t_train[:validation_num]
x_train = x_train[validation_num:]
t_train = t_train[validation_num:]
在此,输入数据和标注数据在分离训练数据之前会进行洗牌。这是因为某些数据集可能存在偏差(例如,数字“0”到“10”按顺序排列)。shuffle_dataset函数使用np.random.shuffle,并包含在common/util.py中。
接下来,让我们使用验证数据来看一下优化超参数的技术。
优化超参数
在优化超参数时,重要的是逐步缩小包含“好”超参数值的范围。为此,我们将首先设置一个较大的范围,从该范围内随机选择超参数(采样),并使用采样得到的值来评估识别准确率。接下来,我们将重复这些步骤几次,并观察识别准确率的结果。根据结果,我们将缩小“好”超参数值的范围。通过重复这个过程,我们可以逐渐限制合适超参数的范围。
有报告称,在进行搜索之前进行随机采样比系统性搜索(如网格搜索)在优化神经网络的超参数时提供更好的结果 (James Bergstra 和 Yoshua Bengio (2012):随机搜索超参数优化. 机器学习研究期刊 13, 2012 年 2 月,281–305)。这是因为不同的超参数对最终识别准确率的影响程度不同。
指定“广泛”范围的超参数是有效的。我们将以“10 的幂”为单位指定范围,例如从 0.001(10^−3)到 1000(10³)(这也叫做“在对数尺度上指定”)。
请注意,在优化超参数时,深度学习需要大量时间(甚至可能需要几天或几周)。因此,在寻找超参数时,任何看似不合适的超参数必须被舍弃。在优化超参数时,减少训练的 epoch 大小以缩短每次评估所需的时间是有效的。我们之前已经讨论过超参数的优化,以下是对此讨论的总结:
步骤 0
指定超参数的范围。
步骤 1
从范围内随机采样超参数。
步骤 2
使用在步骤 1中采样得到的超参数值进行训练,并使用验证数据评估识别准确率(设置较小的 epoch)。
步骤 3
重复步骤 1和步骤 2若干次(例如 100 次),并根据识别准确率的结果逐步缩小超参数的范围。当范围缩小到一定程度时,从中选择一个超参数值。这是优化超参数的一个实际方法。
注意
然而,你可能会觉得这种方法更多是工程师的“智慧”而非科学。如果你需要一种更精细的优化超参数的技术,可以使用贝叶斯优化。它很好地利用了贝叶斯定理等数学理论,提供了更严格且更高效的优化。详细内容请参见论文 Practical Bayesian Optimization of Machine Learning Algorithms(Jasper Snoek, Hugo Larochelle, and Ryan P. Adams (2012):Practical Bayesian Optimization of Machine Learning Algorithms. In F. Pereira, C. J. C. Burges, L. Bottou, & K. Q. Weinberger, eds. Advances in Neural Information Processing Systems 25. Curran Associates, Inc., 2951 – 2959)。
实现超参数优化
现在,让我们使用 MNIST 数据集来优化一些超参数。我们将寻找两个超参数:学习率和权重衰减率。权重衰减率控制权重衰减的强度。这个问题和解决方案基于斯坦福大学的CS231n(CS231n: Convolutional Neural Networks for Visual Recognition(cs231n.github.io/))课程。
如前所述,超参数是通过在对数尺度范围内随机采样来验证的,例如从 0.001(10−3)到 1,000(103)。我们可以在 Python 中写成 10 ** np.random.uniform(-3, 3)。这个实验将从权重衰减率的范围 10−8 到 10−4 以及学习率的范围 10−6 到 10−2 开始。在这种情况下,我们可以将超参数的随机采样写成如下形式:
weight_decay = 10 ** np.random.uniform(-8, -4)
lr = 10 ** np.random.uniform(-6, -2)
在这里,超参数是随机采样的,采样值用于训练。然后,使用不同的超参数值重复训练多次,以寻找适合的超参数值。这里省略了实现的细节,只显示了结果。优化超参数的源代码位于ch06/hyperparameter_optimization.py。
当我们有权重衰减率的范围 10−8 到 10−4 和学习率的范围 10−6 到 10−2 时,我们得到以下结果。在这里,我们可以看到在学习验证数据时,按高识别准确率的降序排列的过渡:

图 6.24:实线表示验证数据的识别准确率,虚线表示训练数据的识别准确率
这表明训练从 Best-1 到 Best-5 顺利进行。让我们检查一下 Best-1 到 Best-5 的超参数值(即学习率和权重衰减率)。这些是结果:
Best-1 (val acc:0.83) | lr:0.0092, weight decay:3.86e-07
Best-2 (val acc:0.78) | lr:0.00956, weight decay:6.04e-07
Best-3 (val acc:0.77) | lr:0.00571, weight decay:1.27e-06
Best-4 (val acc:0.74) | lr:0.00626, weight decay:1.43e-05
Best-5 (val acc:0.73) | lr:0.0052, weight decay:8.97e-06
在这里,我们可以看到,当学习率为 0.001 到 0.01,权重衰减率为 10−8 到 10−6 时,学习进展良好。因此,观察到训练成功的超参数范围有所缩小。你可以在缩小的范围内重复相同的过程,从而逐步缩小适合超参数的范围,并在某个阶段选择最终的超参数。
总结
本章介绍了一些用于神经网络训练的重要技术。如何更新参数、如何指定初始权重值、批量归一化和 Dropout 是现代神经网络中使用的基本技术。这里描述的技术通常用于最先进的深度学习。在本章中,我们学习了以下内容:
-
四种著名的参数更新方法:Momentum、AdaGrad、Adam 和 SGD。
-
如何指定初始权重值,这是正确训练时非常重要的。
-
Xavier 初始化器和 He 初始化器,这些是有效的初始权重值。
-
批量归一化加速训练并提高初始权重值的鲁棒性。
-
权重衰减和 Dropout 是减少过拟合的正则化技术。
-
寻找合适超参数的有效方法是逐渐缩小合适值所在的范围。
第八章:7. 卷积神经网络
本章介绍卷积神经网络(CNN)。CNN 广泛应用于 AI 领域,包括图像识别和语音识别。本章将详细讲解 CNN 的机制及如何在 Python 中实现它们。
总体架构
首先,让我们看看 CNN 的网络架构。你可以通过组合各层来创建 CNN,方法与我们之前看到的神经网络相似。然而,CNN 还有其他层:卷积层和池化层。我们将在接下来的部分详细探讨卷积层和池化层。本节将描述如何组合这些层来创建 CNN。
在我们迄今为止看到的神经网络中,所有相邻层的神经元都是连接的。这些层被称为全连接层,我们将它们实现为仿射层。例如,你可以使用仿射层创建一个由五个全连接层组成的神经网络,如图 7.1所示。
如图 7.1所示,在全连接神经网络中,激活函数的 ReLU 层(或 Sigmoid 层)跟随仿射层。在这里,经过四对仿射 – ReLU层后,接着是第五层的仿射层。最后,Softmax 层输出最终结果(概率):

图 7.1:由全连接层(仿射层)组成的示例网络
那么,CNN 的架构是什么样的呢?图 7.2展示了一个示例 CNN:

图 7.2:示例 CNN – 添加了卷积层和池化层(它们显示为灰色矩形)
如图 7.2所示,CNN 具有额外的卷积层和池化层。在 CNN 中,各层按卷积 – ReLU – (池化)的顺序连接(池化层有时会省略)。我们可以将之前的仿射 – ReLU连接视为被“卷积 – ReLU – (池化)”替代。
在图 7.2中的 CNN 中,请注意靠近输出的层是之前的“仿射 – ReLU”对,而最后的输出层是之前的“仿射 – Softmax”对。这就是普通 CNN 中常见的结构。
卷积层
有一些 CNN 特有的术语,如填充和步幅。流经 CNN 中每一层的数据是具有特定形状的数据(如三维数据),这与之前的全连接网络不同。因此,当你第一次学习 CNN 时,可能会觉得它们很复杂。在这里,我们将深入探讨 CNN 中使用的卷积层的机制。
全连接层的问题
我们之前见过的完全连接神经网络使用了完全连接层(仿射层)。在完全连接层中,邻接层中的所有神经元都会连接在一起,并且输出的数量可以任意确定。
然而,完全连接层的问题在于,数据的形状被忽略了。例如,当输入数据是图像时,它通常具有三维形状,取决于高度、宽度和通道维度。然而,当这些三维数据提供给完全连接层时,必须转换为一维的平面数据。在我们之前使用的 MNIST 数据集的例子中,输入图像的形状是 1, 28, 28(1 个通道,高度为 28 像素,宽度为 28 像素),但这些元素被排列成一行,最终得到了 784 个数据点,并提供给了第一个仿射层。
假设一张图像具有三维形状,并且这种形状包含重要的空间信息。识别这些信息的关键模式可能隐藏在三维形状中。空间上接近的像素具有相似的值,RGB 通道彼此紧密相关,而远离的像素则没有关系。然而,完全连接层忽略了形状,将所有输入数据视为等价的神经元(具有相同维度的神经元),因此无法利用形状相关的信息。
另一方面,卷积层保持数据的形状。对于图像,它接收作为三维数据的输入,并将三维数据输出到下一层。因此,卷积神经网络(CNN)可以正确理解具有形状的数据,例如图像。
在卷积神经网络(CNN)中,卷积层的输入/输出数据有时被称为特征图。卷积层的输入数据叫做输入特征图,而卷积层的输出数据叫做输出特征图。在本书中,输入/输出数据和特征图将交替使用。
卷积操作
卷积层中执行的处理称为“卷积操作”,等同于图像处理中的“滤波操作”。让我们看一个例子(图 7.3)来理解卷积操作:

图 7.3:卷积操作 – ⊛ 符号表示卷积操作
如图 7.3所示,卷积操作将滤波器应用于输入数据。在这个例子中,输入数据的形状有高度和宽度,滤波器的形状也是如此。当我们将数据和滤波器的形状表示为(高度,宽度)时,输入大小为(4,4),滤波器大小为(3,3),输出大小为(2,2)。一些文献中使用“核”这个词来表示“滤波器”。
现在,让我们来分析图 7.3中展示的卷积操作所执行的计算。图 7.4展示了卷积操作的计算过程。
卷积操作被应用到输入数据时,滤波器窗口按固定间隔滑动。这里的窗口指的是图 7.4中显示的灰色 3x3 区域。如图 7.4所示,滤波器的元素和输入的对应元素在每个位置上进行乘法和求和(这个计算有时称为乘加操作)。结果被存储在输出的对应位置。通过在所有位置执行这个过程,可以得到卷积操作的输出。
一个全连接神经网络有偏置和权重参数。在卷积神经网络(CNN)中,滤波器参数对应于之前的“权重”。它同样也有偏置。图 7.3中的卷积操作展示了滤波器应用的阶段。图 7.5展示了卷积操作的处理流程,包括偏置:

图 7.4:卷积操作的计算过程

图 7.5:卷积操作中的偏置 – 在应用滤波器后,向元素中添加一个固定值(偏置)
如图 7.5所示,偏置项在应用滤波器后被添加到数据中。在这里,偏置始终是一个固定值(1x1),即在滤波器应用后的四个数据元素中,每个都存在一个偏置。这个值会被加到滤波器应用后的所有元素中。
填充
在卷积层处理之前,有时会在输入数据周围填充固定数据(如 0)。这叫做填充,并且在卷积操作中经常使用。例如,在图 7.6中,对(4, 4)输入数据进行了填充 1。填充 1 意味着用一个像素宽度的零填充周围:

图 7.6:卷积操作中的填充 – 在输入数据周围添加零(这里用虚线表示填充,零被省略)
如图 7.6所示,填充将(4, 4)输入数据转换为(6, 6)数据。在应用(3, 3)滤波器后,生成(4, 4)输出数据。在这个例子中,使用了填充 1。你可以设置任意整数值作为填充值,例如 2 或 3。如果填充值是 2,输入数据的大小将是(8, 8)。如果填充是 3,则大小为(10, 10)。
注
填充主要用于调整输出大小。例如,当一个(3, 3)滤波器应用于(4, 4)输入数据时,输出大小为(2, 2)。输出大小比输入大小少了两个元素。这在深度网络中会引发一个问题,因为卷积操作会重复很多次。如果每次卷积操作都在空间上减小大小,输出大小最终会达到 1,这时就无法再进行卷积操作了。为避免这种情况,可以使用填充。在前面的例子中,当填充宽度为 1 时,输出大小(4, 4)与输入大小(4, 4)保持一致。因此,你可以在执行卷积操作后将相同空间大小的数据传递给下一层。
步幅
应用滤波器的位置间隔称为步幅。在之前的所有例子中,步幅为 1。例如,当步幅为 2 时,应用滤波器的窗口间隔为两个元素,如图 7.7所示。
在图 7.7中,使用步幅为 2 的滤波器应用于(7, 7)输入数据。当步幅为 2 时,输出大小变为(3, 3)。因此,步幅指定了应用滤波器的间隔。

图 7.7:步幅为 2 的示例卷积操作
如我们所见,步幅越大,输出大小越小;填充越大,输出大小越大。我们如何用方程表示这些关系呢?让我们来看一下如何根据填充和步幅来计算输出大小。
这里,输入大小是(H, W),滤波器大小是(FH, FW),输出大小是(OH, OW),填充是P,步幅是S。在这种情况下,你可以通过以下方程来计算输出大小,即方程(7.1):
![]() |
(7.1) |
|---|
现在,让我们使用这个方程做一些计算:
-
示例 1:示例见图 7.6
输入大小:(4, 4),填充:1,步幅:1,滤波器大小:(3, 3):
![91]()
-
示例 2:示例见图 7.7
输入大小:(7, 7),填充:0,步幅:2,滤波器大小:(3, 3):
![92]()
-
示例 3
输入大小:(28, 31),填充:2,步幅:3,滤波器大小:(5, 5):
![93]()
正如这些例子所示,你可以通过赋值给方程(7.1)来计算输出大小。你只能通过赋值来获得输出大小,但请注意,你必须赋值以确保方程(7.1)中的
和
能被整除。如果输出大小不能整除(即结果是小数),你必须通过生成错误来处理这个问题。一些深度学习框架会在没有生成错误的情况下提前处理这个过程;例如,当无法整除时,它们会将值四舍五入到最接近的整数。
对三维数据进行卷积操作
到目前为止,我们查看的示例针对的是具有高度和宽度的二维形状。对于图像,我们必须处理具有通道维度、高度和宽度的三维数据。在这里,我们将使用之前示例中使用的相同技术,查看三维数据上的卷积操作示例。
图 7.8 显示了卷积操作的示例,而 图 7.9 显示了计算过程。在这里,我们可以看到在三维数据上执行卷积操作的结果。与二维数据(图 7.3 中的示例)相比,你可以看到特征图的深度(通道维度)增加了。如果在通道维度上有多个特征图,则对每个通道使用输入数据和滤波器执行卷积操作,并将结果相加以获得一个输出:

图 7.8:三维数据的卷积操作

图 7.9:三维数据的卷积操作计算过程
注意
在三维卷积操作中,如本例所示,输入数据和滤波器在通道数上必须相同。在本例中,输入数据和滤波器的通道数相同,都是三。然而,你可以设置滤波器的大小为你喜欢的任何值。在本例中,滤波器的大小为 (3, 3)。你可以将其设置为任何大小,如 (2, 2)、(1, 1) 或 (5, 5)。然而,如前所述,通道数必须与输入数据的通道数相同。在本例中,必须是三。
思考块
在三维卷积操作中,你可以将数据和滤波器视为矩形块。这里的块是一个三维长方体,如图 7.10所示。我们将三维数据表示为一个多维数组,顺序为通道、高度、宽度。因此,当通道数为 C,高度为 H,宽度为 W 时,形状表示为 (C, H, W)。我们将以相同的顺序表示滤波器,因此当通道数为 C,高度为 FH(滤波器高度),宽度为 FW(滤波器宽度)时,滤波器的形状表示为 (C, FH, FW):

图 7.10:使用块来考虑卷积操作
在本例中,数据的输出是一个特征图。一个特征图意味着输出通道的大小为一。那么,如何在通道维度上提供多个卷积操作的输出呢?为此,我们使用多个滤波器(权重)。图 7.11 图示了这一点:

图 7.11:具有多个滤波器的示例卷积操作
如 图 7.11 所示,当应用的滤波器数量为 FN 时,生成的输出地图数量也为 FN。通过组合 FN 地图,您可以创建形状为(FN,OH,OW)的块。将这个完成的块传递到下一层是 CNN 的过程。
您还必须考虑卷积操作中的滤波器数量。为此,我们将编写滤波器权重数据作为四维数据(output_channel,input_channel,height,width)。例如,当有 20 个具有三个通道的大小为 5 x 5 的滤波器时,表示为(20,3,5,5)。
卷积操作具有偏置项(类似于全连接层)。图 7.12 展示了在添加偏置项后 图 7.11 提供的示例。
正如我们所见,每个通道只有一个偏置数据。这里,偏置的形状是(FN,1,1),而滤波器输出的形状是(FN,OH,OW)。添加这两个块将相同的偏置值添加到滤波器输出结果的每个通道中,(FN,OH,OW)。NumPy 的广播功能有助于处理不同形状的块(请参考第一章中Python 介绍中的广播部分):

图 7.12:卷积操作流程(也添加了偏置项)
批处理
输入数据在神经网络处理中批处理。到目前为止,我们已经查看了对全连接神经网络的实现,支持批处理,这使得在训练过程中支持小批量更加高效。
我们还可以通过将每层数据流作为四维数据存储来支持卷积操作的批处理。具体来说,数据按顺序存储为(batch_num,channel,height,width)。例如,当对 N 个数据进行批处理中显示的处理时,数据的形状如下。
在此展示的批处理数据流中,每个数据片段的批次维度都添加在数据的开头。因此,数据作为四维数据通过每一层传递。请注意,在网络中流动的四维数据表示对 N 个数据进行卷积操作;即,同时进行 N 个处理:

图 7.13:卷积操作流程(批处理)
池化层
池化操作使高度和宽度的空间变小。如 图 7.14 所示,它将一个 2 x 2 的区域转换为一个元素以减小空间的大小:

图 7.14:最大池化过程
本例展示了当进行 2 x 2 最大池化,并且步幅为 2 时的过程。“最大池化”取区域内的最大值,而“2 x 2”表示目标区域的大小。如图所示,它取的是 2 x 2 区域内的最大元素。步幅在这个例子中是 2,因此 2 x 2 窗口每次移动两个元素。一般来说,池化窗口的大小和步幅使用相同的值。例如,3 x 3 窗口的步幅是 3,4 x 4 窗口的步幅是 4。
注意
除了最大池化,还可以使用平均池化。最大池化取目标区域中的最大值,而平均池化则对目标区域内的值进行平均。在图像识别中,主要使用最大池化。因此,本书中的“池化层”指的就是最大池化。
池化层的特性
池化层具有多种特性,下面将进行描述。
没有需要学习的参数
与卷积层不同,池化层没有需要学习的参数。池化层没有需要学习的参数,因为它仅仅是在目标区域内取最大值(或对值进行平均)。
通道数不变
在池化中,输出数据的通道数与输入数据的通道数相同。如图 7.15所示,这一计算是独立地对每个通道进行的:

图 7.15:池化不会改变通道数量
对微小的位移变化具有鲁棒性
即使输入数据发生轻微偏移,池化层也能返回相同的结果。因此,它对输入数据的微小位移变化具有鲁棒性。例如,在 3 x 3 池化中,池化能够吸收输入数据的位移,正如图 7.16所示:

图 7.16:即使输入数据在宽度方向上移动了一个元素,输出仍然相同(可能因数据不同而有所不同)
实现卷积层和池化层
到目前为止,我们已经详细了解了卷积层和池化层。在本节中,我们将用 Python 实现这两个层。如《第五章 反向传播》中所述,这里实现的类同样提供了前向和后向方法,可以作为一个模块使用。
你可能觉得实现卷积和池化层比较复杂,但如果你使用某些“技巧”,就能轻松实现它们。本节将介绍这个技巧,使得当前的任务变得简单。接下来,我们将实现一个卷积层。
四维数组
如前所述,在 CNN 的每一层中,四维数据都会流动。例如,当数据的形状是 (10, 1, 28, 28) 时,表示有十块数据,每块数据的高度为 28,宽度为 28,并且只有 1 个通道。你可以在 Python 中如下实现:
>>> x = np.random.rand(10, 1, 28, 28) # Generate data randomly
>>> x.shape
(10, 1, 28, 28)
要访问第一块数据,你可以写 x[0](在 Python 中,索引从 0 开始)。同样,你可以写 x[1] 来访问第二块数据:
>>> x[0].shape # (1, 28, 28)
>>> x[1].shape # (1, 28, 28)
要访问第一块数据中第一个通道的空间数据,你可以写如下内容:
>>> x[0, 0] # or x[0][0]
你可以在 CNN 中通过这种方式处理四维数据。因此,卷积操作的实现可能会很复杂。然而,有一个被称为 im2col 的“技巧”使得这个任务变得简单。
通过 im2col 进行扩展
要实现卷积操作,通常需要多次嵌套 for 语句。这样的实现方式稍显麻烦,且在 NumPy 中使用 for 语句会降低处理速度(在 NumPy 中,最好不要使用 for 语句来访问元素)。在这里,我们不会使用任何 for 语句,而是使用一个简单的函数——im2col 来实现一个简单的实现。
im2col 函数便捷地为滤波器(权重)扩展输入数据。如 图 7.17 所示,im2col 将三维输入数据转换为二维矩阵(准确地说,它将包括批量数量的四维数据转换为二维数据)。
im2col 为滤波器(权重)便捷地扩展输入数据。具体来说,它将滤波器将在输入数据(一个三维块)上应用的区域扩展为一行,如 图 7.18 所示。im2col 扩展了所有应用滤波器的区域。
在 图 7.18 中,使用了大步幅,以使得滤波器区域不重叠。这是出于可视化的原因。在实际的卷积操作中,滤波器区域在大多数情况下会重叠,此时通过 im2col 扩展后的元素数量会比原始块中的更多。因此,使用 im2col 的实现存在一个缺点,就是比通常情况下消耗更多的内存。然而,将数据放入大矩阵对于计算机进行计算是有利的。例如,矩阵计算库(线性代数库)对矩阵计算进行了高度优化,可以快速乘大矩阵。因此,通过将输入数据转换为矩阵,你可以有效地使用线性代数库:

图 7.17:im2col 概述

图 7.18:从一开始就在行中扩展滤波器目标区域
注意
im2col 这个名称是 "image to column"(图像到列)的缩写,意思是将图像转换为矩阵。深度学习框架如 Caffe 和 Chainer 提供了 im2col 函数,用于实现卷积层。
在使用im2col扩展输入数据后,你需要做的就是将卷积层的滤波器(权重)展开为一行,并将这两个矩阵相乘(见图 7.19)。这个过程几乎与全连接的仿射层相同:

图 7.19:卷积操作中的滤波细节——将滤波器展开成列,并将矩阵与通过im2col扩展的数据相乘。最后,重塑输出数据的结果大小。
如图 7.19所示,使用im2col函数的输出是一个二维矩阵。你必须将二维输出数据转换为合适的形状,因为 CNN 将数据存储为四维数组。下一部分将介绍实现卷积层的流程。
实现卷积层
本书使用了im2col函数,我们将把它当作一个黑盒来使用,而不考虑它的实现。im2col的实现位于common/util.py。它是一个简单的函数,长度大约为 10 行。如果你感兴趣,请参考它。
这个im2col函数的接口如下:
im2col (input_data, filter_h, filter_w, stride=1, pad=0)
-
input_data:由四维数组组成的输入数据(数据量、通道数、高度、宽度) -
filter_h:滤波器的高度 -
filter_w:滤波器的宽度 -
stride:步长 -
pad:填充
im2col函数考虑了“滤波器大小”、“步长”和“填充”来将输入数据扩展为二维数组,具体如下:
import sys, os
sys.path.append(os.pardir)
from common.util import im2col
x1 = np.random.rand(1, 3, 7, 7)
col1 = im2col(x1, 5, 5, stride=1, pad=0)
print(col1.shape) # (9, 75)
x2 = np.random.rand(10, 3, 7, 7)
col2 = im2col(x2, 5, 5, stride=1, pad=0)
print(col2.shape) # (90, 75)
上述代码展示了两个例子。第一个使用 7x7 的数据,批次大小为 1,通道数为 3。第二个使用相同形状的数据,批次大小为 10。当我们使用im2col函数时,在这两种情况下第二维的元素数量都是 75。这是滤波器中元素的总数(3 个通道,大小为 5x5)。当批次大小为 1 时,im2col的结果大小是(9, 75)。另一方面,当批次大小为 10 时,第二个例子的结果是(90, 75),因为批次大小是 10,它可以存储 10 倍的数据。
现在,我们将使用im2col实现一个卷积层,作为一个名为Convolution的类:
class Convolution:
def __init__(self, W, b, stride=1, pad=0):
self.W = W
self.b = b
self.stride = stride
self.pad = pad
def forward(self, x):
FN, C, FH, FW = self.W.shape
N, C, H, W = x.shape
out_h = int(1 + (H + 2*self.pad - FH) / self.stride)
out_w = int(1 + (W + 2*self.pad - FW) / self.stride)
col = im2col(x, FH, FW, self.stride, self.pad)
col_W = self.W.reshape(FN, -1).T # Expand the filter
out = np.dot(col, col_W) + self.b
out = out.reshape(N, out_h, out_w, -1).transpose(0, 3, 1, 2)
return out
卷积层的初始化方法接受滤波器(权重)、偏置、步长和填充作为参数。滤波器是四维的,(FN、C、FH 和 FW)。FN表示滤波器数量(滤波器的个数),C表示通道数,FH表示滤波器的高度,FW表示滤波器的宽度。
在卷积层的实现中,一个重要的部分已经用粗体标出。在这里,im2col用来扩展输入数据,而reshape用来将滤波器扩展为二维数组。扩展后的矩阵被相乘。
扩展滤波器的代码段(前面代码中加粗的部分)将每个滤波器的块扩展为一行,如图 7.19所示。这里,-1 被指定为 reshape (FN, -1),这是 reshape 的一个便捷特性。当 reshape 使用 -1 时,元素数量会自动调整以匹配多维数组中的元素数量。例如,一个形状为 (10, 3, 5, 5) 的数组总共有 750 个元素。此处指定 reshape(10, -1) 后,它会被重塑为形状为 (10, 75) 的数组。
forward 函数会在最后适当调整输出大小。这里使用了 NumPy 的 transpose 函数。transpose 函数改变多维数组中轴的顺序。如图 7.20所示,你可以指定从 0 开始的索引顺序,以改变轴的顺序。
因此,你可以几乎以与全连接的仿射层相同的方式实现卷积层的前向过程,通过使用 im2col 来进行扩展(详见第五章中的实现仿射层与 Softmax 层、反向传播)。接下来,我们将实现卷积层的反向传播。请注意,卷积层的反向传播必须执行 im2col 的反向操作。这由本书提供的 col2im 函数处理(位于 common/util.py)。除非使用 col2im,否则你可以像实现仿射层一样实现卷积层的反向传播。卷积层反向传播的实现位于 common/layer.py。

图 7.20:使用 NumPy 的转置函数更改轴的顺序——指定索引(数字)以改变轴的顺序
实现池化层
在实现池化层时,可以像卷积层一样使用 im2col 来扩展输入数据。不同之处在于,池化操作不依赖于通道维度,这与卷积层不同。如图 7.21所示,目标池化区域在每个通道上独立扩展。
扩展后,你只需在扩展后的矩阵的每一行中取最大值,并将结果转变为适当的形状(图 7.22)。
这就是池化层中前向过程的实现方法。以下是一个 Python 示例实现:
class Pooling:
def __init__(self, pool_h, pool_w, stride=1, pad=0):
self.pool_h = pool_h
self.pool_w = pool_w
self.stride = stride
self.pad = pad
def forward(self, x):
N, C, H, W = x.shape
out_h = int(1 + (H - self.pool_h) / self.stride)
out_w = int(1 + (W - self.pool_w) / self.stride)
# Expansion (1)
col = im2col(x, self.pool_h, self.pool_w, self.stride, self.pad)
col = col.reshape(-1, self.pool_h*self.pool_w)
# Maximum value (2)
out = np.max(col, axis=1)
# Reshape (3)
out = out.reshape(N, out_h, out_w, C).transpose(0, 3, 1, 2)
return out

图 7.21:扩展输入数据的目标池化区域(2x2 池化)
如图 7.22所示,实现池化层时有三个步骤:
-
扩展输入数据。
-
在每行中取最大值。
-
适当调整输出形状。
每个步骤的实现都很简单,只有一两行代码:

图 7.22:池化层实现的流程——池化区域中的最大元素以灰色显示
注意
你可以使用 NumPy 的np.max方法来获取最大值。通过在np.max中指定axis参数,你可以沿指定的轴获取最大值。例如,np.max(x, axis=1)返回x在第一维每个轴上的最大值。
这就是池化层前向过程的全部内容。如图所示,在将输入数据扩展成适合池化的形状后,后续的实现非常简单。
对于池化层中的反向过程,max的反向传播(用于第五章中ReLU 层小节中的 ReLU 层实现),提供了更多的信息。池化层的实现位于common/layer.py。
实现一个 CNN
到目前为止,我们已经实现了卷积和池化层。现在,我们将结合这些层,创建一个可以识别手写数字的卷积神经网络(CNN)并实现它,如图 7.23所示。
如图 7.23所示,网络由“卷积 – ReLU – 池化 – 仿射 – ReLU – 仿射 – Softmax”层组成。我们将实现它作为一个名为SimpleConvNet的类:

图 7.23:简单 CNN 的网络配置
现在,让我们来看看SimpleConvNet (__init__)的初始化。它接受以下参数:
-
input_dim:输入数据的维度(通道,高度,宽度)。 -
conv_param:卷积层的超参数(字典)。以下是字典的键: -
filter_num:滤波器的数量 -
filter_size:滤波器的大小 -
stride:步幅 -
pad:填充 -
hidden_size:隐藏层中的神经元数量(全连接) -
output_size:输出层中的神经元数量(全连接) -
weight_init_std:初始化时权重的标准差
这里,卷积层的超参数作为一个名为conv_param的字典提供。我们假设所需的超参数值存储为{'filter_num':30, 'filter_size':5, 'pad':0, 'stride':1}。
SimpleConvNet初始化的实现稍微长一些,所以这里将其分成三个部分,以便更容易理解。以下代码展示了初始化过程的第一部分:
class SimpleConvNet:
def __init__(self, input_dim=(1, 28, 28),
conv_param={'filter_num':30, 'filter_size':5,
'pad':0, 'stride':1},
hidden_size=100, output_size=10, weight_init_std=0.01):
filter_num = conv_param['filter_num']
filter_size = conv_param['filter_size']
filter_pad = conv_param['pad']
filter_stride = conv_param['stride']
input_size = input_dim[1]
conv_output_size = (input_size - filter_size + 2*filter_pad) / \
filter_stride + 1
pool_output_size = int(filter_num * (conv_output_size/2) *(conv_output_size/2))
这里,初始化参数提供的卷积层超参数从字典中提取(以便我们可以在后续使用)。然后,计算卷积层的输出大小。以下代码初始化了权重参数:
self.params = {}
self.params['W1'] = weight_init_std * \
np.random.randn(filter_num, input_dim[0],
filter_size, filter_size)
self.params['b1'] = np.zeros(filter_num)
self.params['W2'] = weight_init_std * \
np.random.randn(pool_output_size,hidden_size)
self.params['b2'] = np.zeros(hidden_size)
self.params['W3'] = weight_init_std * \
np.random.randn(hidden_size, output_size)
self.params['b3'] = np.zeros(output_size)
训练所需的参数是第一层(卷积层)和剩余的两层全连接层的权重和偏置。这些参数存储在实例字典变量params中。W1键用于表示第一层(卷积层)的权重,b1键用于表示第一层(卷积层)的偏置。同样,W2和b2键分别用于表示第二层(全连接层)的权重和偏置,W3和b3键分别用于表示第三层(全连接层)的权重和偏置。最后,所需的层被生成,具体如下:
self.layers = OrderedDict( )
self.layers['Conv1'] = Convolution(self.params['W1'],
self.params['b1'],
conv_param['stride'],
conv_param['pad'])
self.layers['Relu1'] = Relu( )
self.layers['Pool1'] = Pooling(pool_h=2, pool_w=2, stride=2)
self.layers['Affine1'] = Affine(self.params['W2'],
self.params['b2'])
self.layers['Relu2'] = Relu( )
self.layers['Affine2'] = Affine(self.params['W3'],
self.params['b3'])
self.last_layer = SoftmaxWithLoss( )
层按适当顺序添加到有序字典(OrderedDict)中。只有最后一层SoftmaxWithLoss被添加到另一个变量last-layer中。
这是SimpleConvNet的初始化。在初始化之后,您可以实现predict方法来进行预测,以及loss方法来计算损失函数的值,具体如下:
def predict(self, x):
for layer in self.layers.values( ):
x = layer.forward(x)
return x
def loss(self, x, t):
y = self.predict(x)
return self.lastLayer.forward(y, t)
这里,x参数是输入数据,t参数是标签。predict方法只按顺序调用添加的各层,将结果传递到下一层。除了predict方法中的前向处理外,loss方法在最后一层SoftmaxWithLoss之前执行前向处理。
以下实现通过反向传播获得梯度,具体如下:
def gradient(self, x, t):
# forward
self.loss(x, t)
# backward
dout = 1
dout = self.lastLayer.backward(dout)
layers = list(self.layers.values( ))
layers.reverse( )
for layer in layers:
dout = layer.backward(dout)
# Settings
grads = {}
grads['W1'] = self.layers['Conv1'].dW
grads['b1'] = self.layers['Conv1'].db
grads['W2'] = self.layers['Affine1'].dW
grads['b2'] = self.layers['Affine1'].db
grads['W3'] = self.layers['Affine2'].dW
grads['b3'] = self.layers['Affine2'].db
return grads
使用反向传播来获得参数的梯度。为此,前向传播和后向传播依次进行。由于每一层的前向和后向传播都已正确实现,这里只需要按适当顺序调用它们。最后,每个权重参数的梯度存储在grads字典中。因此,您可以实现SimpleConvNet。
现在,让我们使用 MNIST 数据集来训练SimpleConvNet类。训练的代码几乎与《第四章,神经网络训练》中的实现训练算法部分描述的相同。因此,这里不再展示代码(源代码位于ch07/train_convnet.py)。
当使用SimpleConvNet训练 MNIST 数据集时,训练数据的识别准确率为 99.82%,测试数据的识别准确率为 98.96%(不同的训练可能导致略有不同的识别准确率)。对于一个相对较小的网络,99%的测试数据识别准确率已经非常高。在下一章,我们将添加更多层,创建一个测试数据识别准确率超过 99%的网络。
如我们所见,卷积层和池化层是图像识别中不可或缺的模块。卷积神经网络(CNN)能够读取图像的空间特征,并在手写数字识别中达到高精度。
可视化卷积神经网络(CNN)
卷积神经网络(CNN)中的卷积层“看到”什么?在这里,我们将可视化卷积层,探索卷积神经网络中的运作。
可视化第一层的权重
之前,我们对 MNIST 数据集进行了简单的 CNN 训练。第一层(卷积层)权重的形状为(30, 1, 5, 5)。它的尺寸为 5x5,具有 1 个通道和 30 个过滤器。当过滤器的尺寸为 5x5 且具有 1 个通道时,它可以被视为一个单通道灰度图像。现在,让我们将卷积层(第一层)的过滤器作为图像展示。在这里,我们将比较训练前后的权重。图 7.24 展示了结果(源代码位于 ch07/visualize_filter.py):

图 7.24:训练前后第一层(卷积层)权重。权重的元素是实数,但它们在 0 到 255 之间进行归一化,以便展示图像,最小值为黑色(0),最大值为白色(255)。
如图 7.24所示,训练前的过滤器是随机初始化的。黑白色调没有任何规律。另一方面,训练后的过滤器是具有某种模式的图像。一些过滤器从白色到黑色有渐变,而一些过滤器有小的颜色区域(称为“斑点”),这表明训练为过滤器提供了模式。
在图 7.24的右侧,具有模式的过滤器“看到了”边缘(颜色的边界)和斑点。例如,当一个过滤器的左半部分为白色、右半部分为黑色时,它对垂直边缘做出了反应,如图 7.25所示。
图 7.25 展示了选择两个学习到的过滤器并对输入图像进行卷积处理时的结果。你可以看到“过滤器 1”对垂直边缘做出了反应,而“过滤器 2”对水平边缘做出了反应:

图 7.25:过滤器对水平和垂直边缘的反应。输出图像 1 中的垂直边缘处出现了白色像素。与此同时,输出图像 2 中的水平边缘处出现了许多白色像素。
因此,你可以看到卷积层中的过滤器提取了诸如边缘和斑点等基本信息。之前实现的 CNN 将这些基本信息传递给后续层。
使用层次结构提取信息
上述结果来自第一个(卷积)层。它提取了低级信息,如边缘和斑点。那么,具有多层的 CNN 中的每一层提取了什么类型的信息呢?关于深度学习中的可视化研究[Matthew D. Zeiler 和 Rob Fergus (2014): 可视化和理解卷积网络。在 David Fleet, Tomas Pajdla, Bernt Schiele, & Tinne Tuytelaars 编辑的《计算机视觉 – ECCV 2014》一书中,Lecture Notes in Computer Science. Springer International Publishing,818 – 833]和[A. Mahendran 和 A. Vedaldi (2015): 通过反转深度图像表示理解它们。在 2015 年 IEEE 计算机视觉与模式识别会议(CVPR)上,5188 – 5196. DOI: (dx.doi.org/10.1109/CVPR.2015.7299155)]中指出,越深的层,提取的信息越抽象(更准确地说,是反应强烈的神经元)。
典型的 CNN
迄今为止,已经提出了多种不同架构的卷积神经网络(CNN)。在本节中,我们将介绍两个重要的网络。其中一个是 LeNet(Y. Lecun, L. Bottou, Y. Bengio, 和 P. Haffner (1998): 基于梯度的学习应用于文档识别。《IEEE 86 卷,11 期》(1998 年 11 月),2278 – 2324. DOI: (dx.doi.org/10.1109/5.726791))。它是最早的 CNN 之一,并于 1998 年首次提出。另一个是 AlexNet(Alex Krizhevsky, Ilya Sutskever, 和 Geoffrey E. Hinton (2012): 基于深度卷积神经网络的 ImageNet 分类。在 F. Pereira, C. J. C. Burges, L. Bottou, & K. Q. Weinberger 编辑的《神经信息处理系统进展 25》一书中,Curran Associates,Inc.,1097 – 1105)。它在 2012 年提出,引起了人们对深度学习的关注。
LeNet
LeNet 是一个用于手写数字识别的网络,提出于 1998 年。在该网络中,卷积层和池化层(即只“稀疏元素”的子采样层)被重复使用,最后通过全连接层输出结果。
LeNet 与“当前 CNN”之间确实存在一些差异。一个是激活函数的不同,LeNet 使用的是 sigmoid 函数,而现在主要使用 ReLU。原始 LeNet 中使用了子采样来减少中间数据的大小,而现在主要使用最大池化:
这样,LeNet 与“当前 CNN”之间存在一些差异,但差异并不显著。考虑到 LeNet 几乎是 20 年前提出的“第一个 CNN”,这一点令人惊讶。
AlexNet
AlexNet 发布距 LeNet 提出已有近 20 年。尽管 AlexNet 引发了深度学习的热潮,但其网络架构与 LeNet 相比变化不大:
AlexNet 将卷积层和池化层堆叠,并通过全连接层输出结果。它的架构与 LeNet 差异不大,但也有一些不同之处,如下所示:
-
使用 ReLU 作为激活函数
-
使用了一种名为局部响应归一化(LRN)的局部归一化层。
-
使用了 Dropout(请参见第六章中的Dropout小节,训练技巧)。
LeNet 和 AlexNet 在网络架构上没有太大区别。然而,周围的环境和计算机技术有了显著进步。现在,人人都能获取大量数据,而且广泛使用的 GPU 擅长进行大规模并行计算,从而能够以高速进行大规模操作。大数据和 GPU 极大地推动了深度学习的发展。
注意
深度学习中通常存在许多参数(即网络具有许多层)。训练需要大量计算,而且需要大量数据来“满足”这些参数。我们可以说,GPU 和大数据为解决这些挑战提供了帮助。
总结
在本章中,我们学习了 CNN。具体来说,我们详细讨论了卷积层和池化层(构成 CNN 的基本模块),以便从实现的角度理解它们。CNN 主要用于处理与图像相关的数据。在继续学习之前,请确保你理解了本章的内容。
本章我们学习了以下内容:
-
在 CNN 中,卷积层和池化层被添加到之前由全连接层组成的网络中。
-
你可以使用
im2col(一个用于将图像扩展为数组的函数)来简洁高效地实现卷积层和池化层。 -
可视化 CNN 可以让你看到随着网络层次变深,如何提取更高级的信息。
-
典型的 CNN 包括 LeNet 和 AlexNet。
-
大数据和 GPU 对深度学习的发展有着重要的推动作用。
第九章:8. 深度学习
深度学习是一种基于深度神经网络的机器学习方法。通过向我们迄今为止描述的网络中添加层,你可以创建一个深度网络。然而,深度网络也存在一些问题。本章将介绍深度学习的特点、问题与可能性,以及当前深度学习实践的概述。
使网络更深
在本书中,我们已经学习了很多关于神经网络的知识,包括构成神经网络的各种层、训练中使用的有效技巧、尤其适用于图像处理的卷积神经网络(CNN)以及如何优化参数。这些都是深度学习中的重要技巧。在这里,我们将整合到目前为止学到的技巧,创建一个深度网络。接着,我们将尝试使用 MNIST 数据集进行手写数字识别。
更深的网络
首先,我们将创建一个具有图 8.1中所示网络架构的 CNN。这个网络基于 VGG 网络,下一节将对此进行描述。
如图 8.1所示,网络比我们迄今为止实现的网络要深。这里使用的所有卷积层都是小的 3x3 卷积核。在这里,随着网络的加深,通道数变得更大(卷积层中的通道数从第一层的 16 增加到 16、32、32、64 和 64)。如你所见,池化层被插入以逐渐减少中间数据的空间大小,同时丢弃层用于后续的全连接层:

图 8.1:用于手写数字识别的深度 CNN
该网络使用"He 初始化器"来初始化权重,并使用 Adam 来更新权重参数,从而具有以下特点:
-
使用小的 3×3 卷积核的卷积层
-
ReLU 作为激活函数
-
在全连接层后使用的丢弃层
-
优化由 Adam 进行
-
"He 初始化器"用于初始权重值
正如这些特点所示,图 8.1中的网络使用了我们迄今为止学到的许多神经网络技巧。现在,我们来使用这个网络进行训练。结果表明,这个网络的识别准确率为 99.38%(最终的识别准确率可能略有不同,但该网络通常会超过 99%)。
注意
实现图 8.1中显示的网络的源代码位于ch08/deep_convnet.py。用于训练的代码在ch08/train_deepnet.py中提供。你可以使用这些代码来重现这里进行的训练。深度网络的训练需要大量时间(可能超过半天)。本书提供了在ch08/deep_conv_net_params.pkl中的训练权重参数。deep_convnet.py代码文件提供了一个加载训练参数的功能,你可以根据需要使用它。
如图 8.1所示,网络的错误率仅为 0.62%。在这里,我们可以看到哪些图像被错误地识别了。图 8.2展示了识别错误的示例:

图 8.2:识别错误的示例图像——每个图像的左上角显示正确标签,而右下角则显示该网络的预测结果
如图 8.2所示,这些图像即使对我们人类来说也很难识别。左上角的图像看起来像是“0”(正确答案是“6”),旁边的图像显然像是“5”(正确答案是“3”)。一般来说,“1”和“7”,“0”和“6”,“3”和“5”之间的区别很难区分。这些示例解释了为什么它们被错误识别。
尽管这个深度卷积神经网络(CNN)非常精确,但它以与人类相似的方式错误地识别了图像。这也向我们展示了深度卷积神经网络的巨大潜力。
提高识别准确性
名为“这个图像属于什么类别?”的网站(Rodrigo Benenson 的博客 “分类数据集结果”(rodrigob.github.io/are_we_there_yet/build/classification_datasets_results.html))根据相关文献中发布的技术对各种数据集的识别准确率进行了排名(见图 8.3):

图 8.3:MNIST 数据集的排名技术
注意
图 8.3摘自参考文献,Rodrigo Benenson 的博客 “分类数据集结果”(rodrigob.github.io/are_we_there_yet/build/classification_datasets_results.html),数据截至 2016 年 6 月。
在图 8.3中显示的排名中,“神经网络”、“深度”和“卷积”等关键词十分显眼。许多排名靠前的技术基于 CNN。截至 2016 年 6 月,MNIST 数据集的最高识别准确率为 99.79%(错误率为 0.21%),该技术同样是基于 CNN 的(Li Wan, Matthew Zeiler, Sixin Zhang, Yann L. Cun, 和 Rob Fergus(2013):使用 DropConnect 对神经网络进行正则化。发表于 Sanjoy Dasgupta & David McAllester 编辑的《第 30 届国际机器学习会议(ICML2013)论文集》。JMLR 工作坊和会议记录,1058-1066)。这里使用的 CNN 并不深(包含两层卷积层和两层全连接层)。
注意
对于 MNIST 数据集,即使网络不是很深,也可以立即获得最高的准确率。对于像手写数字识别这样相对简单的问题,网络的表示能力不需要非常高。因此,增加层数并不是很有益。在大规模的通用目标识别过程中,增加网络的层数可以大大提高识别精度,因为这是一个复杂的问题。
通过检查上述高排名的技术,我们可以找到一些进一步提高识别准确率的技术和技巧。例如,我们可以看到,集成学习、学习率衰减和数据增强都有助于提高识别准确率。数据增强是一种简单但特别有效的提高识别准确率的方法。
数据增强使用一种算法来人工扩展输入图像(训练图像)。如图 8.4所示,它通过旋转或垂直/水平移动稍微改变输入图像来增加图像。当数据集中的图像数量有限时,这种方法尤其有效:

图 8.4:样本数据增强
你可以通过多种方式使用数据增强来扩展图像,而不仅仅是图 8.4中所示的修改。例如,你可以裁剪图像的一部分(或剪裁)或水平翻转图像(称为翻转,但这仅在图像的对称性不需要考虑时有效)。对于普通图像,改变它们的外观(例如,通过调整亮度和缩放它们)也是有效的。如果你可以通过数据增强增加训练图像的数量,你可以通过深度学习提高识别准确率。虽然这看起来是一个简单的技巧,但它通常能带来良好的结果。我们在这里不实现数据增强。由于实现这一点非常简单,如果你有兴趣,可以自己尝试。
更深网络的动机
关于让网络变深的重要性,仍有很多未解之谜。尽管目前的理论研究尚不足够,但过去的研究和实验可以在某种程度上解释一些事情(相对直观)。本节将提供一些数据和解释,支持“让网络变深”这一观点的重要性。
首先,来自大规模图像识别竞赛(如 ILSVRC)的结果显示了“让网络变深”的重要性(详细信息请参见下一节)。这些结果表明,许多近期排名靠前的技术都基于深度学习,并且网络往往会变得更深。网络越深,识别性能越好。
其中一个优势是你可以减少网络中的参数数量。当网络变得更深时,可以用更少的参数实现相似(或更高)的表现。考虑到卷积操作中的滤波器大小,这一点很容易理解。图 8.5 显示了一个具有 5x5 滤波器的卷积层。
请注意,每个输出数据节点计算时所依据的输入数据区域。当然,每个输出节点都基于示例中图 8.5所示的输入数据的 5x5 区域。那么,现在我们来思考一个 3x3 卷积操作重复两次的情况,如图 8.6所示。在这种情况下,中间数据是基于每个输出节点的 3x3 区域。那么,前一个输入数据的哪个区域是中间数据的 3x3 区域依据的呢?当你仔细查看图 8.6时,你会发现它是基于一个 5×5 区域的。因此,图 8.6中的输出数据“查看”了输入数据的 5×5 区域进行计算:

图 8.5:5x5 卷积操作示例

图 8:6:3x3 卷积操作重复两次的示例
一个 5x5 卷积操作的区域相当于两个 3x3 卷积操作的区域。前者使用 25 个参数(5x5),而后者总共使用 18 个参数(2x3x3)。因此,多个卷积层减少了参数数量。随着网络加深,减少的参数数量会变得更大。例如,当 3x3 卷积操作重复三次时,参数总数为 27。为了通过一次卷积操作“查看”相同的区域,需要一个 7x7 的滤波器,这意味着参数数量上升至 49。
注意
通过多次应用小卷积核使网络更深的优势在于它可以减少参数数量并扩展感受野(改变神经元的局部空间区域)。当你增加层数时,卷积层之间会放置一个激活函数,如 ReLU,从而改进网络表示。这是因为激活函数对网络施加了“非线性”力量。多个非线性函数能够表达更复杂的特征。
训练效率是使网络更深的另一个优势。更深的网络可以减少训练数据并快速进行训练。你可以通过记住在第七章《卷积神经网络》中的可视化 CNN部分提供的描述来直观地理解这一点。在该部分,你了解到 CNN 中的卷积层是分层提取信息的。在前面的卷积层中,神经元对简单的形状(如边缘)做出反应。随着层次的加深,神经元对更加复杂的形状做出反应,比如纹理和物体部分。
以这样的网络层次结构为基础,考虑识别“狗”的问题。要在浅层网络中解决这个问题,卷积层必须同时“理解”狗的许多特征。狗有多种类型,它们的外貌因拍摄图像的环境不同而有所变化。因此,理解狗的特征需要多样的训练数据和大量的训练时间。
然而,你可以通过加深网络来将问题进行层次化学习。这样,每一层学习的任务变得更简单。例如,第一层可以集中学习边缘。因此,网络可以通过少量的训练数据高效学习。这是因为包含边缘的图像数量大于包含狗的图像数量,边缘的模式也比狗的模式简单。
同样重要的是,通过加深网络,你可以层次化地传递信息。例如,提取边缘的下一层可以利用边缘信息,因此我们可以期望它能够高效地学习更高级的模式。简而言之,通过加深网络,你可以将每一层学习的问题分解成“容易解决的简单问题”,从而期望高效的训练。
这就是支持“加深网络”重要性的解释。请注意,近年来更深层的网络得益于新的技术和环境,如大数据和计算能力,这些都使得深度网络能够正确训练。
深度学习简史
据说深度学习之所以在大规模图像识别竞赛中引起广泛关注,是因为ImageNet 大规模视觉识别挑战赛(ILSVRC)于 2012 年举行。在比赛中,一种名为 AlexNet 的深度学习技术取得了压倒性的胜利,颠覆了传统的图像识别方法。自 2012 年深度学习反击以来,它一直在随后的竞赛中扮演着主导角色。在这里,我们将探讨当前深度学习在大规模图像识别竞赛(即 ILSVRC)中的趋势。
ImageNet
ImageNet (J. Deng, W. Dong, R. Socher, L.J. Li, Kai Li, 和 Li Fei-Fei (2009):ImageNet:一个大规模层次化图像数据库。在 IEEE 计算机视觉与模式识别大会,2009 年。CVPR 2009。248–255。DOI:(dx.doi.org/10.1109/CVPR.2009.5206848)) 是一个包含超过 100 万张图像的数据集。如图 8.7*所示,它包含了各种类型的图像,每张图像都与一个标签(类别名)相关联。每年都会使用这个庞大的数据集举行一个名为 ILSVRC 的图像识别比赛:

图 8.7: 大规模 ImageNet 数据集中的示例数据
注
图 8.7 引自参考文献,J. Deng, W. Dong, R. Socher, L.J. Li, Kai Li, 和 Li Fei-Fei (2009): ImageNet: A large-scale hierarchical image database。收录于 IEEE 计算机视觉与模式识别会议 Computer Vision and Pattern Recognition, 2009. CVPR 2009. 248 – 255. DOI: (dx.doi.org/10.1109/CVPR.2009.5206848)。
ILSVRC 比赛提供了一些测试项目,其中一个是“分类”(在“分类”项目中,1,000 个类别通过识别准确率进行竞赛)。图 8.8显示了自 2010 年以来 ILSVRC 分类项目的获胜队伍结果。在这里,如果前五个预测中包含正确的类别,则认为分类是“正确的”。以下条形图展示了错误率:

图 8.8: ILSVRC 比赛获胜队伍的结果—纵坐标显示错误率,横坐标显示年份。队伍名称或技术名称显示在横坐标上的括号内。
请注意从前面的图表中可以看出,自 2012 年以来,深度学习技术一直处于领先地位。实际上,我们可以看到,在 2012 年,AlexNet 显著降低了错误率。从那时起,深度学习技术在准确性方面稳步提升。这一点在 2015 年的 ResNet 中尤为明显,ResNet 是一个具有超过 150 层的深度网络,并且将错误率降至 3.5%。甚至有人说,这一结果超过了普通人类的识别能力。
在过去几年中,取得显著成果的深度学习网络中,VGG、GoogLeNet 和 ResNet 最为著名。你会在许多与深度学习相关的地方遇到它们。接下来我将简要介绍这三种著名网络。
VGG
VGG 是一个“基础”卷积神经网络(CNN),由卷积层和池化层组成。如图 8.9所示,它可以拥有最多 16 层(或 19 层)带权重的层(卷积层和全连接层),使得网络更深,并且根据层数的不同,有时被称为“VGG16”或“VGG19”:

图 8.9: VGG
注
图 8.9 引自参考文献,Karen Simonyan 和 Andrew Zisserman (2014): Very Deep Convolutional Networks for Large-Scale Image Recognition. arXiv:1409.1556[cs] (2014 年 9 月)。
VGG 包含连续的卷积层,并使用小的 3x3 滤波器。如前图所示,两个或四个连续的卷积层和一个池化层将尺寸减半,并且这一过程会重复进行。最终,结果通过全连接层给出。
注
VGG 在 2014 年的比赛中获得了第二名(接下来的 GoogLeNet 获得了 2014 年的冠军)。它的性能不如第一名的网络,但许多工程师更喜欢使用基于 VGG 的网络,因为其结构非常简单且具有广泛的适用性。
GoogLeNet
图 8.10 显示了 GoogLeNet 的网络架构。矩形表示各种层,例如卷积层和池化层:

图 8.10:GoogLeNet
注意
图 8.10 和 图 8.11 引自 Christian Szegedy 等人 (2015):通过卷积更深。发表于 IEEE 计算机视觉与模式识别大会(CVPR)。
当你观察它时,网络架构似乎非常复杂,但本质上与 CNN 的架构相似。GoogLeNet 的独特之处在于,网络不仅在垂直方向上有深度,而且在水平方向上也有深度(扩展)。
GoogLeNet 在水平方向上有“宽度”。它被称为“inception 架构”,并基于图 8.11所示的结构:

图 8.11:GoogLeNet 的 Inception 架构
如图 8.11所示,Inception 架构应用了多种不同大小的滤波器(及池化)并将结果合并。将这一 Inception 架构作为一个构建模块(组件)是 GoogLeNet 的主要特点。
GoogLeNet 在许多地方使用 1x1 滤波器的卷积层。这种 1x1 卷积操作减少了通道方向上的大小,从而减少了参数的数量并加速了处理。
ResNet
ResNet (Kaiming He, Xiangyu Zhang, Shaoqing Ren, and Jian Sun (2015): 深度残差学习用于图像识别. arXiv:1512.03385[cs] (2015 年 12 月)) 是微软团队开发的网络。其特点是具有一种“机制”,能够使网络比以往更深。
增加网络深度对提高其性能非常重要。然而,当网络变得过深时,深度学习就会失败,最终性能通常较差。为了解决这个问题,ResNet 引入了“跳跃架构”(也称为“快捷方式”或“旁路”)。通过引入这一跳跃架构,随着网络深度的增加,性能可以得到提升(尽管深度有一定限制)。
跳跃架构跳过输入数据中的卷积层,将输入数据添加到输出中,如图 8.12所示:

图 8.12:ResNet 的组成——这里的“权重层”表示一个卷积层
注意
图 8.12 和 图 8.13 引自文献,Kaiming He, Xiangyu Zhang, Shaoqing Ren, and Jian Sun (2015): 深度残差学习用于图像识别. arXiv:1512.03385[cs] (2015 年 12 月)。
在图 8.12中,输入x通过跳过两个连续的卷积层与输出相连。两个卷积层的输出本来是F(x),而跳跃架构则将其改为F(x) + x。
采用这种跳跃架构使得即使网络很深,也能够高效地进行学习。这是因为跳跃架构在反向传播时传递信号而不发生衰减。
注意
跳跃架构只将输入数据“原样”传递。在反向传播时,它也将来自上游的梯度“原样”传递给下游,不会发生变化。因此,你不需要担心梯度变小(或过大)的问题。你可以预期“有意义的梯度”会传递到前面的层。你也可以预期跳跃架构能够缓解传统的梯度消失问题,避免随着网络加深,梯度逐渐减小。
ResNet 基于我们之前描述的 VGG 网络,并采用跳跃架构使网络更深。图 8.13展示了这一结果:

图 8.13:ResNet – 块支持 3x3 卷积层。其特点是跳跃架构,即跳过某些层。
如图 8.13所示,ResNet 跳过了两个卷积层,从而使得网络更深。实验表明,即使网络包含 150 层或更多层,识别精度仍然持续提高。在 ILSVRC 比赛中,它取得了 3.5%的惊人结果(即错误率,表示正确类别未包含在前 5 个预测中的比例)。
注意
使用巨大的 ImageNet 数据集训练的权重数据通常能够有效地使用。这被称为迁移学习。将部分训练好的权重复制到另一个神经网络中进行微调。例如,提供一个与 VGG 结构相同的网络。将训练好的权重作为初始值,并针对新数据集进行微调。当数据集较少时,迁移学习尤其有效。
加速深度学习
大数据和大规模网络要求在深度学习中进行大量操作。我们迄今为止使用了 CPU 进行计算,但仅仅依靠 CPU 不足以应对深度学习的需求。实际上,许多深度学习框架支持图形处理单元(GPU)来快速处理大量操作。最近的框架开始通过使用多个 GPU 或机器来支持分布式学习。本节描述了加速深度学习计算的方法。我们在第 8.1 节中结束了深度学习的实现。在这里,我们不会实现所描述的加速(例如 GPU 的支持)。
面临的挑战
在讨论加速深度学习之前,我们先来看一下深度学习中哪些过程需要消耗时间。图 8.14中的饼图展示了 AlexNet 在前向处理过程中各类操作所消耗的时间:

图 8.14:AlexNet 前向处理过程中各层所花时间的百分比——左侧图表显示 GPU 时间,右侧图表显示 CPU 时间
这里,“conv”表示卷积层,“pool”表示池化层,“fc”表示全连接层,“norm”表示归一化层(引用自贾扬清(2014):大规模学习语义图像表示。博士论文,加利福尼亚大学伯克利分校电子电气计算机系,2014 年 5 月,www.eecs.berkeley.edu/Pubs/TechRpts/2014/EECS-2014-93.html))。
如你所见,卷积层在 AlexNet 中花费了大量的时间。实际上,卷积层的总处理时间占 GPU 时间的 95%和 CPU 时间的 89%!因此,在卷积层进行快速高效的操作是深度学习的主要挑战。图 8.14展示了推理阶段的结果,但卷积层在训练阶段也花费了大量时间。
注释
如在第七章中卷积神经网络部分所述,卷积层中的操作基本上是“乘加运算”。因此,加速深度学习的关键在于如何快速高效地进行大量的“乘加运算”。
使用 GPU 进行加速
最初,GPU 仅用于图形处理。最近,GPU 不仅用于图形处理,还用于一般数值计算。由于 GPU 能够快速进行并行算术运算,GPU 计算利用其强大的计算能力广泛应用于各种领域。
深度学习需要大量的乘加运算(或大矩阵的乘积)。GPU 擅长这种大规模并行运算,而 CPU 擅长连续的复杂计算。与仅使用 CPU 相比,使用 GPU 来加速深度学习运算的效果非常显著。图 8.15对比了 AlexNet 在 CPU 和 GPU 上学习所花费的时间:

图 8.15:对比 AlexNet 在“16 核 Xeon CPU”和“Titan 系列”GPU 之间的学习时间
注释
图 8.15引用自参考文献,NVIDIA 博客 "NVIDIA 通过 TITAN X、新的 DIGITS 训练系统和 DevBox 推动深度学习" (blogs.nvidia.com/blog/2015/03/17/digits-devbox/)。
如您所见,CPU 用了超过 40 天,而 GPU 只用了 6 天。我们还可以看到,使用针对深度学习优化的 cuDNN 库,进一步加速了训练过程。
GPU 主要由两家公司提供,NVIDIA 和 AMD。尽管您可以使用两者的 GPU 进行一般的算术运算,但 NVIDIA 的 GPU 更“熟悉”深度学习。实际上,许多深度学习框架只能从 NVIDIA 的 GPU 中受益。这是因为 NVIDIA 提供的 GPU 计算集成开发环境 CUDA 被广泛用于深度学习框架中。图 8.15中所示的 cuDNN 库运行在 CUDA 上,实现了针对深度学习优化的各种功能。
注意事项
我们使用了im2col将卷积层中的操作转化为大矩阵的乘积。实现这一im2col方法适合 GPU。GPU 擅长一次性计算大批量数据,而不是逐个计算小批量数据。通过使用im2col计算巨大的矩阵乘积,可以轻松展现 GPU 的真正实力。
分布式训练
通过使用 GPU,您可以加速深度学习的操作,但深度网络仍然需要几天甚至几周的时间来完成训练。正如我们到目前为止所看到的,深度学习涉及大量的试错过程。为了创建一个好的网络,您必须尝试许多方法。自然,您希望尽可能减少训练所需的时间。于是,扩展深度学习或“分布式训练”变得至关重要。
为了进一步加速深度学习所需的计算,您可能希望将计算任务分布到多个 GPU 或机器上。现在,一些深度学习框架支持通过多个 GPU 或机器进行分布式训练。其中,谷歌的 TensorFlow 和微软的计算网络工具包(CNTK)已被开发出来,专注于分布式训练。基于数据中心中低延迟和高吞吐量的网络,这些框架的分布式训练取得了令人惊讶的效果。
分布式训练能够加速深度学习的速度吗?答案是:GPU 数量越多,训练速度越快。事实上,100 个 GPU(即安装在多台机器上的 100 个 GPU)相比单个 GPU,能实现 56 倍的加速。这意味着,通常需要 7 天才能完成的训练,使用 100 个 GPU 只需 3 小时,充分展示了分布式训练的惊人效果。
分布式训练中的“如何分配计算”是一个非常难的问题。它包含了许多难以解决的问题,例如机器之间的通信和数据同步。你可以将这些难题交给优秀的框架,如 TensorFlow。在这里,我们不会讨论分布式训练的细节。如需了解分布式训练的技术细节,请参阅有关 TensorFlow 的技术论文(白皮书)(Martín Abadi 等人(2016):TensorFlow:在异构分布式系统上进行大规模机器学习。arXiv:1603.04467[cs](2016 年 3 月))。
降低算术精度的位数
内存空间和总线带宽,以及计算复杂度,可能成为加速深度学习的瓶颈。对于内存空间,必须在内存中存储大量的权重参数和中间数据。对于总线带宽,当通过 GPU(或 CPU)总线的数据量增加并超过限制时,会出现瓶颈。在这些情况下,你希望网络中流动的数据的位数尽可能小。
计算机主要使用 64 位或 32 位浮点数来表示实数。使用更多的位数表示数字可以减少数值计算中的误差影响,但会增加处理成本和内存使用量,并对总线带宽造成负担。
根据我们对深度学习中数值精度(即表示数值所使用的位数)的了解,它并不需要非常高的精度。这是神经网络最重要的特性之一,因为它具有鲁棒性。这里的鲁棒性意味着,例如,即使输入图像中含有少量噪声,神经网络的输出结果也不会改变。可以把它理解为即使网络中的数据“退化”,鲁棒性也使得输出结果受影响较小。
计算机通常使用 32 位单精度浮点数表示法或 64 位双精度浮点数表示法来表示十进制数。实验表明,16 位float在深度学习中足够使用(Suyog Gupta, Ankur Agrawal, Kailash Gopalakrishnan, 和 Pritish Narayanan (2015):使用有限数值精度的深度学习。CoRR, abs/1502.02551 392 (2015))。实际上,NVIDIA 的 Pascal 架构支持半精度浮点数的运算。人们认为未来半精度格式将成为标准。
注意
NVIDIA 的 Maxwell 架构的 GPU 支持存储半精度浮点数(以保持数据),但并未进行 16 位运算。下一代 Pascal 架构则进行了 16 位运算。我们可以预期,仅使用半精度浮点数进行计算将加速处理,使其速度大约是上一代 GPU 的两倍。
在之前的深度学习实现中,我们没有涉及数值精度的问题。Python 通常使用 64 位浮点数。NumPy 提供了一种 16 位半精度浮点数据类型(但仅用于存储,而非计算)。我们可以很容易地证明,使用 NumPy 的半精度浮点数不会降低识别准确性。如果您感兴趣,请参见 ch08/half_float_network.py。
一些研究已经探讨了在深度学习中减少位数的问题。在最近的研究中,提出了一种名为“二值化神经网络”的技术(Matthieu Courbariaux 和 Yoshua Bengio (2016): 二值化神经网络:训练具有约束权重和激活值为 +1 或 -1 的深度神经网络。arXiv 预印本 arXiv:1602.02830 (2016))。该技术用 1 位表示权重和中间数据。减少位数以加速深度学习是一个我们应该关注的话题,尤其是在考虑将深度学习用于嵌入式设备时。
深度学习的实际应用
作为使用深度学习的一个例子,我们主要讨论了图像分类,如手写数字识别,这被称为“物体识别”。然而,除了物体识别,我们还可以将深度学习应用于许多其他问题。深度学习在许多问题中表现出色,如图像识别、语音(语音识别)和自然语言处理。本节将介绍深度学习在计算机视觉领域的应用。
物体检测
物体检测识别图像中物体的位置并对其进行分类。物体检测比物体识别更具挑战性。物体识别的目标是识别整个图像,而物体检测则必须识别图像中各类物体的位置,并且可能存在多个物体。
一些基于 CNN 的技术已被提出用于物体检测。这些技术表现出色,表明深度学习在物体检测中同样有效。
在基于 CNN 的物体检测技术中,一种名为 R-CNN(Ross Girshick, Jeff Donahue, Trevor Darrell, 和 Jitendra Malik (2014): 用于精确物体检测和语义分割的丰富特征层次结构. 第 580 – 587 页)的技术非常著名。图 8.16 展示了 R-CNN 的流程:

图 8.16:R-CNN 的流程图
注意
图 8.16 引用了参考文献,Ross Girshick, Jeff Donahue, Trevor Darrell, 和 Jitendra Malik (2014): 用于精确物体检测和语义分割的丰富特征层次结构. 第 580 – 587 页。
在图 8.16中,请注意 2. 提取区域提议 和 3. 计算 CNN 特征 部分。第一个技术检测出看似物体的区域(以某种方式),然后对提取的区域应用 CNN 进行分类。R-CNN 将图像转换为正方形,并使用 支持向量机(SVMs)进行分类。其实际流程略显复杂,但主要由上述过程组成:提取候选区域并计算 CNN 特征。
在 R-CNN 的“提取区域候选”过程中,检测到目标候选区域,这时可以使用计算机视觉中已经开发的各种技术。在关于 R-CNN 的论文中,使用了一种叫做选择性搜索的技术。最近,提出了一种叫做“Faster R-CNN”的技术(Shaoqing Ren, Kaiming He, Ross Girshick, 和 Jian Sun (2015):Faster R-CNN:通过区域提议网络实现实时目标检测。发表于 C. Cortes, N. D. Lawrence, D. D. Lee, M. Sugiyama 和 R. Garnett 编辑的《神经信息处理系统进展 28》。Curran Associates, Inc., 91 – 99)。它甚至使用 CNN 提取区域提议。Faster R-CNN 使用一个 CNN 完成整个过程,从而实现快速处理。
分割
分割是基于像素对图像进行分类的技术。它通过使用标注了物体的像素训练数据进行学习,并在推理过程中对输入图像的所有像素进行分类。到目前为止,我们实现的神经网络对整个图像进行分类。那么,如何基于像素进行分类呢?
使用神经网络进行分割的最简单方法是对每个像素进行预测。例如,你可以提供一个网络,分类一个矩形区域中心的像素,并对所有像素进行预测。如你所见,这需要与像素数量相同的前向计算过程,因此完成起来需要大量时间(问题在于卷积操作会无用地重复计算很多区域)。为了减少这种无效计算,提出了一种叫做 全卷积网络(FCN)的技术(Jonathan Long, Evan Shelhamer 和 Trevor Darrell (2015):用于语义分割的全卷积网络。发表于 IEEE 计算机视觉与模式识别会议(CVPR))。它在一次前向计算中对所有像素进行分类(见图 8.20)。
FCN 是一种仅由卷积层组成的网络。虽然普通的 CNN 包含全连接层,但 FCN 用执行相同功能的卷积层替换了全连接层。在用于目标识别的网络中的全连接层中,中间数据的空间体积被处理为线性排列的节点。另一方面,在一个仅由卷积层组成的网络中,空间体积可以在处理过程中保持,直到最后的输出。
FCN 的主要特点是最后的空间尺寸会扩展。这个扩展可以使缩小的中间数据膨胀,从而一次性恢复到与输入图像相同的大小。FCN 末尾的扩展是通过双线性插值(双线性扩展)实现的。FCN 使用反卷积来进行双线性扩展(详细内容请参见论文《(Jonathan Long, Evan Shelhamer, and Trevor Darrell (2015): Fully Convolutional Networks for Semantic Segmentation. In The IEEE Conference on Computer Vision and Pattern Recognition (CVPR))》)。
注意
在全连接层中,输出连接到所有输入。你也可以在卷积层中创建一个结构相同的连接。例如,一个输入数据大小为 32x10x10(通道数为 32,高度为 10,宽度为 10)的全连接层可以用一个过滤器大小为 32x10x10 的卷积层来替代。如果全连接层有 100 个输出节点,卷积层可以通过提供 100 个 32x10x10 的过滤器完全实现相同的处理。这样,全连接层就可以被一个进行等效处理的卷积层替代。
生成图像描述
有一些有趣的研究正在进行,这些研究结合了自然语言和计算机视觉。当提供一张图像时,自动生成解释该图像的文本(即图像描述)。
例如,一张来自越野摩托车比赛的摩托车图像可能会附带这样的描述:“一个人在泥路上骑摩托车”(该文本是从图像中自动生成的)。令人惊讶的是,系统甚至“理解”到它是在泥路上,并且有一个人骑着摩托车。
一个名为神经图像描述(NIC)的模型通常用于生成图像描述,用于深度学习。NIC 由一个深度卷积神经网络(CNN)和一个用于处理自然语言的递归神经网络(RNN)组成。RNN 具有递归连接,常用于处理自然语言和时间序列数据等顺序数据。
NIC 使用 CNN 从图像中提取特征,并将其传递给 RNN。RNN 利用 CNN 提取的特征作为初始值,通过“递归”生成文本。我们在此不讨论技术细节。基本上,NIC 有一个简单的架构,结合了两个神经网络:CNN 和 RNN。它能够生成惊人的精准图像描述。处理图像和自然语言等不同类型信息的能力被称为多模态处理。近年来,多模态处理受到了广泛关注:
注意
RNN 中的 R 代表递归。“递归”表示神经网络的递归网络架构。由于递归架构,RNN 受到之前生成的信息的影响——换句话说,它能记住过去的信息。这是 RNN 的主要特征。例如,在生成单词“I”后,它会受到该单词的影响,生成下一个单词“am”。然后,它会受到之前生成的“I am”这两个词的影响,生成单词“sleeping”。对于自然语言和时间序列数据等连续数据,RNN 的行为就像记住了过去的信息一样。
深度学习的未来
深度学习现在正在被应用于各个领域,包括传统领域。 本节描述了深度学习的可能性以及一些展示深度学习未来的研究。
转换图像风格
有一项研究正在进行,利用深度学习“绘制”图像,就像艺术家一样。神经网络的一个流行应用案例是根据两张提供的图像创建一张新图像。其中一张被称为“内容图像”,而另一张被称为“风格图像”。新图像是基于这两张图像创建的。
在一个示例中,你可以指定梵高的画风作为要应用于内容图像的风格,深度学习将按指定方式绘制一幅新图像。这项研究发表在论文《艺术风格的神经算法》中(Leon A. Gatys, Alexander S. Ecker, 和 Matthias Bethge (2015):艺术风格的神经算法。arXiv:1508.06576[cs, q-bio](2015 年 8 月)),并在发布后立刻引起了全世界的关注。
粗略地说,在该技术中,网络中的中间数据会进行学习,从而接近“内容图像”的中间数据。通过这种方式,输入图像可以转换成类似内容图像的形状。为了吸收来自“风格图像”的风格,引入了风格矩阵的概念。通过训练使得风格矩阵的差距变小,输入图像可以接近梵高的风格。
生成图像
前述的图像风格迁移示例需要两张图像来生成一张新图像。另一方面,一些研究尝试在不需要任何图像的情况下生成新图像(该技术通过预先使用大量图像进行训练,但生成新图像时不需要任何图像)。例如,你可以使用深度学习从零开始生成一张“卧室”图像。
它们看起来像真实的照片,但实际上是由 DCGAN 新生成的。这些由 DCGAN 生成的图像是没有人见过的图像(即那些不在训练数据中的图像),并且是从零开始新创作的。
当 DCGAN 生成看起来像真实图像时,它创建了一个图像生成过程的模型。该模型通过使用许多图像(例如卧室的图像)进行学习。训练完成后,你可以使用该模型生成新的图像。
DCGANs 使用深度学习。DCGAN 技术的关键点是它使用了两个神经网络:一个生成器和一个判别器。生成器生成看起来真实的图像,而判别器判断图像是否真实,即判断它是由生成器生成的,还是实际上是拍摄的。通过这种方式,两个网络通过相互竞争进行训练。
生成器学习创建假图像的更复杂技术,而判别器则像一个评估师,可以更高精度地检测假图像。有趣的是,在一种被称为生成对抗网络(GAN)的技术中,它们通过竞争共同成长。最终,通过竞争成长起来的生成器能够绘制看起来真实的图像(或者可能会成长得更好)。
注意
我们到目前为止看到的机器学习问题被称为监督学习问题。它们使用包含图像数据和标签对的数据集,比如手写数字识别。而这里的问题中并没有提供标签数据。只提供图像(即一组图像)。这被称为无监督学习。无监督学习已经研究了相对较长时间(深度置信网络和深度玻尔兹曼机是著名的),但似乎现在它并没有被积极研究。由于像 DCGANs 这样的深度学习技术越来越受到关注,预计无监督学习将在未来得到进一步发展。
自动驾驶
“自动驾驶”技术,即计算机代替人类驾驶汽车,可能很快会实现。IT 公司、大学、研究机构以及汽车制造商都在竞争实现自动驾驶。这只有在诸如路径规划技术(确定交通路线)和传感技术(包括摄像头和激光器)等各种技术结合的情况下才能实现。据说,用于正确识别周围环境的技术是最重要的。要识别一个每天每时每刻都在变化的环境,以及那些自由移动的汽车和人类,极其困难。
如果系统能够在各种环境中稳健且可靠地识别出旅行区域,自动驾驶可能在不久的将来得以实现——这是深度学习应该证明其价值的重要任务。
例如,一种基于 CNN 的网络叫做 SegNet(Vijay Badrinarayanan, Kendall, 和 Roberto Cipolla(2015):SegNet:一种用于图像分割的深度卷积编码解码架构。arXiv 预印本 arXiv:1511.00561(2015))可以准确识别道路环境,如图 8.17所示:

图 8.17:通过深度学习进行图像分割的示例 – 道路、汽车、建筑物和人行道被准确识别
注意
图 8.17来自参考文献,SegNet 演示页面(mi.eng.cam.ac.uk/projects/segnet/)。
对输入图像进行分割(像素级评估),如图 8.17所示。结果表明,道路、建筑物、人行道、树木、汽车和摩托车得到了较为准确的区分。如果深度学习能够提升这些识别技术的准确性和速度,自动驾驶有可能在不久的将来实现实际应用。
深度 Q 网络(强化学习)
有一个研究领域叫做强化学习,计算机通过试错自我学习,就像人类学习如何骑自行车一样。这与“监督学习”不同,后者是由“监督者”面对面地进行教学。
强化学习的基本框架是,代理根据环境的情况选择行动,并且它的行动会改变环境。采取行动后,环境会给予代理一些奖励。强化学习的目的是确定代理的行动策略,使其能够获得更好的奖励,如下图所示:

图 8.18:强化学习的基本框架 – 代理通过自我学习获得更好的奖励
图 8.18中的图示显示了强化学习的基本框架。请注意,奖励不是像监督学习中那样的标注数据。例如,在视频游戏《超级马里奥兄弟》中,移动马里奥向右所获得的奖励数量未必是明确的。在这种情况下,"预期"奖励必须通过清晰的指标(如游戏得分、获得金币、击败敌人、游戏结束逻辑等)来确定。在监督学习中,每个行动都可以由“监督者”正确评估。
深度 Q 网络(DQN)是一种强化学习技术(Volodymyr Mnih 等人(2015):通过深度强化学习实现人类水平的控制,《自然》518,7540(2015),529 – 533),它使用深度学习。它基于一种叫做 Q 学习的强化学习算法。Q 学习确定一个叫做最优动作值函数的函数,用来确定最优动作。DQN 使用深度学习(CNN)来逼近这个函数。
一些研究表明,DQNs 可以自动学习视频游戏,取得比人类更成功的游戏成绩。如图 8.19所示,当 CNN 用于 DQN 时,它接收四帧连续的游戏图像作为输入,并输出游戏控制器(操纵杆的移动和按钮操作)的“价值”。
传统上,当网络学习视频游戏时,通常会提前提取并提供游戏的状态(如角色的位置)。与此同时,DQN 仅接收视频游戏的图像作为输入数据,如图 8.19所示。这是 DQN 值得注意的地方,并大大提高了其适用性。因为你不需要为每个游戏更改设置,只需要向 DQN 提供游戏图像。事实上,DQNs 已经以相同的配置学习了许多游戏,如“吃豆人”和“雅达利 2600”,并取得了比人类更好的结果:

图 8.19:使用深度 Q 网络学习视频游戏的操作。在这里,网络接收视频游戏的图像作为输入,并通过反复试错学习游戏控制器(操纵杆)的操作。
注意
图 8.17引用自参考文献,Volodymyr Mnih 等人(2015):通过深度强化学习实现人类水平的控制,《自然》518,7540(2015),529 – 533。
注意
一则名为 AlphaGo 的人工智能(David Silver 等人(2016):通过深度神经网络和树搜索掌握围棋游戏,《自然》529,7587(2016),484 – 489)战胜围棋冠军的消息引起了广泛关注。AlphaGo 也使用了深度学习和强化学习。它通过学习 3000 万条由专业人士创建的游戏记录,并多次与自己对战,积累了足够的知识。AlphaGo 和 DQNs 都由谷歌的 DeepMind 进行研究。未来我们必须关注它们的活动。
总结
在本章中,我们实现了一个深度卷积神经网络(CNN),并取得了超过 99%的优秀手写数字识别结果。我们还讨论了使网络更深的动机以及当前趋向于更深网络的趋势。我们还探讨了深度学习的趋势和应用,并且加速研究将推动这项技术进入未来。
在深度学习领域,仍有许多未知的内容,新的研究成果不断发布。全球的研究人员和工程师们持续积极研究,并将实现我们甚至无法想象的技术。
本章涵盖了以下几点:
-
使网络更深将提升许多深度学习问题的性能。
-
在图像识别比赛中,使用深度学习的技术获得了高排名,当前的网络比前代更深。
-
著名的网络包括 VGG、GoogLeNet 和 ResNet。
-
GPU、分布式训练和减少位精度可以加速深度学习。
-
深度学习(神经网络)可以用于物体检测和分割,以及物体识别。
-
使用深度学习的应用包括图像描述生成、图像生成和强化学习。如今,深度学习在自动驾驶中的应用也备受期待。
感谢您阅读本书。我们希望您对深度学习有了更好的理解,并且觉得这是一段有趣的旅程。
附录 A
关于
本节内容是为了帮助学生完成书中的活动。它包括学生为完成并实现活动目标所需要执行的详细步骤。
Softmax-with-Loss 层的计算图
下图是 Softmax-with-Loss 层的计算图,并获得反向传播。我们将 softmax 函数称为 Softmax 层,交叉熵误差称为 交叉熵误差 层,而这两者组合的层称为 Softmax-with-Loss 层。你可以通过 图 A.1 提供的计算图表示 Softmax-with-Loss 层:熵:

图 A.1:Softmax-with-Loss 层的计算图
图 A.1 中显示的计算图假设有一个神经网络用于分类三个类别。来自上一层的输入是 (a1, a2, a3),Softmax 层输出 (y1, y2, y3)。标签是 (t1, t2, t3),交叉熵误差层输出损失 L。
本附录展示了 Softmax-with-Loss 层反向传播的结果为 (y1 − t1, y2 − t2, y3 − t3),如 图 A.1 所示。
前向传播
图 A.1 中显示的计算图并没有展示 Softmax 层和交叉熵误差层的详细信息。在这里,我们将首先描述这两个层的细节。
首先,让我们来看一下 Softmax 层。我们可以用以下方程表示 softmax 函数:
![]() |
(A.1) |
|---|
因此,我们可以通过 图 A.2 提供的计算图展示 Softmax 层。在这里,S 代表指数和,是方程 (A.1) 中的分母。最终输出为 (y1, y2, y3)。

图 A.2:Softmax 层的计算图(仅前向传播)
接下来,让我们来看一下交叉熵误差层。以下方程展示了交叉熵误差:
![]() |
(A.2) |
|---|
基于方程 (A.2),我们可以画出交叉熵误差层的计算图,如 图 A.3 所示。
图 A.3 中显示的计算图只是将方程 (A.2) 展示为计算图。因此,我认为这里没有什么特别困难的。

图 A.3:交叉熵误差层的计算图(仅前向传播)
现在,让我们来看一下反向传播:
反向传播
首先,让我们来看一下交叉熵误差层的反向传播。我们可以如下绘制交叉熵误差层的反向传播:

图 A.4: 交叉熵误差层的反向传播
请注意以下事项,以便获得此计算图的反向传播:
-
反向传播的初始值(图 A.4中的最右侧反向传播值)为 1(因为
)。 -
对于“x”节点的反向传播,正向传播输入信号的“反转值”乘以上游的导数后传递到下游。
-
对于“+”节点,来自上游的导数被传递而不进行任何更改。
-
“log”节点的反向传播遵循以下方程:
![99]()
基于此,我们可以轻松地获得交叉熵误差层的反向传播。因此,值
将作为反向传播到 Softmax 层的输入。
接下来,我们来看看 Softmax 层的反向传播。由于 Softmax 层稍微复杂,我想一步步检查它的反向传播:
第 1 步:

图 A.5: 第 1 步
反向传播的值来自前一层(交叉熵误差层)。
第 2 步:

图 A.6: 第 2 步
“x”节点“反转”正向传播的值以进行乘法计算。这里,执行以下计算:
![]() |
(A.3) |
|---|
第 3 步:

图 A.7: 第 3 步
如果正向传播中的流分支成多个值,则在反向传播中这些分离的值会被相加。因此,在这里三个分开的反向传播值
被加在一起。对于添加的值,进行 / 的反向传播,结果是
。这里,(t1, t2, t3) 是标签和“独热向量”。独热向量意味着 (t1, t2, t3) 中的一个值为 1,其他值都为 0。因此,(t1, t2, t3) 的和为 1。
第 4 步:

图 A.8: 第 4 步
“+”节点只传递值而不改变它。
第 5 步:

图 A.9: 第 5 步
“x”节点“反转”值以进行乘法计算。这里,
被用来转化方程式。
第 6 步:

图 A.10: 第 6 步
在“exp”节点中,以下方程成立:
![]() |
(A.4) |
|---|
因此,将两个单独输入的和相加,并乘以 exp(a1),就是反向传播所得到的结果。我们可以将其写为
,并通过变换得到
。因此,在前向传播输入为
的节点,反向传播为
。对于
和
,我们可以使用相同的过程(结果分别为
和
)。通过这种方式,我们可以轻松地证明,即使我们想要分类 n 类而不是三类,我们也能得到相同的结果。
摘要
这里详细展示了带损失的 Softmax 层的计算图,并得到了其反向传播结果。图 A.11 显示了带损失的 Softmax 层的完整计算图:

图 A.11:带损失的 Softmax 层的计算图
如图 A.11所示,计算图看起来复杂。然而,如果按步骤推进计算图,获得导数(即反向传播过程)将会变得不那么麻烦。当你遇到看起来复杂的层(例如批归一化层)时,除了这里描述的带损失的 Softmax 层,你可以使用此过程。这比仅仅看公式更容易理解。





















的梯度
——虚线表示函数的等高线





















的分布







)。


浙公网安备 33010602011771号