深度学习的数学-全-
深度学习的数学(全)
原文:
zh.annas-archive.org/md5/464625086f3c8bb3d41d4f9b710afb03译者:飞龙
序言

数学是现代世界的基础。深度学习也正在迅速成为必不可少的一部分。从自动驾驶汽车的承诺,到医疗系统比绝大多数医生更好地检测骨折,再到越来越强大的、可能令人担忧的语音控制助手,深度学习无处不在。
本书涵盖了理解深度学习所必需的数学知识。的确,你可以学习工具包,设置配置文件或 Python 代码,格式化数据并训练模型,所有这些都不需要理解你正在做什么,更不用说背后的数学了。而且,由于深度学习的强大力量,你通常会成功。然而,你不会理解,你不应该满足于此。要理解,你需要一些数学知识。不是很多数学,而是一些特定的数学。特别是,你需要具备概率论、统计学、线性代数和微积分的基础知识。幸运的是,这些正是本书将要涉及的内容。
本书适合谁阅读?
这不是一本入门级的深度学习书籍。它不会教你深度学习的基础。相反,它是作为一本入门书籍的补充。(参见我的书《实用深度学习:基于 Python 的入门》(Practical Deep Learning: A Python-Based Introduction),[No Starch Press, 2021])。我希望你至少在概念上对深度学习有所了解,尽管我会在书中对一些内容进行解释。
此外,我希望你能带来一定的知识基础。我期望你具备高中数学知识,特别是代数。我还希望你熟悉使用 Python、R 或类似语言的编程。我们将使用 Python 3.x 以及一些流行的工具包,如 NumPy、SciPy 和 scikit-learn。
我尽量保持其他期望的最低限度。毕竟,本书的目的是为你提供成功学习深度学习所需的内容。
本书介绍
本书的核心是一本数学书。但我们不会使用证明和练习题,而是通过代码来说明概念。深度学习是一个应用学科,你需要通过实践才能理解。因此,我们将通过代码来弥合纯粹的数学知识和实际应用之间的差距。
各章内容是层层递进的,基础章节之后是更高级的数学主题,最终将涉及深度学习算法,涵盖前面章节的所有内容。我建议你从头到尾通读全书,如果你已经熟悉某些主题,可以在遇到时跳过它们。
第一章:设置舞台 本章配置了我们的工作环境和将要使用的工具包,这些是深度学习中最常用的工具包。
第二章:概率 概率影响深度学习几乎所有方面,对于理解神经网络如何学习至关重要。本章是关于该主题的两章中的第一章,介绍了概率的基本概念。
第三章:更多概率 概率如此重要,以至于一个章节不足以涵盖。 本章继续我们的探索,并包括深度学习中的关键主题,如概率分布和贝叶斯定理。
第四章:统计学 统计学能够解释数据,对于评估模型至关重要。统计学与概率密切相关,因此我们需要理解统计学才能理解深度学习。
第五章:线性代数 线性代数是向量和矩阵的世界。深度学习的核心是线性代数。实现神经网络是向量和矩阵数学的练习,因此理解这些概念的意义及如何使用它们至关重要。
第六章:更多线性代数 本章继续我们对线性代数的探索,重点讲解与矩阵相关的重要主题。
第七章:微分学 训练神经网络的最基本概念可能就是梯度。为了理解梯度、它是什么以及如何使用它,我们必须知道如何处理函数的导数。本章为理解导数和梯度提供了必要的基础。
第八章:矩阵微积分 深度学习操作向量和矩阵的导数。因此,在本章中,我们将导数的概念推广到这些对象。
第九章:神经网络中的数据流 为了理解神经网络如何操作向量和矩阵,我们需要了解数据如何在网络中流动。这正是本章的主题。
第十章:反向传播 成功训练神经网络通常涉及两个密切相关的算法:反向传播和梯度下降。在本章中,我们详细讲解反向传播,看看我们之前学到的数学如何应用于神经网络的训练。
第十一章:梯度下降 梯度下降利用反向传播算法提供的梯度来训练神经网络。本章从一维示例开始,逐步讲解梯度下降,直到完全连接的神经网络。还描述并比较了常见的梯度下降变体。
附录:深入学习 我们必须在必然的情况下略过许多概率、统计、线性代数和微积分的内容。本附录为您指引了可以帮助您进一步了解深度学习背后的数学资源。
你可以从这里下载书中的所有代码:github.com/rkneusel9/MathForDeepLearning/。同时,请查看 nostarch.com/math-deep-learning/ 以获取未来的勘误信息。让我们开始吧。
第一章:设置舞台

尽管本书没有传统的数学练习,但如果我们想掌握这些概念,我们确实需要玩弄一下。我们将有很多机会做到这一点,但我们将使用代码而不是铅笔和纸的练习。
本章将帮助您通过配置我们的工作环境来设置舞台。在整本书中,我将在 Linux 中工作,具体来说是 Ubuntu 20.04,尽管我们所做的大多数工作也可能适用于 Ubuntu 的后续版本和大多数其他 Linux 发行版。为了完整起见,我还包括了配置 macOS 和 Windows 环境的部分。我应该指出,深度学习的预期操作系统是 Linux,大多数事情也可以在 macOS 下工作。Windows 通常是一个次要考虑因素,并且许多深度学习工具包的移植维护不佳,尽管随着时间的推移情况有所改善。
我们将从一些安装预期软件包的说明开始。然后,我们将快速查看 Python 3.x中的 NumPy 库。NumPy 是几乎所有 Python 科学用途的基础,并且您需要知道如何在基本水平上使用它。接下来,我将介绍 SciPy。这也是科学上必需的工具包,但在这里我们只需要使用它的一小部分。最后,我会简要介绍 Scikit-Learn 工具包,这里简称为sklearn。这个有价值的工具包实现了许多传统的机器学习模型。
在整本书中,我经常使用运行示例来说明概念。所有代码片段都假定执行了以下行:
import numpy as np
此外,在某些地方,代码将引用本章前面出现的片段的输出。代码示例很简短,因此从一个到另一个的跟随不应该是繁琐的。我建议在您阅读一章时保持单个 Python 会话运行,尽管这不是必需的。
安装工具包
本节的最终目标是安装以下工具包,并且至少具有列出的版本号:
-
Python 3.8.5
-
NumPy 1.17.4
-
SciPy 1.4.1
-
Matplotlib 3.1.2
-
Scikit-Learn (
sklearn) 0.23.2
比这些版本更新的版本几乎肯定也会起作用。
让我们快速看看如何在主要操作系统中安装这些工具包。
Linux
对于以下内容,$提示符表示命令行,而>>>是 Python 提示符。
Ubuntu 20.04 桌面的新安装为我们提供了 Python 3.8.5。使用以下代码
$ cat /etc/os-release
验证您的操作系统版本并使用python3运行 Python,因为仅使用python将启动较旧的 Python 2.7。
这些命令安装了 NumPy、SciPy、Matplotlib 和sklearn:
$ sudo apt-get install python3-pip
$ sudo apt-get install python3-numpy
$ sudo apt-get install python3-scipy
$ sudo pip3 install matplotlib
$ sudo pip3 install scikit-learn
通过启动 Python 3 并导入每个模块(numpy,scipy和sklearn)来测试安装。然后打印__version__字符串以确保它符合或超过上述版本。例如,请参阅以下代码。
>>> import numpy; numpy.__version__
'1.17.4'
>>> import scipy; scipy.__version__
'1.4.1'
>>> import matplotlib; matplotlib.__version__
'3.1.2'
>>> import sklearn; sklearn.__version__
'0.23.2'
macOS
要为 Macintosh 安装 Python 3.x,请访问 www.python.org/,在 下载 下选择 Mac OS X。然后选择最新的稳定版 Python 3 版本。写这篇文章时,最新版本是 3.9.2。下载完成后,运行安装程序以安装 Python 3.9.2。
安装完成后,打开终端窗口,并通过以下命令验证安装是否成功:
$ python3 --version
Python 3.9.2
假设 Python 3 安装正确,现在我们可以使用终端窗口和安装程序为我们设置好的 pip3 来安装库:
$ pip3 install numpy --user
$ pip3 install scipy --user
$ pip3 install matplotlib --user
$ pip3 install scikit-learn --user
最后,我们可以从 Python 3 内部检查库的版本。输入 python3 打开终端中的 Python 控制台,然后导入 numpy、scipy、matplotlib 和 sklearn 并打印版本信息,就像上面做的那样,以验证它们是否符合或超过最低版本要求。
Windows
要在 Windows 10 上安装 Python 3 和工具包,请按照以下步骤操作:
-
访问
www.python.org/,点击 下载 和 Windows。 -
在页面底部,选择 x86-64 可执行安装程序。
-
运行安装程序,选择默认选项。
-
选择 为所有用户安装 和 将 Python 添加到 Windows PATH 中。这一点很重要。
安装程序完成后,由于我们让安装程序将 Python 添加到 PATH 环境变量中,因此可以在命令提示符下使用 Python。打开命令提示符(WINDOWS-R,输入 cmd),然后输入 python。如果一切顺利,你将看到 Python 启动消息,并出现 >>> 交互式提示符。写这篇文章时,安装的版本是 3.8.2。请注意,要退出 Windows 中的 Python,请使用 CTRL-Z,而不是 CTRL-D。
Python 安装程序还贴心地为我们安装了 pip。我们可以直接在 Windows 命令提示符下使用它来安装所需的库。在提示符下,输入以下命令来安装 NumPy、SciPy、Matplotlib 和 sklearn 库:
> pip install numpy
> pip install scipy
> pip install matplotlib
> pip install sklearn
对我来说,这次安装了 NumPy 1.18.1、SciPy 1.4.1、Matplotlib 3.2.1 和 sklearn 0.22.2,这些版本符合上面提到的最低要求,因此一切正常。
为了测试,打开命令提示符,启动 Python 并导入 numpy、scipy、matplotlib 和 sklearn。这三个库都应该能够正常加载。如果要编写 Python 代码,可以安装任何你喜欢的编辑器,或者直接使用记事本。
在工具包安装并准备就绪后,我们快速了解每个库,至少对它们有一些基本的了解。全书中我们会看到很多例子,但我建议你查看推荐的文档。这样做是值得的。
NumPy
我们在上一节安装了 NumPy。现在我将介绍一些基本的 NumPy 概念和操作。完整的教程可以在网上找到,网址是 docs.scipy.org/doc/numpy/user/quickstart.html。
启动 Python。然后在提示符下尝试以下操作:
>>> import numpy as np
>>> np.__version__
'1.16.2'
第一行加载 NumPy 并为其设置一个快捷名称np。使用快捷名称并非强制要求,但几乎是普遍做法。接下来我们会假设使用np。第二行显示了版本号,应该至少与上面显示的版本相同。
定义数组
NumPy 操作数组,且非常擅长将列表转换为数组。想象一下在像 C 或 Java 这样的语言中你会遇到什么样的数组。NumPy 提供了一个优势,因为尽管 Python 优雅,但在使用列表模拟数组时,Python 的速度过慢,不适合科学计算。实际的数组要快得多。以下是一个从列表定义数组的示例,然后检查它的一些属性:
>>> a = np.array([1,2,3,4])
>>> a
array([1, 2, 3, 4])
>>> a.size
4
>>> a.shape
(4,)
>>> a.dtype
dtype('int64')
这个示例定义了一个包含四个元素的列表,然后将其传递给np.array,将其转换为一个 NumPy 数组。基本的数组属性包括大小和形状。大小是四个元素。形状也是四,作为一个元组,表示a是一个向量,属于一维(1D)数组。形状为四是因为数组a包含四个元素。如果a是二维(2D)数组,形状将包含两个值,分别表示数组的每个轴。请看下面的示例,其中b的形状告诉我们b有两行四列:
>>> b = np.array([[1,2,3,4],[5,6,7,8]])
>>> print(b)
[[1 2 3 4]
[5 6 7 8]]
>>> b.shape
(2, 4)
数据类型
Python 的数字数据类型有两种类型:任意大小的整数(可以尝试2**1000)和浮动点数。然而,NumPy 允许多种不同类型的数组。底层实现中,NumPy 是用 C 语言编写的,因此它支持 C 语言支持的数据类型。上面的示例展示了np.array函数接收给定的列表,由于列表中的每个元素都是整数,因此创建了一个每个元素都是有符号 64 位整数的数组。表 1-1 列出了 NumPy 支持的数据类型;我们可以让 NumPy 为我们选择数据类型,也可以显式指定它。
表 1-1: NumPy 数据类型名称、C 类型和范围
| NumPy 名称 | 对应的 C 类型 | 范围 |
|---|---|---|
| float64 | 双精度浮点型 | ±[2.225 × 10^(–308), 1.798 × 10³⁰⁸] |
| float32 | 单精度浮点型 | ±[1.175 × 10^(–38), 3.403 × 10³⁸] |
| int64 | 长长整型 | [–2⁶³, 2⁶³–1] |
| uint64 | 无符号长长整型 | [0, 2⁶⁴–1] |
| int32 | 长整型 | [–2³¹, 2³¹–1] |
| uint32 | 无符号长整型 | [0, 2³²–1] |
| uint8 | 无符号字符型 | [0, 255 = 2²–1] |
让我们来看一些具有特定数据类型的数组示例:
>>> a = np.array([1,2,3,4], dtype="uint8")
>>> a.dtype
dtype('uint8')
>>> a = np.array([1,2,3,4], dtype="int16")
>>> a = np.array([1,2,3,4], dtype="uint32")
>>> b = np.array([1,2,3,4.0])
>>> b.dtype
dtype('float64')
>>> b = np.array([1,2,3,4.0], dtype="float32")
>>> c = np.array([111,222,333,444], dtype="uint8")
>>> c
array([111, 222, 77, 188], dtype=uint8)
使用数组a的示例采用了整数类型,而使用数组b的示例则采用了浮动点类型。注意,第一个b示例默认使用了 64 位浮点数。NumPy 这样做是因为输入列表的一个元素是浮动点数(4.0)。
最后一个定义数组 c 的例子似乎是个 bug,但其实不是。NumPy 不会警告我们如果请求的数据类型无法容纳给定的值。这里,我们有一个 8 位整数,它只能容纳 [0, 255] 范围内的值。前两个,111 和 222,适合,但最后两个,333 和 444,太大了。NumPy 默默地保留了这些值的最低 8 位,分别对应 77 和 188。教训是,NumPy 期望你在处理数据类型时知道自己在做什么。通常这不是问题,但需要记住这一点。
2D 数组
如果一个列表变成了 1D 向量,我们可能会猜测一个列表的列表会变成一个 2D 数组。我们猜得没错:
>>> d = np.array([[1,2,3],[4,5,6],[7,8,9]])
>>> d.shape
(3, 3)
>>> d.size
9
>>> d
array([[1, 2, 3],
[4, 5, 6],
[7, 8, 9]])
我们看到,三个子列表被映射为一个 3 × 3 的数组(矩阵)。NumPy 数组的下标从零开始,因此上面引用的 d[1,2] 返回 6。
零和一
两个特别有用的 NumPy 函数是 np.zeros 和 np.ones。它们都根据给定的形状定义数组。第一个将数组元素初始化为零,而第二个将它们初始化为一。这是从头开始创建 NumPy 数组的主要方式:
>>> a = np.zeros((3,4), dtype="uint32")
>>> a[0,3] = 42
>>> a[1,1] = 66
>>> a
array([[ 0, 0, 0, 42],
[ 0, 66, 0, 0],
[ 0, 0, 0, 0]], dtype=uint32)
>>> b = 11*np.ones((3,1))
>>> b
array([[11.],
[11.],
[11.]])
第一个参数是一个元组,给出每个维度的大小。如果我们传入一个标量,结果数组将是一个 1D 向量。我们来看看 b 的定义。在这里,我们将 3 × 1 的数组乘以标量(11)。这使得数组的每个元素(最初被初始化为 1.0)都被乘以 11。
高级索引
我们在上面的例子中看到了简单的数组索引,我们使用一个单一的值进行索引。NumPy 支持更复杂的数组索引。我们经常使用的一种类型是返回完整子数组的单一索引。下面是一个例子:
>>> a = np.arange(12).reshape((3,4))
>>> a
array([[ 0, 1, 2, 3],
[ 4, 5, 6, 7],
[ 8, 9, 10, 11]])
>>> a[1]
array([4, 5, 6, 7])
>>> a[1] = [44,55,66,77]
>>> a
array([[ 0, 1, 2, 3],
[44, 55, 66, 77],
[ 8, 9, 10, 11]])
本例介绍了np.arange,它是 NumPy 对 Python 的 range 函数的等价物。注意使用 reshape 方法将 12 元素的向量转换为 3 × 4 的矩阵。同时,注意到 a[1] 返回整个子数组,从第一维的第一个索引开始。这种语法是 a[1,:] 的简写,其中 : 表示给定维度的所有元素。这个简写在赋值时也适用,如下一行所示。
对 Python 列表的切片使用相同的语法也适用于 NumPy。如果我们继续上面的例子,结果如下:
>>> a[:2]
array([[ 0, 1, 2, 3],
[44, 55, 66, 77]])
>>> a[:2,:]
array([[ 0, 1, 2, 3],
[44, 55, 66, 77]])
>>> a[:2,:3]
array([[ 0, 1, 2],
[44, 55, 66]])
>>> b = np.arange(12)
>>> b
array([ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
>>> b[::2]
array([ 0, 2, 4, 6, 8, 10])
>>> b[::3]
array([0, 3, 6, 9])
>>> b[::-1]
array([11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0])
我们看到 a[:2] 返回前两行,并隐式地对第二维使用 :,正如下一行所示。通过第三个命令,我们通过取前两行和前三列,使用 a[:2,:3] 获取一个二维子数组。关于 b 的例子展示了如何提取每隔一个或每隔三个元素。最后一个例子尤其实用:它使用负增量来反转维度。增量是 -1 来反转所有值。如果增量是 -2,我们将按反向顺序获取 b 的每隔一个元素。
NumPy 使用:表示特定维度上所有元素。它还允许使用...(省略号)作为“根据需要使用多个:”的简写。例如,让我们定义一个三维(3D)数组:
>>> a = np.arange(24).reshape((4,3,2))
>>> a
array([[[ 0, 1],
[ 2, 3],
[ 4, 5]],
[[ 6, 7],
[ 8, 9],
[10, 11]],
[[12, 13],
[14, 15],
[16, 17]],
[[18, 19],
[20, 21],
[22, 23]]])
你可以把数组a看作是四个 3 × 2 矩阵的集合。要更新其中的第二个矩阵,你可以使用如下代码:
>>> a[1,:,:] = [[11,22],[33,44],[55,66]]
>>> a
array([[[ 0, 1],
[ 2, 3],
[ 4, 5]],
[[11, 22],
[33, 44],
[55, 66]],
[[12, 13],
[14, 15],
[16, 17]],
[[18, 19],
[20, 21],
[22, 23]]])
在这里,我们使用:显式地指定了维度,并展示了 NumPy 并不挑剔:它知道一个列表的列表符合子数组的预期形状,并相应地更新了数组a。我们也可以使用省略号...来实现相同的效果,如下所示。
>>> a[2,...] = [[99,99],[99,99],[99,99]]
>>> a
array([[[ 0, 1],
[ 2, 3],
[ 4, 5]],
[[11, 22],
[33, 44],
[55, 66]],
[[99, 99],
[99, 99],
[99, 99]],
[[18, 19],
[20, 21],
[22, 23]]])
我们现在已经更新了第三个 3 × 2 子数组。
读取和写入磁盘
NumPy 数组可以通过使用np.save和np.load从磁盘写入和加载,如下所示:
>>> a = np.random.randint(0,5,(3,4))
>>> a
array([[4, 2, 1, 3],
[4, 0, 2, 4],
[0, 4, 3, 1]])
>>> np.save("random.npy",a)
>>> b = np.load("random.npy")
>>> b
array([[4, 2, 1, 3],
[4, 0, 2, 4],
[0, 4, 3, 1]])
在这里,我们使用np.random.randint创建一个随机的 3 × 4 整数数组,值的范围在 0 到 5 之间。NumPy 拥有丰富的随机数库。我们将数组a保存为random.npy文件。.npy扩展名是必要的,如果没有提供,系统会自动添加。然后,我们使用np.load从磁盘加载该数组。
我们将在本书中遇到其他 NumPy 函数。我会在首次介绍时对它们进行解释。接下来,让我们快速了解一下 SciPy 库。
SciPy
SciPy 为 Python 添加了大量功能。它在底层使用 NumPy,因此这两个库通常是一起安装的。这里有一个完整的教程:docs.scipy.org/doc/scipy/reference/tutorial/index.html。
在本书中,我们将重点介绍scipy.stats模块中的函数。启动 Python 并尝试以下操作:
>>> import scipy
>>> scipy.__version__
'1.2.1'
这会加载 SciPy 模块并验证版本号至少是应该的版本。任何更新的 SciPy 版本应该都能正常工作。
作为快速测试,让我们尝试以下操作:
>>> from scipy.stats import ttest_ind
>>> a = np.random.normal(0,1,1000)
>>> b = np.random.normal(0,0.5,1000)
>>> c = np.random.normal(0.1,1,1000)
>>> ttest_ind(a,b)
Ttest_indResult(statistic=-0.027161815649563964, pvalue=0.9783333836992686)
>>> ttest_ind(a,c)
Ttest_indResult(statistic=-2.295584443456226, pvalue=0.021802794508002675)
首先,我们加载 NumPy,然后加载 SciPy 的stats模块中的ttest_ind函数。该函数接受两组数据,例如两班的考试成绩,并提出问题:这两组数据的平均值是否相同?或者,更准确地说,它问:我们能有多大信心认为这两组数据来自相同的过程?t 检验是回答这个问题的经典方法。评估其结果的一种方式是查看p 值。你可以将p 值理解为如果这两组数据来自相同的生成过程,它们的平均值差异会有多大概率出现。接近 1 的概率意味着我们非常有信心这两组数据来自同一个过程。
变量a、b和c是 1D 数组,其中的值(此处为 1000 个)来自高斯曲线,也称为正态曲线。我们稍后会详细讨论这些内容,但现在需要知道的是,这些数字来自一个钟形曲线,其中接近中间的值比靠近边缘的值更有可能被选中。normal函数的前两个参数是平均值和标准差,标准差是衡量钟形曲线宽度的指标:标准差越大,曲线越平坦,越宽。
对于这个示例,我们期望a和b非常相似,因为它们的平均值都是 0.0,尽管钟形曲线的形状略有不同。然而,c的平均值为 0.1。我们希望 t 检验能够检测到这一点,并告诉我们,我们需要小心认为a和c是由相同的过程生成的。
ttest_ind函数的输出列出了p-值(pvalue)。正如我们预期的那样,比较a和b返回了一个p-值为 0.98,意味着在假设这两组数据来自相同的生成过程的情况下,平均值之间的差异的概率大约为 98%。然而,当我们比较a和c时,得到一个p-值为 2.7%(0.027)。这意味着如果a和c是由相同过程生成的,看到它们之间的差异的概率约为 3%。因此,我们得出结论,a和c来自不同的过程。我们可以说,这两组数据的差异是统计学显著的。
历史上,p-值小于 0.05 被认为具有统计学显著性。然而,这一阈值是任意设定的,最近在实验重复性,尤其是软科学领域的经验表明,应该设定更严格的阈值。使用p-值为 0.05 意味着你在 20 次中大约会错 1 次(1/20 = 0.05),这是一个过于宽松的阈值。话虽如此,接近 0.05 的p-值仍然表明“某些事情”正在发生,更多的调查(以及更大的数据集)是必要的。
Matplotlib
我们将使用 Matplotlib 来生成图形。这里我们来验证它的 2D 和 3D 绘图能力。首先,展示一个简单的 2D 示例:
>>> import numpy as np
>>> import matplotlib.pylab as plt
>>> x = np.random.random(100)
>>> plt.plot(x)
>>> plt.show()
本示例加载了 NumPy,Matplotlib 最适合与其配合使用,并生成一个包含 100 个随机值的向量x,值范围在 0, 1)之间,这是np.random.random的输出。接着,我们使用plt.plot绘制该向量,并用plt.show显示它。Matplotlib 的输出是交互式的。可以玩弄图形窗口,熟悉如何使用该窗口。例如,[图 1-1 显示了 Linux 系统中图形窗口的样子。由于该图是随机生成的,你将看到不同的数值序列,但窗口中的控制按钮将保持相同。

图 1-1:一个示例 Matplotlib 绘图窗口
对于 3D 图形,试试这个:
>>> from mpl_toolkits.mplot3d import Axes3D
>>> import matplotlib.pylab as plt
>>> import numpy as np
>>> x = np.random.random(20)
>>> y = np.random.random(20)
>>> z = np.random.random(20)
>>> fig = plt.figure()
>>> ax = fig.add_subplot(111, projection='3d')
>>> ax.scatter(x,y,z)
>>> plt.show()
我们首先加载 3D 坐标轴工具包、Matplotlib 和 NumPy。然后,使用 NumPy,我们生成三个随机向量,[0, 1)范围内。这些就是我们的 3D 点。通过plt.figure和fig.add_subplot,我们设置一个 3D 投影。111是 Matplotlib 的简写,表示我们需要一个 1×1 的网格,并且当前绘图应放在该网格的第一个位置。因此,111表示一个单一的绘图。projection关键字使得绘图准备好进行 3D 展示。最后,使用ax.scatter绘制散点图,并通过plt.show显示出来。与 2D 绘图一样,3D 绘图是交互式的。用鼠标抓住并按住,可以旋转图形。
Scikit-Learn
本书的目标是讲解深度学习的数学原理,而不是深度学习的实现。然而,偶尔通过简单的神经网络模型会有所帮助。在这些情况下,我们将使用sklearn,特别是MLPClassifier类。此外,sklearn还包含一些有用的工具,用于评估模型的性能和可视化高维数据。
作为一个简单的示例,让我们构建一个简单的神经网络,用于分类手写数字的小型 8×8 像素灰度图像。这个数据集内置于sklearn中。以下是示例代码:
import numpy as np
from sklearn.datasets import load_digits
from sklearn.neural_network import MLPClassifier
❶ d = load_digits()
digits = d["data"]
labels = d["target"]
N = 200
❷ idx = np.argsort(np.random.random(len(labels)))
x_test, y_test = digits[idx[:N]], labels[idx[:N]]
x_train, y_train = digits[idx[N:]], labels[idx[N:]]
❸ clf = MLPClassifier(hidden_layer_sizes=(128,))
clf.fit(x_train, y_train)
score = clf.score(x_test, y_test)
pred = clf.predict(x_test)
err = np.where(y_test != pred)[0]
print("score : ", score)
print("errors:")
print(" actual : ", y_test[err])
print(" predicted: ", pred[err])
我们首先导入 NumPy。然后从sklearn中导入load_digits函数,用于返回小的数字图像数据集,以及MLPClassifier类,用于训练传统神经网络,即多层感知器。接着,我们获取数字数据,并提取图像及其关联的标签,0 . . . 9❶。数字图像以 8×8 = 64 元素向量的形式存储,表示图像展开后行像素逐一排列。数字数据集包括 1,797 张图像,因此digits是一个 2D 的 NumPy 数组,包含 1,797 行,每行 64 列,labels是一个包含 1,797 个数字标签的向量。
我们将图像的顺序随机化,注意确保每个标签与对应的数字正确匹配❷,并提取训练和测试数据(x_train,x_test)及标签(y_train,y_test)。我们将前 200 张数字图像留作测试数据,并用剩下的 1,597 张图像进行模型训练。这意味着每个数字大约有 160 张图像用于训练,每个数字大约有 20 张图像用于测试。
接下来,我们通过创建MLPClassifier的实例来构建模型❸。我们将使用所有默认设置,仅指定单一隐藏层的大小,该层有 128 个节点。输入向量有 64 个元素,因此我们将其大小加倍,以适应隐藏层。无需显式指定输出层的大小;sklearn会根据y_train中的标签来推断。训练模型只需简单地调用clf.fit,并传入训练图像向量(x_train)和标签(y_train)。
对于像这样的一个小数据集,训练只需要几秒钟。当训练完成后,学习到的权重和偏差会存储在模型(clf)中。我们首先获取得分,整体准确率(score),然后是测试集上的实际模型类标签预测(pred)。通过查找实际标签(y_test)与预测结果不匹配的地方,任何错误都会被捕捉到err中。最后,我们展示错误的实际类标签和预测标签。
每次运行这段代码时,我们都会得到不同的数字数据排序,这导致了不同的训练集和测试集。此外,神经网络在训练前是随机初始化的。所以,每次我们都会得到不同的结果。我第一次运行这段代码时,整体得分为 0.97(97%的准确率)。如果通过猜测来预测,准确率大约为 10%,因此我们可以说模型已经学得相当不错了。
总结
在本章中,我们学习了如何配置我们的工作环境。接着,我从高层次介绍了我们的 Python 工具包,并提供了进一步学习的指引。在工作环境安全并繁荣发展的基础上,下一章将深入探讨概率论。
第二章:概率**

概率影响着我们生活的方方面面,但实际上,我们在这方面都做得不好,正如本章中的一些例子所展示的那样。我们需要学习概率学才能掌握它。而我们需要掌握它,因为深度学习广泛涉及概率论的概念。概率无处不在,从神经网络的输出到不同类别在现实中出现的频率,再到用于初始化深度网络的分布。
本章的目标是让你了解在深度学习中你会经常遇到的与概率相关的概念和术语。我们将从概率的基本概念开始,并介绍随机变量的概念。接着我们将讨论概率的基本规则。这些部分涵盖了基础知识,为我们后续讨论联合概率与边际概率奠定基础。在你深入探索深度学习时,你会一遍又一遍地遇到这些术语。一旦你掌握了如何使用联合概率和边际概率,我将介绍本书中讨论的两个链式法则中的第一个。第二个在第六章的微积分部分中讨论。我们将在第三章继续研究概率。
基本概念
一个概率是一个介于零和一之间的数字,用来衡量某件事发生的可能性。如果某件事不可能发生,那么它的概率就是零。如果某件事肯定会发生,那么它的概率就是一。我们通常以这种方式表达概率,尽管在日常生活中,人们似乎不喜欢说“明天下雨的概率是 0.25”。相反,我们会说“明天下雨的几率是 25%”。在日常口语中,我们将分数概率转换为百分比。在本章中我们也会这样做。
上一段使用了多个与概率相关的词汇:可能性、机会和确定性。在日常使用中这样表达是可以的,甚至在深度学习中也是如此,但当我们需要明确时,我们将坚持使用概率,并将其以零到一之间的数字表示,范围为[0, 1]。方括号表示上下限都包括在内。如果范围内不包括某个限值,则使用普通括号。例如,NumPy 函数np.random.random()返回一个伪随机的浮点数,范围是[0, 1)。因此,它可能返回零,但永远不会返回一。
接下来,我将介绍样本空间、事件和随机变量的基础概念。最后,我会给出一些关于人类如何在概率上犯错的例子。
样本空间和事件
简单来说,样本空间 是一个离散集合或连续范围,表示一个事件的所有可能结果。事件 是发生的事情,通常是某个物理过程的结果,比如硬币的翻转或骰子的掷出。我们将所有可能的事件归纳在一起,形成我们正在处理的样本空间。每个事件都是样本空间中的一个样本,而样本空间则代表所有可能的事件。让我们来看几个例子。
硬币翻转的可能结果是正面(H)或反面(T);因此,硬币翻转的样本空间是集合 {H, T}。标准骰子掷出的样本空间是集合 {1, 2, 3, 4, 5, 6},因为排除骰子在其边缘停留的情况,当骰子停止移动时,六个面中的一个会在顶部。这些是离散样本空间的例子。在深度学习中,大多数样本空间是连续的,它们由浮点数组成,而不是整数或集合的元素。例如,如果神经网络的某个特征输入可以取值于区间 [0, 1],那么 [0, 1] 就是该特征的样本空间。
我们可以询问某些事件发生的可能性。例如,对于一个硬币,我们可以问,硬币翻转后正面朝上的概率是多少?直观地讲,假设硬币没有偏重,也就是说两面出现的可能性相等,我们可以说正面朝上的概率是 50%。那么正面朝上的概率就是 0.5(以百分比表示为 50%)。我们可以看到,反面朝上的概率也是 0.5。最后,由于正面和反面是唯一的可能结果,我们发现所有可能结果的概率和为 0.5 + 0.5 = 1.0。概率总是加起来等于 1.0,覆盖所有可能的样本空间值。
那么,掷一个六面骰子得到四点的概率是多少呢?同样的,掷骰子的每一面没有偏重,只有六个面中的一个面有四个点,所以概率是六分之一,1/6 ≈ 0.166666... 或大约 17%。
随机变量
让我们用一个变量X表示硬币翻转的结果。X 是所谓的随机变量,一个从其样本空间中取值并具有一定概率的变量。因为这里的样本空间是离散的,X 是一个离散随机变量,我们用大写字母表示。对于硬币,X 为正面的概率等于 X 为反面的概率,都是 0.5。为了正式地表达这个,我们使用:
P(X = 正面) = P(X = 反面) = 0.5
其中P是普遍用于表示括号内指定随机变量事件的概率。连续随机变量是指来自连续样本空间的随机变量,通常用小写字母表示,比如x。我们通常讨论的是随机变量位于样本空间某个范围内的概率,而不是一个特定的实数。例如,如果我们使用 NumPy 的random函数返回一个 0, 1)范围内的值,我们可以问:它返回值落在[0, 0.25)范围内的概率是多少?由于任何数字的返回概率和其他数字一样,我们可以说,落在该范围内的概率是 0.25,或者 25%。
人类在概率上很差
我们将在下一节深入探讨概率的数学。但在此之前,让我们先看看两个涉及概率的例子,看看人类在这方面有多糟糕。这两个例子都曾让专家们感到困惑,这并不是因为专家们缺乏能力,而是因为我们对概率的直觉常常是完全错误的,甚至专家们也是彻底的人类。
蒙提·霍尔难题
这个问题是我特别喜欢的一个,因为它甚至让拥有高级学位的数学家感到困惑。这个难题来源于一个美国的老牌游戏节目《让我们来交易》。节目的原主持人蒙提·霍尔会从观众中选出一个人,展示三扇标有 1、2 和 3 的封闭大门。其中一扇门后面藏着一辆新车,另外两扇门后面则是一些搞笑奖品,比如一只活山羊。
参赛者被要求选择一扇门。然后,霍尔会要求打开参赛者没有选择的其中一扇门,通常是那扇没有新车的门。当观众停止笑声后,霍尔会问参赛者是否愿意坚持原先选择的门,还是更愿意将选择换成剩下的那扇门。这个难题其实就是:他们是保留最初的选择,还是换到剩下的门?
如果你想思考一下这个问题,尽管去做。放下书本,走一走,拿出铅笔和纸,做些笔记,然后,当你有了答案(或放弃了),继续往下读……
这里是正确答案:换门。如果你换,你将在 2/3 的情况下赢得汽车。如果你不换,你只有 1/3 的机会赢得汽车,因为最初选择正确的概率就是 1/3:从三个选项中选一个正确的。
当玛丽莲·沃斯·萨凡特在 1990 年于《Parade》杂志专栏中提出这个问题并指出正确的解答是换门时,她收到了大量的来信,很多来自数学家,其中一些愤怒地坚持认为她错了。她并没有错。验证她正确的一种方法是使用计算机程序来模拟这个游戏。我们这里不开发代码,但并不难。如果你编写并运行它,你会看到随着模拟游戏次数的增加,换门时赢的概率趋近于 2/3。然而,我们也可以通过常识和基本的概率理论来理解解决方案。
首先,如果我们不换门,我们知道赢得汽车的概率是 1/3。现在,考虑一下换门时会发生什么。如果我们换门,唯一可能失败的情况是我们最初选择了正确的门。为什么?假设我们最初选择了一个假奖门。霍尔知道车在哪个门后,他永远不会打开有车的门。由于我们已经选择了一个假门,他被迫选择剩下的假门并为我们打开,这样就能确保车在唯一剩下的门后面。如果我们换门,我们就能赢。因为有两个门没有车,所以我们最初选错门的机会是 2/3。然后我们刚刚看到,如果我们最初选择错门并在有机会时换门,我们就会赢得汽车。因此,通过改变我们的猜测,我们有 2/3 的机会赢得汽车。换门失败的 1/3 概率,当然是指我们最初选择了正确的门的情况。
癌症还是非癌症?
这个例子出现在几本关于概率和统计的流行书籍中(例如,乔尔·贝斯特的《更多可恶的谎言与统计数据》(More Damned Lies and Statistics,UC Press,2004 年)和伦纳德·莫洛迪诺的《醉汉的步伐》(The Drunkard’s Walk,Pantheon,2008 年))。它基于一项实际的研究。任务是确定一位 40 多岁的女性在做了阳性乳腺 X 光检查后,她患有乳腺癌的概率。请注意,以下的数字可能在研究进行时是准确的,但现在可能不再有效,请仅将其视为一个示例。
我们被告知以下内容:
-
一位 40 多岁女性患有乳腺癌的概率是 0.8%(每 1,000 人中有 8 人)。
-
一位患有乳腺癌的女性在做乳腺 X 光检查时,出现阳性结果的概率是 90%。
-
一位没有乳腺癌的女性在做乳腺 X 光检查时,出现阳性结果的概率是 7%。
一位女性来到诊所进行筛查,乳腺 X 光结果为阳性。根据我们得到的信息,她实际上患有乳腺癌的概率是多少?
根据上述#1,我们知道如果我们随机选择 1,000 位 40 多岁的女性,其中平均会有 8 位患乳腺癌。因此,在这 8 位女性中,90%(即上述#2)会有阳性乳腺 X 光检查结果。这意味着 7 位患癌症的女性会有阳性乳腺 X 光检查结果,因为 8 × 0.9 = 7.2。剩下的 1,000 位中没有乳腺癌的女性有 992 位。根据上述#3,992 × 0.07 = 69.4,因此 69 位没有乳腺癌的女性也会有阳性乳腺 X 光检查结果,总共有 7 + 69 = 76 个阳性乳腺 X 光检查结果,其中 7 个是实际的癌症病例,69 个是假阳性结果。因此,阳性乳腺 X 光检查结果表示癌症的概率为 7/76,即 0.092——大约 9%。
医生在面对这个问题时给出的中位数估计是癌症的概率大约为 70%,其中超过三分之一的人给出了 90%的估计。对人类而言,概率是难以掌握的,尤其是对于那些有大量训练的人。医生的错误在于没有正确考虑到随机选择一位 40 多岁的女性患乳腺癌的概率。在[第三章中,我们将看到如何使用贝叶斯定理计算这一结果,贝叶斯定理考虑了这个概率。
现在,让我们从直觉转向数学的严谨性。
概率规则
让我们从概率的基本规则开始。这些是我们在接下来的章节中以及更远的章节中需要用到的基础规则。我们将学习事件的概率、概率的和规则,以及条件概率的含义。之后,乘积规则将帮助我们解决生日悖论。在生日悖论中,我们将看到如何计算在一个房间里,至少有两个人共享生日的概率超过 50%的最小人数。答案比你想象的要少。
事件的概率
我们之前提到过,样本空间中所有概率的总和为 1。这意味着样本空间中的任何事件的概率始终小于或等于 1,因为该事件来自样本空间,而样本空间包含所有可能的事件。这意味着,对于任何事件A,

并且,对于样本空间中的所有事件A[i],

其中∑(sigma)表示对右边表达式中的每个i进行求和。可以将右边的表达式视为 Python 中for循环的循环体。
如果我们掷一个六面骰子,我们直观上(并且正确地)理解到得到任意值的概率是相同的:六种可能中的一种,或者 1/6。因此,方程 2.1 告诉我们,P(1),掷出 1 的概率在 0 和 1 之间。这是因为
。此外,方程 2.2 告诉我们,样本空间中所有事件的概率之和必须是 1。这对于六面骰子同样成立,因为
和
。
如果事件发生的概率是P(A),那么事件A 不发生的概率是

其中
表示“不是A”。
被称为A的补集。有时你会看到
写作P(¬A),其中¬是“非”运算符的逻辑符号。
方程 2.3 来自方程 2.1 和方程 2.2,因为事件的概率小于 1,而样本空间中任何事件的发生概率是 1,所以非A事件发生的概率必须是 1 减去事件A发生的概率。
例如,在掷骰子时,得到[1, 6]范围内的任何值的概率是 1,但得到四点的概率是 1/6。所以,不掷出四点的概率就是去掉掷出四点的概率后剩下的概率。

这意味着我们有 83%的概率不会掷出四点。
如果我们掷两个骰子并求和,会怎样呢?样本空间是从 2 到 12 的整数集合。然而,在这种情况下,每个和的出现概率并不相同,这种情况正是赌场游戏“掷骰子”中的核心。例如,我们通过列举所有可能的情况来计算每个和的概率。通过计算事件发生的方式数并除以事件的总数,我们可以确定概率。表 2-1 显示了生成每个和的所有可能方式。
表 2-1: 两个骰子产生不同和的组合数
| 和 | 组合 | 计数 | 概率 |
|---|---|---|---|
| 2 | 1 + 1 | 1 | 0.0278 |
| 3 | 1 + 2, 2 + 1 | 2 | 0.0556 |
| 4 | 1 + 3, 2 + 2, 3 + 1 | 3 | 0.0833 |
| 5 | 1 + 4, 2 + 3, 3 + 2, 4 + 1 | 4 | 0.1111 |
| 6 | 1 + 5, 2 + 4, 3 + 3, 4 + 2, 5 + 1 | 5 | 0.1389 |
| 7 | 1 + 6, 2 + 5, 3 + 4, 4 + 3, 5 + 2, 6 + 1 | 6 | 0.1667 |
| 8 | 2 + 6, 3 + 5, 4 + 4, 5 + 3, 6 + 2 | 5 | 0.1389 |
| 9 | 3 + 6, 4 + 5, 5 + 4, 6 + 3 | 4 | 0.1111 |
| 10 | 4 + 6, 5 + 5, 6 + 4 | 3 | 0.0833 |
| 11 | 5 + 6, 6 + 5 | 2 | 0.0556 |
| 12 | 6 + 6 | 1 | 0.0278 |
| 36 | 1.0000 |
在表 2-1 中,两个骰子的可能组合有 36 种。我们看到,最可能的和是 7,因为有六种组合的点数和为 7。最不可能的是 2 和 12;这两种情况各只有一种可能。如果有六种方式可以得到和为 7,那么得到 7 的概率是“36 中有 6”,即 6/36 ≈ 0.1667。我们将在下一章讨论概率分布和贝叶斯定理时回到表 2-1。表 2-1 展示了一个普遍的规则:如果我们可以列举样本空间,那么就可以计算特定事件的概率。
作为最后一个例子,如果你同时掷三枚硬币,得到零个、一个、两个或三个正面的概率是多少?我们可以列举可能的结果并观察。结果如下:
| 正面 | 组合 | 计数 | 概率 |
|---|---|---|---|
| 0 | TTT | 1 | 0.125 |
| 1 | HTT, THT, TTH | 3 | 0.375 |
| 2 | HHT, HTH, THH | 3 | 0.375 |
| 3 | HHH | 1 | 0.125 |
| 8 | 1.000 |
从这个表格中,我们可以看出,掷三枚硬币时,得到一个或两个正面的概率是相同的:37.5%。我们可以通过一些代码来验证这一点:
import numpy as np
N = 1000000
M = 3
heads = np.zeros(M+1)
for i in range(N):
flips = np.random.randint(0,2,M)
h, _ = np.bincount(flips, minlength=2)
heads[h] += 1
prob = heads / N
print("Probabilities: %s" % np.array2string(prob))
代码运行了 1,000,000 次测试(N),模拟了三枚硬币的投掷(M)。每次测试中,0、1、2 或 3 个正面的出现次数存储在heads中。每次测试选择三个值[0, 1](flips),并计算出现多少个正面(即零)。我们使用np.bincount来实现这一点,并丢弃反面的数量。然后统计正面的数量,并进行下一组投掷。
当所有N次模拟完成后,我们通过除以运行的模拟次数(prob)将正面的数量转化为概率。最后,我们打印出对应的概率。对于零个、一个、两个或三个正面,一次运行的结果如下:
Probabilities: [0.125236, 0.3751, 0.37505, 0.124614]
这些结果与我们之前计算的概率非常接近,因此我们有信心认为我们的计算是正确的。
求和规则
我们先从定义开始:如果两个事件A和B不能同时发生,意思是要么A发生,要么B发生,我们称这两个事件是互斥的。例如,抛硬币时,要么是正面,要么是反面;它不可能同时是正面和反面。互斥事件意味着如果事件A发生了,那么事件B就被排除,反之亦然。此外,如果两个事件发生的概率完全不相关,也就是说事件A的概率不受事件B是否发生的影响,我们称这两个事件是独立的。
求和法则关注的是多个互斥事件发生的概率。它告诉我们其中一个事件发生的概率。例如,掷标准骰子得到四或五的概率是多少?我们知道掷出四的概率是 1/6,掷出五的概率也是 1/6。由于这些事件是互斥的,我们可以直观地理解得到四 或 五的概率是它们的和,因为四和五作为结果都是样本空间的一部分,而且要么四发生,要么五发生,或者两者都不发生。所以,我们得到以下结论:

这里 ∪ 代表“或”或“并集”。你会经常看到 ∪。对于标准骰子,掷出四或五的概率是
,大约是 33%。
两次投掷硬币的样本空间是 {HH, HT, TH, TT};因此,得到两个正面或两个反面的概率是:

求和法则还有更多内容,但在我们能看到之前,需要先考虑乘法法则。
乘法法则
求和法则告诉我们事件 A 或 B 发生的概率。乘法法则则告诉我们事件 A 和 B 发生的概率:

这里 ∩ 代表“且”或“交集”。
如果事件 A 和 B 是互斥的,我们会立即看到 P(A ∩ B) = 0,因为如果事件 A 发生的概率是 P(A),那么事件 B 的概率是 P(B) = 0,它们的乘积也为零。如果事件 B 发生,那么 P(A) = 0,情况也是如此。
当然,并不是所有事件都是互斥的。例如,假设全球有 80% 的人有棕色眼睛,50% 的人是女性。那么,随机选取一个人是棕色眼睛的女性的概率是多少?我们可以使用乘法法则,
P(女性, 棕色眼睛) = P(女性)P(棕色眼睛) = 0.5(0.8) = 0.4
可以看到,随机选取一个人是棕色眼睛的女性的概率是 40%。
如果我们稍微思考一下,乘法法则就能理解了。计算女性这一事件的概率不会改变这些女性中拥有棕色眼睛的比例。一个事件,女性,并不影响另一个事件,棕色眼睛。
乘法法则不仅限于两个事件。考虑以下情况。根据保险公司数据,在美国,任何一年内被雷击的概率大约是 1/1,222,000,或者 0.000082%。那么,假设你住在美国,成为棕色眼睛女性并在任何一年内被雷击的概率是多少?我们可以再次使用乘法法则:

美国的人口大约是 3.31 亿,其中 0.000033%是今年会被闪电击中的棕眼女性:根据我们上面的计算是 109 人。根据美国国家气象局的统计,每年大约有 270 人会被闪电击中。正如我们上面所看到的,其中 40%的人是棕眼女性,这样计算得出 270(0.4) = 108。所以,我们的计算完全可信。
求和规则回顾
我们上面提到过求和规则还有更多内容。现在让我们看看上面我们遗漏了什么。公式 2.4 给出了互斥事件A和B的求和规则。如果事件不是互斥的呢?在这种情况下,求和规则需要进行修改:

让我们看一个例子。
一位考古学家发现了一小批 20 枚古币。他注意到其中 12 枚是罗马币,8 枚是希腊币。他还注意到 6 枚罗马币和 3 枚希腊币是银币。其余的都是青铜币。那么,从这批硬币中选出一枚银币或罗马币的概率是多少?
如果我们认为银币和罗马币是互斥的,我们可能会倾向于说出以下结论:

然而,两个概率的和是
,而我们不能有大于 1 的概率。显然有什么不对劲。
问题在于,硬币堆中有一些是银制的罗马币。我们已经将它们计算了两次——一次在P(银币)中,再次在P(罗马币)中——因此现在我们需要从总和中减去它们。共有 6 枚银制罗马币。所以,成为银制罗马币的概率是P(银币且罗马币)
。减去这部分后,我们可以看到选出银币或罗马币的概率是 75%:

和求和规则一样,乘法规则还有更多内容,我们稍后会讲解。但是首先,让我们用乘法规则看看能否解决生日悖论。
生日悖论
平均而言,我们需要将多少人聚集在一个房间里,才能让其中两人有超过 50%的概率共享同一天生日?这个问题被称为生日悖论。让我们看看能否利用概率的乘法规则来求解这个问题。
我们忽略闰年,并假设一年有 365 天。直观上,我们可以看到,随机选择的人共享同一天生日的概率是 1 天(共享生日)/365 个可能的生日。因此,样本空间是 365 天,而共享的生日是其中的 1 天。所以,我们得到如下结果:

他们要么共享生日,要么不共享生日:1 – 1/365 = 365/365 – 1/365 = 364/365。因此,我们得到如下结果:

在一年中的 365 天里,有一天可能是匹配的,剩下的 364 天则不匹配。
随机选择的人共享生日的概率为 0.3%,这相当低。意味着如果你随机选择人对并询问他们是否有相同的生日,平均每千对会有三对是匹配的——这并不太可能发生。
对于我们的计算,我们将反过来看待这个问题。我们需要找出多少人集合在一起,才能使得两个人没有共享生日的概率低于 50%。
我们知道两个人随机选择没有共享生日的概率:
。因此,如果我们随机选择两对人,两对人都没有共享生日的概率如下:

在这里,我们使用乘法法则。同样地,对于三个人,(A, B, C),我们可以形成三对不同的组合,(A, B),(A, C) 和 (B, C),所以我们可以计算如下:

对于 n 次比较,这里是没有人共享生日的概率:

我们的任务是找到最小的比较次数,n,使得没有共享生日的概率小于 50%,其中 n 是房间里人数 m 的函数。为什么小于 50%?因为如果我们找到一个 n,使得没有共享生日的概率小于 50%,那么共享生日的概率必定大于 50%。
如果你随机挑选三个人,就有三对人需要检查是否有共同的生日。如果有四个人,就有六对。也就是说,人数越多,组合对数越多。我们能否找到一个规则,将人数 m 映射到需要比较的对数 n 上呢?如果找到了,我们就可以找到最小的 m,使得在该人数下的 n,公式 2.7 的概率小于 50%。
当我们有 m 个独特的物体(例如房间里的人员),并且我们选择它们的组合时,我们可以选择多少种不同的组合?换句话说,m 个物体两两组合有多少种可能?计算 m 个物体中选择 k 个的组合数的公式是:

你有时会听到这个叫做“m 选 k”,对于我们来说,k = 2。让我们找到所需的比较次数 n,并使用两两组合的公式来找出一个 m,使得我们至少有 n 次比较。
一个简单的 Python 循环可以找到我们需要的 n:
for n in range(300):
if ((364/365)**n < 0.5):
print(n)
break
我们知道 n = 253。所以,我们需要平均进行 253 次比较,即 253 对人,才能有超过 50%的机会让这些人中某一对共享生日。最后一步是找出 m 个人数中,至少有 253 种两两组合的数量。通过一些暴力试验和错误,我们得到了以下结果:

我们平均需要 m = 23 人,才能有超过 50%的概率至少有两个人的生日相同。这一切都要归功于乘法规则。
我们的结果可靠吗,还是只是手法巧妙?有些代码可以告诉我们。首先,让我们通过模拟验证随机挑选到两个生日相同的人概率为 0.3%:
match = 0
for i in range(100000):
a = np.random.randint(0,364)
b = np.random.randint(0,364)
if (a == b):
match += 1
print("Probability of a random match = %0.6f" % (match/100000,))
这段代码模拟了 100,000 对随机人物,其中[0, 364]之间的随机整数代表某个人的生日。如果两个人的生日相同,则match会增加。所有模拟完成后,我们会打印出概率。这段代码的运行结果如下,这让我们对 0.3%的概率的断言变得可信:
Probability of a random match = 0.003100
那么,至少要有多少人才能使共享生日的概率大于 50%呢?这里我们有两个循环。第一个是遍历房间内的人数(m),第二个是遍历该人数下的模拟次数(n)。在代码中,它看起来是这样的:
for m in range(2,31):
matches = 0
for n in range(100000):
match = 0
b = np.random.randint(0,364,m)
for i in range(m):
for j in range(m):
if (i != j) and (b[i] == b[j]):
match += 1
if (match != 0):
matches += 1
print("%2d %0.6f" % (m, matches/100000))
我们让m的值从 2 到 30 人变化。对于每一组m人,我们运行 100,000 次模拟。每次模拟中,我们为每个人(b)挑选一个生日,然后将每个人与其他所有人进行比较,看看是否有生日相同。如果有相同的生日,则match增加。如果至少有一次匹配,我们就增加matches并进入下一个模拟。最后,当当前人数的所有模拟完成时,我们会打印出至少有一次匹配的概率。
如果我们运行代码并绘制输出,我们得到图 2-1,其中虚线表示 50%的概率。第一个超出虚线的点是 23 人,正是我们之前计算的结果。

图 2-1:共享生日的概率与房间人数的关系
看到模拟结果与数学结果一致总是令人满意的。
条件概率
假设有一个装有 10 颗弹珠的袋子:8 颗红色和 2 颗蓝色。我们知道,从袋子里随机挑选一颗弹珠,挑中蓝色弹珠的概率是 2/10,或者 20%。假设我们挑选到了蓝色弹珠。在欣赏它漂亮的蓝色后,我们将其放回袋中,摇动袋子,再抽出一颗弹珠。我们第二次挑选到蓝色弹珠的概率是多少?再次,因为袋中仍有 2 颗蓝色弹珠,总共有 10 颗弹珠,所以概率仍然是 20%。
如果事件 A 发生(这里是挑选了一颗蓝色弹珠并将其放回袋中)没有影响未来事件 B 的概率,那么这两个事件是独立的。我们第二次挑选到蓝色弹珠的机会不会受到之前挑选到蓝色弹珠的影响。同样的道理也适用于投掷硬币。我们连续四次正面朝上的事实与下一次投掷得到反面的概率无关,前提是假设硬币是公平的,也就是说它没有偏向某一面,或者是双正面(或双反面)。
现在,考虑一个替代场景。我们仍然有一个装有八颗红色弹珠和两颗蓝色弹珠的袋子。我们拿出一颗弹珠——假设这次是红色的——因为我们喜欢这种颜色,所以我们保留这颗弹珠并将其放在一边。现在,我们从袋子里拿出另一颗弹珠。再拿到一颗红色弹珠的概率是多少?在这里,情况发生了变化。现在有 9 颗弹珠,其中 7 颗是红色的。所以,拿到第二颗红色弹珠的概率现在是 9 分之 7,即 78%。最初拿到红色弹珠的概率是 10 分之 8,即 80%。事件A的发生,即我们拿到了并保留了一颗红色弹珠,改变了第二个事件的概率。这两个事件不再是独立的。第二个事件的概率被第一个事件的发生所改变。从符号上来说,我们写作P**(B|A),意味着在事件A发生的条件下事件B发生的概率。这是一个条件概率,因为它是基于事件A发生的条件。
这里是我们更新乘法法则的地方。方程 2.5 中的版本假设两个事件是独立的,比如性别是女性和眼睛是棕色的。如果我们遇到依赖关系的情况,规则变为:

这意味着两个事件同时发生的概率是其中一个事件在另一个事件发生的条件下发生的概率与另一个事件发生的概率的乘积。
回顾我们上面的弹珠例子,我们计算了在已经拿走并保留了一颗红色弹珠之后,再拿到一颗红色弹珠的概率为 9 分之 7,约为 78%。那就是P**(B|A)。对于P(A),我们需要计算最初拿到红色弹珠的概率,我们说这是 80%。因此,拿到一颗红色弹珠并保留它(A)以及第二次抽到一颗红色弹珠(B)的概率是 62%:

如果两个事件是互斥的,P**(B|A) = P(A|B) = 0。如果事件A和B是独立的,那么P(A|B) = P(A),P(B|A) = P(B),因为条件事件是否发生对后续事件没有影响。
最后,请注意,通常P**(B|A) ≠ P(A|B),混淆这两个条件概率是一个常见且经常是严重的错误。正如我们将在第三章中看到的,贝叶斯定理给出了条件概率之间的正确关系。当我们讨论概率的链式法则时,我们将再次遇到条件概率。
总概率
如果我们的样本空间被划分为不相交的区域,B[i](B[1]、B[2]等),使得样本空间的总和由所有的B[i]组成,并且这些B[i]之间没有重叠,我们可以按如下方式计算事件在所有划分上的概率:

这里的P**(A|B[i])是条件概率,表示在B[i]条件下发生A的概率,而P(B[i])是B[i]的概率,它表示样本空间中B[i]所代表的部分。在这种情况下,P**(A)是A在所有B[i]分区下的总概率。让我们来看一个如何使用这个法则的例子。
你有三座城市,Kish、Kesh 和 Kuara,分别拥有 2000 人、1000 人和 3000 人。此外,这三座城市中拥有蓝眼睛的比例分别是 12%、3%和 21%。我们想知道从这些城市中随机选择一位居民,该居民拥有蓝眼睛的概率是多少。城市的人口影响着这个概率,因为不同城市的蓝眼睛概率不同,而各城市的人口也不相同。为了求得P(blue),我们需要使用总概率法则:

这里的P(blue|Kish)表示在你住在 Kish 的前提下,拥有蓝眼睛的概率,而P(Kish)是住在 Kish 的概率,依此类推。
我们知道了所需的量来计算总概率。每个城市的蓝眼睛概率如上所示,而每个城市的居民比例则由该城市人口与三座城市总人口之比得出:

因此,P(blue)是

这意味着随机选取的三座城市中的一位居民有 15%的机会拥有蓝眼睛。请注意,选择这些城市的概率总和为:P(Kish) + P(Kesh) + P(Kuara) = 1. 这是因为对总样本空间(所有城市居民)进行划分时,所有城市的划分必须覆盖整个样本空间。
联合概率与边缘概率
两个变量的联合概率,P**(X = x, Y = y),是指随机变量X在Y为y的情况下,X为x的概率。我们之前已经看到过一个联合概率的例子。当我们在计算概率时使用“和”时,实际上我们就是在计算联合概率。联合概率是多个条件同时成立的概率,即“和”的概率。边缘概率是指在不考虑其他条件的情况下,计算其中一个或多个条件的概率;换句话说,就是在“和”的条件下计算某一子集的概率。
在本节中,我们将通过简单的表格来研究联合概率和边缘概率。然后,我们将介绍概率的链式法则。这个法则让我们可以将联合概率分解成更小的联合概率和条件概率的乘积。
联合概率表
根据色盲意识组织(www.colourblindawareness.org/)的资料,大约每 12 个男性中就有 1 人是色盲,而每 200 个女性中就有 1 人是色盲。之所以会有差异,是因为导致色盲的基因位于 X 染色体上,女性需要同时从母亲和父亲继承该隐性基因,而男性只需从一个父母那里继承该基因。
假设我们对 1,000 人进行调查。我们可以计算出男性且色盲、女性且色盲、男性且非色盲、女性且非色盲的人数。我们完成这个统计并将数据安排成如下表格:
| 色盲 | 非色盲 | |
|---|---|---|
| 男性 | 42 | 456 |
| 女性 | 3 | 499 |
| 45 | 955 |
这种表格被称为列联表。统计数据位于表格中间的 2 × 2 数值区域。最右边一列是每行的总和,最后一行是每列的总和。最后一行或最后一列的总和位于最后一个单元格,并且必然总和为我们调查的 1,000 人。
我们可以通过将每个单元格除以 1,000(即调查的总人数)来将列联表转化为概率表。这样得到的结果如下:
| 色盲 | 非色盲 | |
|---|---|---|
| 男性 | 0.042 | 0.456 |
| 女性 | 0.003 | 0.499 |
| 0.045 | 0.955 |
现在,这个表格是一个联合概率表。通过它,我们可以查找男性和色盲的概率。符号表示为
P(性别 = 男性,色盲 = 是) = 0.042
同样地,我们可以看到
P(性别 = 女性,色盲 = 否) = 0.499
使用联合概率表,我们可以预测在对人群进行随机抽样时可能观察到的结果。例如,如果我们有一个 20,000 人的样本,那么根据我们的表格,我们预计会发现大约 20000(0.042) = 840 个色盲男性,以及大约 20000(0.003) = 60 个色盲女性。
如果我们想知道一个人是色盲的概率,而不考虑性别怎么办?为此,我们沿着色盲那一列求和,结果显示随机选择一个人是色盲的概率为 4.5%。同样,沿着行求和会给我们估计的女性概率为 50.2%。我们需要牢记的是,我们的表格是基于仅有 1,000 人的样本。如果我们改为对 100,000 人进行抽样,男性和女性的比例将更接近 50/50,而你猜得没错。
从联合概率表中计算色盲或女性的概率实际上是在计算边际概率。在第一个情况下,我们是沿着列求和,以消除性别的影响;而在第二个情况下,我们是沿着行求和,以消除色盲的影响。
从数学上讲,我们通过对不需要的变量进行求和来获得边际概率。如果我们有一个两个变量的联合概率表格,像上面的例子,我们就通过求和得到边际概率:

使用上面的表格,我们可以写出:

其中,我们对性别进行求和,以消除它的影响。现在,让我们探索另一个表格,包含三个变量的表格。
1912 年 4 月 14 日的某个时刻,RMS 泰坦尼克号在从英国前往纽约市的首航途中沉没于北大西洋。根据对 887 名在泰坦尼克号上的乘客样本,我们可以生成表 2-2,显示三个变量的联合概率:生存、性别和舱位等级。
表 2-2: 泰坦尼克号乘客的联合概率表
| 舱位 1 | 舱位 2 | 舱位 3 | ||
|---|---|---|---|---|
| 死亡 | 男性 | 0.087 | 0.103 | 0.334 |
| 女性 | 0.003 | 0.007 | 0.081 | |
| 生还 | 男性 | 0.051 | 0.019 | 0.053 |
| 女性 | 0.103 | 0.079 | 0.081 |
我们使用表 2-2 来计算一些概率。注意,我们将使用表 2-2 中的值,这些值精确到小数点后三位。因此,整体数字与我们从计数中计算出的概率会略有偏差,但这样做能够使表格和方程之间的联系更加明确。
首先,我们可以直接从表格中读取特定的生还、性别和舱位等级的三元组。例如:
P(死亡,男性,舱位 3) = 0.334
这意味着,随机选中的乘客是第三舱的男性且未生还的概率为 33%。那么第一舱的男性呢?这个在表中也有:
P(死亡,男性,舱位 1) = 0.087
这意味着选中的乘客有 9%的机会是第一舱的男性且已死亡。我们可以看到,舱位和社会阶层的差异确实影响了生还率。
让我们使用表格来计算一些其他的联合概率和边际概率。首先,未生还的概率是多少?要找到它,我们需要对性别和舱位进行求和:

在这里,我们为男性/女性(M/F)和舱位等级(1, 2, 3)引入了简写符号。
让我们计算一下给定乘客为男性时未生还的概率,P(死亡|M)。为此,我们回顾方程 2.8,记住“与”意味着联合概率。我们重新写一下方程 2.8,以便解出 P(B|A):

这有时用于首先定义条件概率。请注意,P**(A, B)表示 P(A 和 B)—二者都是联合概率。使用这种形式,给定乘客为男性时未生还的概率是:

其中P(死亡, M)是死亡且为男性的联合概率,P(M)是为男性的概率。
在思考这些概率时我们需要小心。P(死亡, M)并不是指乘客为男性时未存活的概率。相反,它是指随机选择的乘客是未存活的男性的概率。我们想要的是P(死亡|M),即在乘客是男性的条件下未存活的概率。
为了得到P(死亡, M),我们需要对舱位类别求和:

为了得到P(M),我们对存活与舱位类别求和:

最后,为了计算P(死亡|M):

这告诉我们,81%的男性乘客未能存活。
下面的类似计算告诉我们女性并且存活的概率:

我们看到女性比男性更可能存活。这是“女性和儿童优先”这一说法实际上成立的一个例子。我留给你做练习来计算P(存活|F)的个别概率。
我们已经计算了P(死亡, M),即未存活的男性的概率;P(M),即为男性的概率;以及P(死亡|M),即在为男性的条件下未存活的概率。接下来,我们根据表格 2-2 做一次新的计算。让我们找出P(死亡或 M),即未存活或为男性的概率。
公式 2.6 告诉我们这是概率:

如果我们查看公式 2.9 和公式 2.11,我们会发现这两个公式具有相同的项,这些项正是公式 2.10 中求和的项。这就是为什么我们需要从P(死亡或 M)的计算中减去P(死亡, M),以避免重复计算。
总结一下:
-
联合概率是指两个或更多随机变量具有特定取值的概率。联合概率通常用表格表示。
-
随机变量的边际概率是通过对其他随机变量的所有可能值求和得到的。
使用条件概率的乘法法则告诉我们如何计算给定条件概率和无条件概率时,两个随机变量的联合概率。现在让我们看看如何使用概率链式法则来推广这个概念。
概率链式法则
公式 2.8 告诉我们如何通过条件概率来计算两个随机变量的联合概率。通过使用概率链式法则,我们可以扩展公式 2.8,并计算超过两个随机变量的联合概率。
在其通用形式中,n个随机变量的联合概率的链式法则如下:

这里使用⋂表示“和”用于联合概率。方程 2.12 看起来很复杂,但跟着几个例子走,你会发现并不难理解。我需要在条件概率的联合部分使用⋂,但在例子中,我会用逗号,你很快就会看出规律。
下面是链式法则如何分解包含三个随机变量的联合概率:

第一行表示X、Y和Z的概率是X在给定Y和Z情况下的条件概率与Y和Z的概率的乘积。这是方程 2.8,其中X为B,Y和Z为A。第二行对P(Y, Z)应用链式法则得到P(Y|Z)P(Z)。这个规则可以按顺序应用,就像链条一样,因此得名。
那么,四个随机变量的联合概率是什么?我们得到如下:

让我们通过一个使用链式法则的例子来演示。假设我们非常社交,派对上有 50 个人。50 人中有四个在秋天去过波士顿。我们随机挑选三个人。那么,没有一个人去过波士顿的概率是多少?
我们将用A[i]表示一个没有在秋天去过波士顿的人的事件。因此,我们要找的是P(A[3], A[2], A[1]),即三个都没有去过波士顿的人的概率。链式法则让我们可以将这个概率分解如下:

我们可以直观地理解这个方程的右侧。看一下P(A[1])。这是从房间里随机挑选一个没有在秋天去过波士顿的人的概率。四个人去过,因此有 46 个人没有去过,我们看到P(A[1]) = 46/50。选定一个人后,我们需要知道从剩下的 49 个人中选一个人的概率,即P(A[2]|A[1]) = 45/49。剩下的只有 49 个人,而且我们还没有选到四个去过波士顿的人。最后,选了两个人后,房间里剩下 48 个人,其中 44 个没有去过波士顿。因此,P(A[3]|A[2], A[1]) = 44/48。
现在我们准备好回答最初的问题了。从房间里随机选三个人且没有一个人去过秋天的波士顿的概率如下:

这稍微超过了 77%。
我们可以通过模拟多次随机抽取三个人来检查我们的计算是否合理。我们需要的代码如下:
nb = 0
N = 100000
for i in range(N):
s = np.random.randint(0,50,3)
fail = False
for t in range(3):
if (s[t] < 4):
fail = True
if (not fail):
nb += 1
print("No Boston in the fall = %0.4f" % (nb/N,))
我们将进行 100,000 次模拟。每次从 50 个没有在秋季去过波士顿的人中选择三个人时,我们将增加 nb。我们通过选择三个范围在 [0, 50) 内的随机整数,并将其放入 s 中来模拟选择三个人。然后,我们检查这三个整数中的每一个,看看是否有小于四的。如果有,我们就说选择了一个去过波士顿的人,并将 fail 设置为 True。如果三个整数都不小于四,那么这次模拟就是成功的。完成后,我们打印出在所有模拟中,选择了三位从未在秋季去过波士顿的人的比例。
运行这段代码得出了
No Boston in the fall = 0.7780
这个结果足够接近我们计算的值,令我们相信我们找到了正确的答案。
总结
本章介绍了概率的基础知识。我们探讨了概率的基本概念,包括样本空间和随机变量。接着,通过一些例子说明了人类在概率方面的不足。然后,我们学习了概率的规则,并通过示例进行说明。这些规则引导我们进入了联合概率和边际概率,最终探讨了概率的链式法则。
下一章将继续探索概率,首先介绍概率分布及其采样方法,最后讲解贝叶斯定理,它展示了比较条件概率的正确方法。
第三章:更多概率**

第二章为我们介绍了概率的基本概念。在本章中,我们将继续探讨概率,重点关注深度学习和机器学习中常遇到的两个重要话题:概率分布及其如何采样,贝叶斯定理。贝叶斯定理是概率论中最重要的概念之一,它在许多研究人员思考概率和如何应用它的方式上引发了范式转变。
概率分布
概率分布可以看作是一个函数,根据需求生成值。生成的值是随机的——我们不知道哪个值会出现——但任何值出现的概率遵循一般形式。例如,如果我们多次掷标准骰子并统计每个数字出现的次数,我们期望从长远来看,每个数字出现的可能性相等。实际上,这正是设计骰子的目的。因此,骰子的概率分布被称为均匀分布,因为每个数字出现的概率相等。我们还可以想象其他分布,其中某些值或值的范围比其他值更容易出现,比如一个加权骰子,它可能会异常频繁地显示六点。
深度学习从概率分布中采样的主要原因是在训练之前初始化网络。现代网络从不同的分布中选择初始权重,有时还包括偏置,最常见的是均匀分布和正态分布。均匀分布对我们来说比较熟悉,接下来我会讨论正态分布,它是一种连续分布。
在本节中,我将展示几种不同类型的概率分布。我们的重点是理解分布的形状,并学习如何使用 NumPy 从中抽样。我将从直方图开始,向你展示我们通常可以将直方图视为概率分布的近似。接着我将讨论常见的离散概率分布,这些分布返回整数值,如 3 或 7。最后,我将转向连续分布,返回浮动点数值,如 3.8 或 7.592。
直方图与概率
请查看 表 3-1,我们在 第二章 中见过它。
表 3-1: 两个骰子不同和的组合数(摘自 表 2-1)
| 和 | 组合 | 计数 | 概率 |
|---|---|---|---|
| 2 | 1 + 1 | 1 | 0.0278 |
| 3 | 1 + 2, 2 + 1 | 2 | 0.0556 |
| 4 | 1 + 3, 2 + 2, 3 + 1 | 3 | 0.0833 |
| 5 | 1 + 4, 2 + 3, 3 + 2, 4 + 1 | 4 | 0.1111 |
| 6 | 1 + 5, 2 + 4, 3 + 3, 4 + 2, 5 + 1 | 5 | 0.1389 |
| 7 | 1 + 6, 2 + 5, 3 + 4, 4 + 3, 5 + 2, 6 + 1 | 6 | 0.1667 |
| 8 | 2 + 6, 3 + 5, 4 + 4, 5 + 3, 6 + 2 | 5 | 0.1389 |
| 9 | 3 + 6, 4 + 5, 5 + 4, 6 + 3 | 4 | 0.1111 |
| 10 | 4 + 6, 5 + 5, 6 + 4 | 3 | 0.0833 |
| 11 | 5 + 6, 6 + 5 | 2 | 0.0556 |
| 12 | 6 + 6 | 1 | 0.0278 |
| 36 | 1.0000 |
它展示了两个骰子如何加和得到不同的和。不要看实际的数值,看看可能的组合所形成的形状。如果我们去掉最后两列,向左旋转表格,并用“X”替换每个和,我们应该能看到类似下面的内容。
| × | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| × | × | × | ||||||||
| × | × | × | × | × | ||||||
| × | × | × | × | × | × | × | ||||
| × | × | × | × | × | × | × | × | × | ||
| × | × | × | × | × | × | × | × | × | × | × |
| 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
你可以看到,达到每个和的方式数目有一个明确的形状和对称性。这种图表称为直方图。直方图是一种统计落入离散区间的事物数量的图表。对于表 3-1,这些区间是 2 到 12 的数字。统计的是获得该和的可能方式。直方图通常用条形图表示,通常是垂直条形,尽管它们不必是垂直的。表 3-1 基本上是一个水平直方图。使用多少个区间由制作人决定。如果使用太少的区间,直方图将会显得块状,可能无法揭示必要的细节,因为有趣的特征都被归类到同一个区间。如果使用太多区间,直方图将变得稀疏,很多区间没有统计数据。
让我们生成一些直方图。首先,我们将随机抽取[0,9]区间的整数,并统计每个整数出现的次数。实现这一点的代码非常简单:
>>> import numpy as np
>>> n = np.random.randint(0,10,10000)
>>> h = np.bincount(n)
>>> h
array([ 975, 987, 987, 1017, 981, 1043, 1031, 988, 1007, 984])
我们首先将n设置为一个包含 10,000 个[0, 9]区间整数的数组。然后,我们使用np .bincount来统计每个数字出现的次数。我们发现这次运行得到了 975 个零和 984 个九。如果 NumPy 的伪随机生成器工作正常,我们期望在 10,000 个数字的样本中,每个数字平均出现 1,000 次。我们预期会有一定的波动,但大多数值应该接近 1,000,足够具有说服力。
上面的计数告诉我们每个数字出现的次数。如果我们将每个直方图的区间除以所有区间的总和,我们就从简单的计数转换为该区间出现的概率。对于上述随机数字,我们可以得到这些概率。
>>> h = h / h.sum()
>>> h
array([0.0975, 0.0987, 0.0987, 0.1017, 0.0981, 0.1043, 0.1031, 0.0988,
0.1007, 0.0984])
这告诉我们每个数字大约有 0.1 的概率出现,也就是每 10 次中有 1 次出现。将直方图的值除以直方图中计数的总和的这个技巧,使我们能够通过样本估计概率分布。它还告诉我们,当从生成直方图数据的任何过程中进行采样时,特定值出现的可能性。你应该注意,我说的是我们可以估计从一组样本中得到的概率分布。样本数量越大,估计的概率分布越接近实际生成这些样本的总体分布。我们永远无法得到实际的总体分布,但在样本数量无限的限制下,我们可以尽可能接近。
直方图常用于观察图像中像素值的分布。让我们绘制两张图像中像素的直方图。你可以在文件ricky.py中找到代码。(我不会在这里展示它,因为它对讨论没有帮助。)所使用的图像是 SciPy 中 scipy.misc 提供的两个示例灰度图像。第一张图展示了人们正在上楼梯(ascent),第二张是年轻浣熊的脸(face),如 图 3-1 所示。

图 3-1:攀爬中的人(左)和“瑞奇”浣熊(右)
图 3-2 提供了每个图像的直方图作为概率的图示。它显示了图像中灰度值的两种截然不同的分布。对于浣熊的面部,分布更加分散和平坦,而上楼梯的图像则在灰度值 128 附近有一个峰值,并且有几个亮像素。这些分布告诉我们,如果我们从面部图像中随机选择一个像素,我们最有可能得到一个灰度值接近 100 的像素,但在上楼梯的图像中,任意选择一个像素,它的灰度值很可能接近 128。

图 3-2:两个 512×512 像素灰度样本图像的直方图作为概率分布
再次强调,直方图统计了有多少项落入预定义的区间。我们看到,对于图像,作为概率分布的直方图告诉我们,如果我们随机选择一个像素,得到特定灰度值的可能性有多大。同样,前面那个例子中的随机数字的概率分布告诉我们,当我们请求一个随机整数时,得到每个数字的概率,范围是 [0,9]。
直方图是概率分布的离散表示。现在让我们来看看更常见的离散分布。
离散概率分布
我们已经多次遇到最常见的离散分布:均匀分布。那就是我们通过掷骰子或抛硬币自然得到的分布。在均匀分布中,所有可能的结果发生的概率是相等的。一个模拟均匀分布过程的直方图是平的;所有结果出现的频率几乎相同。当我们看连续分布时,我们会再次看到均匀分布。目前,想象骰子。
让我们看看其他几个离散分布。
二项分布
也许第二常见的离散分布是二项分布。这个分布表示在给定的试验次数中,如果每次事件发生的概率是已知的,则预期发生的事件次数。数学上,k次事件在n次试验中发生的概率,如果事件发生的概率是p,可以表示为

例如,抛三次公正的硬币,连续得到三次正面的概率是多少?根据乘法法则,我们知道概率是

使用二项式公式,我们可以通过计算得到相同的结果

到目前为止,似乎并没有什么特别有用的。然而,如果事件的概率不是 0.5 呢?假设我们有一个事件,比如一个人通过不换门赢得让我们来做交易的概率,我们想知道在 13 个人中,有 7 个人不换猜测而获胜的概率是多少?我们知道不换门获胜的概率是 1/3——这就是p。然后我们有 13 次试验(n)和 7 个获胜者(k)。二项式公式告诉我们,概率是

而且,如果玩家确实换门,

二项式公式给出了在指定的事件概率下,在给定次数的试验中发生一定数量事件的概率。如果我们固定n和p,并改变k,0 ≤ k ≤ n,我们可以得到每个k值的概率。这就给出了分布。例如,设n = 5,p = 0.3,那么 0 ≤ k ≤ 5,对于每个k值的概率为

考虑到四舍五入,这个和为 1.0,正如我们所知道的,因为整个样本空间的概率和总是等于 1.0。注意,当n = 5 时,我们计算了所有可能的二项分布值。总的来说,这就指定了概率质量函数 (pmf)。概率质量函数告诉我们所有可能结果的概率。
二项分布由n和p来参数化。当n = 5 且p = 0.3 时,我们从上面的结果可以看到,来自这种二项分布的随机样本最常返回 1——大约 36%的时间。我们如何从二项分布中抽取样本?在 NumPy 中,我们只需要调用random模块中的binomial函数:
>>> t = np.random.binomial(5, 0.3, size=1000)
>>> s = np.bincount(t)
>>> s
array([159, 368, 299, 155, 17, 2])
>>> s / s.sum()
array([0.159, 0.368, 0.299, 0.155, 0.017, 0.002])
我们传递binomial参数为试验次数(5)和每次试验成功的概率(0.3)。然后,我们请求从具有这些参数的二项分布中获取 1,000 个样本。通过使用np.bincount,我们看到最常返回的值确实是 1,正如我们上面计算的那样。通过使用直方图求和技巧,我们得到选择 1 的概率为 0.368——接近我们计算的 0.3601。
伯努利分布
伯努利分布是二项分布的一个特例。在这种情况下,我们将n = 1,这意味着只有一次试验。我们只能抽取值为 0 或 1 的结果;要么事件发生,要么事件不发生。例如,当p = 0.5 时,我们得到
>>> t = np.random.binomial(1, 0.5, size=1000)
>>> np.bincount(t)
array([496, 504])
这是合理的,因为 0.5 的概率意味着我们在抛一个公平的硬币,我们看到正反面出现的比例大致相等。
如果我们将p改为 0.3,我们得到
>>> t = np.random.binomial(1, 0.3, size=1000)
>>> np.bincount(t)
array([665, 335])
>>> 335/1000
0.335
再次接近 0.3,正如我们预期的那样。
当你想模拟具有已知概率的事件时,使用来自二项分布的样本。通过伯努利形式,我们可以抽取二元结果,0 或 1,其中事件发生的可能性不必是公平抛硬币的概率 0.5。
泊松分布
有时候,我们并不知道某个事件在特定试验中发生的概率。相反,我们可能知道某个时间间隔内事件发生的平均次数,假设为λ(lambda)。那么在这个时间间隔内,发生k个事件的概率是

这是泊松分布,它在模拟事件时非常有用,比如放射性衰变或某段时间内 X 射线探测器上光子的发生率。为了根据该分布抽样事件,我们使用poisson,它来自random模块。例如,假设某个时间间隔内,事件的平均发生次数为五次(λ = 5)。使用泊松分布,我们得到什么样的概率分布呢?在代码中,
>>> t = np.random.poisson(5, size=1000)
>>> s = np.bincount(t)
>>> s
array([ 6, 36, 83, 135, 179, 173, 156, 107, 58, 40, 20, 4, 2,
0, 0, 1])
>>> t.max()
15
>>> s = s / s.sum()
>>> s
array([0.006, 0.036, 0.083, 0.135, 0.179, 0.173, 0.156, 0.107, 0.058,
0.04 , 0.02 , 0.004, 0.002, 0. , 0. , 0.001])
在这里,我们看到,与二项分布不同,二项分布不能选择超过n个事件,而泊松分布可以选择超过λ值的事件数量。在这种情况下,时间间隔内的最大事件数是 15,是平均数的三倍。你会发现,最常见的事件数正好接近五的平均值,正如你所预期的那样,但也可能会有显著偏离平均值的情况。
快速加载骰子滚动器
如果我们需要根据任意离散分布抽样该怎么办?之前,我们看到了一些基于图像的直方图。在那种情况下,我们可以通过随机选择图像中的像素,从表示直方图的分布中抽样。但如果我们想根据任意权重抽取整数该怎么办?为了实现这一点,我们可以使用 Saad 等人开发的新快速加载骰子滚动器¹
快速加载骰子滚轮(FLDR)允许我们指定一个任意的离散分布,然后从中抽取样本。代码是用 Python 编写的,并且可以自由获取。(参见 github.com/probcomp/fast-loaded-dice-roller/。)我将展示如何使用这段代码根据通用分布进行抽样。我建议直接从 GitHub 仓库下载fldr.py和fldrf.py文件,而不是运行setup.py。另外,在fldrf.py中编辑.fldr导入行,去掉“.”,使其变为:
from fldr import fldr_preprocess_int
from fldr import fldr_s
使用 FLDR 需要两个步骤。第一步是告诉它你想要从中抽样的特定分布。你通过比率定义分布。(为了我们的目的,我们将使用实际的概率,也就是说,我们的分布总和将始终为 1.0。)这是预处理步骤,对于每个分布我们只需要做一次。之后,我们可以开始抽样。下面的示例将帮助澄清这一点:
>>> from fldrf import fldr_preprocess_float_c
>>> from fldr import fldr_sample
>>> x = fldr_preprocess_float_c([0.6,0.2,0.1,0.1])
>>> t = [fldr_sample(x) for i in range(1000)]
>>> np.bincount(t)
array([598, 190, 108, 104])
首先,我们导入所需的两个 FLDR 函数:fldr_preprocess_float_c和fldr_sample。然后,我们使用四个数字定义分布。四个数字意味着样本将是[0, 3]范围内的整数。然而,与均匀分布不同,在这里我们指定了零出现的概率为 60%,一出现的概率为 20%,而二和三的概率各为 10%。FLDR 需要的从分布中抽样的信息存储在x中。
调用fldr_sample返回一个来自分布的单个样本。注意两点:首先,我们需要传入x,其次,FLDR 不使用 NumPy,因此为了绘制 1,000 个样本,我们使用标准的 Python 列表推导式。1,000 个样本存储在列表t中。最后,我们生成直方图,可以看到近 60%的样本是零,略超过 10%的样本是三,这是我们预期的结果。
让我们使用之前使用过的浣熊面部图像的直方图,看看 FLDR 是否能够遵循一个更复杂的分布。我们将加载图像,生成直方图,将其转换为概率分布,并使用这些概率来设置 FLDR。之后,我们将从分布中抽取 25,000 个样本,计算样本的直方图,并将该直方图与原始直方图一起绘制,看看 FLDR 是否遵循我们提供的实际分布。我们需要的代码是:
from scipy.misc import face
im = face(True)
b = np.bincount(im.ravel(), minlength=256)
b = b / b.sum()
x = fldr_preprocess_float_c(list(b))
t = [fldr_sample(x) for i in range(25000)]
q = np.bincount(t, minlength=256)
q = q / q.sum()
运行这段代码后,我们得到b,这是来自面部图像直方图的概率分布,以及q,这是从 FLDR 分布中抽取的 25,000 个样本创建的分布。图 3-3 展示了这两个分布的图形。
图 3-3 中的实线是我们提供给fldr_preprocess_float_c的概率分布,表示浣熊图像中灰度级(强度)的分布。虚线是来自该分布的 25,000 个样本的直方图。正如我们所看到的,它们遵循了请求的分布,并且与我们从这么少的样本中预期的变化一致。作为练习,将样本数量从 25,000 更改为 500,000,并绘制这两条曲线。你会看到它们几乎完全重叠。

图 3-3:比较快速加载的骰子滚动器分布(虚线)与来自 SciPy 面部图像生成的分布(实线)
离散分布生成具有特定可能性的整数。现在我们暂时离开它们,来考虑返回浮动值的连续概率分布。
连续概率分布
我在本章中还没有讨论连续概率。部分原因是为了让概率背后的概念更容易理解。像离散概率分布一样,连续概率分布也有一个特定的形状。然而,和我们上面看到的不同,连续分布中选择一个特定值的概率是零。一个特定值的概率为零,因为连续分布中可能的值是无限的;这意味着无法选择某个特定值。相反,我们讨论的是在某个特定范围内选择值的概率。
例如,最常见的连续分布是[0, 1]范围内的均匀分布。该分布返回任何位于该范围内的实数。虽然返回特定实数的概率为零,但我们可以讨论返回某个范围内的值的概率,例如[0, 0.25]。
再次考虑在[0, 1]范围内的均匀分布。我们知道从零到一的所有单个概率之和为 1.0。那么,从这个分布中抽取一个值,并且该值位于[0, 0.25]范围内的概率是多少呢?所有值的可能性相同,它们加起来为 1.0,因此,我们有 25%的机会返回一个位于[0, 0.25]范围内的值。同样,我们也有 25%的机会返回一个位于[0.75, 1]范围内的值,因为这个范围也覆盖了可能范围的 1/4。
当我们讨论在一个范围内对无数小事物求和时,我们讨论的是积分,这是微积分的一部分,在本书中我们不会涉及。然而,从概念上讲,如果我们考虑离散分布的极限情况,其中它能返回的值数趋近于无限,并且我们在某个范围内求和概率,那么我们就能理解发生了什么。
我们也可以从图形上理解这一点。图 3-4 展示了我将要讨论的连续概率分布。

图 3-4:一些常见的连续概率分布
要获得在某个范围内抽样一个值的概率,我们需要将该范围内曲线下的面积加起来。事实上,这正是积分的作用;积分符号(∫)不过是一个花哨的“S”,表示求和。它是用于求和离散值的∑的连续版本。
图 3-4 中的分布是你最常遇到的分布,尽管还有许多其他的分布,它们同样足够有用并且被赋予了名字。所有这些分布都有对应的概率密度函数(pdfs),这些封闭形式的函数生成从分布中采样所能得到的概率。我通过使用continuous.py文件中的代码来生成图 3-4 中的曲线。这些曲线是概率密度函数的估计值,我是通过大量样本的直方图来创建它们的。我这样做是为了展示 NumPy 的随机函数从这些分布中采样时能够做到它们所宣称的效果。
对于图 3-4,不必过多关注 x 轴。各个分布的输出范围不同;它们在这里被缩放,以便将所有分布都适配到图表中。需要注意的是它们的形状。均匀分布是遍布整个范围的均匀分布。正态曲线,也常被称为高斯分布或钟形曲线,是深度学习中使用的第二大常见分布。例如,神经网络的 He 初始化策略就是从正态分布中采样初始权重。
生成图 3-4 数据的代码值得关注,因为它向我们展示了如何使用 NumPy 获取样本:
N = 10000000
B = 100
t = np.random.random(N)
u = np.histogram(t, bins=B)[0]
u = u / u.sum()
t = np.random.normal(0, 1, size=N)
n = np.histogram(t, bins=B)[0]
n = n / n.sum()
t = np.random.gamma(5.0, size=N)
g = np.histogram(t, bins=B)[0]
g = g / g.sum()
t = np.random.beta(5,2, size=N)
b = np.histogram(t, bins=B)[0]
b = b / b.sum()
注意
我们在这里使用的是经典的 NumPy 函数,而不是基于生成器的新版函数。NumPy 在最近的版本中更新了伪随机数代码,但使用新代码的开销会影响我们在此想要展示的内容。除非你对伪随机数生成非常认真,否则旧版函数和它们所基于的梅森旋转算法伪随机数生成器完全足够。
为了绘制图表,我们首先使用来自每个分布的 1000 万个样本(N)。然后,我们使用 100 个区间进行直方图绘制(B)。同样,绘制时 x 轴的范围在这里并不重要,重要的是曲线的形状。
均匀分布的样本使用了random,这是我们之前见过的一个函数。将样本传递给histogram并应用“除以总和”的技巧就能生成概率曲线数据(u)。我们对正态分布(normal)、伽马分布(gamma)和贝塔分布(beta)也做了相同的处理。
你会注意到,normal、gamma 和 beta 都接受参数。这些分布是有参数的,通过改变这些参数可以改变它们的形状。对于正态分布, 第一个参数是均值(μ),第二个参数是标准差(σ)。大约 68% 的正态分布位于均值的一个标准差范围内,即[μ - σ, μ + σ]。正态分布在数学和自然界中无处不在,甚至可以专门写一本书来讨论它。它总是围绕均值对称分布,标准差控制着曲线的宽窄。
Gamma 分布也是有参数化的。它接受两个参数:形状参数 (k) 和尺度参数 (θ)。这里,k = 5,尺度保持默认值 θ = 1。当形状参数增大时,gamma 分布越来越像高斯分布,峰值会向分布中心移动。尺度参数会影响峰值的横向大小。
同样,Beta 分布使用两个参数,a 和 b。这里,a = 5 和 b = 2。如果 a > b,则分布的峰值位于右侧;如果反过来,则位于左侧。如果 a = b,则 Beta 分布变为均匀分布。Beta 分布的灵活性使得它在模拟不同过程时非常有用,只要你能够找到逼近所需概率分布的 a 和 b 值。然而,根据你所需的精度,如果你有足够详细的离散分布近似值,上一节中看到的快速加载骰子滚动器可能是一个更实用的选择,尤其是在处理连续分布时。
表 3-2 显示了正态、Gamma 和 Beta 分布的概率密度函数。读者可以通过这些函数重新创建 图 3-4。你的结果会比图中的曲线更光滑。你可以使用 scipy.special.beta 函数计算 表 3-2 中的 B(a, b) 积分。对于 Γ(k),请参见 scipy.special.gamma。此外,如果 Γ 函数的参数是整数,则 Γ(n + 1) = n!,所以 Γ(5) = Γ(4 + 1) = 4! = 24。
表 3-2: 正态分布、Gamma 分布和 Beta 分布的概率密度函数
| normal | ![]() |
|---|---|
| gamma | ![]() |
| beta | ![]() |
如果你对从这些分布中抽样值的方式感兴趣,我的书籍《随机数与计算机》(Springer,2018)比我们在此能提供的更深入地讨论了这些分布及其他分布,包括用 C 语言实现从这些分布中生成样本的代码。现在,让我们来看一下概率论中最重要的定理之一。
中心极限定理
假设我们从某个分布中抽取 N 个样本并计算均值 m。如果我们多次重复这个操作,就会得到一组均值,{m[0], m[1], ...},每个均值来自一个样本集合。每次 N 是否相同并不重要,但 N 不应太小。经验法则是 N 至少应该为 30 个样本。
中心极限定理 表示,基于这一组样本均值,即 m 的直方图或概率分布,将会呈现高斯分布的形状,无论样本最初是从什么样的分布中抽取出来的。
例如,这段代码
M = 10000
m = np.zeros(M)
for i in range(M):
t = np.random.beta(5,2,size=M)
m[i] = t.mean()
创建了来自 Beta 分布 Beta(5,2)的 10,000 组样本,每组包含 10,000 个样本。每组样本的均值存储在m中。如果我们运行这段代码并绘制 m 的直方图,就会得到图 3-5。

图 3-5:Beta(5,2)分布的 10,000 组样本中,每组 10,000 个样本的均值分布
图 3-5 的形状显然是高斯分布的。再次强调,这一形状是中心极限定理的结果,且不依赖于底层分布的形状。图 3-5 告诉我们,来自 Beta(5,2)的多个样本均值的均值大约为 0.714。对于上述代码的一次运行,样本均值的均值 (m.mean()) 为 0.7142929。
计算 Beta 分布均值有一个公式。Beta(5,2)分布的人群均值已知为 a/(a + b) = 5/(5 + 2) = 5/7 = 0.714285。图中图 3-5 的均值是对真实人群均值的度量,而 Beta(5,2)样本中得到的多个均值仅是估算值。
让我们再解释一遍,真正理解这里发生了什么。对于任何分布,比如 Beta(5,2)分布,如果我们抽取 N 个样本,就可以计算出这些样本的均值,一个单一的数字。如果我们对多个 N 样本集合重复这个过程,每个集合都有自己的均值,并且我们对这些均值的分布做一个直方图,就会得到类似图 3-5 的图形。该图告诉我们,所有多个样本均值本身都集中在某个均值附近。均值的均值是对人群均值的度量。它是我们从分布中能够抽取无数样本时得到的均值。如果我们将上述代码改为使用均匀分布,我们将得到 0.5 的人群均值。同样,如果我们切换到均值为 11 的高斯分布,生成的直方图将以 11 为中心。
让我们再次证明这一点,但这次使用离散分布。我们使用快速加载的骰子滚动器通过以下代码从一个偏斜的离散分布中生成样本:
from fldrf import fldr_preprocess_float_c
from fldr import fldr_sample
z = fldr_preprocess_float_c([0.1,0.6,0.1,0.1,0.1])
m = np.zeros(M)
for i in range(M):
t = np.array([fldr_sample(z) for i in range(M)])
m[i] = t.mean()
图 3-6 展示了离散分布(上图)和相应的样本均值分布(下图)。
从概率质量函数中,我们可以看出,样本中最常见的值是 1,概率为 60%。然而,右侧的尾部意味着我们大约 30% 的时间会得到值 2 到 4。这些的加权均值是 0.6(1) + 0.1(2) + 0.1(3) + 0.1(4) = 1.5,这正是图 3-6 底部样本分布的均值。中心极限定理有效。我们将在第四章中讨论假设检验时重新审视中心极限定理。

图 3-6:一个任意离散分布(上图)和从中抽取的样本均值的分布(下图)
大数法则
与中心极限定理相关的一个概念,且常常与其混淆的,是大数法则。大数法则指出,随着从分布中抽取的样本量增大,样本均值越来越接近总体均值。在这里,我们考虑的是从分布中抽取的单个样本,并陈述我们期望其均值与真实总体均值之间的接近程度。而对于中心极限定理,我们有多个不同的样本集,并且我们在谈论这些样本集合均值的分布。
我们可以通过从一个分布中选取越来越大的样本并跟踪样本均值与样本量(抽样数量)之间的关系,简单地演示大数法则。因此,在代码中,
m = []
for n in np.linspace(1,8,30):
t = np.random.normal(1,1,size=int(10**n))
m.append(t.mean())
这里我们从均值为 1 的正态分布中抽取越来越大的样本量。第一个样本量为 10,最后一个为 1 亿。如果我们将样本均值与样本量绘制成图,就可以看到大数法则的作用。
图 3-7 展示了在均值为 1(虚线)的正态分布中,样本均值随着样本数量变化的情况。随着从分布中抽取的样本数量增加,样本的均值逐渐接近总体均值,这展示了大数法则。

图 3-7:大数法则的实际应用
让我们换个话题,进入贝叶斯定理,这是本章的最后一个主题。
贝叶斯定理
在第二章,我们讨论了一个示例,决定一个女性是否患有癌症。在那里,我承诺贝叶斯定理将告诉我们如何正确计算一位 40 多岁女性患乳腺癌的概率。让我们在本节中兑现这个承诺,了解贝叶斯定理是什么以及如何使用它。
使用乘积法则,公式 2.8,我们知道以下两个数学陈述是正确的:
P(B, A) = P(B|A)P(A)
P(A, B) = P(A|B)P(B)
另外,因为A和B的联合概率与我们将哪个事件称为A、哪个事件称为B无关,
P(A, B) = P(B, A)
因此,
P(B|A)P(A) = P(A|B)P(B)
除以P(A),我们得到

这是贝叶斯定理,贝叶斯方法的核心,用来比较两个条件概率:P**(B|A) 和 P(A|B)。你有时会看到方程 3.1 被称为贝叶斯法则。你也经常会看到“贝叶斯”后面没有撇号,这虽然有点草率和不规范,但在日常中很常见。
方程 3.1 已经成为霓虹灯、纹身甚至婴儿名字的标志:“贝叶斯”。这个方程以托马斯·贝叶斯(Thomas Bayes,1701–1761)的名字命名,他是英国的一位牧师和统计学家,这个方程在他去世后才被发布。用文字描述,方程 3.1 可以表述为:
后验概率,P(B|A),是P(A|B),即似然,与P(B),即先验,的乘积,通过P(A),即边际概率或证据,进行归一化。
现在我们知道了贝叶斯定理是什么,让我们看看它是如何应用的,以便更好地理解它。
乳腺癌与否重启
思考贝叶斯定理的组成部分的一种方式是将其放在医学检测的背景下。在第二章的开始部分,我们计算了在乳房 X 光检查为阳性时,女性患乳腺癌的概率,并发现这个概率与我们可能天真认为的差异很大。现在让我们再次使用贝叶斯定理来回顾这个问题。在继续之前,重新阅读第二章的第一部分可能会有所帮助。
我们想使用贝叶斯定理来找到后验概率,即在乳房 X 光检查为阳性时患乳腺癌的概率。我们将其写为P**(bc+ |+),表示乳腺癌(bc+)在乳房 X 光检查为阳性(+)时的概率。
在这个问题中,我们被告知,如果患者患有乳腺癌,乳房 X 光检查结果为阳性的概率是 90%。我们写作
P(+|bc+) = 0.9
这是贝叶斯方程中阳性乳房 X 光检查的似然,P(A|B) = P(+|bc+)。
接下来,我们被告知,随机女性患乳腺癌的概率是 0.8%。因此,我们知道
P(bc+) = 0.008
这是贝叶斯定理中的先验概率,P(B)。
我们已经有了方程 3.1 的所有组成部分,除了一个:P**(A)。在这个背景下,P(A)是什么?它是P(+),即不考虑任何B(任何乳腺癌状态)的情况下,乳房 X 光检查为阳性的边际概率。这也是我们所知道的证据:乳房 X 光检查为阳性。
在这个问题中,我们被告知一个没有乳腺癌的女性有 7%的概率会得到乳房 X 光检查阳性结果。这是P(+)? 不是,它是P(+|bc–),即在没有乳腺癌的情况下,乳房 X 光检查呈阳性的概率。
我已经两次提到过 P**(A) 是边际概率。我们知道如何计算边际或总概率:我们对联合概率中与我们想知道的内容无关的所有其他部分求和。在这里,我们必须对我们不关心的样本空间的所有分区求和,以获得阳性乳腺 X 光检查的边际概率。那么,哪些是我们不关心的分区呢?只有两个:一个是女性患乳腺癌,另一个是她没有乳腺癌。因此,我们需要找到
P(+) = P(+|bc+)P(bc+) + P(+|bc–)P(bc–)
我们已经知道所有这些量,除了 P**(bc–)。这是一个随机选择的女性 没有 乳腺癌的先验概率,P**(bc–) = 1 – P(bc+) = 0.992。
有时,你会在贝叶斯定理的分母中看到联合概率的其他项的求和。即使它们没有被明确指出,它们依然存在,隐含在求解 P**(A) 所需的条件中。
最后,我们已经拥有所有必要的元素,可以使用贝叶斯定理来计算概率:

这是我们之前得到的结果。回想一下,研究中有很大一部分医生声称乳腺 X 光检查阳性时的癌症概率 P**(A|B) 是 90%。他们的错误在于错误地将 P(A|B) 与 P(B|A) 等同起来。贝叶斯定理通过使用先验概率和边际概率正确地将两者联系了起来。
更新先验
我们不需要仅仅停留在这个单一的计算上。考虑以下情况:假设一位女性在收到乳腺 X 光检查呈阳性的消息后,决定在另一家有不同放射科医生解读结果的机构进行第二次乳腺 X 光检查,而且第二次检查也呈阳性。她还会认为自己患乳腺癌的概率是 9% 吗?直观上,我们可能认为她现在更有理由相信自己得了癌症。这种信念是否可以量化?从贝叶斯角度来看,它是可以的,通过用第一次检查计算出来的后验概率 P(bc+|+) 来更新先验 P**(bc+)。毕竟,考虑到第一次的阳性乳腺 X 光检查结果,她现在的乳腺癌先验概率更强。
让我们基于之前的乳腺 X 光检查结果计算这个新的后验概率:

由于 57% 显著高于 9%,我们假设的这位女性现在有了更充足的理由相信她得了乳腺癌。
注意,在这个新的计算中,除了乳腺 X 光检查第二次阳性结果所导致的乳腺癌后验概率的大幅增加,还有哪些变化。首先,乳腺癌的先验概率从 0.008 → 0.094,后验概率是基于第一次检查计算得出的。其次,P(bc–) 也从 0.992 → 0.906 发生了变化。为什么?因为先验发生了变化,且 P(bc–) = 1 – P(bc+)。P(bc+) 和 P(bc–) 的总和仍然必须是 1.0——要么她患乳腺癌,要么她没有——这是整个样本空间。
在上面的例子中,我们基于初步的测试结果更新了先验概率,并且我们在第一个例子中获得了一个初始先验。那么,先验一般是如何选择的呢?在许多情况下,贝叶斯学派的选择先验,至少最初,是基于对问题的实际信念。通常,先验是一个均匀分布,称为无信息先验,因为没有任何东西来指导选择其他任何形式。以乳腺癌的例子为例,先验是可以通过对随机选择的女性样本进行实验来估计的。
如前所述,不要太认真对待这里的数字;它们仅用于示例。同时,虽然女性确实可以选择第二意见,但乳腺癌诊断的黄金标准是活检,这通常是初次阳性乳房 X 光检查后的下一步。最后,在本节中,我提到了女性和乳腺癌。男性也会得乳腺癌,尽管这种情况很少见,男性的病例不到 1%。然而,讨论中仅提及女性使得讨论更简便。我需要指出,男性乳腺癌的病例更可能是致命的,尽管原因尚不清楚。
贝叶斯定理在机器学习中的应用
贝叶斯定理在机器学习和深度学习中广泛应用。贝叶斯定理的一个经典应用,甚至能取得惊人的效果,就是将其用作分类器。这就是所谓的朴素贝叶斯分类器。早期的电子邮件垃圾邮件过滤器就采用了这种方法,且非常有效。
假设我们有一个包含类别标签y和特征向量x的数据集。朴素贝叶斯分类器的目标是告诉我们,每个类别的概率,即给定的特征向量属于该类别的概率。通过这些概率,我们可以通过选择最大概率来分配类别标签。也就是说,我们要为每个类别标签y找到P**(y|x)。这是一个条件概率,因此我们可以使用贝叶斯定理来处理它:

上面的方程表示,特征向量x表示类别标签y实例的概率,是类别标签y生成特征向量x的概率与类别标签y发生的先验概率的乘积,除以所有类别标签上特征向量的边际概率。回顾一下计算P(x)时的隐式求和。
这对我们有什么用呢?既然我们有一个数据集,我们可以利用它来估计P**(y),假设数据集的类别分布能够公平地代表我们在使用模型时遇到的情况。而且,由于我们有标签,我们可以将数据集分割成每个类别的子集。这可能有助于我们做一些有用的事情,以获取每个类别的可能性,P(x|y)。我们将完全忽略边际P(x)。让我们看看为什么,在这种情况下,我们可以这么做。
方程 3.2 是针对一个特定的类标签,比如 y = 1。我们将为数据集中的所有类标签拥有它的其他版本。我们之前提到过,我们的分类器由计算每个类标签的后验概率并选择最大的那个作为分配给未知特征向量的标签组成。方程 3.2 的分母是一个尺度因子,使得输出成为一个真正的概率。然而,在我们的使用场景中,我们只关心不同类标签之间 P**(y|x) 的相对排序。由于 P(x) 对所有 y 都是相同的,它是一个公共因子,这个因子会改变 P**(y|x) 相关的数字,但不会改变不同类标签之间的排序。因此,我们可以忽略它,专注于找到似然度和先验的乘积。尽管这种方式计算出来的最大 P(y|x) 已经不再是一个合适的概率,但它仍然是正确的类标签。
由于我们可以忽略 P(x) 并且 P(y) 值可以从数据集中轻松估计,我们剩下的就是计算 P(x|y),即给定类标签为 y 时,特征向量 x 的似然度。我们在这种情况下该怎么做呢?
首先,我们可以考虑 P(x|y) 是什么。它是给定特征向量属于类 y 时的条件概率。暂时我们可以忽略 y 部分,因为我们知道特征向量都来自类 y。
这只剩下了 P(x),因为我们固定了 y。特征向量是单独特征的集合,x = (x[0], x[1], x[2], . . ., x[n][–1]),其中 n 是向量中的特征数量。因此,P(x) 其实是一个联合概率,表示所有单独特征同时具有其特定值的概率。所以,我们可以写成
P(x) = P(x[0], x[1], x[2], . . ., x[n–1])
这怎么帮助我们呢?如果我们对数据做出一个假设,我们会发现可以方便地拆解这个联合概率。假设我们的特征向量中的所有特征都是独立的。回想一下,独立 的意思是,x[1] 的值,比如说,并不受向量中任何其他特征值的影响。这个通常并不完全正确,对于图像中的像素来说,肯定 不成立,但我们还是假设它成立。我们天真地相信它是对的,这也就是 朴素 贝叶斯中的 朴素。
如果特征是独立的,那么一个特征取特定值的概率就与其他所有特征的取值无关。在这种情况下,乘积规则告诉我们可以像这样将联合概率拆解:
P(x) = P(x[0])P(x[1])P(x[2]) . . . P(x[n–1])
这对我们帮助很大。我们有一个按类别标注的数据集,使我们能够通过计算每个特征值在每个类中出现的频率,来估算任何特征对于任何特定类的概率。
让我们将所有内容结合起来,考虑一个假设的数据集,其中有三个类别——0、1 和 2——以及四个特征。我们首先使用按类别标签划分的数据集来估计每个特征值的概率。这为我们提供了每个类别标签下每个特征的P**(x[0])、P(x[1])等概率。结合类别标签的先验概率,该概率可以通过数据集中每个类别的样本数除以数据集中的总样本数来估计,我们可以为一个新的未知特征向量x进行计算,

在这里,P(x[0])特征概率仅针对类别 0,P(0)是数据集中类别 0 的先验概率。P(0|x)是未归一化的后验概率,表示未知特征向量x属于类别 0。我们称其为未归一化,因为我们忽略了贝叶斯定理中的分母,知道包含它不会改变后验概率的排序,仅会改变其值。
我们可以重复上述计算,得到P(1|x)和P(2|x),确保使用为这些类别计算的每个特征概率(即P(x[0]))。最后,我们为x分配具有最大后验概率的类别标签。
上述描述假设特征值是离散的。通常它们不是离散的,但可以采用一些变通方法。一种方法是将特征值分箱,使其变为离散。例如,如果特征的范围是[0, 3],可以创建一个新的特征,其值为 0、1 或 2,并通过截断小数部分将连续特征分配到这些箱中。
另一种变通方法是对特征值的分布做出假设,并使用该分布来计算每个类别的P**(x[0])。特征通常基于现实世界中的测量,而现实世界中的许多事物都遵循正态分布。因此,通常我们假设各个特征虽然是连续的,但它们服从正态分布,并且我们可以从数据集中找到每个特征和类别标签的均值(μ)和标准差(σ)的估计值。
贝叶斯定理对于计算概率非常有用,在机器学习中也很有帮助。尽管贝叶斯学派与频率学派之间的争斗似乎有所减弱,但哲学上的分歧依然存在。在实践中,大多数研究人员发现这两种方法都有价值,有时应该同时使用两者的方法。我们将在下一章继续这一趋势,届时我们将从频率学派的角度审视统计学。我们通过指出在过去一个世纪里,绝大多数已发布的科学结果都采用了这种统计方法来辩护这一决定,其中也包括深度学习社区,至少在呈现实验结果时是如此。
概述
本章教会了我们概率分布,它们是什么,以及如何从中抽样,无论是离散的还是连续的。在我们探索深度学习的过程中,我们将遇到不同的分布。我们还发现了贝叶斯定理,了解了它如何让我们正确地关联条件概率。我们看到贝叶斯定理如何帮助我们评估癌症的真实可能性,给定一个不完美的医学测试——这是一个常见的情境。我们还学习了如何使用贝叶斯定理,以及我们在第二章中学到的一些基本概率规则,来构建一个简单但通常出奇有效的分类器。
现在让我们进入统计学的世界。
1。Feras A. Saad、Cameron E. Freer、Martin C. Rinard 和 Vikash K. Mansinghka,"快速加载骰子滚动器:一种近似最优的离散概率分布精确采样器",载于 AISTATS 2020:第 23 届国际人工智能与统计学会议论文集,机器学习研究论文集 108,意大利西西里岛巴勒莫,2020 年。
第四章:统计学**

糟糕的数据集会导致糟糕的模型。在构建模型之前,我们希望了解我们的数据,然后利用这些理解创建一个有用的数据集,进而生成符合预期的模型。掌握基本的统计学将帮助我们做到这一点。
统计量是从样本中计算得出的任何数字,并以某种方式用来描述它。在深度学习中,当我们谈论样本时,通常是指数据集。也许最基本的统计量是算术平均数,通常称为平均值。数据集的平均值是数据集的一个单一数字摘要。
本章我们将看到许多不同的统计量。我们将从学习数据类型和用总结性统计量描述数据集开始。接下来,我们将学习分位数并绘制数据以了解其内容。之后将讨论异常值和缺失数据。数据集很少是完美的,因此我们需要有某种方式来检测不良数据并处理缺失数据。我们在讨论不完美的数据集之后,会讨论变量之间的相关性。最后,我们将通过讨论假设检验来结束本章,假设检验帮助我们回答诸如“相同的父进程生成两个数据集的可能性有多大?”这样的问题。假设检验在科学中被广泛应用,包括深度学习。
数据类型
四种数据类型是名义数据、顺序数据、间隔数据和比率数据。让我们逐一看一下它们。
名义数据
名义数据,有时称为类别数据,是没有不同值之间顺序关系的数据。一个例子是眼睛颜色;棕色、蓝色和绿色之间没有关系。
顺序数据
对于顺序数据,数据具有排名或顺序,尽管在数学意义上这些差异并不重要。例如,如果问卷要求你从“强烈反对”、“反对”、“中立”、“同意”和“强烈同意”中选择,显然是有顺序的。不过,仍然可以看出,“同意”并不比“强烈反对”大三倍。我们能说的只是“强烈反对”在“同意”的左侧(以及“中立”和“反对”)。
另一个顺序数据的例子是教育水平。如果一个人的教育水平是四年级,另一个人的教育水平是八年级,我们可以说后者的教育水平高于前者,但我们不能说后者的教育水平是前者的两倍,因为“教育水平是两倍”没有固定的意义。
间隔数据
区间数据具有有意义的差异。例如,如果一杯水的温度是 40 华氏度,另一杯是 80 华氏度,我们可以说这两杯水之间的温差是 40 度。然而,我们不能说第二杯水的热量是第一杯的两倍,因为华氏度的零点是任意的。口语中我们确实会说第二杯更热,但实际上并非如此。要验证这一点,可以想象如果我们将温度量表换成另一个具有任意零点但更合理的量表——摄氏度。我们看到第一杯水的温度大约是 4.4°C,第二杯是 26.7°C。显然,第二杯水并没有比第一杯多六倍的热量。
比率数据
最后,比率数据是指差异有意义并且具有真实零点的数据。身高是比率值,因为零身高就是没有身高。同样,年龄也是比率值,因为零岁意味着没有年龄。如果我们采用一个新的年龄量表,并在一个人达到某个年龄段(例如投票年龄)时称其为零,那么我们就得到了一个区间量表,而不是比率量表。
我们再来看一下温度。我们之前提到温度是一个区间量。这并非总是如此。如果我们用华氏度或摄氏度来测量温度,那么确实它是一个区间量。然而,如果我们用开尔文度(绝对温标)来测量温度,它就变成了比率值。为什么?因为 0 开尔文(或K)温度就是真正的零温度,意味着没有任何温度。如果我们的第一杯水是 40°F,277.59 K,第二杯是 80°F,299.82 K,那么我们可以真实地说第二杯水比第一杯热 1.08 倍,因为(277.59)(1.08)≈299.8。
图 4-1 标明了各个量表,并展示了它们之间的关系。

图 4-1:四种数据类型
图 4-1 中的每一步,从左到右,都为数据增加了左边数据类型所缺乏的内容。从名义数据到有序数据,我们增加了排序;从有序数据到区间数据,我们增加了有意义的差异;最后,从区间数据到比率数据增加了一个真正的零点。
在实际应用中,就统计学而言,我们应该了解数据的类型,以免做出没有意义的分析。如果我们有一份问卷,问题 A 在 1 到 5 的评分量表上的平均值为 2,而问题 B 的平均值为 4,我们不能说 B 的评分是 A 的两倍,只能说 B 的评分高于 A。在这个背景下,“两倍”是什么意思并不明确,很可能是没有意义的。
区间数据和比率数据可以是连续的(浮动数值)或离散的(整数)。从深度学习的角度看,模型通常将连续数据和离散数据同等对待,我们不需要对离散数据做任何特殊处理。
在深度学习中使用名义数据
如果我们有一个名义值,比如一组颜色,像红色、绿色和蓝色,我们想将这些值传递到深度网络中,在使用之前我们需要先对数据进行转换。正如我们刚才看到的,名义数据是没有顺序的,所以虽然我们可能会想把红色赋值为 1,绿色赋值为 2,蓝色赋值为 3,但这样做是错误的,因为网络会将这些数字解释为区间数据。在这种情况下,对网络来说,蓝色 = 3(红色),这显然是无意义的。如果我们想将名义数据与深度网络一起使用,我们需要改变它,使得区间具有实际意义。我们通过独热编码来实现这一点。
在独热编码中,我们将单一的名义变量转化为一个向量,其中向量的每个元素对应于一个名义值。以颜色为例,单一的名义变量变成了一个三元素向量,一个元素代表红色,另一个代表绿色,最后一个代表蓝色。然后,我们将与颜色对应的值设置为 1,其它值设置为 0,如下所示:
| 值 | 向量 | |
|---|---|---|
| 红色 | → | 1 0 0 |
| 绿色 | → | 0 1 0 |
| 蓝色 | → | 0 0 1 |
现在,向量值变得有意义,因为它要么是红色(1),要么不是(0),要么是绿色(1),要么不是(0),要么是蓝色(1),要么不是(0)。零和一之间的区间具有数学意义,因为某个值的存在,比如红色,确实比它的缺失要“大”,对于每种颜色都一样。现在,这些值是区间型数据,网络可以使用它们。在一些工具包中,如 Keras,类标签在传递给模型之前会进行独热编码。这样做是为了确保向量输出能够与独热编码后的类标签在计算损失函数时良好地配合。
汇总统计
我们得到了一份数据集。我们该如何理解它呢?在使用它来构建模型之前,我们应该如何描述它,以便更好地理解它?
为了回答这些问题,我们需要了解汇总统计。计算汇总统计应该是你拿到新数据集后的第一件事。没有在构建模型之前查看数据集,就像买了一辆二手车,却没有检查轮胎,试驾一圈,也没看引擎盖下的情况。
不同的人对什么构成良好的汇总统计有不同的看法。我们将重点关注以下几项:均值;中位数;以及变异性度量,包括方差、标准差和标准误差。数据集的范围和众数也是常被提及的。范围是数据集中最大值和最小值之间的差异。众数是数据集中最频繁出现的值。我们通常通过直方图来视觉上感知众数,因为直方图展示了数据分布的形态。
均值与中位数
我们大多数人在小学时学会了如何计算一组数字的平均值:将数字加起来,再除以数字的个数。这就是算术平均数,更具体地说,是无权重的算术平均数。如果数据集由一组值{x[0], x[1], x[2], . . . , x[n–1]}组成,那么算术平均数是数据的总和除以数据集中元素的数量(n)。在符号上,我们可以这样表示:

是表示样本均值的常见符号。
方程 4.1 计算的是无权重均值。每个值的权重为 1/n,所有权重的总和为 1.0。有时,我们可能需要对数据集中的元素赋予不同的权重;换句话说,并不是所有元素都应该平等计算。在这种情况下,我们计算加权均值,

其中 w[i] 是赋予 x[i] 的权重,Σ[i]w[i] = 1。权重不是数据集的一部分,它们需要从其他地方获取。许多大学使用的学分绩点(GPA)就是加权均值的一个例子。每门课程的成绩乘以课程学分,然后将总和除以学分总数。从代数上讲,这相当于将每个成绩乘以一个权重,w[i] = c[i]/Σ[i]**c[i],其中 c[i] 是课程 i 的学分,Σ[i]**c[i] 是学期的学分总数。
几何均值
算术均值是目前最常用的均值。然而,还有其他均值。两个正数 a 和 b 的几何均值是它们乘积的平方根:

一般来说,n 个正数的几何均值是它们乘积的n次方根:

几何均值通常用于金融中计算平均增长率。在图像处理中,几何均值可以作为滤波器来帮助减少图像噪声。在深度学习中,几何均值出现在马修斯相关系数(MCC)中,MCC 是我们用来评估深度学习模型的指标之一。MCC 是另外两个指标——信息度和显著度的几何均值。
调和均值
两个数 a 和 b 的调和均值是它们倒数的算术均值的倒数:

一般来说,

调和均值在深度学习中表现为 F1 分数。这是一个常用的评估分类器的指标。F1 分数是召回率(灵敏度)和精确度的调和均值:

尽管 F1 分数经常被使用,但并不建议用它来评估深度学习模型。为了说明这一点,考虑召回率和精确度的定义:

在这里,TP 是真正阳性(True Positive)的数量,FN 是假阴性(False Negative)的数量,FP 是假阳性(False Positive)的数量。这些值来自于用于评估模型的测试集。分类器的第四个重要数字是 TN,即真正阴性(True Negative)的数量(假设为二分类器)。F1 分数忽略 TN,但为了了解模型的表现,我们需要考虑正类和负类的分类。因此,F1 分数具有误导性,通常过于乐观。更好的度量标准是上面提到的 MCC 或 Cohen’s κ(Kappa),它与 MCC 相似,通常会紧密跟踪其变化。
中位数
在我们继续讨论变异度量之前,还有一个常用的总结性统计量我们在这里提到。它稍后会在本章中再次出现。数据集的中位数是中间值。它是数据按数值排序后,数据集一半的值位于它之下,一半的值位于它之上的那个值。我们使用这个数据集:
X = {55,63,65,37,74,71,73,87,69,44}
如果我们对 X 进行排序,我们得到
{37, 44, 55, 63, 65, 69, 71, 73, 74, 87}
我们立刻看到一个潜在问题。我说过,我们需要在数据排序后得到中间值。对于 X 中的 10 个数据,没有真正的中间值。中间值位于 65 和 69 之间。当数据集中的元素数量是偶数时,中位数是两个中间数字的算术平均值。因此,在这种情况下,中位数是

数据的算术平均值是 63.8。均值和中位数之间有什么区别?
从设计上看,中位数告诉我们将数据集分开的值,使得位于其上的样本数等于位于其下的样本数。重要的是样本的数量。对于均值,它是所有数据值的总和。因此,均值对值本身敏感,而中位数则对值的排序敏感。
如果我们观察 X,会看到大多数值位于 60 到 70 之间,只有一个较低的值 37。正是这个低值 37 使得均值相对于中位数较低。收入就是这种效应的一个典型例子。美国当前的家庭年收入中位数大约是 62,000 美元。最近的美国家庭收入均值接近 72,000 美元。二者之间的差异是因为少部分人群的收入远高于其他人,他们把整体均值拉高了。因此,对于收入而言,最有意义的统计量是中位数。
请参考 图 4-2。

图 4-2:均值(实线)和中位数(虚线)在样本数据集直方图上的绘制
图 4-2 展示了从 1,000 个模拟数据样本生成的直方图。图中还标出了均值(实线)和中位数(虚线)。两者并不重合;直方图中的长尾将均值拉高。如果我们进行计数,会发现 500 个样本落在虚线以下的区间,500 个样本则落在虚线以上的区间。
有没有可能均值和中位数相同的情况?有的。如果数据的分布完全对称,那么均值和中位数将会相同。一个经典的例子就是正态分布。图 3-4 展示了一个正态分布,左右对称性非常明显。正态分布是特别的,我们将在本章中再次遇到它。现在要记住的是,数据集的分布越接近正态分布,均值和中位数就越接近。
另一个值得记住的情况是:如果数据集的分布与正态分布相差较远,如图 4-2 所示,那么中位数可能是更好的统计量,适合用来总结数据。
变异度的度量
一名初学者射手向目标射击了 10 支箭。初学者的 8 支箭击中了目标,2 支箭完全偏离,且那 8 支命中的箭均匀分布在目标上。另一位专家射手向目标射击了 10 支箭,所有箭都射中了距离中心几厘米以内的区域。想一想这些箭的均值位置。对于专家射手,所有的箭都集中在目标中心附近,因此均值位置也会靠近中心。对于初学者,虽然没有箭射中目标中心,但它们大致均匀分布在目标的左右或上下。由于这个原因,平均位置将会平衡,并接近目标的中心。
然而,第一位射手的箭散布得较广,它们的位置变化很大。另一方面,第二位射手的箭聚集得很紧,位置变化很小。总结和理解数据集的一种有意义的方法是量化它的变化。让我们看看如何实现这一点。
偏差与方差
我们衡量数据集变化的一种方法是找出极差,即最大值和最小值之间的差异。然而,极差是一个粗略的度量,因为它忽略了数据集中的大多数值,仅关注极端值。我们可以通过计算数据值与数据均值之间差异的平均值来做得更好。公式如下:

公式 4.2 是平均偏差。它是一种自然的度量,正是我们想要的:给出每个样本平均偏离均值的程度。虽然计算平均偏差没有问题,但你会发现它在实践中很少被使用。一个原因与代数和微积分有关。绝对值在数学上很麻烦。
让我们不用自然的变异度量,而是通过平方差来计算:

公式 4.3 被称为偏差样本方差。它是数据集中每个值与均值之间平方差的平均值。这是表征数据集散布的另一种方式。为什么它是偏差的,我们稍后会讨论。我们也会很快讨论为什么是
而不是s[n]。
在我们继续之前,值得注意的是,你经常会看到一个稍微不同的公式:

这个公式是无偏样本方差。用n – 1 代替n被称为贝塞尔修正。它与残差的自由度数相关,其中残差是从每个数据值中减去均值后剩余的部分。残差的总和为零,因此如果数据集中有n个值,知道n – 1 个残差就可以计算出最后一个残差。这为残差提供了自由度。我们“自由”地计算出n – 1 个残差,因为我们知道残差的总和为零,可以得出最后一个残差。除以n–1 可以给出方差的一个更少偏差的估计,假设
本身一开始就有偏差。
为什么我们要讨论偏差方差和无偏方差?偏差是什么?我们应该始终记住,数据集是某个母体数据生成过程的一个样本,即母体。真实的母体方差(σ²)是母体围绕真实母体均值(μ)的散布。然而,我们不知道μ或σ²,因此我们从我们拥有的数据集中估计它们。样本的均值是
。这是我们对μ的估计。然后,计算围绕
的平方偏差的均值,并称之为我们对σ²的估计。这就是
(公式 4.3)。这个结论虽然是正确的,但超出了我们展示的范围:
是有偏差的,并不是σ²的最佳估计,但如果应用贝塞尔修正,我们将获得更好的母体方差估计。因此,我们应该使用s²(公式 4.4)来表征数据集围绕均值的方差。
总结来说,我们应该使用
和 s² 来量化数据集的方差。那么,为什么是 s² 呢?方差的平方根是 标准差,对于总体来说用σ表示,针对从数据集中计算出的σ估计值用 s 表示。我们通常需要处理的是标准差。写平方根会很麻烦,因此约定俗成地用σ或 s 表示标准差,讨论方差时则使用其平方形式。
而且,由于生活本身已经足够模糊,你经常会看到σ被用作 s,以及方程式 4.3 被使用,而实际上它应该使用方程式 4.4。一些工具包,包括我们亲爱的 NumPy,便于使用错误的公式。
然而,随着数据集中样本数量的增加,偏差方差和无偏方差之间的差异逐渐减小,因为除以 n 或 n – 1 的影响变得越来越小。以下几行代码展示了这一点:
>>> import numpy as np
>>> n = 10
>>> a = np.random.random(n)
>>> (1/n)*((a-a.mean())**2).sum()
0.08081748204006689
>>> (1/(n-1))*((a-a.mean())**2).sum()
0.08979720226674098
在这里,一个只有 10 个值的样本(a)显示了偏差方差和无偏方差在第三位小数上的差异。如果我们将数据集的大小从 10 增加到 10,000,我们会得到
>>> n = 10000
>>> a = np.random.random(n)
>>> (1/n)*((a-a.mean())**2).sum()
0.08304350577482553
>>> (1/(n-1))*((a-a.mean())**2).sum()
0.08305181095592111
现在,偏差方差和无偏方差估计之间的差异已经在第五位小数。因此,对于我们通常在深度学习中使用的大数据集来说,实际上无论是使用 s[n] 还是 s 作为标准差都没多大区别。
中位数绝对偏差
标准差基于均值。如上所述,均值对极端值敏感,而标准差更为敏感,因为我们对每个样本的偏差进行平方处理。一个对数据集中极端值不敏感的变异性度量是 中位数绝对偏差(MAD)。MAD 定义为数据与中位数之间差异的绝对值的中位数:
MAD = 中位数(|X[i] – 中位数(X)|)
按步骤操作,首先计算数据的中位数,然后将其从每个数据值中减去,使结果为正,并报告该数据集的中位数。实现方法非常直接:
def MAD(x):
return np.median(np.abs(x-np.median(x)))
MAD 不常使用,但它对数据集中极端值的不敏感使得它在异常值检测中具有更频繁使用的价值。
标准误差与标准差
我们还有一个方差的度量需要讨论:均值的标准误差(SEM)。SEM 通常简称为标准误差(SE)。为了理解 SE 是什么以及何时使用它,我们需要回到总体。如果我们从总体中选择一个样本,一个数据集,我们可以计算该样本的均值,
。如果我们选择多个样本并计算这些样本的均值,我们将生成一个包含来自总体样本均值的数据集。这听起来可能有些熟悉;它就是我们在第三章中用来说明中心极限定理的过程。均值集合的标准差就是标准误差。
标准差的标准误差公式很简单,

只是将样本标准差按样本数量的平方根进行缩放。
我们什么时候使用标准差,什么时候使用标准误差?使用标准差来了解样本围绕均值的分布情况。使用标准误差来说明样本均值对总体均值的估计有多好。从某种意义上讲,标准误差与中心极限定理有关,因为它影响来自总体的多个样本均值的标准差,同时也与大数法则相关,因为较大的数据集更有可能给出总体均值的更好估计。
从深度学习的角度来看,我们可能使用标准差来描述用于训练模型的数据集。如果我们训练和测试多个模型,并且记住深度网络初始化的随机性,我们可以对某个指标(例如准确度)计算这些模型的均值。在这种情况下,我们可能会报告均值准确度加上或减去标准误差。随着我们训练更多模型,并且更加确信均值准确度代表了模型架构所能提供的准确度,我们应该期望模型的均值准确度的误差会减少。
总结一下,在本节中,我们讨论了不同的总结性统计量,这些值可以帮助我们开始理解数据集。包括各种均值(算术均值、几何均值和调和均值)、中位数、标准差,以及在适当情况下,标准误差。现在,让我们看看如何使用图表来帮助理解数据集。
分位数和箱线图
要计算中位数,我们需要找到中间值,即将数据集分成两个部分的数值。从数学角度讲,我们说中位数将数据集分为两个分位数。
分位数将数据集划分为固定大小的组,其中固定大小是分位数中数据值的数量。由于中位数将数据集划分为两个大小相等的组,因此它是一个2-分位数。有时你会看到中位数被称为第 50 百分位数,意思是有 50%的数据值小于这个值。通过类似的推理,第 95 百分位数是 95%的数据集小于的值。研究人员通常计算 4-分位数,并称其为四分位数,因为它将数据集划分为四个组,其中 25%的数据值位于第一个四分位数,50%的数据值位于第一个和第二个四分位数,75%的数据值位于第一个、第二个和第三个四分位数,剩余的 25%的数据值位于第四个四分位数。
让我们通过一个例子来理解分位数的含义。这个例子使用了一个合成的考试数据集,代表了 1,000 个考试分数。请参阅文件exams.npy。我们将使用 NumPy 来为我们计算四分位数值,然后绘制该数据集的直方图,并标出四分位数值。首先,让我们计算四分位数的位置:
d = np.load("exams.npy")
p = d[:,0].astype("uint32")
q = np.quantile(p, [0.0, 0.25, 0.5, 0.75, 1.0])
print("Quartiles: ", q)
print("Counts by quartile:")
print(" %d" % ((q[0] <= p) & (p < q[1])).sum())
print(" %d" % ((q[1] <= p) & (p < q[2])).sum())
print(" %d" % ((q[2] <= p) & (p < q[3])).sum())
print(" %d" % ((q[3] <= p) & (p < q[4])).sum())
这段代码以及生成图表的代码都在文件quantiles.py中。
首先,我们加载合成的考试数据并保留第一个考试分数(p)。注意,我们将p设置为整数数组,以便稍后使用np.bincount来制作直方图。(该代码未在上面显示。)然后,我们使用 NumPy 的np.quantile函数来计算四分位数值。该函数接受源数组和一个在[0, 1]范围内的分位数数组。这些值是从数组的最小值到最大值的距离的分数。所以,要求 0.5 分位数实际上是要求位于p的最小值和最大值之间的距离的一半的值,使得每个集合中的值的数量相等。
为了获得四分位数,我们请求 0.25、0.5 和 0.75 分位数,以获取使得p中 25%、50%和 75%的元素小于这些值的四分位数值。我们还请求 0.0 和 1.0 分位数,即p的最小值和最大值。这样做是为了方便我们在计算每个范围内元素数量时使用。注意,我们本可以使用np.percentile函数来代替,它返回与np.quantile相同的值,只不过使用的是百分位数而不是分数。在这种情况下,第二个参数应该是[0,25,50,75,100]。
返回的四分位数值在q中。我们打印它们以获取
18.0, 56.75, 68.0, 78.0, 100.0
在这里,18 是最小值,100 是最大值,三个四分位数的截止值分别是 56.75、68 和 78。请注意,第二个四分位数的截止值是中位数 68。
剩下的代码计算p中每个范围内的值的数量。对于 1,000 个值,我们期望每个范围内有 250 个值,但因为数学运算不总是精确地落在现有数据值上,我们得到的结果是
250, 237, 253, 248
意味着p中有 250 个元素小于 56.75,237 个元素在[56.75, 68]之间,依此类推。
上面的代码使用了一种巧妙的计数技巧,值得解释一下。我们想要计算p中某个范围内的值的数量。我们不能使用 NumPy 的np.where函数,因为它不喜欢复合条件语句。但是,如果我们使用像10 <= p这样的表达式,我们将得到一个与p大小相同的数组,其中每个元素如果满足条件,则为True,否则为False。因此,要求10 <= p且p < 90将返回两个布尔数组。要获得两个条件都为真的元素,我们需要将它们逻辑与(&)在一起。这样,我们得到一个与p大小和形状相同的最终数组,其中所有True元素表示p中位于 10, 90)范围内的值。要计算数量,我们可以使用sum方法,布尔数组中的True作为 1,False作为 0。
[图 4-3 显示了带有四分位数标记的考试数据直方图。

图 4-3:带有四分位数标记的 1,000 个考试分数的直方图
上面的示例再次展示了直方图在可视化和理解数据时的强大作用。我们应尽可能使用直方图来帮助理解数据集的情况。图 4-3 将四分位数值叠加在直方图上。这有助于我们理解四分位数是什么以及它们与数据值的关系,但这并不是一种典型的展示方式。更典型且更有用的展示方式是箱形图,因为它能够展示数据集的多个特征。现在让我们用它来展示上面的考试分数,这次我们还会包括之前忽略的另外两个考试分数集。
我们首先将展示一个箱形图,然后解释它。要查看exams.npy文件中三个考试的箱形图,请使用
d = np.load("exams.npy")
plt.boxplot(d)
plt.xlabel("Test")
plt.ylabel("Scores")
plt.show()
我们加载完整的考试分数集,然后使用 Matplotlib 的boxplot函数。
看一下输出,见图 4-4。

图 4-4:三个考试的箱形图(上),以及带有标记组件的第一个考试的箱形图(下)
图 4-4 中的上方图表显示了|exams.npy|中三个考试分数集的箱形图。这三个考试分数集的第一个在图 4-4 的下方再次绘制,并附有描述图表部分的标签。
箱形图为我们提供了数据的可视化摘要。图 4-4 中下方的图表中的盒子表示第一四分位数(Q1)和第三四分位数(Q3)之间的范围。Q3 和 Q1 之间的数值差异称为四分位距(IQR)。IQR 越大,数据在中位数周围的分布越广泛。注意,这次分数位于 y 轴上。我们本可以轻松地将图表设置为水平,但垂直显示是默认设置。中位数(Q2)标记在盒子中间附近。箱形图中没有显示均值。
箱线图包含了另外两条线,胡须,尽管 Matplotlib 称它们为 飞行器。如图所示,它们是 Q3 上方或 Q1 下方 1.5 倍 IQR 的值。最后,有一些圈圈标记为“可能的离群值”。根据惯例,位于胡须之外的值被认为是 可能的离群值,这意味着它们可能代表错误数据,要么是手动输入错误,要么更有可能是现在从故障传感器收到的错误数据。例如,CCD 相机上的亮点或坏点像素可能会被视为离群值。在评估潜在数据集时,我们应该对离群值保持敏感,并根据最佳判断来处理它们。通常,离群值并不多,我们可以将这些样本从数据集中删除而不会造成伤害。然而,也有可能这些离群值实际上是有效的,并且高度指示某个特定类别。如果是这种情况,我们希望将它们保留在数据集中,以期模型能够有效利用它们。经验、直觉和常识必须在这里指导我们。
让我们解释一下图 Figure 4-4 中显示的三组考试成绩的顶部图表。每次胡须的顶部都在 100,这很有道理:100 是满分,数据集中确实有 100 分。注意到图中的箱子部分并没有在胡须中垂直居中。回想一下,50% 的数据值位于 Q1 和 Q3 之间,箱子中的 Q2 以上和以下各占 25%,我们可以看到数据并不完全符合正态分布;它的分布偏离了正态曲线。回头看看图 Figure 4-3 中的直方图,第一场考试的数据也确认了这一点。类似地,我们看到第二次和第三次考试的数据也偏离了正态性。因此,箱线图可以告诉我们数据集的分布与正态分布的相似程度。当我们讨论假设检验时,我们需要知道数据是否符合正态分布。
那么,可能的离群值呢,低于 Q1 – 1.5 × IQR 的值?我们知道数据集代表的是考试成绩,因此常识告诉我们,这些并不是离群值,而是特别迷茫(或懒散)的学生的有效成绩。如果数据集包含高于 100 或低于 0 的值,那些才可以合理地标记为离群值。
有时丢弃含有离群值的样本是正确的做法。然而,如果离群值是由缺失数据引起的,去除样本可能就不可行了。让我们看看对于缺失数据我们可以做些什么,为什么我们通常应该像对待瘟疫一样避免它。
缺失数据
缺失数据就是我们没有的数据。如果数据集由表示特征向量的样本组成,缺失数据表现为某些样本中一个或多个特征由于某种原因没有被测量。通常,缺失数据会以某种方式进行编码。如果值仅为正数,缺失的特征可能会用 –1 或历史上用 –999 来标记。如果特征以字符串形式给出,字符串可能为空。对于浮点值,可以使用“不是一个数字”(NaN)。NumPy 使我们能够轻松检查数组中的 NaN,通过使用np.isnan:
>>> a = np.arange(10, dtype="float64")
>>> a[3] = np.nan
>>> np.isnan(a[3])
True
>>> a[3] == np.nan
False
>>> a[3] is np.nan
False
注意,直接使用 == 或 is 来比较 np.nan 是行不通的;只有使用 np.isnan 测试才有效。
检测缺失数据是特定于数据集的。假设我们已经确信存在缺失数据,应该如何处理它?
让我们生成一个带有缺失值的小数据集,并利用现有的统计学知识来看看如何处理它们。以下代码在 missing.py 文件中。首先,我们生成一个包含 1,000 个样本的数据集,每个样本有四个特征:
N = 1000
np.random.seed(73939133)
x = np.zeros((N,4))
x[:,0] = 5*np.random.random(N)
x[:,1] = np.random.normal(10,1,size=N)
x[:,2] = 3*np.random.beta(5,2,N)
x[:,3] = 0.3*np.random.lognormal(size=N)
数据集位于 x 中。我们固定随机数种子以获取可重复的结果。第一个特征是均匀分布的。第二个特征是正态分布的,第三个特征遵循 beta 分布,第四个特征遵循对数正态分布。
目前,x 没有缺失值。让我们通过将随机元素设为 NaN 来添加一些缺失值:
i = np.random.randint(0,N, size=int(0.05*N))
x[i,0] = np.nan
i = np.random.randint(0,N, size=int(0.05*N))
x[i,1] = np.nan
i = np.random.randint(0,N, size=int(0.05*N))
x[i,2] = np.nan
i = np.random.randint(0,N, size=int(0.05*N))
x[i,3] = np.nan
现在,数据集的 5% 的值是 NaN。
如果一个大型数据集中的少数样本有缺失数据,我们可以删除这些样本而不必太担心。然而,如果 5% 的样本存在缺失数据,我们可能不想丢失这么多数据。更令人担忧的是,如果缺失数据与某个特定类别之间存在关联呢?丢弃这些样本可能会使数据集产生某种偏差,从而使得模型的效果变差。
那么,我们该怎么做呢?我们刚刚花了很多篇幅学习如何用基本的描述性统计来概括数据集。我们可以使用这些方法吗?当然可以。我们可以查看特征的分布,忽略缺失值,并利用这些分布来决定如何替换缺失数据。我们可能会天真地使用已有数据的均值,但查看分布可能会引导我们使用中位数,具体取决于分布是否远离正态分布。这似乎是箱线图的工作。幸运的是,Matplotlib 的 boxplot 函数很聪明;它会忽略 NaN。因此,生成箱线图只需要简单地调用 boxplot(x)。
图 4-5 展示了忽略 NaN 后的数据集。

图 4-5:忽略缺失值后的数据集箱线图
图 4-5 中的框图展示了特征分布的情况。特征 1 服从均匀分布,因此我们期望在均值/中位数周围有一个对称的框(均匀分布的均值和中位数是相同的)。特征 2 服从正态分布,因此我们得到一个类似于特征 1 的框结构,但由于只有 1,000 个样本,出现了一些不对称性。特征 3 的贝塔分布向其范围的上端偏斜,这在箱型图中可以看到。最后,特征 4 的对数正态分布应该向较低值偏斜,长尾部分显现为许多位于胡须上方的“异常值”,这是一个警示,告诉我们不要盲目地将这些值称为异常值。
由于我们有一些特征的分布高度不符合正态分布,我们将用中位数来替换缺失值,而不是用均值。代码非常简单:
good_idx = np.where(np.isnan(x[:,0]) == False)
m = np.median(x[good_idx,0])
bad_idx = np.where(np.isnan(x[:,0]) == True)
x[bad_idx,0] = m
这里,i 首先保存了特征 1 中非 NaN 的索引。我们使用这些索引来计算中位数(m)。接下来,我们将 i 设置为 NaN 的索引,并用中位数替换它们。我们可以对其他特征执行相同的操作,更新整个数据集,直到不再有缺失值。
我们是否对早期的分布产生了很大变化?没有,因为我们只更新了 5% 的值。例如,对于特征 3,根据贝塔分布,均值和标准差变化如下:
non-NaN mean, std = 2.169986, 0.474514
updated mean, std = 2.173269, 0.462957
故事的启示是,如果缺失数据足够多,以至于删除它会使数据集产生偏差,那么最安全的做法是用均值或中位数替换缺失值。决定使用均值还是中位数时,可以参考描述性统计、箱型图或直方图。
此外,如果数据集是标注过的,比如深度学习数据集,那么上述过程需要使用每个类别的均值或中位数来完成。否则,计算出的值可能不适用于该类别。
在剔除缺失数据后,可以在该数据集上训练深度学习模型。
相关性
有时,数据集中的特征之间会存在某种关联。如果一个特征上升,另一个特征可能也会上升,尽管这种关系不一定是简单的线性关系。或者,另一个特征可能下降——这就是负相关。描述这种关联关系的正确术语是相关性。一种用于衡量相关性的统计量是理解数据集中特征之间关系的便捷工具。
例如,不难看出大多数图像的像素之间高度相关。这意味着如果我们随机选择一个像素,再选择一个相邻的像素,那么第二个像素很可能与第一个像素相似。如果这一点不成立,图像就会显得像是随机噪声。
在传统的机器学习中,高度相关的特征是不受欢迎的,因为它们不会提供任何新的信息,只会混淆模型。特征选择的整个艺术部分是为了去除这种影响。对于现代深度学习来说,由于网络本身学习输入数据的新表示,是否存在不相关的输入变得不那么重要。这部分原因也解释了为什么图像可以作为深度网络的输入,而在旧的机器学习模型中通常完全无法使用。
无论是传统学习还是现代学习,作为总结和探索数据集的一部分,特征之间的相关性是值得检查和理解的。在本节中,我们将讨论两种类型的相关性。每种类型都会返回一个数值,衡量数据集中两个特征之间的相关性强度。
皮尔逊相关
皮尔逊相关系数 返回一个数值,r ϵ [–1, +1],表示两个特征之间 线性 相关性的强度。这里的 线性 指的是我们能多强烈地用一条直线来描述特征之间的相关性。如果相关性是这样的,一个特征的增加正好与另一个特征的增加相对应,那么相关系数就是 +1。相反,如果第二个特征的减少正好与另一个特征的增加相对应,那么相关系数是 –1。相关系数为零表示两个特征之间没有关联;它们是(可能)独立的。
我在上面的句子中加了一个 可能 这个词,因为在某些情况下,两个特征之间的非线性依赖关系可能导致皮尔逊相关系数为零。然而,这种情况并不常见,对于我们的目的来说,我们可以认为接近零的相关系数表示两个特征是独立的。相关系数越接近零,无论是正还是负,特征之间的相关性越弱。
皮尔逊相关是通过两个特征的均值或两个特征乘积的均值来定义的。输入是两个特征,数据集中的两列。我们将这些输入称为 X 和 Y,其中大写字母表示数据值的向量。请注意,由于这两个特征来自数据集,X[i] 和 Y[i] 是成对出现的,意味着它们都来自同一个特征向量。
皮尔逊相关系数的公式是

我们引入了一种新的但常用的符号表示法。X 的均值是 X 的 期望,记作 E(X)。因此,在公式 4.5 中,我们看到 X 的均值 E(X) 和 Y 的均值 E(Y)。正如我们所怀疑的那样,E(XY) 是 X 和 Y 的乘积的均值,逐元素计算。同样,E(X²) 是 X 与其自身的乘积的均值,而 E(X)² 是 X 的均值的平方。有了这个符号表示法,我们可以轻松编写自己的函数来计算两个特征向量的皮尔逊相关系数:
import numpy as np
def pearson(x,y):
exy = (x*y).mean()
ex = x.mean()
ey = y.mean()
exx = (x*x).mean()
ex2 = x.mean()**2
eyy = (y*y).mean()
ey2 = y.mean()**2
return (exy - ex*ey)/(np.sqrt(exx-ex2)*np.sqrt(eyy-ey2))
pearson 函数直接实现了 公式 4.5。
让我们设置一个情境,可以使用 pearson 并将其与 NumPy 和 SciPy 提供的函数进行比较。接下来的代码,包括上面定义的 pearson 函数,都在 correlation.py 文件中。
首先,我们将创建三个相关的向量 x、y 和 z。我们假设这些是数据集中的特征,因此 x[0] 与 y[0] 和 z[0] 配对。我们需要的代码是
np.random.seed(8675309)
N = 100
x = np.linspace(0,1,N) + (np.random.random(N)-0.5)
y = np.random.random(N)*x
z = -0.1*np.random.random(N)*x
注意,我们再次固定了 NumPy 的伪随机种子,以确保输出是可重复的。第一个特征 x 是从零到一的噪声线。第二个特征 y 跟踪 x,但由于与一个 0, 1) 之间的随机值相乘,它也是有噪声的。最后,z 与 x 是负相关的,因为它的系数是 -0.1。
[图 4-6 上方的图表顺序绘制了三个特征值,以观察它们如何相互跟踪。下方的图表则显示了三个特征作为配对点,其中一个值在 x 轴上,另一个值在 y 轴上。

图 4-6:三个特征按顺序展示它们如何相互跟踪(上图),以及将特征作为配对的散点图(下图)
NumPy 用于计算皮尔逊相关系数的函数是 np.corrcoef。与我们的版本不同,这个函数返回一个矩阵,显示传递给它的所有变量对之间的相关性。例如,使用我们的 pearson 函数,我们得到如下的 x、y 和 z 之间的相关系数:
pearson(x,y): 0.682852
pearson(x,z): -0.850475
pearson(y,z): -0.565361
NumPy 返回以下结果,其中 x、y 和 z 被堆叠为一个 3 × 100 的数组:
>>> d = np.vstack((x,y,z))
>>> print(np.corrcoef(d))
[[ 1. 0.68285166 -0.85047468]
[ 0.68285166 1. -0.56536104]
[-0.85047468 -0.56536104 1. ]]
对角线对应于每个特征与自身的相关性,显然是完美的,因此为 1.0。x 与 y 之间的相关性位于元素 0,1 处,并与我们的 pearson 函数值匹配。同样,x 与 z 之间的相关性位于元素 0,2,y 与 z 之间的相关性位于元素 1,2。还要注意,矩阵是对称的,这与我们预期的相符,因为 corr(X, Y) = corr(Y, X)。
SciPy 的相关函数是 stats.pearsonr,其功能类似于我们的函数,但会返回一个 p 值和 r 值。我们将在本章后面讨论 p 值。我们使用返回的 p 值作为一个无相关系统产生计算得到的相关值的概率。对于我们的示例特征,p 值几乎等于零,这意味着没有合理的可能性是一个无相关的系统生成了这些特征。
我们之前提到过,对于图像来说,附近的像素通常是高度相关的。让我们看看这对于一张示例图像是否真的成立。我们将使用 sklearn 中提供的中国地图图像,并将绿色带的特定行作为配对向量。我们将计算两行相邻行、远离的行和一个随机向量的相关系数:
>>> from sklearn.datasets import load_sample_image
>>> china = load_sample_image('china.jpg')
>>> a = china[230,:,1].astype("float64")
>>> b = china[231,:,1].astype("float64")
>>> c = china[400,:,1].astype("float64")
>>> d = np.random.random(640)
>>> pearson(a,b)
0.8979360
>>> pearson(a,c)
-0.276082
>>> pearson(a,d)
-0.038199
比较第 230 行和第 231 行可以看出它们高度正相关。比较第 230 行和第 400 行则显示出较弱的负相关性。最后,正如我们所预期的,与随机向量的相关性接近于零。
皮尔逊相关系数使用广泛,因此你常常会看到它仅被称为相关系数。现在让我们来看第二个相关性函数,并看看它与皮尔逊系数有何不同。
斯皮尔曼相关性
我们将要探讨的第二个相关性度量是斯皮尔曼相关系数,ρ ϵ [–1, +1]。这是一个基于特征值排名的度量,而不是基于值本身。
为了对X进行排名,我们用X中每个值在X的排序版本中的索引替代每个值。如果X是
[86, 62, 28, 43, 3, 92, 38, 87, 74, 11]
那么,排名是
[7, 5, 2, 4, 0, 9, 3, 8, 6, 1]
因为当X排序时,86 排在第八位(从零开始计数),而 3 排在第一位。
皮尔逊相关性寻找的是线性关系,而斯皮尔曼则寻找输入之间的任何单调关联。
如果我们已经有了特征值的排名,那么斯皮尔曼系数就是

其中,n是样本数量,d = 排名(X) – 排名(Y)是成对的X和Y值的排名差异。请注意,只有在排名唯一时,方程式 4.6 才有效(即X或Y中没有重复值)。
为了计算方程式 4.6 中的d,我们需要对X和Y进行排名,并使用排名的差异。斯皮尔曼相关性是排名的皮尔逊相关性。
上面的示例展示了 Spearman 相关性的实现方法:
import numpy as np
def spearman(x,y):
n = len(x)
t = x[np.argsort(x)]
rx = []
for i in range(n):
rx.append(np.where(x[i] == t)[0][0])
rx = np.array(rx, dtype="float64")
t = y[np.argsort(y)]
ry = []
for i in range(n):
ry.append(np.where(y[i] == t)[0][0])
ry = np.array(ry, dtype="float64")
d = rx - ry
return 1.0 - (6.0/(n*(n*n-1)))*(d**2).sum()
为了获得排名,我们首先需要对X(t)进行排序。然后,对于X(x)中的每个值,我们通过np.where找到它在t中的位置,并取第一个元素,第一个匹配项。在构建了rx列表后,我们将其转换为浮点 NumPy 数组。对于Y,我们做同样的事情来获得ry。有了排名,d被设置为它们的差值,然后使用方程式 4.6 返回斯皮尔曼ρ值。
请注意,这个版本的斯皮尔曼相关性受限于方程式 4.6,当X或Y中没有重复值时应使用。在本节中的示例使用了随机浮点数值,因此完全重复的概率非常低。
我们将比较我们的spearman实现与 SciPy 版本的stats.spearmanr。像 SciPy 版本的皮尔逊相关性一样,stats.spearmanr返回一个p-值。我们将忽略它。让我们看看我们的函数如何比较:
>>> from scipy.stats import spearmanr
>>> print(spearman(x,y), spearmanr(x,y)[0])
0.694017401740174 0.6940174017401739
>>> print(spearman(x,z), spearmanr(x,z)[0])
-0.8950855085508551 -0.895085508550855
>>> print(spearman(y,z), spearmanr(y,z)[0])
-0.6414041404140414 -0.6414041404140414
我们与 SciPy 函数在浮点值的最后几位完全一致。
重要的是要记住皮尔逊相关性和斯皮尔曼相关性之间的根本区别。例如,考虑线性斜坡与 Sigmoid 函数之间的相关性:
ramp = np.linspace(-20,20,1000)
sig = 1.0 / (1.0 + np.exp(-ramp))
print(pearson(ramp,sig))
print(spearman(ramp,sig))
这里,ramp 从 -20 增加到 20,而 sig 则呈现出 S 型曲线(“S” 曲线)。皮尔逊相关系数会偏高,因为随着 x 的增加,ramp 和 sig 都在增加,但这种关系并非完全线性。运行示例后得到的结果是
0.905328
1.0
这表明皮尔逊相关系数为 0.9,但斯皮尔曼相关系数为 1.0,因为每次 ramp 增加时,sig 也只会增加,而没有其他变化。斯皮尔曼相关捕捉到了参数之间的非线性关系,而皮尔逊相关仅仅揭示了这一点。如果我们正在分析一个为传统机器学习算法准备的数据集,斯皮尔曼相关可能帮助我们决定保留哪些特征,丢弃哪些特征。
这标志着我们对统计学在描述和理解数据中的应用的探讨结束。接下来,让我们学习如何通过假设检验来解释实验结果,并回答像“这两组数据样本是否来自同一母体分布?”这样的问题。
假设检验
我们有两组各 50 名学生学习细胞生物学。由于学生是随机分配的,因此我们没有理由认为两组之间存在显著差异。组 1 参加了讲座,并额外完成了一系列结构化的计算机练习;组 2 仅参加了讲座。两组都参加了相同的期末考试,得到了表 4-1 中的测试成绩。我们想知道让学生进行计算机练习是否对他们的期末考试成绩有所影响。
表 4-1: 组 1 和 组 2 的测试成绩
| 组 1 | 81 80 85 87 83 87 87 90 79 83 88 75 87 92 78 80 83 91 82 88 89 92 97 82 79 82 82 85 89 91 83 85 77 81 90 87 82 84 86 79 84 85 90 84 90 85 85 78 94 100 |
|---|---|
| 组 2 | 92 82 78 74 86 69 83 67 85 82 81 91 79 82 82 88 80 63 85 86 77 94 85 75 77 89 86 71 82 82 80 88 72 91 90 92 95 87 71 83 94 90 78 60 76 88 91 83 85 73 |
图 4-7 展示了表 4-1 的数据箱线图。
为了了解两组之间的最终测试成绩是否有显著变化,我们需要进行假设检验。我们将使用的检验方法被称为 假设检验,这是现代科学中的一个关键环节。

图 4-7:来自表 4-1 的数据箱线图
假设检验是一个广泛的主题,内容过于庞大,无法在这里提供全面介绍。由于这是一本关于深度学习的书,我们将重点讨论深度学习研究者可能遇到的情境。我们将仅考虑两种假设检验:针对方差不同的非配对样本的 t 检验(一个参数检验)和 Mann-Whitney U 检验(一个非参数检验)。随着我们深入了解这些检验的含义,我们将理解为什么选择这两种检验,以及什么是 参数检验 和 非参数检验。
要成功进行假设检验,我们需要明确什么是假设,因此我们首先会解决这个问题,并解释为何我们会限制考虑的假设检验类型。掌握了假设的概念后,我们将依次讨论 t 检验和 Mann-Whitney U 检验,并以表 4-1 中的数据作为示例。让我们开始吧。
假设
为了了解两组数据是否来自相同的母体分布,我们可以查看摘要统计量。图 4-7 显示了第 1 组和第 2 组的箱型图。看起来这两个组的均值和标准差不同。我们怎么知道的呢?箱型图显示了中位数的位置,而“须”部分告诉我们一些关于方差的信息。这两者结合起来表明均值可能不同,因为中位数不同,并且这两组数据都在中位数周围对称。须之间的距离暗示了标准差。那么,让我们用数据集的均值来提出假设。
在假设检验中,我们有两个假设。第一个称为零假设(H[0]),即这两个数据集来自相同的母体分布,意味着它们没有显著的区别。第二个假设是备择假设(H[a]),即这两个组来自不同的分布。由于我们将使用均值,H[0]的意思是,这些数据实际上是来自生成这些数据的母体的均值是相同的。类似地,如果我们拒绝了H[0],我们就隐式接受了H[a],并声明我们有证据表明均值存在差异。由于我们没有真正的总体均值,因此我们将使用样本均值和标准差来代替。
假设检验并不能明确告诉我们H[0]是否成立。相反,它为我们提供了拒绝或接受零假设的证据。记住这一点是至关重要的。
我们正在测试两个独立样本,看看它们是否可以认为来自相同的母体分布。虽然有其他方式可以使用假设检验,但在深度学习中我们很少遇到。对于眼前的任务,我们需要样本均值和样本标准差。我们的测试将提出这样一个问题:“这两个数据集的均值之间有显著差异吗?”
我们只关心检测这两组数据是否来自相同的母体分布,因此我们将做出另一个简化假设:我们所有的检验都将是双侧的,或者说是双尾的。当我们使用像接下来要描述的 t 检验时,我们将我们的计算检验统计量(t 值)与检验统计量的分布进行比较,并询问我们计算得到的 t 值有多可能。如果我们想了解检验统计量高于或低于分布的某一部分,那么我们就是在进行双侧检验。如果我们只关心检验统计量是否超过某个特定值,而不关心它是否低于该值,或者反之,那么我们就是在进行单侧检验。
让我们列出我们的假设和方法:
-
我们有两组独立的数据需要进行比较。
-
我们并没有假设数据的标准差是否相同。
-
我们的原假设是数据集的母体分布的均值相同,H[0] : μ[1] = μ[2]。我们将使用样本均值
和样本标准差(s[1],s[2])来帮助我们决定接受或拒绝 H[0]。 -
假设检验假定数据是独立同分布(i.i.d.)的。我们将其解释为数据是一个公平的随机样本。
在理解了这些假设之后,让我们从 t 检验开始,这是最广泛使用的假设检验方法。
t 检验
t 检验依赖于t,即检验统计量。这个统计量与 t 分布进行比较,并用来生成一个p-值,这个概率值将帮助我们得出关于 H[0] 的结论。t 检验和相关的 z 检验有着丰富的历史背景,我们在这里将忽略这些背景。我鼓励你在有机会时深入了解假设检验,或者至少复习一些关于正确进行假设检验和解读其结果的有价值文章。
t 检验是一个参数检验。这意味着数据和数据分布有一些假设。具体来说,t 检验假设除了数据是 i.i.d.之外,数据的分布(直方图)是正态分布。我们之前已经提到,许多物理过程似乎遵循正态分布,因此有理由认为实际测量的数据也可能遵循正态分布。
有很多方法可以测试数据集是否服从正态分布,但我们将忽略这些方法,因为关于此类测试的有效性存在一些争议。相反,我(有些鲁莽地)建议你同时使用 t 检验和 Mann-Whitney U 检验来帮助决定是否接受或拒绝 H[0]。使用这两种检验可能会导致它们得出相反的结论,一种检验表明存在反对零假设的证据,而另一种则表明没有证据。通常情况下,如果非参数检验表明有证据反对 H[0],那么无论 t 检验的结果如何,应该接受这一证据。如果 t 检验结果反对 H[0],但 Mann-Whitney U 检验结果没有,并且你认为数据是正态的,那么你可能也会接受 t 检验结果。
t 检验有不同的版本。我们在上面明确说明了将使用一个针对不同大小和方差的数据集设计的版本。我们将使用的 t 检验版本是 Welch’s t-test,它不假设两组数据的方差相同。
Welch’s t-test 的 t 分数是

n[1] 和 n[2] 是两组的大小。
t 分数和一个称为 自由度 的相关值,类似于但又不同于上面提到的自由度,生成适当的 t 分布曲线。为了获得 p-值,我们计算曲线下的面积,包括正负 t 分数下方的面积,并将其返回。由于概率分布的积分为 1,正负 t 分数值到正负无穷大的尾部的总面积即为 p-值。我们将使用下面的自由度来帮助我们计算置信区间。
p-值告诉我们什么?它告诉我们,在零假设成立的情况下,看到我们看到的两个均值之间的差异,或更大的差异的概率。通常,如果这个概率低于我们设定的某个阈值,我们就拒绝零假设,并说我们有证据表明这两组的均值不同——它们来自不同的母体分布。当我们拒绝 H[0] 时,我们说这个差异是 统计显著的。接受/拒绝 H[0] 的阈值称为 α,通常 α = 0.05 是一个典型的(尽管有问题的)值。我们将在下面讨论为什么 0.05 是一个有问题的值。
需要记住的一点是,p 值假设零假设为真。它告诉我们,在假设真实 H[0] 的情况下,至少能得到我们所观察到的样本间差异或更大的差异的概率。如果 p 值较小,这有两种可能的含义:(1)零假设是错误的,或者(2)随机抽样误差导致我们得到了超出预期的样本。由于 p 值假设 H[0] 为真,一个较小的 p 值有助于我们减少对(2)的信心,并增加对(1)可能正确的信心。然而,p 值本身不能确认(1);需要其他知识的支持。
我提到使用 α = 0.05 是有问题的。问题的主要原因是它过于宽松,导致过多地拒绝真实的零假设。根据 James Berger 和 Thomas Sellke 在其文章《检验点零假设:P 值与证据的不可调和性》(美国统计学会期刊,1987 年)中的论述,当 α = 0.05 时,大约 30% 的真实零假设会被拒绝。而当我们使用类似 α ≤ 0.001 的值时,错误拒绝真实零假设的几率降到不到 3%。这个故事的寓意是,p < 0.05 不是魔法,坦率地说,对于单个研究来说是不可令人信服的。应该寻找至少为 0.001,或者最好更小的高度显著的 p 值。在 p = 0.05 时,所得到的只是一个提示,应该重复实验。如果重复实验的 p 值都接近 0.05,那么拒绝零假设才开始显得有意义。
置信区间
与 p 值一起,你通常还会看到 置信区间(CIs)。置信区间提供了一个范围,在这个范围内我们认为群体均值的真实差异会存在,且具有对比两个数据集重复样本的给定置信度。通常,我们报告的是 95% 置信区间。我们的假设检验通过检验样本均值的差异是否为零来检查均值是否相等。因此,任何包含零的置信区间都在告诉我们,不能拒绝零假设。
对于 Welch 的 t 检验,自由度是

我们可以用它来计算置信区间,

其中 t[1–α/2,df] 是临界值,t 值是根据给定的置信水平 (α) 和自由度 df 从公式 4.7 得出的。
我们应该如何解读 95% 置信区间?有一个总体值:群体均值之间的真实差异。95% 置信区间的含义是,如果我们能够从生成两个数据集的分布中反复抽取样本,95% 计算出的置信区间将包含均值之间的真实差异。它不是指包含均值差异的范围,且在 95% 的置信度下。
除了检查零是否在置信区间(CI)内外,CI 还有其他用途,因为它的宽度能告诉我们效应的大小。在这里,效应与均值之间的差异有关。我们可能基于p-值获得统计显著的差异,但效应可能在实际中没有意义。当效应较大时,CI 会很窄,因为小的 CI 表示一个包围真实效应的狭窄范围。我们稍后将看到,在可能的情况下,如何计算另一个有用的效应度量。
最后,一个小于 α 的 p-值也会有一个不包含 H[0] 的 CI[α]。换句话说,p-值和置信区间告诉我们的信息是同步的——它们不会互相矛盾。
效应大小
拥有一个统计上显著的 p-值是一回事。另一个问题是由该 p-值表示的差异在现实世界中是否有意义。一个流行的效应大小度量是 Cohen 的 d。对于我们来说,因为我们使用的是 Welch 的 t 检验,Cohen 的 d 是通过计算得到的

Cohen 的 d 通常是主观解释的,尽管我们也应该报告数值。主观上,效应的大小可以是
| d | 效应 |
|---|---|
| 0.2 | 小 |
| 0.5 | 中等 |
| 0.8 | 大 |
Cohen 的 d 是合理的。均值之间的差异是思考效应的一种自然方式。通过均值方差进行缩放后,它被置于一个一致的范围内。从公式 4.9 中我们可以看到,尽管与统计显著结果相关的 p-值可能导致一个小的效应,但该效应在实际中可能没有真正的重要性。
评估测试成绩
让我们把以上所有内容结合起来,应用 t 检验到我们来自表格 4-1 的测试数据。你可以在文件 hypothesis.py 中找到代码。我们首先生成数据集:
np.random.seed(65535)
a = np.random.normal(85,6,50).astype("int32")
a[np.where(a > 100)] = 100
b = np.random.normal(82,7,50).astype("int32")
b[np.where(b > 100)] = 100
再次强调,我们使用固定的 NumPy 伪随机数种子来确保可重复性。我们让 a 作为从均值为 85、标准差为 6.0 的正态分布中抽样的样本。我们从均值为 82、标准差为 7.0 的正态分布中选择 b。对于两者,我们将超过 100 的值限制为 100。毕竟,这些是考试成绩,没有额外学分。
接下来,我们应用 t 检验:
from scipy.stats import ttest_ind
t,p = ttest_ind(a,b, equal_var=False)
print("(t=%0.5f, p=%0.5f)" % (t,p))
我们得到 (t = 2.40234, p = 0.01852)。t 是统计量,p 是计算得出的 p-值。它是 0.019,小于 0.05,但仅小了一倍。我们得到了一个较弱的结果,这告诉我们可能想要拒绝零假设,并认为两组 a 和 b 来自不同的分布。当然,我们知道它们确实来自不同的分布,因为我们是自己生成的,但看到检验结果朝正确的方向发展也让人放心。
请注意,我们从 SciPy 导入的函数是 ttest_ind。这是用于独立样本(即未配对样本)的函数。此外,请注意我们在调用时添加了 equal_var=False。这是使用 Welch 的 t 检验的方法,它不假设两个数据集之间的方差相等。我们知道它们不相等,因为 a 的标准差为 6.0,而 b 的标准差为 7.0。
为了获取置信区间,我们将编写一个 CI 函数,因为 NumPy 和 SciPy 没有包含此功能。该函数直接实现了 方程 4.7 和 4.8:
from scipy import stats
def CI(a, b, alpha=0.05):
n1, n2 = len(a), len(b)
s1, s2 = np.std(a, ddof=1)**2, np.std(b, ddof=1)**2
df = (s1/n1 + s2/n2)**2 / ((s1/n1)**2/(n1-1) + (s2/n2)**2/(n2-1))
tc = stats.t.ppf(1 - alpha/2, df)
lo = (a.mean()-b.mean()) - tc*np.sqrt(s1/n1 + s2/n2)
hi = (a.mean()-b.mean()) + tc*np.sqrt(s1/n1 + s2/n2)
return lo, hi
临界 t 值通过调用 stats.t.ppf 来获取,传入 α/2 值和正确的自由度 df。临界 t 值是 97.5 百分位值,对于 α = 0.05,这是 百分位点函数 (ppf) 返回的值。我们将其除以 2,以涵盖 t 分布的尾部。
对于我们的测试示例,置信区间为 [0.56105, 5.95895]。注意,这个区间不包含零,因此置信区间也表明结果具有统计学意义。然而,范围相当大,因此这不是一个特别稳健的结果。置信区间的范围可能单独解释起来较为困难,因此,最后,让我们计算 Cohen 的 d,看看考虑到置信区间的宽度,这是否合理。在代码中,我们实现了 方程 4.9:
def Cohen_d(a,b):
s1 = np.std(a, ddof=1)**2
s2 = np.std(b, ddof=1)**2
return (a.mean() - b.mean()) / np.sqrt(0.5*(s1+s2))
我们得到 d = 0.48047,对应于中等效应大小。
Mann-Whitney U 检验
t 检验假设源数据的分布是正态分布。如果数据不是正态分布,我们应当使用 非参数检验。非参数检验不对数据的基础分布做任何假设。Mann-Whitney U 检验,有时也称为 Wilcoxon 秩和检验,是一种非参数检验,用来帮助判断两组不同的数据是否来自相同的母体分布。Mann-Whitney U 检验不直接依赖于数据的值,而是使用数据的排名。
该检验的零假设如下:从第 1 组随机选出的值大于从第 2 组随机选出的值的概率为 0.5。我们可以稍微思考一下这个假设。如果数据来自同一个母体分布,那么我们应当期望从这两组中随机选出的任意一对值在大小上没有偏好。
备择假设是,随机从第 1 组选出的值大于随机从第 2 组选出的值的概率不为 0.5。请注意,这里并没有说明该概率是大于还是小于 0.5,仅仅是它不是 0.5;因此,我们将使用的 Mann-Whitney U 检验是双尾的。
Mann-Whitney U 检验的零假设与 t 检验的零假设不同。对于 t 检验,我们在问两组之间的均值是否相同。(实际上,我们在问均值的差异是否为零。)然而,如果两组数据确实来自不同的母体分布,那么这两个零假设都是错误的,因此我们可以用 Mann-Whitney U 检验替代 t 检验,特别是当数据不是正态分布时。
为了生成 U,即 Mann-Whitney 统计量,我们首先将两组数据合并并进行排名。相同的值会被替换为该值排名与下一个排名值之间的平均值。我们还会跟踪数据源组,以便稍后可以重新分组排名。按组求和后的排名得到 R[1] 和 R[2](使用合并数据的排名)。我们计算两个值,

小的值称为 U,即检验统计量。可以从 U 生成 p-值,但需要记住上面关于 p-值含义和使用的讨论。与之前一样,n[1] 和 n[2] 是两组中的样本数。Mann-Whitney U 检验要求这两个数字中的较小者至少为 21 个样本。如果样本数不足,使用 SciPy mannwhitneyu 函数时,结果可能不可靠。
我们可以在来自表 4-1 的测试数据上运行 Mann-Whitney U 检验,
from scipy.stats import mannwhitneyu
u,p = mannwhitneyu(a,b)
print("(U=%0.5f, p=%0.5f)" % (u,p))
使用上面提到的 a 和 b 作为 t 检验的样本。这给出了 (U = 997.00000, p = 0.04058)。p-值刚好低于 0.05 的最小阈值。
a 和 b 的均值分别是 85 和 82。如果我们将 b 的均值设置为 83 或 81,p-值会发生什么变化?改变 b 的均值意味着改变 np.random.normal 的第一个参数。这样做会得到表 4-2,在这里我已经包括了所有结果,以确保完整性。
表 4-2: 不同均值的模拟测试得分的 Mann-Whitney U 检验和 t 检验结果 (n[1]=n[2]=50)
| 均值 | Mann-Whitney U | t 检验 |
|---|---|---|
| 85 对比 83 | (U=1104.50000, p=0.15839) | (t=1.66543, p=0.09959) |
| 85 对比 82 | (U=997.00000, p=0.04058) | (t=2.40234, p=0.01852) |
| 85 对比 81 | (U=883.50000, p=0.00575) | (t=3.13925, p=0.00234) |
表 4-2 对我们来说应该是有意义的。当均值接近时,更难区分它们,因此我们期望更大的 p-值。回想一下,我们每组只有 50 个样本。当均值差异增大时,p-值会降低。均值差异为三时,p-值几乎达不到显著性。当差异更大时,p-值变得真正显著——这也是我们预期的结果。
上述分析引出了一个问题:对于两组之间均值差异较小的情况,p-值如何随样本量的变化而变化?
图 4-8 展示了 25 次运行中,Mann-Whitney U 检验和 t 检验在不同样本大小下的p-值(均值 ± 标准误差),以样本均值为 85 和 84 的情况为例。

图 4-8:均值 p 值与样本大小的关系,样本均值差异为 1,
小型数据集使得当均值差异较小时,很难区分不同的情况。我们还发现,较大的样本量能够揭示出差异,无论使用哪种测试。有趣的是,在图 4-8 中,尽管底层数据是正态分布的,Mann-Whitney U 检验的p-值却低于 t 检验。这与传统观念相反,通常情况下情况应该是相反的。
图 4-8 展示了大样本测试在检测实际差异方面的强大威力。当样本量足够大时,即使是微弱的差异也能变得显著。然而,我们需要与效应大小相平衡。当每组有 1,000 个样本时,我们得到一个统计学上显著的p-值,但 Cohen’s d约为 0.13,表示效应很弱。一项大样本研究可能会发现一个显著的效应,但其效应如此微弱,以至于在实践中几乎没有意义。
总结
本章涉及了你在深入学习深度学习过程中会遇到的统计学关键方面。具体来说,我们学习了不同类型的数据,以及如何确保数据对建立模型有用。接着,我们学习了总结性统计量,并通过示例帮助我们理解数据集。理解我们的数据是成功进行深度学习的关键。我们探讨了不同类型的均值,学习了变异性度量,并看到了通过箱型图可视化数据的实用性。
缺失数据是深度学习的一个痛点。在本章中,我们探讨了如何弥补缺失数据。接下来,我们讨论了相关性,如何检测和衡量数据集元素之间的关系。最后,我们介绍了假设检验。我们将讨论限制为在深度学习中最可能遇到的情景,学习了如何应用 t 检验和 Mann-Whitney U 检验。假设检验让我们接触到了p-值。我们看了它的示例,并讨论了如何正确地解释它。
在下一章,我们将告别统计学,深入探索线性代数的世界。线性代数是我们实现神经网络的基础。
第五章:线性代数**

从形式上讲,线性代数是研究线性方程的学科,其中变量的最高次幂为一。然而,对于我们的目的来说,线性代数指的是多维数学对象——如向量和矩阵——以及对它们的运算。这就是线性代数在深度学习中的典型应用方式,也是实现深度学习算法的程序中数据操作的方式。通过做出这个区分,我们丢弃了大量有趣的数学内容,但由于我们的目标是理解深度学习中使用和应用的数学,因此我们希望能够得到宽恕。
在本章中,我将介绍在深度学习中使用的对象,特别是标量、向量、矩阵和张量。正如我们所看到的,这些对象实际上都是不同阶数的张量。我们将从数学和符号的角度讨论张量,然后使用 NumPy 进行实验。NumPy 明确设计用于向 Python 添加多维数组,它们是我们在本章中处理的数学对象的良好,但并不完全相同的类比。
我们将在本章的大部分时间里学习如何进行张量运算,这对于深度学习至关重要。实现高性能深度学习工具包的大部分努力,都涉及找到尽可能高效地进行张量运算的方法。
标量、向量、矩阵和张量
让我们来介绍一下我们的角色阵容。我会将它们与 Python 变量和 NumPy 数组相关联,展示我们如何在代码中实现这些对象。然后,我会展示张量与几何之间的一个便捷概念映射。
标量
即使你不熟悉这个词,从你第一次学习计数那天起,你就已经知道标量是什么了。标量就是一个数字,比如 7、42 或 π。在表达式中,我们将使用x来表示标量,也就是用于表示变量的普通符号。对于计算机来说,标量是一个简单的数值变量:
>>> s = 66
>>> s
66
向量
向量是一个一维数字数组。从数学上讲,向量有方向性,可以是水平或垂直的。如果是水平的,它就是一个行向量。例如,

是一个由三个元素或分量构成的行向量。请注意,我们将使用x,一个加粗的小写字母,来表示向量。
从数学上讲,向量通常被认为是列向量,

其中y有四个分量,因此它是一个四维(4D)向量。请注意,在公式 5.1 中我们使用了方括号,而在公式 5.2 中我们使用了圆括号。两种符号表示方法都是可以接受的。
在代码中,我们通常将向量实现为一维数组:
>>> import numpy as np
>>> x = np.array([1,2,3])
>>> print(x)
[1 2 3]
>>> print(x.reshape((3,1)))
[[1]
[2]
[3]]
在这里,我们使用reshape将三元素行向量转变为一个三行一列的列向量。
向量的分量通常被解释为沿一组坐标轴的长度。例如,三分量向量可能用于表示三维空间中的一个点。在这个向量中,
x = [x, y, z]
x 可能是沿 x 轴的长度,y 是沿 y 轴的长度,z 是沿 z 轴的长度。这些是笛卡尔坐标,旨在唯一地标识三维空间中的所有点。
然而,在深度学习和机器学习中,向量的分量通常在几何意义上彼此不相关。相反,它们被用来表示特征,即模型用来尝试得出有用输出(如类别标签或回归值)的一些样本的性质。也就是说,表示特征集合的向量,称为特征向量,有时会从几何角度进行思考。例如,一些机器学习模型,如k-最近邻算法,将向量解释为几何空间中的某个坐标。
你经常会听到深度学习领域的人讨论问题的特征空间。特征空间指的是可能输入的集合。模型的训练集需要准确地表示模型在使用时会遇到的可能输入的特征空间。从这个意义上讲,特征向量是一个点,一个在这个n维空间中的位置,其中n是特征向量中的特征数量。
矩阵
矩阵是一个二维数组:

A的元素通过行号和列号进行下标标记。矩阵A有三行四列,所以我们说它是一个 3 × 4 矩阵,其中 3 × 4 是矩阵的阶。请注意,A使用的是从 0 开始的下标。数学文本通常从 1 开始,但越来越多的情况下,使用 0,这样就不会在数学表示和矩阵的计算机表示之间产生偏移。还要注意,我们将使用A,一个加粗的大写字母,表示一个矩阵。
在代码中,矩阵表示为二维数组:
>>> A = np.array([[1,2,3],[4,5,6],[7,8,9]])
>>> print(A)
[[1 2 3]
[4 5 6]
[7 8 9]]
>>> print(np.arange(12).reshape((3,4)))
[[ 0 1 2 3]
[ 4 5 6 7]
[ 8 9 10 11]]
在 Python 中,要获取矩阵A的元素a[12],我们写作A[1,2]。请注意,当我们打印数组时,它们周围有一个额外的[和]。NumPy 使用这些括号来表示二维数组可以被视为行向量,其中每个元素本身就是一个向量。在 Python 术语中,这意味着矩阵可以被视为一个子列表的列表,每个子列表的长度相同。当然,这正是我们最初定义A的方式。
我们可以将向量看作是只有一行或一列的矩阵。一个三元素的列向量是一个 3 × 1 矩阵:它有三行和一列。类似地,一个四元素的行向量就像一个 1 × 4 矩阵:它有一行和四列。我们稍后将利用这一观察结果。
张量
标量没有维度,向量有一个维度,矩阵有两个维度。正如你可能猜到的,我们并不止步于此。一个具有超过两维的数学对象通常被称为张量。在必要时,我们会像这样表示张量:T,作为无衬线大写字母。
张量的维度数量定义了其阶,这与矩阵的阶不同。一个 3D 张量的阶是 3。矩阵是一个阶为 2 的张量。向量是一个阶为 1 的张量,而标量是一个阶为 0 的张量。当我们在第九章讨论数据流过深度神经网络时,我们会看到许多工具包使用阶为 4(或更多)的张量。
在 Python 中,具有三维或更多维度的 NumPy 数组用于实现张量。例如,我们可以如下定义一个阶为 3 的张量:
>>> t = np.arange(36).reshape((3,3,4))
>>> print(t)
[[[ 0 1 2 3]
[ 4 5 6 7]
[ 8 9 10 11]]
[[12 13 14 15]
[16 17 18 19]
[20 21 22 23]]
[[24 25 26 27]
[28 29 30 31]
[32 33 34 35]]]
在这里,我们使用np.arange定义t为一个包含 36 个元素的向量,数值为 0 . . . 35。然后,我们立即将这个向量reshape成一个 3 × 3 × 4 的张量(3 × 3 × 4 = 36)。可以将 3 × 3 × 4 的张量理解为包含三张 3 × 4 图像的堆叠。如果我们记住这一点,以下语句就能理解:
>>> print(t[0])
[[ 0 1 2 3]
[ 4 5 6 7]
[ 8 9 10 11]]
>>> print(t[0,1])
[4 5 6 7]
>>> print(t[0,1,2])
6
请求t[0]将返回堆叠中的第一张 3 × 4图像。那么,接着请求t[0,1]将返回第一张图像的第二行,正如它所做的那样。最后,我们通过请求图像编号(0)、行编号(1)和该行的元素(2)来访问t的单个元素。
将张量的维度分配给一个个更小的集合是帮助记住维度含义的便捷方式。例如,我们可以如下定义一个阶为 5 的张量:
>>> w = np.zeros((9,9,9,9,9))
>>> w[4,1,2,0,1]
0.0
但是,要求w[4,1,2,0,1]是什么意思呢?确切含义取决于应用。例如,我们可能认为w代表一个书架。第一个索引选择书架,第二个选择书架上的书。接着,第三个索引选择书中的页面,第四个选择页面上的行。最后一个索引选择行中的单词。因此,w[4,1,2,0,1]就是要求从书架的第五层架子上的第二本书的第三页的第一行中获取第二个单词,从右到左读取索引可以理解这一点。
书架类比确实有其局限性。NumPy 数组具有固定的维度,这意味着如果w是一个书架,它有九层架子,每一层架子上有正好九本书。同样,每本书有正好九页,每一页有九行。最后,每一行有正好九个单词。NumPy 数组通常在计算机中使用连续内存,因此在定义数组时每个维度的大小是固定的。这样做,并选择特定的数据类型,如无符号整数,使得定位数组中的元素成为一个使用简单公式计算基址偏移量的索引操作。这也是 NumPy 数组比 Python 列表快得多的原因。
任何低于 n 阶的张量都可以通过提供缺失的长度为 1 的维度,表示为一个 n 阶的张量。我们在上面看到过一个例子,当我说一个 m 维向量可以看作是一个 1 × m 或 m × 1 矩阵时。通过添加一个长度为 1 的缺失维度,1 阶张量(向量)变成了 2 阶张量(矩阵)。
作为一个极端例子,我们可以将一个标量(0 阶张量)当作一个 5 阶张量来处理,如下所示:
>>> t = np.array(42).reshape((1,1,1,1,1))
>>> print(t)
[[[[[42]]]]]
>>> t.shape
(1, 1, 1, 1, 1)
>>> t[0,0,0,0,0]
42
在这里,我们将标量 42 重新塑形为一个 5 阶张量(一个五维 [5D] 数组),每个轴的长度都是 1。注意,NumPy 告诉我们张量 t 有五个维度,42 周围的 [[[[[ 和 ]]]]] 表明了这一点。请求 t 的形状验证了它是一个 5D 张量。最后,作为张量,我们可以通过指定所有维度 t[0,0,0,0,0] 来获取它包含的唯一元素的值。我们经常会使用这个添加新维度的技巧。事实上,在 NumPy 中,有一种直接执行此操作的方法,您将在使用深度学习工具包时看到:
>>> t = np.array([[1,2,3],[4,5,6]])
>>> print(t)
[[1 2 3]
[4 5 6]]
>>> w = t[np.newaxis,:,:]
>>> w.shape
(1, 2, 3)
>>> print(w)
[[[1 2 3]
[4 5 6]]]
在这里,我们通过使用 np.newaxis 创建一个长度为 1 的新轴,将 t(一个 2 阶张量,即矩阵)转变为 3 阶张量。这就是为什么 w.shape 返回 (1,2,3) 而不是 (2,3),就像 t 那样。
张量与三阶及以下几何体之间存在类比,有助于我们可视化不同阶数之间的关系:
| 阶数(维度) | 张量名称 | 几何名称 |
|---|---|---|
| 0 | 标量 | 点 |
| 1 | 向量 | 线 |
| 2 | 矩阵 | 平面 |
| 3 | 张量 | 体积 |
请注意,我在表格中使用了张量(tensor)这个常见的定义。似乎没有标准化的名称来表示 3 阶张量。
在这一部分中,我们将深度学习中的数学对象与多维数组相关联,因为它们在代码中是如何实现的。通过这样做,我们丢弃了很多数学内容,但保留了理解深度学习所需的部分。现在让我们继续,看看如何在表达式中使用张量。
张量的算术运算
本节的目的是详细说明张量的操作,特别强调 1 阶张量(向量)和 2 阶张量(矩阵)的操作。我们将假设标量的操作已经非常熟练。
我们将从我所称之为数组操作开始,指的是像 NumPy 这样的工具包在各种维度的数组上执行的逐元素操作。然后我们将继续讲解特定于向量的操作。这为关键的矩阵乘法话题奠定了基础。最后,我们将讨论块矩阵。
数组操作
到目前为止,我们使用 NumPy 工具包的方式展示了所有常见的标量算术运算都可以直接转换到多维数组的世界中。这包括加法、减法、乘法、除法和指数运算等标准操作,以及对数组应用函数。在所有这些情况下,标量操作都逐元素地应用到数组的每个元素上。这里的示例将为本节的其余部分定下基调,并且也会让我们探索一些我们尚未提到的 NumPy 广播规则。
首先让我们定义一些数组来进行操作:
>>> a = np.array([[1,2,3],[4,5,6]])
>>> b = np.array([[7,8,9],[10,11,12]])
>>> c = np.array([10,100,1000])
>>> d = np.array([10,11])
>>> print(a)
[[1 2 3]
[4 5 6]]
>>> print(b)
[[ 7 8 9]
[10 11 12]]
>>> print(c)
[ 10 100 1000]
>>> print(d)
[10 11]
对于维度匹配的数组,逐元素的算术运算是非常简单的:
>>> print(a+b)
[[ 8 10 12]
[14 16 18]]
>>> print(a-b)
[[-6 -6 -6]
[-6 -6 -6]]
>>> print(a*b)
[[ 7 16 27]
[40 55 72]]
>>> print(a/b)
[[0.14285714 0.25 0.33333333]
[0.4 0.45454545 0.5 ]]
>>> print(b**a)
[[ 7 64 729]
[ 10000 161051 2985984]]
这些结果都很容易理解;NumPy 将所需的操作应用到每个数组的对应元素上。两个矩阵(a和b)之间的逐元素乘法通常称为Hadamard 积。(你会在深度学习文献中不时遇到这个术语。)
NumPy 工具包将逐元素操作的概念扩展到了它所称的广播。在广播时,NumPy 应用一些规则,我们通过示例可以看到这些规则,其中一个数组会广播到另一个数组上,生成有意义的输出。
我们之前已经遇到过在标量和数组操作时的一种广播形式。在那个例子中,标量值被广播到数组的每个值上。
在第一个示例中,尽管a是一个 2 × 3 矩阵,NumPy 通过应用广播允许它与三分量向量c进行操作:
>>> print(a+c)
[[ 11 102 1003]
[ 14 105 1006]]
>>> print(c*a)
[[ 10 200 3000]
[ 40 500 6000]]
>>> print(a/c)
[[0.1 0.02 0.003]
[0.4 0.05 0.006]]
在这里,三分量向量c已经广播到 2 × 3 矩阵a的行上。NumPy 识别到a和c的最后一个维度都是三维的,因此可以将向量传递到矩阵上,生成所需的输出。在查看深度学习代码时,尤其是 Python 代码,你会看到像这样的情况。有时需要一些思考,并结合在 Python 提示符下进行一些实验,才能理解发生了什么。
我们能否将d(一个包含两个元素的向量)广播到a(一个 2 × 3 矩阵)上?如果我们尝试用和将c广播到a相同的方式进行操作,我们将会失败:
>>> print(a+d)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: operands could not be broadcast together with shapes (2,3) (2,)
然而,NumPy 的广播规则支持长度为 1 的维度。d的形状是 2,它是一个包含两个元素的向量。如果我们将d重塑为一个形状为 2 × 1 的二维数组,那么我们就给 NumPy 提供了它所需要的信息:
>>> d = d.reshape((2,1))
>>> d.shape
(2, 1)
>>> print(a+d)
[[11 12 13]
[15 16 17]]
我们现在看到 NumPy 将d加到a的列上。
让我们回到数学的世界,来看一下向量的运算。
向量运算
向量在代码中通常表示为一组数值,这些数值可以解释为沿一组坐标轴的值。在这里,我们将定义几种独特的向量运算。
大小
从几何意义上讲,我们可以理解向量具有方向和长度。它们通常被画成箭头,我们将在第六章中看到向量图的一个例子。人们称向量的长度为它的大小。因此,我们将考虑的第一个向量运算是计算其大小。对于一个具有n个分量的向量x,其大小的公式是

在方程 5.3 中,向量周围的双竖线表示它的大小。你也常常会看到有人使用单竖线。单竖线也用于表示绝对值;我们通常依赖上下文来区分这两者。
方程 5.3 是从哪里来的?考虑一个二维向量x = (x, y)。如果x和y分别是沿 x 轴和 y 轴的长度,我们就会看到x和y构成了一个直角三角形的两边。这个直角三角形的斜边长度就是向量的长度。因此,根据毕达哥拉斯定理,以及比他早得多的巴比伦人,这个长度是
,广义到n维度后,变为方程 5.3。
单位向量
现在我们已经能够计算向量的大小,我们可以引入一种有用的向量形式,叫做单位向量。如果我们将向量的各个分量除以它的大小,我们就得到了一个与原始向量方向相同但大小为 1 的向量,这就是单位向量。对于向量v,方向相同的单位向量是

向量上方的帽子用来标识它是一个单位向量。让我们来看一个具体的例子。我们的示例向量是v = (2, –4,3)。因此,方向与v相同的单位向量是

在代码中,我们计算单位向量的方式如下:
>>> v = np.array((2, -4, 3))
>>> u = v / np.sqrt((v*v).sum())
>>> print(u)
[ 0.37139068 -0.74278135 0.55708601 ]
在这里,我们利用了这样一个事实:要平方v的每个元素,我们通过将其与自身相乘(逐元素操作),然后通过调用sum将各个分量加在一起,得到平方后的大小。
向量转置
我们之前提到过,行向量可以被看作是 1 × n矩阵,而列向量是n × 1 矩阵。将行向量转变为列向量,反之亦然,被称为进行转置操作。在第六章中,我们将看到转置也适用于矩阵。在符号上,我们将向量y的转置表示为y^⊤。因此,我们有

当然,我们不仅仅局限于三个分量。
在代码中,我们以多种方式转置向量。正如我们上面所看到的,我们可以使用reshape将向量重塑为 1 × n 或 n × 1 矩阵。我们还可以在向量上调用transpose方法,或者小心地使用转置简写。让我们看一下所有这些方法的示例。首先,定义一个 NumPy 向量,看看reshape如何将其变成一个 3 × 1 列向量和一个 1 × 3 行向量,而不是一个包含三个元素的普通向量:
>>> v = np.array([1,2,3])
>>> print(v)
[1 2 3]
>>> print(v.reshape((3,1)))
[[1]
[2]
[3]]
>>> print(v.reshape((1,3)))
[[1 2 3]]
注意第一次调用print(v)和最后调用reshape((1,3))后的区别。输出现在多了一对括号,以表示前导维度为一。
接下来,我们对v进行转置操作:
>>> print(v.transpose())
[1 2 3]
>>> print(v.T)
[1 2 3]
这里,我们看到调用transpose或T并没有改变v。这是因为v的形状只是3,而不是(1,3)或(3,1)。如果我们显式地将v改为 1 × 3 矩阵,我们会看到transpose和T产生了期望的效果:
>>> v = v.reshape((1,3))
>>> print(v.transpose())
[[1]
[2]
[3]]
>>> print(v.T)
[[1]
[2]
[3]]
这里,v从行向量变成了列向量,正如我们所期望的那样。那么,教训是,在 NumPy 代码中,要小心向量的实际维度。大多数时候,我们可以不太注意,但有时我们需要明确区分普通向量、行向量和列向量。
内积
也许最常见的向量运算是内积,或者常被称为点积。在符号上,两个向量的内积写作


这里,θ是两个向量之间的角度(如果从几何角度解释)。内积的结果是标量。〈a, b〉符号经常出现,尽管在深度学习文献中,a • b 点积符号似乎更为常见。a^⊤b 矩阵乘法符号明确指出了如何计算内积,但我们将在讨论矩阵乘法时解释其含义。目前,求和告诉我们所需的知识:两个长度为 n 的向量的内积是 n 个分量的乘积之和。
向量与自身的内积是其大小的平方:
a • a = ||a||²
内积是可交换的,
a • b = b • a
和分配的,
a • (b + c) = a • b + a • c
但不是结合的,因为第一个内积的结果是标量,而不是向量,乘以标量的向量不是内积。
最后,请注意,当向量之间的角度为 90 度时,内积为零;这是因为 cos θ 为零(方程式 5.5)。这意味着这两个向量彼此垂直,或者说是正交的。
让我们来看几个内积的例子。首先,我们将直观地实现方程式 5.4:
>>> a = np.array([1,2,3,4])
>>> b = np.array([5,6,7,8])
>>> def inner(a,b):
... s = 0.0
... for i in range(len(a)):
... s += a[i]*b[i]
... return s
...
>>> inner(a,b)
70.0
然而,由于 a 和 b 是 NumPy 数组,我们知道我们可以更高效地计算:
>>> (a*b).sum()
70
或者,可能是最有效的做法,我们可以通过使用 np.dot 让 NumPy 为我们计算:
>>> np.dot(a,b)
70
在深度学习代码中,你会经常看到 np.dot。它的功能不仅仅是计算内积,正如我们下面将看到的。
方程 5.5 告诉我们两向量之间的角度为:

在代码中,这可以计算为:
>>> A = np.sqrt(np.dot(a,a))
>>> B = np.sqrt(np.dot(b,b))
>>> t = np.arccos(np.dot(a,b)/(A*B))
>>> t*(180/np.pi)
14.335170291600924
这告诉我们,在将 t 从弧度转换后,a 和 b 之间的角度大约为 14°。
如果我们考虑三维空间中的向量,我们可以看到正交向量之间的点积为零,这意味着它们之间的角度是 90°:
>>> a = np.array([1,0,0])
>>> b = np.array([0,1,0])
>>> np.dot(a,b)
0
>>> t = np.arccos(0)
>>> t*(180/np.pi)
90.0
之所以如此,是因为 a 是沿 x 轴的单位向量,b 是沿 y 轴的单位向量,我们知道它们之间有一个直角。
有了内积作为工具,让我们看看如何用它将一个向量投影到另一个向量上。
投影
一个向量在另一个向量上的投影计算的是第一个向量在第二个向量方向上的分量。a 在 b 上的投影为:

图 5-1 图示了二维向量投影的含义。

图 5-1:a 在二维中投影到 b 的图形表示
投影找到的是 a 在 b 方向上的分量。请注意,a 在 b 上的投影与 b 在 a 上的投影是不一样的。
因为我们在分子中使用了内积,所以可以看到,将一个向量投影到与其正交的另一个向量上,其结果为零。第一个向量在第二个向量的方向上没有任何分量。再想一想 x 轴和 y 轴。我们使用笛卡尔坐标系的整个原因是这两条轴,或者在三维空间中的三条轴,都是互相正交的;一个轴的任何部分都不在其他轴的方向上。这使得我们能够通过指定沿这些轴的分量来确定任何点,以及从原点到该点的向量。稍后,当我们讨论特征向量和主成分分析时,我们将看到如何将一个物体分解成互相正交的分量,见 第六章。
在代码中,计算投影是直接的:
>>> a = np.array([1,1])
>>> b = np.array([1,0])
>>> p = (np.dot(a,b)/np.dot(b,b))*b
>>> print(p)
[1\. 0.]
>>> c = np.array([-1,1])
>>> p = (np.dot(c,b)/np.dot(b,b))*b
>>> print(p)
[-1\. -0.]
在第一个例子中,a 指向从 x 轴向上 45° 的方向,而 b 指向 x 轴。我们会预期 a 的投影沿 x 轴方向,这也是它所做的(p)。在第二个例子中,c 指向从 x 轴开始 135° = 90° + 45° 的方向。因此,我们会预期 c 沿 b 的分量应沿 x 轴方向,但与 b 相反方向,这正是它所做的。
注意
将 c 沿 b 投影得到了 y 轴分量 –0。负号是 IEEE 754 浮点数表示的一个特性。内部表示的尾数(有效数字)为零,但符号仍然可以指定,从而导致时不时地输出负零。关于计算机数字格式的详细解释,包括浮点数,请参阅我的书《数字与计算机》(Springer-Verlag,2017)。
现在让我们继续讨论两个向量的外积。
外积
两个向量的内积返回一个标量值。两个向量的外积则返回一个矩阵。注意,与内积不同,外积不要求两个向量具有相同的维度。具体来说,对于具有 m 个分量的向量 a 和具有 n 个分量的向量 b,外积是通过将 a 中的每个元素与 b 中的每个元素相乘所形成的矩阵,如下所示。

ab^⊤ 符号表示通过矩阵乘法计算外积。请注意,这个符号不同于内积 a^⊤b,并且它假设 a 和 b 是列向量。外积没有一个统一的操作符符号,主要是因为它可以通过矩阵乘法很容易地指定,并且比点积更少见。然而,当外积与二元操作符一起呈现时,⊗ 符号似乎是最常用的。
在代码中,NumPy 提供了一个外积函数:
>>> a = np.array([1,2,3,4])
>>> b = np.array([5,6,7,8])
>>> np.dot(a,b)
70
>>> np.outer(a,b)
array([[ 5, 6, 7, 8],
[10, 12, 14, 16],
[15, 18, 21, 24],
[20, 24, 28, 32]])
我们在讨论内积时使用了 a 和 b。如预期的那样,np.dot 给出了 a•b 的标量输出。然而,np.outer 函数返回一个 4 × 4 的矩阵,其中每一行是向量 b 依次乘以向量 a 中的每个元素,首先是 1,然后是 2,接着是 3,最后是 4。因此,a 中的每个元素都与 b 中的每个元素相乘。结果矩阵是 4 × 4,因为 a 和 b 都有四个元素。
笛卡尔积
两个向量的外积与两个集合 A 和 B 的笛卡尔积之间有直接的类比。笛卡尔积是一个新集合,其中的每个元素是 A 和 B 中元素的一对可能配对。所以,如果 A={1,2,3,4} 且 B={5,6,7,8},则笛卡尔积可以表示为

在这里,我们看到如果我们将每个条目替换为对的乘积,就得到了我们在 NumPy np.outer 中看到的相应的向量积。同时,请注意,当处理集合时,笛卡尔积通常使用 × 来表示。
外积能够混合所有输入组合的能力已被应用于深度学习中的神经协同过滤和视觉问答等应用。这些功能通过先进的网络执行,这些网络可以进行推荐或回答关于图像的文本问题。外积表现为两种不同嵌入向量的混合。嵌入是由网络低层生成的向量,例如传统卷积神经网络(CNN)中的倒数第二个全连接层输出到 softmax 层之前的层。嵌入层通常被认为学会了网络输入的新表示。它可以被看作是将复杂的输入(如图像)映射到一个减少的空间,维度从几百到几千不等。
叉积
我们的最终向量-向量运算符是叉积。这个运算符仅在三维空间(ℝ³)中定义。a和b的叉积是一个新的向量,它垂直于包含a和b的平面。请注意,这并不意味着a和b本身是垂直的。叉积定义为

其中
是单位向量,θ是a和b之间的夹角。
的方向由右手法则确定。用右手时,将食指指向a的方向,中指指向b的方向。然后,大拇指将指向
的方向。公式 5.6 给出了叉积向量在ℝ³中的实际分量。
NumPy 通过np.cross实现叉积运算:
>>> a = np.array([1,0,0])
>>> b = np.array([0,1,0])
>>> print(np.cross(a,b))
[0 0 1]
>>> c = np.array([1,1,0])
>>> print(np.cross(a,c))
[0 0 1]
在第一个例子中,a沿 x 轴方向,b沿 y 轴方向。因此,我们预期叉积会垂直于这些轴,结果确实如此:叉积指向 z 轴。第二个例子表明,a和b是否彼此垂直并不重要。在这里,c与 x 轴成 45°角,但a和c仍在 xy 平面内。因此,叉积仍然沿 z 轴方向。
叉积的定义涉及到 sin θ,而内积则使用 cos θ。当两个向量彼此正交时,内积为零。而叉积则在两个向量方向相同的时候为零,并且在向量垂直时达到最大值。上面的第二个 NumPy 示例之所以成立,是因为c的大小是
且 sin
。因此,
因子相互抵消,最终叉积的大小为 1,因为a是单位向量。
叉乘广泛应用于物理学和其他科学中,但在深度学习中较少使用,因为它仅限于三维空间。然而,如果你打算深入研究深度学习文献,仍然应该对它有所了解。
这就结束了我们对向量-向量运算的讨论。让我们离开一维世界,继续探讨所有深度学习中最重要的运算:矩阵乘法。
矩阵乘法
在前一部分中,我们讨论了如何通过多种方式相乘两个向量:Hadamard 积、内积(点积)、外积和叉积。在这一部分,我们将研究矩阵的乘法,回顾一下行向量和列向量本身就是只有一行或一列的矩阵。
矩阵乘法的性质
我们将很快定义矩阵乘法运算,但在此之前,让我们先看一下矩阵乘法的性质。设A、B和C为矩阵。那么,根据代数约定,将符号放在一起表示乘法,
(AB)C = A(BC)
这意味着矩阵乘法是结合的。其次,矩阵乘法是分配的:


然而,通常情况下,矩阵乘法是非交换的:
AB ≠ BA
正如在方程 5.8 中所看到的,右侧加法下的矩阵乘法与左侧加法下的矩阵乘法产生不同的结果,正如在方程 5.7 中所示。这也解释了我们为什么要展示方程 5.7 和方程 5.8;矩阵乘法可以从左或右进行,结果会不同。
如何相乘两个矩阵
要计算AB,知道A必须在B的左边,我们首先需要验证这两个矩阵是否兼容。只有当A的列数与B的行数相同时,才可以进行矩阵相乘。因此,如果A是一个n × m矩阵,B是一个m × k矩阵,那么乘积AB可以计算出来,并且将是一个新的n × k矩阵。
要计算这个乘积,我们需要进行一系列的内积运算,将A的行向量与B的列向量相乘。图 5-2 展示了一个 3 × 3 矩阵A和一个 3 × 2 矩阵B的计算过程。

图 5-2:将一个 3 × 3 矩阵与一个 3 × 2 矩阵相乘
在图 5-2 中,输出矩阵的第一行是通过计算 A 的第一行与 B 每一列的内积得到的。输出矩阵的第一个元素显示的是 A 的第一行与 B 的第一列相乘的结果。输出矩阵的其余第一行是通过将 A 的第一行与 B 剩余列的点积重复计算得到的。
让我们通过实际数字来展示图 5-2 中矩阵的示例:

注意,AB 是已定义的,但 BA 不是,因为我们无法将一个 3 × 2 的矩阵与一个 3 × 3 的矩阵相乘。B 的列数必须与 A 的行数相同。
另一种理解矩阵乘法的方法是考虑每个输出矩阵元素的构成。例如,如果 A 是 n × m 且 B 是 m × p,我们知道矩阵乘积作为 n × p 的矩阵 C 存在。我们通过计算得到输出元素。

对于i = 0, . . . , n − 1 和 j = 0, . . . , p − 1。在上面的示例中,我们通过求和 a[20]b[01] + a[21]b[11] + a[22]b[21] 来找到 c[21],这符合方程 5.9,其中i = 2, j = 1 和 k = 0, 1, 2。
方程 5.9 告诉我们如何找到单个输出矩阵元素。如果我们对i和j进行循环,就可以找到整个输出矩阵。这意味着矩阵乘法有一个直接的实现方法:
def matrixmul(A,B):
I,K = A.shape
J = B.shape[1]
C = np.zeros((I,J), dtype=A.dtype)
for i in range(I):
for j in range(J):
for k in range(K):
C[i,j] += A[i,k]*B[k,j]
return C
我们假设参数A和B是兼容的矩阵。我们设定输出矩阵C的行数(I)和列数(J),并将它们作为C元素的循环限制。我们创建输出矩阵C,并将其数据类型与A相同。接下来开始三重循环。i循环遍历输出矩阵的所有行。接下来的j循环遍历当前行的列,最内层的k循环则涵盖了A和B元素的组合,如方程 5.9 所示。当所有循环结束后,我们返回矩阵乘积C。
函数matrixmul有效,它可以计算矩阵乘积。然而,在实现方面,它相当简单。实际上,存在更先进的算法,并且在使用编译代码时,简单方法还有许多优化。如下面所示,NumPy 支持矩阵乘法,并且在内部使用高度优化的编译代码库,其性能远远超过上面提到的简单代码。
内积和外积的矩阵表示法
现在我们已经准备好理解上面的矩阵表示法,用于两个向量的内积,a⊤***b***,以及外积,***ab***⊤。在第一种情况中,由于转置,我们有一个 1 × n 的行向量和一个 n × 1 的列向量。算法要求形成行向量和列向量的内积,得到一个 1 × 1 的输出矩阵,即一个单一的标量数值。请注意,a 和 b 中必须有 n 个分量。
对于外积,我们左边有一个 n × 1 的列向量,右边有一个 1 × m 的行向量。因此,我们知道输出矩阵是 n × m。如果 m = n,我们将得到一个 n × n 的输出矩阵。一个行数和列数相等的矩阵称为 方阵。这些矩阵有特殊的性质,其中一些将在第六章中看到。
要通过矩阵乘法找到两个向量的外积,我们将 a 的每一行的每个元素与 b 的每一列相乘,作为一个行向量*。

在这里,b^⊤ 的每一列,一个标量数值,都会沿着 a 的行传递,从而形成两个向量元素之间的每一个可能的乘积。
我们已经了解了如何手动执行矩阵乘法。现在让我们看看 NumPy 如何支持矩阵乘法。
NumPy 中的矩阵乘法
NumPy 提供了两个可以用于矩阵乘法的不同函数。第一个是我们已经见过的 np.dot,尽管我们到目前为止只用它来计算向量的内积。第二个是 np.matmul,它也是在 Python 3.5 及更高版本中使用 @ 二元运算符时调用的。使用这两个函数进行矩阵乘法时,结果都符合预期。然而,NumPy 有时将 1D 数组与行向量或列向量处理得不同。
我们可以使用shape来判断一个 NumPy 数组是 1D 数组、行向量还是列向量,如清单 5-1 所示:
>>> av = np.array([1,2,3])
>>> ar = np.array([[1,2,3]])
>>> ac = np.array([[1],[2],[3]])
>>> av.shape
(3,)
>>> ar.shape
(1, 3)
>>> ac.shape
(3, 1)
清单 5-1:NumPy 向量
在这里,我们看到一个具有三个元素的 1D 数组 av 的形状与三个组件的行向量 ar 或三个组件的列向量 ac 不同。然而,这些数组都包含相同的三个整数:1,2 和 3。
让我们进行一个实验,帮助我们理解 NumPy 如何实现矩阵乘法。我们将测试np.dot,但是如果我们使用 np.matmul 或 @ 运算符,结果是一样的。我们需要一些向量和矩阵来操作。然后,我们将把它们的组合应用于 np.dot 并考虑输出,如果该组合的操作未定义,可能会发生错误。
让我们创建我们需要的数组、向量和矩阵:
a1 = np.array([1,2,3])
ar = np.array([[1,2,3]])
ac = np.array([[1],[2],[3]])
b1 = np.array([1,2,3])
br = np.array([[1,2,3]])
bc = np.array([[1],[2],[3]])
A = np.array([[1,2,3],[4,5,6],[7,8,9]])
B = np.array([[9,8,7],[6,5,4],[3,2,1]])
如果我们记住清单 5-1 的结果,物体的形状应该从定义中可以辨认出来。我们还将定义两个 3 × 3 的矩阵,A 和 B。
接下来,我们将定义一个辅助函数,包装对 NumPy 的调用,以便捕获任何错误:
def dot(a,b):
try:
return np.dot(a,b)
except:
return "fails"
该函数调用 np.dot 并在调用失败时返回词语 fails。 表 5-1 显示了给定输入组合下 dot 的输出。
表 5-1 展示了 NumPy 有时如何将一维数组与行向量或列向量区分对待。请参见 表 5-1 中的 a1,A 与 ar,A 以及 A,ac 之间的区别。A,ac 的输出是我们在数学上期望看到的结果,其中列向量 a[c] 被 A 从左侧乘。
np.dot 和 np.matmul 之间有什么实际区别吗?是的,有一些区别。对于一维和二维数组,二者没有区别。然而,对于大于二维的数组,它们处理的方式有所不同,尽管我们这里不会涉及到这些。此外,np.dot 允许其参数之一是标量,并将另一个参数的每个元素都乘以该标量。使用 np.matmul 乘以标量会抛出错误。
表 5-1: 应用 dot 或 matmul 到不同类型参数的结果
| 参数 | np.dot 或 np.matmul 的结果 |
|---|---|
a1,b1 |
14(标量) |
a1,br |
失败 |
a1,bc |
[14](1 维向量) |
ar,b1 |
[14](1 维向量) |
ar,br |
失败 |
ar,bc |
[14](1 × 1 矩阵) |
ac,b1 |
失败 |
ac,br |
![]() |
ac,bc |
失败 |
A,a1 |
[14 32 50](3 维向量) |
A,ar |
失败 |
A,ac |
![]() |
a1,A |
[30 36 42](3 维向量) |
ar,A |
[30 36 42](1 × 3 矩阵) |
ac,A |
失败 |
A,B |
![]() |
克罗内克积
我们将要讨论的矩阵乘法的最终形式是两个矩阵的克罗内克积或矩阵直积。在计算矩阵乘积时,我们混合了矩阵的各个元素,将它们相乘。对于克罗内克积,我们通过将一个矩阵的元素与另一个矩阵整个相乘,生成一个比输入矩阵更大的输出矩阵。克罗内克积也是引入块矩阵(即由较小矩阵(块)构成的矩阵)概念的便捷方式。
例如,如果我们有三个矩阵

我们可以将一个块矩阵M定义为以下内容。

其中,M 的每个元素是一个较小的矩阵,堆叠在一起。
我们可以通过一个涉及块矩阵的视觉示例最简单地定义克罗内克积。A 和 B 的克罗内克积,通常表示为 A ⊗ B,是

对于A,一个m × n 矩阵。这是一个块矩阵,因为有B,因此,当完全展开时,Kronecker 积会产生一个比A或B都要大的矩阵。请注意,与矩阵乘法不同,Kronecker 积对于任意大小的A和B矩阵都是定义好的。例如,使用来自方程 5.10 的A和B,Kronecker 积为:

上面注意到我们使用了⊗表示 Kronecker 积。这是惯例,尽管符号⊗有时被误用,也用于其他含义。例如,我们曾用它表示两个向量的外积。NumPy 通过np.kron支持 Kronecker 积。
总结
在本章中,我们介绍了深度学习中使用的数学对象:标量、向量、矩阵和张量。然后,我们探讨了与张量的算术运算,特别是与向量和矩阵的运算。我们看到如何对这些对象进行操作,无论是在数学上还是通过 NumPy 代码。
然而,我们对线性代数的探索还没有完成。在接下来的章节中,我们将更深入地探讨矩阵及其属性,讨论我们能够做的一些重要事情。
第六章:更多的线性代数**

在本章中,我们将继续探索线性代数的概念。虽然其中一些概念与深度学习的关系仅是间接的,但它们是你最终会遇到的数学内容。可以将本章看作是假定的背景知识。
具体来说,我们将更深入地了解方阵的性质以及对方阵进行的操作,并介绍一些你在深度学习文献中常遇到的术语。之后,我将介绍方阵的特征值和特征向量的概念,并讲解如何找到它们。接下来,我们将探讨向量范数以及在深度学习中经常遇到的其他距离度量方式。到那个时候,我将介绍协方差矩阵这一重要概念。
我们将在本章最后通过演示主成分分析(PCA)和奇异值分解(SVD)来总结这一章节。这些常用的方法非常依赖于本章中介绍的概念和操作符。我们将了解 PCA 是什么,如何执行 PCA,以及从机器学习的角度它能给我们带来什么。类似地,我们将使用 SVD,看看如何用它来实现 PCA,以及如何计算矩形矩阵的伪逆。
方阵
方阵在线性代数的世界中占有特殊的地位。让我们更详细地探讨它们。这里使用的术语将在深度学习及其他领域中经常出现。
为什么是方阵?
如果我们将一个矩阵与一个列向量相乘,我们将得到另一个列向量作为输出:

从几何角度解释,2 × 4 的矩阵将 4 × 1 的列向量(ℝ⁴ 中的一个点)映射到了 ℝ² 中的一个新点。这个映射是线性的,因为点的值仅仅是与 2 × 4 矩阵的元素相乘;没有非线性操作,比如将向量的分量升幂等操作。
从这个角度看,我们可以使用矩阵在不同空间之间进行点的变换。如果矩阵是方阵,比如 n × n,则映射是从 ℝ^(n) 到 ℝ^(n)。例如,考虑

其中,点 (11, 12, 13) 被映射到点 (74, 182, 209),这两个点都位于 ℝ³ 中。
使用矩阵将点从一个空间映射到另一个空间,使得可以使用旋转矩阵将一组点绕某一轴旋转。对于简单的旋转,我们可以在二维中定义矩阵,

并且在三维空间中,

旋转通过一个角度 θ 来进行,对于三维空间,旋转是围绕 x 轴、y 轴或 z 轴进行的,如下标所示。
使用矩阵,我们可以创建一个仿射变换。仿射变换将一组点映射到另一组点,使得原空间中的直线上的点在映射后的空间中仍然位于直线上。该变换是
y = Ax + b
仿射变换将矩阵变换 A 与平移 b 结合,以将一个向量 x 映射到一个新的向量 y。我们可以通过将 A 放在矩阵的左上角,并将 b 添加为右侧的新列,将此操作合并为一个单一的矩阵乘法。底部的一行全是零,右侧列中有一个 1,构成了增广的变换矩阵。对于仿射变换矩阵

和平移向量

我们得到

这种形式将一个点,(x, y),映射到一个新的点,(x′, y′)。
这一操作与在实现神经网络时有时使用的 偏置技巧 完全相同,即通过在增广权重矩阵中包含一个额外的特征向量并将其设为 1 来“埋藏”偏置。事实上,我们可以将前馈神经网络视为一系列仿射变换,其中变换矩阵是层与层之间的权重矩阵,偏置向量提供了平移。每一层的激活函数改变了层与层之间的线性关系。正是这种非线性使得网络能够学习映射输入的全新方式,从而最终输出反映网络设计要学习的功能关系。
我们使用方阵来将点从一个空间映射回同一空间,例如围绕某个轴旋转它们。现在让我们来看看方阵的一些特殊性质。
转置、迹和幂
第五章向我们展示了通过向量转置在列向量和行向量之间移动。转置操作不仅限于向量,它对任何矩阵都有效,通过沿主对角线翻转行和列。例如,

转置是通过翻转矩阵元素的索引形成的:
a[ij] ← a[ij], i = 0, 1, …… , n − 1, j = 0, 1, …… , m − 1
这将一个 n × m 矩阵转换为一个 m × n 矩阵。注意,方阵在转置操作下其阶数保持不变,且主对角线上的值不变。
在 NumPy 中,你可以对数组调用 transpose 方法,但由于转置操作非常常见,也有一种简写方式(.T)。例如,
>>> import numpy as np
>>> a = np.array([[1,2,3],[4,5,6],[7,8,9]])
>>> print(a)
[[1 2 3]
[4 5 6]
[7 8 9]]
>>> print(a.transpose())
[[1 4 7]
[2 5 8]
[3 6 9]]
>>> print(a.T)
[[1 4 7]
[2 5 8]
[3 6 9]]
迹 是另一个常见的操作,应用于方阵:

作为一个算子,迹有某些特性。例如,它是线性的:
tr(A + B) = trA + trB
同样成立的是 tr(A) = tr(A^T) 和 tr(AB) = tr(BA)。
NumPy 使用 np.trace 快速计算矩阵的迹,并使用 np.diag 返回矩阵的对角线元素作为一维数组。
(a[00], a[11],……, a[n−1,n−1])
对于一个 n × n 或 n × m 矩阵。
对于 NumPy 来说,矩阵不需要是方阵,也可以返回其对角线上的元素。尽管从数学上讲,迹通常只适用于方阵,但 NumPy 会计算任何矩阵的迹,返回对角线元素的和:
>>> b = np.array([[1,2,3,4],[5,6,7,8]])
>>> print(b)
[[1 2 3 4]
[5 6 7 8]]
>>> print(np.diag(b))
[1 6]
>>> print(np.trace(b))
7
最后,你可以将一个方阵与其自身相乘,这意味着你可以通过将方阵自乘n次来将其提升到整数幂n。请注意,这与将矩阵的元素提升到幂不同。例如,

矩阵的幂遵循与将任何数字提升到幂相同的规则:
An**A**m = A^(n+m)
(An*)(m*) = A^(nm)
对于
(正整数),且A是方阵。
NumPy 提供了一种方法,比反复调用np.dot更高效地计算方阵的幂:
>>> from numpy.linalg import matrix_power
>>> a = np.array([[1,2],[3,4]])
>>> print(matrix_power(a,2))
[[ 7 10]
[15 22]]
>>> print(matrix_power(a,10))
[[ 4783807 6972050]
[10458075 15241882]]
现在让我们考虑一些你可能会遇到的特殊方阵。
特殊方阵
许多方阵(以及非方阵)已经得到了特殊的命名。有些名称非常直观,比如全是零或全是一次的矩阵,分别称为零矩阵和单位矩阵。NumPy 广泛使用这些:
>>> print(np.zeros((3,5)))
[[0\. 0\. 0\. 0\. 0.]
[0\. 0\. 0\. 0\. 0.]
[0\. 0\. 0\. 0\. 0.]]
>>> print(np.ones(3,3))
[[1\. 1\. 1.]
[1\. 1\. 1.]
[1\. 1\. 1.]]
请注意,你可以通过将单位矩阵乘以c来找到任何常数值的矩阵。
请注意,上面提到的 NumPy 默认使用 64 位浮点数矩阵,对应于 C 语言中的double类型。请参见表 1-1,了解可能的数值数据类型。在纯数学中,我们不太关心数据类型,但在深度学习中,你需要注意,避免定义比实际需要更多内存的数组。许多深度学习模型对 32 位浮点数组非常满意,它们每个元素使用的内存是 NumPy 默认值的一半。另外,许多工具包也开始使用新的或以前不常用的数据类型,比如 16 位浮点数,以便更好地利用内存。NumPy 支持 16 位浮点数,可以通过将dtype设置为float16来实现。
单位矩阵
到目前为止,最重要的特殊矩阵是单位矩阵。这是一个方阵,所有对角线上的元素都为 1:

单位矩阵在乘法中起着类似于数字 1 的作用。因此,
AI = IA = A
对于一个n × n的方阵A和一个n × n的单位矩阵I,在必要时,我们会添加下标以表示单位矩阵的阶数,例如,I[n]。
NumPy 使用np.identity或np.eye生成给定大小的单位矩阵:
>>> a = np.array([[1,2],[3,4]])
>>> i = np.identity(2)
>>> print(i)
[[1\. 0.]
[0\. 1.]]
>>> print(a @ i)
[[1\. 2.]
[3\. 4.]]
仔细观察上面的例子。从数学角度看,我们说将方阵与相同阶数的单位矩阵相乘会返回原矩阵。然而,NumPy 做了一件我们可能不希望发生的事情。矩阵 a 被定义为整数元素,因此它的数据类型是 int64,这是 NumPy 对整数的默认数据类型。然而,由于我们没有显式地为 np.identity 指定数据类型,NumPy 默认使用了 64 位浮点数。因此,矩阵乘法(@)操作 a 和 i 返回了 a 的浮点版本。这种微妙的数据类型变化可能对后续计算很重要,因此,再次提醒,在使用 NumPy 时我们需要关注数据类型。
无论你使用 np.identity 还是 np.eye 都没关系。事实上,np.identity 实际上只是 np.eye 的一个封装。
三角矩阵
有时,你会听到关于三角矩阵的说法。三角矩阵有两种:上三角和下三角。正如你从名字中可以直观推测的那样,上三角矩阵是指在主对角线或其上方有非零元素的矩阵,而下三角矩阵则只有在主对角线或其下方才有元素。例如,

是一个上三角矩阵,而

是一个下三角矩阵。一个只在主对角线上有元素的矩阵,毫不奇怪,是一个对角矩阵。
NumPy 有两个函数,np.triu 和 np.tril,分别返回给定矩阵的上三角部分或下三角部分。所以,
>>> a = np.arange(16).reshape((4,4))
>>> print(a)
[[ 0 1 2 3]
[ 4 5 6 7]
[ 8 9 10 11]
[12 13 14 15]]
>>> print(np.triu(a))
[[ 0 1 2 3]
[ 0 5 6 7]
[ 0 0 10 11]
[ 0 0 0 15]]
>>> print(np.tril(a))
[[ 0 0 0 0]
[ 4 5 0 0]
[ 8 9 10 0]
[12 13 14 15]]
我们在深度学习中不常使用三角矩阵,但我们在线性代数中确实使用它们,部分原因是计算行列式,接下来我们将讨论这一点。
行列式
我们可以将方阵 n × n 的行列式看作是一个将方阵映射到标量的函数。在深度学习中,行列式的主要用途是计算矩阵的特征值。我们将在本章后面看到这是什么意思,但现在可以将特征值看作是与矩阵相关的特殊标量值。行列式还可以告诉我们矩阵是否有逆矩阵,下面我们也会看到这一点。在符号上,我们用竖线表示矩阵的行列式。例如,如果 A 是一个 3 × 3 的矩阵,我们写行列式为

我们明确声明行列式的值是一个标量(ℝ 的元素)。所有方阵都有一个行列式。现在,我们来考虑一下行列式的一些性质:
-
如果 A 的任一行或列为零,则 det(A) = 0。
-
如果 A 的任意两行相同,则 det(A) = 0。
-
如果 A 是上三角或下三角矩阵,那么 det
。 -
如果 A 是对角矩阵,那么 det
。 -
单位矩阵的行列式,无论大小如何,都是 1。
-
矩阵乘积的行列式等于行列式的乘积,det(AB) = det(A)det(B)。
-
det(A) = det(A^⊤)。
-
det(A^n) = det(A)^(n)。
属性 7 指出,转置操作不会改变行列式的值。属性 8 是属性 6 的一个结果。
我们有多种方法可以计算方阵的行列式。这里我们只研究一种方法,涉及使用递归公式。所有递归公式都适用于自身,就像代码中的递归函数调用自己一样。一般的思路是,每次递归都处理一个简化版的问题,最终将这些结果结合,得到大问题的解答。
例如,我们可以计算一个整数的阶乘,
n! = n(n − 1)(n − 2)(n − 3) . . . 1
如果我们注意到以下几点,它是递归的:

第一个语句说,n的阶乘是n乘以(n − 1)的阶乘。第二个语句说零的阶乘是 1。递归就是第一个语句,但没有某个条件返回值,这个递归将永远不会结束。这就是第二个语句的意义,即基准情况:它说当我们达到零时,递归结束。
这在代码中可能会更清晰。我们可以这样定义阶乘:
def factorial(n):
if (n == 0):
return 1
return n*factorial(n-1)
注意,factorial在参数减去 1 时会调用自身,除非参数为零,在这种情况下,它立即返回 1。这个代码能正常工作是因为 Python 的调用栈。调用栈跟踪所有n*factorial(n-1)的计算。当我们遇到基准情况时,所有待完成的乘法都已经完成,我们返回最终值。
因此,为了递归地计算行列式,我们需要一个递归语句,定义行列式为更简化的行列式的组合。我们还需要一个基准情况,它给出一个确定的值。对于行列式,基准情况是当我们得到一个 1 × 1 的矩阵时。对于任何 1 × 1 矩阵A,我们有
det(A) = a[00]
意味着 1 × 1 矩阵的行列式就是它包含的单一数值。
我们的计划是通过将计算分解为一个个逐渐简化的行列式来计算行列式,直到我们达到上述基准情况。为了做到这一点,我们需要一个涉及递归的语句。然而,在我们能够作出这个语句之前,我们需要定义一些内容。首先,我们需要定义矩阵的次式。矩阵A的(i, j)-次式是从A中删除第i行和第j列后剩下的矩阵。我们用A[ij]表示次式矩阵。例如,给定

然后

其中,次式A[11]是通过删除第 1 行和第 1 列,只留下下划线的数值来找到的。
其次,我们需要定义次式,即C**[ij],即A[ij]的余因子。这就是我们递归语句出现的地方。定义是
C**[ij] = (−1)^(i+j+2)det(A[ij])
余因子取决于代数余子式的行列式。注意-1 的指数,它写作i + j + 2。如果你查看大多数数学书籍,你会看到指数是i + j。我们做出了一个有意识的选择,将矩阵定义为从零开始的索引,这样数学和代码实现就可以匹配,而不至于因为偏移而出错。这里是我们选择的一个地方,这使得我们在表达上不如数学书籍中的优雅。由于我们的索引“错”了 1,我们需要将这一点加回到余因子的指数中,这样余因子使用的正负值模式才是正确的。这意味着我们需要在指数中的每个变量上加 1:i → i + 1 和 j → j + 1。这样,指数i + j → (i + 1) + (j + 1) = i + j + 2。
我们现在准备好使用余因子展开来定义A的行列式的完整递归公式。事实证明,求解一个方阵任意一行或一列的矩阵值和相应余因子的乘积并求和,将得到行列式。因此,我们将使用矩阵的第一行来计算行列式,公式如下:

你可能会想:在方程 6.3 中,递归在哪里?它出现在代数余子式的行列式中。如果A是一个n × n的矩阵,代数余子式A[ij]是一个(n − 1) × (n − 1)的矩阵。因此,为了计算余因子以找到一个n × n矩阵的行列式,我们需要知道如何计算一个(n − 1) × (n − 1)矩阵的行列式。然而,我们可以使用余因子展开来计算(n − 1) × (n − 1)的行列式,这涉及到计算一个(n − 2) × (n − 2)矩阵的行列式。这个过程会持续,直到我们得到一个 1 × 1 的矩阵。我们已经知道,1 × 1 矩阵的行列式就是它包含的唯一值。
让我们通过一个 2 × 2 矩阵来演示这个过程:

使用余因子展开,我们得到

这是 2 × 2 矩阵行列式的公式。2 × 2 矩阵的代数余子式是 1 × 1 矩阵,在这种情况下,每个余子式的值分别为d或c。
在 NumPy 中,我们使用np.linalg.det来计算行列式。例如,
>>> a = np.array([[1,2],[3,4]])
>>> print(a)
[[1 2]
[3 4]]
>>> np.linalg.det(a)
-2.0000000000000004
>>> 1*4 - 2*3
-2
代码的最后一行使用了我们之前推导出的 2 × 2 矩阵的公式作为对比。实际上,NumPy 内部并不使用递归的余因子展开来计算行列式。相反,它将矩阵分解为三个矩阵的乘积:(1)一个置换矩阵,看起来像是一个打乱的单位矩阵,每行每列只有一个 1,(2)一个下三角矩阵,以及(3)一个上三角矩阵。置换矩阵的行列式是+1 或-1,三角矩阵的行列式是对角线元素的乘积,而矩阵乘积的行列式是各个矩阵行列式的乘积。
我们可以使用行列式来判断一个矩阵是否有逆矩阵。现在让我们转到这个话题。
逆矩阵
公式 6.2 定义了单位矩阵。我们说这个矩阵像数字 1 一样作用,因此当它与一个方阵相乘时,返回的仍然是相同的方阵。对于标量乘法,我们知道对于任何数字,x ≠ 0,存在另一个数字,记作y,使得xy = 1。这个数字是x的乘法逆元。此外,我们知道y的确切值,它是 1/x = x^(−1)。
类似地,我们可能会想,既然我们有一个像数字 1 一样作用的单位矩阵,是否存在另一个方阵,记作A^(−1),对于给定的方阵A,使得
AA^(−1) = A^(−1) A = I
如果A^(−1)存在,那么它被称为A的逆矩阵,并且A被称为可逆的。对于实数,除零以外的所有数都有逆。对于矩阵来说,情况并不像实数那样简单。许多方阵没有逆。为了检查A是否有逆,我们使用行列式:det(A) = 0 表示A没有逆。进一步地,如果A^(−1)存在,那么

还要注意,(A(−1))(−1) = A,这与实数的情况相同。逆矩阵的另一个有用属性是
(AB)^(−1) = B^(−1) A^(−1)
右边乘积的顺序非常重要。最后,请注意,对角矩阵的逆仅仅是对角元素的倒数:

可以通过行变换手动计算逆矩阵,我们在这里方便地忽略了这一点,因为在深度学习中很少使用。余因子展开法也可以计算逆矩阵,但为了节省时间,我们不会在这里详细说明过程。对我们来说,重要的是知道方阵通常有逆矩阵,并且我们可以通过 NumPy 的np.linalg.inv来计算逆矩阵。
如果一个矩阵不可逆,则称该矩阵为奇异矩阵。因此,奇异矩阵的行列式为零。如果一个矩阵有逆矩阵,则它是非奇异的或非退化的矩阵。
在 NumPy 中,我们使用np.linalg.inv来计算方阵的逆。例如,
>>> a = np.array([[1,2,1],[2,1,2],[1,2,2]])
>>> print(a)
[[1 2 1]
[2 1 2]
[1 2 2]]
>>> b = np.linalg.inv(a)
>>> print(b)
[[ 0.66666667 0.66666667 -1\. ]
[ 0.66666667 -0.33333333 0\. ]
[-1. 0. 1\. ]]
>>> print(a @ b)
[[1\. 0\. 0.]
[0\. 1\. 0.]
[0\. 0\. 1.]]
>>> print(b @ a)
[[1\. 0\. 0.]
[0\. 1\. 0.]
[0\. 0\. 1.]]
注意逆矩阵(b)按预期工作,并在从左或右相乘时返回单位矩阵。
对称矩阵、正交矩阵和酉矩阵
如果对于一个方阵A,我们有
A^⊤ = A
那么A称为对称矩阵。例如,

是一个对称矩阵,因为A^⊤ = A。
注意对角矩阵是对称的,并且两个对称矩阵的乘积是可交换的:AB = BA。如果存在的话,对称矩阵的逆也是对称矩阵。
如果以下条件成立,
AA^⊤ = A^⊤ A = I
A是一个正交矩阵。如果A是正交矩阵,那么
A^(−1) = A^⊤
结果是,
det(A) = ±1
如果矩阵中的值允许是复数(这种情况在深度学习中不常见),并且
U^U = UU^ = I
那么U是一个单位矩阵,其中U^是U的共轭转置。共轭转置是普通的矩阵转置,随后执行复共轭操作,将
变为−i*。因此,我们可能会有

有时候,特别是在物理学中,共轭转置被称为埃尔米特伴随,表示为A^†。如果一个矩阵等于它的共轭转置,那么它被称为埃尔米特矩阵。注意,实对称矩阵也是埃尔米特矩阵,因为当数值是实数时,共轭转置与普通转置相同。因此,在提到具有实数元素的矩阵时,你可能会遇到埃尔米特这个术语,代替对称。
对称矩阵的定性
在本节的开头,我们看到一个n × n的方阵将一个ℝ(*n*)中的向量映射到另一个ℝ(n)中的向量。现在让我们考虑一个对称的n × n矩阵B,它的元素是实数值。我们可以通过它如何映射向量来表征这个矩阵,使用的是映射向量与原始向量之间的内积。具体来说,如果x是一个列向量(n × 1),那么Bx也是一个n × 1 的列向量。因此,这个向量与原始向量x的内积是x^⊤Bx,这是一个标量。
如果以下条件成立:

那么B就被称为正定的。这里,粗体字0是一个n × 1 的全零列向量,∀是数学符号,表示“对于所有”。
类似地,如果

那么B是负定的。放松内积关系以及对x的非零要求会产生两个额外的情况。如果

那么B被称为正半定,并且

使得B成为负半定矩阵。最后,一个既不是正定也不是负定的实对称矩阵被称为不定矩阵。矩阵的定性告诉我们一些关于特征值的信息,接下来我们将在下一节中进一步学习。如果一个对称矩阵是正定的,那么它的所有特征值都是正数。类似地,一个对称的负定矩阵具有所有负的特征值。正半定和负半定对称矩阵的特征值分别全为正数或零,或全为负数或零。
现在让我们从讨论矩阵类型转到探索特征向量和特征值的重要性,这是我们在深度学习中经常使用的矩阵的关键特性。
特征向量与特征值
我们在上面学习过,一个方阵将一个向量映射到同一维空间中的另一个向量,v′ = Av,其中v′和v都是n维向量,如果A是一个n × n矩阵。
考虑这个方程,

对于某个方阵A,其中λ是标量值,v是一个非零列向量。
方程 6.4 表示向量v被矩阵A映射回其自身的一个标量倍。我们称v为A的特征向量,特征值为λ。前缀eigen来自德语,通常翻译为“自我”、“特性”或“固有”。从几何角度来看,方程 6.4 表示矩阵A对其特征向量在ℝ^(n)中的作用是缩小或扩展向量,而不改变其方向。请注意,虽然v是非零的,但λ也可能是零。
方程 6.4 如何与单位矩阵I相关?根据定义,单位矩阵将一个向量映射回自身,而不对其进行缩放。因此,单位矩阵具有无数个特征向量,并且它们的特征值都是 1,因为对于任何x,有Ix = x。因此,同一个特征值可能适用于多个特征向量。
回想一下,方程 6.1 定义了一个 2D 空间中的旋转矩阵,对于某个给定角度θ。这个矩阵没有特征向量,因为对于任何非零向量,它会将向量旋转θ,因此它永远无法将一个向量映射回其原始方向。因此,并非每个矩阵都有特征向量。
寻找特征值和特征向量
要找到矩阵的特征值(如果有的话),我们回到方程 6.4 并重写它:

我们可以在λ和v之间插入单位矩阵I,因为Iv = v。因此,要找到矩阵A的特征值,我们需要找到那些使矩阵A − λI将一个非零向量v映射为零向量的λ值。方程 6.5 只有在A − λI的行列式为零时,才有非零向量以外的解。
上述内容为我们提供了一种找到特征值的方法。例如,考虑对于一个 2 × 2 矩阵,A − λI的形式:

我们在上面学习过,2 × 2 矩阵的行列式有一个简单的形式;因此,上述矩阵的行列式为
det(A − λI) = (a − λ)(d − λ) − bc
这个方程是 λ 的二次多项式。由于我们需要行列式为零,我们将这个多项式设为零并找到它的根。根就是 A 的特征值。这个过程找到的多项式叫做特征多项式,方程 6.5 是特征方程。注意上面提到的特征多项式是二次多项式。通常,n × n 矩阵的特征多项式是 n 次多项式,因此一个矩阵最多有 n 个不同的特征值,因为一个 n 次多项式最多有 n 个根。
一旦我们知道了特征多项式的根,我们就可以返回到方程 6.5,将每个根代入 λ,并解出相关的特征向量,即方程 6.5 中的 v。
三角矩阵的特征值(包括对角矩阵)容易计算,因为这种矩阵的行列式仅仅是主对角线元素的乘积。例如,对于一个 4 × 4 的三角矩阵,特征方程的行列式为
det(A − λI) = (a[00] − λ)(a[11] − λ)(a[22] − λ)(a[33] − λ)
它有四个根:主对角线上的值。对于三角矩阵和对角矩阵,主对角线上的元素就是特征值。
让我们看一下以下矩阵的特征值示例:

我选择这个矩阵是为了使数学计算更简便,但这个过程适用于任何矩阵。特征方程意味着我们需要找出使行列式为零的 λ 值,如下所示。

特征多项式可以轻松因式分解得到 λ = −1, −2。
在代码中,为了找到矩阵的特征值和特征向量,我们使用 np.linalg.eig。让我们检查一下上面的计算,看看 NumPy 是否同意:
>>> a = np.array([[0,1],[-2,-3]])
>>> print(np.linalg.eig(a)[0])
[-1\. -2.]
np.linalg.eig 函数返回一个列表。第一个元素是矩阵的特征值向量。第二个元素(我们暂时忽略它)是一个矩阵,其列是与每个特征值相关的特征向量。注意,我们也可以使用 np.linalg.eigvals 来仅返回特征值。无论如何,我们看到我们计算出的 A 的特征值是正确的。
为了找到相关的特征向量,我们将每个特征值代入方程 6.5 并解出 v。例如,当 λ = −1 时,我们得到

这导致了以下方程组:
v[0] + v[1] = 0
−2v[0] − 2v[1] = 0
这个系统有许多解,只要 v[0] = −v[1]。这意味着我们可以选择 v[0] 和 v[1],只要它们之间的关系被保留。由此,我们得到了我们的特征向量。

如果我们对λ = −2 重复这个过程,我们得到v[2]的分量之间的关系是 2v[0] = −v[1]。因此,我们选择
作为第二个特征向量。
让我们看看 NumPy 是否与我们达成一致。这次,我们将显示np.linalg.eig返回的第二个列表元素。这是一个矩阵,矩阵的列是特征向量:
>>> print(np.linalg.eig(a)[1])
[[ 0.70710678 -0.4472136 ]
[-0.70710678 0.89442719]]
嗯……这个矩阵的列似乎与我们选择的特征向量不匹配。但别担心——我们没有犯错。回想一下,特征向量不是唯一确定的,只有分量之间的关系是确定的。如果我们愿意,我们可以选择其他值,只要对于一个特征向量,它们的大小相等且符号相反,对于另一个特征向量,它们的比值是 2:1 且符号相反。NumPy 返回的是一组单位长度的特征向量。因此,为了验证我们的手工计算是正确的,我们需要通过将每个分量除以分量平方和的平方根,将我们的特征向量变成单位向量。在代码中,这样写很简洁,尽管有点乱:
>>> np.array([1,-1])/np.sqrt((np.array([1,-1])**2).sum())
array([ 0.70710678, -0.70710678])
>>> np.array([-1,2])/np.sqrt((np.array([-1,2])**2).sum())
array([-0.4472136, 0.89442719])
现在我们看到我们是正确的。特征向量的单位向量版本确实与 NumPy 返回的矩阵的列匹配。
我们在做深度学习时经常使用矩阵的特征向量和特征值。例如,当我们研究主成分分析(PCA)时,我们会再次看到它们。但是在我们学习 PCA 之前,我们需要再次转换焦点,学习在深度学习中常用的向量范数和距离度量,特别是关于协方差矩阵的内容。
向量范数和距离度量
在常见的深度学习术语中,人们有些随便地将范数和距离互换使用。我们可以原谅他们这样做;正如我们下面将看到的,实际上这两个术语的区别很小。
向量范数是一个将向量(无论是实数还是复数)映射到某个值的函数,x ∈ ℝ,x ≥ 0。范数必须满足某些特定的数学性质,但在实践中,并不是所有称为范数的东西,实际上都是范数。在深度学习中,我们通常使用范数作为向量对之间的距离。实际上,距离度量的一个重要特性是输入的顺序不重要。如果f(x, y)是一个距离,那么f(x, y) = f(y, x)。再一次,这个性质并非严格遵循;例如,通常会看到使用 Kullback-Leibler 散度(KL 散度)作为距离,尽管这个性质并不成立。
让我们从向量范数开始,看看如何将它们作为向量之间的距离度量来使用。接着我们将介绍一个重要的概念——协方差矩阵,它在深度学习中有广泛应用,并且我们将看到如何从它创建一个距离度量:马氏距离。最后,我们将介绍 KL 散度,它可以作为两种离散概率分布之间的度量。
L-范数和距离度量
对于一个 n-维向量 x,我们定义该向量的 p-范数为

其中 p 是一个实数。尽管我们在定义中使用了 p,人们通常称这些为 L[p] 范数。我们在第五章中看到过其中一种范数,当时我们定义了向量的大小。在那种情况下,我们计算的是 L2-范数,

它是 x 与自身的内积的平方根。
在深度学习中,我们最常用的范数是 L2-范数和 L1-范数,

它不过是 x 各个分量的绝对值之和。你还会遇到另一种范数,叫做 L**[∞]-范数,
L[∞] = max |x**[i]|
x 各个分量的最大绝对值。
如果我们将 x 替换为两个向量的差 x − y,我们可以将范数视为这两个向量之间的距离度量。或者,我们可以将这个过程看作是在向量 x 和 y 之间的差异上计算范数。
从范数到距离的转换在方程 6.6 中只做了一个简单的变换:

L2-距离变为

这是两个向量之间的 欧几里得距离。L1-距离通常称为 曼哈顿距离(也叫 城市街区距离、箱车距离 或 出租车距离):

之所以这么命名,是因为它对应的是出租车在曼哈顿街区网格上行驶的距离。L[∞]-距离有时也被称为 切比雪夫距离。
范数方程在深度学习中有其他用途。例如,权重衰减作为深度学习中的正则化方法,使用模型权重的 L2-范数来防止权重过大。权重的 L1-范数有时也作为正则化器使用。
现在让我们来讨论一个重要的概念:协方差矩阵。它本身不是一个距离度量,但在某些度量中会用到它,并且它将在本章稍后再次出现。
协方差矩阵
如果我们有多个变量的测量集合,例如一个包含特征向量的训练集,我们可以计算特征之间的方差。例如,以下是一个包含四个变量的观测矩阵,每行代表一个变量:

实际上,X是著名的鸢尾花数据集的前五个样本。对于鸢尾花数据集,特征是来自三种不同物种的鸢尾花各部分的测量值。你可以通过sklearn将此数据集加载到 NumPy 中:
>>> from sklearn import datasets
>>> iris = datasets.load_iris()
>>> X = iris.data[:5]
>>> X
array([[5.1, 3.5, 1.4, 0.2],
[4.9, 3.0, 1.4, 0.2],
[4.7, 3.2, 1.3, 0.2],
[4.6, 3.1, 1.5, 0.2],
[5.0, 3.6, 1.4, 0.2]])
我们可以计算每个特征的标准差,即A的列,但这仅能告诉我们该特征值围绕均值的方差。由于我们有多个特征,因此了解特征之间的变化关系会很有帮助,比如第零列和第一列之间的变化关系。为了确定这一点,我们需要计算协方差矩阵。该矩阵捕捉了各个特征沿主对角线的方差。与此同时,非对角线的值表示一个特征随着另一个特征的变化而变化——这些就是协方差。由于有四个特征,协方差矩阵总是方阵,在这种情况下是一个 4 × 4 的矩阵。我们通过计算来找出协方差矩阵Σ的元素:

假设矩阵X的行是观测值,列代表不同的特征。所有行中每个特征的均值分别为
和
,分别对应特征i和j。这里,n是观测值的数量,即X中的行数。我们可以看到,当i = j时,协方差值是该特征的常规方差。当i ≠ j时,值表示i和j的变化关系。我们通常将协方差矩阵记作Σ,并且它总是对称的:∑[ij] = ∑[ji]。
让我们计算上面X的协方差矩阵的一些元素。每个特征的均值为
。我们来找出Σ的第一行。这将告诉我们第一个特征(X的列)的方差,以及该特征与第二、第三和第四个特征的变化关系。因此,我们需要计算∑[00]、∑[01]、∑[02]和∑[03]:

我们可以对Σ的所有行重复这个计算,得到完整的协方差矩阵:

对角线上的元素表示X特征的方差。请注意,X的第四个特征的所有方差和协方差都为零。这是有道理的,因为这个特征在X中的所有值都是相同的;没有方差。
我们可以通过使用np.cov在代码中计算一组观测值的协方差矩阵:
>>> print(np.cov(X, rowvar=False))
[[ 0.043 0.0365 -0.0025 0. ]
[ 0.0365 0.067 -0.0025 0. ]
[-0.0025 -0.0025 0.005 0. ]
[ 0. 0. 0. 0. ]]
请注意,调用np.cov时包含了rowvar=False。默认情况下,np.cov期望其参数的每一行是一个变量,而列是该变量的观测值。这与深度学习中通常存储观测值的矩阵方式相反。因此,我们使用rowvar关键字告诉 NumPy,观测值是行,而不是列。
我上面提到过,协方差矩阵的对角线返回的是X中各特征的方差。NumPy 有一个函数 np.std 用于计算标准差,对该函数的输出进行平方应该能得到各特征的方差。对于X,我们得到
>>> print(np.std(X, axis=0)**2)
[0.0344 0.0536 0.004 0. ]
这些方差看起来不像协方差矩阵的对角线。其差异源于协方差方程中分母的 n − 1,公式 6.8。默认情况下,np.std 计算的是样本方差的偏倚估计。这意味着它不是除以 n − 1,而是除以 n。为了让 np.std 计算无偏方差估计,我们需要添加 ddof=1 关键字,
>>> print(np.std(X, axis=0, ddof=1)**2)
[0.043 0.067 0.005 0. ]
然后我们将得到与 Σ 对角线相同的值。
现在我们知道如何计算协方差矩阵,让我们在距离度量中使用它。
马哈拉诺比斯距离
上面,我们通过一个矩阵表示数据集,其中数据集的行是观测值,列是构成每个观测值的变量的值。在机器学习中,行是特征向量。正如我们上面看到的,我们可以计算每个特征在所有观测值中的均值,并可以计算协方差矩阵。通过这些值,我们可以定义一个距离度量,称为马哈拉诺比斯距离,

其中,x 是一个向量,μ 是由每个特征的均值构成的向量,Σ 是协方差矩阵。请注意,这个度量使用的是协方差矩阵的逆,而不是协方差矩阵本身。
公式 6.9 在某种意义上是在测量一个向量与具有均值向量 μ 的分布之间的距离。分布的离散程度由 Σ 捕捉。如果数据集中的特征之间没有协方差,且每个特征具有相同的标准差,那么 Σ 就变成了单位矩阵,它是其自身的逆。在这种情况下,Σ^(−1) 在公式 6.9 中实际上会被省略,马哈拉诺比斯距离就变成了 L2 距离(欧几里得距离)。
另一种理解马哈拉诺比斯距离的方法是,将μ替换为另一个向量,称之为y,它来自与x相同的数据集。那么 D[M] 就是两个向量之间的距离,考虑到数据集的方差。
我们可以使用马哈拉诺比斯距离来构建一个简单的分类器。如果给定一个数据集,我们计算数据集中每个类别的均值特征向量(这个向量也叫做质心),我们可以使用马哈拉诺比斯距离为一个未知特征向量x分配一个标签。我们通过计算所有马哈拉诺比斯距离到各个类别质心的距离,并将x分配给返回最小值的类别。此类分类器有时被称为最近质心分类器,你经常会看到它用 L2 距离代替马哈拉诺比斯距离来实现。可以说,马哈拉诺比斯距离更为优越,因为它考虑了数据集的方差。
让我们使用sklearn附带的乳腺癌数据集,使用马哈拉诺比斯距离构建最近质心分类器。乳腺癌数据集有两个类别:良性(0)和恶性(1)。该数据集包含 569 个观测值,每个观测值有 30 个特征,来源于组织学切片。我们将构建两个版本的最近质心分类器:一个使用马哈拉诺比斯距离,另一个使用欧几里得距离。我们预计,使用马哈拉诺比斯距离的分类器会表现得更好。
我们需要的代码非常简单:
import numpy as np
from sklearn import datasets
❶ from scipy.spatial.distance import mahalanobis
bc = datasets.load_breast_cancer()
d = bc.data; l = bc.target
❷ i = np.argsort(np.random.random(len(d)))
d = d[i]; l = l[i]
xtrn, ytrn = d[:400], l[:400]
xtst, ytst = d[400:], l[400:]
❸ i = np.where(ytrn == 0)
m0 = xtrn[i].mean(axis=0)
i = np.where(ytrn == 1)
m1 = xtrn[i].mean(axis=0)
S = np.cov(xtrn, rowvar=False)
SI= np.linalg.inv(S)
def score(xtst, ytst, m, SI):
nc = 0
for i in range(len(ytst)):
d = np.array([mahalanobis(xtst[i],m[0],SI),
mahalanobis(xtst[i],m[1],SI)])
c = np.argmin(d)
if (c == ytst[i]):
nc += 1
return nc / len(ytst)
mscore = score(xtst, ytst, [m0,m1], SI)
❹ escore = score(xtst, ytst, [m0,m1], np.identity(30))
print("Mahalanobis score = %0.4f" % mscore)
print("Euclidean score = %0.4f" % escore)
我们首先导入所需的模块,包括 SciPy 中的mahalanobis❶。该函数接受两个向量和协方差矩阵的逆矩阵,并返回D[M]。接着我们在d中获取数据集,并在l中获取标签。我们随机化顺序❷并提取前 400 个观测值作为训练数据(xtrn)和标签(ytrn)。其余观测值则留作测试数据(xtst,ytst)。
接下来我们训练模型。训练过程包括提取属于每个类别的所有观测值❸,并计算m0和m1。这两个值是类别 0 和类别 1 的所有观测值在 30 个特征上的均值。然后我们计算整个训练集的协方差矩阵(S)及其逆矩阵(SI)。
score函数接收测试观测值、类别均值向量的列表以及协方差矩阵的逆矩阵。它遍历每个测试观测值并计算马哈拉诺比斯距离(d)。然后它使用最小的距离来分配类别标签(c)。如果分配的标签与实际测试标签匹配,我们就计数(nc)。在函数结束时,我们返回整体准确率。
我们调用score函数两次。第一次调用使用逆协方差矩阵(SI),第二次调用使用单位矩阵,从而让score计算欧几里得距离。最后,我们打印出两个结果。
数据集的随机化❷意味着每次运行代码时,输出的得分会略有不同。运行代码 100 次得到以下平均得分(±标准差)。
| 距离 | 平均得分 |
|---|---|
| 马哈拉诺比斯 | 0.9595 ± 0.0142 |
| 欧几里得 | 0.8914 ± 0.0185 |
这清楚地表明,使用马氏距离可以提升模型的表现,准确率大约提高了 7 个百分点。
近年来,马氏距离在深度学习中的一个应用是提取顶层嵌入层的值(一个向量),并使用马氏距离来检测域外输入或对抗性输入。域外输入是指与模型训练时使用的数据类型有显著不同的输入。对抗性输入是指对手故意试图通过提供一个不是类 X 的数据来欺骗模型,尽管模型会将其标记为类 X。
Kullback-Leibler 散度
Kullback-Leibler 散度(KL 散度),或称相对熵,是衡量两个概率分布相似度的一种度量:值越小,分布越相似。
如果P和Q是离散概率分布,则 KL 散度为

其中 log[2]是以 2 为底的对数。这是一个信息论度量,输出的是比特信息。有时也使用自然对数 ln,这种情况下度量单位称为nats。实现 KL 散度的 SciPy 函数在scipy.special中,命名为rel_entr。请注意,rel_entr使用的是自然对数,而不是以 2 为底的对数。还要注意,KL 散度在数学意义上不是一种距离度量,因为它违反了对称性属性,DKL ≠ DKL,但这并没有妨碍人们偶尔把它当作一种距离度量来使用。
让我们看一个例子,了解如何使用 KL 散度来度量不同离散概率分布之间的差异。我们将测量两个不同的二项分布与一个均匀分布之间的散度。然后,我们将绘制这些分布,看看从视觉上是否相信这些数字。
为了生成分布,我们将从一个有 12 个可能输出的均匀分布中抽取很多次。我们可以通过使用np.random.randint在代码中快速实现这一点。接着,我们将从两个不同的二项分布中抽取数据,B(12, 0.4) 和 B(12, 0.9),这意味着每次试验有 12 次,概率分别为 0.4 和 0.9。我们将生成抽取结果的直方图,除以计数的总和,并将重新缩放的直方图作为我们的概率分布。然后,我们可以测量它们之间的散度。
我们需要的代码是
from scipy.special import rel_entr
N = 1000000
❶ p = np.random.randint(0,13,size=N)
❷ p = np.bincount(p)
❸ p = p / p.sum()
q = np.random.binomial(12,0.9,size=N)
q = np.bincount(q)
q = q / q.sum()
w = np.random.binomial(12,0.4,size=N)
w = np.bincount(w)
w = w / w.sum()
print(rel_entr(q,p).sum())
print(rel_entr(w,p).sum())
我们从 SciPy 加载rel_entr,并将每个分布的抽样次数设为 1,000,000(N)。生成各自概率分布的代码对于每个分布的方法是相同的。我们从分布中抽取N个样本,首先从均匀分布开始❶。我们使用randint,因为它返回的整数范围是[0, 12],这样我们就可以匹配binomial在 12 次试验中返回的离散[0, 12]值。通过使用np.bincount,我们从抽样结果中获得直方图。这个函数会统计向量中唯一值的频率❷。最后,我们通过将直方图除以总和❸,将计数值转换为分数。这给了我们一个包含 12 个元素的p向量,表示randint返回 0 到 12 之间的值的概率。假设randint使用的是良好的伪随机数生成器,我们预计p中的每个值的概率大致相等。(NumPy 使用的是 Mersenne Twister 伪随机数生成器,这是当前最好的之一,因此我们有信心能得到良好的结果。)
我们重复这个过程,将binomial替换为randint,使用概率分别为 0.9 和 0.4 的二项分布进行抽样。同样,通过对抽样结果进行直方图统计,并将计数转换为分数,我们得到了基于 0.9 和 0.4 的剩余概率分布,分别为q和w。
我们终于准备好测量偏差了。rel_entr函数与其他函数有些不同,因为它不会直接返回D[KL]。相反,它返回一个与其参数长度相同的向量,其中每个元素都是导致D[KL]的总和的一部分。因此,要得到实际的偏差值,我们需要将这个向量的元素相加。因此,我们打印rel_entr的输出的和,将两个二项分布与均匀分布进行比较。
抽样的随机性质意味着每次运行代码时得到的数字略有不同。一次运行结果为:
| 分布 | 偏差 |
|---|---|
| DKL | 1.1826 |
| DKL | 0.6218 |
这表明,概率为 0.9 的二项分布比概率为 0.4 的二项分布偏离均匀分布的程度更大。回想一下,偏离越小,两个概率分布就越相似。
我们相信这个结果吗?一种检查方法是通过可视化,绘制三个分布并查看B(12, 0.4)是否比B(12, 0.9)更像一个均匀分布。这将导致图 6-1。

图 6-1:三种不同的离散概率分布:均匀分布(前向哈希)、B(12, 0.4)(后向哈希)和B(12, 0.9)(水平哈希)
虽然显然没有一个二项分布特别均匀,B(12, 0.4)分布相对集中在范围内,并且比B(12, 0.9)分布更广泛地分布在多个值上。将B(12, 0.4)看作更像均匀分布似乎是合理的,这正是 KL 散度通过返回较小值告诉我们的。
现在我们拥有了实现主成分分析所需的一切。
主成分分析
假设我们有一个矩阵,X,表示一个数据集。我们理解每个特征的方差不一定相同。如果我们把每个观察值看作是一个在n维空间中的点,其中n是每个观察值的特征数量,我们可以想象出一群点,在不同的方向上有不同的散布程度。
主成分分析(PCA)是一种用于学习数据集散布方向的技术,首先从最大散布方向开始。这个方向被称为主成分。然后你可以按散布递减的顺序找到剩余的成分,每个新的成分都与其他成分正交。图 6-2 的顶部显示了一个二维数据集和两条箭头。即使我们对数据集一无所知,我们也能看到最大的箭头指向散布最大的方向。这就是我们所说的主成分。

图 6-2:鸢尾花数据集的前两个特征及主成分方向(上图),以及 PCA 变换后的鸢尾花数据集(下图)
我们通常使用 PCA 来降低数据集的维度。如果每个观察值有 100 个变量,但前两个主成分解释了数据中 95%的散布,那么将数据集映射到这两个成分上,并丢弃剩余的 98 个成分,可能足以用两个变量充分表征数据集。我们也可以使用 PCA 来增强数据集,前提是特征是连续的。
那么,PCA 是如何工作的呢?关于数据散布的讨论意味着 PCA 可能能够利用协方差矩阵,事实上,它的确是这样做的。我们可以将 PCA 算法分解为几个步骤:
-
对数据进行均值中心化。
-
计算均值中心化数据的协方差矩阵,Σ。
-
计算Σ的特征值和特征向量。
-
按照绝对值递减的顺序对特征值进行排序。
-
丢弃最弱的特征值/特征向量(可选)。
-
使用剩余的特征向量构造一个变换矩阵,W。
-
从现有数据集中生成新的变换值,x′ = Wx。这些值有时被称为派生变量。
让我们通过使用鸢尾花数据集(Listing 6-1)来演示这个过程。我们将把数据的维度从四个特征降至两个。先是代码,再是解释:
from sklearn.datasets import load_iris
iris = load_iris().data.copy()
❶ m = iris.mean(axis=0)
ir = iris - m
❷ cv = np.cov(ir, rowvar=False)
❸ val, vec = np.linalg.eig(cv)
val = np.abs(val)
❹ idx = np.argsort(val)[::-1]
ex = val[idx] / val.sum()
print("fraction explained: ", ex)
❺ w = np.vstack((vec[:,idx[0]],vec[:,idx[1]]))
❻ d = np.zeros((ir.shape[0],2))
for i in range(ir.shape[0]):
d[i,:] = np.dot(w,ir[i])
Listing 6-1:主成分分析(PCA)
我们首先加载鸢尾花数据集,感谢sklearn提供。这给我们提供了一个 150 × 4 的矩阵iris,因为有 150 个观察值,每个观察值有四个特征。我们计算每个特征的均值 ❶,并从数据集中减去均值,利用 NumPy 的广播规则从iris的每一行中减去m。接下来我们将使用均值中心化后的矩阵ir。
下一步是计算协方差矩阵 ❷。输出cv是一个 4 × 4 的矩阵,因为每个观察值有四个特征。接着,我们计算cv的特征值和特征向量 ❸,然后取特征值的绝对值以获得其大小。我们希望特征值按大小降序排列,因此我们使用 Python 的[::-1]惯用法反转列表或数组的顺序,从而获取排序后的索引 ❹。
特征值的大小与数据集中每个主成分沿着的方差比例成正比;因此,如果我们将特征值按它们的总和进行缩放,就可以得到每个主成分解释的比例(ex)。解释的方差比例为
fraction explained: [0.92461872 0.05306648 0.01710261 0.00521218]
这表明两个主成分解释了鸢尾花数据集中近 98%的方差。因此,接下来我们只保留前两个主成分。
我们从与两个最大特征值对应的特征向量中创建变换矩阵w ❺。回顾一下,eig返回特征向量作为矩阵vec的列。变换矩阵w是一个 2 × 4 的矩阵,因为它将四维特征向量映射到新的二维向量。
剩下的就是创建一个地方来存放变换后的观察值并将其填充 ❻。新的、降维后的数据集存储在d中。现在我们可以绘制整个变换后的数据集,并根据每个点所属的类别进行标记。结果是图 6-2 的底部部分。
在图 6-2 的顶部是仅使用前两个特征绘制的原始数据集图。箭头表示前两个主成分,箭头的大小显示了这些主成分解释了数据中多少方差。第一个主成分解释了大部分方差,这在视觉上是合理的。
在这个示例中,图 6-2 底部的衍生变量使数据集变得更容易处理,因为各类之间的分离比仅使用原始的两个特征时更加明显。有时候,PCA 使得模型更容易学习,因为特征向量的维度降低。然而,这并非总是如此。在 PCA 过程中,可能会丢失一个对于类分离至关重要的特征。正如机器学习中的大多数事情,实验非常重要。
PCA 是常用的,因此在多个工具包中得到了很好的支持。我们可以通过使用sklearn.decomposition模块中的PCA类来完成与上面相同的操作,而无需写上面的几十行代码:
from sklearn.decomposition import PCA
pca = PCA(n_components=2)
pca.fit(ir)
d = pca.fit_transform(ir)
新的、降维后的数据集在d中。像其他sklearn类一样,在我们告诉PCA要学习多少个主成分后,它使用fit来设置变换矩阵(在列表 6-1 中是w)。然后,我们通过调用fit_transform来应用变换。
奇异值分解与伪逆
本章的最后,我们将介绍奇异值分解(SVD)。这是一种强大的技术,可以将任意矩阵分解为三个具有特殊性质的矩阵的乘积。SVD 的推导超出了本书的范围。我相信有兴趣的读者可以深入研究线性代数的广泛文献,找到关于 SVD 的来源及其最佳理解方式的满意阐述。我们的目标更为 modest:熟悉深度学习中使用的数学。因此,我们将满足于了解 SVD 的定义、它带给我们的理解、它的一些应用以及如何在 Python 中使用它。对于深度学习,你最有可能在计算非方阵的伪逆时遇到 SVD。我们将在本节中看到如何操作。
对于输入矩阵A,其元素为实数且形状为m × n,其中m不一定等于n(尽管它们可能相等),SVD 的输出是:

A已经被分解为三个矩阵:U,Σ和V。请注意,你有时可能会看到V⊤被写作***V***,即V*的共轭转置。这是适用于复数矩阵的更一般形式。我们将限制在实值矩阵的范围内,因此只需要普通的矩阵转置。
一个m × n矩阵A的 SVD 返回以下结果:U,它是m × m且是正交的;Σ,它是m × n且是对角的;以及V,它是n × n且是正交的。回忆一下,正交矩阵的转置就是它的逆,因此UU^⊤ = I[m],VV^⊤ = I[n],其中单位矩阵的下标表示矩阵的阶数,m × m或n × n。
在本章的这一部分,你可能会对“Σ,尺寸为m × n且为对角矩阵”这一说法挑起眉头,因为我们通常认为只有方阵才是对角矩阵。这里,当我们说“对角矩阵”时,我们指的是矩形对角矩阵。这是对对角矩阵的自然扩展,其中原本属于对角线的元素是非零的,其他位置的元素为零。例如,

是一个 3 × 5 的矩形对角矩阵,因为只有主对角线上的元素是非零的。术语“奇异”出现在“奇异值分解”中,来源于Σ对角矩阵中的元素,它们是矩阵A^TA**的正特征值的平方根,即奇异值。
SVD 的实际应用
让我们明确一点,使用 SVD 来分解矩阵。我们的测试矩阵是:

我们将展示 SVD 的实际应用步骤。为了得到 SVD,我们使用scipy.linalg中的svd函数。
>>> from scipy.linalg import svd
>>> a = np.array([[3,2,2],[2,3,-2]])
>>> u,s,vt = svd(a)
其中u是U,vt是V^⊤,s包含奇异值:
>>> print(u)
[[-0.70710678 -0.70710678]
[-0.70710678 0.70710678]]
>>> print(s)
[5\. 3.]
>>> print(vt)
[[-7.07106781e-01 -7.07106781e-01 -5.55111512e-17]
[-2.35702260e-01 2.35702260e-01 -9.42809042e-01]
[-6.66666667e-01 6.66666667e-01 3.33333333e-01]]
让我们验证奇异值确实是矩阵A^TA**的正特征值的平方根:
>>> print(np.linalg.eig(a.T @ a)[0])
[2.5000000e+01 5.0324328e-15 9.0000000e+00]
这表明,确实,5 和 3 分别是 25 和 9 的平方根。回想一下,eig返回的是一个列表,其中的第一个元素是特征值的向量。还要注意,还有第三个特征值:零。你可能会问:“我们应该如何界定一个数值为零?”这是一个很好的问题,但没有固定的答案。通常,我会将小于 10^(-9)的数值视为零。
SVD 的声明是,U和V是单位矩阵。如果是这样,它们与自身转置的乘积应该是单位矩阵:
>>> print(u.T @ u)
[[1.00000000e+00 3.33066907e-16]
[3.33066907e-16 1.00000000e+00]]
>>> print(vt @ vt.T)
[[ 1.00000000e+00 8.00919909e-17 -1.85037171e-17]
[ 8.00919909e-17 1.00000000e+00 -5.55111512e-17]
[-1.85037171e-17 -5.55111512e-17 1.00000000e+00]]
鉴于上面提到的关于应当视为零的数值,这确实是单位矩阵。注意,svd返回的是V⊤,而不是***V***。然而,由于(***V***⊤)^⊤ = V,我们仍然在乘V^⊤V。
svd函数返回的不是Σ,而是Σ的对角线上的值。因此,我们需要重建Σ并使用它来验证 SVD 的效果,这意味着我们可以使用U、Σ和V^⊤来恢复A:
>>> S = np.zeros((2,3))
>>> S[0,0], S[1,1] = s
>>> print(S)
[[5\. 0\. 0.]
[0\. 3\. 0.]]
>>> A = u @ S @ vt
>>> print(A)
[[ 3\. 2\. 2.]
[ 2\. 3\. -2.]]
这就是我们开始时的A——几乎:恢复的A不再是整数类型,这是一个细微的变化,值得在编写代码时记住。
两个应用
SVD 是一个巧妙的技巧,但我们可以用它做什么呢?简短的答案是“很多”。我们来看两个应用。第一个是使用 SVD 进行 PCA。我们在上一节中使用的sklearn PCA类在内部就是使用 SVD 的。第二个例子出现在深度学习中:使用 SVD 计算 Moore-Penrose 伪逆,它是方阵逆矩阵的推广,适用于m × n矩阵。
PCA 的 SVD
为了了解如何使用 SVD 进行 PCA,我们使用上一节中的鸢尾花数据,这样可以与之前的结果进行比较。关键在于对Σ和V^⊤矩阵进行截断,仅保留所需数量的最大奇异值。分解代码会将奇异值按降序排列在Σ的对角线上,我们只需要保留Σ的前k列。代码如下:
u,s,vt = svd(ir)
❶ S = np.zeros((ir.shape[0], ir.shape[1]))
for i in range(4):
S[i,i] = s[i]
❷ S = S[:, :2]
T = u @ S
在这里,我们使用来自列表 6-1 的ir。这是一个均值中心化版本的鸢尾花数据集矩阵,包含 150 行,每行有四个特征。调用svd将为我们提供ir的分解。接下来的三行❶创建了矩阵Σ的完整矩阵S。由于鸢尾花数据集有四个特征,svd返回的s向量将包含四个奇异值。
截断通过保留S的前两列来实现❷。这样,Σ矩阵从 150 × 4 变为 150 × 2。将U与新的Σ相乘得到转换后的鸢尾花数据集。由于U是 150 × 150,Σ是 150 × 2,我们得到一个 150 × 2 的数据集T。如果我们将其绘制为T[:,0]与T[:,1]的关系,我们将得到与图 6-2 底部部分完全相同的图。
Moore-Penrose 伪逆
正如承诺的,我们的第二个应用是计算A^+,即一个m × n矩阵A的 Moore-Penrose 伪逆。矩阵A^+被称为伪逆,因为它与A配合时,表现得像一个逆矩阵,其关系为

其中AA+有点像单位矩阵,使得***A***+有点像A的逆矩阵。
由于矩形对角矩阵的伪逆仅仅是对角线值的倒数,其余位置为零,接着取转置,我们可以将任何一般矩阵的伪逆计算为
A^+ = VΣ^+ U*
对于A = UΣV,是矩阵A的 SVD。注意,我们使用的是共轭转置V*,而不是普通转置***V***⊤。如果A*是实数矩阵,那么普通转置和共轭转置是相同的。
让我们验证一下关于A^+的声明是否正确。我们将从上面章节中使用的A矩阵开始,计算 SVD,并使用这些部分来找到伪逆。最后,我们将验证方程 6.11。
我们将从A开始,使用我们在上面 SVD 示例中使用的相同数组:
>>> A = np.array([[3,2,2],[2,3,-2]])
>>> print(A)
[[ 3 2 2]
[ 2 3 -2]]
应用 SVD 将为我们提供U和V⊤以及Σ的对角线。我们将使用对角元素手动构造Σ+。回想一下,Σ^+是Σ的转置,其中非零的对角线元素会被替换为它们的倒数:
>>> u,s,vt = svd(A)
>>> Splus = np.array([[1/s[0],0],[0,1/s[1]],[0,0]])
>>> print(Splus)
[[0.2 0. ]
[0. 0.33333333]
[0. 0. ]]
现在我们可以计算A+并验证***AA***+A = A:
>>> Aplus = vt.T @ Splus @ u.T
>>> print(Aplus)
[[ 0.15555556 0.04444444]
[ 0.04444444 0.15555556]
[ 0.22222222 -0.22222222]]
>>> print(A @ Aplus @ A)
[[ 3\. 2. 2.]
[ 2\. 3\. -2.]]
在这种情况下,AA^+是单位矩阵:
>>> print(A @ Aplus)
[[1.00000000e+00 5.55111512e-17]
[1.66533454e-16 1.00000000e+00]]
这就是我们对 SVD 的快速回顾和线性代数的讨论。我们只是触及了表面,但我们已经涵盖了需要知道的内容。
总结
这一章的内容以及前面的第五章讲解了大量的线性代数。作为一个数学主题,线性代数远比我们在这里展示的要丰富得多。
我们将本章重点放在了方阵上,因为它们在线性代数中占有特殊的地位。具体来说,我们讨论了方阵的一般性质,并提供了示例。我们学习了特征值和特征向量,如何求解它们,以及它们为何有用。接下来,我们研究了向量范数和其他测量距离的方法,因为这些在深度学习中经常出现。最后,我们通过学习主成分分析(PCA)及其原理结束了本章,并深入探讨了奇异值分解及其在深度学习中的两个相关应用。
下一章将转到微积分,重点讲解微分学。幸运的是,这部分是微积分中的“简单”部分,一般来说,它是我们理解深度学习中特定算法所需的全部内容。所以,系好安全带,确保你的手脚完全在车内,准备好启程进入微分学的世界吧。
第七章:微分学**

艾萨克·牛顿爵士和戈特弗里德·威廉·莱布尼茨分别发现了“微积分”,这是数学历史上最伟大的成就之一。微积分通常分为两大部分:微分学和积分学。微分学讨论的是变化率及其关系,体现在导数的概念中。积分学则关注曲线下的面积等问题。
深度学习中我们不需要用到积分学,但微分学将会被频繁使用。例如,我们用微分学来训练神经网络;我们通过梯度下降法调整神经网络的权重,而梯度下降依赖于通过反向传播算法计算的导数。
导数将是本章的核心。我们将首先介绍斜率的概念,看看它如何引出导数的概念。然后我们会正式定义导数,并学习如何计算单变量函数的导数。之后,我们将学习如何利用导数找出函数的最小值和最大值。接下来是偏导数,即针对多变量函数的单一变量的导数。我们将在反向传播算法中广泛使用偏导数。最后,我们将介绍梯度,进而引入矩阵微积分,这也是第八章的内容。
斜率
在代数课上,我们学习了关于直线的所有内容。定义直线的一种方式是斜截式,
y = mx + b
其中 m 是斜率,b 是y 截距,即直线与 y 轴交点的位置。我们关注的是斜率。如果我们知道直线上的两个点(x[1], y[1])和(x[0], y[0]),我们就知道直线的斜率:

斜率告诉我们,x位置的任何变化,会引起y的多少变化。如果斜率为正,那么x的正变化会导致y的正变化。另一方面,负斜率意味着x的正变化会导致y的负变化。
直线的斜截式告诉我们,斜率是x和y之间的比例常数。截距,b,是一个常数偏移量。这意味着,x从x[1]变到x[0]时,y的变化为m(x[1] − x[0])。斜率关联了两件事,告诉我们改变一个变量如何影响另一个变量。我们将在本书中多次回到这个概念。
现在,让我们通过一些例子来形象化这一点。图 7-1 显示了一个曲线和一些与其相交的直线。

图 7-1:一条曲线及其割线(A)和切线(B)
标记为 A 的线与曲线在两点交叉,x[1] 和 x[0]。通过曲线上两点之间的连线称为 割线。另一条线,B,恰好在点 x**[t] 触及曲线。与曲线在一个点上接触的线称为 切线。我们将在下一节回到割线,但现在请注意,切线在 x**[t] 处具有特定的斜率,而割线随着 x[1] 和 x[0] 之间的距离趋向于零而变成切线。
假设我们将点 x**[t] 从曲线上的一个位置移动到另一个位置;我们可以看到,x**[t] 处的切线斜率也会随着位置的变化而改变。当我们接近曲线的最小点,约在 x = 0.3 处时,我们看到斜率变得越来越平缓。如果从左侧接近,斜率为负,并且变得越来越不负。如果从右侧接近,斜率为正,但变得越来越小。在实际的最小点,约在 x = 0.3 处,切线是水平的,斜率为零。类似地,如果我们接近曲线的最大点,约在 x = −0.8 处,斜率也会接近零。
我们可以看到,切线告诉我们曲线在某点的变化情况。正如我们将在本章后面看到的那样,切线的斜率在曲线的最小值和最大值处为零,这将引导我们找到这些点的方法。切线斜率为零的点被称为 驻点。
当然,要利用切线的斜率,我们需要能够求出曲线上任意 x 处切线的斜率。下一节将向我们展示如何做到这一点。
导数
上一节介绍了割线和切线的概念,并暗示了知道曲线在某一点的切线斜率可能是有用的。切线在 x 处的斜率被称为 x 处的 导数。它告诉我们曲线(函数)在 x 处的变化情况,即函数值如何随着 x 的微小变化而变化。在这一节中,我们将正式定义导数,并学习计算单变量函数 x 导数的简化规则。
一个正式的定义
一般的第一学期微积分课程会通过研究极限来引入导数。我在上面提到过,曲线两点之间的割线的斜率会在两点重合时变成切线,而这正是极限发挥作用的地方。
例如,如果 y = f(x) 是一条曲线,并且我们在曲线上有两个点,x[0] 和 x[1],那么这两点之间的斜率,Δy/Δx,为

这就是你可能在学校里学过的 升高比水平(rise over run)。升高,Δy = y[1] − y[0] = f(x[1]) − f(x[0]),除以 水平,Δx = x[1] − x[0]。我们通常用 Δ 作为前缀来表示某个变量的变化。
如果我们定义 h = x[1] − x[0],我们可以将 方程 7.1 重写为

因为 x[1] = x[0] + h。
在这种新形式下,我们可以通过让 h 越来越接近零,即 h → 0,来找到 x[0] 处切线的斜率。让一个值逼近另一个值就是一个极限。让 h → 0 就是让我们计算斜率的两点越来越接近。这直接导致了导数的定义。

dy/dx 或 f′(x) 用来表示 f(x) 的导数。
在我们深入讨论导数的含义之前,先花点时间讨论一下符号。使用 f′(x) 来表示导数是跟随 Joseph-Louis Lagrange 的做法。莱布尼茨使用 dy/dx 来模仿斜率的符号,将 Δ → d。如果 Δy 是两个点之间 y 的变化量,dy 则是单个点上 y 的微小变化。牛顿使用了另一种符号,
,其中点表示 f 的导数。物理学家通常使用牛顿的符号来表示关于时间的导数。例如,如果 f(t) 是粒子的位置关于时间 t 的函数,那么
就是关于 t 的导数,也就是位置随时间的变化。位置随时间的变化即为速度(如果使用向量则为速度)。你在书籍中会看到所有这些符号。我的偏好是保留
用于时间的函数,并在其他地方交替使用 Lagrange 的 f′(x) 和 Leibniz 的 dy/dx。
尽管上面的 方程 7.2 很繁琐,且是许多初学微积分学生的噩梦,至少直到他们遇到积分,但如果不得不使用它,你还是可以操作的。不过,在本书中我们不会讨论积分,所以你可以深呼吸,放松一下。
在与极限的斗争之后,微积分学生会得到一个秘密:一小套规则将使你无需使用极限就能计算几乎所有的导数。我们将通过一个一个的例子介绍这些规则,然后,在本节的末尾,我们将把这些规则整合成一种适合印在 T 恤上的形式。
然而,在我们深入讨论规则之前,让我们再花点时间讨论一下导数究竟在告诉我们什么。前面我提到,位置随时间的变化由导数给出。这对于所有的导数都是适用的;它们告诉我们某个事物如何随另一个事物的变化而变化。我们甚至可以在莱布尼茨的符号 dy/dx 中看到这一点,它表示 dy 随 dx 变化的程度。x 处的导数告诉我们该点处函数是如何变化的。正如我们将看到的,f(x) 的导数本身就是一个关于 x 的新函数。如果我们选择一个特定的 x[0],那么我们知道 f(x[0]) 就是该点处函数的值。
同样地,如果我们知道导数,那么f′(x[0])表示函数f(x)在x[0]处变化的速度和方向。考虑速度的定义,即位置随时间变化的情况。我们甚至用语言来表达:我当前的速度是 30 英里每小时—miles per hour—即位置随时间的变化。
我们将使用导数来表示速率,看看改变一个变量如何影响另一个变量。最终,在深度学习中,我们希望了解在网络中改变一个参数的值将如何最终改变损失函数,即网络预期输出与实际输出之间的误差。
如果f′(x)是x的函数,那么我们应该能够对其求导。我们称f′(x)为一阶导数。它的导数,用f′′(x)表示,是二阶导数。在莱布尼茨符号中,我们写作d²y/dx²。二阶导数告诉我们一阶导数是如何随x变化的。物理学在这里提供了帮助。位置关于时间的函数f的一阶导数是速度,
—即位置随时间的变化。因此,位置的二阶导数,
,也就是速度的一阶导数,表示速度随时间的变化。我们称之为加速度。
理论上,我们可以计算任意多次导数。实际上,许多函数最终的导数是常数值。由于常数值不变化,它的导数为零。
总结一下,f(x)的导数是另一个函数,f′(x)或dy/dx,它告诉我们在每一点上f(x)的切线斜率。而且,由于f′(x)是x的函数,它也有导数,f′′(x)或d²y/dx²,即第二导数,它告诉我们f′(x)在每个x处的变化情况,依此类推。我们将在下面看到如何利用一阶和二阶导数。目前,先学习微分规则,即计算导数的过程。
基本规则
我们在上一节中提到了一条规则:常数c的导数为零。所以,我们写作

这里我们使用的是莱布尼茨符号的算子形式:d/dx。可以将d/dx看作是作用于后面的部分;它的作用方式与否定相同:要否定c,我们写作−c;要对c求导,我们写作
。如果我们的表达式中没有x,那么我们将其视为常数,导数为零。
幂法则
x的幂的导数使用幂法则,

其中a是常数,n是指数,不需要是整数。我们来看一些例子:

我们经常通过加法和减法构建代数表达式。微分是一个线性算子,所以我们可以写作

这意味着我们逐项计算导数。例如,使用我们目前掌握的规则,我们现在知道如何计算一个多项式的导数:

一般来说,

在这里我们看到一个多项式的导数是另一个次数为n − 1 的多项式,并且原始多项式中的任何常数项都变为零。
乘积法则
函数相乘的微分有它自己的规则,乘积法则:

乘积的导数是第一个函数的导数乘以第二个函数,加上第二个函数的导数乘以第一个函数。考虑以下例子:

商法则
一个函数除以另一个函数的导数遵循商法则:

这就引出了像这样的例子:

链式法则
下一个规则涉及函数的复合。当一个函数的输出作为另一个函数的输入时,我们就得到了函数的复合。链式法则适用于函数的复合,并且在神经网络的训练中至关重要。该规则是

我们将外函数的导数,使用g(x)作为变量,乘以内函数对x的导数。
作为第一个例子,考虑函数f(x) = (x² + 2x + 3)²。这是两个函数的复合吗?是的。让我们定义f(g) = g² 和 g(x) = x² + 2x + 3。那么,我们可以通过将f(g)中的每个g实例替换为关于x的g定义来找到f(x):g(x) = x² + 2x + 3。这就得到了
f(x) = g² = (x² + 2x + 3)²
这自然是我们最初的目标。为了找到f′(x),我们首先找到f′(g),即f对g的导数,然后乘以g′(x),即g对x的导数。最后一步,我们用关于x的g的定义来替换所有关于g的引用。所以,我们计算f′(x)为

我们通常不会明确指出f(g)和g(x),但在头脑中我们会经过相同的过程。让我们来看一些更多的例子。在这个例子中,我们想找到

如果我们使用链式法则,我们可以看到我们有f(g) = 2g² + 3 和 g(x) = 4x − 5。 因此,我们可以写出

其中f′(g) = 2g 和 g′(x) = 4。通过一些练习,我们可以在脑海中将 4x − 5 看作是f(x)的一个变量(即g的替代),然后记得在计算完成后乘以 4x − 5 的导数。如果我们没能看到复合函数呢?如果我们扩展整个函数f(x),然后再求导呢?我们最好能得到使用链式法则得出的答案。让我们来看一看……

这是我们上面找到的结果,所以我们应用链式法则是正确的。
让我们看一个新的例子。如果
,我们应该如何考虑计算导数?如果我们把这个函数看作
,其中 u(x) = 1 和 v(x) = 3x²,我们可以使用商法则得到如下结果。

这里,我们使用了 u 和 v 的简写表示法,省略了 x 的正式函数符号。
我们也可以将 f(x) 看作 (3x²)^(−1)。如果我们这样想,我们可以应用链式法则和幂法则得到

证明有时计算导数确实不止一种方法。
我们使用拉格朗日符号表示了链式法则。在本章稍后的部分,我们将使用莱布尼茨符号再次看到它。现在让我们继续,介绍一组三角函数的法则。
三角函数的法则
基本三角函数的导数是直接的:

如果我们将基本微分法则应用到正切的定义上,可以看到最后一个法则是正确的:

记住,sec x = 1/ cos x 和 sin² x + cos² x = 1。
让我们看一些使用新三角法则的例子。我们将从一个包含三角函数的复合函数开始:

我们可以看到这是一个 f(g) = sin(g) 和 g(x) = x³ − 3x 的复合函数,所以我们知道可以应用链式法则得到导数,即 f′(g)g′(x),其中 f′(g) = cos(g) 和 g′(x) = 3x² − 3。第二行简化了答案。
让我们看一个更复杂的复合函数:

这一次,我们把复合函数拆分为 f(g) = g² 和 g(x) = sin(x³ − 3x)。然而,g(x) 本身是一个复合函数,g(u) = sin(u) 和 u(x) = x³ − 3x,就像我们在前面的例子中所做的那样。所以,第一步是写出如下式子。

第一行是复合函数的导数定义。第二行代入了 f(g) 的导数,即 2g,第三行用 sin(x³ − 3x) 替换了 g(x)。现在,我们只需要找到 g′(x),我们可以通过第二次使用链式法则来做到这一点,其中 g(u) = sin u 和 u(x) = x³ − 3x,正如我们在上面的例子中所做的那样。这样我们得到 g′(x) = cos(x³ − 3x)(3x² − 3),所以现在我们知道 f′(x) 是

让我们做一个更多的例子。这个例子将涉及多个三角函数。我们想看看如何计算

正如在处理三角函数时经常出现的那样,恒等式起了作用。在这里,我们看到 f(x) 可以重写为:

现在,导数使用了三角函数法则、正割的定义、链式法则和乘积法则,如下所示。

让我们继续看一下指数和对数的导数。
指数和对数的规则
e^x的导数,其中e是自然对数的底数(e ≈ 2.718...),是特别简单的。它就是它本身:

当自变量是x的函数时,这变成了

如果ex*的导数是*ex,那么当a是除e以外的实数时,ax*的导数是什么?要看答案,我们需要记住*ex和自然对数 ln x,它们是以e为底的对数的反函数,所以e^(lna) = a。然后,我们可以写出
a^x = (e^(ln a))^(x) = *ex*(ln a)
我们现在知道如何从方程 7.3 中求出ex*(lna*)的导数。它是

但是e x ln a = a^x,因此我们有

并且一般来说,

注意,如果a = e,我们得到 ln(e) = 1,并且方程 7.4 变成方程 7.3。
现在让我们来看自然对数本身的导数。它是

当自变量是x的函数时,这变成了

你可能会想:我们如何求一个以e以外的底数为基础的对数的导数?例如,log[10] x的导数是什么?为了回答这个问题,我们做的事情与上面求a^x的导数类似。我们将* x的对数,底数为b*,表示为自然对数的形式,如下所示

在这里,ln b是一个常数,与x无关。此外,我们现在知道如何求 ln x的导数,所以我们看到 log[b] x的导数必须是

对于任何实数底数b ≠ 1。而且,更一般地,

在这里,我们再次注意到,如果b = e,我们得到 ln e = 1,并且方程 7.6 变成方程 7.5。
在方程 7.6 中,我们已经总结了导数的规则。现在让我们将这些规则整理成一个表格,方便在本书剩余部分进行查阅。结果就是表 7-1。
现在我们知道如何求导了。我鼓励你寻找带有解答的练习题,以确保自己理解规则并知道如何应用它们。接下来我们来看一下如何利用导数来求函数的最小值和最大值。找到最小值对神经网络的训练至关重要。
表 7-1: 求导规则
| 类型 | 规则 |
|---|---|
| 常数 | ![]() |
| 幂 | ![]() |
| 和式 | ![]() |
| 积 | ![]() |
| 商 | ![]() |
| 链式法则 | ![]() |
| 三角学 | ![]() |
| 指数 | ![]() |
| 对数 | ![]() |
函数的极小值和极大值
之前,我定义了驻点作为函数的一阶导数为零的地方,即切线的斜率为零的地方。我们可以利用这个信息来判断一个特定的点,记作 x[m],是否是函数 f(x) 的极小值或极大值。如果 x[m] 是极小值,那么它是函数的最低点,在这个点上 f(x[m]) 小于 f(x[m]) 左右相邻点的值。类似地,如果 f(x[m]) 高于 f(x[m]) 左右相邻点的值,那么 f(x[m]) 是极大值。我们统称极小值和极大值为 f(x) 的极值(单数形式为 极值点)。
从导数的角度看,极小值 是指 x[m] 左侧点的导数为负,而右侧点的导数为正。极大值 则相反:左侧的导数为正,右侧的导数为负。
回顾 图 7-1。在这里,我们大约在 x = −0.8 处有一个局部极大值,在 x = 0.3 处有一个局部极小值。假设最大值实际上在 x[m] = −0.8 处。这个点是极大值,因为如果我们查看 x[m] 附近的任何点 x[p],都会发现 f(x[p]) 小于 f(x[m])。同样地,如果最小值在 x[m] = 0.3 处,那是因为任何接近它的点 x[p] 都有 f(x[p]) > f(x[m])。如果我们想象切线沿着图形滑动,当它接近 x = −0.8 时,我们会看到斜率为正,但正朝向零。当我们越过 x = −0.8 时,斜率变为负数。最小值 x = 0.3 处则相反,左侧的切线斜率为负,但一旦越过 x = 0.3,斜率变为正数。
你会读到和听到术语全局和局部应用于极小值和极大值。f(x)的全局极小值是所有极小值中最低的,而全局极大值是所有极大值中最高的。因此,其他极小值和极大值被视为局部的;它们在特定区域有效,但还有更低的极小值或更高的极大值。我们应该注意,并非所有的函数都有极小值或极大值。例如,直线 f(x) = mx + b 就没有极小值或极大值,因为直线上的任何点都不满足极小值或极大值的要求。
所以,如果一阶导数 f′(x) 为零,我们就有极小值或极大值,对吧?并不一定。在其他驻点处,尽管一阶导数为零,但判断极小值或极大值的其他条件并不满足。这些点通常被称为拐点,或者如果是在多维空间中,则称为鞍点。例如,考虑 y = x³。其一阶导数为 y′ = 3x²,二阶导数为 y′′ = 6x。在 x = 0 时,第一和第二导数都为零。然而,正如我们在图 7-2 中看到的,x = 0 左右两侧的斜率都是正的。因此,斜率并没有从正变负或从负变正,意味着 x = 0 不是极值点,而是拐点。

图 7-2:显示 x = 0 处拐点的 y = x³ 图像
现在假设 x[s] 是一个驻点,所以 f′(x[s]) = 0。如果我们选择另外两个点,x[s−∊] 和 x[s+∊],分别位于 x[s] 左右两侧,且 ∊(ε)非常小,我们就可以得到四种可能的 f′(x[s][−∊]) 和 f′(x[s][+∊]) 的值,如表 7-2 所示。
表 7-2:识别驻点
| f’(x[s] – ∊), f’(x[s] + ∊) 的符号** | 驻点类型在 x[s] – ∊ < x[s] < x[s] + ∊ 时 |
|---|---|
| +, – | 极大值 |
| –, + | 极小值 |
| +, + | 既不是极小值也不是极大值 |
| –, – | 既不是极小值也不是极大值 |
因此,候选驻点的一阶导数值不足以告诉我们该点是否为极小值或极大值。我们可以观察候选点周围的区域来帮助我们做出判断。我们还可以查看 f′′(x),即 f(x) 的二阶导数。如果 x[s] 是一个驻点且 f′(x[s]) = 0,那么 f′′(x[s]) 的符号可以告诉我们 x[s] 可能是什么类型的驻点。如果 f′′(x[s]) < 0,那么 x[s] 是 f(x) 的极大值。如果 f′′(x[s]) > 0,那么 x[s] 是极小值。如果 f′′(x[s]) = 0,二阶导数没有帮助;我们需要用一阶导数显式地测试附近的点。
我们首先如何找到候选的驻点呢?对于代数函数,我们解 f′(x) = 0;我们找到所有使得 f′(x) 为零的 x 值的解集。然后,我们使用导数检验来决定这些点是极小值、极大值还是拐点。
对于许多函数,我们可以直接求解 f′(x) = 0。例如,如果 f(x) = x³ − 2x + 4,我们有 f′(x) = 3x² − 2。如果我们将其设为零,3x[2] − 2 = 0,并使用二次公式求解,我们发现有两个驻点:
和
。f(x) 的二阶导数是 f′′(x) = 6x。f′′(x[0]) 的符号为负,因此 x[0] 代表一个最大值。由于 f′′(x[1]) 的符号为正,x[1] 是一个最小值。
我们可以看到,导数测试是正确的。图 7-3 的上部分显示了 f(x) = x³ − 2x + 4 的图,其中 x[0] 是一个最大值,x[1] 是一个最小值。
让我们看一个例子。这次,我们有 f(x) = x⁵−2x³+x+2,见图 7-3 的底部图。我们找到一阶导数并将其设为零:
f′(x) = 5x⁴ − 6x² + 1 = 0
如果我们代入 u = x²,我们可以通过找到 5u²−6u+1 的根并将这些根设置为 x²来求解 f′(x) 的根。这样做得到!Image 和
,所以我们有四个驻点。为了测试它们,我们可以使用二阶导数测试。二阶导数是 f′′(x) = 20x³ − 12x。
将驻点代入 f′′ 计算得出:

意味着 x[0] 是一个最大值,x[1] 是一个最小值,x[2] 是另一个最大值,x[3] 是一个最小值。图 7-3 再次验证了我们的结论。

图 7-3: f(x) = x³ – 2x + 4(上)和 x⁵ – 2x³ + x + 2(下)的图,标出极值
如果我们无法轻易找到一个函数的驻点怎么办?也许我们无法代数地求解该函数,或者可能它不能以封闭形式表示,也就是说没有有限的操作集来表示它。典型的微积分课程通常不会关注这些情况。然而,我们需要关注这些问题,因为一种看待神经网络的方式是将其视为一个函数逼近器,它的函数无法直接表示。我们还能利用我们新学到的导数知识吗?答案是肯定的,我们可以。我们可以使用导数作为指示器,告诉我们如何越来越接近极值。这正是梯度下降法的作用,我们将在本书后面花大量时间讨论它。
目前,让我们继续研究多变量函数,并看看这对导数的概念有什么影响。
偏导数
到目前为止,我们只关注了一个变量的函数 x。当我们处理多个变量的函数时,f(x, y) 或 f(x[0], x[1], x[2], . . . , x[n]),导数的概念会发生什么变化呢?为了处理这些情况,我们将引入偏导数的概念。请注意,为了清晰起见,本节将使用莱布尼茨符号。
方程 7.2 定义了 f(x) 对 x 的导数。如果 x 是唯一的变量,为什么我们要加上“对 x 的偏导数”这句话?现在我们来看看为什么:在这个表达式中,某个变量的偏导数是通过将其他变量视为常数并固定它们来求得的。然后我们说我们正在计算对那个未固定的变量的偏导数。
让我们来看一个例子。设 f(x, y) = xy + x/y。然后,我们可以计算 两个 偏导数,一个是对 x 的偏导数,另一个是对 y 的偏导数:

我们在本章之前学过的微分法则仍然适用。注意,d 已经变成了 ∂。这表示函数 f 是多变量的。同时,看到在计算相应的导数时,我们将其他变量视作常数参数。这就是偏导数的计算方法。接下来,我们来看一些例子,帮助你更好地理解这个概念。
如果 f(x, y, z) = x² + y² + z² + 3xyz,我们可以得到三个部分导数:

其他两个变量被视为常数。这就是为什么例如在对 x 的部分导数中,y² 和 z² 变为 0,而 3xyz 变为 3yz。
如果
,我们有四个部分导数:

作为一个更复杂的例子,考虑 f(x, y) = e^(xy) cos x sin y。接下来的部分导数列出了我们在每个情况下使用乘积法则。

混合偏导数
就像单变量函数的导数一样,我们也可以对部分导数进行偏导数运算。这些称为混合偏导数。此外,我们有更多的灵活性,因为我们可以改变对哪一个变量进行下一步偏导数运算。例如,之前我们看到,z的部分导数是

这仍然是一个关于 x、y、z 和 t 的函数。因此,我们可以像这样计算第二阶部分导数:

我来解释一下符号。我们从 f 对 z 的偏导数开始,所以我们写 ∂f/∂z。然后,从这个起点出发,我们进行其他部分导数运算。所以,如果我们想表示对 x 的部分导数,我们可以这样理解:

在这里,我们可以将偏导数算符看作“乘”上“分子”和“分母”,就像分数一样。然而,需要明确的是,这些并不是分数;符号只是继承了其斜率起源的分数风格。尽管如此,如果这种记忆法对你有帮助,那就有帮助。对于二阶偏导数,与其求导的变量位于左侧。如果变量相同,还会使用某种形式的指数,例如 ∂²f/∂z²。
偏导数的链式法则
为了将链式法则应用于偏导数,我们需要追踪所有变量。因此,如果我们有 f(x, y),其中 x 和 y 都是其他变量的函数,x(r, s) 和 y(r, s),那么我们可以通过分别应用链式法则来求出 f 对 r 和 s 的偏导数,具体如下,

作为一个例子,设 f(x, y) = x³+y³,且 x(r, s) = 3r+2s 和 y(r, s) = r²−3s。现在求 ∂f/∂r 和 ∂f/∂s。为了求出这些偏导数,我们需要计算六个表达式,

这样所得到的偏导数是

就像单变量函数一样,多变量函数的链式法则是递归的,因此,如果 r 和 s 本身是另一个变量的函数,我们可以再应用一次链式法则,找到关于该变量的 f 的偏导数。例如,如果我们有 x(r, s),y(r, s),且 r(w),s(w),我们可以通过以下方式求出 ∂f/∂w:

最后,我们需要记住 ∂f/∂w 告诉我们 f 如何因 w 的微小变化而变化。我们将在梯度下降中使用这个事实。
本节关注的是偏导数的机械计算。让我们继续探索这些量背后的更多含义。这将引导我们到梯度的概念。
梯度
在第八章中,我们将深入探讨深度学习中使用的矩阵微积分表示法。然而,在此之前,我们将通过引入梯度的概念来结束本章内容。梯度建立在我们之前计算的导数基础上。简而言之,梯度告诉我们一个多变量函数如何变化,以及它变化最快的方向。
计算梯度
如果我们有 f(x, y, z),我们上面已经展示了如何计算每个变量的偏导数。如果我们将这些变量解释为坐标轴上的位置,我们就能看出 f 是一个返回标量(单一数值)的函数,这个数值适用于三维空间中的任意位置 (x, y, z)。我们甚至可以写作 f(x),其中 x = (x, y, z) 来表明 f 是一个向量输入的函数。像前几章一样,我们将使用加粗小写字母表示向量,x。注意,有些人使用
来表示向量。
我们可以将向量横向写成行向量,像前一段那样,或者纵向写成列向量,

在列向量中,我们还使用了方括号而不是圆括号。两种表示法都是可以接受的。除非我们故意草率,通常在讨论代码中的向量时,我们会假设我们的向量是列向量。这意味着向量是一个 n 行一列的矩阵,即 n × 1。
接受向量输入并返回单一数值输出的函数称为 标量场。标量场的典型例子是温度。我们可以测量房间中任意一点的温度。我们将位置表示为相对于某个选定原点的三维向量,温度则是该点的值,即该区域分子平均动能的大小。我们也可以讨论接受向量作为输入并返回向量作为输出的函数,这些称为 矢量场。在这两种情况下,场一词指的是在某个适当的定义域内,函数对所有输入都有值。
梯度是接受向量作为输入的函数的导数。从数学上讲,我们将梯度表示为偏导数概念在 n 维空间中的推广。例如,在三维空间中,我们可以写作

梯度算子 ▽ 对 f 在每个维度上的偏导数进行操作。▽ 算子有多个名字,如 del、grad 或 nabla。当我们不单纯说“梯度算子”时,我们将使用▽并称之为 del。
通常,我们可以写作

让我们解析一下 方程 7.7。首先,我们有一个函数 f,它接受一个向量输入 x,并返回一个标量值。对这个函数,我们应用梯度算子:

这会返回一个 向量 (y)。梯度算子将 f 的标量输出转化为向量。让我们花些时间思考这意味着什么,它告诉我们关于标量场在给定位置的值的信息。(当我们处理向量时,我们会使用 空间 一词,即使没有直观的方式去可视化这个空间。虽然三维空间的类比有帮助,但它的意义有限;在数学上,空间的概念更为广泛。)
举个例子,考虑一个二维空间中的函数,f(x) = f(x, y) = x² + xy + y²。此时,f 的梯度为

由于 f 是一个标量场,二维平面上的每个点都有一个函数值。这就是 f(x) = f(x, y) 的输出。因此,我们可以在三维空间中绘制 f,以展示它随位置变化的表面。然而,梯度则给出了一组方程。这些方程集体告诉我们在点 x = (x, y) 处,函数值变化的方向和大小。
对于单变量函数,每个点只有一个斜率。再看看图 7-1 中的切线。在 x[t] 处,只有一个斜率。导数的符号表示斜率的方向,而导数的绝对值表示斜率的大小(陡度)。
然而,一旦我们转到多维空间,就会遇到一些难题。我们不仅有一个斜率切线,而是有无数个。我们可以想象在某个点上有一条切线,并且这条线可以指向我们希望的任何方向。该线的斜率告诉我们函数值在特定方向上的变化。我们可以通过方向导数来计算这种变化,即该点处的梯度与我们感兴趣方向的单位向量之间的点积:
D[u]f(x) ≡ u• ▽f(x) = u^T▽f(x) = ||u||||▽f(x)|| cos θ
其中,u 是一个特定方向上的单位向量,▽f(x) 是该点 x 处的函数梯度,θ 是它们之间的角度。当 cos θ 最大时,方向导数也最大,这发生在 θ = 0 时。因此,函数在任何点的最大变化方向就是该点的梯度方向。
举个例子,假设我们选择二维平面上的一个点,比如 x = (x, y) = (0.5, −0.4),并且根据上面给出的 f(x, y) = x² + xy + y²。此时,函数值在 x 处为 x² + xy + y² = (0.5)² + (0.5)(−0.4) + (−0.4)² = 0.21,这是一个标量值。然而,梯度在该点的值为

因此,我们现在知道,在点 (0.5, −0.4) 处,f 的最大变化方向是 (0.6, −0.3) 方向,且其大小为
。
梯度的可视化
让我们将这一切变得不那么抽象。图 7-4 的顶部展示了在选定点上,f(x, y) = x² + xy + y² 的图像。

图 7-4:x² + xy + y² 的图像(顶部)以及相关梯度场的二维投影(底部)
生成此图的代码很简单:
import numpy as np
x = np.linspace(-1.0,1.0,50)
y = np.linspace(-1.0,1.0,50)
xx = []; yy = []; zz = []
for i in range (50):
for j in range (50):
xx.append(x[i])
yy.append(y[j])
zz.append(x[i]*x[i]+x[i]*y[j]+y[j]*y[j])
x = np.array(xx)
y = np.array(yy)
z = np.array(zz)
在这里,我们明确地进行循环,生成散点图的x、y和z值,以清楚地展示发生了什么。首先,我们使用 NumPy 生成 50 个均匀分布的点,在x和y上定义范围[−1, 1]。然后,我们设置双重循环,以便每个x与每个y配对计算函数值z。临时列表xx、yy和zz存储这些三元组。最后,我们将这些列表转换为 NumPy 数组,以便进行绘图。
生成散点图的代码是
from mpl_toolkits.mplot3d import Axes3D
import matplotlib.pylab as plt
fig = plt.figure()
ax = fig.add_subplot(111, projection='3d')
ax.scatter(x, y, z, marker='.', s=2, color='b')
ax.view_init(30,20)
plt.draw()
plt.show()
我们首先加载matplotlib扩展库来进行 3D 绘图,然后设置 3D 投影的子图。图形本身是通过ax.scatter生成的,而ax.view_init和plt.draw则旋转图形,以便在展示之前给我们更好的函数形状视图。
在图 7-4 的底部,我们可以看到函数x² + xy + y²的梯度场向量图。该图展示了在网格点(x,y)上梯度向量的方向和相对大小。回想一下,梯度是一个向量场,因此xy平面上的每个点都有一个指向函数值变化最快方向的向量。通过视觉上,我们可以看到梯度向量图与图 7-4 顶部函数图的关系,其中靠近(−1,−1)和(1,1)点的函数值变化很快,而靠近(0,0)点的函数值变化很慢。
生成向量场图的代码是
fig = plt.figure()
ax = fig.add_subplot(111)
x = np.linspace(-1.0,1.0,20)
y = np.linspace(-1.0,1.0,20)
xv, yv = np.meshgrid(x, y, indexing='ij', sparse=False)
dx = 2*xv + yv
dy = 2*yv + xv
ax.quiver(xv, yv, dx, dy, color='b')
plt.axis('equal')
plt.show()
我们首先定义图形(fig)和 2D 子图(没有projection关键字)。然后,我们需要一组点的网格。上面我们通过循环获取这个网格,以便理解需要生成什么内容。在这里,我们通过np.meshgrid使用 NumPy 生成网格。请注意,我们传递给np.meshgrid的仍然是上面定义的x和y向量,用于定义域。
接下来的两行代码是对f的梯度的直接实现,方程 7.8。这些是我们希望绘制的向量,其中dx和dy给出了方向和大小,而xv和yv是输入点的集合——总共 400 个。
该图使用ax.quiver(因为它绘制的是箭头)。参数是点的网格(xv、yv)以及在这些点上的向量的相关x和y值(dx、dy)。最后,我们确保坐标轴相等(plt.axis),以避免扭曲向量显示,然后展示图形。
我们将在这里结束对梯度的介绍。我们将在本书的剩余部分再次看到它们,包括在第八章的符号表示和第十一章的梯度下降讨论中。
总结
本章介绍了微分学的主要概念。我们从斜率的概念开始,并学习了单变量函数的割线和切线的区别。然后我们正式定义了导数,即割线斜率趋近于一个点时的斜率。从这里开始,我们学习了微分的基本规则,并看到了如何应用它们。
接下来,我们学习了函数的极小值和极大值,以及如何通过导数找到这些点。然后我们引入了偏导数,作为计算多变量函数导数的一种方法。偏导数进一步引导我们进入梯度的概念,梯度将标量场转化为向量场,并告诉我们函数变化最剧烈的方向。我们在二维中计算了一个示例梯度,并展示了如何生成图形,显示函数与梯度之间的关系。我们学到了一个至关重要的事实:函数的梯度指向在某一点上函数值变化最快的方向。
让我们继续探索深度学习背后的数学,进入矩阵微积分的世界。
第八章:矩阵微积分**

第七章向我们介绍了微分学。在本章中,我们将讨论矩阵微积分,它将微分扩展到涉及向量和矩阵的函数。
深度学习广泛使用向量和矩阵,因此开发一种表示涉及这些对象的导数的符号和方法是有意义的。这就是矩阵微积分所提供的东西。在第七章的结尾,我们引入了梯度来表示标量函数关于向量的导数——这是一个接受向量参数并返回标量的函数,f(x)。
我们将从矩阵微积分导数及其定义的表格开始。接下来,我们将研究一些涉及矩阵导数的恒等式。数学家喜欢恒等式;然而,为了保持理智,我们只考虑少数几个。在矩阵微积分中,出现了一些特殊矩阵,即雅可比矩阵和海森矩阵。在你深入学习深度学习的过程中,你会遇到这两种矩阵,因此我们将在优化的背景下讨论它们。回想一下,训练神经网络本质上是一个优化问题,因此理解这些特殊矩阵的含义以及我们如何使用它们尤其重要。我们将以一些矩阵导数的例子结束本章。
公式
表 8-1 总结了我们将在本章中探讨的矩阵微积分导数。这些是实践中常用的导数。
表 8-1: 矩阵微积分导数
| 标量 | 向量 | 矩阵 | |
|---|---|---|---|
| 标量 | ∂f/∂x | ∂f/∂x | ∂F/∂x |
| 向量 | ∂f/∂x | ∂f/∂x | — |
| 矩阵 | ∂f/∂X | — | — |
表 8-1 的列表示函数,意味着返回值。请注意,我们使用了三种版本的字母 f:普通、粗体和大写。我们使用f表示返回值为标量,f表示返回值为向量,F表示返回值为矩阵。表 8-1 的行表示导数是相对于哪些变量计算的。相同的符号规则适用:x是标量,x是向量,X是矩阵。
表 8-1 定义了六个导数,但表格中有九个单元格。虽然可以定义其余的导数,但它们并未标准化,也不常用,因此不值得覆盖它们。对我们来说,这是个好事,因为这六个已经足够挑战我们的数学大脑了。
表 8-1 中的第一个导数,即左上角的那个,是第七章中的标准导数,表示关于标量的标量函数的导数。(参阅第七章了解所有关于标准微分的内容。)
我们将在下面的章节中介绍其余五个导数。我们将以标量导数的形式定义每一个。我们将首先展示定义,然后解释符号的含义。定义将帮助你在脑海中建立起对导数的模型。我猜测在本节结束时,你将能够预见到这些定义。
然而,在我们开始之前,有一个问题需要讨论。矩阵微积分的符号较为复杂,而且在符号的使用上没有统一的标准。我们以前已经看到过,表示微分的方法有很多种。对于矩阵微积分,有两种方法——分子布局和分母布局。不同学科似乎偏好其中一种,尽管例外几乎成了常态,符号混合也很常见。就深度学习而言,我个人的一些非正式观察显示,分子布局稍微更为流行,所以我们在这里将采用这种布局。只是要注意,市面上有两种符号体系,其中一种通常是另一种的转置。
由标量参数定义的向量函数
接受标量参数的向量函数是我们的第一个导数;参见 表 8-1,第一行第二列。我们将这样的函数写作 f(x),以表示标量参数 x 和向量输出 f。像 f 这样的函数接受标量并将其映射到多维向量:
f : ℝ → ℝ^(m)
在这里,m 是输出向量中元素的数量。像 f 这样的函数被称为具有标量参数的向量值函数。
三维空间中的参数曲线是此类函数的一个优秀例子。这些函数通常写作

其中
,
, 和
是 x、y 和 z 方向上的单位向量。
图 8-1 显示了一个三维参数曲线的图像,

在这里,随着 t 的变化,三个轴的值也会变化,从而描绘出螺旋曲线。每个 t 值指定三维空间中的一个点。

图 8-1:三维参数曲线
在矩阵微积分符号中,我们不会像在 方程 8.1 中那样写 f。相反,我们将 f 写作函数的列向量,

一般来说,

对于具有 n 个元素的 f。
f(x) 的导数被称为切向量。导数是什么样的?由于 f 是一个向量,我们可能期望 f 的导数是代表每个元素的函数的导数,事实也的确如此:

让我们看一个简单的例子。首先,我们将定义 f(x),然后是导数:

在这里,∂f/∂x 的每个元素是 f 中相应函数的导数。
通过向量参数的标量函数
在第七章中,我们学到了一个接受向量输入但返回标量的函数是标量场:
f : ℝ^(m) → ℝ
我们还学到了该函数的导数是梯度。在矩阵微积分符号中,我们写作 ∂f/∂x,表示 f(x)。

其中 x = [x[0] x[1] ... x[m–1]]^⊤ 是一个变量向量,f 是这些变量的函数。
请注意,由于我们决定使用分子布局方法,∂f/∂x 被写作一个 行 向量。因此,为了符合我们的符号记法,我们必须写作

将行向量转化为列向量,以与梯度匹配。记住,▽是梯度的符号;我们在第七章中看到过梯度的例子。
通过向量的向量函数
如果通过标量对向量值函数求导得到列向量,而通过向量对标量函数求导得到行向量,那么通过向量对向量值函数求导是否得到矩阵?答案是肯定的。在这种情况下,我们考虑的是 ∂f/∂x,这是一个接受向量输入并返回向量的函数。
分子布局约定给了我们 f(x) 导数的列向量,这意味着我们需要为 f 中的每个函数准备一行。类似地,f(x) 的导数生成了一个行向量。因此,合并这两者就得到了 f(x) 的导数:

这是对于一个函数 f 的情况,返回一个 n 元素的向量并接受一个 m 元素的向量 x 作为其自变量:
f : ℝ^(m) → ℝ^(n)
f 的每一行都是一个 x 的标量函数,例如,f0。因此,我们可以将方程 8.2 写作

这给我们提供了一个矩阵,作为每个标量函数在 f 中梯度的集合。我们将在本章稍后讨论这个矩阵。
通过标量的矩阵函数
如果 f(x) 是一个接受标量参数但返回向量的函数,那么我们可以正确地假设 F(x) 可以看作是一个接受标量参数并返回矩阵的函数:
F : ℝ → ℝ^(n×m)
例如,假设 F 是一个 n × m 的标量函数矩阵:

对于自变量 x 的导数,计算过程很简单:

正如我们上面所看到的,通过标量对向量值函数求导,得到的是切向量。类比地,通过标量对矩阵值函数求导,得到的是 切向矩阵。
通过矩阵的标量函数
现在让我们考虑 f(X),一个接受矩阵并返回标量的函数:
f : ℝ^(n×m) → ℝ
我们可以认为F关于矩阵X的导数本身就是一个矩阵,这样的理解是正确的。然而,为了符合我们的分子排列惯例,结果矩阵并不是像X那样排列,而是像X^⊤,即X的转置。
为什么使用X的转置而不是X本身?为了回答这个问题,我们需要回顾一下我们如何定义∂f/∂x。即使x是列向量,根据标准惯例,我们表示导数是一个行向量。我们使用了x^⊤作为排列方式。因此,为了保持一致性,我们需要将∂f/∂X按照X的转置排列,并将X的列转化为导数中的行。结果,我们得到了以下定义。

这是一个m × n的输出矩阵,针对m × n的输入矩阵X。
方程式 8.4 定义了梯度矩阵,对于矩阵,它起着类似于梯度▽f(x)的作用。方程式 8.4 还完整地列出了我们的矩阵微积分导数。接下来,我们将考虑一些矩阵导数恒等式。
等式
矩阵微积分涉及标量、向量、矩阵及其函数,这些函数自身返回标量、向量或矩阵,意味着存在许多恒等式和关系。然而,在这里,我们将集中于展示矩阵微积分与第七章的微分微积分之间关系的基本恒等式。
以下每个子节展示了与所指示的特定类型的导数相关的恒等式。这些恒等式涵盖了基本关系,并在适用时涉及链式法则。在所有情况下,结果都遵循我们在本章中使用的分子排列方案。
标量函数与向量
我们从与标量函数和向量输入相关的恒等式开始。如果没有特别说明,f和g是关于向量x的函数,并返回一个标量。一个不依赖于x的常量向量表示为a,而a表示一个标量常数。
基本规则是直观的:

以及

这些表明,乘以标量常数的运算与在第七章中的表现一致,偏导数的线性性质也同样如此。
乘积法则也像我们预期的那样有效:

在这里稍作停顿,提醒自己上面方程的输入和输出。我们知道,标量函数关于向量变量的导数在我们的符号中是一个行向量。因此,方程式 8.5 返回的是一个行向量乘以一个标量——导数的每个元素都乘以a。
由于微分是一个线性算子,它对加法分配,因此方程式 8.6 会产生两个项,每个项都是由各自的导数生成的行向量。
对于 方程 8.7,乘积法则,结果再次包含两个项。在每种情况下,导数返回一个行向量,该向量乘以一个标量函数值,可能是 f(x) 或 g(x)。因此,方程 8.7 的输出也是一个行向量。
标量对向量的链式法则变为

f(g) 返回一个标量并接受一个标量参数,而 g(x) 返回一个标量并接受一个向量参数。最终结果是一个行向量。让我们通过一个完整的例子来演示。
我们有一个向量,x = [x[0], x[1], x[2]]^⊤;该向量的一个分量形式的函数,g(x) = x[0] + x[1]x[2];以及 g 的一个函数,f(g) = g²。根据 方程 8.8,f 对 x 的导数是

为了检查我们的结果,我们可以从 g(x) = x[0] + x[1]x[2] 和 f(g) = g² 开始,通过代入直接求出 f(x)。这样做得出的结果是

从中我们得到

这与我们使用链式法则得到的结果相匹配。当然,在这个简单的例子中,先进行代入再求导更容易,但我们一样证明了我们的结论。
然而,我们还没有完全解决标量对向量的恒等式。点积操作接受两个向量并产生一个标量,因此它适配我们正在处理的函数形式,尽管点积的参数是向量。
例如,考虑这个结果:

这里,我们有 x 和一个与 x 无关的向量 a 之间的点积的导数。
我们可以扩展 方程 8.9,将 x 替换为向量值函数 f(x):

这个结果的形式是什么?假设 f 接受一个 m 元素的输入并返回一个 n 元素的向量输出。同样,假设 a 是一个 n 元素的向量。从 方程 8.2,我们知道导数 ∂f/∂x 是一个 n × m 矩阵。因此,最终结果是一个 (1 × n) × (n × m) → 1 × m 的行向量。很好!我们知道,当使用分子布局约定时,标量函数对向量的导数应该是一个行向量。
最后,两个向量值函数 f 和 g 的点积的导数是

如果 方程 8.10 是一个行向量,那么像它这样的两个项的和也是一个行向量。
标量乘以向量的函数
向量对标量的微分,表 8-1,第一行,第二列,在机器学习中较少见,因此我们只会检查一些恒等式。首先是常数乘法:

请注意,我们可以通过左乘矩阵,因为导数是一个列向量。
求和规则仍然适用,

就像链式法则一样,

方程 8.12 是正确的,因为标量对向量的导数是一个列向量,而向量对向量的导数是一个矩阵。因此,将右侧的矩阵与列向量相乘会返回一个列向量,正如预期的那样。
另有两个涉及标量的点积导数是值得了解的。第一个与方程 8.11 相似,但有两个标量值函数:

第二次导数涉及f(g)和g(x)关于x的合成:

这是一个行向量和列向量的点积。
向量函数与向量的关系
向量值函数与向量参数的导数在物理学和工程学中很常见。在机器学习中,它们通常出现在反向传播中,例如,在损失函数的导数中。让我们从一些简单的恒等式开始:

和

其中结果是两个矩阵的和。
接下来是链式法则,它的作用与上述标量对向量和向量对标量的导数相同:

结果是两个矩阵的乘积。
一个标量函数与矩阵的关系
对于返回标量的矩阵函数X,我们有求和规则的常规形式:

结果是两个矩阵的和。回想一下,如果X是一个n × m的矩阵,那么分子布局符号中的导数是一个m × n的矩阵。
乘积法则也按预期工作:

然而,链式法则有所不同。它依赖于f(g),一个接受标量输入的标量函数,以及g(X),一个接受矩阵输入的标量函数。在这个限制下,链式法则的形式看起来很熟悉:

让我们来看一下方程 8.13 的应用。首先,我们需要X,一个 2 × 2 的矩阵:

接下来,我们需要
和g(X) = x[0]x[3] + x[1]x[2]。注意,虽然g(X)接受矩阵输入,但结果是一个标量,由矩阵的值计算得出。
为了应用链式法则,我们需要两个导数,

我们再次使用分子布局来表示结果。
为了找到整体结果,我们计算

为了验证,我们将这些函数结合起来写成一个单一的函数,
,并使用标准的链式法则对结果矩阵中的每个元素进行求导。这会给我们

与之前的结果相匹配。
我们已经有了定义和恒等式。让我们重新审视一下带有向量参数的向量值函数的导数,因为得到的矩阵是特殊的。我们将在深度学习中频繁遇到它。
雅可比矩阵与赫西矩阵
方程 8.2 定义了带有向量参数x的向量值函数f的导数:

这个导数被称为雅可比矩阵,J,或简称雅可比,你会在深度学习文献中时常遇到它,特别是在讨论梯度下降和其他用于训练模型的优化算法时。雅可比矩阵有时会带有下标,表示它是相对于某个变量的;例如,J[x] 表示相对于x。当上下文清晰时,我们通常会省略下标。
在本节中,我们将讨论雅可比矩阵及其含义。然后我们将介绍另一个矩阵——赫西矩阵(或简称赫西),它基于雅可比矩阵,并学习如何在优化问题中使用它。
本节的核心内容如下:雅可比矩阵是第一导数的概括,赫西矩阵是第二导数的概括。
关于雅可比矩阵
我们之前看到,我们可以将方程 8.14 看作是转置梯度向量的堆叠(方程 8.3):

将雅可比矩阵看作是梯度堆叠,给了我们一些线索,帮助理解它的意义。回想一下,标量场的梯度,一个接受向量参数并返回标量的函数,指向函数最大变化的方向。类似地,雅可比矩阵为我们提供了有关向量值函数在某一点x[p]附近行为的信息。雅可比矩阵对于向量值函数和向量的关系,就像梯度对于标量值函数和向量的关系;它告诉我们函数如何随着x[p]位置的小变化而变化。
一种理解雅可比矩阵的方法是将其视为我们在第七章中遇到的更具体情况的概括。表 8-2 展示了函数与其导数所度量的关系。
表 8-2: 雅可比矩阵、梯度和斜率之间的关系
| 函数 | 导数 |
|---|---|
| f(x) | ∂f/∂x, 雅可比矩阵 |
| f(x) | ∂f/∂x,梯度向量 |
| f(x) | df/dx,斜率 |
雅可比矩阵是三者中最一般的。如果我们将函数限制为标量,则雅可比矩阵变为梯度向量(分子形式的行向量)。如果我们将函数和参数都限制为标量,梯度则变为斜率。从某种意义上说,它们都表示相同的东西:函数在空间中某一点周围如何变化。
雅可比矩阵有许多用途。这里我将展示两个例子。第一个来自微分方程系统。第二个使用牛顿法来寻找向量值函数的根。当我们讨论反向传播时,还会再次看到雅可比矩阵,因为反向传播需要计算相对于向量的向量值函数的导数。
自治微分方程
微分方程将导数和函数值结合在一个方程中。微分方程在物理学和工程学中随处可见。我们的例子来自自治系统的理论,它是微分方程的一种形式,其中独立变量不出现在方程的右侧。例如,如果系统由函数的值和关于时间 t 的一阶导数组成,则系统的方程中没有显式的 t。
前面的段落只是背景介绍;你不需要记住它。处理自治微分方程系统最终会导致雅可比矩阵,这就是我们的目标。我们可以将系统看作一个向量值函数,我们将使用雅可比矩阵来描述该系统的临界点(即导数为零的点)。我们在第七章中处理了函数的临界点。
例如,让我们探讨以下方程组:

这个系统包括两个函数,x(t) 和 y(t),它们是耦合的,即 x(t) 的变化率依赖于 x 和 y 的值,反之亦然。
我们将系统视为一个单一的向量值函数:

在这里,我们将 x 替换为 x[0],将 y 替换为 x[1]。
f表示的系统在f = 0的地方具有临界点,其中0是 2 × 1 维的零向量。临界点是

其中将每个点代入f后得到零向量。暂时假设我们已经得到了临界点,现在我们想要描述它们。
为了描述一个临界点,我们需要f生成的雅可比矩阵:

由于雅可比矩阵描述了函数在某一点附近的行为,因此我们可以用它来描述临界点。在第七章中,我们使用导数来判断一个点是函数的最小值还是最大值。对于雅可比矩阵,我们通过使用J的特征值来以类似的方式讨论临界点的类型和稳定性。
首先,让我们在每个临界点处求雅可比矩阵:

我们可以使用 NumPy 来获得雅可比矩阵的特征值:
>>> import numpy as np
>>> np.linalg.eig([[4,0],[0,2]])[0]
array([4., 2.])
>>> np.linalg.eig([[2,0],[1,-2]])[0]
array([-2., 2.])
>>> np.linalg.eig([[0,-4],[2,-4]])[0]
array([-2.+2.j, -2.-2.j])
我们在第六章中遇到了np.linalg.eig。特征值是eig返回的第一个值,因此函数调用中有[0]的下标。
对于一组自治微分方程的临界点,特征值表明了点的类型和稳定性。如果两个特征值是实数并且符号相同,那么临界点是一个节点。如果特征值小于零,那么节点是稳定的;否则,它是不稳定的。你可以将稳定节点看作是一个坑;如果你接近它,你会掉进去。一个不稳定的节点像一座山;如果你从顶部偏离临界点,你会掉下来。第一个临界点c[0]的特征值为正实数,因此它代表一个不稳定的节点。
如果雅可比矩阵的特征值是实数但符号相反,那么临界点就是鞍点。我们在第七章中讨论了鞍点。鞍点最终是不稳定的,但在二维空间中,有一个方向你可以“掉进”鞍点,另一个方向则可以“从”鞍点“掉出来”。一些研究者认为,在训练深度神经网络时,找到的大多数最小值实际上是损失函数的鞍点。我们看到临界点c[1]是一个鞍点,因为特征值是实数且符号相反。
最后,c[2]的特征值是复数。复特征值表示一个螺旋(也叫焦点)。如果复特征值的实部小于零,那么螺旋是稳定的;否则,它是不稳定的。由于特征值是彼此的复共轭,实部的符号必须相同;不能有一个是正的,而另一个是负的。对于c[2],实部是负的,因此c[2]表示一个稳定的螺旋。
牛顿法
我通过公理性的方法给出了方程 8.15 的临界点。系统足够简单,我们可以通过代数方法求解临界点,但通常情况下情况并非如此。一个经典的求解函数根的方法(即函数值为零的点)被称为牛顿法。这是一种使用一阶导数和初始猜测值来逼近根的迭代方法。我们先看一下它在一维空间中的应用,然后再扩展到二维空间。我们将看到,进入二维或更高维空间时,需要使用雅可比矩阵。
让我们用牛顿法来求解 2 的平方根。为此,我们需要一个方程,使得
。稍加思考,我们得到了一个方程:f(x) = 2 − x²。显然,当
。
牛顿法在一维中的控制方程是

其中 x[0] 是对解的某个初始猜测值。
我们将x[0]代入方程 8.18 右侧来求得x[1]。然后我们重复使用x[1]代入右侧来得到x[2],以此类推,直到我们看到x[n]的变化非常小。此时,如果我们的初始猜测合理,我们就得到了我们想要的值。牛顿法收敛很快,所以对于典型的例子,我们只需要几次迭代。当然,我们手头有强大的计算机,所以我们会使用它们,而不是手工计算。我们需要的 Python 代码在清单 8-1 中。
import numpy as np
def f(x):
return 2.0 - x*x
def d(x):
return -2.0*x
x = 1.0
for i in range(5):
x = x - f(x)/d(x)
print("%2d: %0.16f" % (i+1,x))
print("NumPy says sqrt(2) = %0.16f for a deviation of %0.16f" %
(np.sqrt(2), np.abs(np.sqrt(2)-x)))
清单 8-1:通过牛顿法求解
的值
清单 8-1 定义了两个函数。第一个,f(x),返回给定x的函数值。第二个,d(x),返回x处的导数。如果f(x) = 2 − x²,则f′(x) = −2x。
我们的初始猜测是 x = 1.0。我们对方程 8.18 进行五次迭代,每次打印当前的平方根 2 的估计值。最后,我们使用 NumPy 计算真实值,并查看我们与真实值的偏差。
运行清单 8-1 产生了
1: 1.5000000000000000
2: 1.4166666666666667
3: 1.4142156862745099
4: 1.4142135623746899
5: 1.4142135623730951
NumPy says sqrt(2) = 1.4142135623730951 for a
deviation of 0.0000000000000000
结果令人印象深刻;我们仅用五次迭代就得到了
,精确到 16 位小数。
为了将牛顿法扩展到向量值函数的向量问题(如方程 8.15),我们用雅可比矩阵的逆替代了导数的倒数。为什么是逆矩阵?回想一下,对于一个对角矩阵,逆矩阵是对角元素的倒数。如果我们将标量导数视为一个 1 × 1 矩阵,那么倒数和逆矩阵是相同的。方程 8.18 已经使用了雅可比矩阵的逆,尽管它是一个 1 × 1 矩阵的逆。因此,我们将进行迭代

对于合适的初始值,x[0],以及在x[n]处计算的雅可比矩阵的逆。让我们使用牛顿法来找到方程 8.15 的临界点。
在我们编写 Python 代码之前,我们需要方程 8.17 中的雅可比矩阵的逆。一个 2 × 2 矩阵的逆,

是

假设行列式不为零。A的行列式是ad − bc。因此,方程 8.17 的逆矩阵是

现在我们可以编写代码了。结果见清单 8-2。
import numpy as np
def f(x):
x0,x1 = x[0,0],x[1,0]
return np.array([[4*x0-2*x0*x1],[2*x1+x0*x1-2*x1**2]])
def JI(x):
x0,x1 = x[0,0],x[1,0]
d = (4-2*x1)*(2-x0-4*x1)+2*x0*x1
return (1/d)*np.array([[2-x0-4*x1,2*x0],[-x1,4-2*x0]])
x0 = float(input("x0: "))
x1 = float(input("x1: "))
❶ x = np.array([[x0],[x1]])
N = 20
for i in range(N):
❷ x = x - JI(x) @ f(x)
if (i > (N-10)):
print("%4d: (%0.8f, %0.8f)" % (i, x[0,0],x[1,0]))
清单 8-2:二维牛顿法
清单 8-2 是对 1D 情况的清单 8-1 的回显。我们有f(x)来计算给定输入向量的函数值,JI(x)来提供在x处的雅可比矩阵的逆。注意,f(x)返回一个列向量,JI(x)返回一个 2 × 2 矩阵。
代码首先要求用户输入初始猜测值,x0和x1。这些值会形成初始向量x。注意,我们显式地将x形成列向量 ❶。
接下来是方程 8.19 的实现 ❷。逆雅可比矩阵是一个 2 × 2 的矩阵,我们将其与函数值(一个 2 × 1 的列向量)相乘,使用 NumPy 的矩阵乘法运算符 @。结果是一个 2 × 1 的列向量,从当前的 x 值(也是一个 2 × 1 的列向量)中减去。如果循环在 10 次迭代内完成,当前值会在控制台打印出来。
清单 8-2 是否有效?让我们运行它,看看是否能找到初始猜测,从而找到每个关键点(方程 8.16)。对于初始猜测
,我们得到
11: (0.00004807, -1.07511237)
12: (0.00001107, -0.61452262)
13: (0.00000188, -0.27403667)
14: (0.00000019, -0.07568702)
15: (0.00000001, -0.00755378)
16: (0.00000000, -0.00008442)
17: (0.00000000, -0.00000001)
18: (0.00000000, -0.00000000)
19: (0.00000000, -0.00000000)
这是方程 8.15 的第一个关键点。为了找到剩余的两个关键点,我们需要小心选择初始猜测。一些猜测会爆炸,而许多猜测会导致返回零向量。然而,通过一些试错法,我们得到了

这表明牛顿法可以找到方程 8.15 的关键点。
我们从一个微分方程系统开始,并将其解释为一个向量值函数。然后我们使用雅可比矩阵来表征该系统的关键点。接下来,我们第二次使用雅可比矩阵通过牛顿法定位系统的关键点。我们之所以能够这样做,是因为雅可比矩阵是梯度对向量值函数的推广,而梯度本身是标量函数一阶导数的推广。如上所述,当我们讨论反向传播时,还会看到雅可比矩阵,第十章中会详细介绍。
关于 Hessian
如果雅可比矩阵类似于单变量函数的第一导数,那么Hessian 矩阵就像第二导数。在这种情况下,我们限制在标量场上,即对于向量输入返回标量值的函数。让我们从定义开始,然后从那里出发。对于函数 f(x),Hessian 定义为

其中 x = [x[0] x[1] . . . x[n–1]]^⊤。
查看方程 8.20 告诉我们 Hessian 是一个方阵。此外,它是对称矩阵,意味着 H = H^⊤。
Hessian 是标量场梯度的雅可比矩阵:
H[f] = J(▽f)
让我们用一个例子来看这个。考虑这个函数:

如果我们直接使用方程 8.20 中对 Hessian 的定义,我们可以看到
,因为 ∂f/∂x[0] = 4x[0] + x[2]。类似的计算给出了其余的 Hessian 矩阵:

在这种情况下,Hessian 是常数,而不是 x 的函数,因为 f(x) 中变量的最高次幂是 2。
使用列向量定义的 f(x) 的梯度是

梯度的雅可比矩阵给出了以下内容,这与我们通过直接使用公式 8.20 找到的矩阵是相同的。

最小值和最大值
我们在第七章中看到,我们可以利用二阶导数来测试一个函数的临界点是最小值(f′′ > 0)还是最大值(f′′ < 0)。我们将在下一节看到如何在优化问题中使用临界点。现在,让我们通过考虑海森矩阵的特征值来使用海森矩阵找到临界点。我们将继续使用上面的例子。海森矩阵是 3 × 3 矩阵,意味着它有三个(或更少)特征值。为了节省时间,我们将使用 NumPy 来告诉我们它们是什么:
>>> np.linalg.eig([[4,0,1],[0,-2,3],[1,3,0]])[0]
array([ 4.34211128, 1.86236874, -4.20448002])
三个特征值中有两个是正的,一个是负的。如果三个特征值都是正的,那么临界点将是最小值。同样,如果三个特征值都是负的,临界点将是最大值。请注意,最小值/最大值的标签与符号相反,就像单变量的情况一样。然而,如果至少有一个特征值是正的,另一个是负的(正如我们例子中的情况),那么临界点就是鞍点。
自然地,我们会问,向量值函数 f(x)的海森矩阵是否存在。毕竟,我们可以计算这样的函数的雅可比矩阵;我们之前也这样做了,来展示海森矩阵是梯度的雅可比矩阵。
可以将海森矩阵扩展到向量值函数。然而,结果不再是一个矩阵,而是一个三阶张量。为了证明这一点,考虑向量值函数的定义:

我们可以把一个向量值函数(一个向量场)看作是一个由向量的标量函数组成的向量。我们可以计算f中每个m个函数的海森矩阵,得到一个矩阵向量,

但一个矩阵向量是一个三维对象。想象一下 RGB 图像:一个由三个二维图像组成的三维数组,分别表示红色、绿色和蓝色通道。因此,虽然可以定义和计算,但向量值函数的海森矩阵超出了我们当前的讨论范围。
优化
在深度学习中,你最常见到海森矩阵是与优化相关的。训练神经网络从大体上来说就是一个优化问题——目标是找到能够最小化损失函数的权重和偏置。
在第七章中,我们看到梯度提供了如何朝着最小值移动的信息。优化算法,如梯度下降法,第十一章的主题,利用梯度作为指导。因为梯度是损失函数的一阶导数,所以完全基于梯度的算法被称为一阶优化方法。
Hessian 矩阵提供了比梯度更丰富的信息。作为二阶导数,Hessian 包含了关于损失函数梯度变化的信息,也就是其曲率。这里的物理类比可能有帮助。一个粒子在一维空间中的运动可以通过时间的某个函数 x(t) 来描述。其一阶导数,速度,是 dx/dt = v(t)。速度告诉我们位置随时间变化的快慢。然而,速度可能随时间变化,因此其导数 dv/dt = a(t) 就是加速度。如果速度是位置的一阶导数,那么加速度就是二阶导数,d²x/dt² = a(t)。类似地,损失函数的二阶导数,即 Hessian,提供了梯度变化的信息。使用 Hessian 或其近似值的优化算法被称为二阶优化方法。
让我们从一个一维的例子开始。我们有一个函数 f(x),并且当前在某个 x[0] 处。我们希望从这个位置移动到一个新的位置 x[1],接近 f(x) 的最小值。一个一阶算法会使用梯度(这里是导数)作为指导,因为我们知道,沿着导数的反方向移动会使函数值变小。因此,对于某个步长,记作 η(希腊字母 eta),我们可以写成
x[1] = x[0] − η f′(x[0])
这将把我们从 x[0] 移动到 x[1],假设最小值存在的话,x[1] 会更接近 f(x) 的最小值。
上面的方程是有意义的,那么为什么还要考虑二阶方法呢?二阶方法的作用是在我们从 f(x) 移动到 f(x) 时起作用。现在我们有了梯度,而不仅仅是导数,并且 f(x) 在某个点附近的形态可能会更加复杂。梯度下降的通用形式是
x[1] = x[0] − η▽f(x[0])
但是,Hessian 矩阵中的信息是有帮助的。为了了解其作用,我们首先需要引入泰勒级数展开的概念,这是一种将任意函数近似为一系列项之和的方法。在物理学和工程学中,我们经常使用泰勒级数来简化某个特定点附近的复杂函数。我们也经常用它们来计算超越函数的值(无法通过有限的基本代数运算来表示的函数)。例如,当你在编程语言中使用cos(x)时,结果可能是通过泰勒级数展开生成的,所用的项数足以让余弦函数达到 32 位或 64 位浮点精度:

一般来说,为了在某个点 x = a 附近近似函数 f(x),泰勒级数展开是

其中 f^((k))(a) 是 f(x) 在点 a 处的 k 阶导数。
在 x = a 附近,f(x) 的线性近似是
f(x) ≈ f(a) + f′(a)(x - a)
而f(x)的二次近似变为

在这里,我们看到使用一阶导数的线性近似和使用f(x)的一阶和二阶导数的二次近似。一阶优化算法使用线性近似,而二阶优化算法使用二次近似。
从标量函数f(x)到向量的标量函数f(x)的转换,将一阶导数变为梯度,二阶导数变为赫塞矩阵,

其中Hf是f(x)在点a处求值的赫塞矩阵。为了使维度匹配,乘积的顺序发生了变化,因为我们现在需要处理向量和矩阵。
例如,如果x有n个元素,则f(a)是一个标量;在a处的梯度是一个n维的列向量乘以(x − a)^⊤,一个n维的行向量,产生一个标量;最后一项是 1 × n乘以n × n乘以n × 1,结果是 1 × n乘以n × 1,这也是一个标量。
为了使用泰勒级数展开进行优化,找到f的最小值,我们可以像在方程式 8.18 中一样使用牛顿法。首先,我们将方程式 8.21 重新写为从当前位置(x)的位移(Δx)的角度来看。然后,方程式 8.21 变为

方程式 8.22 是Δx的抛物线,我们将其作为f在x + Δx区域内更复杂形状的替代。为了找到方程式 8.22 的最小值,我们对其求导并令其为零,然后解出Δx。导数给出了

如果设为零,则得到

方程式 8.23 告诉我们当前坐标x到达f(x)最小值的偏移量,假设f(x)是一个抛物线。实际上,f(x)并不是抛物线,所以方程式 8.23 中的Δx并不是f(x)最小值的实际偏移量。然而,由于泰勒级数展开使用了f(x)在x处的实际斜率f′(x)和曲率f′′(x),因此方程式 8.23 的偏移量比线性近似更接近f(x)的实际最小值,假设存在最小值。
如果我们从x移动到x + Δx,那就没有理由不能再次使用方程式 8.23,将新位置称为x。这样思考会导致一个可以迭代的方程:

对于x[0],某个初始起点。
我们可以针对带有向量参数的标量函数 f(x) 计算上面的内容,这类函数是我们在深度学习中通过损失函数最常遇到的类型。公式 8.24 变为:

其中,二阶导数的倒数变成了在 x[n] 处求得的 Hessian 矩阵的逆。
很棒!我们有一个可以快速找到类似 f(x) 函数最小值的算法。我们在上面看到,牛顿法收敛速度很快,因此用它来最小化损失函数也应该会迅速收敛,比只考虑一阶导数的梯度下降法要快。
如果是这样,为什么我们不使用牛顿法来训练神经网络,而选择梯度下降法呢?
有几个原因。首先,我们没有讨论 Hessian 适用性带来的问题,尤其是 Hessian 作为正定矩阵的问题。一个对称矩阵是正定的,若它的所有特征值都为正。在鞍点附近,Hessian 可能不是正定的,这会导致更新规则偏离最小值。正如你可能会预料的那样,像牛顿法这样的简单算法中,一些变体尝试解决这类问题,但即便解决了 Hessian 特征值的问题,使用 Hessian 更新网络参数的计算负担也正是牛顿法无法继续进行的原因。
每次网络的权重和偏置被更新时,Hessian 矩阵都会发生变化,需要重新计算它及其逆矩阵。考虑到网络训练过程中使用的小批量数据量,即使是一个小批量,网络中也有 k 个参数,其中 k 很容易达到百万甚至数十亿级别。Hessian 是一个 k × k 的对称正定矩阵。求逆 Hessian 通常使用 Cholesky 分解,这比其他方法更高效,但仍然是一个 𝒪(k³) 的算法。大-O 符号表示算法的资源消耗随着矩阵大小的立方(时间、内存或两者)而增长。这意味着如果网络中的参数数量翻倍,求逆 Hessian 的计算时间将增加 2³ = 8 倍,而三倍数量的参数需要约 3³ = 27 倍的计算工作量,四倍则需要大约 4³ = 64 倍。而且这还没有提到存储 Hessian 矩阵中的 k² 个元素,所有这些都是浮动的数值。
即使对于中等规模的深度网络,使用牛顿法的计算量也是惊人的。基于梯度的一阶优化方法几乎是我们训练神经网络时唯一可以使用的。
注意
这个说法可能有些过早。最近在 神经进化 领域的研究表明,进化算法可以成功地训练深度模型。我自己在群体优化技术和神经网络方面的实验也为这种方法提供了证据。
第一阶方法如此有效,似乎目前为止是一个非常幸运的偶然。
矩阵微积分导数的一些例子
我们以一些类似于深度学习中常见导数的例子结束本章。
元素级操作的导数
我们从元素级操作的导数开始,包括像将两个向量相加这样的操作。考虑

这实际上是将两个向量按元素相加。那么,∂f/∂a,即 f 的雅可比矩阵,是什么样子的呢?根据定义,我们有

但是 f[0] 只依赖于 a[0],而 f[1] 依赖于 a[1],以此类推。因此,所有 ∂f[i]/∂a[j],对于 i ≠ j 都是零。这就去除了矩阵的所有非对角元素,剩下的是

因为 ∂f[i]/∂a[i] = 1 对所有 i 都成立。类似地,∂f/∂b = I。此外,如果我们将加法改为减法,∂f/∂a = I,但 ∂f/∂b = −I。
如果操作是 a 和 b 的元素级乘法,f = a ⊗ b,那么我们得到如下结果,其中 diag(x) 表示向量 x 的 n 个元素沿对角线排列在一个 n × n 的矩阵中,其他位置为零。

激活函数的导数
让我们找出前馈网络中隐藏层单个节点的权重和偏置的导数。回顾一下,节点的输入是前一层的输出 x,与权重 w 按项相乘并加上偏置值 b(一个标量)。结果是一个标量,传递给激活函数以产生节点的输出值。在这里,我们使用的是 修正线性单元(ReLU),如果输入为正,则返回该输入值;如果输入为负,则返回零。我们可以将这个过程写成

为了实现反向传播,我们需要方程 8.25 对w和 b 的导数。让我们看看如何找到它们。
我们首先考虑方程 8.25 的各个部分。例如,从方程 8.9 中,我们知道点积对w的导数是

这里我们利用了点积是交换律的事实,w • x = x • w。另外,由于 b 不依赖于 w,我们有

那么 ReLU 的导数是什么样子的呢?根据定义,

这意味着

因为 ∂z/∂z = 1。
为了找到方程 8.25 对w和 b 的导数,我们需要链式法则和上面的结果。我们从 w 开始。链式法则告诉我们如何

z = w • x + b 和 y = ReLU(z)。
我们知道 ∂y/∂z;它是 ReLU 的上述两种情况,方程 8.27。所以现在我们有

我们知道 ∂z/∂w = x^⊤;它是方程 8.26。因此,我们的最终结果是

我们已将 z 替换为 w • x + b。
我们采用相同的程序来找到 ∂y/∂b,如

但 ∂y/∂z 是 0 或 1,取决于 z 的符号。同样,∂z/∂b = 1,这导致

总结
在这一密集章节中,我们学习了矩阵微积分,包括处理涉及向量和矩阵的函数的导数。我们通过了定义并讨论了一些恒等式。然后,我们引入了雅可比矩阵和海森矩阵,作为一阶和二阶导数的类比,并学习了如何在优化问题中使用它们。训练深度神经网络,实质上是一个优化问题,因此雅可比矩阵和海森矩阵的潜在用途显而易见,即使后者在大型神经网络中不易使用。我们以一些深度学习中常见的导数表达式为例,结束了本章。
本书的数学部分到此为止。接下来,我们将把注意力转向利用所学内容理解深度神经网络的工作原理。我们从讨论数据如何在神经网络模型中流动开始。
第九章:神经网络中的数据流**

在这一章,我将展示数据是如何在训练好的神经网络中流动的。换句话说,我们将查看如何从输入向量或张量转换到输出,以及数据在过程中所呈现的形式。如果你已经熟悉神经网络的运作原理,那就太好了;如果没有,跟随数据从一层流向另一层的过程,将帮助你建立对这些过程的理解。
首先,我们将了解如何在两种不同类型的网络中表示数据。接着,我们将通过一个传统的前馈网络来为自己打下坚实的基础。我们将看到在神经网络中进行推理时,代码是如何简洁的。最后,我们将通过引入卷积层和池化层,追踪数据在卷积神经网络中的流动。本章的目标不是展示流行工具包如何传递数据。这些工具包是高度优化的软件,其低层次的知识在此阶段对我们帮助不大。相反,目标是帮助你理解数据是如何从输入流向输出的。
数据表示
最终,深度学习的一切都与数据有关。我们使用数据来创建模型,然后用更多的数据进行测试,最终让我们能够对更多的数据进行预测。我们将从了解如何在两种类型的神经网络中表示数据开始:传统神经网络和深度卷积网络。
传统神经网络
对于传统神经网络或其他经典机器学习模型,输入是一个数字向量,即特征向量。训练数据是一组这些特征向量,每个特征向量都带有一个关联的标签。(本章我们将限制在基本的监督学习上。)特征向量集合方便地实现为一个矩阵,每一行是一个特征向量,行数与数据集中的样本数相匹配。正如我们现在所知道的,计算机方便地使用二维数组表示矩阵。因此,在处理传统神经网络或其他经典模型(如支持向量机、随机森林等)时,我们将把数据集表示为二维数组。
例如,在第六章中我们首次接触的鸢尾花数据集,每个特征向量包含四个特征。我们将它表示为一个矩阵:
>>> import numpy as np
>>> from sklearn import datasets
>>> iris = datasets.load_iris()
>>> X = iris.data[:5]
>>> X
array([[5.1, 3.5, 1.4, 0.2],
[4.9, 3\. , 1.4, 0.2],
[4.7, 3.2, 1.3, 0.2],
[4.6, 3.1, 1.5, 0.2],
[5\. , 3.6, 1.4, 0.2]])
>>> Y = iris.target[:5]
这里,我们展示了前五个样本,就像在第六章中做的那样。上面的样本全部属于类别 0(I. setosa)。为了将这些知识传递给模型,我们需要一个与之匹配的类别标签向量;X[i]返回样本i的特征向量,Y[i]返回类别标签。类别标签通常是一个整数,并且从零开始为数据集中每个类别编号。一些工具包更喜欢使用独热编码(one-hot encoding)的类别标签,但我们可以轻松地从更标准的整数标签中生成它们。
因此,传统的数据集在层与层之间使用矩阵来保存权重,每一层的输入和输出是一个向量。这相对直接。那么,更现代的深度网络呢?
深度卷积网络
深度网络可能会使用特征向量,尤其是在模型实现一维卷积时,但更多情况下,使用深度网络的核心目的是让卷积层利用数据中的空间关系。通常,这意味着输入是图像,我们使用二维数组表示图像。但是,输入不一定非得是图像。模型并不关心输入代表的是什么;只有模型设计者知道,并根据这些知识决定架构。为了简单起见,我们假设输入是图像,因为我们已经了解计算机如何处理图像,至少从高层次来看是这样的。
黑白图像,或者带有灰度的图像,称为灰度图像,使用单个数字表示每个像素的强度。因此,灰度图像由一个矩阵组成,在计算机中表示为二维数组。然而,我们在计算机上看到的大多数图像都是彩色图像,而非灰度图像。大多数软件通过三个数字表示一个像素的颜色:红色的量、绿色的量和蓝色的量。这就是计算机上彩色图像被标记为RGB的原因。还有许多其他表示颜色的方法,但 RGB 是最常见的。通过这些基础色的混合,计算机能够显示数百万种颜色。如果每个像素需要三个数字,那么彩色图像就不再是一个二维数组,而是三个二维数组,每个数组代表一种颜色。
例如,在第四章中,我们从sklearn加载了一张彩色图像。我们再来看一遍,看看它是如何在内存中排列的:
>>> from sklearn.datasets import load_sample_image
>>> china = load_sample_image('china.jpg')
>>> china.shape
(427, 640, 3)
图像以 NumPy 数组的形式返回。请求数组的形状会返回一个元组:(427, 640, 3)。这个数组有三个维度。第一个是图像的高度,427 个像素。第二个是图像的宽度,640 个像素。第三个是通道的数量,这里是三,因为它是 RGB 图像。第一个通道是每个像素的红色分量,第二个是绿色,最后一个是蓝色。如果需要的话,我们可以将每个通道当作一张灰度图像来看:
>>> from PIL import Image
>>> Image.fromarray(china).show()
>>> Image.fromarray(china[:,:,0]).show()
>>> Image.fromarray(china[:,:,1]).show()
>>> Image.fromarray(china[:,:,2]).show()
PIL 指的是 Pillow,这是 Python 用于处理图像的库。如果你还没有安装它,运行以下命令可以为你安装:
pip3 install pillow
每张图像看起来相似,但如果将它们并排放置,你会注意到一些差异。见图 9-1。每个通道图像的合成效果形成了显示的实际颜色。将china[:,:,0]替换为china,即可查看完整的彩色图像。

图 9-1:红色(左)、绿色(中)、蓝色(右)*china*图像通道
深度网络的输入通常是多维的。如果输入是彩色图像,我们需要使用一个 3D 张量来包含图像。然而,这还没有完毕。每个输入样本是一个 3D 张量,但我们通常不会一次只处理一个样本。在训练深度网络时,我们使用小批量,即一组样本一起处理以得到平均损失。这意味着输入张量还需要多出一个维度,用来指定我们想要的小批量中的哪个成员。因此,输入是一个 4D 张量:N × H × W × C,其中N是小批量中的样本数,H是每个图像的高度,W是每个图像的宽度,C是通道数。我们有时会将其写成元组形式:(N, H, W, C)。
让我们来看一下用于深度网络的一些实际数据。数据集是 CIFAR-10 数据集。这是一个广泛使用的基准数据集,可以在这里找到:www.cs.toronto.edu/~kriz/cifar.html。不过,你并不需要下载原始数据集。我们已经在本书的代码中包含了 NumPy 版本。正如前面提到的,我们需要两个数组:一个用于图像,另一个用于对应的标签。你可以在cifar10_test_images.npy和cifar10_test_labels.npy文件中找到它们。让我们来看看:
>>> images = np.load("cifar10_test_images.npy")
>>> labels = np.load("cifar10_test_labels.npy")
>>> images.shape
(10000, 32, 32, 3)
>>> labels.shape
(10000,)
注意到images数组具有四个维度。第一个是数组中图像的数量(N = 10,000)。第二个和第三个表示图像的大小为 32×32 像素。最后一个维度表示有三个通道,这意味着数据集包含的是彩色图像。需要注意的是,通常情况下,通道的数量可以指代任何按这种方式分组的数据集合——它不一定是实际的图像。labels向量也有 10,000 个元素。这些是类标签,共有 10 个类别,包含动物和车辆。例如,
>>> labels[123]
2
>>> Image.fromarray(images[123]).show()
这表示图像 123 属于第 2 类(鸟类),并且标签是正确的;显示的图像应该是鸟类的图像。回想一下,在 NumPy 中,要求单个索引时会返回整个子数组,所以images[123]等价于images[123,:,:,:]。Image类的fromarray方法将 NumPy 数组转换为图像,以便show可以显示它。
使用小批量时,我们将整个数据集的一个子集传递给模型。如果我们的模型使用 24 个样本的小批量,那么深度网络的输入是一个(24,32,32,3)数组:24 张图像,每张图像有 32 行、32 列和 3 个通道。稍后我们会看到,通道的概念不仅仅局限于深度网络的输入,它同样适用于在各层之间传递的数据形状。
我们稍后会回到深度网络的数据。现在,让我们先转向更直接的主题:传统前馈神经网络中的数据流。
传统神经网络中的数据流
如上所示,在传统的神经网络中,层与层之间的权重以矩阵的形式存储。如果第i层有n个节点,第i−1 层有m个输出,那么这两层之间的权重矩阵W[i]就是一个n × m的矩阵。当这个矩阵与第i−1 层的m × 1 列向量相乘时,结果是一个n × 1 的输出,表示输入到第i层的n个节点的值。具体来说,我们计算
a[i] = σ(W[i]a[i−1] + b[i])
其中,a[i][−1]是来自第i−1 层的m × 1 输出向量,它与W[i]相乘,产生一个n × 1 列向量。我们将第i层的偏置值b[i]加到该向量中,并对结果向量W[i]a[i][−1] + b[i]的每个元素应用激活函数σ,从而得到a[i],即第i层的激活值。我们将激活值作为第i层的输出传递给第i+1 层。通过使用矩阵和向量,矩阵乘法规则自动计算所有必要的乘积,而无需在代码中显式使用循环。
让我们看一个简单神经网络的例子。我们将生成一个包含两个特征的随机数据集,然后将该数据集分成训练组和测试组。我们将使用sklearn在训练集上训练一个简单的前馈神经网络。该网络有一个隐藏层,包含五个节点,并使用修正线性激活函数(ReLU)。然后我们将测试训练好的网络,看看它学得如何,最重要的是,查看实际的权重矩阵和偏置向量。
为了构建数据集,我们将选择一组在二维空间中聚集但略有重叠的点。我们希望网络学习一些不完全简单的内容。以下是代码:
from sklearn.neural_network import MLPClassifier
np.random.seed(8675309)
❶ x0 = np.random.random(50)-0.3
y0 = np.random.random(50)+0.3
x1 = np.random.random(50)+0.3
y1 = np.random.random(50)-0.3
x = np.zeros((100,2))
x[:50,0] = x0; x[:50,1] = y0
x[50:,0] = x1; x[50:,1] = y1
❷ y = np.array([0]*50+[1]*50)
❸ idx = np.argsort(np.random.random(100))
x = x[idx]; y = y[idx]
x_train = x[:75]; x_test = x[75:]
y_train = y[:75]; y_test = y[75:]
我们需要从sklearn导入MLPClassifier类,因此首先加载它。然后我们定义一个二维数据集x,由两组各 50 个点组成。点是随机分布的(x0,y0和x1,y1),但分别集中在(0.2, 0.8)和(0.8, 0.2)位置 ❶。请注意,我们将 NumPy 的随机数种子设置为固定值,因此每次运行都会生成相同的一组数字。如有需要,可以删除这一行并尝试在不同数据集生成的情况下,网络的训练效果。
我们知道x中的前 50 个点来自我们所称之为类别 0,接下来的 50 个点是类别 1,因此我们定义一个标签向量y ❷。最后,我们随机化x ❸中的点的顺序,并小心地以相同的方式调整标签,然后将它们分成训练集(x_train)和标签(y_train),以及测试集(x_test)和标签(y_test)。我们保留 75%的数据用于训练,剩下的 25%用于测试。
图 9-2 显示了完整数据集的图形,其中每个特征位于一个坐标轴上。圆圈表示类 0 实例,方块表示类 1 实例。两个类别之间有明显的重叠。

图 9-2:用于训练神经网络的数据集,类 0 实例以圆圈表示,类 1 实例以方块表示
我们现在准备好训练模型了。如果使用默认设置,sklearn工具包使这变得非常简单:
❶ clf = MLPClassifier(hidden_layer_sizes=(5,))
clf.fit(x_train, y_train)
❷ score = clf.score(x_test, y_test)
print("Model accuracy on test set: %0.4f" % score)
❸ W0 = clf.coefs_[0].T
b0 = clf.intercepts_[0].reshape((5,1))
W1 = clf.coefs_[1].T
b1 = clf.intercepts_[1]
训练过程包括创建模型类的一个实例。注意,使用默认设置时(包括使用 ReLU 激活函数),我们只需指定隐藏层中节点的数量。我们希望有一个包含五个节点的隐藏层,因此传入元组(5,)。训练只需要调用一次fit函数,传入训练数据x_train和相应的标签y_train。完成后,我们通过计算测试集(x_test, y_test)上的准确率(score)来测试模型,并显示结果。
神经网络是随机初始化的,但由于我们在生成数据集时固定了 NumPy 随机数种子,并且由于sklearn也使用 NumPy 的随机数生成器,因此每次运行代码时,训练网络的结果应该是相同的。模型在测试数据上的准确率为 92%。这对我们很方便,但也令人担忧——如此多的工具包在底层使用 NumPy,因而固定随机数种子所导致的交互是很可能发生的,通常是不希望出现的,并且可能很难检测。
我们现在终于准备好从训练好的网络中获取权重矩阵和偏置向量。由于sklearn使用np.dot进行矩阵乘法,我们取权重矩阵W0和W1的转置,以便将它们转换为数学上更易于理解的形式。稍后我们将详细说明为什么这样做是必要的。同样,b0,隐藏层的偏置向量,是一个 1D 的 NumPy 数组,因此我们将其转换为列向量。输出层的偏置b1是一个标量,因为该网络只有一个输出,即我们传递给 sigmoid 函数的值,用于获得属于类 1 的概率。
让我们跟随网络计算第一个测试样本。为了节省空间,我们只展示数值的前三位,但我们的计算将使用完整精度。网络的输入是

我们希望网络给出一个输出,表示该输入属于类 1 的可能性。
为了获得隐藏层的输出,我们将x与权重矩阵W[0]相乘,加入偏置向量b[0],然后将结果通过 ReLU:

隐藏层到输出层的过渡使用相同的形式,用a[0]代替x,但这里没有应用 ReLU:

为了获得最终的输出概率,我们使用a[1],一个标量值,作为sigmoid 函数(也称为logistic 函数)的参数:

这意味着网络已将输入值属于类别 1 的可能性设定为 35.5%。对于二分类模型,通常的类别分配阈值为 50%,因此网络会将x分配给类别 0。查看y_test[0]可以告诉我们,网络在此情况下是正确的:x来自类别 0。
卷积神经网络中的数据流
我们在上面看到,数据在传统神经网络中的流动是直接的矩阵-向量运算。为了跟踪数据在卷积神经网络(CNN)中的流动,我们首先需要了解卷积操作是什么,以及它是如何工作的。具体来说,我们将学习如何通过卷积层和池化层将数据传递到模型顶部的全连接层。这个过程涵盖了许多 CNN 架构,至少在概念层面上是如此。
卷积
卷积涉及两个函数,并且是一个在另一个上滑动的过程。如果函数是f(x)和g(x),则卷积定义为

幸运的是,我们在离散域中工作,而且通常是 2D 输入,因此积分实际上并没有被使用,尽管*仍然是该操作的有用符号。
方程 9.1 的净效果是将g(x)滑动到f(x)上,进行不同的位移。让我们用一个一维离散示例来澄清。
一维卷积
图 9-3 显示了底部的图和顶部标有f和g的两组数字。

图 9-3:一维离散卷积
我们从图 9-3 顶部显示的数字开始。第一行列出了f的离散值。下面是g,一个三元素的线性斜坡。卷积将g与f的左边对齐,如图所示。我们将两个数组之间的相应元素相乘,
[2, 6, 15] × [−1, 0, 1] = [−2, 0, 15]
然后将得到的值相加,
−2 + 0 + 15 = 13
为了得到输出中指定元素的值,f * g。为了完成卷积,g向右滑动一个元素,过程重复进行。请注意,在图 9-3 中,为了清晰起见,我们展示了f和g的每个其他对齐方式,所以看起来好像g向右滑动了两个元素。通常,我们将g称为核,它是滑动到输入f上的值集合。
图 9-3 底部的图是f(x) = ⌊255 exp(−0.5x²)⌋,其中x在[−3, 3]之间,圆点标记了对应的点。向下取整操作使输出为整数,以便简化下面的讨论。
图 9-3 中的方形点是f(x)与g(x) = [−1, 0, 1]卷积的输出。
图 9-3 中的f和f * g点是通过以下方式生成的:
x = np.linspace(-3,3,20)
f = (255*np.exp(-0.5*x**2)).astype("int32")
g = np.array([-1,0,1])
fp= np.convolve(f,g[::-1], mode='same')
这段代码需要一些解释。
首先,我们有x,一个在[−3, 3]范围内按 20 步生成的向量;这个向量生成了f(上面的f(x))。我们希望f是整数类型,这就是astype为我们做的事情。接下来,我们定义了g,这是一个小的线性斜坡。正如我们所看到的,卷积操作将g滑动到f的各个元素上以生成输出。
接下来是卷积操作。由于卷积是常用的操作,NumPy 提供了一个一维卷积函数np.convolve。第一个参数是f,第二个是g。稍后我会解释为什么我们要在g上添加[::-1]来反转它。我还会解释mode='same'的含义。卷积的输出将存储在fp中。
图 9-3 顶部显示的第一个位置填入了输出中的 13。那么,13 左边的 6 是从哪里来的呢?卷积在f的边缘存在问题,因为卷积核并没有完全覆盖输入数据。对于一个包含三个元素的卷积核,f的每一端都会有一个边缘元素。卷积核通常有奇数个值,因此会有一个明确的中间元素。如果g有五个元素,那么在f的两端会有两个元素是g无法覆盖的。
卷积函数需要在这些边缘情况做出选择。一个选择是仅返回卷积的有效部分,忽略边缘情况。如果我们采用这种方法,称为有效卷积,那么输出yp将从元素 13 开始,长度比输入y少两个。
另一种方法是用零填充f中的缺失值。这被称为零填充,我们通常使用它使卷积操作的输出与输入大小相同。
使用mode='same'与np.convolve一起时,选择了零填充。这解释了 13 左边的 6。它是我们在f的 2 前面加上 0 并应用卷积核时得到的结果:
[0, 2, 6] × [−1, 0, 1] = [0, 0, 6],0 + 0 + 6 = 6
如果我们只想要有效的输出值,我们会使用mode='valid'。
上述对np.convolve的调用并没有使用g,我们传入的是g[::-1],即g的反向。我们这样做是为了让np.convolve的行为像深度神经网络中使用的卷积。从数学和信号处理的角度来看,卷积操作使用的是核的反向。因此,np.convolve函数会反转核,这意味着我们需要提前反转核,才能得到我们想要的效果。更技术一点地说,如果我们执行的操作被称为卷积,但没有翻转核,那么我们实际上在做交叉相关。在深度学习中,这个问题很少出现,因为我们在训练过程中学习核的元素,而不是提前指定它们。因此,工具包实现卷积操作时对核进行的任何翻转都不会影响结果,因为学习到的核值就是在翻转后的状态下学习得到的。我们假设接下来没有翻转,并且在必要时会翻转我们传递给 NumPy 和 SciPy 函数的核。另外,我们将继续使用卷积这一术语,指的是在深度学习中没有翻转核的情况。
通常,离散卷积操作涉及将核放置在输入数据上,从左侧开始,进行元素匹配相乘、求和,并将结果放入输出中,位置是核的中心与输入位置重合的地方。然后,核向右滑动一个元素,过程重复进行。我们可以将离散卷积操作扩展到二维。大多数现代深度卷积神经网络(CNN)使用二维核,尽管也可以使用一维和三维核。
二维卷积
使用二维核进行卷积需要一个二维数组。图像是值的二维数组,卷积是常见的图像处理操作。例如,我们加载一张图像,之前在第三章中看到的浣熊面部图像,并使用二维卷积对其进行处理。考虑以下内容:
from scipy.signal import convolve2d
from scipy.misc import face
img = face(True)
img = img[:512,(img.shape[1]-612):(img.shape[1]-100)]
k = np.array([[1,0,0],[0,-8,0],[0,0,3]])
c = convolve2d(img, k, mode='same')
在这里,我们使用的是来自signal模块的 SciPy convolve2d函数。首先,我们加载浣熊图像,并将其裁剪为一个 512×512 像素的浣熊面部图像(img)。接着,我们定义一个 3 × 3 的核,k。最后,我们将这个核与面部图像进行卷积,并将结果存储在c中。mode='same'关键字对图像进行零填充,以处理边缘情况。
上面的代码会导致
img[:8,:8]:
[[ 88 97 112 127 116 97 84 84]
[ 62 70 100 131 126 88 52 51]
[ 41 46 87 127 146 116 78 56]
[ 42 45 76 107 145 137 112 76]
[ 58 59 69 79 111 106 90 68]
[ 74 73 68 60 72 74 72 67]
[ 92 87 75 63 57 74 91 93]
[105 97 85 74 60 79 102 110]]
k:
[[ 1 0 0]
[ 0 -8 0]
[ 0 0 3]]
c[1:8,1:8]:
[[-209 -382 -566 -511 -278 -69 -101]
[-106 -379 -571 -638 -438 -284 -241]
[-168 -391 -484 -673 -568 -480 -318]
[-278 -357 -332 -493 -341 -242 -143]
[-335 -304 -216 -265 -168 -165 -184]
[-389 -307 -240 -197 -274 -396 -427]
[-404 -331 -289 -215 -368 -476 -488]]
这里,我们展示的是图像的上 8×8 角以及卷积的有效部分。回顾一下,有效部分是指核完全覆盖输入数组的部分。
对于核和图像,第一个有效的卷积输出是−209。数学上,第一步是与核进行逐元素相乘,

然后进行求和,
264 + 0 + 0 + 0 + (−560) + 0 + 0 + 0 + 87 = −209
请注意,使用的核并不是我们定义的k。相反,convolve2d首先将核上下翻转,然后左右翻转,之后再进行应用。剩下的c通过将核向右移动一个位置并重复乘法和加法运算来传递。在一行的末尾,核会向下移动一个位置并返回到左侧,直到整个图像处理完毕。深度学习工具包将这种移动称为步幅,并且步幅不一定是一个位置,也不一定在水平方向和垂直方向上相等。
图 9-4 显示了卷积的效果。

图 9-4:原始浣熊面部图像(左)和卷积结果(右)
为了生成图像,c 被向上移动,使最小值为零,然后除以最大值映射到[0, 1]。最后,输出乘以 255 并显示为灰度图像。原始的人脸图像在左侧,卷积后的图像在右侧。图像与核的卷积改变了图像,突出了某些特征,同时抑制了其他特征。
用卷积核对图像进行卷积不仅仅是为了帮助我们理解卷积操作。它在训练 CNN 时具有深远的意义。从概念上讲,CNN 由两个主要部分组成:一组卷积层和其他层,用于学习输入的新表示,以及一个顶层分类器,用于利用新表示对输入进行分类。正是新表示和分类器的联合学习使得 CNN 如此强大。学习输入的新表示的关键是学习到的卷积核。卷积核如何改变输入,随着数据流经 CNN 创建新的表示。使用梯度下降和反向传播训练网络,教会它创建哪些卷积核。
现在我们可以开始跟踪数据通过 CNN 的卷积层。让我们看一看。
卷积层
上面我们讨论了深度网络如何将张量从一层传递到另一层,以及张量通常具有四个维度,N × H × W × C。为了跟踪卷积层中的数据,我们将忽略N,知道我们讨论的内容适用于张量中的每个样本。这将留下卷积层的输入为H × W × C,即一个三维张量。
卷积层的输出是另一个三维张量。输出的高度和宽度取决于卷积核的具体情况以及我们如何处理边缘。在这里的示例中,我们将使用有效卷积,这意味着我们将丢弃核没有完全覆盖的输入部分。如果卷积核是 3 × 3,则输出的高度和宽度会少两个,每个边缘少一个。如果卷积核是 5 × 5,则高度和宽度会少四个,每个边缘少两个。
卷积层使用一组滤波器来完成其目标。一个滤波器是多个核的堆叠。我们需要为每个期望的输出通道配置一个滤波器。每个滤波器中的核的数量与输入中的通道数量相匹配。因此,如果输入有M个通道,并且我们想要使用K × K 核获得N个输出通道,我们需要N个滤波器,每个滤波器是一个堆叠了M K × K 核的集合。
此外,每个N个滤波器都有一个偏置值。我们将在下面看到偏置是如何使用的,但我们现在已经知道了实现一个具有M个输入通道、K × K 核和N个输出的卷积层需要学习多少个参数。需要的参数数量是 K × K × M × N,其中每个滤波器有 K × K × M 个参数,再加上N个偏置项——每个滤波器一个。
让我们把这一切具象化。我们有一个卷积层。该层的输入是一个 (H,W,C) = (5,5,2) 的张量,意味着高度和宽度都是五,并且有两个通道。我们将使用一个 3 × 3 的卷积核,采用有效卷积,因此输出的高度和宽度为 3 × 3,来自 5 × 5 的输入。我们可以选择输出通道的数量。让我们选择三个。因此,我们需要使用卷积和卷积核将 (5,5,2) 的输入映射到 (3,3,3) 的输出。从上面讨论的内容可以得出,我们需要三个滤波器,每个滤波器有 3 × 3 × 2 个参数,再加上一个偏置项。
我们的输入堆叠是

我们将第三维度分离开来,显示两个输入通道,每个为 5 × 5。
这三个滤波器是

再次,我们将第三维度分离开来。请注意,每个滤波器都有两个 3 × 3 的卷积核,每个卷积核对应于 5 × 5 × 2 输入的一个通道。
让我们通过应用第一个滤波器f[0]来进行计算。我们需要将输入的第一个通道与f[0]的第一个卷积核进行卷积:

接下来,我们需要将第二个输入通道与f[0]的第二个核进行卷积:

最后,我们将两个卷积输出与单个偏置标量相加:

我们现在得到了第一个 3 × 3 的输出。
对f[1]和f[2]重复上述过程得到

我们已经完成了卷积层并生成了 3 × 3 × 3 的输出。
许多工具包使得在设置卷积层的调用中添加操作变得容易,但从概念上讲,这些操作本身就是独立的层,它们将 3 × 3 × 3 的输出作为输入。例如,如果需要,Keras 会对输出应用 ReLU。对卷积的输出应用 ReLU(一种非线性操作)将得到

注意,现在所有小于零的元素都变为零。我们在卷积层之间使用非线性激活函数,原因与在传统神经网络中使用非线性激活函数相同:防止卷积层坍塌为一个单一的线性层。请注意,生成滤波器输出的操作是纯线性的;每个输出元素是输入值的线性组合。添加 ReLU 激活函数可以打破这种线性关系。
创建卷积层的一个原因是为了减少需要学习的参数数量。在上面的例子中,输入是 5 × 5 × 2 = 50 个元素。期望的输出是 3 × 3 × 3 = 27 个元素。如果在这些元素之间使用全连接层,则需要学习 50 × 27 = 1,350 个权重,再加上 27 个偏置值。然而,卷积层只学习了三个过滤器,每个过滤器有 3 × 3 × 2 个权重,以及三个偏置值,总共需要 3(3 × 3 × 2) + 3 = 57 个参数。添加卷积层可以节省大约 1,300 个额外的权重学习。
卷积层的输出通常是池化层的输入。接下来我们将考虑这种类型的层。
池化层
卷积网络通常在卷积层后使用池化层。它们的使用有些争议,因为池化会丢失信息,而信息的丢失可能使得网络更难学习空间关系。池化通常在空间域内进行,沿着输入张量的高度和宽度,同时保留通道数。
池化操作很简单:你将一个窗口在图像上滑动,通常是 2 × 2 的窗口,步长为二,以便将值分组。对每个分组执行的具体池化操作是最大池化或平均池化。最大池化操作保留窗口中的最大值,其余的值会被丢弃。平均池化则取窗口中所有值的均值。
一个 2 × 2 的窗口,步长为二,会导致每个空间方向上的尺寸减半。因此,一个 (24,24,32) 的输入张量会变成一个 (12,12,32) 的输出张量。图 9-5 展示了最大池化的过程。

图 9-5:使用 2 × 2 窗口和步长为二的最大池化
输入的一个通道,具有 8 的高度和宽度,位于左侧。2 × 2 的窗口滑动在输入上,每次跳跃两个位置,因此窗口之间没有重叠。每个 2 × 2 区域的输出是最大值。平均池化则会输出四个数字的均值。与正常的卷积一样,在每行的末尾,窗口会向下滑动两个位置,过程重复进行,将 8 × 8 的输入通道转换为 4 × 4 的输出通道。
如上所述,没有重叠的池化窗口会丢失空间信息。这使得深度学习界的一些人,尤其是 Geoffrey Hinton,感到遗憾,因为丢失空间信息会扭曲输入中物体或物体部分之间的关系。例如,将一个 2 × 2 的最大池化窗口,步幅为一而不是二,应用于图 9-5 中的输入矩阵会产生

这是一个 7 × 7 的输出,只丢失了原始 8 × 8 输入的一个行和列。在这种情况下,输入矩阵是随机生成的,因此我们应该期待一个偏向于 8 和 9 的最大池化操作——没有可以捕捉的结构。当然,这在实际的 CNN 中通常不是这样,因为我们希望利用输入中固有的空间结构。
池化在深度学习中被广泛使用,尤其是在卷积神经网络(CNN)中,因此理解池化操作的作用并意识到其潜在陷阱是至关重要的。接下来我们将进入 CNN 的输出端,通常是全连接层。
全连接层
在深度网络中,全连接层在权重和数据方面与传统神经网络中的常规层是相同的。许多与分类相关的深度网络通过一个将张量展平的层将一组卷积层和池化层的输出传递给第一个全连接层,基本上是将张量展开为一个向量。一旦输出变为向量,全连接层就像传统神经网络一样,使用一个权重矩阵将向量输入映射到向量输出。
数据在卷积神经网络中的流动
让我们把所有的部分放在一起,看看数据是如何从输入到输出通过 CNN 流动的。我们将使用一个简单的 CNN,训练于 MNIST 数据集,这是一个由 28×28 像素灰度手写数字图像组成的集合。架构如下所示。
输入 → 卷积(32) → 卷积(64) → 池化 → 展平 → 全连接(128) → 全连接(10)
输入是一个 28×28 像素的灰度图像(一个通道)。卷积层(conv)使用 3 × 3 的卷积核和有效卷积,因此其输出的高度和宽度比输入小两位。第一层卷积学习 32 个过滤器,第二层学习 64 个过滤器。我们忽略了不会影响网络中数据量的层,比如卷积层后的 ReLU 层。最大池化层假设使用 2 × 2 的窗口,步幅为二。第一个全连接层(dense)有 128 个节点,之后是一个具有 10 个节点的输出层,每个数字对应一个节点,范围从 0 到 9。
单个输入样本通过该网络传递的张量是
(28,28,1) →(26,26,32) →(24,24,64) →(12,12,64) → 9216 → 128 → 10
输入 卷积 卷积 池化 展平 全连接 全连接
展平层将 (12,12,64) 张量展开形成一个包含 9,216 个元素的向量(12 × 12 × 64 = 9,216)。我们将展平层输出的 9,216 个元素传递通过第一层全连接层,生成 128 个输出值,最后一步将这个 128 元素的向量映射到 10 个输出值。
请注意,上述值是指传入网络的每个输入样本的数据,属于小批量中的 N 个样本之一。这与网络在训练过程中需要学习的参数(权重和偏置)不同。
上述网络是使用 Keras 在 MNIST 数字数据集上训练的。图 9-6 通过视觉展示了网络在处理两个输入时的每一层的输出,具体来说,它展示了输入图像 4 和 6 的每一层输出。

图 9-6:CNN 对两个样本输入的输出的可视化表示
从顶部开始,我们看到两个输入。图中,强度已被反转,因此较暗的部分表示更高的数值。输入是一个 (28,28,1) 张量,1 表示单通道灰度图像。有效的卷积操作使用 3 × 3 的卷积核返回一个 26 × 26 的输出。第一层卷积学习了 32 个滤波器,因此输出是一个 (26,26,32) 张量。在图中,我们将每个滤波器的输出显示为图像。零被缩放为中灰色(强度为 128),更正的值会变得更暗,而更负的值则变得更亮。我们可以看到输入如何被学习到的滤波器所影响。单一输入通道意味着该层的每个滤波器都是一个单一的 3 × 3 卷积核。亮暗之间的过渡表示特定方向的边缘。
我们将 (26,26,32) 张量传递通过 ReLU(此处未显示),然后通过第二层卷积层。该层的输出是一个 (24,24,64) 张量,图中将其显示为一个 8 × 8 网格的图像。我们可以看到许多输入数字的部分被高亮显示。
池化层保留了通道数,但将空间维度缩小了两倍。在图像中,24×24 像素的 8 × 8 网格图像现在变成了 12×12 像素的 8 × 8 网格图像。展平操作将 (12,12,64) 张量映射到一个 9,216 元素的向量。
第一层全连接层的输出是一个 128 个数字的向量。在图 9-6 中,我们将其显示为一个包含 128 元素的条形码。值从左到右排列。每个条形的高度不重要,只是为了让条形码更加易于查看。输入图像生成的条形码是最后一层包含 10 个节点的输出表示,经过 softmax 函数后用于生成最终输出。softmax 输出最高的值被用来选择类别标签,“4”或“6”。
因此,我们可以将所有 CNN 层看作是通过第一个全连接层将输入映射到新的表示上,这种表示使得简单分类器容易处理。实际上,如果我们将“4”和“6”这两种数字的 10 个示例通过这个网络,并显示出结果中的 128 节点特征向量,我们可以得到图 9-7,在图中我们可以轻松地看到这两种数字模式之间的区别。

图 9-7:多种“4”和“6”输入的第一层全连接层输出
当然,我们书写数字的目的就是为了让人类更容易看到它们之间的差异。虽然我们可以通过 128 元素的向量图像来区分数字,但我们自然更喜欢使用书写的数字,因为习惯的使用以及我们的大脑视觉系统已经具备了高度复杂的分层特征检测器。
CNN 学习新输入表示的例子值得记住,因为人类在图像中用作分类线索的内容不一定是网络学习使用的内容。这或许能部分解释为什么某些预处理步骤(例如数据增强过程中对训练样本的修改)在帮助网络学习泛化时非常有效,而这些修改对我们来说似乎很奇怪。
摘要
本章的目标是演示神经网络如何处理从输入到输出的数据。自然,我们无法覆盖所有网络类型,但总体来说,原理是相同的:对于传统神经网络,数据作为向量从一层传递到另一层,而对于深度网络,它作为张量传递,通常是四维张量。
我们学习了如何将数据以特征向量或多维输入的形式呈现给网络。接着,我们查看了如何将数据传递通过传统神经网络。我们看到,作为输入和输出的向量使得传统神经网络的实现变得简单,实际上就是矩阵-向量乘法和加法的过程。
接下来,我们看到深度卷积网络如何将数据从一层传递到另一层。我们首先了解了卷积操作,然后了解了卷积层和池化层如何作为张量操作数据——对于输入的小批量样本,每个样本都是一个三维张量。在用于分类的 CNN 顶层是全连接层,我们看到它们的作用与传统神经网络中的作用完全一致。
本章最后,我们通过可视化的方式展示了输入图像是如何通过 CNN 产生输出表示的,从而使网络能够正确地标注输入。我们简要讨论了这个过程可能意味着网络在训练过程中所捕捉到的内容,以及它与人类在图像中自然看到的内容之间的差异。
现在我们可以讨论反向传播,它是两个关键算法中的第一个,和梯度下降一起使得深度神经网络的训练成为可能。
第十章:BACKPROPAGATION**

反向传播目前是深度学习的核心算法。如果没有它,我们无法在合理的时间内训练深度神经网络,甚至根本无法训练。因此,深度学习的从业者需要理解反向传播是什么,它为训练过程带来了什么,以及如何实现它,至少对于简单的网络而言。本章的目的是假设你对反向传播没有任何了解。
本章的开始,我们将讨论反向传播是什么以及它不是什么。接着,我们将通过一个简单的网络来推导相关的数学。之后,我们将介绍一种适用于构建全连接前馈神经网络的反向传播矩阵描述。我们将探索数学原理并尝试基于 NumPy 的实现。
像 TensorFlow 这样的深度学习工具包不会像我们在本章前两部分那样实现反向传播。相反,它们使用计算图,我们将在本章结束时对其进行高层次的讨论。
反向传播是什么?
在第七章中,我们介绍了标量函数关于向量的梯度的概念。在第八章中,我们再次使用了梯度,并看到了它们与雅可比矩阵的关系。回想那一章,我们讨论了训练神经网络本质上是一个优化问题。我们知道,训练神经网络涉及一个损失函数,这是一个关于网络权重和偏置的函数,它告诉我们网络在训练集上的表现。当我们进行梯度下降时,我们将使用梯度来决定如何从损失景观的一个部分移动到另一个部分,以找到网络表现最好的地方。训练的目标是最小化训练集上的损失函数。
这就是高层次的概述。现在让我们更具体一点。梯度应用于接受向量输入并返回标量值的函数。对于神经网络来说,向量输入是权重和偏置,它们是定义网络架构固定后如何执行的参数。从符号上看,我们可以将损失函数写作L(θ),其中θ(theta)是网络中所有权重和偏置的向量。我们的目标是沿着损失函数定义的空间移动,找到最小值,即导致最小损失L的特定θ。我们通过使用L(θ )的梯度来实现这一目标。因此,为了通过梯度下降训练神经网络,我们需要了解每个权重和偏置值如何影响损失函数;也就是说,我们需要知道∂L/∂w,其中w是某个权重(或偏置)。
反向传播是告诉我们每个网络的权重和偏置的∂L/∂w是什么的算法。通过这些偏导数,我们可以应用梯度下降来改进网络在下一轮训练数据上的表现。
在继续之前,我们需要说一下术语。你会经常听到机器学习的人用 反向传播 这个词来代指训练神经网络的整个过程。经验丰富的从业者明白他们的意思,但对于刚接触机器学习的人来说,有时会感到有些困惑。为了明确,反向传播 是一个算法,它找出每个权重和偏差对网络误差的贡献,即 ∂L/∂w。梯度下降 是另一个算法,它使用 ∂L/∂w 来修改权重和偏差,从而提高网络在训练集上的表现。
Rumelhart、Hinton 和 Williams 在他们 1986 年的论文《通过反向传播误差学习表示》中介绍了反向传播算法。最终,反向传播是我们在第七章和第八章中讨论的链式法则的应用。反向传播从网络的输出开始,带有损失函数。然后它向 backward(即“反向”)传播,逐层传播错误信号,以找到每个权重和偏差的 ∂L/∂w。需要注意的是,实践者通常将“反向传播”简称为“backprop”,你会经常遇到这个词。
我们将在接下来的两个章节中通过实例来演示反向传播。现在,最需要理解的主要内容是,反向传播是训练神经网络的两个步骤中的第一个步骤。它提供了第二个步骤——梯度下降所需的信息,后者是第十一章的内容。
手动反向传播
让我们定义一个简单的神经网络,它接受两个输入值,在隐藏层有两个节点,并且有一个单一的输出节点,如图 10-1 所示。

图 10-1:一个简单的神经网络
图 10-1 显示了这个网络的六个权重,w[0] 到 w[5],以及三个偏差值,b[0]、b[1] 和 b[2]。每个值都是一个标量。
我们将在隐藏层使用 Sigmoid 激活函数,

输出节点没有激活函数。为了训练这个网络,我们将使用平方误差损失函数,

其中 y 是训练示例的标签,值为零或一,而 a[2] 是网络对于与 y 相关的输入,即 x[0] 和 x[1],的输出。
让我们写出这个网络的前向传播方程,它是从输入 x[0] 和 x[1] 向输出 a[2] 进行的前向传播。方程如下:

在这里,我们引入了中间值 z[0] 和 z[1],它们是激活函数的参数。请注意,a[2] 没有激活函数。我们本可以在这里使用 Sigmoid 函数,但由于我们的标签仅为 0 或 1,我们无论如何都能学习到一个好的输出值。
如果我们通过网络传递一个单独的训练样本,输出是 a[2]。如果与训练样本相关的标签 x = (x[0],x[1]) 是 y,则平方误差损失如 图 10-1 所示。
损失函数的参数是 a[2];y 是一个固定常数。然而,a[2] 直接依赖于 w[4]、w[5]、b[2],以及 a[1] 和 a[0] 的值,而后者又依赖于 w[0]、w[1]、w[2]、w[3]、b[0]、b[1]、x[0] 和 x[1]。因此,从权重和偏差的角度思考,我们可以将损失函数写成
L = L(w[0],w[1],w[2],w[3],w[4],w[5],b[0],b[1],b[2];x[0],x[1],y) = L(θ;x,y)
这里,θ 代表权重和偏差,它被视为变量。分号后面的部分在这种情况下是常数:输入向量 x = (x[0],x[1]) 和相关的标签 y。
我们需要损失函数的梯度,▽L(θ;x,y)。明确来说,我们需要所有的偏导数,∂L/∂w[5],∂L/∂b[0],等等,涉及所有权重和偏差:总共有九个偏导数。
这是我们的攻击计划。首先,我们通过数学推导来计算所有九个值的偏导数表达式。其次,我们将编写一些 Python 代码来实现这些表达式,以便训练 图 10-1 网络来对鸢尾花进行分类。在这个过程中我们将学到一些东西,也许最重要的一点是,通过手工计算偏导数,简直可以说是乏味。我们会成功的,但接下来的部分会让我们看到,幸运的是,背向传播有一种更紧凑的表示方式,尤其是对于完全连接的前馈神经网络。让我们开始吧。
计算偏导数
我们需要得到 图 10-1 网络的损失函数所有偏导数的表达式。我们还需要激活函数(sigmoid)的导数表达式。我们从 sigmoid 开始,因为一个巧妙的技巧可以将导数表示为 sigmoidal 函数本身,这是在前向传播过程中计算得到的值。
Sigmoid 的导数如下所示。


公式 10.2 的技巧是,在分子中加减一个 1,以改变因子的形式,使其成为 sigmoidal 函数本身的另一个副本。因此,sigmoid 的导数是 sigmoid 与 1 减去 sigmoid 的乘积。回顾 公式 10.1,我们看到前向传播计算了 sigmoid,也就是激活函数 a[0] 和 a[1]。因此,在推导反向传播偏导数时,我们可以通过 公式 10.3 用 a[0] 和 a[1] 来代替 sigmoid 的导数,从而避免重复计算。
让我们从偏导数开始。正如反向传播的名字所示,我们将从损失函数开始,逆向工作并应用链式法则,以得出我们所需的表达式。损失函数的导数,

是

这意味着在接下来的表达式中,我们可以将 ∂L/∂a[2] 替换为 a[2] − y。回想一下,y 是当前训练样本的标签,我们在前向传播时将 a[2] 计算为网络的输出。
现在让我们找到 w[5]、w[4] 和 b[2] 的表达式,它们是计算 a[2] 时使用的参数。链式法则告诉我们

因为

我们已经将 方程 10.1 中的 a[2] 表达式代入。
类似的逻辑得出 w[4] 和 b[2] 的表达式:

太棒了!我们得到了三个需要的偏导数——还剩六个。让我们写出 b[1]、w[1] 和 w[3] 的表达式,

在这里我们使用

在计算 a[1] 时,我们将 a[1] 代入 σ(z[1]),因为我们在前向传播过程中计算了 a[1]。
一个类似的计算给出了最后三个偏导数的表达式:

呼!这确实很繁琐,但现在我们已经得到了需要的内容。不过请注意,这是一个非常严谨的过程——如果我们改变网络结构、激活函数或损失函数,就需要重新推导这些表达式。现在让我们使用这些表达式来分类鸢尾花。
转换为 Python 代码
我在这里展示的代码位于文件 nn_by_hand.py 中。请在编辑器中查看它,了解整体结构。我们将从 main 函数开始(列表 10-1):
❶ epochs = 1000
eta = 0.1
❷ xtrn, ytrn, xtst, ytst = BuildDataset()
❸ net = {}
net["b2"] = 0.0
net["b1"] = 0.0
net["b0"] = 0.0
net["w5"] = 0.0001*(np.random.random() - 0.5)
net["w4"] = 0.0001*(np.random.random() - 0.5)
net["w3"] = 0.0001*(np.random.random() - 0.5)
net["w2"] = 0.0001*(np.random.random() - 0.5)
net["w1"] = 0.0001*(np.random.random() - 0.5)
net["w0"] = 0.0001*(np.random.random() - 0.5)
❹ tn0,fp0,fn0,tp0,pred0 = Evaluate(net, xtst, ytst)
❺ net = GradientDescent(net, xtrn, ytrn, epochs, eta)
❻ tn,fp,fn,tp,pred = Evaluate(net, xtst, ytst)
print("Training for %d epochs, learning rate %0.5f" % (epochs, eta))
print()
print("Before training:")
print(" TN:%3d FP:%3d" % (tn0, fp0))
print(" FN:%3d TP:%3d" % (fn0, tp0))
print()
print("After training:")
print(" TN:%3d FP:%3d" % (tn, fp))
print(" FN:%3d TP:%3d" % (fn, tp))
*列表 10-1:*main* 函数
首先,我们设置训练轮数和学习率 η(eta)❶。训练轮数是通过训练集的次数,用来更新网络的权重和偏置。由于网络简单,数据集很小,只有 70 个样本,所以我们需要很多轮训练。梯度下降使用学习率来决定如何根据梯度值进行调整。我们将在第十一章中更深入地探讨学习率。
接下来,我们加载数据集❷。我们使用的是在第六章和第九章中使用过的相同的鸢尾花数据集,仅保留前两个特征和类别 0 与 1。请参阅 nn_by_hand.py 中的 BuildDataset 函数。返回值是 NumPy 数组:xtrn(70 × 2)和 xtst(30 × 2),分别用于训练数据和测试数据,以及在 ytrn 和 ytst 中的相应标签。
我们需要一个地方来存储网络的权重和偏置。使用一个 Python 字典就可以,所以我们接下来将其设置为默认值❸。注意,我们将偏置值设置为零,将权重设置为小的随机值,范围在 [−0.00005, +0.00005] 之间。在这个例子中,这些值似乎效果很好。
main 的其余部分在测试数据上评估随机初始化的网络(Evaluate ❹),执行梯度下降来训练模型(GradientDescent ❺),并再次评估测试数据以证明训练有效 ❻。
清单 10-2 显示了 Evaluate 以及 Evaluate 调用的 Forward。
def Evaluate(net, x, y):
out = Forward(net, x)
tn = fp = fn = tp = 0
pred = []
for i in range(len(y)):
❶ c = 0 if (out[i] < 0.5) else 1
pred.append(c)
if (c == 0) and (y[i] == 0):
tn += 1
elif (c == 0) and (y[i] == 1):
fn += 1
elif (c == 1) and (y[i] == 0):
fp += 1
else:
tp += 1
return tn,fp,fn,tp,pred
def Forward(net, x):
out = np.zeros(x.shape[0])
for k in range(x.shape[0]):
❷ z0 = net["w0"]*x[k,0] + net["w2"]*x[k,1] + net["b0"]
a0 = sigmoid(z0)
z1 = net["w1"]*x[k,0] + net["w3"]*x[k,1] + net["b1"]
a1 = sigmoid(z1)
out[k] = net["w4"]*a0 + net["w5"]*a1 + net["b2"]
return out
清单 10-2: *Evaluate* 函数
让我们从 Forward 开始,它对 x 中的数据执行前向传播。在创建一个存放网络输出的位置(out)之后,每个输入都会使用当前参数值 ❷ 通过网络。注意,这段代码是方程 10.1 的直接实现,其中 out[k] 代替了 a[2]。当所有输入处理完毕后,我们将收集到的输出返回给调用者。
现在让我们来看 Evaluate。它的参数是一组输入特征 x,相关的标签 y,以及网络参数 net。Evaluate 首先通过调用 Forward 将数据传递给网络以填充 out。这些是网络的原始浮动输出。为了与实际标签进行比较,我们应用一个阈值 ❶,将输出 < 0.5 的归为类 0,输出 ≥ 0.5 的归为类 1。预测标签将被附加到 pred 中,并通过与实际标签 y 进行比较来进行统计。
如果实际标签和预测标签都是零,则模型正确识别出了一个真负类(TN),即类 0 的真实实例。如果网络预测为类 0,但实际标签为类 1,则我们得到了假负类(FN),即被标记为类 0 的类 1 实例。相反,将类 0 实例标记为类 1 是假正类(FP)。唯一剩下的选项是一个实际的类 1 实例被标记为类 1,即真正类(TP)。最后,我们将计数和预测结果返回给调用者。
清单 10-3 展示了 GradientDescent,这是清单 10-1 中调用的 ❺。这里实现了前面计算的部分导数。
def GradientDescent(net, x, y, epochs, eta):
❶ for e in range(epochs):
dw0 = dw1 = dw2 = dw3 = dw4 = dw5 = db0 = db1 = db2 = 0.0
❷ for k in range(len(y)):
❸ z0 = net["w0"]*x[k,0] + net["w2"]*x[k,1] + net["b0"]
a0 = sigmoid(z0)
z1 = net["w1"]*x[k,0] + net["w3"]*x[k,1] + net["b1"]
a1 = sigmoid(z1)
a2 = net["w4"]*a0 + net["w5"]*a1 + net["b2"]
❹ db2 += a2 - y[k]
dw4 += (a2 - y[k]) * a0
dw5 += (a2 - y[k]) * a1
db1 += (a2 - y[k]) * net["w5"] * a1 * (1 - a1)
dw1 += (a2 - y[k]) * net["w5"] * a1 * (1 - a1) * x[k,0]
dw3 += (a2 - y[k]) * net["w5"] * a1 * (1 - a1) * x[k,1]
db0 += (a2 - y[k]) * net["w4"] * a0 * (1 - a0)
dw0 += (a2 - y[k]) * net["w4"] * a0 * (1 - a0) * x[k,0]
dw2 += (a2 - y[k]) * net["w4"] * a0 * (1 - a0) * x[k,1]
m = len(y)
❺ net["b2"] = net["b2"] - eta * db2 / m
net["w4"] = net["w4"] - eta * dw4 / m
net["w5"] = net["w5"] - eta * dw5 / m
net["b1"] = net["b1"] - eta * db1 / m
net["w1"] = net["w1"] - eta * dw1 / m
net["w3"] = net["w3"] - eta * dw3 / m
net["b0"] = net["b0"] - eta * db0 / m
net["w0"] = net["w0"] - eta * dw0 / m
net["w2"] = net["w2"] - eta * dw2 / m
return net
清单 10-3:使用 *GradientDescent* 训练网络
GradientDescent 函数包含一个双重循环。外部循环 ❶ 遍历 epochs,即训练集的完整遍历次数。内部循环 ❷ 遍历训练示例,一次处理一个。前向传播首先进行 ❸,用于计算输出 a2 和中间值。
接下来的代码块使用部分导数实现反向传播,通过方程 10.4 到 10.8,将误差(损失)向网络反向传播 ❹。我们使用训练集上的平均损失来更新权重和偏置。因此,我们会为每个训练示例累积每个权重和偏置值对损失的贡献。这也解释了为什么我们需要将每个新贡献加到训练集的总损失中。
将每个训练样本通过网络并累积其对损失的贡献传递后,我们更新权重和偏置 ❺。偏导数给出了梯度,即最大变化的方向;然而,我们想要最小化,所以我们沿着梯度的反方向移动,从当前值中减去每个权重和偏置导致的损失的平均值。
例如,
net["b2"] = net["b2"] - eta * db2 / m
是

其中η = 0.1 为学习率,m为训练集中样本数。求和是对b[2]的偏导数求和,针对每个输入样本x[i]评估,其平均值乘以学习率,用于调整下一个 epoch 的b[2]。我们经常用的另一个名称是学习率步长。该参数控制网络的权重和偏置如何在损失地形图上迈向最小值。
我们的实现已经完成。让我们运行它,看看它的表现如何。
训练和测试模型
让我们看一下训练数据。我们可以绘制特征,每个轴一个,看看分离两个类别有多容易。结果是 Figure 10-2,其中类 0 为圆圈,类 1 为方块。

Figure 10-2: 显示类 0(圆圈)和类 1(方块)的鸢尾花训练数据
很容易看出两个类别彼此相当分离,即使是我们带有两个隐藏神经元的基础网络也应该能够学习它们之间的差异。将此图与 Figure 6-2 左侧进行比较,该图显示了所有三种鸢尾花类别的前两个特征。如果我们的数据集中包含类 2,则两个特征将不足以分离所有三个类别。
运行以下代码
python3 nn_by_hand.py
对我来说,这产生
训练 1000 个 epochs,学习率为 0.10000
训练前:
TN: 15 FP: 0
FN: 15 TP: 0
训练后:
TN: 14 FP: 1
FN: 1 TP: 14
我们被告知训练使用了 70 个示例的训练集进行了 1000 次训练通过。这是 Listing 10-3 的外循环。然后我们被呈现出两个表格的数字,描述了训练前和训练后的网络。让我们逐步走过这些表格,了解它们所讲述的故事。
表格有几个名称:列联表,2 × 2 表或混淆矩阵。术语混淆矩阵最为通用,尽管通常用于多类分类器。标签计算测试集中真阳性、真阴性、假阳性和假阴性的数量。测试集包括 30 个样本,每类 15 个。如果网络完美,则所有 0 类样本将计入 TN 计数,所有 1 类样本将计入 TP 计数。错误为 FP 或 FN 计数。
随机初始化的网络将所有样本都标记为 class 0。我们知道这一点,因为有 15 个 TN 样本(真正的 class 0 样本)和 15 个 FN 样本(15 个被标记为 class 0 的 class 1 样本)。因此,训练前的总体准确率为 15/(15 + 15) = 0.5 = 50%。
经过训练后,在代码清单 10-3 中,代码外部循环执行了 1,000 次,测试数据几乎完全被正确分类,其中 15 个 class 0 中有 14 个被正确标记,15 个 class 1 中有 14 个被正确标记。总体准确率为 (14 + 14)/(15 + 15) = 28/30 = 93.3%,考虑到我们的模型只有一个隐藏层且该层有两个节点,这个结果还不错。
再次强调,这个练习的主要目的是展示手动计算导数是多么繁琐且容易出错。上面的代码是与标量一起工作的;它并未处理向量或矩阵,也没有利用通过更好的表示反向传播算法而可能产生的任何对称性。幸运的是,我们可以做得更好。让我们再次查看全连接网络的反向传播算法,看看是否能利用向量和矩阵来得到更优雅的实现。
全连接网络的反向传播
在本节中,我们将探讨能够将误差项从网络输出传递到输入的方程式。此外,我们还将看到如何使用这个误差项来计算层的权重和偏置的必要偏导数,以便我们能够实现梯度下降。掌握了所有基本的表达式后,我们将实现 Python 类,使我们能够构建并训练具有任意深度和形状的全连接前馈神经网络。最后,我们将通过测试 MNIST 数据集来验证这些类的效果。
反向传播误差
让我们从一个有用的观察开始:全连接神经网络的各层可以被看作是向量函数:
y = f(x)
其中,层的输入是x,输出是y。输入x,要么是网络的实际输入(用于训练样本),要么是模型的某个隐藏层的输出(如果我们在处理隐藏层)。这两者都是向量;每个层中的节点会生成一个标量输出,这些标量输出组合起来就是y,一个表示该层输出的向量。
前向传播依次通过网络的各个层,映射x[i]到y[i],使得y[i]变成x[i]+1,即层i+1 的输入。所有层都处理完后,我们使用最后一层的输出,称之为h,来计算损失,L(h, y[true])。损失是衡量网络在输入x上的错误程度,我们通过将其与真实标签y[true]进行比较来确定。请注意,如果模型是多分类的,输出h是一个向量,每个可能的类别对应一个元素,而真实标签是一个零向量,除了实际类别标签的索引位置为一。这就是为什么许多工具包(如 Keras)将整数类别标签映射到独热编码向量的原因。
我们需要将损失值,或称为误差,反向传播通过网络;这就是反向传播步骤。为了对一个全连接网络使用每层向量和权重矩阵进行操作,我们需要首先了解如何执行前向传播。就像我们为上面构建的网络所做的那样,我们将激活函数的应用与全连接层的操作分开。
例如,对于任何一个输入向量x来自下层的层,我们需要计算一个输出向量,y。对于一个全连接层,前向传播为
y = Wx + b
其中,W是权重矩阵,x是输入向量,b是偏置向量。
对于一个激活层,我们有
y = σ(x)
对于我们选择的任何激活函数σ。我们将在本章的其余部分中使用 sigmoid 函数。请注意,我们将函数设置为向量值函数。为此,我们将标量 sigmoid 函数应用于输入向量的每个元素,以生成输出向量:
σ(x) = [σ(x[0]) σ(x[1]) ... σ(x[n][−1])]^⊤
一个全连接网络由一系列全连接层和后续的激活层组成。因此,前向传播是一个操作链,首先将模型的输入传递给第一层以生成输出,然后将其传递给下一层的输入,依此类推,直到所有层都被处理。
前向传播导致最终输出和损失。损失函数对网络输出的导数是第一个误差项。为了将误差项反向传播到模型中,我们需要计算误差项如何随着层输入的变化而变化,这通过计算层输出变化时误差的变化来实现。具体来说,对于每一层,我们需要知道如何计算

也就是说,我们需要知道误差项如何随着层输入的变化而变化,给定

这表示误差项如何随着层输出的变化而变化。链式法则告诉我们如何做:

其中,层 i 的 ∂E/∂x 在我们向后通过网络时变为层 i − 1 的 ∂E/∂y。
从操作上看,反向传播算法变成了
-
进行一次前向传递,将 x → y,逐层映射,以得到最终输出 h。
-
使用h和y[true]计算损失函数的导数值;这对于输出层变为 ∂E/∂y。
-
对所有先前的层重复此过程,从 ∂E/∂y 计算 ∂E/∂x,使得层 i 的 ∂E/∂x 变为层 i − 1 的 ∂E/∂y。
这个算法将误差项反向传递通过网络。让我们从激活层开始,推导如何按层类型获取所需的偏导数。
我们假设我们知道 ∂E/∂y 并且正在寻找 ∂E/∂x。链式法则说

在这里,我们引入⊙表示哈达玛积。回想一下,哈达玛积是两个向量或矩阵的逐元素乘法。(参见第五章以获取复习资料。)
我们现在知道如何通过激活层传递误差项。我们考虑的唯一其他层是全连接层。如果我们展开方程 10.9,我们得到

由于

结果是 W^⊤,而不是 W,因为矩阵与向量的导数采用分母符号表示时,应该是矩阵的转置,而不是矩阵本身。
让我们暂停一下,回顾并思考一下方程 10.10 和 10.11 的形式。这些方程告诉我们如何将误差项从一层反向传递到另一层。这些值的形状是什么?对于激活层,如果输入有 k 个元素,那么输出也有 k 个元素。因此,方程 10.10 中的关系应该将一个 k 元素的向量映射到另一个 k 元素的向量。误差项 ∂E/∂y 是一个 k 元素的向量,激活函数的导数 σ′(x) 也是一个 k 元素的向量。最后,两者之间的哈达玛积也会输出一个 k 元素的向量,这是所需要的。
对于全连接层,我们有一个 m 元素的输入,x;一个 n × m 元素的权重矩阵,W;以及一个 n 元素的输出向量,y。所以我们需要从 n 元素的误差项 ∂E/∂y 生成一个 m 元素的向量 ∂E/∂x。通过将权重矩阵的转置(一个 m × n 元素的矩阵)与误差项相乘,确实会得到一个 m 元素的向量,因为 m × n 和 n × 1 的乘积是 m × 1,一个 m 元素的列向量。
计算权重和偏置的偏导数
方程 10.10 和方程 10.11 告诉我们如何将误差项反向传递通过网络。然而,反向传播的重点是计算权重和偏置的变化如何影响误差,以便我们使用梯度下降。具体而言,对于每一层完全连接层,我们需要以下表达式:

给定

让我们从∂E/∂b开始。再次应用链式法则得到:

这意味着,完全连接层的偏置项的误差与输出的误差相同。
权重矩阵的计算是类似的:

上面的方程告诉我们,权重矩阵的误差是输出误差与输入x的乘积。权重矩阵是一个n × m的矩阵,因为前向传递是与m元素的输入向量相乘。因此,来自权重的误差贡献∂E/∂W也必须是一个n × m的矩阵。我们知道∂E/∂y是一个n元素的列向量,而x的转置是一个m元素的行向量。两者的外积是一个n × m的矩阵,正如所要求的那样。
方程 10.10、方程 10.11、方程 10.12 和方程 10.13 适用于单个训练样本。这意味着对于特定的输入,这些方程,尤其是 10.12 和 10.13,告诉我们任何一层的偏置和权重对损失的贡献是针对该输入样本的。
为了实现梯度下降,我们需要在训练样本中累积这些误差,即∂E/∂W和∂E/∂b项。然后,我们使用这些误差的平均值在每个 epoch 结束时(或我们将要实现的小批量)更新权重和偏置。由于梯度下降是第十一章的内容,因此我们这里只是概述如何使用反向传播来实现梯度下降,详细内容将在那一章以及我们接下来实现的代码中给出。
一般来说,要训练网络,我们需要对小批量中的每个样本执行以下操作:
-
将样本通过网络进行前向传递以创建输出。在这个过程中,我们需要存储每一层的输入,因为我们在实现反向传播时需要它(即我们需要方程 10.13 中的x^⊤)。
-
计算损失函数的导数值,对我们来说是均方误差,用作反向传播中的第一个误差项。
-
按逆序通过网络的各层,计算每个完全连接层的∂E/∂W和∂E/∂b。这些值会针对小批量中的每个样本进行累积(ΔW,Δb)。
当小批量样本处理完并且误差累积后,便是进行梯度下降步骤的时候了。这时,每一层的权重和偏置通过以下方式更新:

ΔW 和 Δb 是小批量上的累计误差,m 是小批量的大小。重复的梯度下降步骤会得到一组最终的权重和偏置——一个训练好的网络。
本节内容较为数学化。下一节将数学转化为代码,在这里我们将看到,尽管数学复杂,但由于 NumPy 和面向对象设计,代码非常简洁优雅。如果你对数学部分不太熟悉,我猜代码会在很大程度上帮助你澄清这些内容。
一个 Python 实现
我们的实现风格类似于像 Keras 这样的工具包。我们希望能够创建任意的全连接网络,因此我们会使用 Python 类来表示每一层,并将架构存储为一系列层的列表。每一层都会维护自己的权重和偏置,并具备执行前向传播、反向传播和梯度下降步骤的能力。为了简化,我们使用 sigmoid 激活函数和平方误差损失函数。
我们需要两个类:ActivationLayer 和 FullyConnectedLayer。另有一个 Network 类将这些部分结合起来并处理训练。所有类都位于文件 NN.py 中。(此处的代码修改自 Omar Aflak 的原始代码,并已获得他的许可使用。详见 NN.py 中的 GitHub 链接。我修改了代码,使其支持小批量训练,并能在每个样本之外支持其他梯度下降步骤。)
让我们逐步了解每个类,从 ActivationLayer 开始(见 示例 10-4)。我们已经做的数学转化成代码的方式非常优雅,在大多数情况下,只需一行 NumPy 代码。
class ActivationLayer:
def forward(self, input_data):
self.input = input_data
return sigmoid(input_data)
def backward(self, output_error):
return sigmoid_prime(self.input) * output_error
def step(self, eta):
return
示例 10-4: *ActivationLayer* 类
示例 10-4 展示了 ActivationLayer 类,其中只包含三个方法:forward、backward 和 step。最简单的是 step 方法。它不做任何操作,因为激活层在梯度下降期间没有任何需要做的事情,因为没有权重或偏置值。
forward 方法接受输入向量 x,将其存储以供稍后使用,然后通过应用 sigmoid 激活函数计算输出向量 y。
backward 方法接受 ∂E/∂y,即来自上一层的 output_error。然后,它通过应用 sigmoid 函数的导数(sigmoid_prime)到前向传播时的输入集,并按元素与误差相乘,从而返回 公式 10.10。
sigmoid 和 sigmoid_prime 辅助函数是
def sigmoid(x):
return 1.0 / (1.0 + np.exp(-x))
def sigmoid_prime(x):
return sigmoid(x)*(1.0 - sigmoid(x))
接下来是 FullyConnectedLayer 类。它比 ActivationLayer 类更复杂,但差别不大。请参见 示例 10-5。
class FullyConnectedLayer:
def __init__(self, input_size, output_size):
❶ self.delta_w = np.zeros((input_size, output_size))
self.delta_b = np.zeros((1,output_size))
self.passes = 0
❷ self.weights = np.random.rand(input_size, output_size) - 0.5
self.bias = np.random.rand(1, output_size) - 0.5
def forward(self, input_data):
self.input = input_data
❸ return np.dot(self.input, self.weights) + self.bias
def backward(self, output_error):
input_error = np.dot(output_error, self.weights.T)
weights_error = np.dot(self.input.T, output_error)
self.delta_w += np.dot(self.input.T, output_error)
self.delta_b += output_error
self.passes += 1
return input_error
def step(self, eta):
❹ self.weights -= eta * self.delta_w / self.passes
self.bias -= eta * self.delta_b / self.passes
❺ self.delta_w = np.zeros(self.weights.shape)
self.delta_b = np.zeros(self.bias.shape)
self.passes = 0
示例 10-5: *FullyConnectedLayer* 类
我们告诉构造器输入节点和输出节点的数量。输入节点的数量(input_size)指定进入层的向量中元素的数量。同样,output_size 指定输出向量中元素的数量。
全连接层在小批量数据上累积权重和偏置误差,delta_w 中的 ∂E/∂W 项和 delta_b 中的 ∂E/∂b 项 ❶。每处理一个样本,passes 中的计数就会增加。
我们必须使用随机的权重和偏置值来初始化神经网络;因此,构造函数使用区间 [−0.5, 0.5] 内的均匀随机值设置初始权重矩阵和偏置向量 ❷。注意,偏置向量是 1 × n 的行向量。代码颠倒了上述方程的顺序,以匹配训练样本通常存储的方式:一个矩阵,其中每一行是一个样本,每一列是一个特征。计算结果是相同的,因为标量乘法是交换的:ab = ba。
forward 方法将输入向量存储起来,以供 backward 方法稍后使用,然后计算该层的输出,将输入乘以权重矩阵并加上偏置项 ❸。
只剩下两个方法。backward 方法接收 ∂E/∂y(output_error)并计算 ∂E/∂x(input_error)、∂E/∂W(weights_error)和 ∂E/∂b(output_error)。我们将这些误差添加到该层的累积误差总和 delta_w 和 delta_b 中,以供 step 使用。
step 方法包括一个用于全连接层的梯度下降步骤。与 ActivationLayer 的空方法不同,FullyConnectedLayer 有很多工作要做。我们使用平均误差来更新权重矩阵和偏置向量,正如在方程 10.14 ❹中所示。这实现了对小批量数据的梯度下降步骤。最后,我们重置累加器和计数器,为下一个小批量数据做准备 ❺。
Network 类将所有内容整合在一起,如清单 10-6 所示。
class Network:
def __init__(self, verbose=True):
self.verbose = verbose
❶ self.layers = []
def add(self, layer):
❷ self.layers.append(layer)
def predict(self, input_data):
result = []
for i in range(input_data.shape[0]):
output = input_data[i]
for layer in self.layers:
output = layer.forward(output)
result.append(output)
❸ return result
def fit(self, x_train, y_train, minibatches, learning_rate, batch_size=64):
❹ for i in range(minibatches):
err = 0
idx = np.argsort(np.random.random(x_train.shape[0]))[:batch_size]
x_batch = x_train[idx]
y_batch = y_train[idx]
❺ for j in range(batch_size):
output = x_batch[j]
for layer in self.layers:
output = layer.forward(output)
❻ err += mse(y_batch[j], output)
❼ error = mse_prime(y_batch[j], output)
for layer in reversed(self.layers):
error = layer.backward(error)
❽ for layer in self.layers:
layer.step(learning_rate)
if (self.verbose) and ((i%10) == 0):
err /= batch_size
print('minibatch %5d/%d error=%0.9f' % (i, minibatches, err))
清单 10-6: *Network* 类
Network 类的构造函数很简单。我们设置一个 verbose 标志,用于在训练过程中切换显示小批量的平均误差。成功的训练应该显示这个误差随着时间的推移而减小。随着层的增加,它们被存储在 layers 中,构造函数初始化了这个 layers ❶。add 方法通过将层对象附加到 layers 中,来将层添加到网络中 ❷。
在网络训练完成后,predict 方法通过网络的各层进行前向传播,为 input_data 中的每个输入样本生成输出。注意模式:输入样本被赋值给 output;然后循环遍历 layers,依次调用每层的 forward 方法,将上一层的输出作为输入传递给下一层;如此往复,直到整个网络。循环结束时,output 包含最终层的输出,因此它会被附加到 result 中并返回给调用者 ❸。
训练网络是fit的工作。这个名字与sklearn的标准训练方法相匹配。参数是样本向量的 NumPy 数组,每行一个(x_train),以及它们的标签作为一热向量(y_train)。接下来是训练所需的小批量数。稍后我们会讨论小批量。我们还需要提供学习率η(eta)和可选的小批量大小batch_size。
fit方法使用了一个双重循环。第一个循环是遍历所需的小批量数量❹。正如我们之前所学,小批量是完整训练集的一个子集,一个周期(epoch)是训练集的完整遍历。使用整个训练集称为批量训练,批量训练使用周期。然而,正如你将在第十一章中看到的那样,有充分的理由不使用批量训练,因此引入了小批量的概念。典型的小批量大小通常在 16 到 128 个样本之间。为了方便 GPU 深度学习工具包,通常使用 2 的幂次方。对我们来说,使用 64 或 63 个样本的小批量在性能上没有区别。
我们选择大多数小批量作为训练数据的顺序集合,以确保所有数据都被使用。在这里,我们有些懒,改为每次需要小批量时选择随机子集。这样可以简化代码,并增加一个随机性的应用场景。这就是idx的作用,它给我们提供了一个随机排列的训练集索引,只保留前batch_size个样本。然后,我们使用x_batch和y_batch进行实际的前向和反向传播。
第二个循环遍历小批量中的样本❺。样本逐一通过网络的各层,像predict一样调用forward。为了显示目的,实际的均方误差在前向传播输出与样本标签之间累积,得出小批量的误差❻。
反向传递从输出误差项开始,即损失函数的导数mse_prime❼。然后,反向传递继续向后穿过网络的各层,将上一层的输出误差作为输入传递给下一层,这个过程直接镜像了前向传播的过程。
一旦循环处理完所有的小批量样本❺,就该根据每层在样本中累计的平均误差进行梯度下降步骤❽。step的参数仅需要学习率。若设置了verbose,则每处理完 10 个小批量会报告平均误差。
我们将在第十一章中再次实验这段代码,探索梯度下降。现在,让我们用 MNIST 数据集测试它,看看它的效果如何。
使用实现
让我们来试试NN.py。我们将用它来构建一个 MNIST 数据集的分类器,这是我们在第九章中首次遇到的。原始 MNIST 数据集包含 28×28 像素的手写数字灰度图像,背景为黑色。它是机器学习社区的工作马。我们将在将这些图像转化为 196 个元素(= 14 × 14)的向量之前,将它们调整为 14×14 像素。
该数据集包括 60,000 张训练图像和 10,000 张测试图像。这些向量存储在 NumPy 数组中;请参阅dataset目录中的文件。生成数据集的代码在build_dataset.py中。如果你想自己运行代码,首先需要安装 Keras 和 OpenCV 的 Python 版本。Keras 提供了原始的图像集,并将训练集标签映射到 one-hot 向量。OpenCV 将图像从 28×28 调整为 14×14 像素。
我们需要的代码在mnist.py中,见列表 10-7。
import numpy as np
from NN import *
❶ x_train = np.load("dataset/train_images_small.npy")
x_test = np.load("dataset/test_images_small.npy")
y_train = np.load("dataset/train_labels_vector.npy")
y_test = np.load("dataset/test_labels.npy")
❷ x_train = x_train.reshape(x_train.shape[0], 1, 14*14)
x_train /= 255
x_test = x_test.reshape(x_test.shape[0], 1, 14*14)
x_test /= 255
❸ net = Network()
net.add(FullyConnectedLayer(14*14, 100))
net.add(ActivationLayer())
net.add(FullyConnectedLayer(100, 50))
net.add(ActivationLayer())
net.add(FullyConnectedLayer(50, 10))
net.add(ActivationLayer())
❹ net.fit(x_train, y_train, minibatches=40000, learning_rate=1.0)
❺ out = net.predict(x_test)
cm = np.zeros((10,10), dtype="uint32")
for i in range(len(y_test)):
cm[y_test[i],np.argmax(out[i])] += 1
print()
print(np.array2string(cm))
print()
print("accuracy = %0.7f" % (np.diag(cm).sum() / cm.sum(),))
列表 10-7:分类 MNIST 数字
请注意,我们在导入 NumPy 后立即导入NN.py。接下来,我们加载训练图像、测试图像和标签❶。Network类要求每个样本向量是一个 1×n的行向量,因此我们将训练数据从(60000,196)调整为(60000,1,196)—与测试数据相同❷。同时,我们将 8 位数据从[0, 255]缩放到[0, 1]。这是图像数据的标准预处理步骤,因为这样做能使网络更容易学习。
接下来是构建模型❸。首先,我们创建一个Network类的实例。然后,我们通过定义一个具有 196 个输入和 100 个输出的FullyConnectedLayer来添加输入层。接着是一个 sigmoid 激活层。然后,我们添加第二个全连接层,将第一层的 100 个输出映射到 50 个输出,并附加一个激活层。最后,我们添加一个最后的全连接层,将上一层的 50 个输出映射到 10 个输出,即类别数量,并添加其激活层。这种方法模仿了常见的工具包,比如 Keras。
训练通过调用fit ❹来进行。我们指定了 40,000 个小批次,使用默认的小批次大小 64 个样本。我们将学习率设置为 1.0,这在此情况下表现良好。训练在我老旧的 Intel i5 Ubuntu 系统上大约需要 17 分钟。随着模型的训练,每个小批次的平均误差会被报告。当训练完成时,我们将 10,000 个测试样本通过网络,计算出一个 10 × 10 的混淆矩阵 ❺。回忆一下,混淆矩阵的行是实际的类标签,这里是实际的数字 0 到 9。列对应预测的标签,即每个输入样本的 10 个输出中最大的值。矩阵元素表示实际标签是i,而分配的标签是j的次数。如果模型是完美的,那么矩阵将是纯对角线的,意味着没有任何实际标签和模型标签不一致的情况。最后,整体准确率是通过对角线元素的和除以矩阵的总和(即所有测试样本的总数)来计算的。
我运行mnist.py时得到的结果是
minibatch 39940/40000 error=0.003941790
minibatch 39950/40000 error=0.001214253
minibatch 39960/40000 error=0.000832551
minibatch 39970/40000 error=0.000998448
minibatch 39980/40000 error=0.002377286
minibatch 39990/40000 error=0.000850956
[[ 965 0 1 1 1 5 2 3 2 0]
[ 0 1121 3 2 0 1 3 0 5 0]
[ 6 0 1005 4 2 0 3 7 5 0]
[ 0 1 6 981 0 4 0 9 4 5]
[ 2 0 3 0 953 0 5 3 1 15]
[ 4 0 0 10 0 864 5 1 4 4]
[ 8 2 1 1 3 4 936 0 3 0]
[ 2 7 19 2 1 0 0 989 1 7]
[ 5 0 4 5 3 5 7 3 939 3]
[ 5 5 2 10 8 2 1 3 6 967]]
accuracy = 0.9720000
混淆矩阵呈强对角线形状,整体准确率为 97.2%。对于像NN.py这样的简单工具包和一个全连接前馈网络来说,这并不是一个差的结果。网络最大的错误是把七和二混淆了 19 次(混淆矩阵的元素[7,2])。接下来的错误是把四和九混淆了 15 次(元素[4,9])。这两种错误是可以理解的:七和二看起来常常相似,四和九也是如此。
我们在本章一开始创建了一个包含两个输入、两个隐藏层节点和一个输出的网络。文件iris.py通过调整数据集以适应Network的要求,来实现相同的模型。我们不再逐行讲解代码,但一定要运行它。当我运行时,我在测试集上的表现略有提升:类别 0 的正确率为 15 个中 14 个,类别 1 的正确率为 15 个中 15 个。
可惜的是,这里和上一节详细介绍的反向传播方法,最终并不够灵活,无法满足深度学习的需求。现代工具包不再使用这些方法。让我们探索一下现代深度学习工具包在反向传播方面是如何处理的。
计算图
在计算机科学中,图是一种由节点(顶点)和连接它们的边组成的集合。我们一直在使用图来表示神经网络。在这一节中,我们将用图来表示表达式。
考虑这个简单的表达式:
y = mx + b
要评估这个表达式,我们遵循关于运算符优先级的约定规则。遵循这些规则意味着一系列基本操作,我们可以将其表示为一个图,如图 10-3 所示。

图 10-3:实现 y = mx + b 的计算图
数据沿着图 10-3 中的箭头流动,从左到右。数据起始于源,这里是x、m和b,并通过运算符、和+,流向输出y*。
图 10-3 是一个计算图——一个指定如何评估表达式的图。像 C 语言这样的编译器会以某种形式生成计算图,将高级表达式转换为机器语言指令的序列。对于上面的表达式,首先将x和m的值相乘,乘法操作的结果被传递给加法操作,并与b一起产生最终输出y。
我们可以将表达式表示为计算图,包括那些表示复杂深度神经网络的表达式。我们就是这样表示完全连接的前馈模型的,数据从输入x流经隐藏层到达输出,即损失函数。
计算图是像 TensorFlow 和 PyTorch 这样的深度学习工具包如何管理模型结构并实现反向传播的方式。与本章早期的严格计算不同,计算图是通用的,能够表示深度学习中使用的所有架构。
当你浏览深度学习文献并开始使用特定工具包时,你会遇到两种不同的计算图使用方法。第一种是在数据可用时动态生成图,PyTorch 使用这种方法,称为符号到数字。TensorFlow 使用第二种方法,符号到符号,提前构建一个静态计算图。两种方法都实现了图,且都能自动计算反向传播所需的导数。
TensorFlow 以与我们在前一节中做的方式类似的方式生成反向传播所需的导数。像加法一样,每个操作都知道如何根据其输入创建输出的导数。再加上链式法则,这就是实现反向传播所需的全部内容。图的遍历方式取决于 图求值引擎 和特定的模型架构,但图会根据前向和反向传播的需要进行遍历。请注意,由于计算图将表达式分解为更小的操作,每个操作都知道如何在反向步骤中处理梯度(就像我们在上面为 ActivationLayer 和 FullyConnectedLayer 所做的那样),因此可以在层中使用自定义函数,而无需处理导数。图引擎会为你做这件事,只要你使用引擎已经支持的原始操作。
让我们走一遍计算图的前向和反向传播。这一例子来自于 2015 年的论文《TensorFlow:异构分布式系统上的大规模机器学习》(arxiv.org/pdf/1603.04467.pdf)。
完全连接模型中的隐藏层表示为
y = σ(Wx + b)
对于权重矩阵 W、偏置向量 b、输入 x 和输出 y。
图 10-4 以计算图的形式展示了相同的方程。

图 10-4:表示前向传播和反向传播的计算图,经过一个前馈神经网络的层
图 10-4 展示了两个版本。图的顶部显示了前向传播,其中数据从 x、W 和 b 流动,以生成输出。注意箭头是从左到右指向的。
请注意,源是张量,这里是向量或矩阵。操作的输出也是张量。张量在图中流动,因此得名 TensorFlow。图 10-4 将矩阵乘法表示为 @,这是 NumPy 的矩阵乘法操作符。激活函数是 σ。
对于反向传播,导数的序列从 ∂y/∂y = 1 开始,并从操作符输出通过图流回输入。如果有多个输入,就会有多个输出导数。在实际操作中,图的求值引擎会按正确的顺序处理适当的操作符。每个操作符在其被处理时,都能获得所需的输入导数。
图 10-4 在操作符前使用 ∂ 来表示操作符生成的导数。例如,加法操作符 (∂+) 生成两个输出,因为有两个输入,Wx 和 b。矩阵乘法 (∂@) 也是如此。激活函数的导数表示为 σ′。
请注意,箭头从W和x在前向传播中指向反向传播中的矩阵乘法算符的导数。W和x都是计算∂y/∂W和∂y/∂x所必需的—请参见方程式 10.13 和方程式 10.11。没有箭头从b指向矩阵乘法算符,因为∂y/∂b不依赖于b—请参见方程式 10.12。如果在图 10-4 所示的网络结构下方还有一层,则矩阵乘法算符输出的∂y/∂x将成为反向传播经过该层的输入,依此类推。
计算图的强大功能使得现代深度学习工具包具有高度的通用性,支持几乎任何网络类型和架构,而不会让用户承担繁琐且复杂的梯度计算工作。当你继续探索深度学习时,请感激这些工具包仅凭几行代码便能实现的可能性。
总结
本章介绍了反向传播,这是使深度学习实用的两个关键组成部分之一。首先,我们手动计算了一个小型网络所需的导数,了解了这一过程是多么繁琐。然而,我们成功地训练了这个小型网络。
接下来,我们使用来自第八章的矩阵微积分知识,找出了多层全连接网络的方程式,并创建了一个与 Keras 等工具包类似的简单工具包。通过这个工具包,我们成功地使用 MNIST 数据集训练了一个高准确率的模型。尽管该工具包在隐藏层数量和大小方面具有高效和通用性,但它仅限于全连接模型。
本章最后简要介绍了现代深度学习工具包(如 TensorFlow)如何实现模型并自动化反向传播。计算图使得任意组合的基本操作成为可能,每个操作都可以根据需要反向传递梯度,从而支持深度学习中复杂的模型架构。
训练深度模型的第二部分是梯度下降,它将通过反向传播计算出的梯度付诸实践。现在让我们把注意力转向这一部分。
第十一章:梯度下降**

在本章的最后,我们将稍微放慢节奏,重新审视梯度下降。我们将通过图示回顾梯度下降的概念,讨论它是什么以及如何运作。接下来,我们将探讨随机在随机梯度下降中的含义。梯度下降是一个简单的算法,允许进行调整,因此在我们探讨完随机梯度下降后,我们将考虑一个有用且常用的调整方法:动量。最后,我们将通过讨论更先进的自适应梯度下降算法来结束本章,具体包括 RMSprop、Adagrad 和 Adam。
这是一本数学书,但梯度下降非常贴近应用数学,因此我们将通过实验学习。这些方程是直接的,我们在前几章看到的数学知识作为背景是相关的。因此,可以将本章视为应用我们迄今为止所学内容的机会。
基本概念
我们已经遇到过几次梯度下降。我们知道基本的梯度下降更新方程的形式,来自方程 10.14:

这里,ΔW 和 Δb 分别是基于权重和偏差的偏导数计算出的误差;η(eta)是步长或学习率,这是我们用来调整移动方式的值。
方程 11.1 并不特定于机器学习。我们可以使用相同的形式在任意函数上实现梯度下降。让我们通过一维和二维示例讨论梯度下降,为其操作打下基础。我们将使用一种未经修改的梯度下降形式,称为普通梯度下降。
一维梯度下降
让我们从一个标量函数 x 开始:

方程 11.2 是一个向上的抛物线。因此,它有一个最小值。让我们通过将导数设为零并解出x来解析地找到最小值:

抛物线的最小值在 x = 1。现在,让我们改用梯度下降来找到方程 11.2 的最小值。我们应该如何开始?
首先,我们需要写出适当的更新方程,这是方程 11.1 在这种情况下的形式。我们需要梯度,对于一维函数,它就是导数,f′(x) = 12x − 12。通过导数,梯度下降变为:

注意我们减去了η (12x − 12)。这就是算法被称为梯度下降的原因。回想一下,梯度指向函数值变化最大的方向。我们关心的是最小化函数,而不是最大化它,因此我们沿着与梯度相反的方向前进,朝着更小的函数值移动;因此,我们进行减法。
方程 11.3 是一次梯度下降步骤。它从初始位置 x 移动到一个新位置,基于当前点的斜率值。同样,学习率 η 决定了我们移动的距离。
既然我们已经得到了方程,让我们实现梯度下降法。我们将绘制方程 11.2,选择一个起始位置,例如 x = −0.9,并迭代方程 11.3,每次在 x 的新位置绘制函数值。如果我们这样做,我们应该会看到一系列的点在函数上,逐渐逼近最小值位置 x = 1。现在让我们写些代码。
首先,我们实现方程 11.2 及其导数:
def f(x):
return 6*x**2 - 12*x + 3
def d(x):
return 12*x - 12
接下来,我们绘制函数图像,然后迭代方程 11.3,每次绘制新的位置对(x,f(x)):
import numpy as np
import matplotlib.pylab as plt
❶ x = np.linspace(-1,3,1000)
plt.plot(x,f(x))
❷ x = -0.9
eta = 0.03
❸ for i in range(15):
plt.plot(x, f(x), marker='o', color='r')
❹ x = x - eta * d(x)
让我们逐步讲解代码。导入 NumPy 和 Matplotlib 后,我们绘制方程 11.2 ❶。接下来,我们设置初始的 x 位置 ❷,并进行 15 步梯度下降 ❸。我们在每一步之前进行绘图,因此我们看到的是初始的 x,但不会绘制最后一步,这在这种情况下是可以的。
最后一行 ❹ 是关键。它实现了方程 11.3。我们通过将导数在 x 处的值乘以步长 η = 0.03 来更新当前的 x 位置。上面的代码位于 gd_1d.py 文件中。如果我们运行它,我们将得到图 11-1。

图 11-1:使用较小步长(η = 0.03)的单维梯度下降
我们的初始位置可以看作是最小值位置的初步猜测,x = −0.9。显然,这并不是最小值。随着我们进行梯度下降,每一步都会让我们逐渐接近最小值,正如一系列逐步朝向最小值的圆圈所示。
这里有两点需要注意。首先,我们确实越来越接近最小值。经过 14 步后,从实际角度看,我们已经接近最小值:x = 0.997648。其次,每一步梯度下降都会导致 x 的变化越来越小。学习率保持在 η = 0.03,因此 x 的小更新源自于每个 x 位置处的导数值逐渐减小。如果我们思考一下,就能理解。随着我们接近最小值位置,导数会越来越小,直到最小值处导数为零,因此使用导数进行更新时,更新量也会逐渐减小。
我们选择了图 11-1 中的步长,使其平滑地朝着抛物线的最小值移动。如果我们改变步长会怎么样呢?在 gd_1d.py 文件中,代码重复了上述步骤,从 x = 0.75 开始,设置 η = 0.15,步长是图 11-1 中绘制的步长的五倍。结果是图 11-2。

图 11-2:使用较大步长(η = 0.15)的单维梯度下降
在这种情况下,步骤会超越最小值。新的 x 位置会振荡,来回越过真正的最小值位置。虚线连接了连续的 x 位置。整体搜索仍然朝最小值逼近,但由于步长较大,每次更新 x 时可能会越过最小值,导致需要更长时间才能到达。
小的梯度下降步长沿函数走小距离,而大的步长则走大距离。如果学习率过小,可能需要很多梯度下降步骤。如果学习率过大,搜索就会超越并在最小值位置附近振荡。适当的学习率并不容易确定,因此直觉和经验在选择时非常重要。此外,这些例子中假设 η 是常数。事实上,η 不必是常数。在许多深度学习应用中,学习率不是恒定的,而是随着训练进展而变化,实际上使得 η 成为梯度下降步数的函数。
二维梯度下降
一维梯度下降比较简单。接下来,我们转向二维,以增强我们对算法的直觉。下面引用的代码在文件 gd_2d.py 中。我们将首先考虑函数有一个最小值的情况,然后再讨论多个最小值的情况。
单一最小值的梯度下降
为了在二维空间中工作,我们需要一个向量的标量函数,f(x) = f(x, y),为了便于理解,我们将向量分解成其分量,x = (x, y)。
我们将使用的第一个函数是:
f(x, y) = 6x² + 9y² − 12x − 14y + 3
为了实现梯度下降,我们还需要偏导数:

我们的更新方程变为:

在代码中,我们定义了函数和偏导数:
def f(x,y):
return 6*x**2 + 9*y**2 - 12*x - 14*y + 3
def dx(x):
return 12*x - 12
def dy(y):
return 18*y - 14
由于偏导数与另一个变量无关,我们只需要传递 x 或 y。稍后在本节中,我们将看到一个例子,情况并非如此。
梯度下降遵循与之前相同的模式:选择一个初始位置,这次是一个向量,进行若干步迭代,并绘制路径。该函数是二维的,因此我们首先使用等高线来绘制它,如下所示。
N = 100
x,y = np.meshgrid(np.linspace(-1,3,N), np.linspace(-1,3,N))
z = f(x,y)
plt.contourf(x,y,z,10, cmap="Greys")
plt.contour(x,y,z,10, colors='k', linewidths=1)
plt.plot([0,0],[-1,3],color='k',linewidth=1)
plt.plot([-1,3],[0,0],color='k',linewidth=1)
plt.plot(1,0.7777778,color='k',marker='+')
这段代码需要一些解释。为了绘制等高线,我们需要在 (x, y) 对的网格上表示函数。为了生成网格,我们使用 NumPy,特别是 np.meshgrid。np.meshgrid 的参数是 x 和 y 的点,这里由 np.linspace 提供,后者生成从 −1 到 3 的 N = 100 个均匀分布的值。np.meshgrid 函数返回两个 100 × 100 的矩阵。第一个矩阵包含给定范围内的 x 值,第二个矩阵包含 y 值。返回值表示所有可能的 (x, y) 对,形成覆盖 x 和 y 范围为 −1 到 3 的点的网格。将这些点传递给函数后,返回 z,即一个 100 × 100 的矩阵,表示每个 (x, y) 对的函数值。
我们可以绘制三维函数图,但那样不容易观察且在此情况下不必要。相反,我们将使用 x、y 和 z 的函数值来生成等高线图。等高线图以相同 z 值的一系列线条来展示三维信息。可以想象成地形图上环绕山丘的线条,每条线表示相同的海拔高度。随着山丘的升高,线条将围绕越来越小的区域。
等高线图有两种类型:一种是相同函数值的线条,另一种是表示函数范围的阴影。我们将使用灰度图同时绘制这两种类型。这是调用 Matplotlib 的 plt.contourf 和 plt.contour 函数的最终结果。其余的 plt.plot 调用显示了坐标轴,并用加号标记了函数的最小值。等高线图中的较浅阴影表示较低的函数值。
现在我们准备绘制梯度下降步骤的序列。我们将绘制序列中的每个位置,并用虚线将它们连接起来,以使路径更加清晰(见 清单 11-1)。在代码中,这就是
x = xold = -0.5
y = yold = 2.9
for i in range(12):
plt.plot([xold,x],[yold,y], marker='o', linestyle='dotted', color='k')
xold = x
yold = y
x = x - 0.02 * dx(x)
y = y - 0.02 * dy(y)
清单 11-1:二维梯度下降
我们从 (x, y) = (−0.5, 2.9) 开始,进行 12 步梯度下降。为了用虚线连接最后一个位置到新位置,我们跟踪当前的 x 和 y 位置以及先前的位置,(x[old], y[old])。梯度下降步骤更新 x 和 y,使用 η = 0.02,并调用各自的偏导数函数 dx 和 dy。
图 11-3 显示了 清单 11-1 所遵循的梯度下降路径(圆形),以及从 (1.5, −0.8)(方形)和 (2.7, 2.3)(三角形)出发的另外两条路径。

图 11-3:二维梯度下降(小步长)
所有三条梯度下降路径都汇聚到函数的最小值。这并不令人惊讶,因为该函数只有一个最小值。如果函数只有一个最小值,那么梯度下降最终会找到它。如果步长太小,可能需要很多步骤,但它们最终会收敛到最小值。如果步长太大,梯度下降可能会在最小值附近振荡,但会不断越过它。
现在,让我们稍微修改一下函数,将其在x方向上相对于y方向进行拉伸:
f(x, y) = 6x² + 40y² − 12x − 30y + 3
该函数的偏导数为∂f/∂x = 12x − 12 和 ∂f/∂y = 80y − 30。
此外,让我们选择两个起始位置(−0.5, 2.3)和(2.3, 2.3),并分别使用η = 0.02 和η = 0.01 生成一系列梯度下降步骤。图 11-4 显示了结果路径。

图 11-4:具有较大步长和稍有不同函数的二维梯度下降
首先考虑η = 0.02(圆形)路径。新函数像一个峡谷,y方向很窄,但x方向很长。较大的步长使得在y方向上上下振荡,同时朝着x方向的最小值移动。尽管在峡谷壁上反弹,我们仍然能找到最小值。
现在,看看η = 0.01(方形)路径。它很快掉入峡谷,然后沿峡谷底部缓慢移动,朝着最小值位置前进。在峡谷中,沿x方向的梯度分量(x和y的偏导数值)很小,因此沿x方向的运动相对较慢。在y方向上没有运动——峡谷很陡峭,相对较小的学习率已经定位到了峡谷底部,那里梯度主要沿x方向。
这里的教训是什么?同样,步长很重要。然而,函数的形状更为重要。该函数的最小值位于一个狭长峡谷的底部。峡谷中的梯度非常小;峡谷底部在x方向上是平坦的,因此运动较慢,因为它依赖于梯度值。我们在深度学习中经常遇到这种情况:如果梯度很小,学习就很慢。这也是为什么修正线性单元(ReLU)在深度学习中占主导地位的原因;对于正输入,梯度是恒定的。对于 Sigmoid 或双曲正切函数,当输入远离零时,梯度接近零。
具有多个最小值的梯度下降
到目前为止,我们所研究的函数都有一个单一的最小值。如果情况不是这样呢?让我们来看一下当函数有多个最小值时,梯度下降会发生什么。考虑这个函数:

方程 11.4 是两个反向高斯分布的和,一个在 (−1, 1) 处的最小值为 −2,另一个在 (1, −1) 处的最小值为 −1。如果梯度下降法要找到全局最小值,它应该会在 (−1, 1) 处找到。此示例的代码在 gd_multiple.py 中。
偏导数为:

这转换为以下代码:
def f(x,y):
return -2*np.exp(-0.5*((x+1)**2+(y-1)**2)) + \
-np.exp(-0.5*((x-1)**2+(y+1)**2))
def dx(x,y):
return 2*(x+1)*np.exp(-0.5*((x+1)**2+(y-1)**2)) + \
(x-1)*np.exp(-0.5*((x-1)**2+(y+1)**2))
def dy(x,y):
return (y+1)*np.exp(-0.5*((x-1)**2+(y+1)**2)) + \
2*(y-1)*np.exp(-0.5*((x+1)**2+(y-1)**2))
请注意,在这种情况下,偏导数确实依赖于 x 和 y。
gd_multiple.py 中的梯度下降部分代码与之前相同。让我们运行表 11-1 中的案例。
表 11-1: 不同起始位置和梯度下降步数
| 起始点 | 步数 | 符号 |
|---|---|---|
| (–1.5,1.2) | 9 | 圆形 |
| (1.5,–1.8) | 9 | 正方形 |
| (0,0) | 20 | 加号 |
| (0.7,–0.2) | 20 | 三角形 |
| (1.5,1.5) | 30 | 星号 |
“符号”列指的是在图 11-5 中使用的图形符号。对于所有情况,η = 0.4。

图 11-5:具有两个极小值的函数的梯度下降
图 11-5 中所示的梯度下降路径是合理的。在五个案例中,三条路径确实进入了由两个极小值中较深的一个定义的“深谷”——这是一次成功的搜索。然而,对于三角形和正方形,梯度下降却陷入了错误的最小值。显然,在这种情况下,梯度下降的成功与否取决于我们从哪里开始。一旦路径向下移动到一个更深的位置,梯度下降就没有办法反向上升以找到一个可能更好的最小值。
目前的看法是,深度学习模型的损失景观包含许多局部最小值。现在也普遍认为,在大多数情况下,这些最小值是相当相似的,这部分解释了深度学习模型的成功——训练这些模型时,你不需要找到损失的唯一“魔法”全局最小值,只需要找到其中一个(可能)与其他最小值差不多好的最小值。
我选择了本节示例中使用的初始位置,基于对函数形式的了解有意识地进行了选择。对于深度学习模型,选择起始点意味着对权重和偏置的随机初始化。通常,我们不知道损失函数的具体形式,因此初始化是一种盲目的尝试。大多数时候,或者至少很多时候,梯度下降会产生一个表现良好的模型。然而,有时它并不会如此成功;它可能会彻底失败。在这种情况下,可能是因为初始位置像图 11-5 中的正方形那样:它陷入了一个较差的局部最小值,因为它从一个不好的位置开始。
现在我们已经掌握了梯度下降法的基本概念,它是什么以及如何运作,让我们来探讨如何将其应用于深度学习中。
随机梯度下降
训练神经网络的主要目的是最小化损失函数,同时通过各种形式的正则化保持泛化能力。在第十章中,我们将损失表示为L(θ; x, y),其中θ(theta)是权重和偏置的向量,训练实例(x, y)是输入向量x和已知标签 y。请注意,这里,x代表的是所有训练数据,而不仅仅是单个样本。
梯度下降需要∂L/∂θ,我们通过反向传播得到这个值。∂L/∂θ是指所有单独的权重和偏置误差项的简洁表示,它们是反向传播给出的。我们通过对训练数据上的误差进行平均来得到∂L/∂θ。这就引出了一个问题:我们是对所有训练数据进行平均,还是仅对部分训练数据进行平均?
在进行梯度下降步骤之前,将所有训练数据传递给模型的过程称为批量训练。乍一看,批量训练似乎是合理的。毕竟,如果我们的训练集是来自于生成我们模型所需数据的父分布的良好样本,那么为什么不使用所有这些样本来进行梯度下降呢?
当数据集较小时,批量训练是自然的选择。然而,随着模型和数据集的增大,每次梯度下降步骤中将所有训练数据传递给模型的计算负担变得过于沉重。本章的示例已经暗示,为了找到一个好的最小值位置,可能需要进行许多梯度下降步骤,尤其是在使用非常小的学习率时。
因此,实践者开始在每次梯度下降步骤中使用训练数据的子集——即小批量。小批量训练最初可能被视为一种折衷,因为在小批量上计算的梯度是“错误的”,因为它并非基于完整训练集的表现。
当然,批量和小批量之间的区别只是一个约定俗成的虚构。实际上,它是从一个样本的小批量到包含所有可用样本的小批量的一个连续体。考虑到这一点,所有在网络训练过程中计算的梯度都是“错误的”,或者至少是不完整的,因为它们是基于对数据生成器及其能够生成的完整数据集的不完全了解。
因此,小批量训练并不是一种妥协,而是合理的选择。与在较大小批量上计算的梯度相比,小批量的梯度噪声较大,从某种意义上说,小批量的梯度是对“真实”梯度的粗略估计。当情况是噪声较大或随机时,随机这个词通常会出现,就像这里一样。使用小批量的梯度下降就是随机梯度下降(SGD)。
实际上,使用较小的小批量进行梯度下降往往会导致表现优于使用较大小批量训练的模型。一般给出的理论是,较小小批量的噪声梯度帮助梯度下降避免陷入损失景观中的差局部最小值。我们在图 11-5 中看到了这个效果,那里三角形和方形都陷入了错误的最小值。
再次地,我们发现自己非常幸运。以前,我们的幸运在于一阶梯度下降成功地训练了那些由于非线性损失景观而无法训练的模型,而现在,通过故意使用少量数据来估计梯度,我们获得了一个提升,从而避免了一个计算负担,这个负担可能使得深度学习的整个工作变得过于繁琐,在很多情况下难以实施。
我们的小批量应该多大?小批量大小是一个超参数,是我们需要选择的参数来训练模型,但它不是模型本身的一部分。适当的小批量大小取决于数据集。例如,在极端情况下,我们可以为每个样本进行一次梯度下降步骤,这有时效果很好。这个情况通常被称为在线学习。然而,特别是如果我们使用像批量归一化这样的层时,我们需要一个足够大的小批量,以便使计算出的均值和标准差成为合理的估计。同样,就像目前深度学习中的大多数事情一样,这些都是经验性的,你既需要有直觉,又需要尝试许多变种来优化模型的训练。这也是为什么人们研究AutoML系统,这些系统旨在为你进行所有的超参数调优。
另一个好问题是:小批量中应该包含什么?也就是说,我们应该使用完整数据集中的哪个小子集?通常,训练集中的样本顺序是随机化的,随后从数据集中抽取小批量,直到所有样本都被使用。使用数据集中的所有样本定义一个轮次,因此训练集中样本的数量除以小批量大小,决定了每个轮次的小批量数量。
另外,就像我们在NN.py中做的那样,一个小批量可能是真正从可用数据中随机抽取的。某些训练样本可能永远不会被使用,而其他样本可能被多次使用,但总体而言,大部分数据集在训练过程中都会被使用。
一些工具包会为指定数量的小批量进行训练。NN.py和 Caffe 就是采用这种方式。其他工具包,如 Keras 和sklearn,则使用轮次(epochs)。梯度下降步骤在处理完一个小批量后进行。较大的小批量会导致每轮次的梯度下降步骤较少。为了弥补这一点,使用轮次的工具包的实践者需要确保随着小批量大小的增加,梯度下降步骤的数量也增加——较大的小批量需要更多的轮次才能训练得好。
总结一下,深度学习至少因为以下原因不使用全批量训练:
-
计算负担太重,无法在每个梯度下降步骤中将整个训练集通过模型。
-
从小批量的平均损失计算出的梯度是一个噪声较大但合理的估计,接近真实的梯度,尽管这个真实的梯度是不可知的。
-
含噪声的梯度在损失曲线中指向一个略有偏差的方向,从而可能避开不好的最小值。
-
小批量训练在实践中对于许多数据集更有效。
第 4 个原因不容小觑:深度学习中的许多实践最初采用,是因为它们工作得更好。只有后来,才会用理论来证明其合理性,如果有的话。
由于我们已经在第十章实现了 SGD(参见NN.py),所以这里不再重新实现,但在接下来的部分,我们将加入动量,看看它如何影响神经网络训练。
动量
原始梯度下降仅依赖于偏导数的值与学习率的乘积。如果损失曲线有许多局部最小值,特别是这些最小值很陡峭,原始梯度下降可能会陷入其中的一个最小值,无法恢复。为了解决这个问题,我们可以修改原始梯度下降,加入动量项,这个项使用前一步更新的一个比例。将这个动量项包含在梯度下降中,为算法在损失曲线中的运动增加了惯性,从而有可能让梯度下降跳过不好的局部最小值。
让我们定义并尝试使用动量,先用一维和二维示例,如我们之前所做的那样。之后,我们将更新我们的NN.py工具包,使用动量来观察它如何影响在更复杂数据集上训练的模型。
什么是动量?
在物理学中,运动物体的动量定义为质量乘以速度,p = mv。然而,速度本身是位置的导数,v = dx/dt,所以动量是质量乘以物体位置随时间变化的速率。
对于梯度下降,位置是函数值,时间是函数的自变量。然后,速度是函数值随着自变量变化的变化速率,∂f/∂x。因此,我们可以将动量看作是一个缩放的速度项。在物理学中,缩放因子是质量。对于梯度下降,缩放因子是μ(mu),一个介于零和一之间的数字。
如果我们将包含动量项的梯度称为v,那么之前的梯度下降更新方程将是

变成

对于某些初始速度,v = 0,以及“质量”μ。
让我们通过 公式 11.5 来理解它的含义。这个两步更新,先更新v再更新x,使得迭代变得容易,因为我们知道这是梯度下降中必须做的。如果我们将v代入更新方程中的x,我们得到

这使得更新过程变得清晰,它包含了之前的梯度步长,但会加入上一轮步长的一部分。之所以是部分,是因为我们将 μ 限制在 [0, 1] 范围内。如果 μ = 0,我们就回到常规的梯度下降方法。可以将 μ 理解为一个比例因子,表示保留多少上一轮速度并与当前梯度值一起更新。
动量项倾向于使运动在损失景观中沿着其先前的方向继续。μ 的值决定了这一倾向的强度。深度学习从业者通常使用 μ = 0.9,这样大部分先前的更新方向在下一步中得以保留,当前的梯度提供了一个小的调整。同样,像深度学习中的许多事物一样,这个数字是通过经验选择的。
牛顿的第一运动定律表明,除非外力作用,否则物体会保持运动状态。对外力的抗拒与物体的质量有关,这种抗拒被称为 惯性。因此,我们也可以将 μv 项视为惯性,这或许是一个更合适的名称。
不管名字如何,现在我们已经有了它,让我们看看它对我们之前使用常规梯度下降处理的 1D 和 2D 示例有什么影响。
1D 中的动量
让我们修改上面的 1D 和 2D 示例,使用动量项。我们从 1D 情况开始。更新后的代码位于文件 gd_1d_momentum.py 中,并在此作为清单 11-2 展示。
import matplotlib.pylab as plt
def f(x):
return 6*x**2 - 12*x + 3
def d(x):
return 12*x - 12
❶ m = ['o','s','>','<','*','+','p','h','P','D']
x = np.linspace(0.75,1.25,1000)
plt.plot(x,f(x))
❷ x = xold = 0.75
eta = 0.09
mu = 0.8
v = 0.0
for i in range(10):
❸ plt.plot([xold,x], [f(xold),f(x)], marker=m[i], linestyle='dotted',
color='r')
xold = x
v = mu*v - eta * d(x)
x = x + v
for i in range(40):
v = mu*v - eta * d(x)
x = x + v
❹ plt.plot(x,f(x),marker='X', color='k')
清单 11-2:具有动量的一维梯度下降
清单 11-2 有点密集,所以让我们逐步解析。首先,我们进行绘图,因此需要引入 Matplotlib。接下来,我们定义函数 f(x) 及其导数 d(x),与之前一样。为了配置绘图,我们定义了一组标记 ❶,然后绘制函数本身。如同之前一样,我们从 x = 0.75 ❷ 开始,并设置步长(eta)、动量(mu)和初始速度(v)。
我们现在准备开始迭代。我们将使用两个梯度下降循环。第一个绘制每一步 ❸,第二个继续梯度下降以展示我们最终确实能找到最小值,并用 'X' ❹ 标记它。对于每一步,我们通过模仿公式 11.5 计算新的速度,然后将速度加到当前的位置,得到下一个位置。
图 11-6 显示了 gd_1d_momentum.py 的输出。

图 11-6:具有动量的一维梯度下降
请注意,我们故意使用了较大的步长(η),因此我们超越了最小值。动量项也倾向于超越最小值。如果你沿着虚线和图表标记的序列走,你可以通过前 10 步的梯度下降过程。虽然有振荡,但振荡是衰减的,并最终在标记的最小值处稳定下来。添加动量增强了由于较大步长导致的超越。然而,即使有了动量项,在这里并没有什么优势,因为这里只有一个最小值,经过足够的梯度下降步骤,我们最终还是找到了最小值。
二维中的动量
现在,让我们更新我们的二维示例。我们正在使用gd_momentum.py中的代码。回想一下,在二维示例中,函数是两个反向高斯函数的和。包括动量后,代码会稍作更新,如示例 11-3 所示:
def gd(x,y, eta,mu, steps, marker):
xold = x
yold = y
❶ vx = vy = 0.0
for i in range(steps):
plt.plot([xold,x],[yold,y], marker=marker,
linestyle='dotted', color='k')
xold = x
yold = y
❷ vx = mu*vx - eta * dx(x,y)
vy = mu*vy - eta * dy(x,y)
❸ x = x + vx
y = y + vy
❹ gd( 0.7,-0.2, 0.1, 0.9, 25, '>')
gd( 1.5, 1.5, 0.02, 0.9, 90, '*')
示例 11-3:带动量的二维梯度下降
在这里,我们有一个新的函数gd,它执行带动量的梯度下降,从(x, y)开始,使用给定的μ和η,并运行steps次迭代。
初始速度被设置为❶,然后循环开始。公式 11.5 中的速度更新变为vx = mu*vx - eta * dx(x,y)❷,位置更新变为x = x + vx❸。和之前一样,绘制一条线连接最后的位置和当前的位置,以追踪通过函数空间的运动轨迹。
gd_momentum.py中的代码追踪了从之前使用的两个点开始的运动,分别是(0.7, −0.2)和(1.5, 1.5)❹。请注意,不同的点使用的步数和学习率不同,以避免图形过于杂乱。gd_momentum.py的输出为图 11-7。

图 11-7:带动量的二维梯度下降
比较图 11-7 中的路径与图 11-5 中的路径。添加动量后,路径发生了偏移,因此它们趋向于朝同一方向持续运动。注意,从(1.5, 1.5)开始的路径朝着最小值螺旋移动,而另一条路径则朝着较浅的最小值弯曲,经过它后又返回到它附近。
动量项改变了在函数空间中运动的动态。然而,并不立刻能看出动量是否有帮助。毕竟,使用普通梯度下降的(1.5, 1.5)起始位置直接移动到了最小值位置,没有出现螺旋形运动。
让我们将动量添加到我们的NN.py工具集中,看看在训练真实神经网络时它是否能带来好处。
使用动量训练模型
为了在NN.py中支持动量,我们需要在FullyConnectedLayer方法的两个地方进行调整。首先,如示例 11-4 所示,我们修改构造函数以允许momentum关键字:
def __init__(self, input_size, output_size, momentum=0.0):
self.delta_w = np.zeros((input_size, output_size))
self.delta_b = np.zeros((1,output_size))
self.passes = 0
self.weights = np.random.rand(input_size, output_size) - 0.5
self.bias = np.random.rand(1, output_size) - 0.5
❶ self.vw = np.zeros((input_size, output_size))
self.vb = np.zeros((1, output_size))
self.momentum = momentum
示例 11-4:添加动量关键字
在这里,我们向参数列表中添加了一个 momentum 关键字,默认值为零。然后,我们定义了权重(vw)和偏置(vb)的初始速度 ❶。这些是正确形状的矩阵,初始化为零。我们还保留了动量参数以供后续使用。
第二个修改是针对 step 方法的,正如列表 11-5 所示:
def step(self, eta):
❶ self.vw = self.momentum * self.vw - eta * self.delta_w / self.passes
self.vb = self.momentum * self.vb - eta * self.delta_b / self.passes
❷ self.weights = self.weights + self.vw
self.bias = self.bias + self.vb
self.delta_w = np.zeros(self.weights.shape)
self.delta_b = np.zeros(self.bias.shape)
self.passes = 0
列表 11-5:更新 step 以包含动量
我们实现了公式 11.5,首先是针对权重 ❶,然后是下一行的偏置。我们将动量(μ)乘以前一个速度,然后减去每个小批量的平均误差,再乘以学习率。接着,我们通过加上速度 ❷ 来更新权重和偏置。这就是我们引入动量所需要做的全部。然后,要使用它,我们在构建网络时为每个全连接层添加动量关键字,如列表 11-6 所示:
net = Network()
net.add(FullyConnectedLayer(14*14, 100, momentum=0.9))
net.add(ActivationLayer())
net.add(FullyConnectedLayer(100, 50, momentum=0.9))
net.add(ActivationLayer())
net.add(FullyConnectedLayer(50, 10, momentum=0.9))
net.add(ActivationLayer())
列表 11-6:在构建网络时指定动量
每层添加动量使得可以使用特定于每层的动量值。尽管我不知道是否有研究这样做,但这似乎是一个相当明显的尝试,因此现在可能已经有人尝试过了。对于我们的目的,我们将所有层的动量设置为 0.9,并继续进行。
我们应该如何测试我们新的动量?我们可以使用上面提到的 MNIST 数据集,但它并不是一个合适的候选者,因为它太简单了。即使是一个简单的全连接网络也能达到超过 97% 的准确率。因此,我们将用另一个已知更具挑战性的数据集替换 MNIST 数字数据集:Fashion-MNIST 数据集。(请参见 Han Xiao 等人的《Fashion-MNIST: A Novel Image Dataset for Benchmarking Machine Learning Algorithms》,arXiv:1708.07747 [2017]。)
Fashion-MNIST 数据集(FMNIST) 是现有 MNIST 数据集的替代品。它包含来自 10 个服装类别的图像,均为 28×28 像素的灰度图像。为了我们的目的,我们将像 MNIST 那样将 28×28 像素的图像缩减为 14×14 像素。图像存储在 dataset 目录中,以 NumPy 数组的形式。让我们使用这些数据训练一个模型。模型的代码与列表 10-7 类似,唯一不同的是在列表 11-7 中,我们将 MNIST 数据集替换为 FMNIST:
x_train = np.load("fmnist_train_images_small.npy")/255
x_test = np.load("fmnist_test_images_small.npy")/255
y_train = np.load("fmnist_train_labels_vector.npy")
y_test = np.load("fmnist_test_labels.npy")
列表 11-7:加载 Fashion-MNIST 数据集
我们还包括了计算测试数据集的 Matthews 相关系数(MCC)的代码。我们在第四章中首次接触到 MCC,并了解到它比准确率更能衡量模型的表现。要运行的代码在fmnist.py中。在一台较旧的 Intel i5 计算机上,大约 18 分钟完成一次运行,输出结果为:
[[866 1 14 28 8 1 68 0 14 0]
[ 5 958 2 25 5 0 3 0 2 0]
[ 20 1 790 14 126 0 44 1 3 1]
[ 29 21 15 863 46 1 20 0 5 0]
[ 0 0 91 22 849 1 32 0 5 0]
[ 0 0 0 1 0 960 0 22 2 15]
[161 2 111 38 115 0 556 0 17 0]
[ 0 0 0 0 0 29 0 942 0 29]
[ 1 0 7 5 6 2 2 4 973 0]
[ 0 0 0 0 0 6 0 29 1 964]]
accuracy = 0.8721000
MCC = 0.8584048
混淆矩阵,依然是 10×10,因为 FMNIST 有 10 个类别,和我们在 MNIST 中看到的非常干净的混淆矩阵相比,它相当嘈杂。对于全连接模型来说,这是一个具有挑战性的数据集。回想一下,MCC 是一个衡量标准,其值越接近 1,模型越好。
上面的混淆矩阵是用于没有动量训练的模型。学习率为 1.0,训练了 40,000 个包含 64 个样本的小批量。如果我们对每个全连接层添加 0.9 的动量,并将学习率降低到 0.2,会发生什么呢?当我们添加动量时,降低学习率是有道理的,这样我们就不会因为动量已经朝某个方向移动而使步伐变得过大。请尝试在没有动量的情况下,以学习率 0.2 运行fmnist.py,看看会发生什么。
含有动量的代码版本在fmnist_momentum.py中。大约 20 分钟后,这段代码运行一次产生了
[[766 5 14 61 2 1 143 0 8 0]
[ 1 958 2 30 3 0 6 0 0 0]
[ 12 0 794 16 98 0 80 0 0 0]
[ 8 11 13 917 21 0 27 0 3 0]
[ 0 0 84 44 798 0 71 0 3 0]
[ 0 0 0 1 0 938 0 31 1 29]
[ 76 2 87 56 60 0 714 0 5 0]
[ 0 0 0 0 0 11 0 963 0 26]
[ 1 1 6 8 5 1 10 4 964 0]
[ 0 0 0 0 0 6 0 33 0 961]]
accuracy = 0.8773000
MCC = 0.8638721
这给我们带来了略微更高的 MCC。这是否意味着动量起到了帮助作用?也许吧。正如我们现在所理解的那样,训练神经网络是一个随机过程。所以,我们不能依赖单次训练模型的结果。我们需要多次训练模型并对结果进行统计测试。太棒了!这给了我们一个机会,能将我们在第四章中学到的假设检验知识付诸实践。
与其分别运行fmnist.py和fmnist_momentum.py各一次,不如每个运行 22 次。这在我的旧 Intel i5 系统上大概需要一天的时间,但耐心是美德。最终结果是动量模型有 22 个 MCC 值,没有动量的模型也有 22 个。22 个样本没有什么神奇之处,但我们打算使用 Mann-Whitney U 检验,而该检验的经验法则是每个数据集至少要有 20 个样本。
图 11-8 显示了结果的直方图。

图 11-8:显示带有动量(浅灰色)和不带动量(深灰色)模型的 MCC 分布的直方图
深灰色条表示没有动量的 MCC 值,浅灰色条表示有动量的 MCC 值。从视觉上看,这两者有很大的区别。生成图 11-8 的代码在fmnist_analyze.py文件中。请一定查看这段代码,它使用了 SciPy 的ttest_ind和mannwhitneyu,并结合我们在第四章中提供的 Cohen's d实现来计算效应大小。MCC 值本身保存在代码中列出的 NumPy 文件中。
除了图表,fmnist_analyze.py还生成了以下输出:
no momentum: 0.85778 +/- 0.00056
momentum : 0.86413 +/- 0.00075
t-test momentum vs no (t,p): (6.77398299, 0.00000003)
Mann-Whitney U : (41.00000000, 0.00000126)
Cohen's d : 2.04243
其中,前两条线分别是均值和均值的标准误差。t 检验的结果是(t,p),即t统计量及其关联的p值。同样,Mann-Whitney U 检验的结果是(U,p),即U统计量及其p值。回想一下,Mann-Whitney U 检验是一种非参数检验,假设 MCC 值的分布形态没有任何假定。而 t 检验假设它们服从正态分布。由于我们每组样本只有 22 个,实际上我们不能对结果是否服从正态分布做出任何明确的结论;这些直方图看起来并不像高斯曲线。这就是我们包含 Mann-Whitney U 检验结果的原因。
看一下各自的p值,我们可以得出结论,带动量和不带动量的 MCC 值均值差异在统计上是高度显著的,且带动量的结果更具优势。t值为正,且带动量的结果是第一个参数。那么 Cohen 的d值呢?它略高于 2.0,表示效应大小(非常)大。
我们现在可以说动量在这个案例中有帮助吗?大概可以。它在我们使用的超参数下,产生了更好的模型表现。训练神经网络的随机性使得我们有可能调整两个模型的超参数,以消除我们在现有数据中看到的差异。两者之间的架构是固定的,但并没有规定学习率和小批量大小已针对任何一个模型进行了优化。
一位严谨的研究人员可能会感到有必要对超参数进行优化处理,并且一旦确信自己找到了适用于这两种方法的最佳模型,便会在重复实验后做出更明确的结论。幸运的是,我们并不是严谨的研究人员。相反,我们将利用现有的证据,以及全球机器学习研究人员数十年来关于动量在梯度下降法中作用的智慧,来声明:是的,动量确实有助于模型学习,在大多数情况下你应该使用它。
然而,正态性问题仍然需要进一步调查。毕竟,我们是在寻求提升自己在深度学习方面的数学和实践直觉。因此,接下来我们将对带动量的模型进行 FMNIST 训练,不是训练 22 次,而是训练 100 次。作为妥协,我们将把小批量的数量从 40,000 减少到 10,000。尽管如此,预计仍然需要大部分时间来等待程序完成。代码,虽然我们在这里不逐步讲解,位于fmnist_repeat.py中。
图 11-9 展示了结果的直方图。
很明显,这个分布看起来完全不像一个正态曲线。fmnist_repeat.py 的输出包括了 SciPy 的 normaltest 函数的结果。该函数对一组数据进行统计检验,零假设是数据服从正态分布。因此,p-值低于 0.05 或 0.01 表示数据不服从正态分布。我们的 p-值几乎为零。
如何解读 图 11-9?首先,由于结果显然不是正态分布,我们不能使用 t 检验。然而,我们也使用了非参数的 Mann-Whitney U 检验,并得到了高度统计显著的结果,因此我们上述的结论仍然有效。其次,图 11-9 中的长尾在左侧。我们甚至可以提出一个论点,认为结果可能是双峰的:有两个峰,一个接近 0.83,另一个较小,接近 MCC 为 0.75。

图 11-9:FMNIST 模型 100 次训练的 MCC 值分布
大多数模型训练后表现相对一致,MCC 值接近 0.83。然而,长尾表明,当模型的表现不理想时,它的表现非常糟糕。
直观来看,图 11-9 对我来说似乎是合理的。我们知道随机梯度下降对初始化不当敏感,而我们的工具包使用的是传统的小随机值初始化。看起来我们更有可能从一个较差的位置开始,之后就注定会得到较差的表现。
如果尾部出现在右侧呢?那可能意味着什么?右侧的长尾意味着大多数模型表现中等或较差,但偶尔会有一个特别“优秀”的模型出现。这样的情景意味着更好的模型是存在的,但我们的训练和/或初始化策略并不是特别擅长找到它们。我认为左侧的尾部更可取——大多数模型能找到合理的局部最小值,因此大多数训练,除非非常糟糕,最终会在表现上基本达到相同的水平。
现在,让我们来探讨一个常见的动量变种,你在深入学习中无疑会遇到它。
Nesterov 动量
许多深度学习工具包都包含了在梯度下降中使用 Nesterov 动量 的选项。Nesterov 动量是梯度下降的一种改进,广泛应用于优化领域。深度学习中通常实现的版本是从标准动量更新而来的:

到

这里我们使用梯度符号,而不是损失函数的偏导数,以表明该技术是通用的,适用于任何函数,f(x).
标准动量和深度学习 Nesterov 动量之间的差异很微妙,只是在梯度的参数中增加了一个项。其思想是利用现有的动量来计算梯度,而不是在当前位置x上,而是在如果继续使用当前动量,梯度下降将会达到的位置,即 x + μ v。然后,我们使用该位置的梯度值来更新当前位置,如之前所做的那样。
这一声明在优化中得到了很好的证明:这个调整导致了更快的收敛,这意味着梯度下降将在更少的步骤中找到最小值。然而,尽管工具包已经实现了它,但有理由相信,随机梯度下降和小批量引入的噪声抵消了这个调整,使得 Nesterov 动量在训练深度学习模型时可能不比常规动量更有用。(有关更多信息,请参见 Ian Goodfellow 等人著作《深度学习》第 292 页上的评论。)
然而,本章中的二维示例使用了实际的函数来计算梯度,因此我们可以预期在这种情况下 Nesterov 动量会有效。让我们更新二维示例,最小化两个反向高斯分布的和,看看 Nesterov 动量是否如所声称的那样改善了收敛。我们将运行的代码在 gd_nesterov.py 中,它与 gd_momentum.py 中的代码几乎相同。此外,我稍微修改了这两个文件,使它们在梯度下降完成后返回最终位置。这样,我们就可以看到我们接近已知最小值的程度。
实现 方程 11.6 很简单,只会影响速度更新,导致
vx = mu*vx - eta * dx(x,y)
vy = mu*vy - eta * dy(x,y)
变为
vx = mu * vx - eta * dx(x + mu * vx,y)
vy = mu * vy - eta * dy(x,y + mu * vy)
为每个分量 x 和 y 添加动量。其他一切保持不变。
图 11-10 比较了标准动量(上,来自 图 11-7)和 Nesterov 动量(下)。

图 11-10:标准动量(上)和 Nesterov 动量(下)
在视觉上,Nesterov 动量显示出较少的超调,特别是在从 (1.5, 1.5) 开始的螺旋路径上。那每种方法返回的最终位置如何呢?我们可以看到 表 11-2。
表 11-2:使用和不使用 Nesterov 动量的梯度下降最终位置
| 初始点 | 标准 | Nesterov | 最小值 |
|---|---|---|---|
| (1.5,1.5) | (–0.9496, 0.9809) | (–0.9718, 0.9813) | (–1,1) |
| (0.7,–0.2) | (0.8807, –0.9063) | (0.9128, –0.9181) | (1,–1) |
在相同的梯度下降步骤数后,Nesterov 动量的结果比标准动量的结果更接近已知最小值。
自适应梯度下降
梯度下降算法几乎是微不足道的,这也促使了它的适应性。在本节中,我们将深入探讨深度学习社区中流行的三种梯度下降变体:RMSprop、Adagrad 和 Adam。三者中,Adam 至今最为流行,但其他两者也值得了解,因为它们是逐步演变至 Adam 的基础。这三种算法都以某种方式动态调整学习率。
RMSprop
Geoffrey Hinton 在 2012 年 Coursera 讲座系列中介绍了 RMSprop,它代表了 均方根传播(root mean square propagation)。类似于动量(可以与其结合使用),RMSprop 是一种梯度下降法,它跟踪梯度值的变化,并利用这些值来修改步长。
RMSprop 使用 衰减项 γ(gamma)来计算算法执行过程中的梯度运行平均值。在他的讲座中,Hinton 使用 γ = 0.9。
梯度下降更新公式变为

首先,我们更新 m,即梯度平方的加权运行平均值,权重是 γ,即衰减项。接下来是速度项,它与普通梯度下降几乎相同,但我们将学习率除以运行平均值的平方根,这就是 RMSprop 中的 RMS 部分。然后我们从当前的位置中减去缩放后的速度,以便迈出步伐。我们将这一步写成加法,类似于上面的动量方程(方程 11.5 和 11.6);注意速度更新前的减号。
RMSprop 也可以与动量一起使用。例如,扩展 RMSprop 与 Nesterov 动量结合是很直接的:

其中 μ 是动量因子,和之前一样。
有人声称 RMSprop 是一种稳健的分类器。我们将在下面看到它在一个测试中的表现。我们将其视为一种自适应技术,因为学习率(η)是通过梯度的均值的平方根来缩放的;因此,有效学习率是根据下降过程的历史进行调整的——它不是一成不变的。
RMSprop 常用于强化学习,这是机器学习的一个分支,旨在学习如何采取行动。例如,玩雅达利电子游戏就使用了强化学习。当优化过程是 非平稳(nonstationary)时,RMSprop 被认为是稳健的,这意味着统计数据会随着时间发生变化。相反,平稳(stationary)过程是指统计数据不会随时间变化。使用监督学习训练分类器是平稳的,因为训练集通常是固定的,并且不会发生变化,就像输入给分类器的数据应该是固定的一样,尽管这更难强制执行。在强化学习中,时间是一个因素,数据集的统计特性可能会随着时间的推移而变化;因此,强化学习可能涉及非平稳的优化过程。
Adagrad 和 Adadelta
Adagrad 出现于 2011 年(参见 John Duchi 等人的“自适应子梯度方法用于在线学习与随机优化”,机器学习研究杂志 12[7], [2011])。乍一看,它与 RMSprop 非常相似,尽管存在重要的差异。
我们可以将 Adagrad 的基本更新规则写作:

这需要一些解释。
首先,注意速度更新中的i下标,既在速度v上,也在梯度▽f (x上。这里的i指的是速度的一个组件,意味着更新必须逐组件应用。方程 11.9 的顶部对系统的所有组件都重复。对于深度神经网络来说,这意味着所有的权重和偏置。
接下来,看看分母中每个组件的速度更新的和。这里,τ(tau)是优化过程中进行的所有梯度步骤的计数器,意味着对于系统的每个组件,Adagrad 跟踪在每个步骤中计算的梯度平方和。如果我们使用方程 11.9 进行第 11 步梯度下降,那么分母中的和将有 11 项,依此类推。如前所述,η是学习率,在这里是全局的,适用于所有组件。
Adagrad 的一个变种也在广泛使用中:Adadelta。(参见 Matthew Zeiler 的“Adadelta:一种自适应学习率方法”,[2012])。Adadelta 将速度更新中所有步长的平方和的平方根,替换为最近几步的滑动平均,类似于 RMSprop 的滑动平均。Adadelta 还将手动选择的全局学习率η,替换为前几次速度更新的滑动平均。这消除了选择合适的η,但引入了一个新参数γ,用于设置窗口大小,类似于 RMSprop 的做法。γ可能对数据集的特性不如η敏感。注意,在原始的 Adadelta 论文中,γ被写作ρ(rho)。
Adam
Kingma 和 Ba 于 2015 年发布了Adam,即“自适应矩估计”,截至目前已被引用超过 66,000 次。Adam 使用梯度的平方,就像 RMSprop 和 Adagrad 一样,但还跟踪类似动量的项。让我们先展示更新方程,然后逐步解析:

方程 11.10 的前两行定义了m和v作为一阶和二阶矩的滑动平均。一阶矩是均值;二阶矩类似于方差,是数据点与均值之间差异的二阶矩。注意在v的定义中有对梯度值的平方。滑动矩由两个标量参数β[1]和β[2]加权。
接下来的两行定义了
和
。这些是偏差修正项,用来使m和v成为第一和第二矩的更好估计值。这里的t是一个从零开始的整数,表示时间步长。
实际步骤通过减去偏差修正的第一矩,
,再乘以全局学习率的比率,η,以及偏差修正的第二矩的平方根,
,来更新x。∊项是一个常数,用于避免除以零。
方程 11.10 有四个参数,虽然看起来过多,但其中三个参数设置简单且很少更改。原始论文建议β[1] = 0.9,β[2] = 0.999,∊ = 10^(−8)。因此,就像普通的梯度下降一样,用户需要选择η。例如,Keras 默认的η = 0.001,这在许多情况下效果良好。
Kingma 和 Ba 的论文通过实验表明,Adam 通常优于带 Nesterov 动量的 SGD、RMSprop、Adagrad 和 Adadelta。这很可能是为什么 Adam 目前成为许多深度学习任务的首选优化器。
关于优化器的一些思考
使用哪个优化算法以及何时使用取决于数据集。如前所述,Adam 目前在许多任务中是首选,尽管经过适当调优的 SGD 也能非常有效,甚至有人对此深信不疑。虽然无法做出“最佳算法”的绝对判断,因为并不存在所谓的“最佳”,但我们可以进行一个小实验,并讨论结果。
这个实验,我将仅展示结果,使用 16,384 个随机样本对 MNIST 上的小型卷积神经网络进行训练,训练集大小为 128 的迷你批次,训练 12 个周期。结果展示了每个优化器(SGD、RMSprop、Adagrad 和 Adam)五次运行的均值和标准误差。感兴趣的点是测试集的准确率和训练时钟时间。我在同一台机器上训练了所有模型,因此我们应关注相对时间。没有使用 GPU。
图 11-11 展示了按优化器分类的总体测试集准确率(上)和训练时间(下)。
平均来说,SGD 和 RMSprop 的准确率比其他优化器低约 0.5%,而 RMSprop 的准确率波动较大,但始终未能超过 Adagrad 或 Adam。可以说,Adam 在准确率方面表现最好。至于训练时间,SGD 最快,而 Adam 最慢,正如我们所料,Adam 相比 SGD 需要执行更多的每步计算。总体而言,结果支持社区的直觉:Adam 是一个不错的优化器。

图 11-11:按优化器分类的 MNIST 模型准确率(上)和训练时间(下)
总结
本章介绍了梯度下降,从基本形式的原始梯度下降开始,配合 1D 和 2D 的例子。接着,我们介绍了随机梯度下降,并证明了它在深度学习中的应用。
接下来我们讨论了动量,包括标准动量和 Nesterov 动量。通过标准动量,我们展示了它在训练深度模型(好吧,相对“深”)中的帮助。我们用一个 2D 例子直观展示了 Nesterov 动量的效果,并讨论了为什么 Nesterov 动量和随机梯度下降可能会互相抵消。
本章最后,我们回顾了先进算法的梯度下降更新方程,展示了原始梯度下降如何引入修改。一个简单的实验让我们深入了解了这些算法的表现,并似乎证明了深度学习社区普遍认为 Adam 在一般情况下比 SGD 更合适的观点。
随着本章的结束,我们对深度学习数学的探索也画上了句号。剩下的只是最后一个附录,指引你去了解更多的资源。
结语
正如伟大的计算机科学家艾兹格尔·W·戴克斯特拉(Edsger W. Dijkstra)所说:“不应该有无聊的数学。”我真心希望你没有觉得这本书无聊。我可不想得罪戴克斯特拉的幽灵。如果你此刻还在继续阅读,我怀疑你确实找到了些有价值的内容。好极了!感谢你坚持下来。数学永远不应该无聊。
我们已经介绍了理解和使用深度学习所需的基础知识。然而,别停在这里:请使用附录中的参考资料,继续你的数学探索。你永远不应该对自己的知识基础感到满足——总是寻求扩展它。
如果你有任何问题或意见,请通过【mathfordeeplearning@gmail.com】(mailto:mathfordeeplearning@gmail.com)与我联系。
第十二章:深入学习

本书的目标是讨论深度学习背后的核心数学,也就是理解深度学习及其运作所需的数学。我们已经在前 11 章中实现了这一目标。
在本附录中,我的目标是引导你深入更多的内容。由于时间的限制,我们仅仅浅尝辄止,停留在潮池中,这些内容本身已经足够迷人,但在更深处,你将发现更多的美丽和优雅。接下来是一些指引,帮助你从我们讨论的主题中获得更多收获。
概率与统计
有成百上千本关于概率与统计的书籍。这里的列表显然是不完全的,也不是全面的,但它应当能帮助你扩展在这些领域的知识。
《概率与统计》 作者:迈克尔·埃文斯(Michael Evans)与杰弗里·罗斯坦(Jeffrey Rosenthal) 一本全面的教科书,可以在这里免费获取: www.utstat.toronto.edu/mikevans/jeffrosenthal/book.pdf。埃文斯和罗斯坦的这本书面向的读者群体与本书覆盖的背景正好吻合。
《有趣的贝叶斯统计学》 作者:Will Kurt 我们在第三章讨论了贝叶斯定理。这本书以一种易于理解的方式呈现贝叶斯统计学。贝叶斯统计学与机器学习密切相关,随着你学习的深入,你最终会接触到它。
《概率导论》 作者:Joseph Blitzstein 和 Jessica Hwang 另一部受欢迎的概率入门书籍,包含了蒙特卡罗建模。
《Python 在概率、统计与机器学习中的应用》 作者:José Unpingco 这本书提供了一种与本书中方法不同的视角,涵盖了略有不同的主题,但依然使用 Python 和 NumPy。机器学习部分讨论了我所称的“经典机器学习”,并稍微提到了深度学习。
《医学研究中的实用统计学》 作者:Douglas Altman 一本经典的教材,虽然它出自个人计算机普及之前的时代,但依然极具可读性和相关性。其重点是生物统计学,但基础知识是跨学科的,无论应用领域如何,基础都是基础。
线性代数
在我们讨论的所有主题中,我们对线性代数的讲解最为不充分。这里的参考书籍将帮助你更好地领略这一学科的优雅。
《线性代数导论》 作者:Gilbert Strang 一本受欢迎的入门书籍,详细覆盖了我在第五章和第六章中提到的许多主题。
《线性代数》 作者:David Cherney 等 与上述类似,但这本书可以免费获取: www.math.ucdavis.edu/~linear/linear-guest.pdf。
线性代数 由 Jim Hefferon 编写 这本书也可以免费获取,内容水平与其他书籍相同: joshua.smcvt.edu/linearalgebra/book.pdf。
微积分
我们在第七章和第八章中讨论了微积分,但仅限于微分部分。微积分当然不仅仅是微分。微积分的另一个主要部分是积分。我们忽略了积分,因为深度学习很少使用它。卷积在处理连续变量时是积分,但在数字世界中,大多数积分都变成了求和。这里列出的参考资料将填补我们简略处理中的空白。
基础微积分技能练习册 由 Chris McMullen 编写 这本受欢迎的教材/练习册涵盖了微分和初步积分内容,并提供了问题的解决方案。可以将这本书视为第七章的复习以及积分的入门教材。
微积分 由 James Stewart 编写 如果 McMullen 的书是温和的入门,那么这本书是该主题的全面处理。书中涵盖了微分和积分,包括多变量微积分,即偏导数和矢量微积分(见第八章),以及常微分方程。它还包括应用实例。
带有统计学和计量经济学应用的矩阵微积分 由 Jan Magnus 和 Heinz Neudecker 编写 被一些人认为是标准的矩阵微积分参考书,提供了该主题的深入和全面的处理。
矩阵手册 由 Kaare Brandt Petersen 和 Michael Syskind Pedersen 编写 一本受欢迎的矩阵微积分参考书,涵盖了我们在第八章中未涉及的内容。你可以在这里找到它: www2.imm.dtu.dk/pubdb/edoc/imm3274.pdf。
深度学习
深度学习正在迅速发展。虽然一些早期令人印象深刻的“哇,我从没想过这竟然可能”的成果变得越来越少见,但这个领域正在悄然成熟,并深入到几乎每一个科学和技术的领域。深度学习让我们的世界变得不同。
深度学习 由 Ian Goodfellow、Yoshua Bengio 和 Aaron Courville 编写 这是最早的深度学习专用教材之一,被广泛认为是最好的之一。它涵盖了所有必要的内容,但有时讲解较快。
矩阵代数方法与人工智能 由 Xian-Da Zhang 编写 一本新书,涵盖了矩阵和机器学习,包括深度学习。可以将其视为机器学习的更数学化的处理方式。
Coursera 上的深度学习专项课程 这不是一本书,而是一系列由顶级讲师授课的在线课程。你可以在这里找到课程: www.coursera.org/specializations/deep-learning/。
Geoffrey Hinton 的 Coursera 讲座 2012 年,Hinton 在 Coursera 上进行了一系列讲座,即使到现在,这些讲座仍然值得一听。RMSprop 就是在这系列讲座中讨论的。讲座内容非常易懂,并且不会过于偏重数学。你可以在这里找到这些讲座: www.cs.toronto.edu/~hinton/coursera_lectures.html。
深度学习:一种视觉方法 由 Andrew Glassner 编写 本书涵盖了深度学习的广泛内容,从监督学习到强化学习——所有内容都没有数学公式。可以将其作为一个快速的、高层次的领域入门书籍。
Reddit 要时刻掌握深度学习社区的脉动,可以关注 Reddit 上的讨论: www.reddit.com/r/MachineLearning/
Arxiv 可在 arxiv.org/ 查找,Arxiv 是一个预印本存储库。最新的深度学习论文会在这里出现。需要注意的是,由于该领域发展非常迅速,同行评审期刊中的发表并不是常态。相反,在 arxiv.org 上发布论文,特别是那些在会议上展示的论文,是了解最新研究的途径。Arxiv 被分为多个类别。我在这里提供的是我常关注的类别,当然也有其他类别:
-
计算机视觉与模式识别:
arxiv.org/list/cs.CV/recent/ -
神经与进化计算:
arxiv.org/list/cs.NE/recent/





和样本标准差(s[1],s[2])来帮助我们决定接受或拒绝 H[0]。


。
。








浙公网安备 33010602011771号