5.1. 多层感知机
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 SageMaker Studio Lab 中打开 Notebook

第 4.1 节中,我们介绍了softmax回归,我们从零开始实现了该算法(第 4.4 节),并使用了高级API(第 4.5 节)。这使我们能够训练能够识别低分辨率图像中10类服装的分类器。在此过程中,我们学习了如何处理数据,将我们的输出强制转换为有效的概率分布,应用适当的损失函数,并根据模型的参数来最小化它。现在我们已经掌握了简单线性模型背景下的这些机制,我们可以开始探索深度神经网络,这是本书主要关注的相对丰富的模型类别。

%matplotlib inline
import torch
from d2l import torch as d2l
%matplotlib inline
from mxnet import autograd, np, npx
from d2l import mxnet as d2l

npx.set_np()
%matplotlib inline
import jax
from jax import grad
from jax import numpy as jnp
from jax import vmap
from d2l import jax as d2l
No GPU/TPU found, falling back to CPU. (Set TF_CPP_MIN_LOG_LEVEL=0 and rerun for more info.)
%matplotlib inline
import tensorflow as tf
from d2l import tensorflow as d2l

5.1.1. 隐藏层

我们在 第 3.1.1.1 节 中将仿射变换描述为带有偏置的线性变换。首先,回顾一下我们softmax回归示例对应的模型架构,如 图 4.1.1 所示。该模型通过单个仿射变换将输入直接映射到输出,然后进行softmax操作。如果我们的标签确实通过简单的仿射变换与输入数据相关,那么这种方法就足够了。然而,(仿射变换中的)线性是一个假设。

5.1.1.1. 线性模型的局限性

例如,线性意味着单调性这一更弱的假设,即特征的任何增加必须要么总是导致模型输出的增加(如果相应的权重为正),要么总是导致模型输出的减少(如果相应的权重为负)。有时这很有道理。例如,如果我们试图预测一个人是否会偿还贷款,我们可能会合理地假设,在其他条件相同的情况下,收入较高的申请人总是比收入较低的申请人更有可能偿还。虽然是单调的,但这种关系可能与还款概率并非线性相关。收入从0美元增加到50,000美元可能对应着还款可能性的大幅增加,而从100万美元增加到105万美元的增加则可能对应着较小的增加。处理这个问题的一种方法可能是对我们的结果进行后处理,使线性变得更合理,例如使用逻辑斯蒂映射(从而使用结果概率的对数)。

请注意,我们很容易就能想出违反单调性的例子。比如,我们想根据体温预测健康状况。对于正常体温高于37°C(98.6°F)的人来说,体温越高,风险越大。然而,如果体温低于37°C,那么体温越低,风险越大!同样,我们或许可以通过一些巧妙的预处理来解决这个问题,比如使用与37°C的距离作为特征。

但是,如何对猫和狗的图像进行分类呢?增加位置(13, 17)处像素的强度是否应该总是增加(或总是减少)图像描绘的是一只狗的可能性?依赖线性模型相当于隐含地假设,区分猫和狗的唯一要求是评估单个像素的亮度。在一个反转图像保留类别不变的世界里,这种方法注定会失败。

然而,尽管与前面的例子相比,这里的线性性显得荒谬,但我们是否能通过简单的预处理修复来解决这个问题,却不那么明显。这是因为任何像素的重要性都以复杂的方式取决于其上下文(周围像素的值)。虽然可能存在一种能够考虑我们特征之间相关交互的数据表示,并且在线性模型之上是合适的,但我们根本不知道如何手动计算它。对于深度神经网络,我们使用观测数据来联合学习一个通过隐藏层的表示和一个作用于该表示的线性预测器。

这个非线性问题至少已经研究了一个世纪 (Fisher, 1925)。例如,决策树在其最基本的形式中使用一系列二元决策来决定类别成员资格 (Quinlan, 1993)。同样,核方法几十年来一直被用于建模非线性依赖关系 (Aronszajn, 1950)。这已应用于非参数样条模型 (Wahba, 1990) 和核方法 (Schölkopf and Smola, 2002)。这也是大脑很自然地解决的问题。毕竟,神经元输入到其他神经元,而这些神经元又再次输入到其他神经元 (Ramón y Cajal and Azoulay, 1894)。因此,我们有一系列相对简单的变换。

