Run this notebook online:Binder or Colab: Colab

4.1. 多层感知机

Section 3 中,我们介绍了softmax回归( Section 3.4 ),然后我们从零开始实现softmax回归( Section 3.6 ),接着使用DJL 实现了算法( Section 3.7 ),并训练分类器从低分辨率图像中识别10类服装。在这个过程中,我们学习了如何处理数据,将输出转换为有效的概率分布,并应用适当的损失函数,根据模型参数最小化损失。我们已经在简单的线性模型背景下掌握了这些知识,现在我们可以开始对深度神经网络的探索,这也是本书主要涉及的一类模型。

4.1.1. 隐藏层

我们在 Section 3.1.1.1中描述了仿射变换,它是一个带有偏置项的线性变换。首先,回想一下如 fig_softmaxreg中所示的softmax回归的模型结构。该模型通过单个仿射变换将我们的输入直接映射到输出,然后进行softmax操作。如果我们的标签通过仿射变换后确实与我们的输入数据相关,那么这种方法就足够了。但是,仿射变换中的线性是一个很强的假设。

4.1.1.1. 线性模型可能会出错

例如,线性意味着单调假设:特征的任何增大都会导致模型输出增大(如果对应的权重为正),或者导致模型输出减少(如果对应的权重为负)。有时这是有道理的。例如,如果我们试图预测一个人是否会偿还贷款。我们可以认为,在其他条件不变的情况下,收入较高的申请人总是比收入较低的申请人更有可能偿还贷款。但是,虽然收入与还款概率存在单调性,但它们不是线性相关的。收入从0增加到5万,可能比从100万增加到105万带来更大的还款可能性。处理这一问题的一种方法是对我们的数据进行预处理,使线性变得更合理,如使用收入的对数作为我们的特征。

我们可以很容易地找出违反单调性的例子。例如,我们想要根据体温预测死亡率。对于体温高于37摄氏度的人来说,温度越高风险越大。然而,对于体温低于37摄氏度的人来说,温度越高风险就越低。在这种情况下,我们也可以通过一些巧妙的预处理来解决问题。例如,我们可以使用与37摄氏度的距离作为特征。

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

与我们前面的例子相比,这里的线性很荒谬,而且我们难以通过简单的预处理来解决这个问题。这是因为任何像素的重要性都以复杂的方式取决于该像素的上下文(周围像素的值)。我们的数据可能会有一种表示,这种表示会考虑到我们的特征之间的相关交互作用。在此表示的基础上建立一个线性模型可能会是合适的,但我们不知道如何手动计算这么一种表示。对于深度神经网络,我们使用观测数据来联合学习隐藏层表示和应用于该表示的线性预测器。

4.1.1.2. 合并隐藏层

我们可以通过合并一个或多个隐藏层来克服线性模型的限制,并处理更一般化的函数。要做到这一点,最简单的方法是将许多全连接层堆叠在一起。每一层都输出到上面的层,直到生成最后的输出。我们可以把前\(L-1\)层看作表示,把最后一层看作线性预测器。这种架构通常称为多层感知机(multilayer perceptron),通常缩写为MLP。下面,我们以图的方式描述了多层感知机( fig_mlp)。

一个单隐藏层的多层感知机,具有5个隐藏单元 .. _fig_mlp:

这个多层感知机有4个输入,3个输出,其隐藏层包含5个隐藏单元。输入层不涉及任何计算,因此使用此网络产生输出只需要实现隐藏层和输出层的计算;因此,这个多层感知机中的层数为2。注意,这两个层都是全连接的。每个输入都会影响隐藏层中的每个神经元,而隐藏层中的每个神经元又会影响输出层中的每个神经元。

然而,正如 Section 3.4.3 所说,具有全连接层的多层感知机的参数开销可能会高得令人望而却步, 即使在不改变输入或输出大小的情况下,也可能促使在参数节约和模型有效性之间进行权衡 [Zhang.Tay.Zhang.ea.2021]

4.1.1.3. 从线性到非线性

跟之前的章节一样,我们通过矩阵\(\mathbf{X} \in \mathbb{R}^{n \times d}\)来表示\(n\)个样本的小批量,其中每个样本具有\(d\)个输入(特征)。对于具有\(h\)个隐藏单元的单隐藏层多层感知机,用\(\mathbf{H} \in \mathbb{R}^{n \times h}\)表示隐藏层的输出,称为隐藏表示(hidden representations)。在数学或代码中,\(\mathbf{H}\)也被称为隐藏层变量(hidden-layer variable)或隐藏变量(hidden variable)。 因为隐藏层和输出层都是全连接的,所以我们具有隐藏层权重\(\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}\)。形式上,我们按如下方式计算单隐藏层多层感知机的输出\(\mathbf{O} \in \mathbb{R}^{n \times q}\)