5.1.1.2. 引入隐藏层

我们可以通过加入一个或多个隐藏层来克服线性模型的局限性。最简单的方法是将许多全连接层堆叠在一起。每一层都输入到它上面的层,直到我们生成输出。我们可以将前 \(L-1\) 层视为我们的表示,最后一层视为我们的线性预测器。这种架构通常被称为多层感知机,常缩写为MLP图 5.1.1)。

../_images/mlp.svg

图 5.1.1 一个带有一个包含五个隐藏单元的隐藏层的MLP。

这个MLP有四个输入,三个输出,其隐藏层包含五个隐藏单元。由于输入层不涉及任何计算,用这个网络产生输出需要实现隐藏层和输出层的计算;因此,这个MLP的层数是两层。注意,这两层都是全连接的。每个输入都影响隐藏层中的每个神经元,而这些神经元中的每一个又影响输出层中的每个神经元。唉,我们还没完全讲完。

5.1.1.3. 从线性到非线性

和之前一样,我们用矩阵 \(\mathbf{X} \in \mathbb{R}^{n \times d}\) 表示一个包含 \(n\) 个样本的小批量,其中每个样本有 \(d\) 个输入(特征)。对于一个单隐藏层MLP,其隐藏层有 \(h\) 个隐藏单元,我们用 \(\mathbf{H} \in \mathbb{R}^{n \times h}\) 表示隐藏层的输出,这些输出是隐藏表示。由于隐藏层和输出层都是全连接的,我们有隐藏层权重 \(\mathbf{W}^{(1)} \in \mathbb{R}^{d \times h}\) 和偏置 \(\mathbf{b}^{(1)} \in \mathbb{R}^{1 \times h}\),以及输出层权重 \(\mathbf{W}^{(2)} \in \mathbb{R}^{h \times q}\) 和偏置 \(\mathbf{b}^{(2)} \in \mathbb{R}^{1 \times q}\)。这使我们能够按如下方式计算单隐藏层MLP的输出 \(\mathbf{O} \in \mathbb{R}^{n \times q}\)

(5.1.1)\[\begin{split}\begin{aligned} \mathbf{H} & = \mathbf{X} \mathbf{W}^{(1)} + \mathbf{b}^{(1)}, \\ \mathbf{O} & = \mathbf{H}\mathbf{W}^{(2)} + \mathbf{b}^{(2)}. \end{aligned}\end{split}\]

注意,在添加了隐藏层之后,我们的模型现在需要我们跟踪和更新额外的参数集。那么我们换来了什么呢?你可能会惊讶地发现——在上面定义的模型中——我们什么也没得到!原因很简单。上面的隐藏单元是由输入的仿射函数给出的,而输出(softmax之前)只是隐藏单元的仿射函数。一个仿射函数的仿射函数本身就是一个仿射函数。此外,我们的线性模型已经能够表示任何仿射函数。

为了形式化地看到这一点,我们可以将上述定义中的隐藏层合并,得到一个等效的单层模型,其参数为 \(\mathbf{W} = \mathbf{W}^{(1)}\mathbf{W}^{(2)}\)\(\mathbf{b} = \mathbf{b}^{(1)} \mathbf{W}^{(2)} + \mathbf{b}^{(2)}\)

(5.1.2)\[\mathbf{O} = (\mathbf{X} \mathbf{W}^{(1)} + \mathbf{b}^{(1)})\mathbf{W}^{(2)} + \mathbf{b}^{(2)} = \mathbf{X} \mathbf{W}^{(1)}\mathbf{W}^{(2)} + \mathbf{b}^{(1)} \mathbf{W}^{(2)} + \mathbf{b}^{(2)} = \mathbf{X} \mathbf{W} + \mathbf{b}.\]

为了发挥多层架构的潜力,我们还需要一个关键要素:一个非线性激活函数\(\sigma\),应用于仿射变换后的每个隐藏单元。例如,一个流行的选择是 ReLU(修正线性单元)激活函数 (Nair and Hinton, 2010) \(\sigma(x) = \mathrm{max}(0, x)\),它对其参数按元素操作。激活函数 \(\sigma(\cdot)\) 的输出被称为激活值。一般来说,有了激活函数,就不再可能将我们的MLP坍缩成一个线性模型

(5.1.3)\[\begin{split}\begin{aligned} \mathbf{H} & = \sigma(\mathbf{X} \mathbf{W}^{(1)} + \mathbf{b}^{(1)}), \\ \mathbf{O} & = \mathbf{H}\mathbf{W}^{(2)} + \mathbf{b}^{(2)}.\\ \end{aligned}\end{split}\]

由于 \(\mathbf{X}\) 中的每一行对应小批量中的一个样本,我们可以滥用一下符号,定义非线性函数 \(\sigma\) 按行方式应用于其输入,即一次一个样本。请注意,当我们在 第 4.1.1.3 节 中表示逐行操作时,我们对softmax使用了相同的符号。我们使用的激活函数通常不仅是逐行应用,而且是逐元素应用的。这意味着在计算层的线性部分之后,我们可以计算每个激活值,而无需查看其他隐藏单元所取的值。

为了构建更通用的MLP,我们可以继续堆叠这样的隐藏层,例如,\(\mathbf{H}^{(1)} = \sigma_1(\mathbf{X} \mathbf{W}^{(1)} + \mathbf{b}^{(1)})\)\(\mathbf{H}^{(2)} = \sigma_2(\mathbf{H}^{(1)} \mathbf{W}^{(2)} + \mathbf{b}^{(2)})\),一层叠一层,从而产生更具表达力的模型。

5.1.1.4. 通用逼近器

我们知道大脑能够进行非常复杂的统计分析。因此,值得一问,一个深度网络到底能有多强大。这个问题已经被多次回答,例如,在 Cybenko (1989) 的MLP背景下,以及在 Micchelli (1984) 的再生核希尔伯特空间背景下,这可以被看作是带有一个隐藏层的径向基函数(RBF)网络。这些(以及相关的)结果表明,即使只有一个隐藏层的网络,只要有足够多的节点(可能多得离谱),以及合适的权重集,我们就可以对任何函数进行建模。不过,真正学习这个函数是困难的部分。你可以把你的神经网络想象成有点像C语言。这种语言,像任何其他现代语言一样,能够表达任何可计算的程序。但真正想出一个满足你规范的程序才是困难的部分。

此外,仅仅因为一个单隐藏层网络可以学习任何函数,并不意味着你应该尝试用它来解决你所有的问题。事实上,在这种情况下,核方法要有效得多,因为它们能够精确地解决问题,即使在无限维空间中也是如此 (Kimeldorf and Wahba, 1971, Schölkopf et al., 2001)。事实上,我们可以通过使用更深(而不是更宽)的网络来更紧凑地逼近许多函数 (Simonyan and Zisserman, 2014)。我们将在后续章节中触及更严谨的论证。

5.1.2. 激活函数

激活函数通过计算加权和并进一步加上偏置来决定神经元是否应该被激活。它们是将输入信号转换为输出的可微算子,同时它们中的大多数都增加了非线性。因为激活函数是深度学习的基础,让我们简要地回顾一些常见的激活函数。

5.1.2.1. ReLU 函数

最受欢迎的选择是修正线性单元ReLU(Nair and Hinton, 2010),这既因为它的实现简单,也因为它在各种预测任务上的良好性能。ReLU 提供了一个非常简单的非线性变换。给定一个元素 \(x\),该函数定义为该元素与 \(0\) 的最大值

(5.1.4)\[\operatorname{ReLU}(x) = \max(x, 0).\]

通俗地说,ReLU函数只保留正元素,并通过将相应的激活设置为0来丢弃所有负元素。为了获得一些直观的理解,我们可以绘制该函数。如你所见,激活函数是分段线性的。