(4.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)}\)的等价单层模型:

(4.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}.\]

为了发挥多层结构的潜力,我们还需要一个额外的关键要素:在仿射变换之后对每个隐藏单元应用非线性的激活函数(activation function)\(\sigma\)。激活函数的输出(例如,\(\sigma(\cdot)\))被称为激活值(activations)。一般来说,有了激活函数,就不可能再将我们的多层感知机退化成线性模型:

(4.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\)也以按行的方式作用于其输入,即一次计算一个样本。我们在 Section 3.4.5 中以相同的方式使用了softmax符号来表示按行操作。但是在本节中,我们应用于隐藏层的激活函数通常不仅仅是按行的,而且也是按元素。 这意味着在计算每一层的线性部分之后,我们可以计算每个激活值,而不需要查看其他隐藏单元所取的值。对于大多数激活函数都是这样。

为了构建更通用的多层感知机,我们可以继续堆叠这样的隐藏层,例如,\(\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)})\),一层叠一层,从而产生更有表达能力的模型。

4.1.1.4. 通用近似定理

多层感知机可以通过隐藏神经元捕捉到我们输入之间复杂的相互作用,这些神经元依赖于每个输入的值。我们可以很容易地设计隐藏节点来执行任意计算。例如,在一对输入上进行基本逻辑操作。多层感知机是通用近似器。即使是网络只有一个隐藏层,给定足够的神经元(可能非常多)和正确的权重,我们可以对任意函数建模,尽管实际中学习该函数是很困难的。你可能认为神经网络有点像C语言。C语言和任何其他现代编程语言一样,能够表达任何可计算的程序。但实际上,想出一个符合规范的程序才是最困难的部分。

而且,虽然一个单隐层网络能学习任何函数,但并不意味着应该尝试使用单隐藏层网络来解决所有问题。事实上,通过使用更深(而不是更广)的网络,我们可以更容易地逼近许多函数。我们将在后面的章节中进行更细致的讨论。

4.1.2. 激活函数

:label:subsec:activation-functions

激活函数通过计算加权和并加上偏置来确定神经元是否应该被激活。它们是将输入信号转换为输出的可微运算。大多数激活函数都是非线性的。由于激活函数是深度学习的基础,下面(简要介绍一些常见的激活函数)。

4.1.2.1. ReLU函数

最受欢迎的选择是线性整流单元(Rectified linear unit,ReLU),因为它实现简单,同时在各种预测任务中表现良好。 [ReLU提供了一种非常简单的非线性变换]。 给定元素\(x\),ReLU函数被定义为该元素与\(0\)的最大值:

(**

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

**

通俗地说,ReLU函数通过将相应的激活值设为0来仅保留正元素并丢弃所有负元素。为了直观感受一下,我们可以画出函数的曲线图。正如从图中所看到,激活函数是分段线性的。

%load ../utils/djl-imports
%load ../utils/plot-utils
NDManager manager = NDManager.newBaseManager();
NDArray x = manager.arange(-8.0f, 8.0f, 0.1f);
x.setRequiresGradient(true);
NDArray y = Activation.relu(x);

// Converting the data into float arrays to render them in a plot.
float[] X = x.toFloatArray();
float[] Y = y.toFloatArray();

Table data = Table.create("Data").addColumns(
    FloatColumn.create("X", X),
    FloatColumn.create("relu(x)", Y)
);
render(LinePlot.create("", data, "x", "relu(X)"), "text/html");

当输入为负时,ReLU函数的导数为0,而当输入为正时,ReLU函数的导数为1。注意,当输入值精确等于0时,ReLU函数不可导。在此时,我们默认使用左侧的导数,即当输入为0时导数为0。我们可以忽略这种情况,因为输入可能永远都不会是0。这里用上一句古老的谚语,“如果微妙的边界条件很重要,我们很可能是在研究数学而非工程”,这个观点正好适用于这里。下面我们绘制ReLU函数的导数。

try(GradientCollector collector = manager.getEngine().newGradientCollector()){
    y = Activation.relu(x);
    collector.backward(y);
}

NDArray res = x.getGradient();
float[] X = x.toFloatArray();
float[] Y = res.toFloatArray();

Table data = Table.create("Data").addColumns(
    FloatColumn.create("X", X),
    FloatColumn.create("grad of relu", Y)
);
render(LinePlot.create("", data, "x", "grad of relu"), "text/html");

使用ReLU的原因是,它求导表现得特别好:要么让参数消失,要么让参数通过。这使得优化表现得更好,并且ReLU减轻了困扰以往神经网络的梯度消失问题(稍后将详细介绍)。

注意,ReLU函数有许多变体,包括参数化 ReLU(Parameterized ReLU,pReLU)函数 [He et al., 2015]。该变体为ReLU添加了一个线性项,因此即使参数是负的,某些信息仍然可以通过:

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

4.1.2.2. sigmoid函数

[对于一个定义域在:math:`mathbb{R}`中的输入,*sigmoid函数*将输入变换为区间(0, 1)上的输出]。因此,sigmoid通常称为挤压函数(squashing function):它将范围(-inf, inf)中的任意输入压缩到区间(0, 1)中的某个值:

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

在最早的神经网络中,科学家们感兴趣的是对“激发”或“不激发”的生物神经元进行建模。因此,这一领域的先驱,如人工神经元的发明者麦卡洛克和皮茨,从他们开始就专注于阈值单元。阈值单元在其输入低于某个阈值时取值0,当输入超过阈值时取值1。

当人们的注意力逐渐转移到基于梯度的学习时,sigmoid函数是一个自然的选择,因为它是一个平滑的、可微的阈值单元近似。当我们想要将输出视作二分类问题的概率时,sigmoid仍然被广泛用作输出单元上的激活函数(你可以将sigmoid视为softmax的特例)。然而,sigmoid在隐藏层中已经较少使用,它在大部分时候已经被更简单、更容易训练的ReLU所取代。在后面关于循环神经网络的章节中,我们将描述利用sigmoid单元来控制时序信息流动的结构。

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

NDArray y = Activation.sigmoid(x);
float[] Y = y.toFloatArray();

Table data = Table.create("Data").addColumns(
    FloatColumn.create("X", X),
    FloatColumn.create("sigmoid(x)", Y)
);
render(LinePlot.create("", data, "x", "sigmoid(X)"));

sigmoid函数的导数为下面的公式:

(4.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。

try(GradientCollector collector = manager.getEngine().newGradientCollector()){
    y = Activation.sigmoid(x);
    collector.backward(y);
}

NDArray res = x.getGradient();
float[] X = x.toFloatArray();
float[] Y = res.toFloatArray();

Table data = Table.create("Data").addColumns(
    FloatColumn.create("X", X),
    FloatColumn.create("grad of sigmoid", Y)
);
render(LinePlot.create("", data, "x", "grad of sigmoid"), "text/html");

4.1.2.3. tanh函数

与sigmoid函数类似,tanh(双曲正切)函数也能将其输入压缩转换到区间(-1, 1)上。tanh函数的公式如下:

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

下面我们绘制tanh函数。注意,当输入在0附近时,tanh函数接近线性变换。函数的形状类似于sigmoid函数,不同的是tanh函数关于坐标系原点中心对称。

NDArray y = Activation.tanh(x);
float[] Y = y.toFloatArray();

Table data = Table.create("Data").addColumns(
    FloatColumn.create("X", X),
    FloatColumn.create("tanh(x)", Y)
);

render(LinePlot.create("", data, "x", "tanh(X)"));

tanh函数的导数是:

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

tanh函数的导数图像如下所示。当输入接近0时,tanh函数的导数接近最大值1。与我们在sigmoid函数图像中看到的类似,输入在任一方向上越远离0点,导数越接近0。

try(GradientCollector collector = manager.getEngine().newGradientCollector()){
    y = Activation.tanh(x);
    collector.backward(y);
}

NDArray res = x.getGradient();
float[] X = x.toFloatArray();
float[] Y = res.toFloatArray();

Table data = Table.create("Data").addColumns(
    FloatColumn.create("X", X),
    FloatColumn.create("grad of tanh", Y)
);
render(LinePlot.create("", data, "x", "grad of tanh"), "text/html");

总结一下,我们现在知道如何结合非线性函数来构建具有更强表达能力的多层神经网络结构。顺便说一句,你的知识已经让你掌握了一个类似于1990年左右深度学习从业者的工具。在某些方面,你比在20世纪90年代工作的任何人都有优势,因为你可以利用功能强大的开源深度学习框架,只需几行代码就可以快速构建模型。在以前,训练这些网络需要研究人员编写数千行的C或Fortran代码。

4.1.3. 小结

  • 多层感知机在输出层和输入层之间增加一个或多个全连接的隐藏层,并通过激活函数转换隐藏层的输出。

  • 常用的激活函数包括ReLU函数、sigmoid函数和tanh函数。

4.1.4. 练习

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

  2. 证明一个仅使用ReLU(或pReLU)的多层感知机构造了一个连续的分段线性函数。

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

  4. 假设我们有一个非线性单元,将它一次应用于一个小批量的数据。你认为这会导致什么样的问题?