x = torch.arange(-8.0, 8.0, 0.1, requires_grad=True)
y = torch.relu(x)
d2l.plot(x.detach(), y.detach(), 'x', 'relu(x)', figsize=(5, 2.5))
../_images/output_mlp_76f463_18_0.svg
x = np.arange(-8.0, 8.0, 0.1)
x.attach_grad()
with autograd.record():
    y = npx.relu(x)
d2l.plot(x, y, 'x', 'relu(x)', figsize=(5, 2.5))
[21:54:14] ../src/storage/storage.cc:196: Using Pooled (Naive) StorageManager for CPU
../_images/output_mlp_76f463_21_1.svg
x = jnp.arange(-8.0, 8.0, 0.1)
y = jax.nn.relu(x)
d2l.plot(x, y, 'x', 'relu(x)', figsize=(5, 2.5))
../_images/output_mlp_76f463_24_0.svg
x = tf.Variable(tf.range(-8.0, 8.0, 0.1), dtype=tf.float32)
y = tf.nn.relu(x)
d2l.plot(x.numpy(), y.numpy(), 'x', 'relu(x)', figsize=(5, 2.5))
../_images/output_mlp_76f463_27_0.svg

当输入为负时,ReLU函数的导数为0;当输入为正时,ReLU函数的导数为1。请注意,当输入值恰好等于0时,ReLU函数是不可微的。在这些情况下,我们默认使用左侧导数,并称当输入为0时导数为0。我们可以这样做,因为输入可能永远不会真正为零(数学家会说它在测度为零的集合上不可微)。有句老话说,如果细微的边界条件很重要,我们可能正在做(真正的)数学,而不是工程。这个传统智慧可能在这里适用,或者至少,我们没有在执行约束优化 (Mangasarian, 1965, Rockafellar, 1970)。下面我们绘制了ReLU函数的导数。

y.backward(torch.ones_like(x), retain_graph=True)
d2l.plot(x.detach(), x.grad, 'x', 'grad of relu', figsize=(5, 2.5))
../_images/output_mlp_76f463_33_0.svg
y.backward()
d2l.plot(x, x.grad, 'x', 'grad of relu', figsize=(5, 2.5))
[21:54:14] ../src/base.cc:48: GPU context requested, but no GPUs found.
../_images/output_mlp_76f463_36_1.svg
grad_relu = vmap(grad(jax.nn.relu))
d2l.plot(x, grad_relu(x), 'x', 'grad of relu', figsize=(5, 2.5))
../_images/output_mlp_76f463_39_0.svg
with tf.GradientTape() as t:
    y = tf.nn.relu(x)
d2l.plot(x.numpy(), t.gradient(y, x).numpy(), 'x', 'grad of relu',
         figsize=(5, 2.5))
../_images/output_mlp_76f463_42_0.svg

使用ReLU的原因是它的导数表现得特别好:要么消失,要么直接让参数通过。这使得优化过程表现得更好,并且缓解了困扰早期神经网络的梯度消失问题(稍后会详细介绍)。

请注意,ReLU函数有许多变体,包括参数化ReLUpReLU)函数 (He et al., 2015)。这种变体为ReLU添加了一个线性项,所以即使参数为负,一些信息仍然可以通过

(5.1.5)\[\operatorname{pReLU}(x) = \max(0, x) + \alpha \min(0, x).\]

5.1.2.2. Sigmoid 函数

sigmoid 函数将值域在 \(\mathbb{R}\) 内的输入转换为在区间 (0, 1) 内的输出。因此,sigmoid 通常被称为挤压函数:它将范围在(-inf, inf)的任何输入挤压到范围在(0, 1)的某个值

(5.1.6)\[\operatorname{sigmoid}(x) = \frac{1}{1 + \exp(-x)}.\]

在最早的神经网络中,科学家们对模拟生物神经元感兴趣,这些神经元要么激活要么不激活。因此,这个领域的先驱们,一直追溯到人工神经元的发明者McCulloch和Pitts,都专注于阈值单元 (McCulloch and Pitts, 1943)。阈值激活函数当其输入低于某个阈值时取值为0,当输入超过阈值时取值为1。

当注意力转向基于梯度的学习时,sigmoid函数是一个自然的选择,因为它是对阈值单元的光滑、可微的近似。当我们想将输出解释为二元分类问题的概率时,Sigmoid仍然被广泛用作输出单元的激活函数:你可以将sigmoid视为softmax的一个特例。然而,对于隐藏层中的大多数用途,sigmoid在很大程度上已经被更简单且更容易训练的ReLU所取代。这很大程度上是因为sigmoid对优化提出了挑战 (LeCun et al., 1998),因为它的梯度对于大的正负参数都会消失。这可能导致难以摆脱的平台期。尽管如此,sigmoid仍然很重要。在后续章节(例如,第 10.1 节)关于循环神经网络中,我们将描述利用sigmoid单元来控制信息在时间上传递的架构。

下面,我们绘制了sigmoid函数。请注意,当输入接近0时,sigmoid函数接近一个线性变换。

y = torch.sigmoid(x)
d2l.plot(x.detach(), y.detach(), 'x', 'sigmoid(x)', figsize=(5, 2.5))
../_images/output_mlp_76f463_48_0.svg
with autograd.record():
    y = npx.sigmoid(x)
d2l.plot(x, y, 'x', 'sigmoid(x)', figsize=(5, 2.5))
../_images/output_mlp_76f463_51_0.svg
y = jax.nn.sigmoid(x)
d2l.plot(x, y, 'x', 'sigmoid(x)', figsize=(5, 2.5))
../_images/output_mlp_76f463_54_0.svg
y = tf.nn.sigmoid(x)
d2l.plot(x.numpy(), y.numpy(), 'x', 'sigmoid(x)', figsize=(5, 2.5))
../_images/output_mlp_76f463_57_0.svg

sigmoid函数的导数由以下方程给出

(5.1.7)\[\frac{d}{dx} \operatorname{sigmoid}(x) = \frac{\exp(-x)}{(1 + \exp(-x))^2} = \operatorname{sigmoid}(x)\left(1-\operatorname{sigmoid}(x)\right).\]

下面绘制了sigmoid函数的导数。请注意,当输入为0时,sigmoid函数的导数达到最大值0.25。当输入在任一方向上偏离0时,导数接近0。

# Clear out previous gradients
x.grad.data.zero_()
y.backward(torch.ones_like(x),retain_graph=True)
d2l.plot(x.detach(), x.grad, 'x', 'grad of sigmoid', figsize=(5, 2.5))
../_images/output_mlp_76f463_63_0.svg
y.backward()
d2l.plot(x, x.grad, 'x', 'grad of sigmoid', figsize=(5, 2.5))
../_images/output_mlp_76f463_66_0.svg
grad_sigmoid = vmap(grad(jax.nn.sigmoid))
d2l.plot(x, grad_sigmoid(x), 'x', 'grad of sigmoid', figsize=(5, 2.5))
../_images/output_mlp_76f463_69_0.svg
with tf.GradientTape() as t:
    y = tf.nn.sigmoid(x)
d2l.plot(x.numpy(), t.gradient(y, x).numpy(), 'x', 'grad of sigmoid',
         figsize=(5, 2.5))
../_images/output_mlp_76f463_72_0.svg

5.1.2.3. Tanh 函数

与sigmoid函数类似,tanh(双曲正切)函数也将其输入压缩,将它们转换为在 \(-1\)\(1\) 之间的区间上的元素

(5.1.8)\[\operatorname{tanh}(x) = \frac{1 - \exp(-2x)}{1 + \exp(-2x)}.\]

我们在下面绘制了tanh函数。注意,当输入接近0时,tanh函数接近一个线性变换。虽然该函数的形状与sigmoid函数相似,但tanh函数关于坐标系原点表现出点对称性 (Kalman and Kwasny, 1992)

y = torch.tanh(x)
d2l.plot(x.detach(), y.detach(), 'x', 'tanh(x)', figsize=(5, 2.5))
../_images/output_mlp_76f463_78_0.svg
with autograd.record():
    y = np.tanh(x)
d2l.plot(x, y, 'x', 'tanh(x)', figsize=(5, 2.5))
../_images/output_mlp_76f463_81_0.svg
y = jax.nn.tanh(x)
d2l.plot(x, y, 'x', 'tanh(x)', figsize=(5, 2.5))
../_images/output_mlp_76f463_84_0.svg
y = tf.nn.tanh(x)
d2l.plot(x.numpy(), y.numpy(), 'x', 'tanh(x)', figsize=(5, 2.5))
../_images/output_mlp_76f463_87_0.svg

tanh函数的导数是

(5.1.9)\[\frac{d}{dx} \operatorname{tanh}(x) = 1 - \operatorname{tanh}^2(x).\]

它被绘制在下面。当输入接近0时,tanh函数的导数接近最大值1。正如我们对sigmoid函数所看到的那样,当输入在任一方向上远离0时,tanh函数的导数接近0。

# Clear out previous gradients
x.grad.data.zero_()
y.backward(torch.ones_like(x),retain_graph=True)
d2l.plot(x.detach(), x.grad, 'x', 'grad of tanh', figsize=(5, 2.5))
../_images/output_mlp_76f463_93_0.svg
y.backward()
d2l.plot(x, x.grad, 'x', 'grad of tanh', figsize=(5, 2.5))
../_images/output_mlp_76f463_96_0.svg
grad_tanh = vmap(grad(jax.nn.tanh))
d2l.plot(x, grad_tanh(x), 'x', 'grad of tanh', figsize=(5, 2.5))
../_images/output_mlp_76f463_99_0.svg
with tf.GradientTape() as t:
    y = tf.nn.tanh(x)
d2l.plot(x.numpy(), t.gradient(y, x).numpy(), 'x', 'grad of tanh',
         figsize=(5, 2.5))
../_images/output_mlp_76f463_102_0.svg

5.1.3. 总结与讨论

我们现在知道如何结合非线性来构建富有表现力的多层神经网络架构。顺便说一句,你的知识已经让你掌握了与1990年左右的从业者类似的工具集。在某些方面,你比那时工作的任何人都更有优势,因为你可以利用强大的开源深度学习框架,仅用几行代码就能快速构建模型。以前,训练这些网络需要研究人员用C、Fortran甚至Lisp(在LeNet的情况下)显式地编写层和导数。

第二个好处是,ReLU比sigmoid或tanh函数更易于优化。可以说,这是过去十年帮助深度学习复兴的关键创新之一。不过,请注意,激活函数的研究并未停止。例如,Hendrycks and Gimpel (2016) 提出的GELU(高斯误差线性单元)激活函数 \(x \Phi(x)\)(其中 \(\Phi(x)\) 是标准高斯累积分布函数)和 Ramachandran et al. (2017) 提出的Swish激活函数 \(\sigma(x) = x \operatorname{sigmoid}(\beta x)\) 在许多情况下可以产生更好的准确性。

5.1.4. 练习

  1. 证明向一个线性深度网络(即没有非线性\(\sigma\)的网络)添加层永远不会增加网络的表达能力。举一个它实际上会降低表达能力的例子。

  2. 计算pReLU激活函数的导数。

  3. 计算Swish激活函数 \(x \operatorname{sigmoid}(\beta x)\) 的导数。

  4. 证明一个仅使用ReLU(或pReLU)的MLP构造了一个连续分段线性函数。

  5. Sigmoid和tanh非常相似。

    1. 证明 \(\operatorname{tanh}(x) + 1 = 2 \operatorname{sigmoid}(2x)\)

    2. 证明由两种非线性参数化的函数类是相同的。提示:仿射层也有偏置项。

  6. 假设我们有一个一次只应用于一个小批量的非线性,比如批量归一化 (Ioffe and Szegedy, 2015)。你预期这会引起什么样的问题?

  7. 给出一个sigmoid激活函数的梯度消失的例子。